Skip to content
UI Kit
Accent color

Customer account

The account of a shop. A start page with the latest order on its way, the address book, saved ways to pay with a live card preview, the account frame with its menu and order tracking without an account.

One of 4 components in Account and loyalty

5 variants

Variants

Account overview

Hello by name, the latest order with a track that fills to its stage, points that count up with a bar to the next reward and shortcuts with counts that rise in turn and light their icon on hover.

Open in Studio

Address book

Address cards with default badges that move when another address becomes the default. Removing asks once more in place, a new address is entered in a card that zooms in and pops into the list when saved.

Open in Studio

Payment methods

Saved ways to pay as drawn cards that lift and tilt on hover, without network logos. A card that expires soon says so, "Add a card" opens a form with a live preview that fills as you type and turns to its back for the security code.

Open in Studio

Account frame

The frame around every account page. On desktop a side menu with counts and a marker that slides to the open page, on phones and tablets a row of tabs with a sliding pill that scrolls the open tab to the middle. The page runs in on every switch.

Open in Studio

Order lookup

Tracking without an account. Order number and email in, a short search, then the order zooms in with its stage, the track and the items. A wrong pair shakes the form and says where to find both.

Open in Studio

Details

What Customer account brings along, read from the registry and the source.

Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/customer-account.tsx
Brings along
utilskitbuttoniconrevealmotionsectionnumber-tickerproduct-image
npm packages
None
Languages
English and German, 50 texts of its own

Props

The CustomerAccountProps interface from components/ui/customer-account.tsx, with the comments from the code.

  • variantoptional
    CustomerAccountVariant

    overviewaddressespayment-methodslayoutorder-lookup

  • eyebrowoptional
    string
  • titleoptional
    string
  • descriptionoptional
    string
  • customeroptional
    { name: string; since?: string; points?: number; nextReward?: { points: number; label: string }; avatar?: React.ReactNode; rewardsHref?: string }
  • orderoptional
    AccountOrder
  • shortcutsoptional
    { icon?: IconName; label: string; count?: number; href: string }[]
  • addressesoptional
    AccountAddress[]
  • methodsoptional
    PaymentMethod[]
  • navoptional
    AccountNavItem[]
  • defaultNavoptional
    string
  • defaultFoundoptional
    boolean

    order-lookup: show the found order right away (previews)

  • currencyoptional
    string

Built-in texts

Customer account writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 50 texts
helloEnglish HelloGerman Hallo
memberSinceEnglish Member sinceGerman Dabei seit
pointsEnglish pointsGerman Punkte
yourPointsEnglish Your pointsGerman Deine Punkte
rewardsEnglish See all rewardsGerman Alle Prämien ansehen
toNextEnglish more toGerman fehlen bis
latestOrderEnglish Your latest orderGerman Deine letzte Bestellung
orderEnglish OrderGerman Bestellung
trackEnglish Track parcelGerman Sendung verfolgen
viewOrderEnglish View orderGerman Bestellung ansehen
stepsEnglish Ordered,Packed,On the way,DeliveredGerman Bestellt,Verpackt,Unterwegs,Zugestellt
defaultShippingEnglish Default shippingGerman Standard-Lieferadresse
defaultBillingEnglish Default billingGerman Standard-Rechnungsadresse
editEnglish EditGerman Bearbeiten
removeEnglish RemoveGerman Entfernen
removeAskEnglish Remove this address?German Diese Adresse entfernen?
keepEnglish Keep itGerman Behalten
removeYesEnglish RemoveGerman Entfernen
makeDefaultEnglish Use as defaultGerman Als Standard nutzen
addAddressEnglish Add an addressGerman Adresse hinzufügen
newAddressEnglish New addressGerman Neue Adresse
nameEnglish Full nameGerman Vor- und Nachname
streetEnglish Street and numberGerman Straße und Hausnummer
postcodeEnglish PostcodeGerman Postleitzahl
cityEnglish CityGerman Ort
labelFieldEnglish Label, e.g. Home or WorkGerman Bezeichnung, z. B. Zuhause oder Büro
saveEnglish Save addressGerman Adresse speichern
cancelEnglish CancelGerman Abbrechen
savedEnglish SavedGerman Gespeichert
expiresEnglish ExpiresGerman Gültig bis
expiringSoonEnglish Expires soonGerman Läuft bald ab
isDefaultEnglish DefaultGerman Standard
addCardEnglish Add a cardGerman Karte hinzufügen
cardNumberEnglish Card numberGerman Kartennummer
expiryEnglish ExpiryGerman Gültig bis
cvcEnglish Security codeGerman Prüfnummer
holderEnglish Name on cardGerman Name auf der Karte
saveCardEnglish Save cardGerman Karte speichern
cardPreviewEnglish Your new cardGerman Deine neue Karte
menuEnglish Account menuGerman Kontomenü
signOutEnglish Sign outGerman Abmelden
lookupTitleEnglish Track an orderGerman Bestellung verfolgen
orderNumberEnglish Order numberGerman Bestellnummer
emailEnglish Email used for the orderGerman E-Mail der Bestellung
findEnglish Find my orderGerman Bestellung finden
findingEnglish Looking it upGerman Wird gesucht
notFoundEnglish We could not find an order with this number and email. Check the order confirmation for both.German Zu dieser Nummer und E-Mail finden wir keine Bestellung. Beides steht in der Bestellbestätigung.
anotherEnglish Track another orderGerman Andere Bestellung verfolgen
itemsEnglish itemsGerman Artikel
itemEnglish itemGerman Artikel

Accessibility

Read from the source of the component.

  • Customer account handles the keys Home itself.
  • Sets aria-current, aria-label, aria-live and aria-selected, so screen readers announce purpose and state.
  • Uses the roles alert, alertdialog, region, tab and tablist.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Moves with the scroll, its progress follows the position on the page.
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 7 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 971 lines
components/ui/customer-account.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';
import { Eyebrow, Section } from '@/components/ui/section';
import { NumberTicker } from '@/components/ui/number-ticker';
import { ProductMedia, ShopMotion, Swap, formatNumber, formatPrice } from '@/components/ui/product-image';

/*
 * The customer account of a shop.
 *
 *   overview         hello, the latest order on its way, points and shortcuts
 *   addresses        the address book: default badges, add, edit, remove with a second look
 *   payment-methods  saved cards and other ways to pay, a new card with a live preview
 *   layout           the account frame: a side menu on desktop, a sliding tab row on phones
 *   order-lookup     track an order without an account, with order number and email
 */

export type CustomerAccountVariant = 'overview' | 'addresses' | 'payment-methods' | 'layout' | 'order-lookup';

