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..4abd2f41 100644 --- a/packages/react/src/components/checkout/discount/discount-standalone.tsx +++ b/packages/react/src/components/checkout/discount/discount-standalone.tsx @@ -1,17 +1,18 @@ 'use client'; +import { enUs } from '@godaddy/localizations'; +import { Loader2, X } from 'lucide-react'; 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 { 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 { eventIds } from '@/tracking/events'; import { TrackingEventType, track } from '@/tracking/track'; -import { Discounts } from './discounts'; import type { DiscountFormProps } from './types'; export function DiscountStandalone({ @@ -20,6 +21,7 @@ export function DiscountStandalone({ onError, }: DiscountFormProps) { const { t } = useGoDaddyContext(); + const { elements } = useCheckoutContext(); const isPaymentDisabled = useIsPaymentDisabled(); const { data: draftOrder } = useDraftOrder(); @@ -67,16 +69,59 @@ export function DiscountStandalone({ 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 }); + }; + + draftOrder.discounts?.forEach(addAmount); + draftOrder.lineItems?.forEach(lineItem => { + lineItem.discounts?.forEach(addAmount); + }); + draftOrder.shippingLines?.forEach(shippingLine => { + shippingLine.discounts?.forEach(addAmount); + }); + + return amounts; + }, [draftOrder]); + const [discountCode, setDiscountCode] = useState(''); 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 handleInputChange = (e: React.ChangeEvent) => { - setDiscountCode(e.target.value); + // Same space-stripping behavior DiscountInput used to provide + setDiscountCode(e.target.value.replace(/\s+/g, '')); + setFormErrors(undefined); + }; + + const handleClearInput = () => { + setDiscountCode(''); setFormErrors(undefined); }; @@ -215,38 +260,110 @@ export function DiscountStandalone({ } }; + 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 ( -
-
-
- + + + {currentDiscountCodes.length > 0 && ( +
+ {currentDiscountCodes.map(code => { + const amountInfo = discountAmountsByCode.get(code); + return ( + handleRemoveDiscount(code)} + isRemoving={isRemovingDiscount === code} + /> + ); + })} +
+ )} + +
+
+ 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 + )} /> -
- -
- - {currentDiscountCodes.length > 0 && ( -
- + {hasError ? ( +
+
+ ) : ( + + )}
- )} + + {primaryError ? ( +

+ {primaryError} +

+ ) : null} +
); }