← All templates

Booking flow

A complete service-booking route with site chrome, scheduling, details, review, and confirmation.

Install the whole screen
npx shadcn@latest add https://1st-pouf.worksonmy.dev/r/booking.json

Lands with 10 components: button, feedback, footer, input, layout, media, navbar, progress, surface, text.

app.example.com/booking
View source
import { useRef, useState, type FormEvent } from 'react'
import { Button } from '../Button'
import { Empty } from '../feedback'
import { Footer } from '../footer'
import { Field, Input, Textarea } from '../Input'
import { Grid, Row, Stack } from '../layout'
import { Badge, Blob } from '../media'
import { Navbar } from '../navbar'
import { Progress } from '../progress'
import { Card, RowCard } from '../surface'
import { Eyebrow, Heading, Text } from '../text'

interface Service {
  id: string
  name: string
  description: string
  duration: string
  price: number
  icon: 'sparkle' | 'smile' | 'wand'
  tone: 'purple' | 'pink' | 'mint'
}

const SERVICES: Service[] = [
  { id: 'consult', name: 'First consultation', description: 'A calm 45-minute session to map goals and next steps.', duration: '45 min', price: 90, icon: 'sparkle', tone: 'purple' },
  { id: 'followup', name: 'Follow-up session', description: 'Keep the momentum with a focused working session.', duration: '30 min', price: 65, icon: 'smile', tone: 'mint' },
  { id: 'workshop', name: 'Team workshop', description: 'A hands-on session for up to 6 people.', duration: '90 min', price: 240, icon: 'wand', tone: 'pink' },
]

const DAYS = ['2026-08-03', '2026-08-04', '2026-08-05', '2026-08-06']
const TIMES = ['09:30', '11:00', '14:00', '16:30']
const dateFormatter = new Intl.DateTimeFormat('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
const moneyFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })
const formatDay = (iso: string) => dateFormatter.format(new Date(`${iso}T12:00:00`))

/** A complete booking route: site chrome, service/date selection, contact
 * details, review, and a confirmation state. */
