feat: add compatibility page
This commit is contained in:
parent
32d1dca9b6
commit
32a326c8b9
@ -13,7 +13,8 @@ import {
|
|||||||
SubscriptionCheckout,
|
SubscriptionCheckout,
|
||||||
SubscriptionReceipts,
|
SubscriptionReceipts,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
PaymentIntents
|
PaymentIntents,
|
||||||
|
AICompatCategories
|
||||||
} from './resources'
|
} from './resources'
|
||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
@ -32,7 +33,8 @@ const api = {
|
|||||||
getSubscriptionStatus: createMethod<SubscriptionStatus.Payload, SubscriptionStatus.Response>(SubscriptionStatus.createRequest),
|
getSubscriptionStatus: createMethod<SubscriptionStatus.Payload, SubscriptionStatus.Response>(SubscriptionStatus.createRequest),
|
||||||
getSubscriptionReceipt: createMethod<SubscriptionReceipts.GetPayload, SubscriptionReceipts.Response>(SubscriptionReceipts.createGetRequest),
|
getSubscriptionReceipt: createMethod<SubscriptionReceipts.GetPayload, SubscriptionReceipts.Response>(SubscriptionReceipts.createGetRequest),
|
||||||
createSubscriptionReceipt: createMethod<SubscriptionReceipts.Payload, SubscriptionReceipts.Response>(SubscriptionReceipts.createRequest),
|
createSubscriptionReceipt: createMethod<SubscriptionReceipts.Payload, SubscriptionReceipts.Response>(SubscriptionReceipts.createRequest),
|
||||||
createPaymentIntent: createMethod<PaymentIntents.Payload, PaymentIntents.Response>(PaymentIntents.createRequest)
|
createPaymentIntent: createMethod<PaymentIntents.Payload, PaymentIntents.Response>(PaymentIntents.createRequest),
|
||||||
|
getAiCompatCategories: createMethod<AICompatCategories.Payload, AICompatCategories.Response>(AICompatCategories.createRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ApiContextValue = typeof api
|
export type ApiContextValue = typeof api
|
||||||
|
|||||||
24
src/api/resources/AICompatCategories.ts
Normal file
24
src/api/resources/AICompatCategories.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import routes from "@/routes";
|
||||||
|
import { getBaseHeaders } from "../utils";
|
||||||
|
|
||||||
|
export interface Payload {
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Response {
|
||||||
|
compat_categories: CompatCategory[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompatCategory {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createRequest = ({ locale }: Payload): Request => {
|
||||||
|
const url = new URL(routes.server.compatCategories());
|
||||||
|
const query = new URLSearchParams({ locale });
|
||||||
|
|
||||||
|
url.search = query.toString();
|
||||||
|
|
||||||
|
return new Request(url, { method: "GET", headers: getBaseHeaders() });
|
||||||
|
};
|
||||||
@ -12,3 +12,4 @@ export * as SubscriptionCheckout from './UserSubscriptionCheckout'
|
|||||||
export * as SubscriptionStatus from './UserSubscriptionStatus'
|
export * as SubscriptionStatus from './UserSubscriptionStatus'
|
||||||
export * as SubscriptionReceipts from './UserSubscriptionReceipts'
|
export * as SubscriptionReceipts from './UserSubscriptionReceipts'
|
||||||
export * as PaymentIntents from './UserPaymentIntents'
|
export * as PaymentIntents from './UserPaymentIntents'
|
||||||
|
export * as AICompatCategories from './AICompatCategories'
|
||||||
|
|||||||
102
src/components/Compatibility/index.tsx
Normal file
102
src/components/Compatibility/index.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import MainButton from "../MainButton";
|
||||||
|
import Title from "../Title";
|
||||||
|
import styles from "./styles.module.css";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import NameInput from "./nameInput";
|
||||||
|
import { DatePicker } from "../DateTimePicker";
|
||||||
|
import { IDate, getDateAsString } from "@/services/date";
|
||||||
|
import { AICompatCategories, useApi, useApiCall } from "@/api";
|
||||||
|
|
||||||
|
function CompatibilityPage(): JSX.Element {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const [isDisabled, setIsDisabled] = useState(false);
|
||||||
|
const [name, setName] = useState<string>('');
|
||||||
|
const [date, setDate] = useState<string | IDate>('');
|
||||||
|
const [compatCategory, setCompatCategory] = useState(2);
|
||||||
|
const handleNext = () => console.log(name, date, compatCategory);
|
||||||
|
|
||||||
|
const api = useApi()
|
||||||
|
const locale = i18n.language
|
||||||
|
const loadData = useCallback(() => {
|
||||||
|
return api.getAiCompatCategories({ locale })
|
||||||
|
.then((resp: AICompatCategories.Response) => resp.compat_categories)
|
||||||
|
}, [api, locale])
|
||||||
|
const { data } = useApiCall<AICompatCategories.CompatCategory[]>(loadData)
|
||||||
|
|
||||||
|
const handleValidName = (name: string) => {
|
||||||
|
setIsDisabled(name === '');
|
||||||
|
setName(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleValidDate = (date: IDate | string) => {
|
||||||
|
setIsDisabled(date === '');
|
||||||
|
setDate(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeCompatCategory = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setCompatCategory(parseInt(event.target.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={`${styles.page} page`}>
|
||||||
|
<Title variant="h1" className={styles.title}>
|
||||||
|
{t("compatibility")}
|
||||||
|
</Title>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<Title variant="h2" className={styles.iam}>
|
||||||
|
{t("iAm")}
|
||||||
|
</Title>
|
||||||
|
<Title variant="h3" className={styles.plus}>
|
||||||
|
+
|
||||||
|
</Title>
|
||||||
|
<div className={styles['inputs-container']}>
|
||||||
|
<div className={styles['input-container__name-container']}>
|
||||||
|
<NameInput
|
||||||
|
name="name"
|
||||||
|
value={name}
|
||||||
|
placeholder={t('name')}
|
||||||
|
onValid={handleValidName}
|
||||||
|
onInvalid={() => setIsDisabled(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles['input-container__date-container']}>
|
||||||
|
<DatePicker
|
||||||
|
name='birthdate'
|
||||||
|
value={getDateAsString(date)}
|
||||||
|
inputClassName={styles['date-input']}
|
||||||
|
onValid={handleValidDate}
|
||||||
|
onInvalid={() => setIsDisabled(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{data && data.length && (
|
||||||
|
<div className={styles['compatibility-categories']}>
|
||||||
|
{
|
||||||
|
data.map((item, index) => (
|
||||||
|
<div className="compatibility-categories__item" key={index}>
|
||||||
|
<input className={`${styles["compatibility-categories__input"]} ${compatCategory === item.id ? styles["compatibility-categories__input--checked"] : ''}`}
|
||||||
|
type="radio"
|
||||||
|
name="compatCategory"
|
||||||
|
id={String(item.id)}
|
||||||
|
value={item.id}
|
||||||
|
checked={compatCategory === item.id}
|
||||||
|
onChange={changeCompatCategory} />
|
||||||
|
<label htmlFor={String(item.id)}>
|
||||||
|
{item.name}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<MainButton className={styles['check-btn']} onClick={handleNext} disabled={isDisabled}>
|
||||||
|
{t("check")}
|
||||||
|
</MainButton>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CompatibilityPage;
|
||||||
37
src/components/Compatibility/nameInput.tsx
Normal file
37
src/components/Compatibility/nameInput.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { FormField } from '@/types'
|
||||||
|
import styles from './styles.module.css'
|
||||||
|
|
||||||
|
const isValidName = (name: string) => {
|
||||||
|
return name.length > 0 && name.length < 30
|
||||||
|
}
|
||||||
|
|
||||||
|
function NameInput(props: FormField<string>): JSX.Element {
|
||||||
|
const { name, value, placeholder, onValid, onInvalid } = props
|
||||||
|
const [userName, setUserName] = useState(value)
|
||||||
|
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setUserName(event.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isValidName(userName)) {
|
||||||
|
onValid(userName)
|
||||||
|
} else {
|
||||||
|
onInvalid()
|
||||||
|
}
|
||||||
|
}, [userName, onInvalid, onValid])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles['name-input-container']}>
|
||||||
|
<input
|
||||||
|
name={name}
|
||||||
|
type="text"
|
||||||
|
value={userName}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={placeholder ?? ' '}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NameInput
|
||||||
154
src/components/Compatibility/styles.module.css
Normal file
154
src/components/Compatibility/styles.module.css
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
.page {
|
||||||
|
position: relative;
|
||||||
|
min-height: calc(100vh - 0px);
|
||||||
|
max-height: -webkit-fill-available;
|
||||||
|
flex: auto;
|
||||||
|
background-color: #121212;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iam,
|
||||||
|
.plus {
|
||||||
|
color: #ea445a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iam {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 32px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
/* font-size: 24px; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.plus {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 100;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-input-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container__date-container {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-input-container > input {
|
||||||
|
background-color: #595452;
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: solid 2px #ea445a;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-input-container > input:focus {
|
||||||
|
border-color: #066fde;
|
||||||
|
transition-delay: 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-input > h3 {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-input input {
|
||||||
|
background-color: #595452;
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: solid 2px #ea445a;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-input p {
|
||||||
|
text-align: center;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: #83807e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-btn {
|
||||||
|
border-radius: 30px;
|
||||||
|
background-color: #ea445a;
|
||||||
|
max-width: 200px;
|
||||||
|
margin: 48px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compatibility-categories {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
margin-top: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compatibility-categories__input--checked,
|
||||||
|
.compatibility-categories__input {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
}
|
||||||
|
.compatibility-categories__input--checked + label,
|
||||||
|
.compatibility-categories__input + label {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 48px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 24px;
|
||||||
|
display: inline-block;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.compatibility-categories__input--checked + label:before,
|
||||||
|
.compatibility-categories__input + label:before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 100%;
|
||||||
|
}
|
||||||
|
.compatibility-categories__input--checked + label:after,
|
||||||
|
.compatibility-categories__input + label:after {
|
||||||
|
content: "";
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background-image: url(/check-mark-1.png);
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 6px;
|
||||||
|
-webkit-transition: all 0.2s ease;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.compatibility-categories__input + label:after {
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-transform: scale(0);
|
||||||
|
transform: scale(0);
|
||||||
|
}
|
||||||
|
.compatibility-categories__input--checked + label:after {
|
||||||
|
opacity: 1;
|
||||||
|
-webkit-transform: scale(1);
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
.compatibility-categories__input--checked + label::before {
|
||||||
|
background-color: #ea445a;
|
||||||
|
border-color: #ea445a;
|
||||||
|
}
|
||||||
@ -10,7 +10,7 @@ type DateInputProps = Omit<FormField<DatePartValue>, 'onValid' | 'onInvalid'> &
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DateInput(props: DateInputProps): JSX.Element {
|
function DateInput(props: DateInputProps): JSX.Element {
|
||||||
const { label, placeholder, name, value = '', max, maxLength, onChange } = props
|
const { label, placeholder, name, inputClassName, value = '', max, maxLength, onChange } = props
|
||||||
const validate = (value: number): boolean => value >= 0 && value <= max
|
const validate = (value: number): boolean => value >= 0 && value <= max
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const datePart = e.target.value ? parseInt(e.target.value, 10) : 0
|
const datePart = e.target.value ? parseInt(e.target.value, 10) : 0
|
||||||
@ -20,7 +20,7 @@ function DateInput(props: DateInputProps): JSX.Element {
|
|||||||
onChange(datePart > 0 ? normalize(datePart, maxLength) : '')
|
onChange(datePart > 0 ? normalize(datePart, maxLength) : '')
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="date-picker__field">
|
<div className={`date-picker__field ${inputClassName ?? ''}`}>
|
||||||
<h3 className='date-picker__field-label'>{label}</h3>
|
<h3 className='date-picker__field-label'>{label}</h3>
|
||||||
<label className="date-picker__input">
|
<label className="date-picker__input">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@ -4,11 +4,13 @@ import { FormField } from '@/types'
|
|||||||
import DateInput from './DateInput'
|
import DateInput from './DateInput'
|
||||||
import ErrorText from '../ErrorText'
|
import ErrorText from '../ErrorText'
|
||||||
import { stringify, getCurrentYear } from './utils'
|
import { stringify, getCurrentYear } from './utils'
|
||||||
|
import { IDate, getDateAsString } from '@/services/date'
|
||||||
|
|
||||||
export function DatePicker(props: FormField<string>): JSX.Element {
|
export function DatePicker(props: FormField<Date | IDate | string>): JSX.Element {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { name, value, onValid, onInvalid } = props
|
const { name, value, inputClassName, onValid, onInvalid } = props
|
||||||
const [initYear, initMonth, initDay] = value.split('-')
|
const date = getDateAsString(value)
|
||||||
|
const [initYear, initMonth, initDay] = date.split('-')
|
||||||
const [year, setYear] = useState(initYear)
|
const [year, setYear] = useState(initYear)
|
||||||
const [month, setMonth] = useState(initMonth)
|
const [month, setMonth] = useState(initMonth)
|
||||||
const [day, setDay] = useState(initDay)
|
const [day, setDay] = useState(initDay)
|
||||||
@ -42,6 +44,7 @@ export function DatePicker(props: FormField<string>): JSX.Element {
|
|||||||
maxLength={4}
|
maxLength={4}
|
||||||
label={t('year')}
|
label={t('year')}
|
||||||
placeholder='YYYY'
|
placeholder='YYYY'
|
||||||
|
inputClassName={inputClassName}
|
||||||
onChange={(year: string) => setYear(year)}
|
onChange={(year: string) => setYear(year)}
|
||||||
/>
|
/>
|
||||||
<DateInput
|
<DateInput
|
||||||
@ -51,6 +54,7 @@ export function DatePicker(props: FormField<string>): JSX.Element {
|
|||||||
maxLength={2}
|
maxLength={2}
|
||||||
label={t('month')}
|
label={t('month')}
|
||||||
placeholder='MM'
|
placeholder='MM'
|
||||||
|
inputClassName={inputClassName}
|
||||||
onChange={(month: string) => setMonth(month)}
|
onChange={(month: string) => setMonth(month)}
|
||||||
/>
|
/>
|
||||||
<DateInput
|
<DateInput
|
||||||
@ -60,6 +64,7 @@ export function DatePicker(props: FormField<string>): JSX.Element {
|
|||||||
maxLength={2}
|
maxLength={2}
|
||||||
label={t('day')}
|
label={t('day')}
|
||||||
placeholder='DD'
|
placeholder='DD'
|
||||||
|
inputClassName={inputClassName}
|
||||||
onChange={(day: string) => setDay(day)}
|
onChange={(day: string) => setDay(day)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
.date-picker__container {
|
.date-picker__container {
|
||||||
grid-gap: 12px;
|
grid-gap: 12px;
|
||||||
background-color: #fff;
|
background-color: transparent;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
grid-template-columns: repeat(3,1fr);
|
grid-template-columns: repeat(3,1fr);
|
||||||
|
|||||||
15
src/services/date/index.ts
Normal file
15
src/services/date/index.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export interface IDate {
|
||||||
|
year: string;
|
||||||
|
month: string;
|
||||||
|
day: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDateAsString = (date: Date | IDate | string): string => {
|
||||||
|
if (date instanceof Date) {
|
||||||
|
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
|
||||||
|
}
|
||||||
|
if (typeof date === 'string') {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
return `${date.year}-${date.month}-${date.day}`;
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user