← Files WixARCHIVED FILE

skills/wix-headless-templates/events/project/src/wix/events/registration-store.ts

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

↓ Download file

// The registration flow for one event as a framework-free store — the logic behind
// useEventRegistration, usable from React (the hook wraps it), from a static page's event view,
// from Vue/Svelte, or as the specification for a port. Branched on event.registrationType:
// TICKETING loads the tier picker (quantities per tier or per pricing option, a named price for
// donation tiers, Wix's running totals) and checkout() reserves → redirects to Wix's hosted
// checkout; RSVP renders the ORGANIZER'S form (event.rsvpForm) and submits it in place. All
// correctness (the visitor-public tier read, the reservation payload, the redirect callbacks,
// rsvpV2, the form body) lives in the data layer — this store orchestrates; the page owns how it
// looks.
//
// One store per event surface: createRegistrationStore(event), not a singleton. A static site
// passes `paths` so the hosted checkout returns to its files (`event-confirmation.html`).
import { fetchTicketTiers, startTicketCheckout, submitRsvp, type CheckoutPaths } from "./registration";
import {
  canSubmitRsvp,
  clampQuantity,
  errorMessage,
  guestPriceValid,
  rsvpFormOf,
  selectionKey,
  selectionTotals,
  ticketCount as countTickets,
  type RsvpValues,
} from "./registration-core";
import type { EventDetail, RegistrationResult, RsvpForm, SelectionTotals, TicketSelection, TicketTier } from "./types";

export type { RsvpValues };

export interface RegistrationStoreOptions {
  /** Where the hosted checkout returns to; defaults are the Astro routes. */
  paths?: CheckoutPaths;
}

export interface RegistrationState {
  /** TICKETING: tiers for the picker — null while loading (skeletons), [] honest empty. Other types: []. */
  tiers: TicketTier[] | null;
  /** Selected quantity per selection key — `tierId`, or `${tierId}:${optionId}` for a pricing option (0 when untouched). */
  quantities: Record<string, number>;
  /** GUEST (donation) tiers: the amount the visitor typed, per tier id. */
  guestPrices: Record<string, string>;
  /** The current selection as line items (quantity > 0 only). */
  selections: TicketSelection[];
  ticketCount: number;
  /** The event's cap on one order — the picker refuses more (`+` disabled at the cap). */
  ticketLimitPerOrder: number;
  /** Wix's running totals for the selection (formatted); null with nothing selected. The hosted checkout is authoritative. */
  totals: SelectionTotals | null;
  /** True when ≥ 1 ticket is selected, within the order cap, and every donation tier has a valid amount — gate the checkout CTA on this. */
  canCheckout: boolean;
  /** RSVP: the form to render — the organizer's (event.rsvpForm) or the built-in name + email. */
  rsvpForm: RsvpForm;
  /** RSVP answers keyed by input name (CHECKBOX answers are arrays). */
  rsvpValues: RsvpValues;
  /** RSVP: additional guests (only meaningful when rsvpForm.guestControl is set). */
  guestCount: number;
  guestNames: string[];
  /** True when the built-in identity and every mandatory input are filled — gate the RSVP CTA on this. */
  canRsvp: boolean;
  submitting: boolean;
  /** Set after an RSVP completes (kind "rsvpConfirmed"; status may be "WAITLIST"). */
  confirmed: RegistrationResult | null;
  error: string | null;
}

export interface RegistrationStore {
  getState(): RegistrationState;
  subscribe(listener: () => void): () => void;
  /** Load the tiers of a TICKETING event. Call once when mounted (a browser). */
  start(): void;
  /** Stop reacting; drop a late response. */
  stop(): void;
  /** Clamped to 0..limitPerCheckout (0 = sold out); ignored for tiers not on sale. `optionId` for an OPTIONS tier. Clears `error`. */
  setQuantity(tierId: string, quantity: number, optionId?: string): void;
  /** GUEST tiers: the amount the visitor pays (a decimal string). Clears `error`. */
  setGuestPrice(tierId: string, value: string): void;
  /** Answer one RSVP input by name (an array for CHECKBOX inputs). */
  setRsvpValue(inputName: string, value: string | string[]): void;
  /** RSVP: how many additional guests (0..guestControl.maxGuests) and their names. */
  setGuestCount(count: number): void;
  setGuestName(index: number, name: string): void;
  /** Reserves + redirects to the Wix-hosted checkout. On "redirect" the browser is navigating. Throws (and sets .error) on refusal. */
  checkout(): Promise<RegistrationResult>;
  /** attending=false only when event.rsvpResponseType is "YES_AND_NO". Throws (and sets .error) on refusal. */
  rsvp(attending?: boolean): Promise<RegistrationResult>;
}

