Skip to content

Full headless example

This component wires every part of the booking flow to the composables. It is deliberately unstyled: replace each element with your own primitives and classes and keep the bindings. It uses the useZaptimeInit composable from Getting started.

Render it client-side only (inside onMounted or <ClientOnly>).

vue
<template>
  <div>
    <p v-if="status === 'loading' || status === 'idle'">Loading…</p>
    <p v-else-if="status === 'disabled'">This event type is not accepting bookings.</p>
    <p v-else-if="status === 'error'">Calendar could not be loaded.</p>

    <template v-else>
      <!-- 1. Month grid -->
      <section v-if="view === 'calendar'">
        <header>
          <button type="button" :disabled="prevDisabled" @click="prev">‹</button>
          <span>{{ monthName }} {{ currentYear }}</span>
          <button type="button" :disabled="nextDisabled" @click="next">›</button>
        </header>

        <div v-if="state.loading">Loading days…</div>
        <div v-else>
          <div v-if="!state.monthHasTimeSlots">
            <p>{{ config.locale?.texts?.noTimeSlotAvailable }}</p>
            <button type="button" @click="next">{{ config.locale?.texts?.showNextMonth }}</button>
          </div>

          <div role="grid">
            <span v-for="h in state.headers" :key="h" role="columnheader">{{ h }}</span>
            <button
              v-for="(day, i) in state.days"
              :key="i"
              type="button"
              role="gridcell"
              :disabled="!day.date || day.isPast || !dayHasTimeSlot(day)"
              :aria-selected="isSelectedDay(day)"
              :data-today="day.isToday || undefined"
              @click="dayClicked(day)"
            >
              {{ day.label }}
            </button>
          </div>
        </div>

        <!-- 2. Time slots for the selected day -->
        <aside v-if="state.timeSlots.length">
          <h3>{{ getFormattedDayInMonth(state.timeSlots[0].start) }}</h3>
          <p>{{ config.locale?.texts?.choosePreferredTime }}</p>
          <button
            v-for="slot in state.timeSlots"
            :key="slot.start"
            type="button"
            :aria-pressed="isSelected(slot)"
            @click="pickSlot(slot)"
          >
            {{ getFormattedTime(slot.start) }} – {{ getFormattedTime(slot.end) }}
            <small v-if="slot.seats > 1">{{ slot.seats }} seats</small>
          </button>
        </aside>
        <p v-else-if="state.monthHasTimeSlots">{{ config.locale?.texts?.chooseDate }}</p>

        <!-- Optional preferences -->
        <label>
          Timezone
          <select :value="timezone" @change="onTimezoneChange">
            <option v-for="tz in timezones" :key="tz" :value="tz">{{ tz }}</option>
          </select>
        </label>
        <label>
          <input
            type="checkbox"
            :checked="hourCycle === 'h23'"
            @change="setHourCycle(hourCycle === 'h23' ? 'h11' : 'h23')"
          />
          24h
        </label>
      </section>

      <!-- 3. Booking form / reschedule confirmation -->
      <form v-else-if="view === 'form' && selectedTimeSlot" @submit.prevent="submit">
        <template v-if="reservation">
          <h2>{{ config.locale?.confirmationForm?.reschedulingEvent }}</h2>
          <p><s>{{ getFormattedDayInMonth(reservation.start) }} {{ getFormattedTime(reservation.start) }}</s></p>
        </template>
        <h2 v-else>{{ config.locale?.confirmationForm?.confirmBooking }}</h2>
        <p>
          {{ getFormattedDayInMonth(selectedTimeSlot.start) }},
          {{ getFormattedTime(selectedTimeSlot.start) }} – {{ getFormattedTime(selectedTimeSlot.end) }}
        </p>

        <template v-if="!reservation">
          <div v-for="field in bookingForm" :key="field.uuid">
            <label :for="field.uuid">{{ field.label }}</label>

            <textarea
              v-if="field.type === 'textarea'"
              :id="field.uuid" :required="field.required" :placeholder="field.placeholder"
              @input="setCustomFieldValue(field.uuid, ($event.target as HTMLTextAreaElement).value)"
            />
            <select
              v-else-if="field.type === 'select'"
              :id="field.uuid" :required="field.required"
              @change="setCustomFieldValue(field.uuid, ($event.target as HTMLSelectElement).value)"
            >
              <option value="" disabled selected>{{ field.placeholder }}</option>
              <option v-for="o in field.options" :key="o" :value="o">{{ o }}</option>
            </select>
            <select
              v-else-if="field.type === 'multiselect'"
              :id="field.uuid" :required="field.required" multiple
              @change="setCustomFieldValue(field.uuid, Array.from(($event.target as HTMLSelectElement).selectedOptions, (o) => o.value))"
            >
              <option v-for="o in field.options" :key="o" :value="o">{{ o }}</option>
            </select>
            <fieldset v-else-if="field.type === 'radio'">
              <label v-for="o in field.options" :key="o">
                <input type="radio" :name="field.uuid" :value="o" :required="field.required" @change="setCustomFieldValue(field.uuid, o)" />
                {{ o }}
              </label>
            </fieldset>
            <input
              v-else-if="field.type === 'checkbox' || field.type === 'switch'"
              :id="field.uuid" type="checkbox" :required="field.required"
              @change="setCustomFieldValue(field.uuid, ($event.target as HTMLInputElement).checked)"
            />
            <input
              v-else
              :id="field.uuid"
              :type="field.type === 'phone' ? 'tel' : field.type"
              :required="field.required || field.mergeTag === 'EMAIL'"
              :placeholder="field.placeholder"
              @input="setCustomFieldValue(field.uuid, field.type === 'number' ? Number(($event.target as HTMLInputElement).value) : ($event.target as HTMLInputElement).value)"
            />
          </div>

          <div v-if="guestsEnabled">
            <div v-for="(guest, i) in guests" :key="i">
              <input type="email" :value="guest" placeholder="guest@example.com" @input="updateGuest(i, ($event.target as HTMLInputElement).value)" />
              <button type="button" @click="removeGuest(i)">×</button>
            </div>
            <button v-if="canAddGuest" type="button" @click="addGuest()">
              {{ config.locale?.confirmationForm?.addGuests }}
            </button>
          </div>
        </template>

        <p v-if="submitError" role="alert">{{ submitError }}</p>

        <button type="button" :disabled="submitting" @click="view = 'calendar'">
          {{ config.locale?.confirmationForm?.buttons?.goBack }}
        </button>
        <button type="submit" :disabled="submitting">
          {{ reservation ? config.locale?.confirmationForm?.buttons?.reschedule : config.locale?.confirmationForm?.buttons?.confirmBooking }}
        </button>
      </form>

      <!-- 4. Success -->
      <section v-else-if="view === 'success'">
        <h2>Booked</h2>
        <p v-if="selectedTimeSlot">
          {{ getFormattedDayInMonth(selectedTimeSlot.start) }},
          {{ getFormattedTime(selectedTimeSlot.start) }}
        </p>
      </section>
    </template>
  </div>
