From 6fd796e3274db165fb7210382f0335798cf4668b Mon Sep 17 00:00:00 2001 From: abansal2-godaddy Date: Mon, 24 Aug 2026 13:30:18 +0530 Subject: [PATCH 1/6] Redesign checkout coupon UI and add en-US discount copy keys. Implement combined coupon input states (default, filled, success, error) in DiscountStandalone, add DiscountAppliedBar, and extend enUs discounts strings for GOLF to translate into other locales. Co-authored-by: Cursor --- .changeset/vnext-83184-coupon-redesign.md | 6 + packages/localizations/src/enUs.ts | 3 + .../__tests__/checkout-discount.test.tsx | 16 +- .../__tests__/checkout-free-order.test.tsx | 3 +- .../discount/discount-applied-bar.tsx | 74 +++++ .../checkout/discount/discount-standalone.tsx | 265 +++++++++++------- 6 files changed, 264 insertions(+), 103 deletions(-) create mode 100644 .changeset/vnext-83184-coupon-redesign.md create mode 100644 packages/react/src/components/checkout/discount/discount-applied-bar.tsx diff --git a/.changeset/vnext-83184-coupon-redesign.md b/.changeset/vnext-83184-coupon-redesign.md new file mode 100644 index 00000000..a459acd0 --- /dev/null +++ b/.changeset/vnext-83184-coupon-redesign.md @@ -0,0 +1,6 @@ +--- +"@godaddy/localizations": patch +"@godaddy/react": patch +--- + +Redesign checkout coupon code UI with updated states and add new discount copy keys for en-US (GOLF handles other locales). diff --git a/packages/localizations/src/enUs.ts b/packages/localizations/src/enUs.ts index ffef4e36..d4d58569 100644 --- a/packages/localizations/src/enUs.ts +++ b/packages/localizations/src/enUs.ts @@ -155,12 +155,15 @@ export const enUs = { noCountryFound: 'No country found.', }, discounts: { + haveACouponCode: 'Have a coupon code?', placeholder: 'Coupon code', enterCode: 'Enter coupon code', apply: 'Apply', alreadyApplied: 'This coupon code has already been applied', failedToApply: 'Failed to apply coupon code', enterCodeValidation: 'Please enter a coupon code', + invalid: "This coupon code isn't valid. Please try again.", + removeCoupon: 'Remove coupon', }, totals: { subtotal: 'Subtotal', diff --git a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx index c20c8e12..f040aad7 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx @@ -120,7 +120,7 @@ describe('Checkout discounts', () => { expect(getOperations('ApplyCheckoutSessionDiscount')[0].input).toEqual({ discountCodes: ['onedollar'], }); - expect(screen.getAllByText('onedollar')).toHaveLength(2); + expect(screen.getAllByText('onedollar').length).toBeGreaterThan(0); clearOperations(); await user.click( @@ -170,7 +170,7 @@ describe('Checkout discounts', () => { ).not.toBeInTheDocument(); }); - it('renders the API error code inline when discount apply fails', async () => { + it('renders the localized invalid message when discount apply fails with GraphQL codes', async () => { const { user } = renderCheckout({ sessionOverrides: { enableShipping: false, @@ -192,12 +192,12 @@ describe('Checkout discounts', () => { await waitForOperation('ApplyCheckoutSessionDiscount'); await waitFor(() => { - expect(document.body).toHaveTextContent(/DISCOUNT_NOT_FOUND/i); + expect(document.body).toHaveTextContent(enUs.discounts.invalid); }); await flushPromises(); }); - it('renders the localized generic message when discount apply fails without GraphQL codes', async () => { + it('renders the localized invalid message when discount apply fails without GraphQL codes', async () => { const { user } = renderCheckout({ sessionOverrides: { enableShipping: false, @@ -214,14 +214,12 @@ describe('Checkout discounts', () => { await waitForOperation('ApplyCheckoutSessionDiscount'); await waitFor(() => { - expect(document.body).toHaveTextContent(enUs.discounts.failedToApply); + expect(document.body).toHaveTextContent(enUs.discounts.invalid); }); await flushPromises(); }); - it('keeps empty coupon apply disabled and does not call the API', async () => { - // TODO(T-1401): Product copy requests click-to-validate empty input, but - // current UI disables Apply while the trimmed discount code is empty. + it('renders the coupon label and keeps apply disabled when empty', async () => { renderCheckout({ sessionOverrides: { enableShipping: false, @@ -232,6 +230,8 @@ describe('Checkout discounts', () => { await waitForCheckoutReady(); clearOperations(); + expect(screen.getAllByText(enUs.discounts.haveACouponCode).length).toBeGreaterThan(0); + const button = screen.getAllByRole('button', { name: /apply/i })[0]; expect(button).toBeDisabled(); fireEvent.click(button); diff --git a/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx index ea014b76..e6bfdbd5 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx @@ -1,3 +1,4 @@ +import { enUs } from '@godaddy/localizations'; import { screen, waitFor } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { @@ -472,6 +473,6 @@ describe('Checkout free / offline orders', () => { expect( screen.queryByRole('button', { name: /complete your free order/i }) ).not.toBeInTheDocument(); - expect(document.body).toHaveTextContent(/failed to apply coupon code/i); + expect(document.body).toHaveTextContent(enUs.discounts.invalid); }); }); diff --git a/packages/react/src/components/checkout/discount/discount-applied-bar.tsx b/packages/react/src/components/checkout/discount/discount-applied-bar.tsx new file mode 100644 index 00000000..15383be6 --- /dev/null +++ b/packages/react/src/components/checkout/discount/discount-applied-bar.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { Check, Loader2, X } from 'lucide-react'; + +import { useFormatCurrency } from '@/components/checkout/utils/format-currency'; +import { cn } from '@/lib/utils'; + +interface DiscountAppliedBarProps { + code: string; + amount: number; + currencyCode: string; + inputInMinorUnits?: boolean; + onRemove?: () => void; + isRemoving?: boolean; +} + +export function DiscountAppliedBar({ + code, + amount, + currencyCode, + inputInMinorUnits = true, + onRemove, + isRemoving, +}: DiscountAppliedBarProps) { + const formatCurrency = useFormatCurrency(); + + const formattedAmount = formatCurrency({ + amount, + currencyCode, + inputInMinorUnits, + }); + + return ( +
+
+ + + {code} +
+ +
+ + – {formattedAmount} + + {onRemove ? ( + <> +
+
+ ); +} diff --git a/packages/react/src/components/checkout/discount/discount-standalone.tsx b/packages/react/src/components/checkout/discount/discount-standalone.tsx index 1ecc4f33..69e8d616 100644 --- a/packages/react/src/components/checkout/discount/discount-standalone.tsx +++ b/packages/react/src/components/checkout/discount/discount-standalone.tsx @@ -1,88 +1,122 @@ 'use client'; -import React, { useState } from 'react'; -import { DiscountApplyButton } from '@/components/checkout/discount/discount-apply-button'; -import { DiscountErrorList } from '@/components/checkout/discount/discount-error-list'; -import { DiscountInput } from '@/components/checkout/discount/discount-input'; +import { enUs } from '@godaddy/localizations'; +import { Loader2, X } from 'lucide-react'; +import React, { useMemo, useState } from 'react'; +import { useCheckoutContext } from '@/components/checkout/checkout'; +import { DiscountAppliedBar } from '@/components/checkout/discount/discount-applied-bar'; import { useDiscountApply } from '@/components/checkout/discount/utils/use-discount-apply'; import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; import { useIsPaymentDisabled } from '@/components/checkout/payment/utils/use-is-payment-disabled'; import { useGoDaddyContext } from '@/godaddy-provider'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; +import { cn } from '@/lib/utils'; +import type { DraftOrder } from '@/types'; import { eventIds } from '@/tracking/events'; import { TrackingEventType, track } from '@/tracking/track'; -import { Discounts } from './discounts'; import type { DiscountFormProps } from './types'; -export function DiscountStandalone({ - initialDiscounts = [], - onDiscountsChange, - onError, -}: DiscountFormProps) { - const { t } = useGoDaddyContext(); - const isPaymentDisabled = useIsPaymentDisabled(); - const { data: draftOrder } = useDraftOrder(); +type AppliedDiscount = { + code: string; + amount: number; + currencyCode: string; +}; - // Get current discount codes from order-level, line item-level, and shipping line-level discounts - const currentDiscountCodes = React.useMemo(() => { - if (!draftOrder) return []; +function collectAppliedDiscounts(draftOrder: DraftOrder): AppliedDiscount[] { + const discountsByCode = new Map(); - const allCodes = new Set(); + const addDiscount = (discount: { + code?: string | null; + amount?: { value?: number | null; currencyCode?: string | null } | null; + }) => { + if (!discount.code) return; - // Add order-level discount codes - if (draftOrder.discounts) { - for (const discount of draftOrder.discounts) { - if (discount.code) { - allCodes.add(discount.code); - } - } - } + const existing = discountsByCode.get(discount.code); + const amountValue = discount.amount?.value ?? 0; + const currencyCode = discount.amount?.currencyCode ?? 'USD'; - // Add line item-level discount codes - if (draftOrder.lineItems) { - for (const lineItem of draftOrder.lineItems) { - if (lineItem.discounts) { - for (const discount of lineItem.discounts) { - if (discount.code) { - allCodes.add(discount.code); - } - } - } - } + if (existing) { + existing.amount += amountValue; + return; } - // Add shipping line-level discount codes - if (draftOrder.shippingLines) { - for (const shippingLine of draftOrder.shippingLines) { - if (shippingLine.discounts) { - for (const discount of shippingLine.discounts) { - if (discount.code) { - allCodes.add(discount.code); - } - } - } - } - } + discountsByCode.set(discount.code, { + code: discount.code, + amount: amountValue, + currencyCode, + }); + }; + + draftOrder.discounts?.forEach(addDiscount); + draftOrder.lineItems?.forEach(lineItem => { + lineItem.discounts?.forEach(addDiscount); + }); + draftOrder.shippingLines?.forEach(shippingLine => { + shippingLine.discounts?.forEach(addDiscount); + }); - return Array.from(allCodes); - }, [draftOrder]); + return Array.from(discountsByCode.values()); +} + +function collectDiscountCodes(appliedDiscounts: AppliedDiscount[]): string[] { + return appliedDiscounts.map(discount => discount.code); +} + +export function DiscountStandalone({ + onDiscountsChange, + onError, +}: DiscountFormProps) { + const { t } = useGoDaddyContext(); + const { elements } = useCheckoutContext(); + const isPaymentDisabled = useIsPaymentDisabled(); + const { data: draftOrder } = useDraftOrder(); + const appliedDiscounts = useMemo( + () => (draftOrder ? collectAppliedDiscounts(draftOrder) : []), + [draftOrder] + ); + const currentDiscountCodes = useMemo( + () => collectDiscountCodes(appliedDiscounts), + [appliedDiscounts] + ); const [discountCode, setDiscountCode] = useState(''); - const [formErrors, setFormErrors] = useState(undefined); + const [formErrors, setFormErrors] = useState( + undefined + ); const [isSubmitting, setIsSubmitting] = useState(false); const [isRemovingDiscount, setIsRemovingDiscount] = useState< string | undefined >(undefined); + const [isFocused, setIsFocused] = useState(false); const applyDiscount = useDiscountApply(); + const hasError = !!formErrors?.length; + const hasInputValue = discountCode.trim().length > 0; + const isApplyDisabled = + !hasInputValue || isPaymentDisabled || isSubmitting || !!isRemovingDiscount; + + const resolveErrorMessage = (error: string) => { + if ( + error === t.discounts.alreadyApplied || + error === t.discounts.enterCodeValidation + ) { + return error; + } + + return t.discounts.invalid ?? enUs.discounts.invalid; + }; + const handleInputChange = (e: React.ChangeEvent) => { - setDiscountCode(e.target.value); + setDiscountCode(e.target.value.replace(/\s+/g, '')); setFormErrors(undefined); }; - const handleApply = async () => { - // Validation + const handleClearInput = () => { + setDiscountCode(''); + setFormErrors(undefined); + }; + const handleApply = async () => { if (!discountCode.trim()) { setFormErrors([t.discounts.enterCodeValidation]); return; @@ -90,22 +124,18 @@ export function DiscountStandalone({ try { setIsSubmitting(true); - // Normalize the discount code to uppercase for consistency const normalizedCode = discountCode.trim(); - // Check if the code already exists if (currentDiscountCodes.includes(normalizedCode)) { setFormErrors([t.discounts.alreadyApplied]); return; } - // Apply discount with current codes + new code const newDiscountCodes = [...currentDiscountCodes, normalizedCode]; await applyDiscount.mutateAsync({ discountCodes: newDiscountCodes, }); - // Track successful discount application track({ eventId: eventIds.applyCoupon, type: TrackingEventType.CLICK, @@ -115,17 +145,12 @@ export function DiscountStandalone({ }, }); - // Call the change handler if provided onDiscountsChange?.(newDiscountCodes); - - // Reset the input setDiscountCode(''); setFormErrors(undefined); } catch (error) { if (error instanceof GraphQLErrorWithCodes) { setFormErrors(error.codes); - - // Track discount error track({ eventId: eventIds.discountError, type: TrackingEventType.EVENT, @@ -138,8 +163,6 @@ export function DiscountStandalone({ const genericError = new Error(t.discounts.failedToApply); setFormErrors([t.discounts.failedToApply]); onError?.(genericError); - - // Track generic discount error track({ eventId: eventIds.discountError, type: TrackingEventType.EVENT, @@ -163,7 +186,7 @@ export function DiscountStandalone({ const handleRemoveDiscount = async (discountToRemove: string) => { const newDiscountCodes = currentDiscountCodes.filter( - d => d !== discountToRemove + code => code !== discountToRemove ); try { @@ -172,7 +195,6 @@ export function DiscountStandalone({ discountCodes: newDiscountCodes, }); - // Track discount removal track({ eventId: eventIds.removeDiscount, type: TrackingEventType.CLICK, @@ -187,8 +209,6 @@ export function DiscountStandalone({ } catch (error) { if (error instanceof GraphQLErrorWithCodes) { setFormErrors(error.codes); - - // Track discount error track({ eventId: eventIds.discountError, type: TrackingEventType.EVENT, @@ -201,8 +221,6 @@ export function DiscountStandalone({ const genericError = new Error(t.discounts.failedToApply); setFormErrors([t.discounts.failedToApply]); onError?.(genericError); - - // Track generic discount error track({ eventId: eventIds.discountError, type: TrackingEventType.EVENT, @@ -215,38 +233,97 @@ export function DiscountStandalone({ } }; + const label = + t.discounts.haveACouponCode ?? enUs.discounts.haveACouponCode; + const primaryError = formErrors?.[0] + ? resolveErrorMessage(formErrors[0]) + : undefined; + return ( -
-
-
- + + + {appliedDiscounts.length > 0 ? ( +
+ {appliedDiscounts.map(discount => ( + handleRemoveDiscount(discount.code)} + isRemoving={isRemovingDiscount === discount.code} + /> + ))} +
+ ) : null} + +
+
+ setIsFocused(true)} + onBlur={() => setIsFocused(false)} placeholder={t.discounts.placeholder} - hasError={!!formErrors?.length} - className='h-12' disabled={isPaymentDisabled || !!isRemovingDiscount} + className={cn( + 'min-w-0 flex-1 border-0 bg-transparent text-base text-[#111111] outline-none placeholder:text-[#9CA3AF] disabled:cursor-not-allowed disabled:opacity-50', + elements?.input + )} /> + + {hasError ? ( +
+
+ ) : ( + + )}
- + + {primaryError ? ( +

+ {primaryError} +

+ ) : null}
- - - {currentDiscountCodes.length > 0 && ( -
- -
- )}
); } From 29c717349dd967b4187deccd0145046f6d5c30e8 Mon Sep 17 00:00:00 2001 From: abansal2-godaddy Date: Mon, 24 Aug 2026 13:39:18 +0530 Subject: [PATCH 2/6] Remove changeset file from the coupon UI change. Versioning can be handled in the release workflow; the coupon redesign does not need a local changeset. Co-authored-by: Cursor --- .changeset/vnext-83184-coupon-redesign.md | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 .changeset/vnext-83184-coupon-redesign.md diff --git a/.changeset/vnext-83184-coupon-redesign.md b/.changeset/vnext-83184-coupon-redesign.md deleted file mode 100644 index a459acd0..00000000 --- a/.changeset/vnext-83184-coupon-redesign.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@godaddy/localizations": patch -"@godaddy/react": patch ---- - -Redesign checkout coupon code UI with updated states and add new discount copy keys for en-US (GOLF handles other locales). From e965e3ec8270b0ef699b413de1b8d5136db75f66 Mon Sep 17 00:00:00 2001 From: abansal2-godaddy Date: Mon, 24 Aug 2026 15:09:42 +0530 Subject: [PATCH 3/6] Add changeset for checkout coupon UI redesign. Bump @godaddy/react and @godaddy/localizations so the Version Packages workflow can publish after merge. Co-authored-by: Cursor --- .changeset/vnext-83184-coupon-redesign.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/vnext-83184-coupon-redesign.md diff --git a/.changeset/vnext-83184-coupon-redesign.md b/.changeset/vnext-83184-coupon-redesign.md new file mode 100644 index 00000000..a459acd0 --- /dev/null +++ b/.changeset/vnext-83184-coupon-redesign.md @@ -0,0 +1,6 @@ +--- +"@godaddy/localizations": patch +"@godaddy/react": patch +--- + +Redesign checkout coupon code UI with updated states and add new discount copy keys for en-US (GOLF handles other locales). From 42dee4854c3145eb3f870f9577398e447b6328d9 Mon Sep 17 00:00:00 2001 From: abansal2-godaddy Date: Mon, 24 Aug 2026 15:19:13 +0530 Subject: [PATCH 4/6] Document why coupon UI inlines layout and adds DiscountAppliedBar. Clarify in DiscountStandalone and DiscountAppliedBar that the redesign cannot reuse the old input/button/chip composition, so those imports were dropped from this path. Co-authored-by: Cursor --- .../checkout/discount/discount-applied-bar.tsx | 8 ++++++++ .../checkout/discount/discount-standalone.tsx | 15 +++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/react/src/components/checkout/discount/discount-applied-bar.tsx b/packages/react/src/components/checkout/discount/discount-applied-bar.tsx index 15383be6..b5f219b4 100644 --- a/packages/react/src/components/checkout/discount/discount-applied-bar.tsx +++ b/packages/react/src/components/checkout/discount/discount-applied-bar.tsx @@ -1,5 +1,13 @@ 'use client'; +/** + * Success state for an applied coupon (green bar with check, code, amount, remove). + * + * Replaces the old DiscountTag chip for DiscountStandalone: tags only showed the + * code, while the redesign requires the discounted amount beside a remove control + * in a full-width success row. + */ + import { Check, Loader2, X } from 'lucide-react'; import { useFormatCurrency } from '@/components/checkout/utils/format-currency'; diff --git a/packages/react/src/components/checkout/discount/discount-standalone.tsx b/packages/react/src/components/checkout/discount/discount-standalone.tsx index 69e8d616..7bbfd94f 100644 --- a/packages/react/src/components/checkout/discount/discount-standalone.tsx +++ b/packages/react/src/components/checkout/discount/discount-standalone.tsx @@ -1,5 +1,20 @@ 'use client'; +/** + * Coupon entry for checkout totals (VNEXT-83184 design). + * + * Layout is intentionally inlined here instead of composing the older + * DiscountInput / DiscountApplyButton / DiscountErrorList / Discounts / + * DiscountTag pieces. Those matched the previous UX (separate input + button, + * large error alert, chip tags without amounts). The new specs require: + * - one combined bordered field (input + Apply, or clear on error) + * - compact helper error text under the field + * - a full-width success bar with code + formatted amount + remove + * + * Applied coupons render via DiscountAppliedBar. Legacy discount-* modules are + * left in the package for now (still exported) but are unused by this path. + */ + import { enUs } from '@godaddy/localizations'; import { Loader2, X } from 'lucide-react'; import React, { useMemo, useState } from 'react'; From 7a9ff3e705947a01dec608e00e1d87c5be95d357 Mon Sep 17 00:00:00 2001 From: abansal2-godaddy Date: Mon, 24 Aug 2026 15:21:38 +0530 Subject: [PATCH 5/6] Remove in-code layout rationale from discount components. Keep the explanation in the PR description and review comments instead. Co-authored-by: Cursor --- .../checkout/discount/discount-applied-bar.tsx | 8 -------- .../checkout/discount/discount-standalone.tsx | 15 --------------- 2 files changed, 23 deletions(-) diff --git a/packages/react/src/components/checkout/discount/discount-applied-bar.tsx b/packages/react/src/components/checkout/discount/discount-applied-bar.tsx index b5f219b4..15383be6 100644 --- a/packages/react/src/components/checkout/discount/discount-applied-bar.tsx +++ b/packages/react/src/components/checkout/discount/discount-applied-bar.tsx @@ -1,13 +1,5 @@ 'use client'; -/** - * Success state for an applied coupon (green bar with check, code, amount, remove). - * - * Replaces the old DiscountTag chip for DiscountStandalone: tags only showed the - * code, while the redesign requires the discounted amount beside a remove control - * in a full-width success row. - */ - import { Check, Loader2, X } from 'lucide-react'; import { useFormatCurrency } from '@/components/checkout/utils/format-currency'; diff --git a/packages/react/src/components/checkout/discount/discount-standalone.tsx b/packages/react/src/components/checkout/discount/discount-standalone.tsx index 7bbfd94f..69e8d616 100644 --- a/packages/react/src/components/checkout/discount/discount-standalone.tsx +++ b/packages/react/src/components/checkout/discount/discount-standalone.tsx @@ -1,20 +1,5 @@ 'use client'; -/** - * Coupon entry for checkout totals (VNEXT-83184 design). - * - * Layout is intentionally inlined here instead of composing the older - * DiscountInput / DiscountApplyButton / DiscountErrorList / Discounts / - * DiscountTag pieces. Those matched the previous UX (separate input + button, - * large error alert, chip tags without amounts). The new specs require: - * - one combined bordered field (input + Apply, or clear on error) - * - compact helper error text under the field - * - a full-width success bar with code + formatted amount + remove - * - * Applied coupons render via DiscountAppliedBar. Legacy discount-* modules are - * left in the package for now (still exported) but are unused by this path. - */ - import { enUs } from '@godaddy/localizations'; import { Loader2, X } from 'lucide-react'; import React, { useMemo, useState } from 'react'; From 51c4f09445638c43d353eb44ac65cf3b0875ca0c Mon Sep 17 00:00:00 2001 From: abansal2-godaddy Date: Mon, 24 Aug 2026 15:38:11 +0530 Subject: [PATCH 6/6] Restructure DiscountStandalone so the PR diff keeps apply/remove logic intact. Preserve the original code collection and handlers; limit the change to the new coupon UI, amount lookup for the success bar, and error helper copy. Co-authored-by: Cursor --- .../checkout/discount/discount-standalone.tsx | 218 +++++++++++------- 1 file changed, 129 insertions(+), 89 deletions(-) diff --git a/packages/react/src/components/checkout/discount/discount-standalone.tsx b/packages/react/src/components/checkout/discount/discount-standalone.tsx index 69e8d616..4abd2f41 100644 --- a/packages/react/src/components/checkout/discount/discount-standalone.tsx +++ b/packages/react/src/components/checkout/discount/discount-standalone.tsx @@ -2,7 +2,7 @@ import { enUs } from '@godaddy/localizations'; import { Loader2, X } from 'lucide-react'; -import React, { useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { DiscountAppliedBar } from '@/components/checkout/discount/discount-applied-bar'; import { useDiscountApply } from '@/components/checkout/discount/utils/use-discount-apply'; @@ -11,78 +11,97 @@ import { useIsPaymentDisabled } from '@/components/checkout/payment/utils/use-is import { useGoDaddyContext } from '@/godaddy-provider'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; import { cn } from '@/lib/utils'; -import type { DraftOrder } from '@/types'; import { eventIds } from '@/tracking/events'; import { TrackingEventType, track } from '@/tracking/track'; import type { DiscountFormProps } from './types'; -type AppliedDiscount = { - code: string; - amount: number; - currencyCode: string; -}; +export function DiscountStandalone({ + initialDiscounts = [], + onDiscountsChange, + onError, +}: DiscountFormProps) { + const { t } = useGoDaddyContext(); + const { elements } = useCheckoutContext(); + const isPaymentDisabled = useIsPaymentDisabled(); + const { data: draftOrder } = useDraftOrder(); -function collectAppliedDiscounts(draftOrder: DraftOrder): AppliedDiscount[] { - const discountsByCode = new Map(); + // Get current discount codes from order-level, line item-level, and shipping line-level discounts + const currentDiscountCodes = React.useMemo(() => { + if (!draftOrder) return []; - const addDiscount = (discount: { - code?: string | null; - amount?: { value?: number | null; currencyCode?: string | null } | null; - }) => { - if (!discount.code) return; + const allCodes = new Set(); - const existing = discountsByCode.get(discount.code); - const amountValue = discount.amount?.value ?? 0; - const currencyCode = discount.amount?.currencyCode ?? 'USD'; + // Add order-level discount codes + if (draftOrder.discounts) { + for (const discount of draftOrder.discounts) { + if (discount.code) { + allCodes.add(discount.code); + } + } + } - if (existing) { - existing.amount += amountValue; - return; + // Add line item-level discount codes + if (draftOrder.lineItems) { + for (const lineItem of draftOrder.lineItems) { + if (lineItem.discounts) { + for (const discount of lineItem.discounts) { + if (discount.code) { + allCodes.add(discount.code); + } + } + } + } } - discountsByCode.set(discount.code, { - code: discount.code, - amount: amountValue, - currencyCode, - }); - }; + // Add shipping line-level discount codes + if (draftOrder.shippingLines) { + for (const shippingLine of draftOrder.shippingLines) { + if (shippingLine.discounts) { + for (const discount of shippingLine.discounts) { + if (discount.code) { + allCodes.add(discount.code); + } + } + } + } + } - draftOrder.discounts?.forEach(addDiscount); - draftOrder.lineItems?.forEach(lineItem => { - lineItem.discounts?.forEach(addDiscount); - }); - draftOrder.shippingLines?.forEach(shippingLine => { - shippingLine.discounts?.forEach(addDiscount); - }); + return Array.from(allCodes); + }, [draftOrder]); + + // Amounts for the success bar UI only (apply/remove still use currentDiscountCodes) + const discountAmountsByCode = React.useMemo(() => { + const amounts = new Map(); + if (!draftOrder) return amounts; + + const addAmount = (discount: { + code?: string | null; + amount?: { value?: number | null; currencyCode?: string | null } | null; + }) => { + if (!discount.code) return; + const existing = amounts.get(discount.code); + const value = discount.amount?.value ?? 0; + const currencyCode = discount.amount?.currencyCode ?? 'USD'; + if (existing) { + existing.amount += value; + return; + } + amounts.set(discount.code, { amount: value, currencyCode }); + }; - return Array.from(discountsByCode.values()); -} + draftOrder.discounts?.forEach(addAmount); + draftOrder.lineItems?.forEach(lineItem => { + lineItem.discounts?.forEach(addAmount); + }); + draftOrder.shippingLines?.forEach(shippingLine => { + shippingLine.discounts?.forEach(addAmount); + }); -function collectDiscountCodes(appliedDiscounts: AppliedDiscount[]): string[] { - return appliedDiscounts.map(discount => discount.code); -} - -export function DiscountStandalone({ - onDiscountsChange, - onError, -}: DiscountFormProps) { - const { t } = useGoDaddyContext(); - const { elements } = useCheckoutContext(); - const isPaymentDisabled = useIsPaymentDisabled(); - const { data: draftOrder } = useDraftOrder(); - const appliedDiscounts = useMemo( - () => (draftOrder ? collectAppliedDiscounts(draftOrder) : []), - [draftOrder] - ); - const currentDiscountCodes = useMemo( - () => collectDiscountCodes(appliedDiscounts), - [appliedDiscounts] - ); + return amounts; + }, [draftOrder]); const [discountCode, setDiscountCode] = useState(''); - const [formErrors, setFormErrors] = useState( - undefined - ); + const [formErrors, setFormErrors] = useState(undefined); const [isSubmitting, setIsSubmitting] = useState(false); const [isRemovingDiscount, setIsRemovingDiscount] = useState< string | undefined @@ -95,18 +114,8 @@ export function DiscountStandalone({ const isApplyDisabled = !hasInputValue || isPaymentDisabled || isSubmitting || !!isRemovingDiscount; - const resolveErrorMessage = (error: string) => { - if ( - error === t.discounts.alreadyApplied || - error === t.discounts.enterCodeValidation - ) { - return error; - } - - return t.discounts.invalid ?? enUs.discounts.invalid; - }; - const handleInputChange = (e: React.ChangeEvent) => { + // Same space-stripping behavior DiscountInput used to provide setDiscountCode(e.target.value.replace(/\s+/g, '')); setFormErrors(undefined); }; @@ -117,6 +126,8 @@ export function DiscountStandalone({ }; const handleApply = async () => { + // Validation + if (!discountCode.trim()) { setFormErrors([t.discounts.enterCodeValidation]); return; @@ -124,18 +135,22 @@ export function DiscountStandalone({ try { setIsSubmitting(true); + // Normalize the discount code to uppercase for consistency const normalizedCode = discountCode.trim(); + // Check if the code already exists if (currentDiscountCodes.includes(normalizedCode)) { setFormErrors([t.discounts.alreadyApplied]); return; } + // Apply discount with current codes + new code const newDiscountCodes = [...currentDiscountCodes, normalizedCode]; await applyDiscount.mutateAsync({ discountCodes: newDiscountCodes, }); + // Track successful discount application track({ eventId: eventIds.applyCoupon, type: TrackingEventType.CLICK, @@ -145,12 +160,17 @@ export function DiscountStandalone({ }, }); + // Call the change handler if provided onDiscountsChange?.(newDiscountCodes); + + // Reset the input setDiscountCode(''); setFormErrors(undefined); } catch (error) { if (error instanceof GraphQLErrorWithCodes) { setFormErrors(error.codes); + + // Track discount error track({ eventId: eventIds.discountError, type: TrackingEventType.EVENT, @@ -163,6 +183,8 @@ export function DiscountStandalone({ const genericError = new Error(t.discounts.failedToApply); setFormErrors([t.discounts.failedToApply]); onError?.(genericError); + + // Track generic discount error track({ eventId: eventIds.discountError, type: TrackingEventType.EVENT, @@ -186,7 +208,7 @@ export function DiscountStandalone({ const handleRemoveDiscount = async (discountToRemove: string) => { const newDiscountCodes = currentDiscountCodes.filter( - code => code !== discountToRemove + d => d !== discountToRemove ); try { @@ -195,6 +217,7 @@ export function DiscountStandalone({ discountCodes: newDiscountCodes, }); + // Track discount removal track({ eventId: eventIds.removeDiscount, type: TrackingEventType.CLICK, @@ -209,6 +232,8 @@ export function DiscountStandalone({ } catch (error) { if (error instanceof GraphQLErrorWithCodes) { setFormErrors(error.codes); + + // Track discount error track({ eventId: eventIds.discountError, type: TrackingEventType.EVENT, @@ -221,6 +246,8 @@ export function DiscountStandalone({ const genericError = new Error(t.discounts.failedToApply); setFormErrors([t.discounts.failedToApply]); onError?.(genericError); + + // Track generic discount error track({ eventId: eventIds.discountError, type: TrackingEventType.EVENT, @@ -233,30 +260,41 @@ export function DiscountStandalone({ } }; - const label = - t.discounts.haveACouponCode ?? enUs.discounts.haveACouponCode; - const primaryError = formErrors?.[0] - ? resolveErrorMessage(formErrors[0]) - : undefined; + const primaryError = (() => { + const error = formErrors?.[0]; + if (!error) return undefined; + if ( + error === t.discounts.alreadyApplied || + error === t.discounts.enterCodeValidation + ) { + return error; + } + return t.discounts.invalid ?? enUs.discounts.invalid; + })(); return (
- + - {appliedDiscounts.length > 0 ? ( + {currentDiscountCodes.length > 0 && (
- {appliedDiscounts.map(discount => ( - handleRemoveDiscount(discount.code)} - isRemoving={isRemovingDiscount === discount.code} - /> - ))} + {currentDiscountCodes.map(code => { + const amountInfo = discountAmountsByCode.get(code); + return ( + handleRemoveDiscount(code)} + isRemoving={isRemovingDiscount === code} + /> + ); + })}
- ) : null} + )}