'use client'; import { useState } from 'react'; import { Gift } from 'lucide-react'; import type { Checkout } from 'brainerce'; import { BrainerceError } from 'brainerce'; import { getClient } from '@/core/lib/brainerce'; import { useTranslations } from '@/core/lib/translations'; import { LoadingSpinner } from '@/ui/shared/loading-spinner'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { cn } from '@/core/lib/utils'; interface GiftCardInputProps { checkoutId: string; /** * Cards already on this checkout. Each is removed by its own tenderId. * * Derived from `Checkout` rather than written out, and deliberately NOT the * `CheckoutTender` that `applyGiftCard` returns — that one carries a third * field, `providerAmountDue`, because an apply response reports the new * amount due alongside the tender it just created. Reading a checkout puts * that figure at the top level instead, once, since it describes the whole * checkout and not any single card. Naming the source type here keeps the two * from being confused again. */ tenders: NonNullable; /** * Whether the checkout can still take a card. * * False from the moment a payment intent exists: the server refuses every * tender change on a `PAYMENT_PENDING` / `PAYMENT_PROCESSING` checkout, * because changing what a card pays underneath a created intent would leave * the provider charging the wrong amount. That refusal is correct. What was * wrong was leaving the field enabled in front of it, so a shopper typed a * good code into a box that could never accept one. */ locked: boolean; /** Formats an amount in the checkout's currency. */ formatAmount: (value: string) => string; onUpdate: () => void; className?: string; } /** * Redeem a gift card at checkout. * * ## A gift card is NOT a coupon * * This sits beside the coupon field and behaves nothing like it. A coupon * reduces what the order is worth; a gift card pays for an order that is still * worth what it was. The order total does not move, tax is unchanged, and what * drops is only the amount the payment provider is charged. * * Rendering it as a discount would misstate the order to the shopper and, on a * receipt, misstate the taxable base. That is why the applied card appears on * its own line near the total rather than inside the discount row. * * ## Checkout only, despite living in `ui/cart/` * * It sits beside `coupon-input.tsx` because a shopper reaches for both in the * same place — but a coupon has a cart-level call and a gift card does not. * `applyGiftCard` needs a checkout, so `checkoutId` is a REQUIRED prop: the type * system, not a convention, is what stops this being dropped onto a cart page. * * ## More than one card * * A checkout can carry several. Each is removed by its `tenderId`, never by the * code — the code is bearer value and is not kept anywhere after it is applied. * * ## Refusals say one thing * * The server answers identically for "no such code", "expired", "already spent" * and "wrong currency". That is deliberate: any response that distinguishes them * is an oracle someone walks the code space against. So this shows ONE message * of its own for all of them, rather than forwarding the server's text -- which * would put the API's English on a translated storefront and make the guarantee * depend on the server never adding a more helpful sentence. * * Two refusals are NOT about the card and must not read as if they were: a 5xx * or a dropped connection, and a checkout already locked for payment. Telling * someone their card is bad in either case sends them to support over a card * that works. */ export function GiftCardInput({ checkoutId, tenders, locked, formatAmount, onUpdate, className, }: GiftCardInputProps) { const t = useTranslations('giftCard'); const tc = useTranslations('common'); const [code, setCode] = useState(''); const [applying, setApplying] = useState(false); const [removingId, setRemovingId] = useState(null); const [error, setError] = useState(null); /** * `CHECKOUT_LOCKED` is the one 4xx that is not a statement about the card. * The code travels in the response body, which the SDK hands over whole as * `details`; read it defensively, since an older backend may not send one. */ function isCheckoutLocked(err: unknown): boolean { if (!(err instanceof BrainerceError)) return false; const details = err.details as { code?: unknown } | null | undefined; return details?.code === 'CHECKOUT_LOCKED'; } async function handleApply() { const trimmed = code.trim(); if (!trimmed || applying) return; try { setApplying(true); setError(null); await getClient().applyGiftCard(checkoutId, trimmed); // Cleared on success only. A rejected code stays in the field so the // shopper can fix a typo instead of re-reading it off the card. setCode(''); onUpdate(); } catch (err) { // ONE message for every rejected code, written by this store. // // Echoing the server's text put raw English on a Hebrew storefront — the // class-validator string for a too-short code came through verbatim. The // deeper reason is that a refusal must not vary: the backend answers // "expired", "revoked", "wrong currency" and "no such code" with a single // sentence on purpose, because a response that tells them apart is an // oracle for walking the code space. Forwarding whatever arrives makes // that guarantee depend on the server never adding a more helpful // message, which is not a guarantee at all. // // A 5xx or a dropped connection is NOT a refusal and must not read as // one — telling someone their card is bad when the network failed sends // them to support over a working card. Neither is a locked checkout, // which is a 400 and says nothing whatever about the code typed in. const refused = err instanceof BrainerceError && err.statusCode < 500; setError( isCheckoutLocked(err) ? t('locked') : refused ? t('invalidCode') : t('applyFailed') ); } finally { setApplying(false); } } async function handleRemove(tenderId: string) { if (removingId) return; try { setRemovingId(tenderId); setError(null); await getClient().removeGiftCard(checkoutId, tenderId); onUpdate(); } catch (err) { // Same three cases as apply, and for the same reason: the server's own // text is English, and a locked checkout is not a failed removal. setError(isCheckoutLocked(err) ? t('locked') : t('removeFailed')); } finally { setRemovingId(null); } } return (
{tenders.length > 0 && (
    {tenders.map((tender) => (
  • {/* Gone rather than disabled once the checkout is locked. The server refuses a removal at that point too, and a dimmed button still reads as something to press. */} {!locked && ( )}
  • ))}
)} {/* The field is GONE once the checkout is locked, not disabled and not silently broken. Payment starts the moment the shopper picks a shipping rate, and from then on the server refuses every tender change — correctly, since the provider is already holding an amount. The box stayed on screen and accepted typing anyway, so a shopper at the payment step could enter a perfectly good code and be told the card was no good. A card is added before payment or not at all, and saying so is kinder than a field that cannot work. */} {locked ? ( // Only to someone who has a card on this order. With nothing applied // there is nothing to explain, and unprompted gift-card copy at the // payment step is just noise. tenders.length > 0 ? (

{t('lockedHint')}

) : null ) : (
{ setCode(e.target.value); if (error) setError(null); }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleApply(); } }} placeholder={t('placeholder')} aria-label={t('placeholder')} aria-invalid={!!error} // A gift card code is Latin and case-insensitive but the server // normalises it, so the shopper may type it however it is printed. // LTR because the code is Latin even on an RTL storefront. dir="ltr" autoComplete="off" spellCheck={false} className={cn('h-9 flex-1 rounded font-mono', error && 'border-destructive')} />
)} {error && (

{error}

)}
); }