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

Zurück zum Demo-Shop

Einzelbeispiel · v1

Formularfehler verständlich anzeigen.

Dies ist das Kontaktformular des Shops mit seiner tatsächlichen Validierung und den simulierten Antworten.

Vorbereiten leert Kontaktentwurf und Ergebnis. Es gibt keine Anmeldung, E-Mail-Zustellung oder Support-Anbindung.

Kontakt zum Demo-Shop.

Probiere eine Nachricht aus oder melde eine Barriere. Nutze fiktive Angaben. Die drei Hauptfelder sind Pflichtfelder.

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/contact/contact-form.tsx

'use client';
import {useEffect,useRef,useState} from 'react';
import {useTranslations} from 'next-intl';
import {Button} from '@/components/ui/button';
import {FormErrors,FormField} from '@/components/shared/forms/form-field';
import {useDemoState} from '@/lib/state/use-demo-state';
import {readState} from '@/lib/state/registry';
import {beginContact,contactErrors,finishContact,prefillBarrier,sampleContact,type ContactDraft,type ContactField} from '@/lib/contact/model';
import {scenarios} from '@/lib/scenarios/manifest';
import type {DemoRoute} from '@/lib/routes';
export function ContactForm({route,seed,embedded=false}:{embedded?:boolean;route:DemoRoute;seed:{topic:'contact'|'barrier';path:string}}) {
 const Root=embedded?'section':'main';const Heading=embedded?'h2':'h1';const PanelHeading=embedded?'h3':'h2';
 const submitButton=useRef<HTMLButtonElement>(null);const completeButton=useRef<HTMLButtonElement>(null);const previousResult=useRef('idle');
 const t=useTranslations('Contact');const f=useTranslations('Forms');const [state,setContact]=useDemoState('contact');const [errors,setErrors]=useState<ContactField[]>([]);const [pause,setPause]=useState(true);const summary=useRef<HTMLDivElement>(null);const focusErrors=useRef(false);const seeded=useRef(false);const improved=route.variant==='improved';
 useEffect(()=>{if(state.result==='loading')completeButton.current?.focus();else if(previousResult.current==='loading')submitButton.current?.focus();previousResult.current=state.result;},[state.result]);
 useEffect(()=>{if(seeded.current)return;seeded.current=true;if(seed.topic==='barrier'){const current=readState('contact');const next=prefillBarrier(current,seed.path);if(next!==current)setContact(next);}},[seed,setContact]);
 useEffect(()=>{if(focusErrors.current){summary.current?.focus();focusErrors.current=false;}},[errors]);
 useEffect(()=>{const reset=()=>{setErrors([]);setPause(true);};window.addEventListener('demo-reset',reset);return()=>window.removeEventListener('demo-reset',reset);},[]);
 function update<K extends keyof ContactDraft>(field:K,value:ContactDraft[K]){const current=readState('contact');setContact({...current,draft:{...current.draft,[field]:value},result:'idle'});}
 function finish(){setContact(finishContact(readState('contact')));}
 const busy=state.result==='loading';const errorFor=(field:ContactField)=>errors.includes(field)?t(`errors.${field}`):undefined;
 return <Root id={embedded?'contact-fixture':'main-content'} className={embedded?'embedded-fixture':'page-container purchase-page'}><header className="catalog-page-header"><Heading>{t('title')}</Heading><p>{t('intro')}</p></header><div className="account-layout"><section className="account-form-section" aria-label={t('formLabel')}>
 {improved&&<FormErrors headingLevel={embedded?3:2} id="contact-error-summary" ref={summary} title={f('checkFields')} errors={errors.map(field=>({id:`contact-${field}`,text:t(`errors.${field}`)}))}/>}
 <form className="demo-form" noValidate onSubmit={event=>{event.preventDefault();const current=readState('contact');const invalid=contactErrors(current.draft);focusErrors.current=improved&&invalid.length>0;setErrors(invalid);if(invalid.length){setContact({...current,result:'idle'});return;}const next=beginContact(current);setContact(pause?next:finishContact(next));}}>
 <fieldset disabled={busy}><legend className="sr-only">{t('formLabel')}</legend><div className="form-field"><label htmlFor="contact-topic">{t('topic')}</label><select id="contact-topic" value={state.draft.topic} onChange={event=>{update('topic',event.target.value as 'contact'|'barrier');setErrors([]);}}><option value="contact">{t('general')}</option><option value="barrier">{t('barrier')}</option></select></div>
 {(['name','email'] as const).map(field=><FormField key={field} id={`contact-${field}`} label={t(`fields.${field}`)} type={field==='email'?'email':'text'} value={state.draft[field]} onChange={event=>update(field,event.target.value)} maxLength={80} autoComplete="off" required error={improved?errorFor(field):undefined} className={errorFor(field)?'field-invalid':undefined}/>)}
 <div id="contact-message-field" className="form-field"><label htmlFor="contact-message">{t('fields.message')}</label><textarea id="contact-message" value={state.draft.message} maxLength={1200} required aria-invalid={improved&&errors.includes('message')?true:undefined} aria-describedby={improved&&errors.includes('message')?'contact-message-error':'contact-message-help'} className={errorFor('message')?'field-invalid':undefined} onChange={event=>update('message',event.target.value)}/><p id="contact-message-help" className="field-help">{t('messageHelp')}</p>{improved&&errorFor('message')&&<p className="field-error" id="contact-message-error">{errorFor('message')}</p>}</div>
 {state.draft.topic==='barrier'&&<><FormField id="contact-path" label={t('fields.path')} value={state.draft.path} maxLength={200} onChange={event=>update('path',event.target.value)} error={improved?errorFor('path'):undefined} className={errorFor('path')?'field-invalid':undefined}/><p className="field-help">{t('pathHelp')}</p><div className="form-field"><label htmlFor="contact-scenario">{t('fields.scenario')}</label><select id="contact-scenario" value={state.draft.scenario} onChange={event=>update('scenario',event.target.value)} aria-invalid={improved&&errors.includes('scenario')?true:undefined} aria-describedby={improved&&errors.includes('scenario')?'contact-scenario-error':undefined}><option value="">{t('noScenario')}</option>{scenarios.map(scenario=><option key={scenario.id} value={scenario.id}>{scenario.id}</option>)}</select>{improved&&errorFor('scenario')&&<p className="field-error" id="contact-scenario-error">{errorFor('scenario')}</p>}</div></>}
 <Button type="button" variant="outline" size="sm" onClick={()=>{const current=readState('contact');setContact({...current,draft:{...current.draft,name:sampleContact.name,email:sampleContact.email,message:t('sampleMessage')},result:'idle'});setErrors([]);}}>{t('fillSample')}</Button></fieldset>
 <fieldset disabled={busy} className="simulation-controls"><legend>{t('demoControls')}</legend><div className="form-field"><label htmlFor="contact-outcome">{t('outcome')}</label><select id="contact-outcome" value={state.choice} onChange={event=>setContact({...readState('contact'),choice:event.target.value as 'success'|'failure',result:'idle'})}><option value="success">{t('successChoice')}</option><option value="failure">{t('failureChoice')}</option></select></div><label className="check-label"><input type="checkbox" checked={pause} onChange={event=>setPause(event.target.checked)}/>{f('pause')}</label></fieldset>
 {busy?<div className="submission-paused"><p>{f('paused')}</p><div className="form-actions"><Button ref={completeButton} type="button" onClick={finish}>{t('complete')}</Button><Button type="button" variant="outline" onClick={()=>setContact({...readState('contact'),result:'idle'})}>{t('cancel')}</Button></div></div>:<Button ref={submitButton} type="submit">{t(state.result==='failure'?'retry':'submit')}</Button>}
 </form>
 {/* contact-status: the barriers result intentionally has no live region. */}
 <p id="contact-submit-status" role={improved?(state.result==='failure'?'alert':'status'):undefined} aria-atomic={improved?true:undefined} className={state.result==='failure'?'field-error':'form-status'}>{state.result==='idle'?'':t(`results.${state.result}`)}</p>
 </section><aside className="demo-help"><PanelHeading>{t('demoTitle')}</PanelHeading><p>{t('demoCopy')}</p><p className="field-help">{t('privacy')}</p></aside></div></Root>;
}

