-
Notifications
You must be signed in to change notification settings - Fork 58
VNEXT-83184: Redesign checkout coupon UI #1439
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
base: main
Are you sure you want to change the base?
Changes from all commits
6fd796e
29c7173
e965e3e
42dee48
7a9ff3e
51c4f09
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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). |
| 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) { | ||
| 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'; | ||
|
Author
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. Why these imports changed / why the old ones are gone from this file Previously this file composed:
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:
The old modules are still in the package/exported for now; they just are not used by |
||
|
|
||
| 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<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); | ||
| }; | ||
|
|
||
|
|
@@ -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
Author
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. 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> | ||
| ); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
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.
Why a new
DiscountAppliedBarinstead of tweakingDiscountTagDiscountTagis 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
DiscountStandalonerather than overloading the chip.DiscountTag/Discountsremain in the package unused by this path until a cleanup PR.