Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/vnext-83184-coupon-redesign.md
Original file line number Diff line number Diff line change
@@ -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).
3 changes: 3 additions & 0 deletions packages/localizations/src/enUs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { enUs } from '@godaddy/localizations';
import { screen, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import {
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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) {

@abansal2-godaddy abansal2-godaddy Aug 24, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Why a new DiscountAppliedBar instead of tweaking DiscountTag

DiscountTag is a small chip (tag icon + code + X) and has no amount. The success state in the design is a full-width green bar: check + code + – $amount + remove.

That is a different component shape, so we added this bar for DiscountStandalone rather than overloading the chip. DiscountTag / Discounts remain in the package unused by this path until a cleanup PR.

const formatCurrency = useFormatCurrency();

const formattedAmount = formatCurrency({
amount,
currencyCode,
inputInMinorUnits,
});

return (
<div
className={cn(
'flex h-14 items-center justify-between rounded-md border border-[#22C55E] bg-[#F0FDF4] px-4'
)}
>
<div className='flex items-center gap-3'>
<span className='flex h-6 w-6 items-center justify-center rounded-full bg-[#22C55E] text-white'>
<Check className='h-4 w-4' aria-hidden='true' />
</span>
<span className='text-base font-semibold text-[#15803D]'>{code}</span>
</div>

<div className='flex items-center gap-4'>
<span className='text-base font-semibold text-[#15803D]'>
– {formattedAmount}
</span>
{onRemove ? (
<>
<span
className='h-6 w-px bg-[#D1D5DB]'
aria-hidden='true'
/>
<button
type='button'
className='flex h-6 w-6 items-center justify-center text-[#111111] disabled:opacity-50'
onClick={onRemove}
disabled={isRemoving}
aria-label={`Remove ${code}`}
>
{isRemoving ? (
<Loader2 className='h-4 w-4 animate-spin' />
) : (
<X className='h-4 w-4' />
)}
</button>
</>
) : null}
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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';

@abansal2-godaddy abansal2-godaddy Aug 24, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Why these imports changed / why the old ones are gone from this file

Previously this file composed:

  • DiscountInput
  • DiscountApplyButton
  • DiscountErrorList
  • Discounts / DiscountTag

Those matched the old UX (separate input + button, large alert errors, chip tags with code only).

VNEXT-83184 needs a different UX, so those imports were removed from this path:

  • Input + Apply (or clear) must live in one bordered row → not standalone DiscountInput / DiscountApplyButton
  • Errors are a short helper under the field → not DiscountErrorList
  • Success needs code + formatted amount + remove in a full-width bar → not chip DiscountTags

The old modules are still in the package/exported for now; they just are not used by DiscountStandalone anymore. Cleanup can be a follow-up.


export function DiscountStandalone({
Expand All @@ -20,6 +21,7 @@ export function DiscountStandalone({
onError,
}: DiscountFormProps) {
const { t } = useGoDaddyContext();
const { elements } = useCheckoutContext();
const isPaymentDisabled = useIsPaymentDisabled();
const { data: draftOrder } = useDraftOrder();

Expand Down Expand Up @@ -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<string, { amount: number; currencyCode: string }>();
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<string>('');
const [formErrors, setFormErrors] = useState<string[] | undefined>(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<HTMLInputElement>) => {
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);
};

Expand Down Expand Up @@ -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 (
<div>
<div className='flex gap-2 items-start'>
<div className='flex-1 m-0'>
<DiscountInput
<div className='flex flex-col gap-2'>
<label className='text-sm font-medium text-[#111111]'>
{t.discounts.haveACouponCode ?? enUs.discounts.haveACouponCode}
</label>

{currentDiscountCodes.length > 0 && (
<div className='flex flex-col gap-2'>
{currentDiscountCodes.map(code => {
const amountInfo = discountAmountsByCode.get(code);
return (
<DiscountAppliedBar
key={code}
code={code}
amount={amountInfo?.amount ?? 0}
currencyCode={amountInfo?.currencyCode ?? 'USD'}
onRemove={() => handleRemoveDiscount(code)}
isRemoving={isRemovingDiscount === code}
/>
);
})}
</div>
)}

<div className='flex flex-col gap-1.5'>
<div
className={cn(
'flex h-14 items-center justify-between rounded-md border bg-white py-2 pl-4 pr-2',
hasError
? 'border-[#EF4444]'
: isFocused || hasInputValue
? 'border-[#2563EB]'
: 'border-[#D1D5DB]'
)}
Comment on lines +299 to +308

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Why the layout is inlined here

This combined bordered row (input + Apply, border color by default/filled/error) is the core of the redesign. Wiring the old separate input/button components into this shell would mean rewriting their APIs anyway, so the field markup lives here and keeps the four visual states in one place.

>
<input
type='text'
value={discountCode}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={() => 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
)}
/>
</div>
<DiscountApplyButton
onClick={handleApply}
isSubmitting={isSubmitting}
disabled={!discountCode.trim() || isPaymentDisabled}
className='h-12 px-4'
/>
</div>
<DiscountErrorList checkoutErrors={formErrors} />

{currentDiscountCodes.length > 0 && (
<div className='mt-2'>
<Discounts
discounts={currentDiscountCodes}
onRemove={handleRemoveDiscount}
isRemovingDiscount={isRemovingDiscount}
/>
{hasError ? (
<div className='flex items-center gap-4'>
<span className='h-6 w-px bg-[#D1D5DB]' aria-hidden='true' />
<button
type='button'
className='flex h-6 w-6 items-center justify-center text-[#111111]'
onClick={handleClearInput}
aria-label={
t.discounts.removeCoupon ?? enUs.discounts.removeCoupon
}
>
<X className='h-4 w-4' />
</button>
</div>
) : (
<button
type='button'
onClick={handleApply}
disabled={isApplyDisabled}
className={cn(
'inline-flex h-10 shrink-0 items-center justify-center rounded-md px-6 text-sm font-semibold transition-colors',
isApplyDisabled
? 'cursor-not-allowed bg-[#E5E7EB] text-[#9CA3AF]'
: 'bg-[#2563EB] text-white hover:bg-[#2563EB]/90',
elements?.button
)}
>
{isSubmitting ? (
<Loader2 className='h-4 w-4 animate-spin' />
) : (
t.discounts.apply
)}
</button>
)}
</div>
)}

{primaryError ? (
<p className='text-[13px] font-medium leading-4 text-[#DC2626]'>
{primaryError}
</p>
) : null}
</div>
</div>
);
}