src/components/shared/forms/form-field.tsx

'use client';
import type {ComponentProps,Ref} from 'react';
export function FormField({id,label,error,...props}:ComponentProps<'input'>&{id:string;label:string;error?:string}) {
 return <div className="form-field"><label htmlFor={id}>{label}</label><input {...props} id={id} aria-invalid={error?true:undefined} aria-describedby={error?`${id}-error`:props['aria-describedby']}/>{error&&<p className="field-error" id={`${id}-error`}>{error}</p>}</div>;
}
export function FormErrors({id,errors,title,ref,headingLevel=2}:{headingLevel?:2|3;id:string;errors:{id:string;text:string}[];title:string;ref?:Ref<HTMLDivElement>}) {
 const Heading=headingLevel===3?'h3':'h2';
 if(!errors.length)return null;
 return <div id={id} ref={ref} tabIndex={-1} className="error-summary"><Heading>{title}</Heading><ul>{errors.map(error=><li key={error.id}><a href={`#${error.id}`} onClick={event=>{event.preventDefault();document.getElementById(error.id)?.focus();}}>{error.text}</a></li>)}</ul></div>;
}

src/lib/contact/model.ts

import {z} from 'zod';
import {routeSchema} from '../routes';
import {findProduct} from '../fixtures/catalog';
import {scenarios} from '../scenarios/manifest';
export function isDemoReference(value:string) {
 if(!value)return true;
 if(value.length>200||/[?#%\\\s]/.test(value))return false;
 const parts=value.split('/');if(!routeSchema.safeParse({locale:parts[1],version:parts[2],variant:parts[3]}).success)return false;
 const suffix=parts.slice(4).join('/');return ['', 'products','cart','checkout','login','account/settings','contact','preferences'].includes(suffix)||/^products\/[a-z-]+$/.test(suffix)&&Boolean(findProduct(parts[5]));
}
export const contactFields=['name','email','message','path','scenario'] as const;
export type ContactField=typeof contactFields[number];
export const contactDraftSchema=z.object({topic:z.enum(['contact','barrier']),name:z.string().max(80),email:z.string().max(80),message:z.string().max(1200),path:z.string().max(200),scenario:z.string().max(80)}).strict();
export type ContactDraft=z.infer<typeof contactDraftSchema>;
export const emptyContact:ContactDraft={topic:'contact',name:'',email:'',message:'',path:'',scenario:''};
export const sampleContact:ContactDraft={...emptyContact,name:'Alex Beispiel',email:'alex@example.test',message:'Dies ist eine fiktive Nachricht für die Demo.'};
export const validContactSchema=contactDraftSchema.superRefine((data,ctx)=>{
 const fail=(field:ContactField)=>ctx.addIssue({code:'custom',path:[field],message:field});
 if(data.name.trim().length<2)fail('name');if(!z.email().safeParse(data.email).success)fail('email');if(data.message.trim().length<10)fail('message');
 if(data.topic==='barrier'&&!isDemoReference(data.path))fail('path');
 if(data.topic==='barrier'&&data.scenario&&!scenarios.some(scenario=>scenario.id===data.scenario))fail('scenario');
});
export function contactErrors(value:unknown):ContactField[]{const result=validContactSchema.safeParse(value);return result.success?[]:contactFields.filter(field=>result.error.issues.some(issue=>issue.path[0]===field));}
export const contactStateSchema=z.object({draft:contactDraftSchema,result:z.enum(['idle','loading','success','failure']),choice:z.enum(['success','failure'])}).strict().refine(state=>state.result==='idle'||validContactSchema.safeParse(state.draft).success,'Invalid submitted draft');
export type ContactState=z.infer<typeof contactStateSchema>;
export const initialContact:ContactState={draft:emptyContact,result:'idle',choice:'success'};
export function beginContact(state:ContactState):ContactState {
 validContactSchema.parse(state.draft);return contactStateSchema.parse({...state,result:'loading'});
}
export function finishContact(state:ContactState):ContactState {
 const current=contactStateSchema.parse(state);if(current.result!=='loading')throw new Error('Start a submission first');
 validContactSchema.parse(current.draft);return {...current,result:current.choice};
}

export function prefillBarrier(state:ContactState,path:string):ContactState {
 if(!isDemoReference(path))throw new Error('Unsupported demo path');
 if(state.draft.topic==='barrier'&&state.draft.path===path)return state;
 return contactStateSchema.parse({...state,draft:{...state.draft,topic:'barrier',path},result:'idle'});
}

src/app/[locale]/globals.css

.form-field .field-invalid {border:2px solid var(--destructive);}
.field-error {color:var(--destructive);font-size:.875rem;margin:.5rem 0 0;}
.error-summary {border:2px solid var(--destructive);padding:1.25rem;margin-top:1.5rem;}