Fiktiver Demo-Shop mit absichtlichen Barrieren. Keine echten Käufe.

Zurück zum Demo-Shop

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.

Zurück zum Warenkorb

Dein Demo-Checkout.

Ein simulierter Kauf in drei Schritten. Nutze nur fiktive Angaben. Es wird nichts versendet oder berechnet.

Dein Warenkorb ist leer.

Zur Kollektion
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;}