PropTech

Tour Scheduler

A property tour booking card to pick a day and time slot with the assigned agent and a confirm action.

Install

npx shadcn@latest add @paragon/tour-scheduler

tour-scheduler.tsx

"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>
  );
}