export interface AccountOrder {
  number: string;
  /** Already formatted */
  date: string;
  /** e.g. "On the way" */
  status: string;
  /** 0 ordered, 1 packed, 2 on the way, 3 delivered */
  stage: number;
  /** e.g. "Arrives Thursday, Oct 23" */
  eta?: string;
  total: number;
  images?: React.ReactNode[];
  /** Names of the steps, default from the labels */
  steps?: string[];
  href?: string;
  items?: { name: string; variant?: string; quantity: number; image?: React.ReactNode }[];
}

export interface AccountAddress {
  id: string;
  /** e.g. "Home" */
  label?: string;
  name: string;
  lines: string[];
  phone?: string;
  defaultShipping?: boolean;
  defaultBilling?: boolean;
}

export interface PaymentMethod {
  id: string;
  kind: 'card' | 'debit' | 'invoice' | 'wallet';
  /** e.g. "Card", "Direct debit" */
  label: string;
  /** Last four digits or a masked line */
  last4?: string;
  holder?: string;
  /** e.g. "08/28" */
  expires?: string;
  expiringSoon?: boolean;
  isDefault?: boolean;
  /** Card face: graphite, ocean, sand, forest, accent */
  tone?: 'graphite' | 'ocean' | 'sand' | 'forest' | 'accent';
}

export interface AccountNavItem {
  id: string;
  label: string;
  icon?: IconName;
  count?: number;
  /** What the page shows for this item */
  content?: React.ReactNode;
}

export interface CustomerAccountProps {
  variant?: CustomerAccountVariant;
  eyebrow?: string;
  title?: string;
  description?: string;
  customer?: { name: string; since?: string; points?: number; nextReward?: { points: number; label: string }; avatar?: React.ReactNode; rewardsHref?: string };
  order?: AccountOrder;
  shortcuts?: { icon?: IconName; label: string; count?: number; href: string }[];
  addresses?: AccountAddress[];
  methods?: PaymentMethod[];
  nav?: AccountNavItem[];
  defaultNav?: string;
  /** order-lookup: show the found order right away (previews) */
  defaultFound?: boolean;
  currency?: string;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    hello: 'Hello',
    memberSince: 'Member since',
    points: 'points',
    yourPoints: 'Your points',
    rewards: 'See all rewards',
    toNext: 'more to',
    latestOrder: 'Your latest order',
    order: 'Order',
    track: 'Track parcel',
    viewOrder: 'View order',
    steps: 'Ordered,Packed,On the way,Delivered',
    defaultShipping: 'Default shipping',
    defaultBilling: 'Default billing',
    edit: 'Edit',
    remove: 'Remove',
    removeAsk: 'Remove this address?',
    keep: 'Keep it',
    removeYes: 'Remove',
    makeDefault: 'Use as default',
    addAddress: 'Add an address',
    newAddress: 'New address',
    name: 'Full name',
    street: 'Street and number',
    postcode: 'Postcode',
    city: 'City',
    labelField: 'Label, e.g. Home or Work',
    save: 'Save address',
    cancel: 'Cancel',
    saved: 'Saved',
    expires: 'Expires',
    expiringSoon: 'Expires soon',
    isDefault: 'Default',
    addCard: 'Add a card',
    cardNumber: 'Card number',
    expiry: 'Expiry',
    cvc: 'Security code',
    holder: 'Name on card',
    saveCard: 'Save card',
    cardPreview: 'Your new card',
    menu: 'Account menu',
    signOut: 'Sign out',
    lookupTitle: 'Track an order',
    orderNumber: 'Order number',
    email: 'Email used for the order',
    find: 'Find my order',
    finding: 'Looking it up',
    notFound: 'We could not find an order with this number and email. Check the order confirmation for both.',
    another: 'Track another order',
    items: 'items',
    item: 'item',
  },
  de: {
    hello: 'Hallo',
    memberSince: 'Dabei seit',
    points: 'Punkte',
    yourPoints: 'Deine Punkte',
    rewards: 'Alle Prämien ansehen',
    toNext: 'fehlen bis',
    latestOrder: 'Deine letzte Bestellung',
    order: 'Bestellung',
    track: 'Sendung verfolgen',
    viewOrder: 'Bestellung ansehen',
    steps: 'Bestellt,Verpackt,Unterwegs,Zugestellt',
    defaultShipping: 'Standard-Lieferadresse',
    defaultBilling: 'Standard-Rechnungsadresse',
    edit: 'Bearbeiten',
    remove: 'Entfernen',
    removeAsk: 'Diese Adresse entfernen?',
    keep: 'Behalten',
    removeYes: 'Entfernen',
    makeDefault: 'Als Standard nutzen',
    addAddress: 'Adresse hinzufügen',
    newAddress: 'Neue Adresse',
    name: 'Vor- und Nachname',
    street: 'Straße und Hausnummer',
    postcode: 'Postleitzahl',
    city: 'Ort',
    labelField: 'Bezeichnung, z. B. Zuhause oder Büro',
    save: 'Adresse speichern',
    cancel: 'Abbrechen',
    saved: 'Gespeichert',
    expires: 'Gültig bis',
    expiringSoon: 'Läuft bald ab',
    isDefault: 'Standard',
    addCard: 'Karte hinzufügen',
    cardNumber: 'Kartennummer',
    expiry: 'Gültig bis',
    cvc: 'Prüfnummer',
    holder: 'Name auf der Karte',
    saveCard: 'Karte speichern',
    cardPreview: 'Deine neue Karte',
    menu: 'Kontomenü',
    signOut: 'Abmelden',
    lookupTitle: 'Bestellung verfolgen',
    orderNumber: 'Bestellnummer',
    email: 'E-Mail der Bestellung',
    find: 'Bestellung finden',
    finding: 'Wird gesucht',
    notFound: 'Zu dieser Nummer und E-Mail finden wir keine Bestellung. Beides steht in der Bestellbestätigung.',
    another: 'Andere Bestellung verfolgen',
    items: 'Artikel',
    item: 'Artikel',
  },
};

type Texts = (typeof TEXTS)['en'];

const FOCUS = 'outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ring)]';
const CARD = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
const FIELD = 'h-11 w-full rounded-lg border border-input bg-background px-3.5 text-[15px] text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] placeholder:text-subtle-foreground hover:border-border-strong focus:border-border-strong focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--ring)_22%,transparent)]';
const ACCENT_TEXT = 'text-[color-mix(in_oklab,var(--primary)_78%,var(--foreground))]';

