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
Variants
Details
Was Kundenkonto mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Konto und Treue
- 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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle CustomerAccountProps aus components/ui/customer-account.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalCustomerAccountVariantoverviewaddressespayment-methodslayoutorder-lookup
eyebrowoptionalstringtitleoptionalstringdescriptionoptionalstringcustomeroptional{ name: string; since?: string; points?: number; nextReward?: { points: number; label: string }; avatar?: React.ReactNode; rewardsHref?: string }orderoptionalAccountOrdershortcutsoptional{ icon?: IconName; label: string; count?: number; href: string }[]addressesoptionalAccountAddress[]methodsoptionalPaymentMethod[]navoptionalAccountNavItem[]defaultNavoptionalstringdefaultFoundoptionalbooleanorder-lookup: show the found order right away (previews)
currencyoptionalstring
Eingebaute Texte
Diese Texte schreibt Kundenkonto selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 50 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| hello | Englisch Hello | Deutsch Hallo |
| memberSince | Englisch Member since | Deutsch Dabei seit |
| points | Englisch points | Deutsch Punkte |
| yourPoints | Englisch Your points | Deutsch Deine Punkte |
| rewards | Englisch See all rewards | Deutsch Alle Prämien ansehen |
| toNext | Englisch more to | Deutsch fehlen bis |
| latestOrder | Englisch Your latest order | Deutsch Deine letzte Bestellung |
| order | Englisch Order | Deutsch Bestellung |
| track | Englisch Track parcel | Deutsch Sendung verfolgen |
| viewOrder | Englisch View order | Deutsch Bestellung ansehen |
| steps | Englisch Ordered,Packed,On the way,Delivered | Deutsch Bestellt,Verpackt,Unterwegs,Zugestellt |
| defaultShipping | Englisch Default shipping | Deutsch Standard-Lieferadresse |
| defaultBilling | Englisch Default billing | Deutsch Standard-Rechnungsadresse |
| edit | Englisch Edit | Deutsch Bearbeiten |
| remove | Englisch Remove | Deutsch Entfernen |
| removeAsk | Englisch Remove this address? | Deutsch Diese Adresse entfernen? |
| keep | Englisch Keep it | Deutsch Behalten |
| removeYes | Englisch Remove | Deutsch Entfernen |
| makeDefault | Englisch Use as default | Deutsch Als Standard nutzen |
| addAddress | Englisch Add an address | Deutsch Adresse hinzufügen |
| newAddress | Englisch New address | Deutsch Neue Adresse |
| name | Englisch Full name | Deutsch Vor- und Nachname |
| street | Englisch Street and number | Deutsch Straße und Hausnummer |
| postcode | Englisch Postcode | Deutsch Postleitzahl |
| city | Englisch City | Deutsch Ort |
| labelField | Englisch Label, e.g. Home or Work | Deutsch Bezeichnung, z. B. Zuhause oder Büro |
| save | Englisch Save address | Deutsch Adresse speichern |
| cancel | Englisch Cancel | Deutsch Abbrechen |
| saved | Englisch Saved | Deutsch Gespeichert |
| expires | Englisch Expires | Deutsch Gültig bis |
| expiringSoon | Englisch Expires soon | Deutsch Läuft bald ab |
| isDefault | Englisch Default | Deutsch Standard |
| addCard | Englisch Add a card | Deutsch Karte hinzufügen |
| cardNumber | Englisch Card number | Deutsch Kartennummer |
| expiry | Englisch Expiry | Deutsch Gültig bis |
| cvc | Englisch Security code | Deutsch Prüfnummer |
| holder | Englisch Name on card | Deutsch Name auf der Karte |
| saveCard | Englisch Save card | Deutsch Karte speichern |
| cardPreview | Englisch Your new card | Deutsch Deine neue Karte |
| menu | Englisch Account menu | Deutsch Kontomenü |
| signOut | Englisch Sign out | Deutsch Abmelden |
| lookupTitle | Englisch Track an order | Deutsch Bestellung verfolgen |
| orderNumber | Englisch Order number | Deutsch Bestellnummer |
| Englisch Email used for the order | Deutsch E-Mail der Bestellung | |
| find | Englisch Find my order | Deutsch Bestellung finden |
| finding | Englisch Looking it up | Deutsch Wird gesucht |
| notFound | Englisch 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. |
| another | Englisch Track another order | Deutsch Andere Bestellung verfolgen |
| items | Englisch items | Deutsch Artikel |
| item | Englisch item | Deutsch 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
'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.