Skip to content

Commit

Permalink
feat(core): add new address for customer (#811)
Browse files Browse the repository at this point in the history
* feat(core): add new address for customer

* feat(functional): Add test to add and remove address

---------

Co-authored-by: Anudeep Vattipalli <anudeep.vattipalli@bigcommerce.com>
  • Loading branch information
bc-alexsaiannyi and avattipalli committed May 22, 2024
1 parent c0355e8 commit 6661e3e
Show file tree
Hide file tree
Showing 19 changed files with 666 additions and 101 deletions.
5 changes: 5 additions & 0 deletions .changeset/tender-kangaroos-melt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bigcommerce/catalyst-core": patch
---

Add new address for customer
75 changes: 75 additions & 0 deletions core/app/[locale]/(default)/account/[tab]/_actions/add-address.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use server';

import { revalidatePath } from 'next/cache';

import {
addCustomerAddress,
AddCustomerAddressInput,
} from '~/client/mutations/add-customer-address';

const isAddCustomerAddressInput = (data: unknown): data is AddCustomerAddressInput => {
if (typeof data === 'object' && data !== null && 'address1' in data) {
return true;
}

return false;
};

export const addAddress = async ({
formData,
reCaptchaToken,
}: {
formData: FormData;
reCaptchaToken?: string;
}) => {
try {
const parsed: unknown = [...formData.entries()].reduce<
Record<string, FormDataEntryValue | Record<string, FormDataEntryValue>>
>((parsedData, [name, value]) => {
const key = name.split('-').at(-1) ?? '';
const sections = name.split('-').slice(0, -1);

if (sections.includes('customer')) {
parsedData[key] = value;
}

if (sections.includes('address')) {
parsedData[key] = value;
}

return parsedData;
}, {});

if (!isAddCustomerAddressInput(parsed)) {
return {
status: 'error',
error: 'Something went wrong with proccessing user input.',
};
}

const response = await addCustomerAddress({
input: parsed,
reCaptchaToken,
});

revalidatePath('/account/addresses', 'page');

if (response.errors.length === 0) {
return { status: 'success', message: 'The address has been added.' };
}

return {
status: 'error',
message: response.errors.map((error) => error.message).join('\n'),
};
} catch (error: unknown) {
if (error instanceof Error) {
return {
status: 'error',
message: error.message,
};
}

return { status: 'error', message: 'Unknown error.' };
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const deleteAddress = async (addressId: number): Promise<State> => {
revalidatePath('/account/addresses', 'page');

if (response.errors.length === 0) {
return { status: 'success', message: 'The address has been deleted' };
return { status: 'success', message: 'Address deleted from your account.' };
}

return {
Expand All @@ -28,6 +28,6 @@ export const deleteAddress = async (addressId: number): Promise<State> => {
};
}

return { status: 'error', message: 'Unknown error' };
return { status: 'error', message: 'Unknown error.' };
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
'use client';

import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useRef, useState } from 'react';
import { useFormStatus } from 'react-dom';
import ReCaptcha from 'react-google-recaptcha';

import {
createFieldName,
FieldNameToFieldId,
FieldWrapper,
Picklist,
PicklistOrText,
Text,
} from '~/app/[locale]/(default)/login/register-customer/_components/register-customer-form/fields';
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 { addAddress } from '../../_actions/add-address';

import {
createCountryChangeHandler,
createTextInputValidationHandler,
} from './address-field-handlers';
import { NewAddressQueryResponseType } from './customer-new-address';

interface FormStatus {
status: 'success' | 'error';
message: string;
}

export type AddressFields = NonNullable<
NewAddressQueryResponseType['site']['settings']
>['formFields']['shippingAddress'];

export type Countries = NonNullable<NewAddressQueryResponseType['geography']['countries']>;
type CountryCode = Countries[number]['code'];
type CountryStates = Countries[number]['statesOrProvinces'];

interface SumbitMessages {
messages: {
submit: string;
submitting: string;
};
}

const SubmitButton = ({ messages }: SumbitMessages) => {
const { pending } = useFormStatus();

return (
<Button
className="relative items-center px-8 py-2 md:w-fit"
loading={pending}
loadingText={messages.submitting}
variant="primary"
>
{messages.submit}
</Button>
);
};

interface AddAddressProps {
addressFields: AddressFields;
countries: Countries;
defaultCountry: {
id: number;
code: CountryCode;
states: CountryStates;
};
reCaptchaSettings?: {
isEnabledOnStorefront: boolean;
siteKey: string;
};
}

export const AddAddress = ({
addressFields,
countries,
defaultCountry,
reCaptchaSettings,
}: AddAddressProps) => {
const form = useRef<HTMLFormElement>(null);
const [formStatus, setFormStatus] = useState<FormStatus | null>(null);

const reCaptchaRef = useRef<ReCaptcha>(null);
const router = useRouter();
const t = useTranslations('Account.Addresses');
const [reCaptchaToken, setReCaptchaToken] = useState('');
const [isReCaptchaValid, setReCaptchaValid] = useState(true);

const [textInputValid, setTextInputValid] = useState<Record<string, boolean>>({});
const [countryStates, setCountryStates] = useState(defaultCountry.states);

const handleTextInputValidation = createTextInputValidationHandler(
setTextInputValid,
textInputValid,
);
const handleCountryChange = createCountryChangeHandler(setCountryStates, countries);

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);
}

setReCaptchaValid(true);

const submit = await addAddress({ formData, reCaptchaToken });

if (submit.status === 'success') {
form.current?.reset();
setFormStatus({
status: 'success',
message: t('successMessage'),
});

setTimeout(() => {
router.replace('/account/addresses');
}, 3000);
}

if (submit.status === 'error') {
setFormStatus({ status: 'error', message: submit.message || '' });
}

window.scrollTo({
top: 0,
behavior: 'smooth',
});
};

return (
<>
{formStatus && (
<Message className="mx-auto mb-8 w-full" variant={formStatus.status}>
<p>{formStatus.message}</p>
</Message>
)}
<Form action={onSubmit} ref={form}>
<div className="grid grid-cols-1 gap-y-6 lg:grid-cols-2 lg:gap-x-6 lg:gap-y-2">
{addressFields.map((field) => {
switch (field.__typename) {
case 'TextFormField': {
return (
<FieldWrapper fieldId={field.entityId} key={field.entityId}>
<Text
field={field}
isValid={textInputValid[field.entityId]}
name={createFieldName('address', field.entityId)}
onChange={handleTextInputValidation}
/>
</FieldWrapper>
);
}

case 'PicklistFormField':
return (
<FieldWrapper fieldId={field.entityId} key={field.entityId}>
<Picklist
defaultValue={
field.entityId === FieldNameToFieldId.countryCode
? defaultCountry.code
: undefined
}
field={field}
name={createFieldName('address', field.entityId)}
onChange={
field.entityId === FieldNameToFieldId.countryCode
? handleCountryChange
: undefined
}
options={countries.map(({ name, code }) => {
return { label: name, entityId: code };
})}
/>
</FieldWrapper>
);

case 'PicklistOrTextFormField':
return (
<FieldWrapper fieldId={field.entityId} key={field.entityId}>
<PicklistOrText
defaultValue={
field.entityId === FieldNameToFieldId.stateOrProvince
? countryStates[0]?.name
: undefined
}
field={field}
name={createFieldName('address', field.entityId)}
options={countryStates.map(({ name }) => {
return { entityId: name, label: name };
})}
/>
</FieldWrapper>
);

default:
return null;
}
})}

{reCaptchaSettings?.isEnabledOnStorefront && (
<Field className="relative col-span-full max-w-full space-y-2 pb-7" name="ReCAPTCHA">
<ReCaptcha
onChange={onReCaptchaChange}
ref={reCaptchaRef}
sitekey={reCaptchaSettings.siteKey}
/>
{!isReCaptchaValid && (
<span className="absolute inset-x-0 bottom-0 inline-flex w-full text-xs font-normal text-error">
{t('recaptchaText')}
</span>
)}
</Field>
)}
</div>

<div className="mt-8 flex flex-col justify-stretch gap-2 md:flex-row md:justify-start md:gap-6">
<FormSubmit asChild>
<SubmitButton messages={{ submit: t('submit'), submitting: t('submitting') }} />
</FormSubmit>
<Button asChild className="items-center px-8 md:w-fit" variant="secondary">
<Link href="/account/addresses">{t('cancel')}</Link>
</Button>
</div>
</Form>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,10 @@ import { Link } from '~/components/link';
import { Button } from '~/components/ui/button';
import { Message } from '~/components/ui/message';

import { deleteAddress } from '../_actions/delete-address';
import { State } from '../_actions/submit-customer-change-password-form';

import { useAccountStatusContext } from './account-status-provider';
import { Modal } from './modal';
import { deleteAddress } from '../../_actions/delete-address';
import { State } from '../../_actions/submit-customer-change-password-form';
import { useAccountStatusContext } from '../account-status-provider';
import { Modal } from '../modal';

export type Addresses = NonNullable<Awaited<ReturnType<typeof getCustomerAddresses>>>['addresses'];

Expand Down Expand Up @@ -71,13 +70,13 @@ const AddressChangeButtons = ({
};

interface Props {
customerAddressBook: Addresses;
customerAddresses: Addresses;
addressesCount: number;
}

export const AddressesList = ({ customerAddressBook, addressesCount }: Props) => {
export const AddressBook = ({ customerAddresses, addressesCount }: Props) => {
const t = useTranslations('Account.Addresses');
const [addressBook, setAddressBook] = useState(customerAddressBook);
const [addressBook, setAddressBook] = useState(customerAddresses);
const { accountState, setAccountState } = useAccountStatusContext();

return (
Expand Down Expand Up @@ -127,7 +126,9 @@ export const AddressesList = ({ customerAddressBook, addressesCount }: Props) =>
)}
<li className="flex w-full border-collapse flex-col justify-start gap-2 border-t border-gray-200 pt-8">
<Button aria-label={t('addNewAddress')} asChild className="w-fit hover:text-white">
<Link href="/account/add-address">{t('addNewAddress')}</Link>
<Link href={{ pathname: '/account/addresses', query: { action: 'add-new-address' } }}>
{t('addNewAddress')}
</Link>
</Button>
</li>
</ul>
Expand Down
Loading

0 comments on commit 6661e3e

Please sign in to comment.