From 9684f2099e542900369080d11d667752512fb480 Mon Sep 17 00:00:00 2001 From: yurytut1993 Date: Wed, 27 Mar 2024 20:28:19 +0200 Subject: [PATCH] feat(core): create register customer page --- .../account/register-customer/page.tsx | 86 ++++ .../client/management/get-country-states.ts | 1 + apps/core/client/queries/get-store-country.ts | 26 ++ .../register-customer-form/_actions/login.ts | 28 ++ .../_actions/register-customer.ts | 65 +++ .../fields/password.tsx | 61 +++ .../fields/picklist-with-text.tsx | 94 ++++ .../fields/picklist.tsx | 47 ++ .../fields/shared/field-wrapper.tsx | 39 ++ .../register-customer-form/fields/text.tsx | 58 +++ .../register-customer-form/index.tsx | 404 ++++++++++++++++++ apps/core/messages/en.json | 12 + .../components/src/components/form/form.tsx | 3 +- .../src/components/select/select.tsx | 1 + 14 files changed, 924 insertions(+), 1 deletion(-) create mode 100644 apps/core/app/[locale]/(default)/account/register-customer/page.tsx create mode 100644 apps/core/client/queries/get-store-country.ts create mode 100644 apps/core/components/register-customer-form/_actions/login.ts create mode 100644 apps/core/components/register-customer-form/_actions/register-customer.ts create mode 100644 apps/core/components/register-customer-form/fields/password.tsx create mode 100644 apps/core/components/register-customer-form/fields/picklist-with-text.tsx create mode 100644 apps/core/components/register-customer-form/fields/picklist.tsx create mode 100644 apps/core/components/register-customer-form/fields/shared/field-wrapper.tsx create mode 100644 apps/core/components/register-customer-form/fields/text.tsx create mode 100644 apps/core/components/register-customer-form/index.tsx diff --git a/apps/core/app/[locale]/(default)/account/register-customer/page.tsx b/apps/core/app/[locale]/(default)/account/register-customer/page.tsx new file mode 100644 index 000000000..55ba1a9aa --- /dev/null +++ b/apps/core/app/[locale]/(default)/account/register-customer/page.tsx @@ -0,0 +1,86 @@ +import { redirect } from 'next/navigation'; +import { NextIntlClientProvider } from 'next-intl'; +import { getMessages, getTranslations } from 'next-intl/server'; + +import { auth } from '~/auth'; +import { FormFieldSortInput } from '~/client/generated/graphql'; +import { getCountries } from '~/client/management/get-countries'; +import { getAddressFormFields } from '~/client/queries/get-address-form-fields'; +import { getCustomerFormFields } from '~/client/queries/get-customer-form-fields'; +import { getReCaptchaSettings } from '~/client/queries/get-recaptcha-settings'; +import { getStoreCountry } from '~/client/queries/get-store-country'; +import { RegisterCustomerForm } from '~/components/register-customer-form'; +import { LocaleType } from '~/i18n'; + +import { getShippingStates } from '../../cart/_actions/get-shipping-states'; + +interface Props { + params: { + locale: LocaleType; + }; +} + +const MOCKED_STATE_PROVINCE_FIELD = { + entityId: 12, + label: 'State/Province', + sortOrder: 10, + isBuiltIn: true, + isRequired: true, + __typename: 'PicklistWithTextFormField' as const, + choosePrefix: 'Choose your State or Province', +}; + +const FALLBACK_COUNTRY = { + id: 226, + name: 'United States', + country_iso2: 'US', +}; + +export type PicklistWithTextFormField = typeof MOCKED_STATE_PROVINCE_FIELD; + +export default async function RegisterCustomer({ params: { locale } }: Props) { + const session = await auth(); + + if (session) { + redirect('/account'); + } + + const messages = await getMessages({ locale }); + const Account = messages.Account ?? {}; + const t = await getTranslations({ locale, namespace: 'Account.Register' }); + + const defaultCountry = (await getStoreCountry()) || FALLBACK_COUNTRY.name; + + const countries = await getCountries(); + + const { country_iso2 = FALLBACK_COUNTRY.country_iso2, id = FALLBACK_COUNTRY.id } = + countries.find(({ country }) => country === defaultCountry) || {}; + + const defaultCountryStates = (await getShippingStates(id)).data || []; + + const customerFields = await getCustomerFormFields({ + sortBy: FormFieldSortInput.SortOrder, + }); + + const addressFields = await getAddressFormFields({ sortBy: FormFieldSortInput.SortOrder }); + + const addressFieldsWithMocked = [...(addressFields ?? []), MOCKED_STATE_PROVINCE_FIELD]; + + const reCaptchaSettings = await getReCaptchaSettings(); + + return ( +
+

{t('heading')}

+ + + +
+ ); +} diff --git a/apps/core/client/management/get-country-states.ts b/apps/core/client/management/get-country-states.ts index c9b2c8e98..89ca0d04f 100644 --- a/apps/core/client/management/get-country-states.ts +++ b/apps/core/client/management/get-country-states.ts @@ -14,6 +14,7 @@ const CountryStatesSchema = z.array( // List of States or Provinces for Country export const getCountryStates = async (countryId: number) => { const response = await client.fetchCountryStates(countryId); + const parsedResponse = CountryStatesSchema.safeParse(response); if (parsedResponse.success) { diff --git a/apps/core/client/queries/get-store-country.ts b/apps/core/client/queries/get-store-country.ts new file mode 100644 index 000000000..767c2e115 --- /dev/null +++ b/apps/core/client/queries/get-store-country.ts @@ -0,0 +1,26 @@ +import { cache } from 'react'; + +import { client } from '..'; +import { graphql } from '../graphql'; +import { revalidate } from '../revalidate-target'; + +const GET_STORE_COUNTRY_QUERY = graphql(` + query getStoreSettings { + site { + settings { + contact { + country + } + } + } + } +`); + +export const getStoreCountry = cache(async () => { + const response = await client.fetch({ + document: GET_STORE_COUNTRY_QUERY, + fetchOptions: { next: { revalidate } }, + }); + + return response.data.site.settings?.contact?.country; +}); diff --git a/apps/core/components/register-customer-form/_actions/login.ts b/apps/core/components/register-customer-form/_actions/login.ts new file mode 100644 index 000000000..b2a00dcba --- /dev/null +++ b/apps/core/components/register-customer-form/_actions/login.ts @@ -0,0 +1,28 @@ +'use server'; + +import { isRedirectError } from 'next/dist/client/components/redirect'; + +import { signIn } from '~/auth'; + +export const login = async ( + email: FormDataEntryValue | null, + password: FormDataEntryValue | null, +) => { + try { + const singin = await signIn('credentials', { + email, + password, + redirectTo: '/account', + }); + + return singin; + } catch (error: unknown) { + if (isRedirectError(error)) { + throw error; + } + + return { + status: 'error', + }; + } +}; diff --git a/apps/core/components/register-customer-form/_actions/register-customer.ts b/apps/core/components/register-customer-form/_actions/register-customer.ts new file mode 100644 index 000000000..3df0bcfdb --- /dev/null +++ b/apps/core/components/register-customer-form/_actions/register-customer.ts @@ -0,0 +1,65 @@ +'use server'; + +import { RegisterCustomerInput } from '~/client/generated/graphql'; +import { registerCustomer as registerCustomerClient } from '~/client/mutations/register-customer'; + +interface RegisterCustomerForm { + formData: FormData; + reCaptchaToken?: string; +} + +const isRegisterCustomerInput = (data: unknown): data is RegisterCustomerInput => { + if (typeof data === 'object' && data !== null && 'email' in data) { + return true; + } + + return false; +}; + +export const registerCustomer = async ({ formData, reCaptchaToken }: RegisterCustomerForm) => { + formData.delete('customer-confirmPassword'); + + const parsedData = Array.from(formData.entries()).reduce<{ + [key: string]: FormDataEntryValue | { [key: string]: FormDataEntryValue }; + address: { [key: string]: FormDataEntryValue }; + }>( + (acc, [name, value]) => { + const key = name.split('-').at(-1) ?? ''; + const sections = name.split('-').slice(0, -1); + + if (sections.includes('customer')) { + acc[key] = value; + } + + if (sections.includes('address')) { + acc.address[key] = value; + } + + return acc; + }, + { address: {} }, + ); + + console.log(parsedData, 'parsedData'); + + if (!isRegisterCustomerInput(parsedData)) { + return { + status: 'error', + error: 'Something went wrong with proccessing user input', + }; + } + + const response = await registerCustomerClient({ + formFields: parsedData, + reCaptchaToken, + }); + + if (response.errors.length === 0) { + return { status: 'success', data: parsedData }; + } + + return { + status: 'error', + error: response.errors.map((error) => error.message).join('\n'), + }; +}; diff --git a/apps/core/components/register-customer-form/fields/password.tsx b/apps/core/components/register-customer-form/fields/password.tsx new file mode 100644 index 000000000..b4af3133c --- /dev/null +++ b/apps/core/components/register-customer-form/fields/password.tsx @@ -0,0 +1,61 @@ +import { Field, FieldControl, FieldLabel, FieldMessage } from '@bigcommerce/components/form'; +import { Input } from '@bigcommerce/components/input'; +import { useTranslations } from 'next-intl'; +import { ChangeEvent } from 'react'; + +import { CustomerFields, FieldNameToFieldId } from '..'; + +type PasswordType = Extract< + NonNullable[number], + { __typename: 'PasswordFormField' } +>; + +interface PasswordProps { + field: PasswordType; + isValid?: boolean; + onChange: (e: ChangeEvent) => void; + name: string; + variant?: 'error'; +} + +export const Password = ({ field, isValid, name, onChange, variant }: PasswordProps) => { + const t = useTranslations('Account.Register'); + + return ( + + + {field.label} + + + + + {field.isRequired && ( + + {t('emptyPasswordValidatoinMessage')} + + )} + {FieldNameToFieldId[field.entityId] === 'confirmPassword' && ( + { + return !isValid; + }} + > + {t('equalPasswordValidatoinMessage')} + + )} + + ); +}; diff --git a/apps/core/components/register-customer-form/fields/picklist-with-text.tsx b/apps/core/components/register-customer-form/fields/picklist-with-text.tsx new file mode 100644 index 000000000..7f08a7567 --- /dev/null +++ b/apps/core/components/register-customer-form/fields/picklist-with-text.tsx @@ -0,0 +1,94 @@ +import { Field, FieldControl, FieldLabel, FieldMessage } from '@bigcommerce/components/form'; +import { Input } from '@bigcommerce/components/input'; +import { Select, SelectContent, SelectItem } from '@bigcommerce/components/select'; +import { Loader2 as Spinner } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { ChangeEvent } from 'react'; + +import { AddressFieldsWithMocked, FieldNameToFieldId } from '..'; + +type PicklistWithTextType = Extract< + NonNullable[number], + { __typename: 'PicklistWithTextFormField' } +>; + +interface PicklistWithTextProps { + defaultValue?: string; + field: PicklistWithTextType; + name: string; + onChange?: (e: ChangeEvent) => void; + options: Array<{ label: string; entityId: string | number }>; + pending?: boolean; + variant?: 'error'; +} + +export const PicklistWithText = ({ + defaultValue, + field, + name, + onChange, + options, + pending, + variant, +}: PicklistWithTextProps) => { + const t = useTranslations('Account.Register'); + + return ( + + + + {field.label} + {pending && field.entityId === FieldNameToFieldId.stateOrProvince && ( + + + )} + + + + {field.entityId === FieldNameToFieldId.stateOrProvince && options.length === 0 ? ( + + ) : ( + + )} + + {field.isRequired && options.length === 0 && ( + + {t('emptyTextValidatoinMessage')} + + )} + + ); +}; diff --git a/apps/core/components/register-customer-form/fields/picklist.tsx b/apps/core/components/register-customer-form/fields/picklist.tsx new file mode 100644 index 000000000..65603e8d8 --- /dev/null +++ b/apps/core/components/register-customer-form/fields/picklist.tsx @@ -0,0 +1,47 @@ +import { Field, FieldControl, FieldLabel } from '@bigcommerce/components/form'; +import { Select, SelectContent, SelectItem } from '@bigcommerce/components/select'; + +import { AddressFieldsWithMocked, FieldNameToFieldId } from '..'; + +type PicklistType = Extract< + NonNullable[number], + { __typename: 'PicklistFormField' } +>; + +interface PicklistProps { + defaultValue?: string; + field: PicklistType; + name: string; + onChange?: (value: string) => Promise; + options: Array<{ label: string; entityId: string | number }>; +} + +export const Picklist = ({ defaultValue, field, name, onChange, options }: PicklistProps) => { + return ( + + + {field.label} + + + + + + ); +}; diff --git a/apps/core/components/register-customer-form/fields/shared/field-wrapper.tsx b/apps/core/components/register-customer-form/fields/shared/field-wrapper.tsx new file mode 100644 index 000000000..e9f900d01 --- /dev/null +++ b/apps/core/components/register-customer-form/fields/shared/field-wrapper.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { PropsWithChildren } from 'react'; + +export enum FieldNameToFieldId { + email = 1, + password, + confirmPassword, + firstName, + lastName, + company, + phone, + address1, + address2, + city, + countryCode, + stateOrProvince, + postalCode, + currentPassword = 24, + exclusiveOffers = 25, +} + +const LAYOUT_SINGLE_LINE_FIELDS = [ + FieldNameToFieldId.email, + FieldNameToFieldId.company, + FieldNameToFieldId.phone, +]; + +export const FieldWrapper = ({ children, fieldId }: { fieldId: number } & PropsWithChildren) => { + if (LAYOUT_SINGLE_LINE_FIELDS.includes(fieldId)) { + return ( +
+ {children} +
+ ); + } + + return children; +}; diff --git a/apps/core/components/register-customer-form/fields/text.tsx b/apps/core/components/register-customer-form/fields/text.tsx new file mode 100644 index 000000000..f25e286c6 --- /dev/null +++ b/apps/core/components/register-customer-form/fields/text.tsx @@ -0,0 +1,58 @@ +import { Field, FieldControl, FieldLabel, FieldMessage } from '@bigcommerce/components/form'; +import { Input } from '@bigcommerce/components/input'; +import { useTranslations } from 'next-intl'; +import { ChangeEvent } from 'react'; + +import { AddressFields, CustomerFields, FieldNameToFieldId } from '..'; + +type TextType = + | Extract[number], { __typename: 'TextFormField' }> + | Extract[number], { __typename: 'TextFormField' }>; + +interface TextProps { + field: TextType; + isValid?: boolean; + name: string; + onChange: (e: ChangeEvent) => void; + type?: string; +} + +export const Text = ({ field, isValid, name, onChange, type }: TextProps) => { + const t = useTranslations('Account.Register'); + + return ( + + + {field.label} + + + + + {field.isRequired && ( + + {t('emptyTextValidatoinMessage')} + + )} + {FieldNameToFieldId[field.entityId] === 'email' && ( + + {t('emailValidationMessage')} + + )} + + ); +}; diff --git a/apps/core/components/register-customer-form/index.tsx b/apps/core/components/register-customer-form/index.tsx new file mode 100644 index 000000000..9e67c05d4 --- /dev/null +++ b/apps/core/components/register-customer-form/index.tsx @@ -0,0 +1,404 @@ +'use client'; + +import { Button } from '@bigcommerce/components/button'; +import { Field, Form, FormSubmit } from '@bigcommerce/components/form'; +import { Message } from '@bigcommerce/components/message'; +import { Loader2 as Spinner } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { ChangeEvent, useRef, useState } from 'react'; +import { useFormStatus } from 'react-dom'; +import ReCaptcha from 'react-google-recaptcha'; + +import { PicklistWithTextFormField } from '~/app/[locale]/(default)/account/register-customer/page'; +import { getShippingStates } from '~/app/[locale]/(default)/cart/_actions/get-shipping-states'; +import { getCountries } from '~/client/management/get-countries'; +import { getCountryStates } from '~/client/management/get-country-states'; +import { getAddressFormFields } from '~/client/queries/get-address-form-fields'; +import { getCustomerFormFields } from '~/client/queries/get-customer-form-fields'; + +import { login } from './_actions/login'; +import { registerCustomer } from './_actions/register-customer'; +import { Password } from './fields/password'; +import { Picklist } from './fields/picklist'; +import { PicklistWithText } from './fields/picklist-with-text'; +import { FieldWrapper } from './fields/shared/field-wrapper'; +import { Text } from './fields/text'; + +interface FormStatus { + status: 'success' | 'error'; + message: string; +} + +type ArrayElement = + ArrayType extends ReadonlyArray ? ElementType : never; + +export type CustomerFields = Awaited>; +export type AddressFields = NonNullable>>; +export type AddressFieldsWithMocked = Array< + ArrayElement | PicklistWithTextFormField +>; +type Countries = Awaited>; +type CountryCode = Countries[number]['country_iso2']; +type CountryStates = Awaited>; + +interface RegisterCustomerProps { + addressFields: AddressFieldsWithMocked; + countries: Countries; + customerFields: CustomerFields; + defaultCountry: { + id: number; + code: CountryCode; + states: CountryStates; + }; + fallbackCountryId: number; + reCaptchaSettings?: { + isEnabledOnStorefront: boolean; + siteKey: string; + }; +} + +/* This mapping needed for aligning built-in fields names to their ids + for creating valid register customer request object + that will be sent in mutation */ +export enum FieldNameToFieldId { + email = 1, + password, + confirmPassword, + firstName, + lastName, + company, + phone, + address1, + address2, + city, + countryCode, + stateOrProvince, + postalCode, + currentPassword = 24, + exclusiveOffers = 25, +} + +const CUSTOMER_FIELDS_TO_EXCLUDE = [ + FieldNameToFieldId.currentPassword, + FieldNameToFieldId.exclusiveOffers, +]; + +export const BOTH_CUSTOMER_ADDRESS_FIELDS = [ + FieldNameToFieldId.firstName, + FieldNameToFieldId.lastName, + FieldNameToFieldId.company, + FieldNameToFieldId.phone, +]; + +interface SumbitMessages { + messages: { + submit: string; + submitting: string; + }; +} + +const SubmitButton = ({ messages }: SumbitMessages) => { + const { pending } = useFormStatus(); + + return ( + + ); +}; + +export const RegisterCustomerForm = ({ + addressFields, + countries, + customerFields, + defaultCountry, + fallbackCountryId, + reCaptchaSettings, +}: RegisterCustomerProps) => { + const form = useRef(null); + const [formStatus, setFormStatus] = useState(null); + + const [textInputValid, setTextInputValid] = useState<{ [key: string]: boolean }>({}); + const [passwordValid, setPassswordValid] = useState<{ [key: string]: boolean }>({ + [FieldNameToFieldId.password]: true, + [FieldNameToFieldId.confirmPassword]: true, + }); + + const [countryStates, setCountryStates] = useState(defaultCountry.states); + const [countryStatesPending, setCountryStatesPending] = useState(false); + + const [picklistWithTextValid, setPicklistWithTextValid] = useState<{ [key: string]: boolean }>( + {}, + ); + + const reCaptchaRef = useRef(null); + const [reCaptchaToken, setReCaptchaToken] = useState(''); + const [isReCaptchaValid, setReCaptchaValid] = useState(true); + + const t = useTranslations('Account.Register'); + + const handleTextInputValidation = (e: ChangeEvent) => { + const fieldId = Number(e.target.id.split('-')[1]); + + const validityState = e.target.validity; + const validationStatus = validityState.valueMissing || validityState.typeMismatch; + + return setTextInputValid({ ...textInputValid, [fieldId]: !validationStatus }); + }; + + const handlePasswordValidation = (e: ChangeEvent) => { + const fieldId = e.target.id.split('-')[1] ?? ''; + + switch (FieldNameToFieldId[Number(fieldId)]) { + case 'password': + return setPassswordValid((prevState) => ({ + ...prevState, + [fieldId]: !e.target.validity.valueMissing, + })); + + case 'confirmPassword': { + const confirmPassword = e.target.value; + + const passwordFieldName = `customer-${BOTH_CUSTOMER_ADDRESS_FIELDS.includes(FieldNameToFieldId.password) ? 'address-' : ''}${FieldNameToFieldId[Number(FieldNameToFieldId.password)] || fieldId}`; + const password = new FormData(e.target.form ?? undefined).get(passwordFieldName); + + return setPassswordValid((prevState) => ({ + ...prevState, + [fieldId]: password === confirmPassword && !e.target.validity.valueMissing, + })); + } + + default: + return setPassswordValid((prevState) => ({ + ...prevState, + [fieldId]: !e.target.validity.valueMissing, + })); + } + }; + + const handleCountryChange = async (value: string) => { + const country = countries.find(({ country_iso2 }) => country_iso2 === value); + + setCountryStatesPending(true); + + const { data = [] } = await getShippingStates(country?.id || fallbackCountryId); + + setCountryStatesPending(false); + + setCountryStates(data); + }; + + const handlePicklistWihtTextValidation = (e: ChangeEvent) => { + const fieldId = Number(e.target.id.split('-')[1]); + + const validationStatus = e.target.validity.valueMissing; + + return setPicklistWithTextValid({ ...picklistWithTextValid, [fieldId]: !validationStatus }); + }; + + const onReCaptchaChange = (token: string | null) => { + if (!token) { + return setReCaptchaValid(false); + } + + setReCaptchaToken(token); + setReCaptchaValid(true); + }; + + const onSubmit = async (formData: FormData) => { + if (formData.get('customer-password') !== formData.get('customer-confirmPassword')) { + setFormStatus({ + status: 'error', + message: t('equalPasswordValidatoinMessage'), + }); + + return window.scrollTo({ + top: 0, + behavior: 'smooth', + }); + } + + if (reCaptchaSettings?.isEnabledOnStorefront && !reCaptchaToken) { + return setReCaptchaValid(false); + } + + setReCaptchaValid(true); + + const submit = await registerCustomer({ formData }); + + if (submit.status === 'success') { + form.current?.reset(); + setFormStatus({ + status: 'success', + message: t('successMessage', { + firstName: submit.data?.firstName, + lastName: submit.data?.lastName, + }), + }); + + setTimeout(() => { + void login(formData.get('customer-email'), formData.get('customer-password')); + }, 3000); + } + + if (submit.status === 'error') { + setFormStatus({ status: 'error', message: submit.error ?? '' }); + } + + window.scrollTo({ + top: 0, + behavior: 'smooth', + }); + }; + + return ( + <> + {formStatus && ( + +

{formStatus.message}

+
+ )} +
+
+ {customerFields + ?.filter((field) => !CUSTOMER_FIELDS_TO_EXCLUDE.includes(field.entityId)) + .map((field) => { + switch (field.__typename) { + case 'TextFormField': + return ( + + + + ); + + case 'PasswordFormField': { + return ( + + + + ); + } + + default: + return null; + } + })} +
+
+ {addressFields.map((field) => { + switch (field.__typename) { + case 'TextFormField': + return ( + + + + ); + + case 'PicklistFormField': + return ( + + { + return { label: country, entityId: country_iso2 }; + })} + /> + + ); + + case 'PicklistWithTextFormField': + return ( + + { + return { entityId: state, label: state }; + })} + pending={ + FieldNameToFieldId.stateOrProvince === field.entityId + ? countryStatesPending + : undefined + } + variant={ + picklistWithTextValid[field.entityId] === false ? 'error' : undefined + } + /> + + ); + + default: + return null; + } + })} + {reCaptchaSettings?.isEnabledOnStorefront && ( + + + {!isReCaptchaValid && ( + + {t('recaptchaText')} + + )} + + )} +
+ + + + +
+ + ); +}; diff --git a/apps/core/messages/en.json b/apps/core/messages/en.json index f37bca4a1..66db7bbbb 100644 --- a/apps/core/messages/en.json +++ b/apps/core/messages/en.json @@ -199,6 +199,18 @@ "createLink": "Create Account " } }, + "Register": { + "heading": "New account", + "loadingStates": "Loading states according to selected country...", + "emptyTextValidatoinMessage": "This field can not be empty", + "emailValidationMessage": "Enter a valid email such as name@domain.com", + "emptyPasswordValidatoinMessage": "Password field can not be empty", + "equalPasswordValidatoinMessage": "Confirm Password field must be equal with Password", + "submit": "Create account", + "submitting": "Creating account...", + "recaptchaText": "Pass ReCAPTCHA check", + "successMessage": "Dear {firstName} {lastName}, your account was successfully created. Redirecting to Account..." + }, "ChangePassword": { "newPasswordLabel": "New password", "confirmPasswordLabel": "Confirm password", diff --git a/packages/components/src/components/form/form.tsx b/packages/components/src/components/form/form.tsx index 1e71323f3..ef318305e 100644 --- a/packages/components/src/components/form/form.tsx +++ b/packages/components/src/components/form/form.tsx @@ -15,7 +15,8 @@ type ValidationPattern = | 'tooShort' | 'typeMismatch' | 'valid' - | 'valueMissing'; + | 'valueMissing' + | ValidationFunction; type ValidationFunction = | ((value: string, formData: FormData) => boolean) diff --git a/packages/components/src/components/select/select.tsx b/packages/components/src/components/select/select.tsx index 434895a50..b82655cdc 100644 --- a/packages/components/src/components/select/select.tsx +++ b/packages/components/src/components/select/select.tsx @@ -23,6 +23,7 @@ type SelectType = typeof SelectPrimitive.Root; type SelectTriggerType = typeof SelectPrimitive.Trigger; interface SelectProps extends ComponentPropsWithRef { + id?: string | number; variant?: 'success' | 'error'; placeholder?: string; className?: string;