← Files WixARCHIVED FILE

skills/wix-headless-templates/restaurants/project/src/wix/restaurants/reservation-store.ts

10.9 KB · Oct 9, 2026 · 12:02 UTC

↓ Download file

// The table-reservation state machine as a framework-free store — the logic behind useReservation,
// usable from React (useReservation wraps it), from a static page or Vue/Svelte, or as the
// specification for a port. Location, date + party size (in the LOCATION's timezone), AVAILABLE
// slots, then one of two paths Wix's own code takes: AUTOMATIC approval → a 10-minute hold with a
// countdown → the details form → reserve; MANUAL approval (or a large party) → the details form →
// one createReservation, no hold. All correctness (AVAILABLE-only slots, hold → reserve with the
// revision, the form rules from the location's configuration, the real status) lives in the data
// layer; this store orchestrates. Browser-only: availability is time-specific. One store per
// mounted surface: createReservationStore(), `start()` when mounted, `stop()` when unmounted.
import { completeReservation, fetchReservationLocations, fetchReservationSlots, holdReservation, requestReservation } from "./reservations";
import { holdSecondsLeft as secondsLeft, resolveApproval, validateReservee } from "./reservations-core";
import { zonedDayKey, zonedIso } from "./time-core";
import type { ReservationConfirmation, ReservationHold, ReservationLocationInfo, ReservationReservee, ReservationSlot } from "./types";

/** Everything a reservation surface renders from. Read it with getState() or through a subscription. */
export interface ReservationState {
  /** null while loading; [] when Table Reservations isn't set up (honest empty state). */
  locations: ReservationLocationInfo[] | null;
  /** The active location (default first). Render a picker only when locations.length > 1. */
  location: ReservationLocationInfo | null;
  /** "YYYY-MM-DD" in the location's timezone, for a date input. */
  date: string;
  /** "HH:mm" anchor for the slot fan-out, wall clock at the restaurant. */
  time: string;
  /** Clamped to the location's partySizeMin/Max. */
  partySize: number;
  /** How this party at this location is approved: AUTOMATIC → hold then reserve; MANUAL → request, pending approval. */
  approval: "AUTOMATIC" | "MANUAL";
  /** AVAILABLE slots for the current query; null until findSlots ran. */
  slots: ReservationSlot[] | null;
  /** The slot the visitor picked (both paths) — render the details form when set. */
  selectedSlot: ReservationSlot | null;
  /** Set after a successful hold (AUTOMATIC path only) — the visitor has 10 minutes to confirm. */
  held: ReservationHold | null;
  /** Seconds until the hold lapses, ticking; null when nothing is held. */
  holdSecondsLeft: number | null;
  reservee: ReservationReservee;
  /** Validation messages per field (firstName, lastName, phone, email, custom:<id>) against the location's form; {} when valid. */
  fieldErrors: Record<string, string>;
  /** True when a slot is picked (and held, on the AUTOMATIC path), the form validates, and nothing is in flight — gate the CTA. */
  canConfirm: boolean;
  /** Set on success. Only outcome "confirmed" is a confirmed table; "pending" awaits the restaurant. */
  confirmed: ReservationConfirmation | null;
  loading: boolean;
  error: string | null;
}

export interface ReservationStore {
  getState(): ReservationState;
  subscribe(listener: () => void): () => void;
  /** Load the locations. Call once when mounted (a browser). */
  start(): void;
  stop(): void;
  setLocationId(id: string): void;
  setDate(date: string): void;
  setTime(time: string): void;
  setPartySize(size: number): void;
  findSlots(): Promise<void>;
  /** Pick a slot: AUTOMATIC approval holds it for 10 minutes; MANUAL approval just selects it (no hold). Then render the form. */
  holdSlot(slot: ReservationSlot): Promise<void>;
  setReserveeField(field: "firstName" | "lastName" | "phone" | "email", value: string): void;
  setMarketingConsent(value: boolean): void;
  setCustomField(id: string, value: string): void;
  /** Reserve the held slot, or send the approval request — by `approval`. */
  confirm(): Promise<void>;
  /** Back to slot picking (keeps date/party). */
  reset(): void;
}

