Skip to content

Commit

Permalink
feat(core): allow applying coupons to cart (#733)
Browse files Browse the repository at this point in the history
* feat(core): allow applying coupons to cart

* fix: add tests

* fix: add accessibility attributes

* fix: change name

* refactor: pass checkout id as form data

* fix: pr feedback

* fix: hide coupon input when coupon is set
  • Loading branch information
jorgemoya committed Apr 5, 2024
1 parent ea5a690 commit 565e871
Show file tree
Hide file tree
Showing 11 changed files with 376 additions and 3 deletions.
5 changes: 5 additions & 0 deletions .changeset/brown-buses-juggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bigcommerce/catalyst-core": minor
---

Allow applying and removing coupons in cart
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
'use server';

import { revalidateTag } from 'next/cache';
import { z } from 'zod';

import { applyCheckoutCoupon } from '~/client/mutations/apply-checkout-coupon';

const ApplyCouponCodeSchema = z.object({
checkoutEntityId: z.string(),
couponCode: z.string(),
});

export async function applyCouponCode(formData: FormData) {
try {
const parsedData = ApplyCouponCodeSchema.parse({
checkoutEntityId: formData.get('checkoutEntityId'),
couponCode: formData.get('couponCode'),
});

const checkout = await applyCheckoutCoupon(parsedData.checkoutEntityId, parsedData.couponCode);

if (!checkout?.entityId) {
return { status: 'error', error: 'Coupon code is invalid' };
}

revalidateTag('checkout');

return { status: 'success', data: checkout };
} catch (e: unknown) {
if (e instanceof Error || e instanceof z.ZodError) {
return { status: 'error', error: e.message };
}

return { status: 'error' };
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
'use server';

import { revalidateTag } from 'next/cache';
import { z } from 'zod';

import { unapplyCheckoutCoupon } from '~/client/mutations/unapply-checkout-coupon';

const RemoveCouponCodeSchema = z.object({
checkoutEntityId: z.string(),
couponCode: z.string(),
});

export async function removeCouponCode(formData: FormData) {
try {
const parsedData = RemoveCouponCodeSchema.parse({
checkoutEntityId: formData.get('checkoutEntityId'),
couponCode: formData.get('couponCode'),
});

const checkout = await unapplyCheckoutCoupon(
parsedData.checkoutEntityId,
parsedData.couponCode,
);

if (!checkout?.entityId) {
return { status: 'error', error: 'Error ocurred removing coupon' };
}

revalidateTag('checkout');

return { status: 'success', data: checkout };
} catch (e: unknown) {
if (e instanceof Error || e instanceof z.ZodError) {
return { status: 'error', error: e.message };
}

return { status: 'error' };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getCheckout } from '~/client/queries/get-checkout';

import { getShippingCountries } from '../_actions/get-shipping-countries';

import { CouponCode } from './coupon-code';
import { ShippingEstimator } from './shipping-estimator';

export const CheckoutSummary = async ({ cartId, locale }: { cartId: string; locale: string }) => {
Expand Down Expand Up @@ -49,6 +50,10 @@ export const CheckoutSummary = async ({ cartId, locale }: { cartId: string; loca
</div>
)}

<NextIntlClientProvider locale={locale} messages={{ Cart: messages.Cart ?? {} }}>
<CouponCode checkout={checkout} />
</NextIntlClientProvider>

{checkout.taxTotal && (
<div className="flex justify-between border-t border-t-gray-200 py-4">
<span className="font-semibold">{t('tax')}</span>
Expand Down
141 changes: 141 additions & 0 deletions apps/core/app/[locale]/(default)/cart/_components/coupon-code.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
'use client';

import { Button } from '@bigcommerce/components/button';
import { Field, FieldControl, FieldMessage, Form, FormSubmit } from '@bigcommerce/components/form';
import { Input } from '@bigcommerce/components/input';
import { AlertCircle, Loader2 as Spinner } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useFormStatus } from 'react-dom';
import { toast } from 'react-hot-toast';

import { getCheckout } from '~/client/queries/get-checkout';
import { ExistingResultType } from '~/client/util';

import { applyCouponCode } from '../_actions/apply-coupon-code';
import { removeCouponCode } from '../_actions/remove-coupon-code';

type Checkout = ExistingResultType<typeof getCheckout>;

const SubmitButton = () => {
const t = useTranslations('Cart.SubmitCouponCode');
const { pending } = useFormStatus();

return (
<Button className="items-center px-8 py-2" disabled={pending} variant="secondary">
{pending ? (
<>
<Spinner aria-hidden="true" className="animate-spin" />
<span className="sr-only">{t('spinnerText')}</span>
</>
) : (
<span>{t('submitText')}</span>
)}
</Button>
);
};

export const CouponCode = ({ checkout }: { checkout: ExistingResultType<typeof getCheckout> }) => {
const t = useTranslations('Cart.CheckoutSummary');
const [showAddCoupon, setShowAddCoupon] = useState(false);
const [selectedCoupon, setSelectedCoupon] = useState<Checkout['coupons'][number] | null>(
checkout.coupons.at(0) || null,
);

const currencyFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: checkout.cart?.currencyCode,
});

useEffect(() => {
if (checkout.coupons[0]) {
setSelectedCoupon(checkout.coupons[0]);
setShowAddCoupon(false);

return;
}

setSelectedCoupon(null);
}, [checkout]);

const onSubmitApplyCouponCode = async (formData: FormData) => {
const { status } = await applyCouponCode(formData);

if (status === 'error') {
toast.error(t('couponCodeInvalid'), {
icon: <AlertCircle className="text-error-secondary" />,
});
}
};

const onSubmitRemoveCouponCode = async (formData: FormData) => {
const { status } = await removeCouponCode(formData);

if (status === 'error') {
toast.error(t('couponCodeRemoveFailed'), {
icon: <AlertCircle className="text-error-secondary" />,
});
}
};

return selectedCoupon ? (
<div className="flex flex-col gap-2 border-t border-t-gray-200 py-4">
<div className="flex justify-between">
<span className="font-semibold">
{t('coupon')} ({selectedCoupon.code})
</span>
<span>{currencyFormatter.format(selectedCoupon.discountedAmount.value * -1)}</span>
</div>
<form action={onSubmitRemoveCouponCode}>
<input name="checkoutEntityId" type="hidden" value={checkout.entityId} />
<input name="couponCode" type="hidden" value={selectedCoupon.code} />
<Button
className="w-fit p-0 text-primary hover:bg-transparent"
type="submit"
variant="subtle"
>
{t('remove')}
</Button>
</form>
</div>
) : (
<div className="flex flex-col gap-2 border-t border-t-gray-200 py-4">
<div className="flex justify-between">
<span className="font-semibold">{t('couponCode')}</span>
<Button
aria-controls="coupon-code-form"
className="w-fit p-0 text-primary hover:bg-transparent"
onClick={() => setShowAddCoupon((open) => !open)}
variant="subtle"
>
{showAddCoupon ? t('cancel') : t('add')}
</Button>
</div>
{showAddCoupon && (
<Form
action={onSubmitApplyCouponCode}
className="my-4 flex flex-col gap-2"
id="coupon-code-form"
>
<input name="checkoutEntityId" type="hidden" value={checkout.entityId} />
<Field name="couponCode">
<FieldControl asChild>
<Input
aria-label={t('couponCode')}
placeholder={t('enterCouponCode')}
required
type="text"
/>
</FieldControl>
<FieldMessage className="text-xs text-error" match="valueMissing">
{t('couponCodeRequired')}
</FieldMessage>
</Field>
<FormSubmit asChild>
<SubmitButton />
</FormSubmit>
</Form>
)}
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const ShippingEstimator = ({
<span>{currencyFormatter.format(checkout.shippingCostTotal?.value || 0)}</span>
) : (
<Button
aria-controls="shipping-options"
className="w-fit p-0 text-primary hover:bg-transparent"
onClick={() => setShowShippingInfo((open) => !open)}
variant="subtle"
Expand All @@ -76,6 +77,7 @@ export const ShippingEstimator = ({
<div className="flex justify-between">
<span>{selectedShippingConsignment.selectedShippingOption?.description}</span>
<Button
aria-controls="shipping-options"
className="w-fit p-0 text-primary hover:bg-transparent"
onClick={() => setShowShippingInfo((open) => !open)}
variant="subtle"
Expand All @@ -93,7 +95,7 @@ export const ShippingEstimator = ({
/>

{showShippingOptions && checkout.shippingConsignments && (
<div className="flex flex-col">
<div className="flex flex-col" id="shipping-options">
{checkout.shippingConsignments.map(({ entityId, availableShippingOptions }) => {
return (
<ShippingOptions
Expand Down
36 changes: 36 additions & 0 deletions apps/core/client/mutations/apply-checkout-coupon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { getSessionCustomerId } from '~/auth';

import { client } from '..';
import { graphql } from '../graphql';

const APPLY_CHECKOUT_COUPON = graphql(`
mutation ApplyCheckoutCoupon($applyCheckoutCouponInput: ApplyCheckoutCouponInput!) {
checkout {
applyCheckoutCoupon(input: $applyCheckoutCouponInput) {
checkout {
entityId
}
}
}
}
`);

export const applyCheckoutCoupon = async (checkoutEntityId: string, couponCode: string) => {
const customerId = await getSessionCustomerId();

const response = await client.fetch({
document: APPLY_CHECKOUT_COUPON,
variables: {
applyCheckoutCouponInput: {
checkoutEntityId,
data: {
couponCode,
},
},
},
customerId: Number(customerId),
fetchOptions: { cache: 'no-store' },
});

return response.data.checkout.applyCheckoutCoupon?.checkout;
};
36 changes: 36 additions & 0 deletions apps/core/client/mutations/unapply-checkout-coupon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { getSessionCustomerId } from '~/auth';

import { client } from '..';
import { graphql } from '../graphql';

const UNAPPLY_CHECKOUT_COUPON = graphql(`
mutation UnapplyCheckoutCoupon($unapplyCheckoutCouponInput: UnapplyCheckoutCouponInput!) {
checkout {
unapplyCheckoutCoupon(input: $unapplyCheckoutCouponInput) {
checkout {
entityId
}
}
}
}
`);

export const unapplyCheckoutCoupon = async (checkoutEntityId: string, couponCode: string) => {
const customerId = await getSessionCustomerId();

const response = await client.fetch({
document: UNAPPLY_CHECKOUT_COUPON,
variables: {
unapplyCheckoutCouponInput: {
checkoutEntityId,
data: {
couponCode,
},
},
},
customerId: Number(customerId),
fetchOptions: { cache: 'no-store' },
});

return response.data.checkout.unapplyCheckoutCoupon?.checkout;
};
7 changes: 7 additions & 0 deletions apps/core/client/queries/get-checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ const GET_CHECKOUT_QUERY = graphql(
type
}
}
coupons {
code
entityId
discountedAmount {
...MoneyFields
}
}
cart {
currencyCode
discountedAmount {
Expand Down
16 changes: 14 additions & 2 deletions apps/core/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,19 @@
"CheckoutSummary": {
"subTotal": "Subtotal",
"discounts": "Discounts",
"coupon": "Coupon",
"couponCode": "Coupon code",
"addCoupon": "Add coupon",
"enterCouponCode": "Enter your coupon code",
"couponCodeRequired": "Please enter a coupon code.",
"couponCodeInvalid": "The coupon code you entered is not valid.",
"remove": "Remove",
"couponCodeRemoveFailed": "There was an error removing the coupon code. Please try again.",
"tax": "Tax",
"grandTotal": "Grand total",
"shipping": "Shipping",
"shippingCost": "Shipping Cost",
"handlingCost": "Handling Cost",
"shippingCost": "Shipping cost",
"handlingCost": "Handling cost",
"add": "Add",
"change": "Change",
"cancel": "Cancel",
Expand Down Expand Up @@ -142,6 +150,10 @@
"SubmitShippingCost": {
"spinnerText": "Submitting...",
"submitText": "Update shipping costs"
},
"SubmitCouponCode": {
"spinnerText": "Submitting...",
"submitText": "Apply"
}
},
"Search": {
Expand Down
Loading

0 comments on commit 565e871

Please sign in to comment.