From 5611c1de5c28a01fdefd522cd884948acfefab8d Mon Sep 17 00:00:00 2001 From: yurytut1993 Date: Wed, 17 Apr 2024 13:45:28 +0300 Subject: [PATCH] feat(core): add update customer form --- .changeset/calm-rivers-admire.md | 5 + .../_components/addresses-content/index.tsx | 5 +- .../[tab]/_components/settings-content.tsx | 20 +- .../account/[tab]/_components/tab-heading.tsx | 13 +- .../_actions/update-customer.ts | 36 +++ .../update-settings-form/fields/text.tsx | 58 +++++ .../update-settings-form/index.tsx | 223 ++++++++++++++++++ .../(default)/account/[tab]/page-data.ts | 117 +++++++++ .../[locale]/(default)/account/[tab]/page.tsx | 29 ++- .../login/_components/login-form.tsx | 4 +- .../register-customer-form/index.tsx | 2 +- .../login/register-customer/page-data.ts | 3 +- core/client/mutations/update-customer.ts | 4 +- core/client/queries/get-customer.ts | 60 ----- core/messages/en.json | 28 ++- 15 files changed, 509 insertions(+), 98 deletions(-) create mode 100644 .changeset/calm-rivers-admire.md create mode 100644 core/app/[locale]/(default)/account/[tab]/_components/update-settings-form/_actions/update-customer.ts create mode 100644 core/app/[locale]/(default)/account/[tab]/_components/update-settings-form/fields/text.tsx create mode 100644 core/app/[locale]/(default)/account/[tab]/_components/update-settings-form/index.tsx create mode 100644 core/app/[locale]/(default)/account/[tab]/page-data.ts delete mode 100644 core/client/queries/get-customer.ts diff --git a/.changeset/calm-rivers-admire.md b/.changeset/calm-rivers-admire.md new file mode 100644 index 0000000000..aef098be33 --- /dev/null +++ b/.changeset/calm-rivers-admire.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +add update customer form diff --git a/core/app/[locale]/(default)/account/[tab]/_components/addresses-content/index.tsx b/core/app/[locale]/(default)/account/[tab]/_components/addresses-content/index.tsx index 0707b9b6e4..56057a45fb 100644 --- a/core/app/[locale]/(default)/account/[tab]/_components/addresses-content/index.tsx +++ b/core/app/[locale]/(default)/account/[tab]/_components/addresses-content/index.tsx @@ -3,7 +3,6 @@ import { getLocale, getTranslations } from 'next-intl/server'; import { getCustomerAddresses } from '~/client/queries/get-customer-addresses'; import { Pagination } from '../../../../(faceted)/_components/pagination'; -import { TabType } from '../../layout'; import { TabHeading } from '../tab-heading'; import { AddressBook } from './address-book'; @@ -16,7 +15,6 @@ interface Props { addressesCount: number; customerAction?: 'add-new-address'; pageInfo: CustomerAddresses['pageInfo']; - title: TabType; } export const AddressesContent = async ({ @@ -24,7 +22,6 @@ export const AddressesContent = async ({ addressesCount, customerAction, pageInfo, - title, }: Props) => { const locale = await getLocale(); const t = await getTranslations({ locale, namespace: 'Account.Home' }); @@ -42,7 +39,7 @@ export const AddressesContent = async ({ return ( <> - + { +type CustomerSettings = NonNullable>>; + +export const SettingsContent = async ({ action, customerSettings }: Props) => { const locale = await getLocale(); const messages = await getMessages({ locale }); - if (action === 'change-password') { + if (action === 'change_password') { return (
- + @@ -26,5 +29,10 @@ export const SettingsContent = async ({ title, action }: Props) => { ); } - return ; + return ( +
+ + +
+ ); }; diff --git a/core/app/[locale]/(default)/account/[tab]/_components/tab-heading.tsx b/core/app/[locale]/(default)/account/[tab]/_components/tab-heading.tsx index 31ed221527..6b77ecf1ef 100644 --- a/core/app/[locale]/(default)/account/[tab]/_components/tab-heading.tsx +++ b/core/app/[locale]/(default)/account/[tab]/_components/tab-heading.tsx @@ -1,17 +1,12 @@ -import { getTranslations } from 'next-intl/server'; +import { getLocale, getTranslations } from 'next-intl/server'; import { TabType } from '../layout'; -export const TabHeading = async ({ - heading, - locale, -}: { - heading: TabType | 'change-password'; - locale: string; -}) => { +export const TabHeading = async ({ heading }: { heading: TabType | 'change_password' }) => { + const locale = await getLocale(); const t = await getTranslations({ locale, namespace: 'Account.Home' }); const tab = heading === 'recently-viewed' ? 'recentlyViewed' : heading; - const title = tab === 'change-password' ? 'changePassword' : tab; + const title = tab === 'change_password' ? 'changePassword' : tab; return

{t(title)}

; }; diff --git a/core/app/[locale]/(default)/account/[tab]/_components/update-settings-form/_actions/update-customer.ts b/core/app/[locale]/(default)/account/[tab]/_components/update-settings-form/_actions/update-customer.ts new file mode 100644 index 0000000000..151e8deaf0 --- /dev/null +++ b/core/app/[locale]/(default)/account/[tab]/_components/update-settings-form/_actions/update-customer.ts @@ -0,0 +1,36 @@ +'use server'; + +import { updateCustomer as updateCustomerClient } from '~/client/mutations/update-customer'; + +interface UpdateCustomerForm { + formData: FormData; + reCaptchaToken?: string; +} + +export const updateCustomer = async ({ formData, reCaptchaToken }: UpdateCustomerForm) => { + formData.delete('g-recaptcha-response'); + + const formFields = Object.fromEntries(formData.entries()); + + const response = await updateCustomerClient({ formFields, reCaptchaToken }); + + if (response.errors.length === 0) { + const { customer } = response; + + if (!customer) { + return { + status: 'error', + error: 'Customer does not exist', + }; + } + + const { firstName, lastName } = customer; + + return { status: 'success', data: { firstName, lastName } }; + } + + return { + status: 'error', + error: response.errors.map((error) => error.message).join('\n'), + }; +}; diff --git a/core/app/[locale]/(default)/account/[tab]/_components/update-settings-form/fields/text.tsx b/core/app/[locale]/(default)/account/[tab]/_components/update-settings-form/fields/text.tsx new file mode 100644 index 0000000000..740f5d257b --- /dev/null +++ b/core/app/[locale]/(default)/account/[tab]/_components/update-settings-form/fields/text.tsx @@ -0,0 +1,58 @@ +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 { FieldNameToFieldId } from '..'; + +interface TextProps { + defaultValue?: string; + entityId: number; + isRequired?: boolean; + isValid?: boolean; + label: string; + onChange: (e: ChangeEvent) => void; + type?: 'text' | 'email'; +} + +export const Text = ({ + defaultValue, + entityId, + isRequired = false, + isValid, + label, + onChange, + type = 'text', +}: TextProps) => { + const t = useTranslations('Account.Settings.validationMessages'); + const fieldName = FieldNameToFieldId[entityId]; + const name = fieldName ?? `field-${entityId}`; + + return ( + + + {label} + + + + + {isRequired && ( + + {t(fieldName ?? 'empty')} + + )} + + ); +}; diff --git a/core/app/[locale]/(default)/account/[tab]/_components/update-settings-form/index.tsx b/core/app/[locale]/(default)/account/[tab]/_components/update-settings-form/index.tsx new file mode 100644 index 0000000000..31ba61ca76 --- /dev/null +++ b/core/app/[locale]/(default)/account/[tab]/_components/update-settings-form/index.tsx @@ -0,0 +1,223 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { ChangeEvent, useRef, useState } from 'react'; +import { useFormStatus } from 'react-dom'; +import ReCaptcha from 'react-google-recaptcha'; + +import { Link } from '~/components/link'; +import { Button } from '~/components/ui/button'; +import { Field, Form, FormSubmit } from '~/components/ui/form'; +import { Message } from '~/components/ui/message'; + +import { getCustomerSettingsQuery } from '../../page-data'; + +import { updateCustomer } from './_actions/update-customer'; +import { Text } from './fields/text'; + +type CustomerInfo = NonNullable< + Awaited> +>['customerInfo']; +type CustomerFields = NonNullable< + Awaited> +>['customerFields']; +type AddressFields = NonNullable< + Awaited> +>['addressFields']; + +interface FormProps { + addressFields: AddressFields; + customerInfo: CustomerInfo; + customerFields: CustomerFields; + reCaptchaSettings?: { + isEnabledOnStorefront: boolean; + siteKey: string; + }; +} + +interface FormStatus { + status: 'success' | 'error'; + message: string; +} + +interface SumbitMessages { + messages: { + submit: string; + submitting: string; + }; +} + +export enum FieldNameToFieldId { + email = 1, + firstName = 4, + lastName, + company, + phone, +} + +type FieldUnionType = keyof typeof FieldNameToFieldId; + +const isExistedField = (name: unknown): name is FieldUnionType => { + if (typeof name === 'string' && name in FieldNameToFieldId) { + return true; + } + + return false; +}; + +const SubmitButton = ({ messages }: SumbitMessages) => { + const { pending } = useFormStatus(); + + return ( + + ); +}; + +export const UpdateSettingsForm = ({ + addressFields, + customerFields, + customerInfo, + reCaptchaSettings, +}: FormProps) => { + const form = useRef(null); + const [formStatus, setFormStatus] = useState(null); + + const [textInputValid, setTextInputValid] = useState>({}); + + const reCaptchaRef = useRef(null); + const [reCaptchaToken, setReCaptchaToken] = useState(''); + const [isReCaptchaValid, setReCaptchaValid] = useState(true); + + const t = useTranslations('Account.Settings'); + + const handleTextInputValidation = (e: ChangeEvent) => { + const fieldId = Number(e.target.id.split('-')[1]); + + const validityState = e.target.validity; + const validationStatus = validityState.valueMissing || validityState.typeMismatch; + + setTextInputValid({ ...textInputValid, [fieldId]: !validationStatus }); + }; + + const onReCaptchaChange = (token: string | null) => { + if (!token) { + setReCaptchaValid(false); + + return; + } + + setReCaptchaToken(token); + setReCaptchaValid(true); + }; + + const onSubmit = async (formData: FormData) => { + if (reCaptchaSettings?.isEnabledOnStorefront && !reCaptchaToken) { + setReCaptchaValid(false); + + return; + } + + setReCaptchaValid(true); + + const submit = await updateCustomer({ formData, reCaptchaToken }); + + if (submit.status === 'success') { + setFormStatus({ + status: 'success', + message: t('successMessage', { + firstName: submit.data?.firstName, + lastName: submit.data?.lastName, + }), + }); + } + + if (submit.status === 'error') { + setFormStatus({ status: 'error', message: submit.error ?? '' }); + } + }; + + return ( + <> + {formStatus && ( + +

{formStatus.message}

+
+ )} +
+
+ {addressFields.map((field) => { + const fieldName = FieldNameToFieldId[field.entityId] ?? ''; + + if (!isExistedField(fieldName)) { + return null; + } + + return ( + + ); + })} +
+ field.entityId === FieldNameToFieldId.email) + ?.label ?? '' + } + onChange={handleTextInputValidation} + type="email" + /> +
+ {reCaptchaSettings?.isEnabledOnStorefront && ( + + + {!isReCaptchaValid && ( + + {t('recaptchaText')} + + )} + + )} +
+ + + + + + {t('changePassword')} + +
+
+
+ + ); +}; diff --git a/core/app/[locale]/(default)/account/[tab]/page-data.ts b/core/app/[locale]/(default)/account/[tab]/page-data.ts new file mode 100644 index 0000000000..aa19539f5e --- /dev/null +++ b/core/app/[locale]/(default)/account/[tab]/page-data.ts @@ -0,0 +1,117 @@ +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'; + +const CustomerSettingsQuery = graphql( + ` + query CustomerSettingsQuery( + $customerFilters: FormFieldFiltersInput + $customerSortBy: FormFieldSortInput + $addressFilters: FormFieldFiltersInput + $addressSortBy: FormFieldSortInput + ) { + customer { + entityId + company + email + firstName + lastName + phone + formFields { + entityId + name + __typename + ... on CheckboxesFormFieldValue { + valueEntityIds + values + } + ... on DateFormFieldValue { + date { + utc + } + } + ... on MultipleChoiceFormFieldValue { + valueEntityId + value + } + ... on NumberFormFieldValue { + number + } + ... on PasswordFormFieldValue { + password + } + ... on TextFormFieldValue { + text + } + } + } + site { + settings { + formFields { + customer(filters: $customerFilters, sortBy: $customerSortBy) { + ...FormFields + } + shippingAddress(filters: $addressFilters, sortBy: $addressSortBy) { + ...FormFields + } + } + reCaptcha { + isEnabledOnStorefront + siteKey + } + } + } + } + `, + [FORM_FIELDS_FRAGMENT], +); + +type Variables = VariablesOf; + +interface Props { + address?: { + filters?: Variables['addressFilters']; + sortBy?: Variables['addressSortBy']; + }; + + customer?: { + filters?: Variables['customerFilters']; + sortBy?: Variables['customerSortBy']; + }; +} + +export const getCustomerSettingsQuery = cache(async ({ address, customer }: Props = {}) => { + const customerId = await getSessionCustomerId(); + + const response = await client.fetch({ + document: CustomerSettingsQuery, + variables: { + addressFilters: address?.filters, + addressSortBy: address?.sortBy, + customerFilters: customer?.filters, + customerSortBy: customer?.sortBy, + }, + fetchOptions: { cache: 'no-store' }, + customerId, + }); + + const addressFields = response.data.site.settings?.formFields.shippingAddress; + const customerFields = response.data.site.settings?.formFields.customer; + const customerInfo = response.data.customer; + + const reCaptchaSettings = response.data.site.settings?.reCaptcha; + + if (!addressFields || !customerFields || !customerInfo) { + return null; + } + + return { + addressFields, + customerFields, + customerInfo, + reCaptchaSettings, + }; +}); diff --git a/core/app/[locale]/(default)/account/[tab]/page.tsx b/core/app/[locale]/(default)/account/[tab]/page.tsx index 773787bb6f..7ddfa619af 100644 --- a/core/app/[locale]/(default)/account/[tab]/page.tsx +++ b/core/app/[locale]/(default)/account/[tab]/page.tsx @@ -1,18 +1,17 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { getTranslations } from 'next-intl/server'; +import { getLocale, getTranslations } from 'next-intl/server'; import { getCustomerAddresses } from '~/client/queries/get-customer-addresses'; -import { LocaleType } from '~/i18n'; import { AddressesContent } from './_components/addresses-content'; import { SettingsContent } from './_components/settings-content'; import { TabHeading } from './_components/tab-heading'; import { TabType } from './layout'; +import { getCustomerSettingsQuery } from './page-data'; interface Props { params: { - locale: LocaleType; tab: TabType; }; searchParams: { @@ -23,7 +22,8 @@ interface Props { }; } -export async function generateMetadata({ params: { tab, locale } }: Props): Promise { +export async function generateMetadata({ params: { tab } }: Props): Promise { + const locale = await getLocale(); const t = await getTranslations({ locale, namespace: 'Account.Home' }); return { @@ -31,13 +31,13 @@ export async function generateMetadata({ params: { tab, locale } }: Props): Prom }; } -export default async function AccountTabPage({ params: { tab, locale }, searchParams }: Props) { +export default async function AccountTabPage({ params: { tab }, searchParams }: Props) { switch (tab) { case 'orders': - return ; + return ; case 'messages': - return ; + return ; case 'addresses': { const { before, after, action } = searchParams; @@ -59,19 +59,26 @@ export default async function AccountTabPage({ params: { tab, locale }, searchPa addressesCount={addressesCount} customerAction={action} pageInfo={pageInfo} - title={tab} /> ); } case 'wishlists': - return ; + return ; case 'recently-viewed': - return ; + return ; case 'settings': { - return ; + const customerSettings = await getCustomerSettingsQuery({ + address: { filters: { entityIds: [4, 5, 6, 7] } }, + }); + + if (!customerSettings) { + notFound(); + } + + return ; } default: diff --git a/core/app/[locale]/(default)/login/_components/login-form.tsx b/core/app/[locale]/(default)/login/_components/login-form.tsx index 32a05ba2d7..de24ad31ae 100644 --- a/core/app/[locale]/(default)/login/_components/login-form.tsx +++ b/core/app/[locale]/(default)/login/_components/login-form.tsx @@ -118,13 +118,13 @@ export const LoginForm = () => { - {t('Form.forgotPassword')} + {t('Form.resetPassword')}
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 index 8c5735da0b..3d8f2a5dcb 100644 --- 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 @@ -184,7 +184,7 @@ export const RegisterCustomerForm = ({ setReCaptchaValid(true); - const submit = await registerCustomer({ formData }); + const submit = await registerCustomer({ formData, reCaptchaToken }); if (submit.status === 'success') { form.current?.reset(); diff --git a/core/app/[locale]/(default)/login/register-customer/page-data.ts b/core/app/[locale]/(default)/login/register-customer/page-data.ts index 0d4a615b31..24fc15fe79 100644 --- a/core/app/[locale]/(default)/login/register-customer/page-data.ts +++ b/core/app/[locale]/(default)/login/register-customer/page-data.ts @@ -4,7 +4,6 @@ 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( ` @@ -79,7 +78,7 @@ export const getRegisterCustomerQuery = cache(async ({ address, customer }: Prop customerFilters: customer?.filters, customerSortBy: customer?.sortBy, }, - fetchOptions: { next: { revalidate } }, + fetchOptions: { cache: 'no-store' }, customerId, }); diff --git a/core/client/mutations/update-customer.ts b/core/client/mutations/update-customer.ts index d45c78a137..23094c857d 100644 --- a/core/client/mutations/update-customer.ts +++ b/core/client/mutations/update-customer.ts @@ -4,9 +4,9 @@ import { client } from '..'; import { graphql, VariablesOf } from '../graphql'; const UPDATE_CUSTOMER_MUTATION = graphql(` - mutation updateCustomer($input: UpdateCustomerInput!) { + mutation updateCustomer($input: UpdateCustomerInput!, $reCaptchaV2: ReCaptchaV2Input) { customer { - updateCustomer(input: $input) { + updateCustomer(input: $input, reCaptchaV2: $reCaptchaV2) { customer { firstName lastName diff --git a/core/client/queries/get-customer.ts b/core/client/queries/get-customer.ts deleted file mode 100644 index b094ef80dd..0000000000 --- a/core/client/queries/get-customer.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { cache } from 'react'; - -import { client } from '..'; -import { graphql } from '../graphql'; - -const GET_CUSTOMER_QUERY = graphql(` - query getCustomer { - customer { - entityId - company - email - firstName - lastName - phone - formFields { - entityId - name - __typename - ... on CheckboxesFormFieldValue { - valueEntityIds - values - } - ... on DateFormFieldValue { - date { - utc - } - } - ... on MultipleChoiceFormFieldValue { - valueEntityId - value - } - ... on NumberFormFieldValue { - number - } - ... on PasswordFormFieldValue { - password - } - ... on TextFormFieldValue { - text - } - } - } - } -`); - -export const getCustomer = cache(async (customerId: string) => { - const response = await client.fetch({ - document: GET_CUSTOMER_QUERY, - fetchOptions: { cache: 'no-store' }, - customerId, - }); - - const customer = response.data.customer; - - if (!customer) { - return null; - } - - return customer; -}); diff --git a/core/messages/en.json b/core/messages/en.json index c9b0688f10..c9ddf634cc 100644 --- a/core/messages/en.json +++ b/core/messages/en.json @@ -202,6 +202,24 @@ "cancel": "Cancel", "submitting": "In progress..." }, + "Settings": { + "emptyTextValidatoinMessage": "This field can not be empty", + "cancel": "Cancel", + "recaptchaText": "Pass ReCAPTCHA check", + "changePassword": "Reset password", + "submit": "Update settings", + "submitting": "Update settings...", + "successMessage": "Dear {firstName} {lastName}, you successfully updated your account data", + "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", + "city": "Enter a suburb / city", + "company": "Enter a company name", + "phone": "Enter a phone number" + } + }, "Login": { "heading": "Log In", "resetPasswordHeading": "Reset password", @@ -214,7 +232,15 @@ "entePasswordMessage": "Enter your password", "submitting": "Submitting...", "logIn": "Log in", - "forgotPassword": "Forgot your password?" + "resetPassword": "Forgot your password?", + "validationMessages": { + "email": "Enter a valid email such as name@domain.com", + "firstName": "Enter your first name", + "lastName": "Enter your last name", + "city": "Enter a suburb / city", + "company": "Enter a company name", + "phone": "Enter a phone number" + } }, "CreateAccount": { "heading": "New customer?",