export function createReservationStore(): ReservationStore {
  let locations: ReservationLocationInfo[] | null = null;
  let locationId: string | null = null;
  let date = zonedDayKey(new Date(), "");
  let time = "19:00";
  let partySize = 2;
  let slots: ReservationSlot[] | null = null;
  let selectedSlot: ReservationSlot | null = null;
  let held: ReservationHold | null = null;
  let holdSecondsLeft: number | null = null;
  let reservee: ReservationReservee = { firstName: "", phone: "", lastName: "", email: "", customFields: {} };
  let confirmed: ReservationConfirmation | null = null;
  let loading = false;
  let error: string | null = null;
  let started = false;
  let timer: ReturnType<typeof setInterval> | null = null;
  const listeners = new Set<() => void>();
  let snapshot: ReservationState | null = null;
  const emit = () => {
    snapshot = null;
    for (const fn of listeners) fn();
  };

  const currentLocation = (): ReservationLocationInfo | null => locations?.find((l) => l.id === locationId) ?? locations?.[0] ?? null;
  const approvalFor = (loc: ReservationLocationInfo | null): "AUTOMATIC" | "MANUAL" => (loc ? resolveApproval(loc, partySize) : "AUTOMATIC");

  const clearTimer = () => {
    if (timer) clearInterval(timer);
    timer = null;
    holdSecondsLeft = null;
  };
  const startTimer = () => {
    clearTimer();
    if (!held) return;
    holdSecondsLeft = secondsLeft(held);
    timer = setInterval(() => {
      if (!held) return clearTimer();
      holdSecondsLeft = secondsLeft(held);
      if (holdSecondsLeft <= 0) {
        // The hold lapsed: the reservation can't be reserved any more — back to slot picking.
        held = null; selectedSlot = null; clearTimer();
        error = "The hold expired — pick a time again.";
      }
      emit();
    }, 1000);
  };

  /** Apply a (new) location: date in its zone, party clamped to its bounds, the form's defaults. */
  const adoptLocation = (loc: ReservationLocationInfo | null) => {
    if (!loc) return;
    date = zonedDayKey(new Date(), loc.timeZone);
    partySize = Math.min(Math.max(partySize, loc.partySizeMin), Math.max(loc.partySizeMax, loc.partySizeMin));
    reservee = { ...reservee, marketingConsent: loc.form.marketingCheckbox ? loc.form.marketingCheckbox.checkedByDefault : undefined };
  };

  function getState(): ReservationState {
    if (snapshot) return snapshot;
    const location = currentLocation();
    const approval = approvalFor(location);
    const check = location ? validateReservee(reservee, location.form) : { ok: false, errors: {} };
    const picked = approval === "MANUAL" ? !!selectedSlot : !!held;
    snapshot = {
      locations,
      location,
      date,
      time,
      partySize,
      approval,
      slots,
      selectedSlot,
      held,
      holdSecondsLeft,
      reservee,
      fieldErrors: check.errors,
      canConfirm: picked && check.ok && !loading,
      confirmed,
      loading,
      error,
    };
    return snapshot;
  }

  const fail = (e: unknown) => (e instanceof Error ? e.message : String(e));

  return {
    getState,
    subscribe(fn) {
      listeners.add(fn);
      return () => listeners.delete(fn);
    },
    start() {
      if (started) return;
      started = true;
      fetchReservationLocations()
        .then((locs) => {
          if (!started) return;
          locations = locs;
          locationId ??= locs[0]?.id ?? null;
          adoptLocation(currentLocation());
          emit();
        })
        .catch((e) => {
          if (!started) return;
          locations = [];
          error = fail(e);
          emit();
        });
    },
    stop() {
      started = false;
      clearTimer();
    },
    setLocationId(id) {
      if (id === locationId) return;
      locationId = id;
      slots = null; selectedSlot = null; held = null; clearTimer();
      adoptLocation(currentLocation());
      emit();
    },
    setDate(next) { date = next; emit(); },
    setTime(next) { time = next; emit(); },
    setPartySize(size) {
      const loc = currentLocation();
      const min = loc?.partySizeMin ?? 1;
      const max = Math.max(loc?.partySizeMax ?? 1, min);
      partySize = Math.min(Math.max(size, min), max);
      emit();
    },
    async findSlots() {
      const loc = currentLocation();
      if (!loc) return;
      error = null; slots = null; selectedSlot = null; held = null; confirmed = null; loading = true; clearTimer();
      emit();
      try {
        // The anchor is the restaurant's wall clock, not the visitor's.
        const aroundIso = zonedIso(date, time, loc.timeZone);
        if (!aroundIso) throw new Error("Pick a date and a time.");
        slots = await fetchReservationSlots(loc.id, aroundIso, partySize, { timeZone: loc.timeZone });
      } catch (e) {
        slots = [];
        error = fail(e);
      } finally {
        loading = false;
        emit();
      }
    },
    async holdSlot(slot) {
      const loc = currentLocation();
      if (!loc) return;
      error = null;
      // Manual approval (the location's mode, or the slot says so): no hold — the form leads to a request.
      if (approvalFor(loc) === "MANUAL" || slot.manualApproval) {
        selectedSlot = slot; held = null; clearTimer();
        emit();
        return;
      }
      loading = true;
      emit();
      try {
        held = await holdReservation(loc.id, slot.startIso, partySize);
        selectedSlot = slot;
        startTimer();
      } catch (e) {
        error = fail(e);
      } finally {
        loading = false;
        emit();
      }
    },
    setReserveeField(field, value) {
      reservee = { ...reservee, [field]: value };
      emit();
    },
    setMarketingConsent(value) {
      reservee = { ...reservee, marketingConsent: value };
      emit();
    },
    setCustomField(id, value) {
      reservee = { ...reservee, customFields: { ...(reservee.customFields ?? {}), [id]: value } };
      emit();
    },
    async confirm() {
      const loc = currentLocation();
      if (!loc || !selectedSlot) return;
      const manual = approvalFor(loc) === "MANUAL" || selectedSlot.manualApproval || !held;
      if (!manual && !held) return;
      error = null; loading = true;
      emit();
      try {
        confirmed = manual
          ? await requestReservation(loc.id, selectedSlot.startIso, partySize, reservee, loc.form)
          : await completeReservation(held!, reservee, loc.form);
        held = null; selectedSlot = null; clearTimer();
      } catch (e) {
        error = fail(e);
        // A validation message keeps the form; anything else on the hold path means the hold is gone.
        if (!manual && !/required|valid|too long|country code/i.test(error)) {
          held = null; selectedSlot = null; clearTimer();
        }
      } finally {
        loading = false;
        emit();
      }
    },
    reset() {
      held = null; selectedSlot = null; confirmed = null; slots = null; error = null; clearTimer();
      emit();
    },
  };
}

SHA-256: 175adbaeca3c63a4cbfed47074fa6b366349d448b675472bbf74fb997732e6f1