Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): allow applying coupons to cart #733

Merged
merged 7 commits into from
Apr 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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', {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any way we can read the local from the page request?

Copy link
Contributor Author

@jorgemoya jorgemoya Apr 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are several instances of this so I think it might be best to tackle in a different PR.

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"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️

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
Loading