Zum Inhalt springen
UI Kit
Accent

Kundenkonto

Das Kundenkonto eines Shops. Eine Startseite mit der letzten Bestellung unterwegs, das Adressbuch, gespeicherte Zahlungsarten mit Live-Kartenvorschau, der Kontorahmen mit Menü und Sendungsverfolgung ohne Konto.

Eine von 4 Components in Konto und Treue

5 Variants

Variants

Kontoübersicht

Begrüßung mit Namen, die letzte Bestellung mit einer Spur, die sich bis zu ihrem Stand füllt, Punkte, die hochzählen, mit Balken bis zur nächsten Prämie, und Kurzwege mit Zahlen, die nacheinander aufsteigen und beim Hover ihr Icon einfärben.

Im Studio öffnen

Adressbuch

Adresskarten mit Standard-Markierungen, die wandern, sobald eine andere Adresse Standard wird. Entfernen fragt direkt in der Karte noch einmal nach, eine neue Adresse wird in einer Karte erfasst, die hereinzoomt und beim Speichern in die Liste springt.

Im Studio öffnen

Zahlungsarten

Gespeicherte Zahlungsarten als gezeichnete Karten, die sich beim Hover heben und neigen, ohne Logos von Kartenanbietern. Eine bald ablaufende Karte sagt es, „Karte hinzufügen“ öffnet ein Formular mit Live-Vorschau, die sich beim Tippen füllt und für die Prüfnummer ihre Rückseite zeigt.

Im Studio öffnen

Kontorahmen

Der Rahmen um jede Kontoseite. Auf dem Desktop ein Seitenmenü mit Zahlen und einer Markierung, die zur offenen Seite gleitet, auf Telefon und Tablet eine Tab-Reihe mit gleitender Pille, die den offenen Tab in die Mitte scrollt. Die Seite läuft bei jedem Wechsel neu ein.

Im Studio öffnen

Bestellung finden

Sendungsverfolgung ohne Konto. Bestellnummer und E-Mail rein, eine kurze Suche, dann zoomt die Bestellung mit Stand, Spur und Artikeln herein. Ein falsches Paar schüttelt das Formular und sagt, wo beides steht.

Im Studio öffnen

Details

Was Kundenkonto mitbringt, aus Registry und Quellcode gelesen.

Bereich
E-Commerce
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/customer-account.tsx
Bringt mit
utilskitbuttoniconrevealmotionsectionnumber-tickerproduct-image
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 50 eigene Texte

Props

Die Schnittstelle CustomerAccountProps aus components/ui/customer-account.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • 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

Eingebaute Texte

Diese Texte schreibt Kundenkonto selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 50 Texte anzeigen
helloEnglisch HelloDeutsch Hallo
memberSinceEnglisch Member sinceDeutsch Dabei seit
pointsEnglisch pointsDeutsch Punkte
yourPointsEnglisch Your pointsDeutsch Deine Punkte
rewardsEnglisch See all rewardsDeutsch Alle Prämien ansehen
toNextEnglisch more toDeutsch fehlen bis
latestOrderEnglisch Your latest orderDeutsch Deine letzte Bestellung
orderEnglisch OrderDeutsch Bestellung
trackEnglisch Track parcelDeutsch Sendung verfolgen
viewOrderEnglisch View orderDeutsch Bestellung ansehen
stepsEnglisch Ordered,Packed,On the way,DeliveredDeutsch Bestellt,Verpackt,Unterwegs,Zugestellt
defaultShippingEnglisch Default shippingDeutsch Standard-Lieferadresse
defaultBillingEnglisch Default billingDeutsch Standard-Rechnungsadresse
editEnglisch EditDeutsch Bearbeiten
removeEnglisch RemoveDeutsch Entfernen
removeAskEnglisch Remove this address?Deutsch Diese Adresse entfernen?
keepEnglisch Keep itDeutsch Behalten
removeYesEnglisch RemoveDeutsch Entfernen
makeDefaultEnglisch Use as defaultDeutsch Als Standard nutzen
addAddressEnglisch Add an addressDeutsch Adresse hinzufügen
newAddressEnglisch New addressDeutsch Neue Adresse
nameEnglisch Full nameDeutsch Vor- und Nachname
streetEnglisch Street and numberDeutsch Straße und Hausnummer
postcodeEnglisch PostcodeDeutsch Postleitzahl
cityEnglisch CityDeutsch Ort
labelFieldEnglisch Label, e.g. Home or WorkDeutsch Bezeichnung, z. B. Zuhause oder Büro
saveEnglisch Save addressDeutsch Adresse speichern
cancelEnglisch CancelDeutsch Abbrechen
savedEnglisch SavedDeutsch Gespeichert
expiresEnglisch ExpiresDeutsch Gültig bis
expiringSoonEnglisch Expires soonDeutsch Läuft bald ab
isDefaultEnglisch DefaultDeutsch Standard
addCardEnglisch Add a cardDeutsch Karte hinzufügen
cardNumberEnglisch Card numberDeutsch Kartennummer
expiryEnglisch ExpiryDeutsch Gültig bis
cvcEnglisch Security codeDeutsch Prüfnummer
holderEnglisch Name on cardDeutsch Name auf der Karte
saveCardEnglisch Save cardDeutsch Karte speichern
cardPreviewEnglisch Your new cardDeutsch Deine neue Karte
menuEnglisch Account menuDeutsch Kontomenü
signOutEnglisch Sign outDeutsch Abmelden
lookupTitleEnglisch Track an orderDeutsch Bestellung verfolgen
orderNumberEnglisch Order numberDeutsch Bestellnummer
emailEnglisch Email used for the orderDeutsch E-Mail der Bestellung
findEnglisch Find my orderDeutsch Bestellung finden
findingEnglisch Looking it upDeutsch Wird gesucht
notFoundEnglisch We could not find an order with this number and email. Check the order confirmation for both.Deutsch Zu dieser Nummer und E-Mail finden wir keine Bestellung. Beides steht in der Bestellbestätigung.
anotherEnglisch Track another orderDeutsch Andere Bestellung verfolgen
itemsEnglisch itemsDeutsch Artikel
itemEnglisch itemDeutsch Artikel

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Kundenkonto reagiert selbst auf die Tasten Pos1.
  • Setzt aria-current, aria-label, aria-live und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen alert, alertdialog, region, tab und tablist.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 7 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 971 Zeilen
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} />;
}

Häufige Fragen

Wie installiere ich Kundenkonto?

Mit aiw add @ai-website/customer-account. Der Befehl legt die Datei components/ui/customer-account.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, reveal, motion, section, number-ticker und product-image. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Kundenkonto?

5 Varianten, Kontoübersicht, Adressbuch, Zahlungsarten, Kontorahmen und Bestellung finden. Du wählst sie mit der Prop variant, zum Beispiel variant="addresses".

Kann ich die Texte von Kundenkonto übersetzen?

Ja. Kundenkonto bringt 50 eigene Texte auf Deutsch und Englisch mit, etwa „Hallo“, „Dabei seit“ und „Punkte“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Kundenkonto einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Kundenkonto mit aiw_components und installieren sie mit aiw_add @ai-website/customer-account. Nenn im Prompt die Variante, etwa variant="overview", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Kundenkonto ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden CustomerAccount-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert