-
Notifications
You must be signed in to change notification settings - Fork 220
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
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
0e3b17c
feat(core): allow applying coupons to cart
jorgemoya c19ff67
fix: add tests
jorgemoya 5c335bc
fix: add accessibility attributes
jorgemoya 9bfbd54
fix: change name
jorgemoya 450069b
refactor: pass checkout id as form data
jorgemoya 1149678
fix: pr feedback
jorgemoya 5509362
fix: hide coupon input when coupon is set
jorgemoya File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
36 changes: 36 additions & 0 deletions
36
apps/core/app/[locale]/(default)/cart/_actions/apply-coupon-code.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' }; | ||
} | ||
} |
39 changes: 39 additions & 0 deletions
39
apps/core/app/[locale]/(default)/cart/_actions/remove-coupon-code.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' }; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
141 changes: 141 additions & 0 deletions
141
apps/core/app/[locale]/(default)/cart/_components/coupon-code.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -63,6 +63,7 @@ export const ShippingEstimator = ({ | |
<span>{currencyFormatter.format(checkout.shippingCostTotal?.value || 0)}</span> | ||
) : ( | ||
<Button | ||
aria-controls="shipping-options" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" | ||
|
@@ -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" | ||
|
@@ -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 | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.