Documentation

Build a booking site

A reservation/booking site backed by the CRM events and reservations modules — the yugo pattern.

A booking site has three jobs: show what's available, take a reservation, and remind people they made one. AppEngine ships the data layer (events, reservations, slot generation, queue) and a Next.js app does the rest. This tutorial walks through the same wiring yugo uses, narrowed to the parts you'll keep.

What you're building

  • Service catalog driven by crm/reservations/definitions
  • Live availability via crm/reservations/slots
  • Reservation creation against crm/reservations/create
  • Optional walk-in queue via crm/service-request-queue/join
  • Confirmation email + ICS attachment fired by AppEngine workflows

The reservation system is one of the AppEngine CRM sub-modules. A definition is a bookable thing (a haircut, a table, a room). It has services, locations, attendants (the staff serving the reservation), and a settings object describing buffers, cancellation rules, and capacity.

Prerequisites

  • An AppEngine instance and an org id (signup)
  • Node 20+, a fresh Next.js 16 app
  • An admin login or API key with reservation permissions
npx create-next-app@latest booking-site --app --ts --tailwind
cd booking-site
npm install

Then drop in the AppEngine client. yugo's src/lib/ is the canonical pattern — copy appmint-client.ts, appmint-config.ts, appmint-endpoints.ts, proxy-utils.ts from there, or follow Add auth to a Next.js app.

The pattern below assumes the AppEngine client functions are already wired. If not, do the auth tutorial first — every endpoint here uses the same processRequest helper.