export function BookingBlock() {
  const [step, setStep] = useState(0)
  const [serviceId, setServiceId] = useState(SERVICES[0]!.id)
  const [day, setDay] = useState(DAYS[0]!)
  const [time, setTime] = useState(TIMES[0]!)
  const [name, setName] = useState('')
  const [email, setEmail] = useState('')
  const [notes, setNotes] = useState('')
  const [error, setError] = useState('')
  const [errorField, setErrorField] = useState<'name' | 'email' | null>(null)
  const [confirmed, setConfirmed] = useState(false)
  const nameRef = useRef<HTMLInputElement>(null)
  const emailRef = useRef<HTMLInputElement>(null)
  const service = SERVICES.find((item) => item.id === serviceId) ?? SERVICES[0]!

  const saveDetails = (event: FormEvent) => {
    event.preventDefault()
    if (!name.trim()) {
      setError('Add your name and a complete email before reviewing the booking.')
      setErrorField('name')
      nameRef.current?.focus()
      return
    }
    if (!email.includes('@')) {
      setError('Add your name and a complete email before reviewing the booking.')
      setErrorField('email')
      emailRef.current?.focus()
      return
    }
    setError('')
    setErrorField(null)
    setStep(3)
  }

  return (
    <div style={{ maxWidth: 1180, margin: '0 auto', padding: 24 }}>
      <Stack gap={6}>
        <Navbar
          brand={<><Blob icon="calendar" tone="purple" size="sm" /> Soft Hours</>}
          links={[
            { label: 'Services', href: '#services', active: true },
            { label: 'About', href: '#about' },
            { label: 'FAQ', href: '#faq' },
          ]}
          actions={<Badge tone="mint">Accepting bookings</Badge>}
        />

        <Grid cols="sidebar" gap={6}>
          <Stack gap={4}>
            <Eyebrow>Book a session</Eyebrow>
            <Heading level={1}>Make time for the work that matters.</Heading>
            <Text muted>
              Pick a service and a time. You’ll see every detail before anything is confirmed.
            </Text>
            <Card variant="tight">
              <Stack gap={2}>
                <Text size="sm" muted>Simple promise</Text>
                <Text>No payment today. Reschedule free up to 24 hours before your session.</Text>
              </Stack>
            </Card>
          </Stack>

          <Card>
            {confirmed ? (
              <Stack gap={5}>
                <Empty icon="ok" title="You’re booked">
                  {formatDay(day)} at {time}. A confirmation is on its way to {email}.
                </Empty>
                <Card variant="tight">
                  <Stack gap={2}>
                    <Badge tone={service.tone}>{service.duration}</Badge>
                    <Heading level={3}>{service.name}</Heading>
                    <Text muted>{moneyFormatter.format(service.price)} · {name}</Text>
                  </Stack>
                </Card>
                <Button
                  block
                  variant="quiet"
                  onClick={() => {
                    setConfirmed(false)
                    setStep(0)
                  }}
                >
                  Book another session
                </Button>
              </Stack>
            ) : (
              <Stack gap={5}>
                <Stack gap={2}>
                  <Row justify="between">
                    <Text size="sm" muted>Step {step + 1} of 4</Text>
                    <Badge tone="blue">{Math.round(((step + 1) / 4) * 100)}%</Badge>
                  </Row>
                  <Progress value={step + 1} max={4} label="Booking progress" />
                </Stack>

                {step === 0 ? (
                  <Stack gap={3}>
                    <Heading level={2}>Choose a service</Heading>
                    {SERVICES.map((item) => (
                      <RowCard
                        key={item.id}
                        selected={item.id === serviceId}
                        onClick={() => setServiceId(item.id)}
                      >
                        <Row justify="between" wrap={false} align="top">
                          <Row gap={3} wrap={false} align="top">
                            <Blob icon={item.icon} tone={item.tone} size="sm" />
                            <Stack gap={1}>
                              <Text>{item.name}</Text>
                              <Text size="sm" muted>{item.description}</Text>
                            </Stack>
                          </Row>
                          <Stack gap={1}>
                            <Text num>{moneyFormatter.format(item.price)}</Text>
                            <Text size="sm" muted>{item.duration}</Text>
                          </Stack>
                        </Row>
                      </RowCard>
                    ))}
                    <Row justify="end"><Button onClick={() => setStep(1)}>Choose a time</Button></Row>
                  </Stack>
                ) : null}

                {step === 1 ? (
                  <Stack gap={4}>
                    <Heading level={2}>Choose a time</Heading>
                    <Grid cols={2} gap={3}>
                      {DAYS.map((item) => (
                        <RowCard key={item} selected={item === day} onClick={() => setDay(item)}>
                          <Text>{formatDay(item)}</Text>
                        </RowCard>
                      ))}
                    </Grid>
                    <Grid cols={2} gap={3}>
                      {TIMES.map((item) => (
                        <RowCard key={item} selected={item === time} onClick={() => setTime(item)}>
                          <Text num>{item}</Text>
                        </RowCard>
                      ))}
                    </Grid>
                    <Row justify="between">
                      <Button variant="quiet" onClick={() => setStep(0)}>Back</Button>
                      <Button onClick={() => setStep(2)}>Add your details</Button>
                    </Row>
                  </Stack>
                ) : null}

                {step === 2 ? (
                  <form onSubmit={saveDetails} noValidate>
                    <Stack gap={4}>
                      <Heading level={2}>Your details</Heading>
                      <Field label="Name" error={errorField === 'name' ? error : undefined}>
                        {(id, describedBy) => (
                          <Input
                            ref={nameRef}
                            id={id}
                            name="name"
                            value={name}
                            onChange={(value) => { setName(value); setError(''); setErrorField(null) }}
                            describedBy={describedBy}
                            invalid={errorField === 'name'}
                            autoComplete="name"
                            placeholder="Ada Lovelace…"
                          />
                        )}
                      </Field>
                      <Field label="Email" error={errorField === 'email' ? error : undefined}>
                        {(id, describedBy) => (
                          <Input
                            ref={emailRef}
                            id={id}
                            name="email"
                            type="email"
                            value={email}
                            onChange={(value) => { setEmail(value); setError(''); setErrorField(null) }}
                            describedBy={describedBy}
                            invalid={errorField === 'email'}
                            autoComplete="email"
                            spellCheck={false}
                            placeholder="ada@example.com…"
                          />
                        )}
                      </Field>
                      <Field label="Anything we should know?" hint="Optional">
                        {(id, describedBy) => (
                          <Textarea
                            id={id}
                            name="notes"
                            value={notes}
                            onChange={setNotes}
                            describedBy={describedBy}
                            autoComplete="off"
                            placeholder="Accessibility needs, context, or a goal…"
                            rows={4}
                          />
                        )}
                      </Field>
                      <Row justify="between">
                        <Button variant="quiet" onClick={() => setStep(1)}>Back</Button>
                        <Button type="submit">Review booking</Button>
                      </Row>
                    </Stack>
                  </form>
                ) : null}

                {step === 3 ? (
                  <Stack gap={4}>
                    <Heading level={2}>Review your booking</Heading>
                    <Card variant="tight">
                      <Stack gap={3}>
                        <Row justify="between"><Text muted>Service</Text><Text>{service.name}</Text></Row>
                        <Row justify="between"><Text muted>Date</Text><Text>{formatDay(day)}</Text></Row>
                        <Row justify="between"><Text muted>Time</Text><Text num>{time}</Text></Row>
                        <Row justify="between"><Text muted>Total</Text><Text num>{moneyFormatter.format(service.price)}</Text></Row>
                      </Stack>
                    </Card>
                    <Text size="sm" muted>Confirmation will be sent to {email}.</Text>
                    <Row justify="between">
                      <Button variant="quiet" onClick={() => setStep(2)}>Edit details</Button>
                      <Button tone="mint" onClick={() => setConfirmed(true)}>Confirm booking</Button>
                    </Row>
                  </Stack>
                ) : null}
              </Stack>
            )}
          </Card>
        </Grid>

        <Footer
          brand="Soft Hours"
          tagline="Thoughtful sessions for teams and founders."
          columns={[
            { title: 'Explore', links: [{ label: 'Services', href: '#services' }, { label: 'About', href: '#about' }] },
            { title: 'Support', links: [{ label: 'FAQ', href: '#faq' }, { label: 'Contact', href: '#contact' }] },
          ]}
          note="© 2026 Soft Hours. Example booking template."
        />
      </Stack>
    </div>
  )
}