diff --git a/.changeset/nasty-news-retire.md b/.changeset/nasty-news-retire.md new file mode 100644 index 000000000..756434ac8 --- /dev/null +++ b/.changeset/nasty-news-retire.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +create register customer page diff --git a/core/app/[locale]/(default)/login/page.tsx b/core/app/[locale]/(default)/login/page.tsx index b62f71a43..f92c72875 100644 --- a/core/app/[locale]/(default)/login/page.tsx +++ b/core/app/[locale]/(default)/login/page.tsx @@ -96,15 +96,8 @@ export default async function Login({ params: { locale }, searchParams }: Props)
  • {t('CreateAccount.ordersTracking')}
  • {t('CreateAccount.wishlists')}
  • - diff --git a/core/app/[locale]/(default)/login/register-customer/_components/register-customer-form/_actions/login.ts b/core/app/[locale]/(default)/login/register-customer/_components/register-customer-form/_actions/login.ts new file mode 100644 index 000000000..975d4822c --- /dev/null +++ b/core/app/[locale]/(default)/login/register-customer/_components/register-customer-form/_actions/login.ts @@ -0,0 +1,26 @@ +'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 { + return await signIn('credentials', { + email, + password, + redirectTo: '/account', + }); + } catch (error: unknown) { + if (isRedirectError(error)) { + throw error; + } + + return { + status: 'error', + }; + } +}; diff --git a/core/app/[locale]/(default)/login/register-customer/_components/register-customer-form/_actions/register-customer.ts b/core/app/[locale]/(default)/login/register-customer/_components/register-customer-form/_actions/register-customer.ts new file mode 100644 index 000000000..f72157b89 --- /dev/null +++ b/core/app/[locale]/(default)/login/register-customer/_components/register-customer-form/_actions/register-customer.ts @@ -0,0 +1,65 @@ +'use server'; + +import { + Input, + registerCustomer as registerCustomerClient, +} from '~/client/mutations/register-customer'; + +interface RegisterCustomerForm { + formData: FormData; + reCaptchaToken?: string; +} + +const isRegisterCustomerInput = (data: unknown): data is Input => { + 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: {} }, + ); + + 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/core/app/[locale]/(default)/login/register-customer/_components/register-customer-form/fields/password.tsx b/core/app/[locale]/(default)/login/register-customer/_components/register-customer-form/fields/password.tsx new file mode 100644 index 000000000..ba8099e05 --- /dev/null +++ b/core/app/[locale]/(default)/login/register-customer/_components/register-customer-form/fields/password.tsx @@ -0,0 +1,61 @@ +import { useTranslations } from 'next-intl'; +import { ChangeEvent } from 'react'; + +import { Field, FieldControl, FieldLabel, FieldMessage } from '~/components/ui/form'; +import { Input } from '~/components/ui/input'; + +import { CustomerFields, FieldNameToFieldId } from '..'; + +type PasswordType = Extract< + NonNullable[number], + { __typename: 'PasswordFormField' } +>; + +interface PasswordProps { + field: PasswordType; + isValid?: boolean; + onChange: (e: ChangeEvent) => void; + name: string; +} + +export const Password = ({ field, isValid, name, onChange }: PasswordProps) => { + const t = useTranslations('Account.Register.validationMessages'); + const fieldName = FieldNameToFieldId[field.entityId]; + + return ( + + + {field.label} + + + + + {field.isRequired && ( + + {t('password')} + + )} + {fieldName === 'confirmPassword' && ( + { + return !isValid; + }} + > + {t('confirmPassword')} + + )} + + ); +}; diff --git a/core/app/[locale]/(default)/login/register-customer/_components/register-customer-form/fields/picklist-or-text.tsx b/core/app/[locale]/(default)/login/register-customer/_components/register-customer-form/fields/picklist-or-text.tsx new file mode 100644 index 000000000..2749257ec --- /dev/null +++ b/core/app/[locale]/(default)/login/register-customer/_components/register-customer-form/fields/picklist-or-text.tsx @@ -0,0 +1,59 @@ +import { useTranslations } from 'next-intl'; + +import { Field, FieldControl, FieldLabel } from '~/components/ui/form'; +import { Input } from '~/components/ui/input'; +import { Select, SelectContent, SelectItem } from '~/components/ui/select'; + +import { AddressFields } from '..'; + +type PicklistOrTextType = Extract< + NonNullable[number], + { __typename: 'PicklistOrTextFormField' } +>; + +interface PicklistOrTextProps { + defaultValue?: string; + field: PicklistOrTextType; + name: string; + options: Array<{ label: string; entityId: string | number }>; + variant?: 'error'; +} + +export const PicklistOrText = ({ defaultValue, field, name, options }: PicklistOrTextProps) => { + const t = useTranslations('Account.Register'); + + return ( + + + {field.label} + + + {options.length === 0 ? ( + + ) : ( + + )} + + + ); +}; diff --git a/core/app/[locale]/(default)/login/register-customer/_components/register-customer-form/fields/picklist.tsx b/core/app/[locale]/(default)/login/register-customer/_components/register-customer-form/fields/picklist.tsx new file mode 100644 index 000000000..551b41e7e --- /dev/null +++ b/core/app/[locale]/(default)/login/register-customer/_components/register-customer-form/fields/picklist.tsx @@ -0,0 +1,45 @@ +import { Field, FieldControl, FieldLabel } from '~/components/ui/form'; +import { Select, SelectContent, SelectItem } from '~/components/ui/select'; + +import { AddressFields, FieldNameToFieldId } from '..'; + +type PicklistType = Extract< + NonNullable[number], + { __typename: 'PicklistFormField' } +>; + +interface PicklistProps { + defaultValue?: string; + field: PicklistType; + name: string; + onChange?: (value: string) => void; + options: Array<{ label: string; entityId: string | number }>; +} + +export const Picklist = ({ defaultValue, field, name, onChange, options }: PicklistProps) => { + return ( + + + {field.label} + + + + + + ); +}; diff --git a/core/app/[locale]/(default)/login/register-customer/_components/register-customer-form/fields/shared/field-wrapper.tsx b/core/app/[locale]/(default)/login/register-customer/_components/register-customer-form/fields/shared/field-wrapper.tsx new file mode 100644 index 000000000..e9f900d01 --- /dev/null +++ b/core/app/[locale]/(default)/login/register-customer/_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/core/app/[locale]/(default)/login/register-customer/_components/register-customer-form/fields/text.tsx b/core/app/[locale]/(default)/login/register-customer/_components/register-customer-form/fields/text.tsx new file mode 100644 index 000000000..34a507754 --- /dev/null +++ b/core/app/[locale]/(default)/login/register-customer/_components/register-customer-form/fields/text.tsx @@ -0,0 +1,60 @@ +import { useTranslations } from 'next-intl'; +import { ChangeEvent } from 'react'; + +import { Field, FieldControl, FieldLabel, FieldMessage } from '~/components/ui/form'; +import { Input } from '~/components/ui/input'; + +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.validationMessages'); + const fieldName = FieldNameToFieldId[field.entityId]; + + return ( + + + {field.label} + + + + + {field.isRequired && ( + + {t(fieldName ?? 'empty')} + + )} + {fieldName === 'email' && ( + + {t('email')} + + )} + + ); +}; diff --git a/core/app/[locale]/(default)/login/register-customer/_components/register-customer-form/index.tsx b/core/app/[locale]/(default)/login/register-customer/_components/register-customer-form/index.tsx new file mode 100644 index 000000000..41f89e155 --- /dev/null +++ b/core/app/[locale]/(default)/login/register-customer/_components/register-customer-form/index.tsx @@ -0,0 +1,372 @@ +'use client'; + +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 { getRegisterCustomerQuery } from '~/app/[locale]/(default)/login/register-customer/page-data'; +import { Button } from '~/components/ui/button'; +import { Field, Form, FormSubmit } from '~/components/ui/form'; +import { Message } from '~/components/ui/message'; + +import { login } from './_actions/login'; +import { registerCustomer } from './_actions/register-customer'; +import { Password } from './fields/password'; +import { Picklist } from './fields/picklist'; +import { PicklistOrText } from './fields/picklist-or-text'; +import { FieldWrapper } from './fields/shared/field-wrapper'; +import { Text } from './fields/text'; + +interface FormStatus { + status: 'success' | 'error'; + message: string; +} + +export type CustomerFields = NonNullable< + Awaited> +>['customerFields']; +export type AddressFields = NonNullable< + Awaited> +>['addressFields']; +type Countries = NonNullable>>['countries']; +type CountryCode = Countries[number]['code']; +type CountryStates = Countries[number]['statesOrProvinces']; + +interface RegisterCustomerProps { + addressFields: AddressFields; + countries: Countries; + customerFields: CustomerFields; + defaultCountry: { + entityId: number; + code: CountryCode; + states: CountryStates; + }; + 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 createFieldName = (fieldType: 'customer' | 'address', fieldId: number) => { + const secondFieldType = fieldType === 'customer' ? 'address' : 'customer'; + + return `${fieldType}-${BOTH_CUSTOMER_ADDRESS_FIELDS.includes(fieldId) ? `${secondFieldType}-` : ''}${FieldNameToFieldId[fieldId] || fieldId}`; +}; + +const SubmitButton = ({ messages }: SumbitMessages) => { + const { pending } = useFormStatus(); + + return ( + + ); +}; + +export const RegisterCustomerForm = ({ + addressFields, + countries, + customerFields, + defaultCountry, + 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 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 = createFieldName('customer', FieldNameToFieldId.password); + 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 = (value: string) => { + const states = countries.find(({ code }) => code === value)?.statesOrProvinces; + + setCountryStates(states ?? []); + }; + + 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 { entityId: code, label: name }; + })} + /> + + ); + + case 'PicklistOrTextFormField': + return ( + + { + return { entityId: name, label: name }; + })} + /> + + ); + + default: + return null; + } + })} + {reCaptchaSettings?.isEnabledOnStorefront && ( + + + {!isReCaptchaValid && ( + + {t('recaptchaText')} + + )} + + )} +
    + + + + +
    + + ); +}; diff --git a/core/app/[locale]/(default)/login/register-customer/page-data.ts b/core/app/[locale]/(default)/login/register-customer/page-data.ts new file mode 100644 index 000000000..0d4a615b3 --- /dev/null +++ b/core/app/[locale]/(default)/login/register-customer/page-data.ts @@ -0,0 +1,105 @@ +import { cache } from 'react'; + +import { getSessionCustomerId } from '~/auth'; +import { client } from '~/client'; +import { FORM_FIELDS_FRAGMENT } from '~/client/fragments/form-fields'; +import { graphql, VariablesOf } from '~/client/graphql'; +import { revalidate } from '~/client/revalidate-target'; + +const RegisterCustomerQuery = graphql( + ` + query RegisterCustomerQuery( + $customerFilters: FormFieldFiltersInput + $customerSortBy: FormFieldSortInput + $addressFilters: FormFieldFiltersInput + $addressSortBy: FormFieldSortInput + ) { + site { + settings { + formFields { + customer(filters: $customerFilters, sortBy: $customerSortBy) { + ...FormFields + } + shippingAddress(filters: $addressFilters, sortBy: $addressSortBy) { + ...FormFields + } + } + } + settings { + contact { + country + } + reCaptcha { + isEnabledOnStorefront + siteKey + } + } + } + geography { + countries { + code + entityId + name + __typename + statesOrProvinces { + abbreviation + entityId + name + __typename + } + } + } + } + `, + [FORM_FIELDS_FRAGMENT], +); + +type Variables = VariablesOf; + +interface Props { + address?: { + filters?: Variables['addressFilters']; + sortBy?: Variables['addressSortBy']; + }; + + customer?: { + filters?: Variables['customerFilters']; + sortBy?: Variables['customerSortBy']; + }; +} + +export const getRegisterCustomerQuery = cache(async ({ address, customer }: Props = {}) => { + const customerId = await getSessionCustomerId(); + + const response = await client.fetch({ + document: RegisterCustomerQuery, + variables: { + addressFilters: address?.filters, + addressSortBy: address?.sortBy, + customerFilters: customer?.filters, + customerSortBy: customer?.sortBy, + }, + fetchOptions: { next: { revalidate } }, + customerId, + }); + + const addressFields = response.data.site.settings?.formFields.shippingAddress; + const customerFields = response.data.site.settings?.formFields.customer; + + const countries = response.data.geography.countries; + const defaultCountry = response.data.site.settings?.contact?.country; + + const reCaptchaSettings = response.data.site.settings?.reCaptcha; + + if (!addressFields || !customerFields || !countries) { + return null; + } + + return { + addressFields, + customerFields, + countries, + defaultCountry, + reCaptchaSettings, + }; +}); diff --git a/core/app/[locale]/(default)/login/register-customer/page.tsx b/core/app/[locale]/(default)/login/register-customer/page.tsx new file mode 100644 index 000000000..86c240047 --- /dev/null +++ b/core/app/[locale]/(default)/login/register-customer/page.tsx @@ -0,0 +1,71 @@ +import { notFound, redirect } from 'next/navigation'; +import { NextIntlClientProvider } from 'next-intl'; +import { getMessages, getTranslations } from 'next-intl/server'; + +import { auth } from '~/auth'; +import { LocaleType } from '~/i18n'; + +import { RegisterCustomerForm } from './_components/register-customer-form'; +import { getRegisterCustomerQuery } from './page-data'; + +interface Props { + params: { + locale: LocaleType; + }; +} + +const FALLBACK_COUNTRY = { + entityId: 226, + name: 'United States', + code: 'US', +}; + +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 registerCustomerData = await getRegisterCustomerQuery({ + address: { sortBy: 'SORT_ORDER' }, + customer: { sortBy: 'SORT_ORDER' }, + }); + + if (!registerCustomerData) { + notFound(); + } + + const { + addressFields, + customerFields, + countries, + defaultCountry = FALLBACK_COUNTRY.name, + reCaptchaSettings, + } = registerCustomerData; + + const { + code = FALLBACK_COUNTRY.code, + entityId = FALLBACK_COUNTRY.entityId, + statesOrProvinces, + } = countries.find(({ name }) => name === defaultCountry) || {}; + + return ( +
    +

    {t('heading')}

    + + + +
    + ); +} diff --git a/core/client/mutations/register-customer.ts b/core/client/mutations/register-customer.ts index 4f74ac672..47a0547c0 100644 --- a/core/client/mutations/register-customer.ts +++ b/core/client/mutations/register-customer.ts @@ -29,7 +29,7 @@ const REGISTER_CUSTOMER_MUTATION = graphql(` `); type Variables = VariablesOf; -type Input = Variables['input']; +export type Input = Variables['input']; interface RegisterCustomer { formFields: Input; diff --git a/core/components/ui/form/form.tsx b/core/components/ui/form/form.tsx index 1e71323f3..8f6be264c 100644 --- a/core/components/ui/form/form.tsx +++ b/core/components/ui/form/form.tsx @@ -20,7 +20,7 @@ type ValidationPattern = type ValidationFunction = | ((value: string, formData: FormData) => boolean) | ((value: string, formData: FormData) => Promise); -type ControlValidationPatterns = ValidationPattern & ValidationFunction; +type ControlValidationPatterns = ValidationPattern | ValidationFunction; type BuiltInValidityState = { [pattern in ValidationPattern]: boolean; }; diff --git a/core/messages/en.json b/core/messages/en.json index a2c0c60ca..fcf7ac459 100644 --- a/core/messages/en.json +++ b/core/messages/en.json @@ -219,6 +219,28 @@ "createLink": "Create Account " } }, + "Register": { + "heading": "New account", + "submit": "Create account", + "submitting": "Creating account...", + "recaptchaText": "Pass ReCAPTCHA check", + "successMessage": "Dear {firstName} {lastName}, your account was successfully created. Redirecting to account...", + "stateProvincePrefix": "Choose state or province", + "validationMessages": { + "email": "Enter a valid email such as name@domain.com", + "empty": "This field can not be empty", + "firstName": "Enter your first name", + "lastName": "Enter your last name", + "password": "Enter a password", + "confirmPassword": "Passwords don't match", + "address1": "Enter an address", + "address2": "Enter an address", + "city": "Enter a suburb / city", + "company": "Enter a company name", + "phone": "Enter a phone number", + "postalCode": "Enter a zip / postcode" + } + }, "ChangePassword": { "currentPasswordLabel": "Current password", "newPasswordLabel": "New password",