A property tour booking card to pick a day and time slot with the assigned agent and a confirm action.
npx shadcn@latest add @paragon/tour-scheduler"use client";
import * as React from "react";
import { Calendar, Check } from "lucide-react";
import { cn } from "@/lib/utils";
export interface TourDay {
/** Weekday abbreviation, e.g. "Wed". */
weekday: string;
/** Day number. */
date: number;
/** Available time slots for this day. */
slots: string[];
}
export interface TourAgent {
name: string;
role?: string;
/** Avatar image; falls back to initials. */
avatar?: string;
}
export interface TourSchedulerProps extends React.ComponentProps<"div"> {
agent: TourAgent;
days: TourDay[];
/** Preselected day index. */
defaultDay?: number;
onBook?: (day: TourDay, slot: string) => void;
}
function initials(name: string) {
return name
.split(" ")
.map((p) => p[0])
.slice(0, 2)
.join("")
.toUpperCase();
}
/**
* A property tour booking card: pick a day, then a time slot, with the assigned
* agent shown. Selection is a real radio-style set with focus rings; the
* confirm button enables only once a slot is chosen. Transitions are color-only
* (no layout motion) and interruptible. Deterministic.
*/
export function TourScheduler({
agent,
days,
defaultDay = 0,
onBook,
className,
...props
}: TourSchedulerProps) {
const [dayIndex, setDayIndex] = React.useState(
Math.min(Math.max(defaultDay, 0), Math.max(days.length - 1, 0)),
);
const [slot, setSlot] = React.useState<string | null>(null);
const [booked, setBooked] = React.useState(false);
const day = days[dayIndex];
const selectDay = (i: number) => {
setDayIndex(i);
setSlot(null);
setBooked(false);
};
return (
<div
data-slot="tour-scheduler"
className={cn(
"w-full max-w-sm rounded-xl bg-card p-5 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-center gap-3">
<span className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-secondary text-xs font-medium text-secondary-foreground">
{agent.avatar ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={agent.avatar} alt="" className="size-full object-cover" />
) : (
initials(agent.name)
)}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{agent.name}</p>
<p className="truncate text-xs text-muted-foreground">
{agent.role ?? "Listing agent"}
</p>
</div>
<Calendar aria-hidden className="ml-auto size-4 text-muted-foreground" />
</div>
<div
role="radiogroup"
aria-label="Tour day"
className="mt-4 grid grid-cols-4 gap-2"
>
{days.map((d, i) => {
const selected = i === dayIndex;
return (
<button
key={`${d.weekday}-${d.date}`}
type="button"
role="radio"
aria-checked={selected}
onClick={() => selectDay(i)}
className={cn(
"flex flex-col items-center rounded-lg py-2 outline-none transition-colors duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring",
selected
? "bg-primary text-primary-foreground"
: "bg-secondary text-muted-foreground hover:text-foreground",
)}
>
<span className="text-[11px]">{d.weekday}</span>
<span className="text-sm font-semibold tabular-nums">{d.date}</span>
</button>
);
})}
</div>
<div
role="radiogroup"
aria-label="Tour time"
className="mt-4 grid grid-cols-3 gap-2"
>
{day?.slots.map((s) => {
const selected = s === slot;
return (
<button
key={s}
type="button"
role="radio"
aria-checked={selected}
onClick={() => {
setSlot(s);
setBooked(false);
}}
className={cn(
"rounded-lg py-2 text-xs font-medium tabular-nums outline-none transition-[color,box-shadow] duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring",
selected
? "text-foreground shadow-border-hover"
: "text-muted-foreground shadow-border hover:text-foreground",
)}
>
{s}
</button>
);
})}
</div>
<button
type="button"
disabled={!slot}
onClick={() => {
if (slot && day) {
setBooked(true);
onBook?.(day, slot);
}
}}
className="pressable mt-4 flex h-10 w-full items-center justify-center gap-2 rounded-lg bg-primary text-sm font-medium text-primary-foreground outline-none transition-[scale,background-color] duration-150 hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50"
>
{booked ? (
<>
<Check aria-hidden className="size-4" strokeWidth={3} />
Tour requested
</>
) : slot ? (
`Request ${day?.weekday} at ${slot}`
) : (
"Select a time"
)}
</button>
</div>
);
}