Rezerwacja
Wybierz dzień i wolną godzinę, podaj swoje dane i odbierz potwierdzenie. Dni minionych, weekendów i dni bez wolnych terminów nie da się wybrać.
Dodaj ten przykład do projektu z zainstalowanym prfct. Polecenie zapisuje poniższe pliki i instaluje komponenty, których używają.
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/booking.json"use client"
import * as React from "react"
import {
ArrowLeftIcon,
CalendarCheckIcon,
CalendarIcon,
ClockIcon,
GlobeIcon,
VideoIcon,
type LucideIcon,
} from "lucide-react"
import host from "./aurora.svg"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
// A fixed "today" keeps the example the same on every visit.
const today = new Date(2026, 8, 23)
const firstFreeDay = new Date(2026, 8, 24)
const fullyBooked = [new Date(2026, 8, 25), new Date(2026, 9, 2)]
const DURATION = 30
/** The hours a call can start at: every half hour, with a break for lunch. */
const starts = [9, 9.5, 10, 10.5, 11, 11.5, 13, 13.5, 14, 14.5, 15, 15.5]
const dayFormat = new Intl.DateTimeFormat("en-US", {
weekday: "long",
month: "long",
day: "numeric",
})
const timeFormat = new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
})
const EMAIL = /^\S+@\S+\.\S+$/
type Guest = { name: string; email: string }
/**
* The free slots of a day. A real calendar asks its server; here every third
* slot is taken, shifted by the date so that days differ.
*/
function freeSlots(day: Date) {
return starts
.filter((_, index) => (index + day.getDate()) % 3 !== 0)
.map(
(hour) =>
new Date(day.getFullYear(), day.getMonth(), day.getDate(), 0, hour * 60)
)
}
const endOf = (slot: Date) => new Date(slot.getTime() + DURATION * 60_000)
function Detail({
icon: Icon,
children,
}: {
icon: LucideIcon
children: React.ReactNode
}) {
return (
<li className="flex items-start gap-2.5">
<Icon className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
{children}
</li>
)
}
/** What is being booked, and once a slot is chosen, when. */
function Summary({ slot }: { slot?: Date }) {
return (
<div className="flex flex-col gap-5">
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage src={host.src} alt="" />
<AvatarFallback>MC</AvatarFallback>
</Avatar>
<span className="text-sm text-muted-foreground">Maya Chen</span>
</div>
<div className="flex flex-col gap-2">
<h2 className="text-heading-lg">Intro call</h2>
<p className="text-sm text-muted-foreground">
A walkthrough of Meridian for your team. Bring your questions.
</p>
</div>
<ul className="flex flex-col gap-2.5 text-sm text-muted-foreground">
{slot && (
<Detail icon={CalendarIcon}>
<span className="font-medium text-foreground first-letter:uppercase">
{dayFormat.format(slot)},{" "}
{timeFormat.formatRange(slot, endOf(slot))}
</span>
</Detail>
)}
<Detail icon={ClockIcon}>{DURATION} minutes</Detail>
<Detail icon={VideoIcon}>Video call, link sent by email</Detail>
<Detail icon={GlobeIcon}>Central European Time</Detail>
</ul>
</div>
)
}
function TimeSlots({
day,
onPick,
}: {
day: Date
onPick: (slot: Date) => void
}) {
return (
<section aria-labelledby="booking-day" className="flex flex-col gap-3">
{/* Announced when the day changes: the times below belong to it. */}
<h3
id="booking-day"
aria-live="polite"
// As tall as the calendar's caption, so the two headings line up.
className="h-9 text-sm leading-9 font-medium first-letter:uppercase"
>
{dayFormat.format(day)}
</h3>
<div className="grid grid-cols-2 gap-2 @sm:grid-cols-3 @3xl:grid-cols-1">
{freeSlots(day).map((slot) => (
<Button
key={slot.getTime()}
variant="outline"
onClick={() => onPick(slot)}
>
{timeFormat.format(slot)}
</Button>
))}
</div>
</section>
)
}
/** A labelled text input that shows its error, wired for screen readers. */
function TextField({
name,
label,
error,
...props
}: React.ComponentProps<typeof Input> & {
name: keyof Guest
label: string
error?: string
}) {
const id = `booking-${name}`
return (
<Field data-invalid={error ? true : undefined}>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<Input
id={id}
name={name}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
{...props}
/>
{error && <FieldError id={`${id}-error`}>{error}</FieldError>}
</Field>
)
}
function DetailsForm({
onBack,
onConfirm,
}: {
onBack: () => void
onConfirm: (guest: Guest) => void
}) {
const [errors, setErrors] = React.useState<Partial<Guest>>({})
const [pending, setPending] = React.useState(false)
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const data = new FormData(form)
const guest: Guest = {
name: String(data.get("name")).trim(),
email: String(data.get("email")).trim(),
}
const problems: Partial<Guest> = {}
if (!guest.name) problems.name = "Enter your name."
if (!EMAIL.test(guest.email)) {
problems.email = "Enter an email address like ada@example.com."
}
setErrors(problems)
const [first] = Object.keys(problems)
if (first) {
// Send people to the first field that needs fixing.
form.querySelector<HTMLInputElement>(`[name="${first}"]`)?.focus()
return
}
setPending(true)
// Stands in for the request that books the slot.
await new Promise((resolve) => setTimeout(resolve, 900))
onConfirm(guest)
}
return (
<form noValidate onSubmit={submit} className="flex flex-col gap-6">
<h3 className="text-heading-sm">Your details</h3>
<FieldGroup>
<TextField
name="name"
label="Name"
autoComplete="name"
autoFocus
error={errors.name}
/>
<TextField
name="email"
label="Email"
type="email"
autoComplete="email"
placeholder="ada@example.com"
error={errors.email}
/>
<Field>
<FieldLabel htmlFor="booking-notes">
Notes
<span className="font-normal text-muted-foreground">
(optional)
</span>
</FieldLabel>
<Textarea
id="booking-notes"
name="notes"
placeholder="Anything we should prepare?"
/>
</Field>
</FieldGroup>
<div className="flex flex-wrap justify-between gap-2">
{/* Off while the booking is sent: going back wouldn't cancel it. */}
<Button
type="button"
variant="ghost"
disabled={pending}
onClick={onBack}
>
<ArrowLeftIcon data-icon="inline-start" />
Change time
</Button>
<Button type="submit" loading={pending}>
Confirm booking
</Button>
</div>
</form>
)
}
function Confirmation({
guest,
onReset,
}: {
guest: Guest
onReset: () => void
}) {
const heading = React.useRef<HTMLHeadingElement>(null)
// The form that had focus is gone; the heading takes it, so screen readers
// announce the result and keyboard users continue from here.
React.useEffect(() => heading.current?.focus(), [])
return (
<div className="flex flex-col items-start gap-6">
<span className="flex size-11 items-center justify-center rounded-xl border bg-card text-muted-foreground shadow-raised">
<CalendarCheckIcon className="size-5" aria-hidden="true" />
</span>
<div className="flex flex-col gap-2">
<h3
ref={heading}
tabIndex={-1}
className="text-heading-lg outline-none"
>
You’re booked
</h3>
<p className="text-sm text-muted-foreground">
An invitation with the video link is on its way to{" "}
<span className="font-medium wrap-anywhere text-foreground">
{guest.email}
</span>
.
</p>
</div>
<Button variant="outline" onClick={onReset}>
Book another time
</Button>
</div>
)
}
export default function BookingExample() {
const [day, setDay] = React.useState(firstFreeDay)
const [slot, setSlot] = React.useState<Date>()
const [guest, setGuest] = React.useState<Guest>()
function reset() {
setSlot(undefined)
setGuest(undefined)
}
return (
<div className="@container">
<div className="mx-auto grid max-w-4xl overflow-hidden rounded-xl border bg-background @2xl:grid-cols-[17rem_minmax(0,1fr)]">
<div className="border-b p-4 @md:p-6 @2xl:border-r @2xl:border-b-0">
<Summary slot={slot} />
</div>
<div className="p-4 @md:p-6">
{guest ? (
<Confirmation guest={guest} onReset={reset} />
) : slot ? (
<DetailsForm
onBack={() => setSlot(undefined)}
onConfirm={setGuest}
/>
) : (
<div className="grid gap-6 @3xl:grid-cols-[auto_minmax(0,1fr)]">
<Calendar
mode="single"
required
today={today}
defaultMonth={day}
selected={day}
onSelect={setDay}
// Past days, weekends and fully booked days can't be chosen.
disabled={[
{ before: today },
{ dayOfWeek: [0, 6] },
...fullyBooked,
]}
className="mx-auto p-0 @3xl:mx-0"
/>
<TimeSlots day={day} onPick={setSlot} />
</div>
)}
</div>
</div>
</div>
)
}<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80">
<rect width="80" height="80" fill="#e4eaff"/>
<circle cx="40" cy="31" r="14" fill="#8ea6ff"/>
<path d="M12 80c0-15.5 12.5-26 28-26s28 10.5 28 26z" fill="#8ea6ff"/>
</svg>import type { Metadata } from "next"
import Booking from "@/components/booking/booking"
export const metadata: Metadata = {
title: "Booking",
description: "Scheduling a call: a calendar with unavailable days, free time slots, a short form and a confirmation.",
}
export default function BookingPage() {
return (
<main className="mx-auto min-h-dvh w-full max-w-[90rem] p-4 sm:p-8">
<Booking />
</main>
)
}