</template>

<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import {
  book,
  reschedule,
  stopReservationRefresh,
  useBookingForm,
  useCalendar,
  useConfig,
  useCurrentTimezone,
  useDateFormatters,
  useGuests,
  useHourCycle,
  useLocations,
  useReservationReschedule,
  useSelectedTimeSlot,
  SlotNoLongerAvailableError,
  RescheduleNotAllowedError,
  slotNoLongerAvailableText,
  rescheduleNotAllowedText,
  type ReservationResponse,
  type TimeSlot,
  type ZaptimeConfig,
} from "@zaptime/core";
import { useZaptimeInit } from "./useZaptimeInit";

const props = defineProps<{ config: ZaptimeConfig; calendarId?: string }>();
const emit = defineEmits<{
  (e: "booked", reservation: ReservationResponse): void;
  (e: "slot-changed", slot: TimeSlot | undefined): void;
}>();

const { status, init } = useZaptimeInit(props.config, props.calendarId);
const { config } = useConfig(props.calendarId);
const {
  state, prev, next, prevDisabled, nextDisabled, monthName, currentYear,
  dayClicked, dayHasTimeSlot, isSelectedDay, selectTimeSlot, isSelected, getDays,
} = useCalendar(props.calendarId);
const { selectedTimeSlot, setSelectedTimeSlot } = useSelectedTimeSlot(props.calendarId);
const { bookingForm, setCustomFieldValue, collectFormValues } = useBookingForm(props.calendarId);
const { guests, guestsEnabled, canAddGuest, addGuest, removeGuest, updateGuest } = useGuests(props.calendarId);
const { locations } = useLocations(props.calendarId);
const { reservation } = useReservationReschedule(props.calendarId);
const { getFormattedTime, getFormattedDayInMonth } = useDateFormatters();
const { timezone, setTimezone } = useCurrentTimezone();
const { hourCycle, setHourCycle } = useHourCycle();

const view = ref<"calendar" | "form" | "success">("calendar");
const submitting = ref(false);
const submitError = ref<string | null>(null);
const timezones = ref<string[]>([]);

function pickSlot(slot: TimeSlot) {
  selectTimeSlot(slot);
  emit("slot-changed", slot);
  view.value = "form";
}

async function onTimezoneChange(e: Event) {
  setTimezone((e.target as HTMLSelectElement).value);
  await getDays(); // slots may move across days
}

async function submit() {
  submitting.value = true;
  submitError.value = null;
  try {
    const res = reservation.value
      ? await reschedule(props.calendarId)
      : await book({
          calendarId: props.calendarId,
          location: locations.value[0],
          ...collectFormValues(),
        });

    if (!res.success) throw new Error("validation");

    emit("booked", res);
    if (!config.value.redirectAfterBookingUrl) view.value = "success";
  } catch (err) {
    if (err instanceof SlotNoLongerAvailableError) {
      submitError.value = slotNoLongerAvailableText(config.value.locale);
      getDays().catch(() => {});
    } else if (err instanceof RescheduleNotAllowedError) {
      submitError.value = rescheduleNotAllowedText(config.value.locale);
    } else {
      submitError.value = "Booking failed. Please try again.";
    }
  } finally {
    submitting.value = false;
  }
}

onMounted(async () => {
  await init();
  try {
    const base = config.value.apiBaseUrl || "https://api.zaptime.app/";
    const json = await fetch(base + "timezones").then((r) => r.json());
    timezones.value = json.data;
  } catch {
    timezones.value = [timezone.value];
  }
});

onUnmounted(() => {
  stopReservationRefresh();
  setSelectedTimeSlot(undefined);
});
</script>

Checklist before shipping

  • Initialization completes against the real token: the grid shows the first month with availability and the first available day is preselected.
  • Clicking a day swaps the time slots; picking a slot opens the form with the date and time formatted in the app's locale and timezone.
  • Every field from the dashboard renders with the right input, required is enforced, and a test booking returns success: true. Cancel it afterwards in the dashboard.
  • Rescheduling, guests, the timezone switch and payments are exercised once if enabled.
  • With SSR, the server log shows no window or navigator errors.

The same component and init composable ship as templates in the zaptime-headless-calendar skill, so a coding agent can adapt them to your design system.