# Interactive consumer experience

Plan a day with responsive navigation, labeled fields, radio descriptions, visible loading and purposeful transitions.

## Try the journey
Plan a day, choose your pace, and create an itinerary. Clear the name and submit to see the error beside the persistent label and hint. Create a valid day to see circular and linear progress, a busy button, and a sliding confirmation. Cancel while loading to retain the draft. Hide or show the landscape for a fade, and navigate to the itinerary and back without losing your edits.

The sidebar becomes a horizontal navigation area on small screens. The preview uses the active documentation theme, density, text direction and reduced-motion preference. Open the accordion and its dialog to inspect longer titles and header spacing.

## Components and ownership
This example imports the public package: layout, surfaces, advanced text fields, radio groups with `ControlLabel`, loading feedback, accordion, dialog, and motion primitives. `ControlLabel` aligns its control with the first label line and exposes the hint as an accessible description. Spread its callback attributes onto the control.

Application code owns the temporary itinerary and a cancelable local preparation timer. Nothing is booked, no server is contacted, and data lasts only while this example is mounted. [Faye Places](/docs/consumer-app/) is the separate full application with on-device persistence, discovery, saved collections and responsive drawers.

## Source
The complete React example is below. Its small [layout stylesheet](/examples/consumer-experience.css) arranges the responsive sidebar and content; component appearance and interaction come from Faye UI.

## Executable example source

