From 565e87173056fe944c94a004a84947ae93e84c00 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Fri, 5 Apr 2024 13:11:56 -0500 Subject: [PATCH] feat(core): allow applying coupons to cart (#733) * 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 --- .changeset/brown-buses-juggle.md | 5 + .../cart/_actions/apply-coupon-code.ts | 36 +++++ .../cart/_actions/remove-coupon-code.ts | 39 +++++ .../cart/_components/checkout-summary.tsx | 5 + .../cart/_components/coupon-code.tsx | 141 ++++++++++++++++++ .../cart/_components/shipping-estimator.tsx | 4 +- .../client/mutations/apply-checkout-coupon.ts | 36 +++++ .../mutations/unapply-checkout-coupon.ts | 36 +++++ apps/core/client/queries/get-checkout.ts | 7 + apps/core/messages/en.json | 16 +- .../tests/ui/desktop/e2e/cart.spec.ts | 54 +++++++ 11 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 .changeset/brown-buses-juggle.md create mode 100644 apps/core/app/[locale]/(default)/cart/_actions/apply-coupon-code.ts create mode 100644 apps/core/app/[locale]/(default)/cart/_actions/remove-coupon-code.ts create mode 100644 apps/core/app/[locale]/(default)/cart/_components/coupon-code.tsx create mode 100644 apps/core/client/mutations/apply-checkout-coupon.ts create mode 100644 apps/core/client/mutations/unapply-checkout-coupon.ts diff --git a/.changeset/brown-buses-juggle.md b/.changeset/brown-buses-juggle.md new file mode 100644 index 000000000..9d6bba405 --- /dev/null +++ b/.changeset/brown-buses-juggle.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Allow applying and removing coupons in cart diff --git a/apps/core/app/[locale]/(default)/cart/_actions/apply-coupon-code.ts b/apps/core/app/[locale]/(default)/cart/_actions/apply-coupon-code.ts new file mode 100644 index 000000000..076ebf2a6 --- /dev/null +++ b/apps/core/app/[locale]/(default)/cart/_actions/apply-coupon-code.ts @@ -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' }; + } +} diff --git a/apps/core/app/[locale]/(default)/cart/_actions/remove-coupon-code.ts b/apps/core/app/[locale]/(default)/cart/_actions/remove-coupon-code.ts new file mode 100644 index 000000000..159c7a188 --- /dev/null +++ b/apps/core/app/[locale]/(default)/cart/_actions/remove-coupon-code.ts @@ -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' }; + } +} diff --git a/apps/core/app/[locale]/(default)/cart/_components/checkout-summary.tsx b/apps/core/app/[locale]/(default)/cart/_components/checkout-summary.tsx index e7d7282af..2ae43e044 100644 --- a/apps/core/app/[locale]/(default)/cart/_components/checkout-summary.tsx +++ b/apps/core/app/[locale]/(default)/cart/_components/checkout-summary.tsx @@ -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 }) => { @@ -49,6 +50,10 @@ export const CheckoutSummary = async ({ cartId, locale }: { cartId: string; loca )} + + + + {checkout.taxTotal && (
{t('tax')} diff --git a/apps/core/app/[locale]/(default)/cart/_components/coupon-code.tsx b/apps/core/app/[locale]/(default)/cart/_components/coupon-code.tsx new file mode 100644 index 000000000..7b8e8c1d6 --- /dev/null +++ b/apps/core/app/[locale]/(default)/cart/_components/coupon-code.tsx @@ -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; + +const SubmitButton = () => { + const t = useTranslations('Cart.SubmitCouponCode'); + const { pending } = useFormStatus(); + + return ( + + ); +}; + +export const CouponCode = ({ checkout }: { checkout: ExistingResultType }) => { + const t = useTranslations('Cart.CheckoutSummary'); + const [showAddCoupon, setShowAddCoupon] = useState(false); + const [selectedCoupon, setSelectedCoupon] = useState( + 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: , + }); + } + }; + + const onSubmitRemoveCouponCode = async (formData: FormData) => { + const { status } = await removeCouponCode(formData); + + if (status === 'error') { + toast.error(t('couponCodeRemoveFailed'), { + icon: , + }); + } + }; + + return selectedCoupon ? ( +
+
+ + {t('coupon')} ({selectedCoupon.code}) + + {currencyFormatter.format(selectedCoupon.discountedAmount.value * -1)} +
+
+ + + +
+
+ ) : ( +
+
+ {t('couponCode')} + +
+ {showAddCoupon && ( +
+ + + + + + + {t('couponCodeRequired')} + + + + + +
+ )} +
+ ); +}; diff --git a/apps/core/app/[locale]/(default)/cart/_components/shipping-estimator.tsx b/apps/core/app/[locale]/(default)/cart/_components/shipping-estimator.tsx index 8d272be3f..1061f18ca 100644 --- a/apps/core/app/[locale]/(default)/cart/_components/shipping-estimator.tsx +++ b/apps/core/app/[locale]/(default)/cart/_components/shipping-estimator.tsx @@ -63,6 +63,7 @@ export const ShippingEstimator = ({ {currencyFormatter.format(checkout.shippingCostTotal?.value || 0)} ) : (