Einzelbeispiel · v1
Ein Checkout in drei Schritten.
Vergleiche Validierung, Schrittfokus und feste Demo-Ergebnisse mit dem tatsächlichen Shop-Checkout.
Vorbereiten ersetzt den Warenkorb durch zwei Notizbuch-Sets: vier Artikel, 48 €. Adresse und Checkout-Ergebnis werden geleert; Deine Anmeldung bleibt erhalten.
Gemeinsamen Quellcode ansehen
Dies sind die tatsächlichen Quelldateien dieses Beispiels. Der erzeugte Stand wird bei der Build-Validierung mit der Implementierung verglichen. Imports und gemeinsame Hilfsfunktionen bleiben sichtbar; es gibt keine getrennte Lehrkopie.
src/components/features/checkout/checkout-page.tsx
'use client';
import {useEffect,useRef,useState} from 'react';
import Link from 'next/link';
import {useTranslations} from 'next-intl';
import {Button} from '@/components/ui/button';
import {cartPath,catalogPath,type DemoRoute} from '@/lib/routes';
import {products} from '@/lib/fixtures/catalog';
import {cartTotalMinor,cartSignature} from '@/lib/cart/model';
import {formatPrice} from '@/lib/format';
import {addressFields,addressErrors,demoAddress,reviewCheckout,completeCheckout,editCheckout,outcomeSchema,type AddressField} from '@/lib/checkout/model';
import {useDemoState} from '@/lib/state/use-demo-state';
import {readState} from '@/lib/state/registry';
export function CheckoutPage({route,embedded=false}: {route:DemoRoute;embedded?:boolean}) {
const Root=embedded?'section':'main';const Heading=embedded?'h2':'h1';const StepHeading=embedded?'h3':'h2';const DetailHeading=embedded?'h4':'h3';
const t=useTranslations();const [cart]=useDemoState('cart');const [state,setCheckout]=useDemoState('checkout');const [errors,setErrors]=useState<AddressField[]>([]);
const heading=useRef<HTMLHeadingElement>(null);const summary=useRef<HTMLDivElement>(null);const moveFocus=useRef(false);const improved=route.variant==='improved';
useEffect(()=>{const reset=()=>{setErrors([]);moveFocus.current=false;};window.addEventListener('demo-reset',reset);return()=>window.removeEventListener('demo-reset',reset);},[]);
useEffect(()=>{if(moveFocus.current){(errors.length?summary.current:heading.current)?.focus();moveFocus.current=false;}},[state.step,errors]);
function focusNext() {moveFocus.current=improved;}
const changed=state.step==='review' && cartSignature(cart)!==cartSignature(state.basket);
function backToAddress() {focusNext();setErrors([]);setCheckout(editCheckout(readState('checkout')));}
const summaryCart=state.step==='address'||changed?cart:state.basket;
const empty=!cart.items.length && state.step!=='outcome';
return <Root id={embedded?'checkout-fixture':'main-content'} className={embedded?'embedded-fixture':'page-container purchase-page'}><header className="catalog-page-header"><Link href={cartPath(route)}>{t('Checkout.backToCart')}</Link><Heading>{t('Checkout.title')}</Heading><p>{t('Checkout.intro')}</p></header>
{empty?<section className="cart-empty"><StepHeading>{t('Cart.empty')}</StepHeading><Link href={catalogPath(route)}>{t('Catalog.backToCollection')}</Link></section>:<>
<ol className="checkout-steps" aria-label={t('Checkout.steps')}>{(['address','review','outcome'] as const).map((step,index)=><li key={step} aria-current={state.step===step?'step':undefined}><span aria-hidden="true">{index+1}. </span>{t(`Checkout.${step}`)}</li>)}</ol>
<div className="checkout-layout"><section className="checkout-main" aria-labelledby="checkout-step-heading"><StepHeading id="checkout-step-heading" tabIndex={-1} ref={heading}>{t(state.step==='outcome'?`Checkout.results.${state.outcome}.title`:`Checkout.${state.step}`)}</StepHeading>
{state.step==='address'&&<form noValidate onSubmit={event=>{event.preventDefault();const current=readState('checkout');const invalid=addressErrors(current.address);focusNext();setErrors(invalid);if(!invalid.length){try{setCheckout(reviewCheckout(current,readState('cart')));}catch{moveFocus.current=false;}}}}>
<p id="checkout-address-help" className="field-help">{t('Checkout.addressHelp')}</p><Button type="button" variant="outline" size="sm" onClick={()=>{setCheckout({...readState('checkout'),address:demoAddress});setErrors([]);}}>{t('Checkout.fillDemo')}</Button>
{/* checkout-validation: barriers deliberately uses borders alone; labels/recovery stay intact. */}
{improved&&errors.length>0&&<div id="checkout-error-summary" ref={summary} tabIndex={-1} className="error-summary"><DetailHeading>{t('Checkout.errorTitle')}</DetailHeading><ul>{errors.map(field=><li key={field}><a href={`#checkout-${field}`} onClick={event=>{event.preventDefault();document.getElementById(`checkout-${field}`)?.focus();}}>{t(`Checkout.errors.${field}`)}</a></li>)}</ul></div>}
<div id="checkout-address-fields" className="address-fields" aria-describedby="checkout-address-help">{addressFields.map(field=><div key={field} className="address-field"><label htmlFor={`checkout-${field}`}>{t(`Checkout.fields.${field}`)}</label><input id={`checkout-${field}`} name={field} type={field==='email'?'email':'text'} inputMode={field==='postalCode'?'numeric':undefined} maxLength={field==='postalCode'?20:120} autoComplete="off" required value={state.address[field]} aria-invalid={improved&&errors.includes(field)?true:undefined} aria-describedby={improved&&errors.includes(field)?`checkout-${field}-error`:undefined} className={errors.includes(field)?'field-invalid':undefined} onChange={event=>setCheckout({...readState('checkout'),address:{...readState('checkout').address,[field]:event.target.value}})}/>{improved&&errors.includes(field)&&<p className="field-error" id={`checkout-${field}-error`}>{t(`Checkout.errors.${field}`)}</p>}</div>)}</div><p className="field-help">{t('Checkout.country')}</p><Button type="submit">{t('Checkout.toReview')}</Button></form>}
{state.step==='review'&&(changed?<div role="status"><p>{t('Checkout.cartChanged')}</p><Button onClick={backToAddress}>{t('Checkout.editAddress')}</Button></div>:<><div className="review-address"><DetailHeading>{t('Checkout.address')}</DetailHeading><p>{state.address.name}<br/>{state.address.street}<br/>{state.address.postalCode} {state.address.city}<br/>{t('Checkout.germany')}<br/>{state.address.email}</p><Button variant="outline" onClick={backToAddress}>{t('Checkout.editAddress')}</Button></div><label className="outcome-control" htmlFor="checkout-outcome">{t('Checkout.chooseOutcome')}<select id="checkout-outcome" value={state.choice} onChange={event=>setCheckout({...readState('checkout'),choice:outcomeSchema.parse(event.target.value)})}>{outcomeSchema.options.map(choice=><option value={choice} key={choice}>{t(`Checkout.choices.${choice}`)}</option>)}</select></label><p className="field-help">{t('Checkout.outcomeHelp')}</p><div className="checkout-actions"><Button onClick={()=>{try{const next=completeCheckout(readState('checkout'),readState('cart'));focusNext();setCheckout(next);}catch{backToAddress();}}}>{t('Checkout.simulate')}</Button><Button variant="outline" onClick={()=>{const current=readState('checkout');focusNext();setCheckout(completeCheckout({...current,choice:'cancel'},readState('cart')));}}>{t('Checkout.cancel')}</Button></div></>)}
{state.step==='outcome'&&<div id="checkout-result"><p>{t(`Checkout.results.${state.outcome}.copy`)}</p>{state.outcome==='success'&&<p className="mock-reference">{t('Checkout.reference',{reference:'DEMO-V1-001'})}</p>}<p className="field-help">{t('Checkout.retained')}</p><div className="checkout-actions"><Button onClick={()=>{const current=readState('checkout');focusNext();setErrors([]);if(readState('cart').items.length)setCheckout(reviewCheckout(current,readState('cart')));else setCheckout(editCheckout(current));}}>{t(state.outcome==='success'?'Checkout.runAgain':'Checkout.retry')}</Button><Button variant="outline" onClick={backToAddress}>{t('Checkout.editAddress')}</Button></div></div>}
</section><aside className="checkout-summary" aria-label={t('Checkout.summary')}><StepHeading>{t('Checkout.summary')}</StepHeading><ul>{summaryCart.items.map(line=>{const product=products.find(product=>product.id===line.productId)!;const variant=product.variants.find(variant=>variant.id===line.variantId)!;return <li key={`${line.productId}-${line.variantId}`}><span>{product.name[route.locale]}<br/><span className="field-help">{t(`Catalog.${line.variantId}`)} × {line.quantity}</span></span><strong>{formatPrice(variant.priceMinor*line.quantity,route.locale)}</strong></li>;})}</ul><div className="cart-total"><span>{t('Cart.total')}</span><strong>{formatPrice(cartTotalMinor(summaryCart),route.locale)}</strong></div><p className="field-help">{t('Cart.demoNote')}</p></aside></div>
</>}
</Root>;
}
src/lib/checkout/model.ts
import {z} from 'zod';
import {cartSchema,cartSignature,type CartState} from '../cart/model';
export const addressFields = ['name','email','street','postalCode','city'] as const;
export type AddressField = typeof addressFields[number];
const boundedText = z.string().max(120);
export const addressDraftSchema = z.object({name:boundedText,email:boundedText,street:boundedText,postalCode:z.string().max(20),city:boundedText}).strict();
export type Address = z.infer<typeof addressDraftSchema>;
export const emptyAddress: Address = {name:'',email:'',street:'',postalCode:'',city:''};
export const demoAddress: Address = {name:'Alex Beispiel',email:'alex@example.test',street:'Musterweg 12',postalCode:'10115',city:'Berlin'};
const validAddressSchema = addressDraftSchema.extend({name:boundedText.trim().min(2),email:z.email().max(120),street:boundedText.trim().min(3),postalCode:z.string().regex(/^[0-9]{5}$/),city:boundedText.trim().min(2)});
export function addressErrors(address: Address): AddressField[] {
const parsed=validAddressSchema.safeParse(address);
return parsed.success ? [] : addressFields.filter(field=>parsed.error.issues.some(issue=>issue.path[0]===field));
}
export const outcomeSchema = z.enum(['success','failure','cancel']);
export type Outcome = z.infer<typeof outcomeSchema>;
const checkoutBaseSchema = z.object({address:addressDraftSchema,step:z.enum(['address','review','outcome']),choice:outcomeSchema,outcome:outcomeSchema.nullable(),basket:cartSchema}).strict();
export const checkoutSchema = checkoutBaseSchema.refine(state=>state.step==='address' ? state.outcome===null : addressErrors(state.address).length===0 && state.basket.items.length>0 && (state.step==='review' ? state.outcome===null : state.outcome===state.choice),'Inconsistent checkout state');
export type CheckoutState = z.infer<typeof checkoutSchema>;
export const initialCheckout: CheckoutState = {address:emptyAddress,step:'address',choice:'success',outcome:null,basket:{items:[]}};
export function reviewCheckout(state: CheckoutState, cart: CartState): CheckoutState {
return checkoutSchema.parse({...state,step:'review',outcome:null,basket:cartSchema.parse(cart)});
}
export function completeCheckout(state: CheckoutState, cart: CartState): CheckoutState {
const current=checkoutSchema.parse(state);
if (current.step!=='review' || !cart.items.length || cartSignature(cart)!==cartSignature(current.basket)) throw new Error('Review the current cart first');
return checkoutSchema.parse({...current,step:'outcome',outcome:current.choice});
}
export function editCheckout(state: CheckoutState): CheckoutState {return checkoutSchema.parse({...state,step:'address',outcome:null,basket:{items:[]}});}
src/app/[locale]/globals.css
.address-field .field-invalid {border:2px solid var(--destructive);}
.error-summary {border:2px solid var(--destructive);padding:1.25rem;margin-top:1.5rem;}