```tsx
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { ArrowLeft, ArrowRight, Bookmark, Check, Compass, Info, Leaf, MapPin, Search, X } from '@lucide/icons';
import { Icon } from '@fayegram-ai/faye-ui/core/icon';
import { Heading, Text } from '@fayegram-ai/faye-ui/core/text';
import { Card, CardBody, CardHeader, CardTitle, CardDescription, CardMedia } from '@fayegram-ai/faye-ui/core/surface';
import { Stack, Inline, Grid } from '@fayegram-ai/faye-ui/layout/layout';
import { Button, IconButton } from '@fayegram-ai/faye-ui/controls/button';
import { TextField } from '@fayegram-ai/faye-ui/controls/text-field';
import { TextareaField } from '@fayegram-ai/faye-ui/controls/textarea-field';
import { ControlLabel } from '@fayegram-ai/faye-ui/controls/field-layout';
import { Radio } from '@fayegram-ai/faye-ui/controls/radio';
import { RadioGroup } from '@fayegram-ai/faye-ui/controls/radio-group';
import { Accordion } from '@fayegram-ai/faye-ui/navigation/accordion';
import { Badge } from '@fayegram-ai/faye-ui/feedback/badge';
import { CircularProgress } from '@fayegram-ai/faye-ui/feedback/circular-progress';
import { LinearProgress } from '@fayegram-ai/faye-ui/feedback/progress';
import { Fade, Slide } from '@fayegram-ai/faye-ui/motion/presence';
import { TransitionGroup } from '@fayegram-ai/faye-ui/motion/transition-group';
import { PageHeader, EmptyState } from '@fayegram-ai/faye-ui/patterns/content';
import { Modal } from '@fayegram-ai/faye-ui/overlays/modal';
import './consumer-experience.css';

const landscape = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400"><rect width="800" height="400" fill="#e7e6d6"/><circle cx="630" cy="95" r="45" fill="#f8f4e4"/><path d="M0 300Q200 50 450 200T800 120V400H0" fill="#95b2a0"/><path d="M0 300Q150 200 320 310T800 220V400H0" fill="#527a68"/><path d="M550 230Q450 300 570 400H740Q580 300 620 220" fill="#c7ddd9"/></svg>')}`;
type Plan = { name: string; notes: string; pace: string };
export default function ConsumerExperience() {
    const id = useId(), nameInput = useRef<HTMLInputElement>(null), heading = useRef<HTMLHeadingElement>(null);
    const [view, setView] = useState('plan'), [name, setName] = useState('Sunday by the water'), [notes, setNotes] = useState('Leave time for a good coffee.'), [pace, setPace] = useState('slow');
    const [error, setError] = useState(''), [pending, setPending] = useState(false), [progress, setProgress] = useState(0), [saved, setSaved] = useState<Plan | null>(null), [preview, setPreview] = useState(true), [details, setDetails] = useState(false);
    const timer = useRef<ReturnType<typeof setInterval> | null>(null), request = useRef(0);
    const cancel = () => { request.current++; if (timer.current) clearInterval(timer.current); timer.current = null; setPending(false); };
    useEffect(() => () => { if (timer.current) clearInterval(timer.current); }, []);
    const navigate = (next: string) => { setView(next); requestAnimationFrame(() => heading.current?.focus()); };
    return <div className="journey" data-consumer-experience>
        <aside className="journey-sidebar"><Text weight="semibold" className="journey-brand"><Icon icon={Compass}/>{' '}<span>Fieldnotes</span></Text><nav aria-label="Fieldnotes navigation">{[['plan', 'Plan a day', Compass], ['saved', 'Your itinerary', Bookmark]].map(([key, label, icon]) => <Button key={String(key)} variant="ghost" fullWidth aria-current={view === key ? 'page' : undefined} onClick={() => navigate(String(key))}><Icon icon={icon as typeof Compass}/>{String(label)}</Button>)}</nav><Text as="p" size="sm" tone="muted">Good days start with a little curiosity.</Text></aside>
        <div className="journey-main"><Heading ref={heading} tabIndex={-1} level={2} className="journey-view-label">{view === 'plan' ? 'Make a little room for wonder.' : 'Your next small adventure.'}</Heading>
        <TransitionGroup mode="overlap" effect="fade" enterDuration={240} exitDuration={180}>
        {view === 'plan' ? <Stack key="plan" gap="6">
            <PageHeader headingLevel={3} eyebrow="A day your way" title="Take the scenic route." description="A quiet walk, a new favourite corner, and time to enjoy it."/>
            <div className="journey-preview"><Fade present={preview} enterDuration={260} exitDuration={180}><CardMedia ratio={2}><img src={landscape} alt="Green hills and a winding river under a pale sun"/></CardMedia></Fade><Button size="sm" variant="ghost" aria-expanded={preview} onClick={() => setPreview(value => !value)}>{preview ? 'Hide landscape preview' : 'Show landscape preview'}</Button></div>
            <form noValidate onSubmit={event => {
                event.preventDefault(); if (timer.current) return;
                if (!name.trim()) { setError('Give your day a name before continuing.'); nameInput.current?.focus(); return; }
                setError(''); setPending(true); setProgress(0); setSaved(null);
                const version = ++request.current, draft = { name: name.trim(), notes, pace }; let value = 0;
                timer.current = setInterval(() => {
                    if (request.current !== version) return;
                    value += 25; setProgress(value);
                    if (value === 100) { clearInterval(timer.current!); timer.current = null; setSaved(draft); setPending(false); }
                }, 400);
            }}><Stack gap="5">
                <fieldset className="journey-fields" disabled={pending}><Stack gap="5">
                    <TextField ref={nameInput} label="Give your day a name" required name="day" value={name} onChange={event => { setName(event.target.value); setError(''); }} error={error || undefined} description="Something you will look forward to. Up to 60 characters." maxLength={60} startAdornment={<Icon icon={Search} size="sm"/>} endAdornment={<IconButton type="button" variant="ghost" size="sm" aria-label="Clear day name" disabled={pending || !name} onClick={() => { setName(''); nameInput.current?.focus(); }}><Icon icon={X} size="sm"/></IconButton>}/>
                    <TextareaField label="A note for your future self" name="notes" value={notes} onChange={event => setNotes(event.target.value)} autosize minRows={2} maxRows={4} maxLength={160} description={`${notes.length}/160 characters · Optional`}/>
                    <div><Text as="p" weight="medium" id={`${id}-pace`}>Find your pace</Text><RadioGroup value={pace} onValueChange={value => setPace(String(value))} aria-labelledby={`${id}-pace`} name="pace"><Stack gap="2">
                        <ControlLabel label="Take it slow, with plenty of room for a detour" description="A waterside walk and a favourite café." disabled={pending}>{attrs => <Radio.Root {...attrs} value="slow"><Radio.Indicator/></Radio.Root>}</ControlLabel>
                        <ControlLabel label="A little more adventure" description="A longer trail and somewhere you have never been." disabled={pending}>{attrs => <Radio.Root {...attrs} value="adventure"><Radio.Indicator/></Radio.Root>}</ControlLabel>
                    </Stack></RadioGroup></div>
                </Stack></fieldset>
                <Fade present={pending} data-journey-loading><Stack gap="3"><Inline><CircularProgress size="sm" label="Preparing your day"/><Text role="status">Putting your day together…</Text></Inline><LinearProgress label="Day preparation progress" value={progress}/></Stack></Fade>
                <Inline wrap><Button type="submit" variant="solid" tone="accent" pending={pending}>Create my day<Icon icon={ArrowRight} mirrorInRtl/></Button>{pending && <Button type="button" onClick={cancel}>Cancel preparation</Button>}</Inline>
                <Slide present={!!saved} direction="up" enterDuration={260} exitDuration={180} data-journey-success><Card><CardHeader><CardTitle><Icon icon={Check} size="sm"/> Your day is ready</CardTitle><CardDescription>{saved?.name}</CardDescription></CardHeader><CardBody><Inline wrap><Badge tone="success">Saved for this visit</Badge><Button type="button" onClick={() => navigate('saved')}>See your itinerary<Icon icon={ArrowRight} mirrorInRtl/></Button></Inline></CardBody></Card></Slide>
            </Stack></form>
            <Accordion.Root><Accordion.Item value="about"><Accordion.Header><Accordion.Trigger><Icon icon={Info} size="sm"/>What happens when I create my day?</Accordion.Trigger></Accordion.Header><Accordion.Panel><Stack gap="3"><Text as="p" size="sm">Your choices become a small itinerary in this preview. Nothing is booked or sent to a server.</Text><Modal open={details} onOpenChange={setDetails} trigger={<Button>About this preview</Button>} title="A little space for your plans" description="Explore at your own pace." footer={<Button onClick={() => setDetails(false)}>Back to my day</Button>}><Text as="p">This example keeps your draft during navigation and shows validation, cancelable loading, a fading preview and a sliding confirmation. It stores nothing after this page is closed.</Text></Modal></Stack></Accordion.Panel></Accordion.Item></Accordion.Root>
        </Stack> : <Stack key="saved" gap="5">{saved ? <><PageHeader headingLevel={3} eyebrow="For another day" title={saved.name} description={saved.notes || 'Follow your curiosity.'}/><Grid columns={{base:1,md:2}} gap="4">{[[MapPin, 'By the water', saved.pace === 'slow' ? 'A gentle riverside stroll.' : 'Take the longer hill trail.'], [Leaf, 'A moment to pause', 'Coffee, a good view, and no hurry.']].map(([icon, title, description]) => <Card key={String(title)}><CardHeader><CardTitle><Icon icon={icon as typeof MapPin} size="sm"/> {String(title)}</CardTitle><CardDescription>{String(description)}</CardDescription></CardHeader></Card>)}</Grid><Button onClick={() => navigate('plan')}><Icon icon={ArrowLeft} mirrorInRtl/>Adjust your day</Button></> : <EmptyState icon={<Icon icon={Bookmark} size="lg"/>} title="A little room for a new plan" description="Choose a pace and a name, then keep your ideas together." action={<Button onClick={() => navigate('plan')}>Plan your first day</Button>}/>}</Stack>}
        </TransitionGroup>
        </div>
    </div>;
}
```