function Head({ eyebrow, title, description, aside }: { eyebrow?: string; title?: React.ReactNode; description?: string; aside?: React.ReactNode }) {
  if (!title) return null;
  return (
    <div className="mb-10 flex flex-col gap-6 sm:mb-12 sm:flex-row sm:items-end sm:justify-between">
      <div className="max-w-[40rem]">
        {eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
        <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
        {description && (
          <p className="kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
            {description}
          </p>
        )}
      </div>
      {aside}
    </div>
  );
}

/** The four stages of an order as a line that fills to the current one */
function OrderTrack({ stage, steps, compact }: { stage: number; steps: string[]; compact?: boolean }) {
  const [ref, inView] = useInView<HTMLDivElement>();
  const share = steps.length > 1 ? Math.min(1, stage / (steps.length - 1)) : 1;
  return (
    <div ref={ref}>
      <div className="relative mx-[6px] h-1.5 rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]">
        <span aria-hidden className="absolute inset-0 origin-left rounded-full bg-primary transition-transform duration-[1200ms] ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${inView ? round(share, 3) : 0})` }} />
        {steps.map((_, i) => (
          <span
            key={i}
            aria-hidden
            className={cn('absolute top-1/2 size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-card transition-colors duration-500', i <= stage && inView ? 'bg-primary' : 'bg-border-strong', i === stage && inView && 'ring-4 ring-[color-mix(in_oklab,var(--primary)_22%,transparent)]')}
            style={{ left: `${round((i / Math.max(1, steps.length - 1)) * 100, 2)}%`, transitionDelay: `${i * 250}ms` }}
          />
        ))}
      </div>
      {/* Each name stands under its dot: the first starts at the edge, the last ends there */}
      <ol className={cn('relative mx-[6px] mt-3 h-4 text-[12px]', compact && 'max-sm:hidden')}>
        {steps.map((s, i) => {
          const last = i === steps.length - 1;
          return (
            <li
              key={s}
              className={cn('absolute top-0 whitespace-nowrap', i === 0 ? '-translate-x-[6px]' : last ? 'translate-x-[calc(-100%+6px)]' : '-translate-x-1/2', i === stage ? 'font-semibold text-foreground' : i < stage ? 'text-muted-foreground' : 'text-subtle-foreground')}
              style={{ left: `${round((i / Math.max(1, steps.length - 1)) * 100, 2)}%` }}
            >
              {s}
            </li>
          );
        })}
      </ol>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* overview                                                            */
/* ------------------------------------------------------------------ */

function Overview(p: CustomerAccountProps & { t: Texts }) {
  const { t, customer, order, currency } = p;
  const locale = useLocale();
  const steps = order?.steps ?? t.steps.split(',');
  const pts = customer?.points ?? 0;
  const next = customer?.nextReward;
  const [ref, inView] = useInView<HTMLDivElement>();
  return (
    <Section className={p.className}>
      <ShopMotion />
      <div className="mb-10 flex items-center gap-4 sm:mb-12 sm:gap-5">
        {customer?.avatar && <span className="kit-rise size-14 shrink-0 overflow-hidden rounded-full bg-muted shadow-[var(--shadow-sm)] sm:size-16 [&>*]:size-full [&>*]:object-cover">{customer.avatar}</span>}
        <div className="min-w-0">
          {p.eyebrow && <Eyebrow className="mb-1.5">{p.eyebrow}</Eyebrow>}
          <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">
            {t.hello}, {customer?.name.split(' ')[0]}
          </h2>
          {(customer?.since || p.description) && (
            <p className="kit-rise mt-1.5 text-[15px] text-muted-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
              {customer?.since ? `${t.memberSince} ${customer.since}` : p.description}
            </p>
          )}
        </div>
      </div>
      <div className="grid grid-cols-1 gap-4 lg:grid-cols-3 lg:gap-5">
        {order && (
          <Reveal effect="rise" className={cn(CARD, 'p-5 sm:p-6 lg:col-span-2')}>
            <div className="flex flex-wrap items-start justify-between gap-3">
              <div>
                <p className="text-[13px] text-muted-foreground">{t.latestOrder}</p>
                <p className="mt-1 text-[20px] font-semibold tracking-[-0.015em] text-foreground">{order.status}</p>
                {order.eta && <p className="mt-0.5 text-[14px] text-muted-foreground">{order.eta}</p>}
              </div>
              <p className="text-[13px] text-muted-foreground sm:text-right">
                {t.order} {order.number}
                <span className="block tabular-nums">
                  {order.date} · {formatPrice(locale, order.total, currency)}
                </span>
              </p>
            </div>
            {order.images?.length ? (
              <div className="mt-5 flex -space-x-3">
                {order.images.slice(0, 4).map((img, i) => (
                  <span key={i} className="size-14 overflow-hidden rounded-xl border-2 border-card bg-surface-2 shadow-[var(--shadow-sm)] transition-transform duration-300 hover:-translate-y-1 [&>*]:size-full" style={{ zIndex: 4 - i }}>
                    {img}
                  </span>
                ))}
              </div>
            ) : null}
            <div className="mt-6">
              <OrderTrack stage={order.stage} steps={steps} />
            </div>
            <div className="mt-6 flex flex-wrap gap-2">
              <Button href={order.href ?? '#'} iconLeft="truck">
                {t.track}
              </Button>
              <Button href={order.href ?? '#'} variant="outline">
                {t.viewOrder}
              </Button>
            </div>
          </Reveal>
        )}
        <Reveal effect="rise" delay={100} className={cn(CARD, 'relative flex flex-col overflow-hidden p-5 sm:p-6')}>
          <div ref={ref} aria-hidden data-clip-ok className="pointer-events-none absolute -right-10 -top-10 size-40 rounded-full bg-[radial-gradient(circle,color-mix(in_oklab,var(--primary)_22%,transparent),transparent_70%)]" />
          <p className="flex items-center gap-2 text-[13px] text-muted-foreground">
            <Icon name="sparkle" size={15} className={ACCENT_TEXT} />
            {t.yourPoints}
          </p>
          <p className="mt-2">
            <NumberTicker value={pts} duration={1400} className="text-[44px] font-semibold leading-none tracking-[-0.04em] text-foreground" />
          </p>
          {next && (
            <>
              <p className="mt-4 text-[13.5px] text-muted-foreground">
                <span className="font-medium tabular-nums text-foreground">{formatNumber(locale, Math.max(0, next.points - pts))}</span> {t.toNext} {next.label}
              </p>
              <span className="relative mt-2 block h-1.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" aria-hidden>
                <span className="absolute inset-0 origin-left rounded-full bg-primary transition-transform duration-[1400ms] ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${inView ? round(Math.min(1, pts / next.points), 3) : 0})` }} />
              </span>
            </>
          )}
          <a href={customer?.rewardsHref ?? '#'} className={cn('group/rw mt-auto inline-flex items-center gap-1.5 pt-6 text-[14px] font-medium text-foreground', FOCUS)}>
            {t.rewards}
            <Icon name="arrow-right" size={14} className="transition-transform duration-300 group-hover/rw:translate-x-1" />
          </a>
        </Reveal>
      </div>
      {p.shortcuts?.length ? (
        <Stagger step={60} as="ul" className="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-4 lg:mt-5 lg:gap-5">
          {p.shortcuts.map((s) => (
            <li key={s.label}>
              <a href={s.href} className={cn(CARD, 'group/sc flex h-full flex-col p-5 transition-[transform,box-shadow] duration-500 ease-[var(--ease-kit)] hover:-translate-y-1 hover:shadow-[var(--highlight),var(--shadow-lg)] motion-reduce:transition-none', FOCUS)}>
                <span className="flex items-center justify-between">
                  <span className="grid size-10 place-items-center rounded-xl bg-muted text-foreground transition-colors duration-300 group-hover/sc:bg-primary group-hover/sc:text-primary-foreground">
                    <Icon name={s.icon ?? 'arrow-right'} size={18} />
                  </span>
                  {s.count !== undefined && <span className="text-[20px] font-semibold tabular-nums text-foreground">{formatNumber(locale, s.count)}</span>}
                </span>
                <span className="mt-5 flex items-center justify-between gap-2 text-[14.5px] font-medium text-foreground">
                  {s.label}
                  <Icon name="arrow-right" size={15} className="text-muted-foreground transition-transform duration-300 group-hover/sc:translate-x-1 group-hover/sc:text-foreground" />
                </span>
              </a>
            </li>
          ))}
        </Stagger>
      ) : null}
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* addresses                                                           */
/* ------------------------------------------------------------------ */

function Addresses(p: CustomerAccountProps & { t: Texts }) {
  const { t } = p;
  const [list, setList] = useState<AccountAddress[]>(p.addresses ?? []);
  const [asking, setAsking] = useState<string | null>(null);
  const [adding, setAdding] = useState(false);
  const [fresh, setFresh] = useState<string | null>(null);
  const id = useId();
  const [form, setForm] = useState({ label: '', name: '', street: '', postcode: '', city: '' });
  const valid = form.name.trim() && form.street.trim() && form.postcode.trim() && form.city.trim();
  const makeDefault = (aid: string) => setList((l) => l.map((a) => ({ ...a, defaultShipping: a.id === aid })));
  const save = (e: React.FormEvent) => {
    e.preventDefault();
    if (!valid) return;
    const nid = `new-${list.length}`;
    setList((l) => [...l, { id: nid, label: form.label || undefined, name: form.name, lines: [form.street, `${form.postcode} ${form.city}`] }]);
    setFresh(nid);
    setAdding(false);
    setForm({ label: '', name: '', street: '', postcode: '', city: '' });
  };
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
      <ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-5">
        {list.map((a, i) => (
          <Reveal as="li" key={a.id} effect="rise" delay={Math.min(i, 5) * 70}>
            <article className={cn(CARD, 'relative flex h-full flex-col p-5 sm:p-6', (a.defaultShipping || a.defaultBilling) && 'shadow-[var(--highlight),0_0_0_1px_var(--border-strong),var(--shadow-sm)]', fresh === a.id && 'animate-[kitshop-pop_0.5s_var(--ease-kit)]')}>
              <div className="flex flex-wrap items-center gap-2">
                <span className="grid size-9 place-items-center rounded-lg bg-muted text-foreground">
                  <Icon name={a.label?.match(/büro|work|office/i) ? 'briefcase' : 'home'} size={16} />
                </span>
                {a.label && <span className="text-[14px] font-semibold text-foreground">{a.label}</span>}
              </div>
              <address className="mt-4 text-[14.5px] not-italic leading-relaxed text-muted-foreground">
                <span className="font-medium text-foreground">{a.name}</span>
                {a.lines.map((l) => (
                  <span key={l} className="block">
                    {l}
                  </span>
                ))}
                {a.phone && <span className="mt-1 block tabular-nums">{a.phone}</span>}
              </address>
              {(a.defaultShipping || a.defaultBilling) && (
                <div className="mt-4 flex flex-wrap gap-1.5">
                  {a.defaultShipping && (
                    <span className="inline-flex animate-[kitshop-fade_0.4s] items-center gap-1 rounded-full bg-[color-mix(in_oklab,var(--primary)_13%,transparent)] px-2.5 py-1 text-[12px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
                      <Icon name="truck" size={12} />
                      {t.defaultShipping}
                    </span>
                  )}
                  {a.defaultBilling && (
                    <span className="inline-flex animate-[kitshop-fade_0.4s] items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-[12px] font-medium text-foreground">
                      <Icon name="receipt" size={12} />
                      {t.defaultBilling}
                    </span>
                  )}
                </div>
              )}
              <div className="mt-auto pt-5">
                {asking === a.id ? (
                  <div className="flex animate-[kitshop-in_0.3s_var(--ease-kit)] flex-wrap items-center justify-between gap-2 rounded-xl bg-[color-mix(in_oklab,var(--destructive)_9%,transparent)] p-2 ps-3" role="alertdialog" aria-label={t.removeAsk}>
                    <span className="text-[13.5px] font-medium text-foreground">{t.removeAsk}</span>
                    <span className="flex gap-1.5">
                      <Button size="sm" variant="ghost" onClick={() => setAsking(null)}>
                        {t.keep}
                      </Button>
                      <Button size="sm" variant="destructive" onClick={() => { setList((l) => l.filter((x) => x.id !== a.id)); setAsking(null); }}>
                        {t.removeYes}
                      </Button>
                    </span>
                  </div>
                ) : (
                  <div className="flex items-center gap-x-1 border-t border-border pt-3">
                    <Button size="sm" variant="ghost" iconLeft="edit" className="h-11 sm:h-9">
                      {t.edit}
                    </Button>
                    {!a.defaultShipping && (
                      <Button size="sm" variant="ghost" iconLeft="check-circle" className="h-11 sm:h-9" onClick={() => makeDefault(a.id)}>
                        {t.makeDefault}
                      </Button>
                    )}
                    <Button size="sm" variant="ghost" iconLeft="trash" className="ms-auto size-11 justify-center px-0 text-muted-foreground sm:size-9" onClick={() => setAsking(a.id)} aria-label={`${t.remove}, ${a.label ?? a.name}`} title={t.remove}>
                      <span className="sr-only">{t.remove}</span>
                    </Button>
                  </div>
                )}
              </div>
            </article>
          </Reveal>
        ))}
        <li>
          {adding ? (
            <form onSubmit={save} className={cn(CARD, 'h-full animate-[kitshop-zoom_0.4s_var(--ease-kit)] p-5 sm:p-6')}>
              <p className="text-[15px] font-semibold text-foreground">{t.newAddress}</p>
              <div className="mt-4 space-y-3">
                {(
                  [
                    ['label', t.labelField, 'off'],
                    ['name', t.name, 'name'],
                    ['street', t.street, 'street-address'],
                  ] as const
                ).map(([k, label, ac]) => (
                  <div key={k}>
                    <label htmlFor={`${id}-${k}`} className="sr-only">
                      {label}
                    </label>
                    <input id={`${id}-${k}`} value={form[k]} onChange={(e) => setForm((f) => ({ ...f, [k]: e.target.value }))} placeholder={label} autoComplete={ac} className={FIELD} />
                  </div>
                ))}
                <div className="grid grid-cols-[7rem_minmax(0,1fr)] gap-3">
                  <label htmlFor={`${id}-pc`} className="sr-only">
                    {t.postcode}
                  </label>
                  <input id={`${id}-pc`} value={form.postcode} onChange={(e) => setForm((f) => ({ ...f, postcode: e.target.value }))} placeholder={t.postcode} autoComplete="postal-code" inputMode="numeric" className={FIELD} />
                  <label htmlFor={`${id}-city`} className="sr-only">
                    {t.city}
                  </label>
                  <input id={`${id}-city`} value={form.city} onChange={(e) => setForm((f) => ({ ...f, city: e.target.value }))} placeholder={t.city} autoComplete="address-level2" className={FIELD} />
                </div>
              </div>
              <div className="mt-4 flex gap-2">
                <Button type="submit" disabled={!valid} iconLeft="check">
                  {t.save}
                </Button>
                <Button type="button" variant="ghost" onClick={() => setAdding(false)}>
                  {t.cancel}
                </Button>
              </div>
            </form>
          ) : (
            <button
              type="button"
              onClick={() => setAdding(true)}
              className={cn('group/add flex h-full min-h-[5rem] w-full items-center justify-center gap-3 rounded-2xl border border-dashed border-border-strong text-muted-foreground transition-[background-color,border-color,color] duration-300 hover:border-foreground hover:bg-surface hover:text-foreground sm:min-h-[15rem] sm:flex-col', FOCUS)}
            >
              <span className="grid size-10 place-items-center rounded-full bg-muted transition-transform duration-500 ease-[var(--ease-kit)] group-hover/add:rotate-90">
                <Icon name="plus" size={18} />
              </span>
              <span className="text-[15px] font-medium">{t.addAddress}</span>
            </button>
          )}
        </li>
      </ul>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* payment-methods                                                     */
/* ------------------------------------------------------------------ */

const FACE: Record<NonNullable<PaymentMethod['tone']>, string> = {
  graphite: 'linear-gradient(135deg, oklch(0.36 0.012 260), oklch(0.22 0.01 260))',
  ocean: 'linear-gradient(135deg, oklch(0.56 0.085 236), oklch(0.36 0.07 240))',
  sand: 'linear-gradient(135deg, oklch(0.84 0.045 78), oklch(0.68 0.06 70))',
  forest: 'linear-gradient(135deg, oklch(0.5 0.06 162), oklch(0.32 0.05 165))',
  accent: 'linear-gradient(135deg, color-mix(in oklab, var(--primary) 90%, white), color-mix(in oklab, var(--primary) 70%, black))',
};

/** A drawn payment card: chip, masked number, holder and expiry. No network logo, the label says what it is. */
function CardFace({ m, back, code, className }: { m: PaymentMethod; back?: boolean; code?: string; className?: string }) {
  const light = m.tone === 'sand';
  const ink = light ? 'text-[oklch(0.25_0.02_70)]' : 'text-white';
  return (
    <div className={cn('relative aspect-[1.586] w-full overflow-hidden rounded-2xl p-5 shadow-[0_18px_40px_-18px_rgb(0_0_0/0.55),inset_0_1px_0_rgb(255_255_255/0.25)]', ink, className)} style={{ background: FACE[m.tone ?? 'graphite'] }}>
      <div aria-hidden data-clip-ok className="absolute -right-16 -top-20 size-56 rounded-full bg-white/10" />
      <div aria-hidden data-clip-ok className="absolute -bottom-24 -left-10 size-56 rounded-full bg-black/10" />
      {back ? (
        <div className="absolute inset-0 animate-[kitshop-fade_0.35s]">
          <div className="mt-6 h-10 bg-black/70" />
          <div className="mx-5 mt-4 flex h-9 items-center justify-end rounded-md bg-white/85 px-3 text-[15px] font-semibold tracking-[0.2em] tabular-nums text-[oklch(0.25_0.02_260)]">{code || '•••'}</div>
        </div>
      ) : (
        <div className="relative flex h-full flex-col">
          <div className="flex items-start justify-between">
            <span className="text-[13px] font-semibold tracking-[0.02em] opacity-90">{m.label}</span>
            {m.kind === 'card' && (
              <svg viewBox="0 0 24 24" className="size-6 opacity-80" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden>
                <path d="M8.5 7.5a6.5 6.5 0 0 1 0 9M12 5.5a9.5 9.5 0 0 1 0 13M15.5 3.5a12.5 12.5 0 0 1 0 17" />
              </svg>
            )}
          </div>
          {m.kind === 'card' || m.kind === 'debit' ? (
            <span aria-hidden className="mt-3 block h-7 w-10 rounded-md bg-[linear-gradient(135deg,oklch(0.88_0.08_85),oklch(0.72_0.09_75))] shadow-[inset_0_0_0_1px_rgb(0_0_0/0.15)]" />
          ) : (
            <Icon name={m.kind === 'invoice' ? 'receipt' : 'wallet'} size={26} className="mt-3 opacity-80" />
          )}
          <p className="mt-auto text-[17px] font-medium tracking-[0.12em] tabular-nums sm:text-[18px]">{m.last4 ? `•••• ${m.last4}` : ''}</p>
          <div className="mt-2 flex items-end justify-between gap-3 text-[12px]">
            <span className="truncate uppercase tracking-[0.06em] opacity-85">{m.holder}</span>
            {m.expires && <span className="shrink-0 tabular-nums opacity-85">{m.expires}</span>}
          </div>
        </div>
      )}
    </div>
  );
}

const groups = (digits: string) => digits.replace(/\D/g, '').slice(0, 16).replace(/(.{4})/g, '$1 ').trim();

function PaymentMethods(p: CustomerAccountProps & { t: Texts }) {
  const { t } = p;
  const [list, setList] = useState<PaymentMethod[]>(p.methods ?? []);
  const [adding, setAdding] = useState(false);
  const [num, setNum] = useState('');
  const [exp, setExp] = useState('');
  const [cvc, setCvc] = useState('');
  const [holder, setHolder] = useState('');
  const [cvcFocus, setCvcFocus] = useState(false);
  const [saved, setSaved] = useState<string | null>(null);
  const id = useId();
  const digits = num.replace(/\D/g, '');
  const valid = digits.length >= 15 && /^\d{2}\/\d{2}$/.test(exp) && cvc.length >= 3 && holder.trim().length > 2;
  const preview: PaymentMethod = { id: 'preview', kind: 'card', label: t.cardPreview, last4: digits.length >= 4 ? digits.slice(-4) : undefined, holder: holder || ' ', expires: exp || 'MM/YY', tone: 'accent' };
  const save = (e: React.FormEvent) => {
    e.preventDefault();
    if (!valid) return;
    const nid = `card-${list.length}`;
    setList((l) => [...l, { ...preview, id: nid, label: list.find((x) => x.kind === 'card')?.label ?? preview.label, tone: 'forest' }]);
    setSaved(nid);
    setAdding(false);
    setNum('');
    setExp('');
    setCvc('');
    setHolder('');
  };
  const makeDefault = (mid: string) => setList((l) => l.map((x) => ({ ...x, isDefault: x.id === mid })));
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head
        eyebrow={p.eyebrow}
        title={p.title}
        description={p.description}
        aside={
          !adding && (
            <Button variant="outline" iconLeft="plus" onClick={() => setAdding(true)}>
              {t.addCard}
            </Button>
          )
        }
      />
      {adding && (
        <form onSubmit={save} className={cn(CARD, 'mb-8 grid animate-[kitshop-zoom_0.45s_var(--ease-kit)] grid-cols-1 gap-8 p-5 sm:p-6 md:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] md:items-center lg:gap-12 lg:p-8')}>
          <div className="mx-auto w-full max-w-[20rem]">
            <CardFace m={preview} back={cvcFocus} code={cvc} />
          </div>
          <div className="grid grid-cols-2 gap-3">
            <div className="col-span-2">
              <label htmlFor={`${id}-num`} className="mb-1.5 block text-[13.5px] font-medium text-foreground">
                {t.cardNumber}
              </label>
              <input id={`${id}-num`} value={num} onChange={(e) => setNum(groups(e.target.value))} inputMode="numeric" autoComplete="cc-number" placeholder="1234 5678 9012 3456" className={cn(FIELD, 'tabular-nums tracking-[0.06em]')} />
            </div>
            <div>
              <label htmlFor={`${id}-exp`} className="mb-1.5 block text-[13.5px] font-medium text-foreground">
                {t.expiry}
              </label>
              <input
                id={`${id}-exp`}
                value={exp}
                onChange={(e) => {
                  const d = e.target.value.replace(/\D/g, '').slice(0, 4);
                  setExp(d.length > 2 ? `${d.slice(0, 2)}/${d.slice(2)}` : d);
                }}
                inputMode="numeric"
                autoComplete="cc-exp"
                placeholder="MM/YY"
                className={cn(FIELD, 'tabular-nums')}
              />
            </div>
            <div>
              <label htmlFor={`${id}-cvc`} className="mb-1.5 block text-[13.5px] font-medium text-foreground">
                {t.cvc}
              </label>
              <input id={`${id}-cvc`} value={cvc} onChange={(e) => setCvc(e.target.value.replace(/\D/g, '').slice(0, 4))} onFocus={() => setCvcFocus(true)} onBlur={() => setCvcFocus(false)} inputMode="numeric" autoComplete="cc-csc" placeholder="123" className={cn(FIELD, 'tabular-nums')} />
            </div>
            <div className="col-span-2">
              <label htmlFor={`${id}-holder`} className="mb-1.5 block text-[13.5px] font-medium text-foreground">
                {t.holder}
              </label>
              <input id={`${id}-holder`} value={holder} onChange={(e) => setHolder(e.target.value)} autoComplete="cc-name" className={FIELD} />
            </div>
            <div className="col-span-2 mt-1 flex gap-2">
              <Button type="submit" disabled={!valid} iconLeft="lock">
                {t.saveCard}
              </Button>
              <Button type="button" variant="ghost" onClick={() => setAdding(false)}>
                {t.cancel}
              </Button>
            </div>
          </div>
        </form>
      )}
      <ul className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6">
        {list.map((m, i) => (
          <Reveal as="li" key={m.id} effect="rise" delay={Math.min(i, 5) * 80}>
            <div className={cn('group/pm', saved === m.id && 'animate-[kitshop-pop_0.5s_var(--ease-kit)]')}>
              <div className="transition-transform duration-500 ease-[var(--ease-kit)] group-hover/pm:-translate-y-1.5 group-hover/pm:rotate-[-1deg] motion-reduce:transition-none">
                <CardFace m={m} />
              </div>
              <div className="mt-4 flex items-center justify-between gap-3">
                <div className="min-w-0">
                  <p className="flex flex-wrap items-center gap-2 text-[14.5px] font-medium text-foreground">
                    {m.label}
                    {m.last4 && <span className="tabular-nums text-muted-foreground">·· {m.last4}</span>}
                  </p>
                  <p className={cn('text-[12.5px]', m.expiringSoon ? 'font-medium text-warning-foreground' : 'text-muted-foreground')}>
                    {m.expiringSoon ? `${t.expiringSoon} · ${m.expires}` : m.expires ? `${t.expires} ${m.expires}` : m.holder}
                  </p>
                </div>
                {m.isDefault ? (
                  <span className="inline-flex shrink-0 animate-[kitshop-fade_0.4s] items-center gap-1 rounded-full bg-[color-mix(in_oklab,var(--primary)_13%,transparent)] px-2.5 py-1 text-[12px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
                    <Icon name="check" size={12} />
                    {t.isDefault}
                  </span>
                ) : (
                  <Button size="sm" variant="ghost" className="h-11 shrink-0 sm:h-9" onClick={() => makeDefault(m.id)}>
                    {t.makeDefault}
                  </Button>
                )}
              </div>
            </div>
          </Reveal>
        ))}
      </ul>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* layout                                                              */
/* ------------------------------------------------------------------ */

function Layout(p: CustomerAccountProps & { t: Texts }) {
  const { t, customer } = p;
  const locale = useLocale();
  const nav = p.nav ?? [];
  const [active, setActive] = useState(p.defaultNav ?? nav[0]?.id ?? '');
  const item = nav.find((n) => n.id === active) ?? nav[0];
  const side = useRef<HTMLUListElement>(null);
  const row = useRef<HTMLDivElement>(null);
  // The fade sits only where more tabs follow, at the end of the row it moves to the start
  const [fade, setFade] = useState('kit-fade-end');
  useEffect(() => {
    const el = row.current;
    if (!el) return;
    const update = () => {
      const max = el.scrollWidth - el.clientWidth;
      setFade(max <= 2 ? '' : el.scrollLeft <= 2 ? 'kit-fade-end' : el.scrollLeft >= max - 2 ? 'kit-fade-start' : 'kit-fade-x');
    };
    update();
    el.addEventListener('scroll', update, { passive: true });
    window.addEventListener('resize', update);
    return () => {
      el.removeEventListener('scroll', update);
      window.removeEventListener('resize', update);
    };
  }, []);
  const [mark, setMark] = useState<{ y: number; h: number; ready: boolean } | null>(null);
  const [pill, setPill] = useState<{ x: number; w: number; ready: boolean } | null>(null);
  const index = nav.findIndex((n) => n.id === active);
  useEffect(() => {
    const update = () => {
      const li = side.current?.children[index] as HTMLElement | undefined;
      if (li) setMark((m) => ({ y: li.offsetTop, h: li.offsetHeight, ready: !!m }));
      const b = row.current?.querySelectorAll<HTMLElement>('[data-tab]')[index];
      if (b) {
        setPill((m) => ({ x: b.offsetLeft, w: b.offsetWidth, ready: !!m }));
        const r = row.current!;
        r.scrollTo({ left: b.offsetLeft - (r.clientWidth - b.offsetWidth) / 2, behavior: 'smooth' });
      }
    };
    update();
    const ro = new ResizeObserver(update);
    if (side.current) ro.observe(side.current);
    if (row.current) ro.observe(row.current);
    return () => ro.disconnect();
  }, [index]);
  return (
    <Section className={p.className}>
      <ShopMotion />
      <div className="grid grid-cols-1 gap-6 lg:grid-cols-[16rem_minmax(0,1fr)] lg:gap-12">
        <aside className="lg:sticky lg:top-8 lg:self-start">
          {customer && (
            <div className="mb-5 flex items-center gap-3 lg:mb-6">
              <span className="grid size-11 shrink-0 place-items-center overflow-hidden rounded-full bg-muted text-[14px] font-semibold text-foreground [&>*]:size-full">{customer.avatar ?? customer.name.charAt(0)}</span>
              <span className="min-w-0">
                <span className="block truncate text-[15px] font-semibold text-foreground">{customer.name}</span>
                {customer.since && (
                  <span className="block truncate text-[12.5px] text-muted-foreground">
                    {t.memberSince} {customer.since}
                  </span>
                )}
              </span>
            </div>
          )}
          {/* Desktop: a side menu with a marker that slides to the open page */}
          <nav aria-label={t.menu} className="hidden lg:block">
            <ul ref={side} className="relative space-y-0.5">
              {mark && <span aria-hidden className={cn('absolute inset-x-0 left-0 top-0 rounded-lg bg-muted', mark.ready && 'transition-[transform,height] duration-400 ease-[var(--ease-kit)] motion-reduce:transition-none')} style={{ height: mark.h, transform: `translateY(${mark.y}px)` }} />}
              {nav.map((n) => {
                const on = n.id === active;
                return (
                  <li key={n.id}>
                    <button type="button" aria-current={on ? 'page' : undefined} onClick={() => setActive(n.id)} className={cn('relative flex h-11 w-full items-center gap-3 rounded-lg px-3 text-[14.5px] transition-colors', on ? (mark ? 'font-medium text-foreground' : 'bg-muted font-medium text-foreground') : 'text-muted-foreground hover:text-foreground', FOCUS)}>
                      <Icon name={n.icon ?? 'arrow-right'} size={17} />
                      <span className="flex-1 text-left">{n.label}</span>
                      {n.count !== undefined && <span className="rounded-full bg-background px-2 py-0.5 text-[12px] tabular-nums text-muted-foreground">{formatNumber(locale, n.count)}</span>}
                    </button>
                  </li>
                );
              })}
            </ul>
            <div className="mt-4 border-t border-border pt-4">
              <button type="button" className={cn('flex h-11 w-full items-center gap-3 rounded-lg px-3 text-[14.5px] text-muted-foreground transition-colors hover:text-foreground', FOCUS)}>
                <Icon name="log-out" size={17} />
                {t.signOut}
              </button>
            </div>
          </nav>
          {/* Phones and tablets: a row of tabs with a sliding pill, the open one scrolls to the middle */}
          <div ref={row} role="tablist" aria-label={t.menu} className={cn('kit-no-scrollbar relative -mx-6 flex gap-1 overflow-x-auto px-6 py-1 lg:hidden', fade)}>
            {pill && <span aria-hidden className={cn('absolute bottom-1 left-0 top-1 rounded-full bg-foreground', pill.ready && 'transition-[transform,width] duration-400 ease-[var(--ease-kit)] motion-reduce:transition-none')} style={{ width: pill.w, transform: `translateX(${pill.x}px)` }} />}
            {nav.map((n) => {
              const on = n.id === active;
              return (
                <button
                  key={n.id}
                  data-tab
                  type="button"
                  role="tab"
                  aria-selected={on}
                  onClick={() => setActive(n.id)}
                  className={cn('relative z-10 inline-flex h-11 shrink-0 items-center gap-2 rounded-full px-4 text-[14px] font-medium transition-colors duration-300', on ? (pill ? 'text-background' : 'bg-foreground text-background') : 'text-muted-foreground', FOCUS)}
                >
                  <Icon name={n.icon ?? 'arrow-right'} size={15} />
                  {n.label}
                  {n.count !== undefined && <span className="tabular-nums opacity-70">{formatNumber(locale, n.count)}</span>}
                </button>
              );
            })}
          </div>
        </aside>
        <div className="min-w-0">
          <div key={active} className="animate-[kitshop-in_0.45s_var(--ease-kit)]" role="region" aria-label={item?.label}>
            <h2 className="text-h3 font-semibold text-foreground">{item?.label}</h2>
            <div className="mt-6">{item?.content}</div>
          </div>
          <button type="button" className={cn('mt-8 flex h-11 items-center gap-2 rounded-lg text-[14.5px] text-muted-foreground transition-colors hover:text-foreground lg:hidden', FOCUS)}>
            <Icon name="log-out" size={17} />
            {t.signOut}
          </button>
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* order-lookup                                                        */
/* ------------------------------------------------------------------ */

function OrderLookup(p: CustomerAccountProps & { t: Texts }) {
  const { t, order } = p;
  const [state, setState] = useState<'idle' | 'finding' | 'found' | 'missing'>(p.defaultFound ? 'found' : 'idle');
  const [num, setNum] = useState(p.defaultFound ? order?.number ?? '' : '');
  const [email, setEmail] = useState('');
  const [shake, setShake] = useState(0);
  const id = useId();
  const steps = order?.steps ?? t.steps.split(',');
  const submit = (e: React.FormEvent) => {
    e.preventDefault();
    setState('finding');
    window.setTimeout(() => {
      const ok = !!order && num.replace(/\s|#/g, '').toLowerCase() === order.number.replace(/\s|#/g, '').toLowerCase() && /\S+@\S+/.test(email);
      setState(ok ? 'found' : 'missing');
      if (!ok) setShake((s) => s + 1);
    }, 800);
  };
  return (
    <Section className={p.className}>
      <ShopMotion />
      <div className="mx-auto max-w-[40rem]">
        <div className="text-center">
          {p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
          <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{p.title ?? t.lookupTitle}</h2>
          {p.description && (
            <p className="kit-rise mx-auto mt-4 max-w-[32rem] text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
              {p.description}
            </p>
          )}
        </div>
        {state !== 'found' ? (
          <form onSubmit={submit} key={`f${shake}`} className={cn(CARD, 'mt-10 p-5 sm:p-7', state === 'missing' && 'animate-[kitshop-shake_0.4s_var(--ease-kit)]')}>
            <div className="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,0.8fr)_minmax(0,1fr)]">
              <div>
                <label htmlFor={`${id}-n`} className="mb-1.5 block text-[13.5px] font-medium text-foreground">
                  {t.orderNumber}
                </label>
                <input id={`${id}-n`} value={num} onChange={(e) => setNum(e.target.value)} placeholder={order?.number} className={cn(FIELD, 'tabular-nums')} />
              </div>
              <div>
                <label htmlFor={`${id}-e`} className="mb-1.5 block text-[13.5px] font-medium text-foreground">
                  {t.email}
                </label>
                <input id={`${id}-e`} type="email" value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" className={FIELD} />
              </div>
            </div>
            {state === 'missing' && (
              <p role="alert" className="mt-4 flex items-start gap-2 text-[13.5px] text-destructive">
                <Icon name="alert-circle" size={15} className="mt-0.5 shrink-0" />
                {t.notFound}
              </p>
            )}
            <Button type="submit" size="lg" className="mt-5 w-full" iconLeft={state === 'finding' ? 'loader' : 'search'} disabled={state === 'finding' || !num.trim() || !email.trim()}>
              <Swap value={state === 'finding'}>{state === 'finding' ? t.finding : t.find}</Swap>
            </Button>
          </form>
        ) : (
          order && (
            <div className={cn(CARD, 'mt-10 animate-[kitshop-zoom_0.5s_var(--ease-kit)] overflow-hidden')} aria-live="polite">
              <div className="p-5 sm:p-7">
                <div className="flex flex-wrap items-start justify-between gap-3">
                  <div>
                    <p className="text-[13px] text-muted-foreground">
                      {t.order} {order.number} · {order.date}
                    </p>
                    <p className="mt-1 text-[24px] font-semibold tracking-[-0.02em] text-foreground">{order.status}</p>
                    {order.eta && <p className="mt-0.5 text-[14.5px] text-muted-foreground">{order.eta}</p>}
                  </div>
                  <span className="grid size-12 place-items-center rounded-full bg-[color-mix(in_oklab,var(--primary)_13%,transparent)] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
                    <Icon name="truck" size={21} className="animate-[kit-float_3s_ease-in-out_infinite] motion-reduce:animate-none" />
                  </span>
                </div>
                <div className="mt-7">
                  <OrderTrack stage={order.stage} steps={steps} />
                </div>
              </div>
              {order.items?.length ? (
                <ul className="divide-y divide-border border-t border-border">
                  {order.items.map((it, i) => (
                    <li key={it.name + i} className="flex items-center gap-4 px-5 py-4 sm:px-7">
                      <ProductMedia className="size-14 shrink-0 rounded-lg">{it.image}</ProductMedia>
                      <div className="min-w-0 flex-1">
                        <p className="truncate text-[14.5px] font-medium text-foreground">{it.name}</p>
                        {it.variant && <p className="truncate text-[13px] text-muted-foreground">{it.variant}</p>}
                      </div>
                      <span className="text-[13.5px] tabular-nums text-muted-foreground">× {it.quantity}</span>
                    </li>
                  ))}
                </ul>
              ) : null}
              <div className="flex flex-col gap-2 border-t border-border bg-surface px-5 py-4 sm:flex-row sm:flex-wrap sm:px-7">
                <Button href={order.href ?? '#'} iconLeft="truck">
                  {t.track}
                </Button>
                <Button variant="ghost" onClick={() => { setState('idle'); setNum(''); setEmail(''); }}>
                  {t.another}
                </Button>
              </div>
            </div>
          )
        )}
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* The block                                                           */
/* ------------------------------------------------------------------ */

/**
 * The customer account in five layouts. overview: the start page.
 * addresses: the address book. payment-methods: saved ways to pay.
 * layout: the account frame with its menu. order-lookup: tracking without an account.
 */
export function CustomerAccount(props: CustomerAccountProps) {
  const { variant = 'overview' } = props;
  const t = useTexts(TEXTS, props.labels);
  if (variant === 'addresses') return <Addresses {...props} t={t} />;
  if (variant === 'payment-methods') return <PaymentMethods {...props} t={t} />;
  if (variant === 'layout') return <Layout {...props} t={t} />;
  if (variant === 'order-lookup') return <OrderLookup {...props} t={t} />;
  return <Overview {...props} t={t} />;
}

Frequent questions

How do I install Customer account?

With aiw add @ai-website/customer-account. The command puts components/ui/customer-account.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, motion, section, number-ticker and product-image. It needs no further npm packages.

Which variants does Customer account have?

5 variants, Account overview, Address book, Payment methods, Account frame and Order lookup. You pick one with the variant prop, for example variant="addresses".

Can I translate the texts of Customer account?

Yes. Customer account carries 50 texts of its own in English and German, such as “Hello”, “Member since” and “points”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Customer account?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Customer account with aiw_components and install it with aiw_add @ai-website/customer-account. Name the variant in your prompt, for example variant="overview", and the assistant writes your content straight into the props.

Can I adjust Customer account without code?

Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching CustomerAccount code to copy.

Often searched

Jump to

Copied to the clipboard