export function createRegistrationStore(event: EventDetail, { paths }: RegistrationStoreOptions = {}): RegistrationStore {
  const ticketed = event.registrationType === "TICKETING";
  const rsvpForm = rsvpFormOf(event);
  let tiers: TicketTier[] | null = ticketed ? null : [];
  let quantities: Record<string, number> = {};
  let guestPrices: Record<string, string> = {};
  let rsvpValues: RsvpValues = {};
  let guestCount = 0;
  let guestNames: string[] = [];
  let submitting = false;
  let confirmed: RegistrationResult | null = null;
  let error: string | null = null;
  let started = false;
  const listeners = new Set<() => void>();
  let snapshot: RegistrationState | null = null;
  const emit = () => {
    snapshot = null;
    for (const fn of listeners) fn();
  };

  function currentSelections(): TicketSelection[] {
    return Object.entries(quantities)
      .filter(([, q]) => q > 0)
      .map(([key, quantity]) => {
        const [tierId, optionId] = key.split(":");
        const tier = (tiers ?? []).find((t) => t.id === tierId);
        return {
          tierId,
          quantity,
          ...(optionId ? { optionId } : {}),
          ...(tier?.pricingType === "GUEST" ? { guestPrice: guestPrices[tierId] ?? "" } : {}),
        };
      });
  }

  function getState(): RegistrationState {
    if (snapshot) return snapshot;
    const selections = currentSelections();
    const ticketCount = countTickets(selections);
    const guestPricesValid = selections.every((s) => {
      const tier = (tiers ?? []).find((t) => t.id === s.tierId);
      return !tier || guestPriceValid(tier, s.guestPrice);
    });
    snapshot = {
      tiers,
      quantities,
      guestPrices,
      selections,
      ticketCount,
      ticketLimitPerOrder: event.ticketLimitPerOrder,
      totals: selectionTotals(tiers ?? [], selections, event.taxSettings),
      canCheckout: ticketCount > 0 && ticketCount <= event.ticketLimitPerOrder && guestPricesValid,
      rsvpForm,
      rsvpValues,
      guestCount,
      guestNames,
      canRsvp: canSubmitRsvp(rsvpForm, rsvpValues, guestCount, true),
      submitting,
      confirmed,
      error,
    };
    return snapshot;
  }

  async function run(op: () => Promise<RegistrationResult>): Promise<RegistrationResult> {
    submitting = true;
    error = null;
    emit();
    try {
      const result = await op();
      if (result.kind === "redirect") {
        if (typeof window !== "undefined") window.location.href = result.url;
      } else {
        confirmed = result;
      }
      return result;
    } catch (e) {
      error = errorMessage(e);
      throw e;
    } finally {
      submitting = false;
      emit();
    }
  }

  return {
    getState,
    subscribe(fn) {
      listeners.add(fn);
      return () => listeners.delete(fn);
    },
    start() {
      if (started) return;
      started = true;
      if (!ticketed) return;
      fetchTicketTiers(event)
        .then((t) => {
          if (!started) return;
          tiers = t;
          emit();
        })
        .catch((e) => {
          if (!started) return;
          tiers = [];
          error = errorMessage(e);
          emit();
        });
    },
    stop() {
      started = false;
    },
    setQuantity(tierId, quantity, optionId) {
      const tier = (tiers ?? []).find((t) => t.id === tierId);
      if (!tier || !tier.available) return;
      if (optionId && !tier.options.some((o) => o.id === optionId)) return;
      const key = selectionKey(tierId, optionId);
      const clamped = clampQuantity(tier, quantity);
      // The event caps one order: never let the total pass it (Wix's reservation would refuse with an opaque error).
      const others = Object.entries(quantities).reduce((sum, [k, q]) => (k === key ? sum : sum + q), 0);
      const capped = Math.max(0, Math.min(clamped, event.ticketLimitPerOrder - others));
      quantities = { ...quantities, [key]: capped };
      error = capped < clamped ? `Up to ${event.ticketLimitPerOrder} tickets per order.` : null; // a stale error never outlives a selection change
      emit();
    },
    setGuestPrice(tierId, value) {
      guestPrices = { ...guestPrices, [tierId]: value };
      error = null;
      emit();
    },
    setRsvpValue(inputName, value) {
      rsvpValues = { ...rsvpValues, [inputName]: value };
      emit();
    },
    setGuestCount(count) {
      const max = rsvpForm.guestControl?.maxGuests ?? 0;
      guestCount = Math.max(0, Math.min(Math.trunc(count) || 0, max));
      guestNames = guestNames.slice(0, guestCount);
      emit();
    },
    setGuestName(index, name) {
      if (index < 0 || index >= guestCount) return;
      const next = guestNames.slice();
      while (next.length < guestCount) next.push("");
      next[index] = name;
      guestNames = next;
      emit();
    },
    checkout: () => run(() => startTicketCheckout(event, currentSelections(), paths, tiers ?? [])),
    rsvp: (attending = true) => run(() => submitRsvp(event, rsvpValues, attending ? "YES" : "NO", { guestCount, guestNames })),
  };
}

SHA-256: c8ecd9833e1209f70c78733f5e034b91788fabb9d9ee54eb3da91e59c9b95eb1