Web InnoventixFreeCode

Booking Form

Original · free

booking form with date and slots

byWeb InnoventixReact + Tailwind
formbookingforms
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/form-booking.json
form-booking.tsx
"use client";

import {
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type FormEvent,
  type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/* ----------------------------- data + helpers ----------------------------- */

const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
const MONTHS = [
  "January",
  "February",
  "March",
  "April",
  "May",
  "June",
  "July",
  "August",
  "September",
  "October",
  "November",
  "December",
] as const;

const SLOT_TIMES = ["09:00", "10:30", "12:00", "13:30", "15:00", "16:30"] as const;

const SESSIONS = [
  {
    id: "discovery",
    label: "Discovery call",
    duration: "30 min",
    price: "Free",
    desc: "A quick fit check — goals, timeline, and whether we're right for the build.",
  },
  {
    id: "strategy",
    label: "Strategy session",
    duration: "45 min",
    price: "$120",
    desc: "We map scope, information architecture, and a phased roadmap you keep.",
  },
  {
    id: "technical",
    label: "Technical review",
    duration: "60 min",
    price: "$180",
    desc: "Audit of an existing site: performance, SEO, accessibility, and stack risks.",
  },
] as const;

type SessionId = (typeof SESSIONS)[number]["id"];

type Slot = { time: string; available: boolean };

type FormErrors = {
  name?: string;
  email?: string;
  date?: string;
  slot?: string;
};

const startOfDay = (d: Date): Date =>
  new Date(d.getFullYear(), d.getMonth(), d.getDate());

const startOfMonth = (d: Date): Date => new Date(d.getFullYear(), d.getMonth(), 1);

const addDays = (d: Date, n: number): Date => {
  const next = new Date(d);
  next.setDate(next.getDate() + n);
  return next;
};

const addMonths = (d: Date, n: number): Date =>
  new Date(d.getFullYear(), d.getMonth() + n, 1);

const isSameDay = (a: Date, b: Date): boolean =>
  a.getFullYear() === b.getFullYear() &&
  a.getMonth() === b.getMonth() &&
  a.getDate() === b.getDate();

const isSameMonth = (a: Date, b: Date): boolean =>
  a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();

const toKey = (d: Date): string =>
  `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
    d.getDate(),
  ).padStart(2, "0")}`;

const to12h = (t: string): string => {
  const [h, m] = t.split(":").map(Number);
  const period = h >= 12 ? "PM" : "AM";
  const hr = h % 12 === 0 ? 12 : h % 12;
  return `${hr}:${String(m).padStart(2, "0")} ${period}`;
};

const formatLong = (d: Date): string =>
  `${WEEKDAYS[d.getDay()]}, ${MONTHS[d.getMonth()]} ${d.getDate()}`;

const hash = (s: string): number => {
  let h = 0;
  for (let i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) >>> 0;
  return h;
};

// Deterministic availability so slots feel real and differ per day.
const slotsFor = (d: Date): Slot[] => {
  const day = d.getDay();
  if (day === 0 || day === 6) return SLOT_TIMES.map((t) => ({ time: t, available: false }));
  const key = toKey(d);
  return SLOT_TIMES.map((t) => ({
    time: t,
    available: hash(`${key}#${t}`) % 5 !== 0,
  }));
};

const countAvailable = (d: Date): number =>
  slotsFor(d).reduce((n, s) => (s.available ? n + 1 : n), 0);

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

/* -------------------------------- component ------------------------------- */

export default function FormBooking() {
  const reduce = useReducedMotion();
  const uid = useId();

  const today = useMemo(() => startOfDay(new Date()), []);
  const minDate = today;
  const maxDate = useMemo(() => addDays(today, 59), [today]);

  const [viewMonth, setViewMonth] = useState<Date>(() => startOfMonth(today));
  const [focusedDate, setFocusedDate] = useState<Date>(today);
  const [selectedDate, setSelectedDate] = useState<Date | null>(null);
  const [selectedSlot, setSelectedSlot] = useState<string | null>(null);
  const [session, setSession] = useState<SessionId>("discovery");
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [notes, setNotes] = useState("");
  const [errors, setErrors] = useState<FormErrors>({});
  const [submitted, setSubmitted] = useState(false);

  const gridRef = useRef<HTMLDivElement | null>(null);
  const moveFocus = useRef(false);

  const cells = useMemo(() => {
    const first = startOfMonth(viewMonth);
    const gridStart = addDays(first, -first.getDay());
    return Array.from({ length: 42 }, (_, i) => addDays(gridStart, i));
  }, [viewMonth]);

  const slots = useMemo(
    () => (selectedDate ? slotsFor(selectedDate) : []),
    [selectedDate],
  );

  // Roving focus: move keyboard focus to the active day after arrow navigation.
  useEffect(() => {
    if (!moveFocus.current) return;
    moveFocus.current = false;
    const el = gridRef.current?.querySelector<HTMLButtonElement>(
      `[data-date="${toKey(focusedDate)}"]`,
    );
    el?.focus();
  }, [focusedDate, viewMonth]);

  const outOfRange = (d: Date): boolean =>
    startOfDay(d).getTime() < minDate.getTime() ||
    startOfDay(d).getTime() > maxDate.getTime();

  const isDisabledDay = (d: Date): boolean =>
    outOfRange(d) || countAvailable(d) === 0;

  const clamp = (d: Date): Date => {
    const t = startOfDay(d).getTime();
    if (t < minDate.getTime()) return minDate;
    if (t > maxDate.getTime()) return maxDate;
    return startOfDay(d);
  };

  const chooseDate = (d: Date) => {
    if (isDisabledDay(d)) return;
    const day = startOfDay(d);
    setSelectedDate(day);
    setFocusedDate(day);
    setSelectedSlot(null);
    setErrors((e) => ({ ...e, date: undefined, slot: undefined }));
    if (!isSameMonth(day, viewMonth)) setViewMonth(startOfMonth(day));
  };

  const shiftFocus = (next: Date) => {
    const target = clamp(next);
    moveFocus.current = true;
    setFocusedDate(target);
    if (!isSameMonth(target, viewMonth)) setViewMonth(startOfMonth(target));
  };

  const onGridKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    switch (e.key) {
      case "ArrowLeft":
        e.preventDefault();
        shiftFocus(addDays(focusedDate, -1));
        break;
      case "ArrowRight":
        e.preventDefault();
        shiftFocus(addDays(focusedDate, 1));
        break;
      case "ArrowUp":
        e.preventDefault();
        shiftFocus(addDays(focusedDate, -7));
        break;
      case "ArrowDown":
        e.preventDefault();
        shiftFocus(addDays(focusedDate, 7));
        break;
      case "Home":
        e.preventDefault();
        shiftFocus(addDays(focusedDate, -focusedDate.getDay()));
        break;
      case "End":
        e.preventDefault();
        shiftFocus(addDays(focusedDate, 6 - focusedDate.getDay()));
        break;
      case "PageUp":
        e.preventDefault();
        shiftFocus(addMonths(focusedDate, -1));
        break;
      case "PageDown":
        e.preventDefault();
        shiftFocus(addMonths(focusedDate, 1));
        break;
      case "Enter":
      case " ":
        e.preventDefault();
        chooseDate(focusedDate);
        break;
      default:
        break;
    }
  };

  const canGoPrev = startOfMonth(viewMonth).getTime() > startOfMonth(minDate).getTime();
  const canGoNext = startOfMonth(viewMonth).getTime() < startOfMonth(maxDate).getTime();

  const activeSession = SESSIONS.find((s) => s.id === session)!;

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const next: FormErrors = {};
    if (!name.trim()) next.name = "Tell us who we're meeting.";
    if (!EMAIL_RE.test(email)) next.email = "Enter a valid email for the invite.";
    if (!selectedDate) next.date = "Pick a day from the calendar.";
    if (!selectedSlot) next.slot = "Choose an available time.";
    setErrors(next);
    if (Object.keys(next).length === 0) setSubmitted(true);
  };

  const reset = () => {
    setSubmitted(false);
    setSelectedDate(null);
    setSelectedSlot(null);
    setSession("discovery");
    setName("");
    setEmail("");
    setNotes("");
    setErrors({});
    setFocusedDate(today);
    setViewMonth(startOfMonth(today));
  };

  const ease = reduce ? undefined : ([0.22, 1, 0.36, 1] as const);

  const field =
    "w-full rounded-xl border border-zinc-300 bg-white/70 px-3.5 py-2.5 text-sm text-zinc-900 placeholder:text-zinc-400 shadow-sm outline-none transition focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-indigo-500/50 dark:border-zinc-700 dark:bg-zinc-900/60 dark:text-zinc-100 dark:placeholder:text-zinc-500";

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-100 px-4 py-16 text-zinc-900 sm:px-6 md:py-24 dark:from-zinc-950 dark:via-zinc-950 dark:to-black dark:text-zinc-100">
      <style>{`
        @keyframes fbkFadeUp {
          from { opacity: 0; transform: translateY(14px); }
          to { opacity: 1; transform: translateY(0); }
        }
        @keyframes fbkPop {
          0% { transform: scale(0.6); opacity: 0; }
          60% { transform: scale(1.08); opacity: 1; }
          100% { transform: scale(1); opacity: 1; }
        }
        @keyframes fbkDraw {
          to { stroke-dashoffset: 0; }
        }
        @keyframes fbkGlow {
          0%, 100% { opacity: 0.35; }
          50% { opacity: 0.6; }
        }
        .fbk-fade-up { animation: fbkFadeUp 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; }
        .fbk-glow { animation: fbkGlow 7s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .fbk-fade-up, .fbk-glow { animation: none !important; }
        }
      `}</style>

      {/* atmosphere */}
      <div
        aria-hidden="true"
        className="fbk-glow pointer-events-none absolute -top-24 left-1/2 h-72 w-[36rem] -translate-x-1/2 rounded-full bg-indigo-400/25 blur-3xl dark:bg-indigo-600/20"
      />
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 opacity-[0.04] dark:opacity-[0.07]"
        style={{
          backgroundImage:
            "radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)",
          backgroundSize: "22px 22px",
        }}
      />

      <div className="fbk-fade-up relative mx-auto max-w-5xl">
        <header className="mb-8 text-center">
          <span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium tracking-wide text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
            <span className="relative flex h-2 w-2">
              <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
              <span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
            </span>
            Booking open · usually replies within a day
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">
            Book a call with our team
          </h2>
          <p className="mx-auto mt-3 max-w-xl text-sm text-zinc-600 sm:text-base dark:text-zinc-400">
            Pick a day, grab a time that works, and tell us a little about the project.
            You&apos;ll get a calendar invite the moment you confirm.
          </p>
        </header>

        <div className="rounded-3xl border border-zinc-200 bg-white/80 p-1.5 shadow-xl shadow-zinc-900/5 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70 dark:shadow-black/40">
          <AnimatePresence mode="wait" initial={false}>
            {submitted ? (
              <motion.div
                key="confirmed"
                initial={{ opacity: 0, y: reduce ? 0 : 12 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: reduce ? 0 : -12 }}
                transition={{ duration: reduce ? 0 : 0.4, ease }}
                className="rounded-[1.35rem] px-6 py-14 text-center sm:px-10"
                role="status"
                aria-live="polite"
              >
                <div
                  className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-500/15"
                  style={reduce ? undefined : { animation: "fbkPop 0.5s ease-out both" }}
                >
                  <svg viewBox="0 0 24 24" className="h-8 w-8" aria-hidden="true">
                    <path
                      d="M5 13l4 4L19 7"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth={2.5}
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      className="text-emerald-600 dark:text-emerald-400"
                      style={
                        reduce
                          ? undefined
                          : {
                              strokeDasharray: 30,
                              strokeDashoffset: 30,
                              animation: "fbkDraw 0.5s ease-out 0.25s forwards",
                            }
                      }
                    />
                  </svg>
                </div>
                <h3 className="mt-6 text-2xl font-semibold tracking-tight">
                  You&apos;re booked in
                </h3>
                <p className="mx-auto mt-2 max-w-md text-sm text-zinc-600 dark:text-zinc-400">
                  We&apos;ve sent a confirmation and calendar invite to{" "}
                  <span className="font-medium text-zinc-900 dark:text-zinc-100">
                    {email}
                  </span>
                  . Here&apos;s what you locked in:
                </p>

                <dl className="mx-auto mt-7 grid max-w-md gap-px overflow-hidden rounded-2xl border border-zinc-200 bg-zinc-200 text-left text-sm dark:border-zinc-800 dark:bg-zinc-800">
                  {[
                    { k: "Session", v: `${activeSession.label} · ${activeSession.duration}` },
                    { k: "Date", v: selectedDate ? formatLong(selectedDate) : "—" },
                    { k: "Time", v: selectedSlot ? to12h(selectedSlot) : "—" },
                    { k: "Attendee", v: name },
                  ].map((row) => (
                    <div
                      key={row.k}
                      className="flex items-center justify-between gap-4 bg-white px-4 py-3 dark:bg-zinc-900"
                    >
                      <dt className="text-zinc-500 dark:text-zinc-400">{row.k}</dt>
                      <dd className="text-right font-medium text-zinc-900 dark:text-zinc-100">
                        {row.v}
                      </dd>
                    </div>
                  ))}
                </dl>

                <button
                  type="button"
                  onClick={reset}
                  className="mt-8 inline-flex items-center justify-center rounded-xl border border-zinc-300 bg-white px-5 py-2.5 text-sm font-medium text-zinc-800 shadow-sm outline-none transition hover:bg-zinc-50 focus-visible:ring-2 focus-visible:ring-indigo-500/60 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700"
                >
                  Book another call
                </button>
              </motion.div>
            ) : (
              <motion.form
                key="form"
                onSubmit={handleSubmit}
                noValidate
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                transition={{ duration: reduce ? 0 : 0.3 }}
                className="grid gap-1.5 md:grid-cols-[minmax(0,1.05fr)_minmax(0,1fr)]"
              >
                {/* ---------------- left: calendar + slots ---------------- */}
                <div className="rounded-[1.35rem] bg-zinc-50/70 p-5 sm:p-6 dark:bg-zinc-950/40">
                  <div className="mb-4 flex items-center justify-between">
                    <div
                      id={`${uid}-month`}
                      aria-live="polite"
                      className="text-sm font-semibold"
                    >
                      {MONTHS[viewMonth.getMonth()]} {viewMonth.getFullYear()}
                    </div>
                    <div className="flex gap-1">
                      <button
                        type="button"
                        onClick={() => canGoPrev && setViewMonth(addMonths(viewMonth, -1))}
                        disabled={!canGoPrev}
                        aria-label="Previous month"
                        className="flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-200 bg-white text-zinc-600 outline-none transition hover:bg-zinc-100 focus-visible:ring-2 focus-visible:ring-indigo-500/60 disabled:cursor-not-allowed disabled:opacity-40 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800"
                      >
                        <svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden="true">
                          <path
                            d="M15 18l-6-6 6-6"
                            fill="none"
                            stroke="currentColor"
                            strokeWidth={2}
                            strokeLinecap="round"
                            strokeLinejoin="round"
                          />
                        </svg>
                      </button>
                      <button
                        type="button"
                        onClick={() => canGoNext && setViewMonth(addMonths(viewMonth, 1))}
                        disabled={!canGoNext}
                        aria-label="Next month"
                        className="flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-200 bg-white text-zinc-600 outline-none transition hover:bg-zinc-100 focus-visible:ring-2 focus-visible:ring-indigo-500/60 disabled:cursor-not-allowed disabled:opacity-40 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800"
                      >
                        <svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden="true">
                          <path
                            d="M9 18l6-6-6-6"
                            fill="none"
                            stroke="currentColor"
                            strokeWidth={2}
                            strokeLinecap="round"
                            strokeLinejoin="round"
                          />
                        </svg>
                      </button>
                    </div>
                  </div>

                  <div
                    role="grid"
                    aria-labelledby={`${uid}-month`}
                    ref={gridRef}
                    onKeyDown={onGridKeyDown}
                  >
                    <div role="row" className="mb-1 grid grid-cols-7">
                      {WEEKDAYS.map((w) => (
                        <div
                          key={w}
                          role="columnheader"
                          aria-label={w}
                          className="py-1.5 text-center text-[0.7rem] font-medium uppercase tracking-wider text-zinc-400 dark:text-zinc-500"
                        >
                          {w.slice(0, 2)}
                        </div>
                      ))}
                    </div>

                    <div className="grid grid-cols-7 gap-1">
                      {cells.map((day) => {
                        const inMonth = isSameMonth(day, viewMonth);
                        const disabled = isDisabledDay(day);
                        const selected = selectedDate && isSameDay(day, selectedDate);
                        const isToday = isSameDay(day, today);
                        const tabbable = isSameDay(day, focusedDate);
                        const free = !disabled && inMonth ? countAvailable(day) : 0;
                        return (
                          <div role="gridcell" key={toKey(day)} className="relative">
                            <button
                              type="button"
                              data-date={toKey(day)}
                              tabIndex={tabbable ? 0 : -1}
                              disabled={disabled}
                              aria-pressed={!!selected}
                              aria-current={isToday ? "date" : undefined}
                              aria-label={`${formatLong(day)}${
                                disabled
                                  ? ", unavailable"
                                  : `, ${free} slot${free === 1 ? "" : "s"} open`
                              }`}
                              onClick={() => chooseDate(day)}
                              className={[
                                "relative flex aspect-square w-full items-center justify-center rounded-lg text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-indigo-500/70",
                                !inMonth ? "text-zinc-300 dark:text-zinc-700" : "",
                                disabled
                                  ? "cursor-not-allowed text-zinc-300 line-through decoration-1 dark:text-zinc-700"
                                  : "cursor-pointer",
                                selected
                                  ? "bg-indigo-600 font-semibold text-white shadow-md shadow-indigo-600/30 dark:bg-indigo-500"
                                  : !disabled && inMonth
                                    ? "text-zinc-700 hover:bg-indigo-50 dark:text-zinc-200 dark:hover:bg-indigo-500/15"
                                    : "",
                                !selected && isToday
                                  ? "ring-1 ring-inset ring-indigo-400 dark:ring-indigo-500"
                                  : "",
                              ].join(" ")}
                            >
                              {day.getDate()}
                              {!disabled && inMonth && !selected ? (
                                <span
                                  aria-hidden="true"
                                  className={[
                                    "absolute bottom-1 h-1 w-1 rounded-full",
                                    free <= 2
                                      ? "bg-amber-400 dark:bg-amber-500"
                                      : "bg-emerald-400 dark:bg-emerald-500",
                                  ].join(" ")}
                                />
                              ) : null}
                            </button>
                          </div>
                        );
                      })}
                    </div>
                  </div>

                  {/* legend */}
                  <div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[0.72rem] text-zinc-500 dark:text-zinc-400">
                    <span className="inline-flex items-center gap-1.5">
                      <span className="h-1.5 w-1.5 rounded-full bg-emerald-400 dark:bg-emerald-500" />
                      Open
                    </span>
                    <span className="inline-flex items-center gap-1.5">
                      <span className="h-1.5 w-1.5 rounded-full bg-amber-400 dark:bg-amber-500" />
                      Filling up
                    </span>
                    <span className="inline-flex items-center gap-1.5">
                      <span className="h-3 border-t border-zinc-400" />
                      Full / weekend
                    </span>
                  </div>

                  {/* time slots */}
                  <div className="mt-5">
                    <div className="mb-2.5 flex items-baseline justify-between">
                      <h3
                        id={`${uid}-slots`}
                        className="text-sm font-semibold text-zinc-800 dark:text-zinc-200"
                      >
                        Available times
                      </h3>
                      <span className="text-xs text-zinc-500 dark:text-zinc-400">
                        {selectedDate ? formatLong(selectedDate) : "Select a day first"}
                      </span>
                    </div>

                    {selectedDate ? (
                      <div
                        role="group"
                        aria-labelledby={`${uid}-slots`}
                        className="grid grid-cols-3 gap-2"
                      >
                        {slots.map((s) => {
                          const active = selectedSlot === s.time;
                          return (
                            <button
                              key={s.time}
                              type="button"
                              disabled={!s.available}
                              aria-pressed={active}
                              onClick={() => {
                                setSelectedSlot(s.time);
                                setErrors((e) => ({ ...e, slot: undefined }));
                              }}
                              className={[
                                "rounded-xl border px-2 py-2 text-sm font-medium tabular-nums outline-none transition focus-visible:ring-2 focus-visible:ring-indigo-500/60",
                                s.available
                                  ? active
                                    ? "border-indigo-600 bg-indigo-600 text-white shadow-sm shadow-indigo-600/30 dark:border-indigo-500 dark:bg-indigo-500"
                                    : "border-zinc-200 bg-white text-zinc-700 hover:border-indigo-400 hover:text-indigo-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:border-indigo-500 dark:hover:text-indigo-300"
                                  : "cursor-not-allowed border-dashed border-zinc-200 bg-transparent text-zinc-300 line-through dark:border-zinc-800 dark:text-zinc-600",
                              ].join(" ")}
                            >
                              {to12h(s.time)}
                            </button>
                          );
                        })}
                      </div>
                    ) : (
                      <p className="rounded-xl border border-dashed border-zinc-300 px-4 py-6 text-center text-sm text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
                        Choose a highlighted day to see open times.
                      </p>
                    )}
                    {errors.date ? (
                      <p className="mt-2 text-xs font-medium text-rose-600 dark:text-rose-400">
                        {errors.date}
                      </p>
                    ) : null}
                    {errors.slot && !errors.date ? (
                      <p className="mt-2 text-xs font-medium text-rose-600 dark:text-rose-400">
                        {errors.slot}
                      </p>
                    ) : null}
                  </div>
                </div>

                {/* ---------------- right: details ---------------- */}
                <div className="flex flex-col gap-5 p-5 sm:p-6">
                  <fieldset>
                    <legend className="mb-2.5 text-sm font-semibold text-zinc-800 dark:text-zinc-200">
                      What should we cover?
                    </legend>
                    <div className="grid gap-2">
                      {SESSIONS.map((s) => {
                        const checked = session === s.id;
                        return (
                          <label
                            key={s.id}
                            className={[
                              "group flex cursor-pointer items-start gap-3 rounded-xl border p-3 transition",
                              checked
                                ? "border-indigo-500 bg-indigo-50/70 dark:border-indigo-500/70 dark:bg-indigo-500/10"
                                : "border-zinc-200 bg-white hover:border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:border-zinc-600",
                            ].join(" ")}
                          >
                            <input
                              type="radio"
                              name="session"
                              value={s.id}
                              checked={checked}
                              onChange={() => setSession(s.id)}
                              className="peer sr-only"
                            />
                            <span
                              aria-hidden="true"
                              className={[
                                "mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 transition peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500/60 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:peer-focus-visible:ring-offset-zinc-900",
                                checked
                                  ? "border-indigo-600 dark:border-indigo-400"
                                  : "border-zinc-300 dark:border-zinc-600",
                              ].join(" ")}
                            >
                              <span
                                className={[
                                  "h-2 w-2 rounded-full bg-indigo-600 transition dark:bg-indigo-400",
                                  checked ? "scale-100" : "scale-0",
                                ].join(" ")}
                              />
                            </span>
                            <span className="min-w-0 flex-1">
                              <span className="flex items-center justify-between gap-2">
                                <span className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
                                  {s.label}
                                </span>
                                <span className="shrink-0 text-xs font-semibold text-indigo-600 dark:text-indigo-300">
                                  {s.price} · {s.duration}
                                </span>
                              </span>
                              <span className="mt-0.5 block text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
                                {s.desc}
                              </span>
                            </span>
                          </label>
                        );
                      })}
                    </div>
                  </fieldset>

                  <div className="grid gap-4 sm:grid-cols-2">
                    <div>
                      <label
                        htmlFor={`${uid}-name`}
                        className="mb-1.5 block text-sm font-medium text-zinc-800 dark:text-zinc-200"
                      >
                        Full name
                      </label>
                      <input
                        id={`${uid}-name`}
                        type="text"
                        value={name}
                        autoComplete="name"
                        placeholder="Jordan Ellis"
                        aria-invalid={!!errors.name}
                        aria-describedby={errors.name ? `${uid}-name-err` : undefined}
                        onChange={(e) => {
                          setName(e.target.value);
                          if (errors.name) setErrors((p) => ({ ...p, name: undefined }));
                        }}
                        className={`${field} ${
                          errors.name
                            ? "border-rose-400 focus-visible:border-rose-500 focus-visible:ring-rose-500/40 dark:border-rose-500/60"
                            : ""
                        }`}
                      />
                      {errors.name ? (
                        <p
                          id={`${uid}-name-err`}
                          className="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
                        >
                          {errors.name}
                        </p>
                      ) : null}
                    </div>

                    <div>
                      <label
                        htmlFor={`${uid}-email`}
                        className="mb-1.5 block text-sm font-medium text-zinc-800 dark:text-zinc-200"
                      >
                        Work email
                      </label>
                      <input
                        id={`${uid}-email`}
                        type="email"
                        value={email}
                        autoComplete="email"
                        placeholder="jordan@company.com"
                        aria-invalid={!!errors.email}
                        aria-describedby={errors.email ? `${uid}-email-err` : undefined}
                        onChange={(e) => {
                          setEmail(e.target.value);
                          if (errors.email) setErrors((p) => ({ ...p, email: undefined }));
                        }}
                        className={`${field} ${
                          errors.email
                            ? "border-rose-400 focus-visible:border-rose-500 focus-visible:ring-rose-500/40 dark:border-rose-500/60"
                            : ""
                        }`}
                      />
                      {errors.email ? (
                        <p
                          id={`${uid}-email-err`}
                          className="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
                        >
                          {errors.email}
                        </p>
                      ) : null}
                    </div>
                  </div>

                  <div>
                    <label
                      htmlFor={`${uid}-notes`}
                      className="mb-1.5 block text-sm font-medium text-zinc-800 dark:text-zinc-200"
                    >
                      Anything to prep?{" "}
                      <span className="font-normal text-zinc-400">(optional)</span>
                    </label>
                    <textarea
                      id={`${uid}-notes`}
                      value={notes}
                      rows={3}
                      placeholder="A link to your current site, the outcome you're after, or a rough budget range."
                      onChange={(e) => setNotes(e.target.value)}
                      className={`${field} resize-none`}
                    />
                  </div>

                  {/* summary + submit */}
                  <div className="mt-auto rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3 dark:border-zinc-800 dark:bg-zinc-950/40">
                    <div
                      className="flex items-center gap-2 text-sm"
                      aria-live="polite"
                    >
                      <svg
                        viewBox="0 0 24 24"
                        className="h-4 w-4 shrink-0 text-indigo-500"
                        aria-hidden="true"
                      >
                        <rect
                          x="3"
                          y="4"
                          width="18"
                          height="17"
                          rx="2"
                          fill="none"
                          stroke="currentColor"
                          strokeWidth={1.8}
                        />
                        <path
                          d="M3 9h18M8 2v4M16 2v4"
                          fill="none"
                          stroke="currentColor"
                          strokeWidth={1.8}
                          strokeLinecap="round"
                        />
                      </svg>
                      {selectedDate && selectedSlot ? (
                        <span className="text-zinc-700 dark:text-zinc-300">
                          <span className="font-semibold text-zinc-900 dark:text-zinc-100">
                            {formatLong(selectedDate)}
                          </span>{" "}
                          at{" "}
                          <span className="font-semibold text-zinc-900 dark:text-zinc-100">
                            {to12h(selectedSlot)}
                          </span>{" "}
                          · {activeSession.duration}
                        </span>
                      ) : (
                        <span className="text-zinc-500 dark:text-zinc-400">
                          No time selected yet
                        </span>
                      )}
                    </div>
                  </div>

                  <button
                    type="submit"
                    className="group inline-flex w-full items-center justify-center gap-2 rounded-xl bg-indigo-600 px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-indigo-600/25 outline-none transition hover:bg-indigo-700 focus-visible:ring-2 focus-visible:ring-indigo-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white active:scale-[0.99] dark:focus-visible:ring-offset-zinc-900"
                  >
                    Confirm booking
                    <svg
                      viewBox="0 0 24 24"
                      className="h-4 w-4 transition-transform group-hover:translate-x-0.5"
                      aria-hidden="true"
                    >
                      <path
                        d="M5 12h14M13 6l6 6-6 6"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth={2}
                        strokeLinecap="round"
                        strokeLinejoin="round"
                      />
                    </svg>
                  </button>
                  <p className="text-center text-xs text-zinc-400 dark:text-zinc-500">
                    No charge to book. Reschedule anytime from your invite.
                  </p>
                </div>
              </motion.form>
            )}
          </AnimatePresence>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →