WalkInEntryForm
A form component designed for fast data entry in walk-in queuing systems. Receptionists can add customers by name, email, phone, service type, and provider—with recent customer names cached in localStorage for rapid re-entry. Shows estimated wait time and queue position upon successful submission.
Install
Section titled “Install”npx thebookingkit add walk-in-entry-formimport { WalkInEntryForm } from "@/components/walk-in-entry-form";
const walkInServices = [ { id: "haircut", title: "Haircut", durationMinutes: 30, priceCents: 2500, currency: "USD" }, { id: "beard", title: "Beard Trim", durationMinutes: 15, priceCents: 1500, currency: "USD" },];
const activeProviders = [ { id: "john", displayName: "John (Main)", acceptingWalkIns: true }, { id: "sarah", displayName: "Sarah (Back)", acceptingWalkIns: false },];
export function ReceptionistPanel() { const handleSubmit = async (values: WalkInEntryFormValues) => { const response = await fetch("/api/walk-ins", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(values), }); const data = await response.json(); return { queuePosition: data.position, estimatedWaitMinutes: data.wait }; };
return ( <WalkInEntryForm eventTypes={walkInServices} providers={activeProviders} onSubmit={handleSubmit} onCancel={() => console.log("Cancelled")} onServiceChange={(serviceId, durationMinutes) => { console.log(`Selected service: ${serviceId} (${durationMinutes} min)`); }} /> );}export interface WalkInEntryFormProps { /** Walk-in-enabled event types */ eventTypes: WalkInEventType[]; /** Providers currently accepting walk-ins */ providers: WalkInProvider[]; /** Called when form is submitted */ onSubmit: (values: WalkInEntryFormValues) => Promise<WalkInEntryResult>; /** Called when form is cancelled */ onCancel?: () => void; /** Pre-select a provider (e.g. from clicking a resource column) */ defaultProviderId?: string; /** Called when the selected service changes — provides duration in minutes */ onServiceChange?: (serviceId: string, durationMinutes: number) => void; /** Additional CSS class name */ className?: string; /** Inline styles */ style?: React.CSSProperties;}
export interface WalkInEventType { id: string; title: string; durationMinutes: number; priceCents?: number; currency?: string;}
export interface WalkInProvider { id: string; displayName: string; acceptingWalkIns: boolean;}
export interface WalkInEntryFormValues { customerName: string; customerEmail?: string; customerPhone?: string; eventTypeId: string; providerId: string; notes?: string;}
export interface WalkInEntryResult { queuePosition: number; estimatedWaitMinutes: number;}Source
Section titled “Source”import React, { useEffect, useState } from "react";import { useForm } from "react-hook-form";import { cn } from "../utils/cn.js";
/** A provider currently accepting walk-ins */export interface WalkInProvider { id: string; displayName: string; acceptingWalkIns: boolean;}
/** A walk-in-enabled event type / service */export interface WalkInEventType { id: string; title: string; durationMinutes: number; priceCents?: number; currency?: string;}
/** Values submitted by the walk-in entry form */export interface WalkInEntryFormValues { customerName: string; customerEmail?: string; customerPhone?: string; eventTypeId: string; providerId: string; notes?: string;}
/** Result returned after adding a walk-in */export interface WalkInEntryResult { queuePosition: number; estimatedWaitMinutes: number;}
/** Props for the WalkInEntryForm component */export interface WalkInEntryFormProps { /** Walk-in-enabled event types */ eventTypes: WalkInEventType[]; /** Providers currently accepting walk-ins */ providers: WalkInProvider[]; /** Called when form is submitted */ onSubmit: (values: WalkInEntryFormValues) => Promise<WalkInEntryResult>; /** Called when form is cancelled */ onCancel?: () => void; /** Pre-select a provider (e.g. from clicking a resource column) */ defaultProviderId?: string; /** Called when the selected service changes — provides duration in minutes */ onServiceChange?: (serviceId: string, durationMinutes: number) => void; /** Additional CSS class name */ className?: string; /** Inline styles */ style?: React.CSSProperties;}
const RECENT_CUSTOMERS_KEY = "tbk-recent-walkins";const MAX_RECENT = 10;
/** * Compact form for adding walk-in customers to the queue. * * Designed for quick entry by receptionists. Remembers recent customer * names in localStorage for fast re-entry. Shows estimated wait time * on successful submission. * * @example * ```tsx * <WalkInEntryForm * eventTypes={walkInServices} * providers={activeProviders} * onSubmit={async (values) => { * const result = await addWalkIn(values); * return { queuePosition: result.position, estimatedWaitMinutes: result.wait }; * }} * /> * ``` */export function WalkInEntryForm({ eventTypes, providers, onSubmit, onCancel, defaultProviderId, onServiceChange, className, style,}: WalkInEntryFormProps) { const [result, setResult] = useState<WalkInEntryResult | null>(null); const [recentNames, setRecentNames] = useState<string[]>([]);
const acceptingProviders = providers.filter((p) => p.acceptingWalkIns);
// Use defaultProviderId if it matches an accepting provider, otherwise first available const initialProviderId = defaultProviderId && acceptingProviders.some((p) => p.id === defaultProviderId) ? defaultProviderId : acceptingProviders[0]?.id ?? "";
const { register, handleSubmit, watch, formState: { errors, isSubmitting }, setError, reset, } = useForm<WalkInEntryFormValues>({ defaultValues: { eventTypeId: eventTypes[0]?.id ?? "", providerId: initialProviderId, }, });
// Load recent customers from localStorage useEffect(() => { try { const stored = localStorage.getItem(RECENT_CUSTOMERS_KEY); if (stored) setRecentNames(JSON.parse(stored)); } catch { // Ignore localStorage errors } }, []);
const saveRecentName = (name: string) => { try { const updated = [name, ...recentNames.filter((n) => n !== name)].slice( 0, MAX_RECENT, ); setRecentNames(updated); localStorage.setItem(RECENT_CUSTOMERS_KEY, JSON.stringify(updated)); } catch { // Ignore localStorage errors } };
const selectedEventTypeId = watch("eventTypeId"); const selectedEventType = eventTypes.find( (et) => et.id === selectedEventTypeId, );
// Notify parent when service selection changes useEffect(() => { if (selectedEventType && onServiceChange) { onServiceChange(selectedEventType.id, selectedEventType.durationMinutes); } }, [selectedEventTypeId]);
const handleFormSubmit = async (values: WalkInEntryFormValues) => { // Validate provider is accepting const provider = providers.find((p) => p.id === values.providerId); if (!provider?.acceptingWalkIns) { setError("providerId", { message: "This provider is not currently accepting walk-ins.", }); return; }
try { const res = await onSubmit(values); saveRecentName(values.customerName); setResult(res); } catch (err) { setError("root", { message: err instanceof Error ? err.message : "Failed to add walk-in.", }); } };
const handleAddAnother = () => { setResult(null); reset(); };
// Show success state if (result) { return ( <div className={cn("tbk-walkin-success", className)} style={style}> <div className="tbk-walkin-success-icon" aria-hidden="true"> ✓ </div> <h3 className="tbk-walkin-success-title">Added to Queue</h3> <dl className="tbk-detail-list"> <dt>Position</dt> <dd>#{result.queuePosition}</dd> <dt>Estimated Wait</dt> <dd> {result.estimatedWaitMinutes === 0 ? "No wait — next up!" : `~${result.estimatedWaitMinutes} min`} </dd> </dl> <div className="tbk-form-actions"> <button type="button" className="tbk-button-primary" onClick={handleAddAnother} > Add Another </button> {onCancel && ( <button type="button" className="tbk-button-secondary" onClick={onCancel} > Close </button> )} </div> </div> ); }
return ( <form className={cn("tbk-walkin-entry-form", className)} style={style} onSubmit={handleSubmit(handleFormSubmit)} noValidate > <h2 className="tbk-form-title">Add Walk-In</h2>
{/* Customer Name */} <div className="tbk-field"> <label htmlFor="wi-name" className="tbk-label"> Customer Name <span aria-hidden="true">*</span> </label> <input id="wi-name" type="text" className="tbk-input" placeholder="Customer name" list="wi-recent-names" autoFocus {...register("customerName", { required: "Customer name is required", })} /> {recentNames.length > 0 && ( <datalist id="wi-recent-names"> {recentNames.map((name) => ( <option key={name} value={name} /> ))} </datalist> )} {errors.customerName ? ( <p className="tbk-error">{errors.customerName.message}</p> ) : null} </div>
{/* Phone or Email (optional) */} <div className="tbk-field-row"> <div className="tbk-field"> <label htmlFor="wi-phone" className="tbk-label"> Phone </label> <input id="wi-phone" type="tel" className="tbk-input" placeholder="+1 555 000 0000" {...register("customerPhone")} /> </div> <div className="tbk-field"> <label htmlFor="wi-email" className="tbk-label"> Email </label> <input id="wi-email" type="email" className="tbk-input" placeholder="email@example.com" {...register("customerEmail")} /> </div> </div>
{/* Service Selector */} <div className="tbk-field"> <label htmlFor="wi-service" className="tbk-label"> Service <span aria-hidden="true">*</span> </label> <select id="wi-service" className="tbk-select" {...register("eventTypeId", { required: "Please select a service" })} > {eventTypes.map((et) => ( <option key={et.id} value={et.id}> {et.title} ({et.durationMinutes} min) </option> ))} </select> {selectedEventType?.priceCents != null && selectedEventType.priceCents > 0 && ( <p className="tbk-field-hint"> Price:{" "} {formatPrice( selectedEventType.priceCents, selectedEventType.currency, )} </p> )} {errors.eventTypeId ? ( <p className="tbk-error">{errors.eventTypeId.message}</p> ) : null} </div>
{/* Provider Selector (only if multiple) */} {acceptingProviders.length > 1 && ( <div className="tbk-field"> <label htmlFor="wi-provider" className="tbk-label"> Provider <span aria-hidden="true">*</span> </label> <select id="wi-provider" className="tbk-select" {...register("providerId", { required: "Please select a provider", })} > {acceptingProviders.map((p) => ( <option key={p.id} value={p.id}> {p.displayName} </option> ))} </select> {errors.providerId ? ( <p className="tbk-error">{errors.providerId.message}</p> ) : null} </div> )}
{/* Notes */} <div className="tbk-field"> <label htmlFor="wi-notes" className="tbk-label"> Notes </label> <textarea id="wi-notes" className="tbk-textarea" rows={2} placeholder="Optional notes..." {...register("notes")} /> </div>
{errors.root ? ( <div className="tbk-alert tbk-alert-error" role="alert"> {errors.root.message} </div> ) : null}
<div className="tbk-form-actions"> <button type="submit" className="tbk-button-primary" disabled={isSubmitting || acceptingProviders.length === 0} > {isSubmitting ? "Adding..." : "Add to Queue"} </button> {onCancel && ( <button type="button" className="tbk-button-secondary" onClick={onCancel} disabled={isSubmitting} > Cancel </button> )} </div>
{acceptingProviders.length === 0 && ( <p className="tbk-alert tbk-alert-warning"> No providers are currently accepting walk-ins. </p> )} </form> );}
/** Format cents to a display price */function formatPrice(cents: number, currency?: string): string { const amount = (cents / 100).toFixed(2); const symbol = currency === "GBP" ? "\u00A3" : currency === "EUR" ? "\u20AC" : "$"; return `${symbol}${amount}`;}