Step-by-step

  1. 1

    Define a reservable service

    In the admin UI, open CRM → Reservations → Definitions, or POST directly to crm/reservations/create-definition. A minimum definition:

    // POST /crm/reservations/create-definition
    {
      "data": {
        "name": "Haircut",
        "description": "30-minute appointment",
        "services": [
          { "id": "s1", "name": "Trim", "duration": 30, "price": 35 },
          { "id": "s2", "name": "Full cut + style", "duration": 60, "price": 70 }
        ],
        "locations": [
          { "id": "loc1", "name": "Downtown", "address": "100 Main St" }
        ],
        "attendants": [
          { "id": "a1", "name": "Sam", "serviceIds": ["s1", "s2"], "locationIds": ["loc1"] }
        ],
        "settings": {
          "bufferBefore": 5,
          "bufferAfter": 5,
          "cancellationPolicy": "24h",
          "maxAdvance": 30
        }
      }
    }
    

    Each definition gets an id you'll use in every subsequent call.

  2. 2

    Build a reservation API client

    Copy yugo/src/modules/reservation-system/api/reservation-api.ts into your project under src/modules/reservation-system/api/. The file is self-contained and only imports getAppEngineClient from your shared lib. The endpoints it wraps:

    // src/modules/reservation-system/api/reservation-api.ts (excerpted)
    const RESERVATION_ENDPOINTS = {
      SLOTS: 'crm/reservations/slots',
      DEFINITIONS: 'crm/reservations/definitions',
      CREATE: 'crm/reservations/create',
      UPDATE: 'crm/reservations/update',
      DELETE: 'crm/reservations/delete',
      GET: 'crm/reservations/get',
      CANCEL: 'crm/reservations/cancel',
      QUEUE_JOIN: 'crm/service-request-queue/join',
    };
    

    Expose typed helpers per call: getDefinitions, getAvailability, createReservation, cancelReservation. Never let UI code touch raw URLs.

  3. 3

    Render the service picker

    A booking flow is a four-step funnel: pick service → pick attendant (optional) → pick slot → confirm. The first step lists services from the definition.

    // src/app/booking/page.tsx
    import { getReservationAPI } from '@/modules/reservation-system/api';
    
    export default async function BookingPage() {
      const api = getReservationAPI();
      const definition = await api.getDefinition(process.env.RESERVATION_DEFINITION_ID!);
    
      return (
        <div className="mx-auto max-w-2xl py-12">
          <h1 className="text-2xl font-semibold">Book a {definition.name.toLowerCase()}</h1>
          <ul className="mt-6 grid gap-4">
            {definition.services?.map((s) => (
              <li key={s.id}>
                <a
                  href={`/booking/${s.id}`}
                  className="block rounded-lg border p-4 hover:border-blue-500"
                >
                  <div className="font-medium">{s.name}</div>
                  <div className="text-sm text-gray-500">
                    {s.duration} min · ${s.price}
                  </div>
                </a>
              </li>
            ))}
          </ul>
        </div>
      );
    }
    

    This is a server component — the call happens once, on the server, with the AppEngine session attached via cookie/JWT.

  4. 4

    Fetch and display available slots

    The slots endpoint takes a definition id, a service id, an optional attendant id, and a date range. AppEngine returns an array of { startTime, endTime, available, attendantId }.

    // src/components/SlotPicker.tsx — client component
    'use client';
    import { useEffect, useState } from 'react';
    import { getReservationAPI } from '@/modules/reservation-system/api';
    
    export function SlotPicker({
      definitionId,
      serviceId,
      date,
      onSelect,
    }: {
      definitionId: string;
      serviceId: string;
      date: string;
      onSelect: (slot: TimeSlot) => void;
    }) {
      const [slots, setSlots] = useState<TimeSlot[]>([]);
      const [loading, setLoading] = useState(true);
    
      useEffect(() => {
        getReservationAPI()
          .getAvailability({ definitionId, serviceId, date })
          .then((s) => {
            setSlots(s.filter((slot) => slot.available));
          })
          .finally(() => setLoading(false));
      }, [definitionId, serviceId, date]);
    
      if (loading) return <p>Checking availability...</p>;
    
      return (
        <div className="grid grid-cols-3 gap-2">
          {slots.map((slot) => (
            <button
              key={slot.startTime}
              onClick={() => onSelect(slot)}
              className="rounded border px-3 py-2 text-sm hover:bg-blue-50"
            >
              {new Date(slot.startTime).toLocaleTimeString([], {
                hour: '2-digit',
                minute: '2-digit',
              })}
            </button>
          ))}
        </div>
      );
    }
    

    The slot endpoint enforces every constraint defined on the definition: existing reservations, attendant calendars, business hours, holidays, and per-slot capacity. Don't reimplement that logic on the client.

  5. 5

    Submit the reservation

    When the user picks a slot and fills the customer-info form, send the whole package to crm/reservations/create. The customer doesn't need to be authenticated — AppEngine creates an anonymous customer record on first booking and links it by email.

    // src/modules/reservation-system/actions.ts
    'use server';
    import { getReservationAPI } from './api';
    
    export async function bookReservation(formData: FormData) {
      const api = getReservationAPI();
      const reservation = await api.createReservation({
        definitionId: formData.get('definitionId') as string,
        serviceId: formData.get('serviceId') as string,
        attendantId: formData.get('attendantId') as string | undefined,
        locationId: formData.get('locationId') as string,
        startTime: formData.get('startTime') as string,
        customer: {
          name: formData.get('customerName') as string,
          email: formData.get('customerEmail') as string,
          phone: formData.get('customerPhone') as string,
        },
        notes: formData.get('notes') as string | undefined,
      });
      return reservation;
    }
    

    The response includes id, confirmationCode, and state (usually pending or confirmed depending on definition settings). Pipe the user to a confirmation page using the id.

  6. 6

    Build the confirmation + manage page

    Reservations come back wrapped in the standard BaseModel. The data payload has state, startTime, endTime, customer, confirmationCode. Show those, plus a cancel button.

    // src/app/booking/[id]/page.tsx
    import { getReservationAPI } from '@/modules/reservation-system/api';
    
    export default async function ConfirmationPage({
      params,
    }: {
      params: Promise<{ id: string }>;
    }) {
      const { id } = await params;
      const r = await getReservationAPI().getReservation(id);
    
      return (
        <div className="mx-auto max-w-md py-12">
          <h1 className="text-xl font-semibold">You're booked</h1>
          <p className="mt-2 text-gray-600">Confirmation: {r.data.confirmationCode}</p>
          <dl className="mt-6 space-y-2">
            <div>
              <dt className="text-sm text-gray-500">When</dt>
              <dd>{new Date(r.data.startTime).toLocaleString()}</dd>
            </div>
            <div>
              <dt className="text-sm text-gray-500">Where</dt>
              <dd>{r.data.location?.name}</dd>
            </div>
          </dl>
        </div>
      );
    }
    

    A cancel action calls crm/reservations/cancel. AppEngine fires a reservation.cancelled event, which a workflow can pick up to send a refund or notification.

  7. 7

    Walk-in queue (optional)

    If the venue takes walk-ins, swap the slot picker for a queue form on busy days. The queue endpoint is crm/service-request-queue/join:

    await api.joinQueue({
      definitionId,
      serviceId,
      customer: { name, email, phone },
      estimatedWait: 25, // minutes; AppEngine recalculates as queue moves
    });
    

    AppEngine emits position updates over the chat WebSocket. Subscribe to those in a client component to give the user a live "you're #4 of 7" view.

  8. 8

    Add reminders

    The reservation create response triggers a reservation.created event. In the admin UI, open Automation → Workflows and create a workflow with that trigger. Add a delayed step ("24 hours before data.startTime") that sends an email and an SMS through Twilio. AppEngine handles the scheduling — you don't need a cron in your Next.js app.

    See Automation workflows for the full event catalog.

What's next

The yugo source has a full polished version of this flow including a calendar UI, demo accounts, and self-check-in screens. Worth reading in order:

  • src/modules/reservation-system/index.tsx — top-level shell
  • src/modules/reservation-system/reservation-calendar.tsx — month/week view
  • src/modules/reservation-system/walk-in-queue.tsx — live queue UI
  • src/modules/reservation-system/payment-checkout.tsx — Stripe handoff for paid reservations

For paid bookings, splice in the storefront checkout tutorial — the reservation id becomes the order's metadata.reservationId so the workflow that confirms the booking can wait for payment.succeeded.