Customer account
The account of a shop. A start page with the latest order on its way, the address book, saved ways to pay with a live card preview, the account frame with its menu and order tracking without an account.
One of 4 components in Account and loyalty
Variants
Details
What Customer account brings along, read from the registry and the source.
- Category
- Account and loyalty
- Area
- E-Commerce
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/customer-account.tsx
- Brings along
- utilskitbuttoniconrevealmotionsectionnumber-tickerproduct-image
- npm packages
- None
- Languages
- English and German, 50 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The CustomerAccountProps interface from components/ui/customer-account.tsx, with the comments from the code.
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
Built-in texts
Customer account writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 50 texts
| Key | English | German |
|---|---|---|
| hello | English Hello | German Hallo |
| memberSince | English Member since | German Dabei seit |
| points | English points | German Punkte |
| yourPoints | English Your points | German Deine Punkte |
| rewards | English See all rewards | German Alle Prämien ansehen |
| toNext | English more to | German fehlen bis |
| latestOrder | English Your latest order | German Deine letzte Bestellung |
| order | English Order | German Bestellung |
| track | English Track parcel | German Sendung verfolgen |
| viewOrder | English View order | German Bestellung ansehen |
| steps | English Ordered,Packed,On the way,Delivered | German Bestellt,Verpackt,Unterwegs,Zugestellt |
| defaultShipping | English Default shipping | German Standard-Lieferadresse |
| defaultBilling | English Default billing | German Standard-Rechnungsadresse |
| edit | English Edit | German Bearbeiten |
| remove | English Remove | German Entfernen |
| removeAsk | English Remove this address? | German Diese Adresse entfernen? |
| keep | English Keep it | German Behalten |
| removeYes | English Remove | German Entfernen |
| makeDefault | English Use as default | German Als Standard nutzen |
| addAddress | English Add an address | German Adresse hinzufügen |
| newAddress | English New address | German Neue Adresse |
| name | English Full name | German Vor- und Nachname |
| street | English Street and number | German Straße und Hausnummer |
| postcode | English Postcode | German Postleitzahl |
| city | English City | German Ort |
| labelField | English Label, e.g. Home or Work | German Bezeichnung, z. B. Zuhause oder Büro |
| save | English Save address | German Adresse speichern |
| cancel | English Cancel | German Abbrechen |
| saved | English Saved | German Gespeichert |
| expires | English Expires | German Gültig bis |
| expiringSoon | English Expires soon | German Läuft bald ab |
| isDefault | English Default | German Standard |
| addCard | English Add a card | German Karte hinzufügen |
| cardNumber | English Card number | German Kartennummer |
| expiry | English Expiry | German Gültig bis |
| cvc | English Security code | German Prüfnummer |
| holder | English Name on card | German Name auf der Karte |
| saveCard | English Save card | German Karte speichern |
| cardPreview | English Your new card | German Deine neue Karte |
| menu | English Account menu | German Kontomenü |
| signOut | English Sign out | German Abmelden |
| lookupTitle | English Track an order | German Bestellung verfolgen |
| orderNumber | English Order number | German Bestellnummer |
| English Email used for the order | German E-Mail der Bestellung | |
| find | English Find my order | German Bestellung finden |
| finding | English Looking it up | German Wird gesucht |
| notFound | English We could not find an order with this number and email. Check the order confirmation for both. | German Zu dieser Nummer und E-Mail finden wir keine Bestellung. Beides steht in der Bestellbestätigung. |
| another | English Track another order | German Andere Bestellung verfolgen |
| items | English items | German Artikel |
| item | English item | German Artikel |
Accessibility
Read from the source of the component.
- Customer account handles the keys Home itself.
- Sets aria-current, aria-label, aria-live and aria-selected, so screen readers announce purpose and state.
- Uses the roles alert, alertdialog, region, tab and tablist.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Moves with the scroll, its progress follows the position on the page.
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 7 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 971 lines
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';
import { Eyebrow, Section } from '@/components/ui/section';
import { NumberTicker } from '@/components/ui/number-ticker';
import { ProductMedia, ShopMotion, Swap, formatNumber, formatPrice } from '@/components/ui/product-image';
/*
* The customer account of a shop.
*
* overview hello, the latest order on its way, points and shortcuts
* addresses the address book: default badges, add, edit, remove with a second look
* payment-methods saved cards and other ways to pay, a new card with a live preview
* layout the account frame: a side menu on desktop, a sliding tab row on phones
* order-lookup track an order without an account, with order number and email
*/
export type CustomerAccountVariant = 'overview' | 'addresses' | 'payment-methods' | 'layout' | 'order-lookup';
export interface AccountOrder {
number: string;
/** Already formatted */
date: string;
/** e.g. "On the way" */
status: string;
/** 0 ordered, 1 packed, 2 on the way, 3 delivered */
stage: number;
/** e.g. "Arrives Thursday, Oct 23" */
eta?: string;
total: number;
images?: React.ReactNode[];
/** Names of the steps, default from the labels */
steps?: string[];
href?: string;
items?: { name: string; variant?: string; quantity: number; image?: React.ReactNode }[];
}
export interface AccountAddress {
id: string;
/** e.g. "Home" */
label?: string;
name: string;
lines: string[];
phone?: string;
defaultShipping?: boolean;
defaultBilling?: boolean;
}
export interface PaymentMethod {
id: string;
kind: 'card' | 'debit' | 'invoice' | 'wallet';
/** e.g. "Card", "Direct debit" */
label: string;
/** Last four digits or a masked line */
last4?: string;
holder?: string;
/** e.g. "08/28" */
expires?: string;
expiringSoon?: boolean;
isDefault?: boolean;
/** Card face: graphite, ocean, sand, forest, accent */
tone?: 'graphite' | 'ocean' | 'sand' | 'forest' | 'accent';
}
export interface AccountNavItem {
id: string;
label: string;
icon?: IconName;
count?: number;
/** What the page shows for this item */
content?: React.ReactNode;
}
export interface CustomerAccountProps {
variant?: CustomerAccountVariant;
eyebrow?: string;
title?: string;
description?: string;
customer?: { name: string; since?: string; points?: number; nextReward?: { points: number; label: string }; avatar?: React.ReactNode; rewardsHref?: string };
order?: AccountOrder;
shortcuts?: { icon?: IconName; label: string; count?: number; href: string }[];
addresses?: AccountAddress[];
methods?: PaymentMethod[];
nav?: AccountNavItem[];
defaultNav?: string;
/** order-lookup: show the found order right away (previews) */
defaultFound?: boolean;
currency?: string;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
hello: 'Hello',
memberSince: 'Member since',
points: 'points',
yourPoints: 'Your points',
rewards: 'See all rewards',
toNext: 'more to',
latestOrder: 'Your latest order',
order: 'Order',
track: 'Track parcel',
viewOrder: 'View order',
steps: 'Ordered,Packed,On the way,Delivered',
defaultShipping: 'Default shipping',
defaultBilling: 'Default billing',
edit: 'Edit',
remove: 'Remove',
removeAsk: 'Remove this address?',
keep: 'Keep it',
removeYes: 'Remove',
makeDefault: 'Use as default',
addAddress: 'Add an address',
newAddress: 'New address',
name: 'Full name',
street: 'Street and number',
postcode: 'Postcode',
city: 'City',
labelField: 'Label, e.g. Home or Work',
save: 'Save address',
cancel: 'Cancel',
saved: 'Saved',
expires: 'Expires',
expiringSoon: 'Expires soon',
isDefault: 'Default',
addCard: 'Add a card',
cardNumber: 'Card number',
expiry: 'Expiry',
cvc: 'Security code',
holder: 'Name on card',
saveCard: 'Save card',
cardPreview: 'Your new card',
menu: 'Account menu',
signOut: 'Sign out',
lookupTitle: 'Track an order',
orderNumber: 'Order number',
email: 'Email used for the order',
find: 'Find my order',
finding: 'Looking it up',
notFound: 'We could not find an order with this number and email. Check the order confirmation for both.',
another: 'Track another order',
items: 'items',
item: 'item',
},
de: {
hello: 'Hallo',
memberSince: 'Dabei seit',
points: 'Punkte',
yourPoints: 'Deine Punkte',
rewards: 'Alle Prämien ansehen',
toNext: 'fehlen bis',
latestOrder: 'Deine letzte Bestellung',
order: 'Bestellung',
track: 'Sendung verfolgen',
viewOrder: 'Bestellung ansehen',
steps: 'Bestellt,Verpackt,Unterwegs,Zugestellt',
defaultShipping: 'Standard-Lieferadresse',
defaultBilling: 'Standard-Rechnungsadresse',
edit: 'Bearbeiten',
remove: 'Entfernen',
removeAsk: 'Diese Adresse entfernen?',
keep: 'Behalten',
removeYes: 'Entfernen',
makeDefault: 'Als Standard nutzen',
addAddress: 'Adresse hinzufügen',
newAddress: 'Neue Adresse',
name: 'Vor- und Nachname',
street: 'Straße und Hausnummer',
postcode: 'Postleitzahl',
city: 'Ort',
labelField: 'Bezeichnung, z. B. Zuhause oder Büro',
save: 'Adresse speichern',
cancel: 'Abbrechen',
saved: 'Gespeichert',
expires: 'Gültig bis',
expiringSoon: 'Läuft bald ab',
isDefault: 'Standard',
addCard: 'Karte hinzufügen',
cardNumber: 'Kartennummer',
expiry: 'Gültig bis',
cvc: 'Prüfnummer',
holder: 'Name auf der Karte',
saveCard: 'Karte speichern',
cardPreview: 'Deine neue Karte',
menu: 'Kontomenü',
signOut: 'Abmelden',
lookupTitle: 'Bestellung verfolgen',
orderNumber: 'Bestellnummer',
email: 'E-Mail der Bestellung',
find: 'Bestellung finden',
finding: 'Wird gesucht',
notFound: 'Zu dieser Nummer und E-Mail finden wir keine Bestellung. Beides steht in der Bestellbestätigung.',
another: 'Andere Bestellung verfolgen',
items: 'Artikel',
item: 'Artikel',
},
};
type Texts = (typeof TEXTS)['en'];
const FOCUS = 'outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ring)]';
const CARD = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
const FIELD = 'h-11 w-full rounded-lg border border-input bg-background px-3.5 text-[15px] text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] placeholder:text-subtle-foreground hover:border-border-strong focus:border-border-strong focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--ring)_22%,transparent)]';
const ACCENT_TEXT = 'text-[color-mix(in_oklab,var(--primary)_78%,var(--foreground))]';
function Head({ eyebrow, title, description, aside }: { eyebrow?: string; title?: React.ReactNode; description?: string; aside?: React.ReactNode }) {
if (!title) return null;
return (
<div className="mb-10 flex flex-col gap-6 sm:mb-12 sm:flex-row sm:items-end sm:justify-between">
<div className="max-w-[40rem]">
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
{description && (
<p className="kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
{description}
</p>
)}
</div>
{aside}
</div>
);
}
/** The four stages of an order as a line that fills to the current one */
function OrderTrack({ stage, steps, compact }: { stage: number; steps: string[]; compact?: boolean }) {
const [ref, inView] = useInView<HTMLDivElement>();
const share = steps.length > 1 ? Math.min(1, stage / (steps.length - 1)) : 1;
return (
<div ref={ref}>
<div className="relative mx-[6px] h-1.5 rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]">
<span aria-hidden className="absolute inset-0 origin-left rounded-full bg-primary transition-transform duration-[1200ms] ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${inView ? round(share, 3) : 0})` }} />
{steps.map((_, i) => (
<span
key={i}
aria-hidden
className={cn('absolute top-1/2 size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-card transition-colors duration-500', i <= stage && inView ? 'bg-primary' : 'bg-border-strong', i === stage && inView && 'ring-4 ring-[color-mix(in_oklab,var(--primary)_22%,transparent)]')}
style={{ left: `${round((i / Math.max(1, steps.length - 1)) * 100, 2)}%`, transitionDelay: `${i * 250}ms` }}
/>
))}
</div>
{/* Each name stands under its dot: the first starts at the edge, the last ends there */}
<ol className={cn('relative mx-[6px] mt-3 h-4 text-[12px]', compact && 'max-sm:hidden')}>
{steps.map((s, i) => {
const last = i === steps.length - 1;
return (
<li
key={s}
className={cn('absolute top-0 whitespace-nowrap', i === 0 ? '-translate-x-[6px]' : last ? 'translate-x-[calc(-100%+6px)]' : '-translate-x-1/2', i === stage ? 'font-semibold text-foreground' : i < stage ? 'text-muted-foreground' : 'text-subtle-foreground')}
style={{ left: `${round((i / Math.max(1, steps.length - 1)) * 100, 2)}%` }}
>
{s}
</li>
);
})}
</ol>
</div>
);
}
/* ------------------------------------------------------------------ */
/* overview */
/* ------------------------------------------------------------------ */
function Overview(p: CustomerAccountProps & { t: Texts }) {
const { t, customer, order, currency } = p;
const locale = useLocale();
const steps = order?.steps ?? t.steps.split(',');
const pts = customer?.points ?? 0;
const next = customer?.nextReward;
const [ref, inView] = useInView<HTMLDivElement>();
return (
<Section className={p.className}>
<ShopMotion />
<div className="mb-10 flex items-center gap-4 sm:mb-12 sm:gap-5">
{customer?.avatar && <span className="kit-rise size-14 shrink-0 overflow-hidden rounded-full bg-muted shadow-[var(--shadow-sm)] sm:size-16 [&>*]:size-full [&>*]:object-cover">{customer.avatar}</span>}
<div className="min-w-0">
{p.eyebrow && <Eyebrow className="mb-1.5">{p.eyebrow}</Eyebrow>}
<h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">
{t.hello}, {customer?.name.split(' ')[0]}
</h2>
{(customer?.since || p.description) && (
<p className="kit-rise mt-1.5 text-[15px] text-muted-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
{customer?.since ? `${t.memberSince} ${customer.since}` : p.description}
</p>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3 lg:gap-5">
{order && (
<Reveal effect="rise" className={cn(CARD, 'p-5 sm:p-6 lg:col-span-2')}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-[13px] text-muted-foreground">{t.latestOrder}</p>
<p className="mt-1 text-[20px] font-semibold tracking-[-0.015em] text-foreground">{order.status}</p>
{order.eta && <p className="mt-0.5 text-[14px] text-muted-foreground">{order.eta}</p>}
</div>
<p className="text-[13px] text-muted-foreground sm:text-right">
{t.order} {order.number}
<span className="block tabular-nums">
{order.date} · {formatPrice(locale, order.total, currency)}
</span>
</p>
</div>
{order.images?.length ? (
<div className="mt-5 flex -space-x-3">
{order.images.slice(0, 4).map((img, i) => (
<span key={i} className="size-14 overflow-hidden rounded-xl border-2 border-card bg-surface-2 shadow-[var(--shadow-sm)] transition-transform duration-300 hover:-translate-y-1 [&>*]:size-full" style={{ zIndex: 4 - i }}>
{img}
</span>
))}
</div>
) : null}
<div className="mt-6">
<OrderTrack stage={order.stage} steps={steps} />
</div>
<div className="mt-6 flex flex-wrap gap-2">
<Button href={order.href ?? '#'} iconLeft="truck">
{t.track}
</Button>
<Button href={order.href ?? '#'} variant="outline">
{t.viewOrder}
</Button>
</div>
</Reveal>
)}
<Reveal effect="rise" delay={100} className={cn(CARD, 'relative flex flex-col overflow-hidden p-5 sm:p-6')}>
<div ref={ref} aria-hidden data-clip-ok className="pointer-events-none absolute -right-10 -top-10 size-40 rounded-full bg-[radial-gradient(circle,color-mix(in_oklab,var(--primary)_22%,transparent),transparent_70%)]" />
<p className="flex items-center gap-2 text-[13px] text-muted-foreground">
<Icon name="sparkle" size={15} className={ACCENT_TEXT} />
{t.yourPoints}
</p>
<p className="mt-2">
<NumberTicker value={pts} duration={1400} className="text-[44px] font-semibold leading-none tracking-[-0.04em] text-foreground" />
</p>
{next && (
<>
<p className="mt-4 text-[13.5px] text-muted-foreground">
<span className="font-medium tabular-nums text-foreground">{formatNumber(locale, Math.max(0, next.points - pts))}</span> {t.toNext} {next.label}
</p>
<span className="relative mt-2 block h-1.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" aria-hidden>
<span className="absolute inset-0 origin-left rounded-full bg-primary transition-transform duration-[1400ms] ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${inView ? round(Math.min(1, pts / next.points), 3) : 0})` }} />
</span>
</>
)}
<a href={customer?.rewardsHref ?? '#'} className={cn('group/rw mt-auto inline-flex items-center gap-1.5 pt-6 text-[14px] font-medium text-foreground', FOCUS)}>
{t.rewards}
<Icon name="arrow-right" size={14} className="transition-transform duration-300 group-hover/rw:translate-x-1" />
</a>
</Reveal>
</div>
{p.shortcuts?.length ? (
<Stagger step={60} as="ul" className="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-4 lg:mt-5 lg:gap-5">
{p.shortcuts.map((s) => (
<li key={s.label}>
<a href={s.href} className={cn(CARD, 'group/sc flex h-full flex-col p-5 transition-[transform,box-shadow] duration-500 ease-[var(--ease-kit)] hover:-translate-y-1 hover:shadow-[var(--highlight),var(--shadow-lg)] motion-reduce:transition-none', FOCUS)}>
<span className="flex items-center justify-between">
<span className="grid size-10 place-items-center rounded-xl bg-muted text-foreground transition-colors duration-300 group-hover/sc:bg-primary group-hover/sc:text-primary-foreground">
<Icon name={s.icon ?? 'arrow-right'} size={18} />
</span>
{s.count !== undefined && <span className="text-[20px] font-semibold tabular-nums text-foreground">{formatNumber(locale, s.count)}</span>}
</span>
<span className="mt-5 flex items-center justify-between gap-2 text-[14.5px] font-medium text-foreground">
{s.label}
<Icon name="arrow-right" size={15} className="text-muted-foreground transition-transform duration-300 group-hover/sc:translate-x-1 group-hover/sc:text-foreground" />
</span>
</a>
</li>
))}
</Stagger>
) : null}
</Section>
);
}
/* ------------------------------------------------------------------ */
/* addresses */
/* ------------------------------------------------------------------ */
function Addresses(p: CustomerAccountProps & { t: Texts }) {
const { t } = p;
const [list, setList] = useState<AccountAddress[]>(p.addresses ?? []);
const [asking, setAsking] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [fresh, setFresh] = useState<string | null>(null);
const id = useId();
const [form, setForm] = useState({ label: '', name: '', street: '', postcode: '', city: '' });
const valid = form.name.trim() && form.street.trim() && form.postcode.trim() && form.city.trim();
const makeDefault = (aid: string) => setList((l) => l.map((a) => ({ ...a, defaultShipping: a.id === aid })));
const save = (e: React.FormEvent) => {
e.preventDefault();
if (!valid) return;
const nid = `new-${list.length}`;
setList((l) => [...l, { id: nid, label: form.label || undefined, name: form.name, lines: [form.street, `${form.postcode} ${form.city}`] }]);
setFresh(nid);
setAdding(false);
setForm({ label: '', name: '', street: '', postcode: '', city: '' });
};
return (
<Section className={p.className}>
<ShopMotion />
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-5">
{list.map((a, i) => (
<Reveal as="li" key={a.id} effect="rise" delay={Math.min(i, 5) * 70}>
<article className={cn(CARD, 'relative flex h-full flex-col p-5 sm:p-6', (a.defaultShipping || a.defaultBilling) && 'shadow-[var(--highlight),0_0_0_1px_var(--border-strong),var(--shadow-sm)]', fresh === a.id && 'animate-[kitshop-pop_0.5s_var(--ease-kit)]')}>
<div className="flex flex-wrap items-center gap-2">
<span className="grid size-9 place-items-center rounded-lg bg-muted text-foreground">
<Icon name={a.label?.match(/büro|work|office/i) ? 'briefcase' : 'home'} size={16} />
</span>
{a.label && <span className="text-[14px] font-semibold text-foreground">{a.label}</span>}
</div>
<address className="mt-4 text-[14.5px] not-italic leading-relaxed text-muted-foreground">
<span className="font-medium text-foreground">{a.name}</span>
{a.lines.map((l) => (
<span key={l} className="block">
{l}
</span>
))}
{a.phone && <span className="mt-1 block tabular-nums">{a.phone}</span>}
</address>
{(a.defaultShipping || a.defaultBilling) && (
<div className="mt-4 flex flex-wrap gap-1.5">
{a.defaultShipping && (
<span className="inline-flex animate-[kitshop-fade_0.4s] items-center gap-1 rounded-full bg-[color-mix(in_oklab,var(--primary)_13%,transparent)] px-2.5 py-1 text-[12px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
<Icon name="truck" size={12} />
{t.defaultShipping}
</span>
)}
{a.defaultBilling && (
<span className="inline-flex animate-[kitshop-fade_0.4s] items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-[12px] font-medium text-foreground">
<Icon name="receipt" size={12} />
{t.defaultBilling}
</span>
)}
</div>
)}
<div className="mt-auto pt-5">
{asking === a.id ? (
<div className="flex animate-[kitshop-in_0.3s_var(--ease-kit)] flex-wrap items-center justify-between gap-2 rounded-xl bg-[color-mix(in_oklab,var(--destructive)_9%,transparent)] p-2 ps-3" role="alertdialog" aria-label={t.removeAsk}>
<span className="text-[13.5px] font-medium text-foreground">{t.removeAsk}</span>
<span className="flex gap-1.5">
<Button size="sm" variant="ghost" onClick={() => setAsking(null)}>
{t.keep}
</Button>
<Button size="sm" variant="destructive" onClick={() => { setList((l) => l.filter((x) => x.id !== a.id)); setAsking(null); }}>
{t.removeYes}
</Button>
</span>
</div>
) : (
<div className="flex items-center gap-x-1 border-t border-border pt-3">
<Button size="sm" variant="ghost" iconLeft="edit" className="h-11 sm:h-9">
{t.edit}
</Button>
{!a.defaultShipping && (
<Button size="sm" variant="ghost" iconLeft="check-circle" className="h-11 sm:h-9" onClick={() => makeDefault(a.id)}>
{t.makeDefault}
</Button>
)}
<Button size="sm" variant="ghost" iconLeft="trash" className="ms-auto size-11 justify-center px-0 text-muted-foreground sm:size-9" onClick={() => setAsking(a.id)} aria-label={`${t.remove}, ${a.label ?? a.name}`} title={t.remove}>
<span className="sr-only">{t.remove}</span>
</Button>
</div>
)}
</div>
</article>
</Reveal>
))}
<li>
{adding ? (
<form onSubmit={save} className={cn(CARD, 'h-full animate-[kitshop-zoom_0.4s_var(--ease-kit)] p-5 sm:p-6')}>
<p className="text-[15px] font-semibold text-foreground">{t.newAddress}</p>
<div className="mt-4 space-y-3">
{(
[
['label', t.labelField, 'off'],
['name', t.name, 'name'],
['street', t.street, 'street-address'],
] as const
).map(([k, label, ac]) => (
<div key={k}>
<label htmlFor={`${id}-${k}`} className="sr-only">
{label}
</label>
<input id={`${id}-${k}`} value={form[k]} onChange={(e) => setForm((f) => ({ ...f, [k]: e.target.value }))} placeholder={label} autoComplete={ac} className={FIELD} />
</div>
))}
<div className="grid grid-cols-[7rem_minmax(0,1fr)] gap-3">
<label htmlFor={`${id}-pc`} className="sr-only">
{t.postcode}
</label>
<input id={`${id}-pc`} value={form.postcode} onChange={(e) => setForm((f) => ({ ...f, postcode: e.target.value }))} placeholder={t.postcode} autoComplete="postal-code" inputMode="numeric" className={FIELD} />
<label htmlFor={`${id}-city`} className="sr-only">
{t.city}
</label>
<input id={`${id}-city`} value={form.city} onChange={(e) => setForm((f) => ({ ...f, city: e.target.value }))} placeholder={t.city} autoComplete="address-level2" className={FIELD} />
</div>
</div>
<div className="mt-4 flex gap-2">
<Button type="submit" disabled={!valid} iconLeft="check">
{t.save}
</Button>
<Button type="button" variant="ghost" onClick={() => setAdding(false)}>
{t.cancel}
</Button>
</div>
</form>
) : (
<button
type="button"
onClick={() => setAdding(true)}
className={cn('group/add flex h-full min-h-[5rem] w-full items-center justify-center gap-3 rounded-2xl border border-dashed border-border-strong text-muted-foreground transition-[background-color,border-color,color] duration-300 hover:border-foreground hover:bg-surface hover:text-foreground sm:min-h-[15rem] sm:flex-col', FOCUS)}
>
<span className="grid size-10 place-items-center rounded-full bg-muted transition-transform duration-500 ease-[var(--ease-kit)] group-hover/add:rotate-90">
<Icon name="plus" size={18} />
</span>
<span className="text-[15px] font-medium">{t.addAddress}</span>
</button>
)}
</li>
</ul>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* payment-methods */
/* ------------------------------------------------------------------ */
const FACE: Record<NonNullable<PaymentMethod['tone']>, string> = {
graphite: 'linear-gradient(135deg, oklch(0.36 0.012 260), oklch(0.22 0.01 260))',
ocean: 'linear-gradient(135deg, oklch(0.56 0.085 236), oklch(0.36 0.07 240))',
sand: 'linear-gradient(135deg, oklch(0.84 0.045 78), oklch(0.68 0.06 70))',
forest: 'linear-gradient(135deg, oklch(0.5 0.06 162), oklch(0.32 0.05 165))',
accent: 'linear-gradient(135deg, color-mix(in oklab, var(--primary) 90%, white), color-mix(in oklab, var(--primary) 70%, black))',
};
/** A drawn payment card: chip, masked number, holder and expiry. No network logo, the label says what it is. */
function CardFace({ m, back, code, className }: { m: PaymentMethod; back?: boolean; code?: string; className?: string }) {
const light = m.tone === 'sand';
const ink = light ? 'text-[oklch(0.25_0.02_70)]' : 'text-white';
return (
<div className={cn('relative aspect-[1.586] w-full overflow-hidden rounded-2xl p-5 shadow-[0_18px_40px_-18px_rgb(0_0_0/0.55),inset_0_1px_0_rgb(255_255_255/0.25)]', ink, className)} style={{ background: FACE[m.tone ?? 'graphite'] }}>
<div aria-hidden data-clip-ok className="absolute -right-16 -top-20 size-56 rounded-full bg-white/10" />
<div aria-hidden data-clip-ok className="absolute -bottom-24 -left-10 size-56 rounded-full bg-black/10" />
{back ? (
<div className="absolute inset-0 animate-[kitshop-fade_0.35s]">
<div className="mt-6 h-10 bg-black/70" />
<div className="mx-5 mt-4 flex h-9 items-center justify-end rounded-md bg-white/85 px-3 text-[15px] font-semibold tracking-[0.2em] tabular-nums text-[oklch(0.25_0.02_260)]">{code || '•••'}</div>
</div>
) : (
<div className="relative flex h-full flex-col">
<div className="flex items-start justify-between">
<span className="text-[13px] font-semibold tracking-[0.02em] opacity-90">{m.label}</span>
{m.kind === 'card' && (
<svg viewBox="0 0 24 24" className="size-6 opacity-80" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden>
<path d="M8.5 7.5a6.5 6.5 0 0 1 0 9M12 5.5a9.5 9.5 0 0 1 0 13M15.5 3.5a12.5 12.5 0 0 1 0 17" />
</svg>
)}
</div>
{m.kind === 'card' || m.kind === 'debit' ? (
<span aria-hidden className="mt-3 block h-7 w-10 rounded-md bg-[linear-gradient(135deg,oklch(0.88_0.08_85),oklch(0.72_0.09_75))] shadow-[inset_0_0_0_1px_rgb(0_0_0/0.15)]" />
) : (
<Icon name={m.kind === 'invoice' ? 'receipt' : 'wallet'} size={26} className="mt-3 opacity-80" />
)}
<p className="mt-auto text-[17px] font-medium tracking-[0.12em] tabular-nums sm:text-[18px]">{m.last4 ? `•••• ${m.last4}` : ''}</p>
<div className="mt-2 flex items-end justify-between gap-3 text-[12px]">
<span className="truncate uppercase tracking-[0.06em] opacity-85">{m.holder}</span>
{m.expires && <span className="shrink-0 tabular-nums opacity-85">{m.expires}</span>}
</div>
</div>
)}
</div>
);
}
const groups = (digits: string) => digits.replace(/\D/g, '').slice(0, 16).replace(/(.{4})/g, '$1 ').trim();
function PaymentMethods(p: CustomerAccountProps & { t: Texts }) {
const { t } = p;
const [list, setList] = useState<PaymentMethod[]>(p.methods ?? []);
const [adding, setAdding] = useState(false);
const [num, setNum] = useState('');
const [exp, setExp] = useState('');
const [cvc, setCvc] = useState('');
const [holder, setHolder] = useState('');
const [cvcFocus, setCvcFocus] = useState(false);
const [saved, setSaved] = useState<string | null>(null);
const id = useId();
const digits = num.replace(/\D/g, '');
const valid = digits.length >= 15 && /^\d{2}\/\d{2}$/.test(exp) && cvc.length >= 3 && holder.trim().length > 2;
const preview: PaymentMethod = { id: 'preview', kind: 'card', label: t.cardPreview, last4: digits.length >= 4 ? digits.slice(-4) : undefined, holder: holder || ' ', expires: exp || 'MM/YY', tone: 'accent' };
const save = (e: React.FormEvent) => {
e.preventDefault();
if (!valid) return;
const nid = `card-${list.length}`;
setList((l) => [...l, { ...preview, id: nid, label: list.find((x) => x.kind === 'card')?.label ?? preview.label, tone: 'forest' }]);
setSaved(nid);
setAdding(false);
setNum('');
setExp('');
setCvc('');
setHolder('');
};
const makeDefault = (mid: string) => setList((l) => l.map((x) => ({ ...x, isDefault: x.id === mid })));
return (
<Section className={p.className}>
<ShopMotion />
<Head
eyebrow={p.eyebrow}
title={p.title}
description={p.description}
aside={
!adding && (
<Button variant="outline" iconLeft="plus" onClick={() => setAdding(true)}>
{t.addCard}
</Button>
)
}
/>
{adding && (
<form onSubmit={save} className={cn(CARD, 'mb-8 grid animate-[kitshop-zoom_0.45s_var(--ease-kit)] grid-cols-1 gap-8 p-5 sm:p-6 md:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] md:items-center lg:gap-12 lg:p-8')}>
<div className="mx-auto w-full max-w-[20rem]">
<CardFace m={preview} back={cvcFocus} code={cvc} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label htmlFor={`${id}-num`} className="mb-1.5 block text-[13.5px] font-medium text-foreground">
{t.cardNumber}
</label>
<input id={`${id}-num`} value={num} onChange={(e) => setNum(groups(e.target.value))} inputMode="numeric" autoComplete="cc-number" placeholder="1234 5678 9012 3456" className={cn(FIELD, 'tabular-nums tracking-[0.06em]')} />
</div>
<div>
<label htmlFor={`${id}-exp`} className="mb-1.5 block text-[13.5px] font-medium text-foreground">
{t.expiry}
</label>
<input
id={`${id}-exp`}
value={exp}
onChange={(e) => {
const d = e.target.value.replace(/\D/g, '').slice(0, 4);
setExp(d.length > 2 ? `${d.slice(0, 2)}/${d.slice(2)}` : d);
}}
inputMode="numeric"
autoComplete="cc-exp"
placeholder="MM/YY"
className={cn(FIELD, 'tabular-nums')}
/>
</div>
<div>
<label htmlFor={`${id}-cvc`} className="mb-1.5 block text-[13.5px] font-medium text-foreground">
{t.cvc}
</label>
<input id={`${id}-cvc`} value={cvc} onChange={(e) => setCvc(e.target.value.replace(/\D/g, '').slice(0, 4))} onFocus={() => setCvcFocus(true)} onBlur={() => setCvcFocus(false)} inputMode="numeric" autoComplete="cc-csc" placeholder="123" className={cn(FIELD, 'tabular-nums')} />
</div>
<div className="col-span-2">
<label htmlFor={`${id}-holder`} className="mb-1.5 block text-[13.5px] font-medium text-foreground">
{t.holder}
</label>
<input id={`${id}-holder`} value={holder} onChange={(e) => setHolder(e.target.value)} autoComplete="cc-name" className={FIELD} />
</div>
<div className="col-span-2 mt-1 flex gap-2">
<Button type="submit" disabled={!valid} iconLeft="lock">
{t.saveCard}
</Button>
<Button type="button" variant="ghost" onClick={() => setAdding(false)}>
{t.cancel}
</Button>
</div>
</div>
</form>
)}
<ul className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6">
{list.map((m, i) => (
<Reveal as="li" key={m.id} effect="rise" delay={Math.min(i, 5) * 80}>
<div className={cn('group/pm', saved === m.id && 'animate-[kitshop-pop_0.5s_var(--ease-kit)]')}>
<div className="transition-transform duration-500 ease-[var(--ease-kit)] group-hover/pm:-translate-y-1.5 group-hover/pm:rotate-[-1deg] motion-reduce:transition-none">
<CardFace m={m} />
</div>
<div className="mt-4 flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="flex flex-wrap items-center gap-2 text-[14.5px] font-medium text-foreground">
{m.label}
{m.last4 && <span className="tabular-nums text-muted-foreground">·· {m.last4}</span>}
</p>
<p className={cn('text-[12.5px]', m.expiringSoon ? 'font-medium text-warning-foreground' : 'text-muted-foreground')}>
{m.expiringSoon ? `${t.expiringSoon} · ${m.expires}` : m.expires ? `${t.expires} ${m.expires}` : m.holder}
</p>
</div>
{m.isDefault ? (
<span className="inline-flex shrink-0 animate-[kitshop-fade_0.4s] items-center gap-1 rounded-full bg-[color-mix(in_oklab,var(--primary)_13%,transparent)] px-2.5 py-1 text-[12px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
<Icon name="check" size={12} />
{t.isDefault}
</span>
) : (
<Button size="sm" variant="ghost" className="h-11 shrink-0 sm:h-9" onClick={() => makeDefault(m.id)}>
{t.makeDefault}
</Button>
)}
</div>
</div>
</Reveal>
))}
</ul>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* layout */
/* ------------------------------------------------------------------ */
function Layout(p: CustomerAccountProps & { t: Texts }) {
const { t, customer } = p;
const locale = useLocale();
const nav = p.nav ?? [];
const [active, setActive] = useState(p.defaultNav ?? nav[0]?.id ?? '');
const item = nav.find((n) => n.id === active) ?? nav[0];
const side = useRef<HTMLUListElement>(null);
const row = useRef<HTMLDivElement>(null);
// The fade sits only where more tabs follow, at the end of the row it moves to the start
const [fade, setFade] = useState('kit-fade-end');
useEffect(() => {
const el = row.current;
if (!el) return;
const update = () => {
const max = el.scrollWidth - el.clientWidth;
setFade(max <= 2 ? '' : el.scrollLeft <= 2 ? 'kit-fade-end' : el.scrollLeft >= max - 2 ? 'kit-fade-start' : 'kit-fade-x');
};
update();
el.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', update);
return () => {
el.removeEventListener('scroll', update);
window.removeEventListener('resize', update);
};
}, []);
const [mark, setMark] = useState<{ y: number; h: number; ready: boolean } | null>(null);
const [pill, setPill] = useState<{ x: number; w: number; ready: boolean } | null>(null);
const index = nav.findIndex((n) => n.id === active);
useEffect(() => {
const update = () => {
const li = side.current?.children[index] as HTMLElement | undefined;
if (li) setMark((m) => ({ y: li.offsetTop, h: li.offsetHeight, ready: !!m }));
const b = row.current?.querySelectorAll<HTMLElement>('[data-tab]')[index];
if (b) {
setPill((m) => ({ x: b.offsetLeft, w: b.offsetWidth, ready: !!m }));
const r = row.current!;
r.scrollTo({ left: b.offsetLeft - (r.clientWidth - b.offsetWidth) / 2, behavior: 'smooth' });
}
};
update();
const ro = new ResizeObserver(update);
if (side.current) ro.observe(side.current);
if (row.current) ro.observe(row.current);
return () => ro.disconnect();
}, [index]);
return (
<Section className={p.className}>
<ShopMotion />
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[16rem_minmax(0,1fr)] lg:gap-12">
<aside className="lg:sticky lg:top-8 lg:self-start">
{customer && (
<div className="mb-5 flex items-center gap-3 lg:mb-6">
<span className="grid size-11 shrink-0 place-items-center overflow-hidden rounded-full bg-muted text-[14px] font-semibold text-foreground [&>*]:size-full">{customer.avatar ?? customer.name.charAt(0)}</span>
<span className="min-w-0">
<span className="block truncate text-[15px] font-semibold text-foreground">{customer.name}</span>
{customer.since && (
<span className="block truncate text-[12.5px] text-muted-foreground">
{t.memberSince} {customer.since}
</span>
)}
</span>
</div>
)}
{/* Desktop: a side menu with a marker that slides to the open page */}
<nav aria-label={t.menu} className="hidden lg:block">
<ul ref={side} className="relative space-y-0.5">
{mark && <span aria-hidden className={cn('absolute inset-x-0 left-0 top-0 rounded-lg bg-muted', mark.ready && 'transition-[transform,height] duration-400 ease-[var(--ease-kit)] motion-reduce:transition-none')} style={{ height: mark.h, transform: `translateY(${mark.y}px)` }} />}
{nav.map((n) => {
const on = n.id === active;
return (
<li key={n.id}>
<button type="button" aria-current={on ? 'page' : undefined} onClick={() => setActive(n.id)} className={cn('relative flex h-11 w-full items-center gap-3 rounded-lg px-3 text-[14.5px] transition-colors', on ? (mark ? 'font-medium text-foreground' : 'bg-muted font-medium text-foreground') : 'text-muted-foreground hover:text-foreground', FOCUS)}>
<Icon name={n.icon ?? 'arrow-right'} size={17} />
<span className="flex-1 text-left">{n.label}</span>
{n.count !== undefined && <span className="rounded-full bg-background px-2 py-0.5 text-[12px] tabular-nums text-muted-foreground">{formatNumber(locale, n.count)}</span>}
</button>
</li>
);
})}
</ul>
<div className="mt-4 border-t border-border pt-4">
<button type="button" className={cn('flex h-11 w-full items-center gap-3 rounded-lg px-3 text-[14.5px] text-muted-foreground transition-colors hover:text-foreground', FOCUS)}>
<Icon name="log-out" size={17} />
{t.signOut}
</button>
</div>
</nav>
{/* Phones and tablets: a row of tabs with a sliding pill, the open one scrolls to the middle */}
<div ref={row} role="tablist" aria-label={t.menu} className={cn('kit-no-scrollbar relative -mx-6 flex gap-1 overflow-x-auto px-6 py-1 lg:hidden', fade)}>
{pill && <span aria-hidden className={cn('absolute bottom-1 left-0 top-1 rounded-full bg-foreground', pill.ready && 'transition-[transform,width] duration-400 ease-[var(--ease-kit)] motion-reduce:transition-none')} style={{ width: pill.w, transform: `translateX(${pill.x}px)` }} />}
{nav.map((n) => {
const on = n.id === active;
return (
<button
key={n.id}
data-tab
type="button"
role="tab"
aria-selected={on}
onClick={() => setActive(n.id)}
className={cn('relative z-10 inline-flex h-11 shrink-0 items-center gap-2 rounded-full px-4 text-[14px] font-medium transition-colors duration-300', on ? (pill ? 'text-background' : 'bg-foreground text-background') : 'text-muted-foreground', FOCUS)}
>
<Icon name={n.icon ?? 'arrow-right'} size={15} />
{n.label}
{n.count !== undefined && <span className="tabular-nums opacity-70">{formatNumber(locale, n.count)}</span>}
</button>
);
})}
</div>
</aside>
<div className="min-w-0">
<div key={active} className="animate-[kitshop-in_0.45s_var(--ease-kit)]" role="region" aria-label={item?.label}>
<h2 className="text-h3 font-semibold text-foreground">{item?.label}</h2>
<div className="mt-6">{item?.content}</div>
</div>
<button type="button" className={cn('mt-8 flex h-11 items-center gap-2 rounded-lg text-[14.5px] text-muted-foreground transition-colors hover:text-foreground lg:hidden', FOCUS)}>
<Icon name="log-out" size={17} />
{t.signOut}
</button>
</div>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* order-lookup */
/* ------------------------------------------------------------------ */
function OrderLookup(p: CustomerAccountProps & { t: Texts }) {
const { t, order } = p;
const [state, setState] = useState<'idle' | 'finding' | 'found' | 'missing'>(p.defaultFound ? 'found' : 'idle');
const [num, setNum] = useState(p.defaultFound ? order?.number ?? '' : '');
const [email, setEmail] = useState('');
const [shake, setShake] = useState(0);
const id = useId();
const steps = order?.steps ?? t.steps.split(',');
const submit = (e: React.FormEvent) => {
e.preventDefault();
setState('finding');
window.setTimeout(() => {
const ok = !!order && num.replace(/\s|#/g, '').toLowerCase() === order.number.replace(/\s|#/g, '').toLowerCase() && /\S+@\S+/.test(email);
setState(ok ? 'found' : 'missing');
if (!ok) setShake((s) => s + 1);
}, 800);
};
return (
<Section className={p.className}>
<ShopMotion />
<div className="mx-auto max-w-[40rem]">
<div className="text-center">
{p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
<h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{p.title ?? t.lookupTitle}</h2>
{p.description && (
<p className="kit-rise mx-auto mt-4 max-w-[32rem] text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
{p.description}
</p>
)}
</div>
{state !== 'found' ? (
<form onSubmit={submit} key={`f${shake}`} className={cn(CARD, 'mt-10 p-5 sm:p-7', state === 'missing' && 'animate-[kitshop-shake_0.4s_var(--ease-kit)]')}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,0.8fr)_minmax(0,1fr)]">
<div>
<label htmlFor={`${id}-n`} className="mb-1.5 block text-[13.5px] font-medium text-foreground">
{t.orderNumber}
</label>
<input id={`${id}-n`} value={num} onChange={(e) => setNum(e.target.value)} placeholder={order?.number} className={cn(FIELD, 'tabular-nums')} />
</div>
<div>
<label htmlFor={`${id}-e`} className="mb-1.5 block text-[13.5px] font-medium text-foreground">
{t.email}
</label>
<input id={`${id}-e`} type="email" value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" className={FIELD} />
</div>
</div>
{state === 'missing' && (
<p role="alert" className="mt-4 flex items-start gap-2 text-[13.5px] text-destructive">
<Icon name="alert-circle" size={15} className="mt-0.5 shrink-0" />
{t.notFound}
</p>
)}
<Button type="submit" size="lg" className="mt-5 w-full" iconLeft={state === 'finding' ? 'loader' : 'search'} disabled={state === 'finding' || !num.trim() || !email.trim()}>
<Swap value={state === 'finding'}>{state === 'finding' ? t.finding : t.find}</Swap>
</Button>
</form>
) : (
order && (
<div className={cn(CARD, 'mt-10 animate-[kitshop-zoom_0.5s_var(--ease-kit)] overflow-hidden')} aria-live="polite">
<div className="p-5 sm:p-7">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-[13px] text-muted-foreground">
{t.order} {order.number} · {order.date}
</p>
<p className="mt-1 text-[24px] font-semibold tracking-[-0.02em] text-foreground">{order.status}</p>
{order.eta && <p className="mt-0.5 text-[14.5px] text-muted-foreground">{order.eta}</p>}
</div>
<span className="grid size-12 place-items-center rounded-full bg-[color-mix(in_oklab,var(--primary)_13%,transparent)] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
<Icon name="truck" size={21} className="animate-[kit-float_3s_ease-in-out_infinite] motion-reduce:animate-none" />
</span>
</div>
<div className="mt-7">
<OrderTrack stage={order.stage} steps={steps} />
</div>
</div>
{order.items?.length ? (
<ul className="divide-y divide-border border-t border-border">
{order.items.map((it, i) => (
<li key={it.name + i} className="flex items-center gap-4 px-5 py-4 sm:px-7">
<ProductMedia className="size-14 shrink-0 rounded-lg">{it.image}</ProductMedia>
<div className="min-w-0 flex-1">
<p className="truncate text-[14.5px] font-medium text-foreground">{it.name}</p>
{it.variant && <p className="truncate text-[13px] text-muted-foreground">{it.variant}</p>}
</div>
<span className="text-[13.5px] tabular-nums text-muted-foreground">× {it.quantity}</span>
</li>
))}
</ul>
) : null}
<div className="flex flex-col gap-2 border-t border-border bg-surface px-5 py-4 sm:flex-row sm:flex-wrap sm:px-7">
<Button href={order.href ?? '#'} iconLeft="truck">
{t.track}
</Button>
<Button variant="ghost" onClick={() => { setState('idle'); setNum(''); setEmail(''); }}>
{t.another}
</Button>
</div>
</div>
)
)}
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* The block */
/* ------------------------------------------------------------------ */
/**
* The customer account in five layouts. overview: the start page.
* addresses: the address book. payment-methods: saved ways to pay.
* layout: the account frame with its menu. order-lookup: tracking without an account.
*/
export function CustomerAccount(props: CustomerAccountProps) {
const { variant = 'overview' } = props;
const t = useTexts(TEXTS, props.labels);
if (variant === 'addresses') return <Addresses {...props} t={t} />;
if (variant === 'payment-methods') return <PaymentMethods {...props} t={t} />;
if (variant === 'layout') return <Layout {...props} t={t} />;
if (variant === 'order-lookup') return <OrderLookup {...props} t={t} />;
return <Overview {...props} t={t} />;
}Frequent questions
How do I install Customer account?
With aiw add @ai-website/customer-account. The command puts components/ui/customer-account.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, motion, section, number-ticker and product-image. It needs no further npm packages.
Which variants does Customer account have?
5 variants, Account overview, Address book, Payment methods, Account frame and Order lookup. You pick one with the variant prop, for example variant="addresses".
Can I translate the texts of Customer account?
Yes. Customer account carries 50 texts of its own in English and German, such as “Hello”, “Member since” and “points”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Customer account?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Customer account with aiw_components and install it with aiw_add @ai-website/customer-account. Name the variant in your prompt, for example variant="overview", and the assistant writes your content straight into the props.
Can I adjust Customer account without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching CustomerAccount code to copy.