Skip to content

Headless calendar

A trimmed version of the full headless example: month grid, time slots, the dashboard-defined booking form and submission. It uses the useZaptimeInit composable from Getting started.

vue
<template>
  <p v-if="status !== 'ready'">{{ status }}</p>

  <div v-else-if="!booked" class="calendar">
    <header>
      <button :disabled="prevDisabled" @click="prev">‹</button>
      <strong>{{ monthName }} {{ currentYear }}</strong>
      <button :disabled="nextDisabled" @click="next">›</button>
    </header>

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

    <div class="slots">
      <button
        v-for="slot in state.timeSlots"
        :key="slot.start"
        :class="{ selected: isSelected(slot) }"
        @click="selectTimeSlot(slot)"
      >
        {{ getFormattedTime(slot.start) }}
      </button>
    </div>

    <form v-if="selectedTimeSlot" @submit.prevent="submit">
      <p>{{ getFormattedDayInMonth(selectedTimeSlot.start) }} · {{ getFormattedTime(selectedTimeSlot.start) }}</p>

      <div v-for="field in bookingForm" :key="field.uuid">
        <label :for="field.uuid">{{ field.label }}</label>
        <input
          :id="field.uuid"
          :type="field.type === 'phone' ? 'tel' : field.type"
          :required="field.required || field.mergeTag === 'EMAIL'"
          :placeholder="field.placeholder"
          @input="setCustomFieldValue(field.uuid, ($event.target as HTMLInputElement).value)"
        />
      </div>

      <p v-if="error" role="alert">{{ error }}</p>
      <button type="submit" :disabled="submitting">{{ config.locale?.confirmationForm?.buttons?.confirmBooking }}</button>
    </form>
  </div>

  <p v-else>Booked. See you {{ getFormattedDayInMonth(booked.start) }}.</p>
</template>

<script setup lang="ts">
import { onMounted, ref } from "vue";
import {
  book,
  useBookingForm,
  useCalendar,
  useConfig,
  useDateFormatters,
  useSelectedTimeSlot,
  SlotNoLongerAvailableError,
  slotNoLongerAvailableText,
} from "@zaptime/core";
import type { TimeSlot } from "@zaptime/core";
import { useZaptimeInit } from "./useZaptimeInit";

const { status, init } = useZaptimeInit({ token: import.meta.env.VITE_ZAPTIME_TOKEN });
const { config } = useConfig();
const {
  state, prev, next, prevDisabled, nextDisabled, monthName, currentYear,
  dayClicked, dayHasTimeSlot, isSelectedDay, selectTimeSlot, isSelected, getDays,
} = useCalendar();
const { selectedTimeSlot } = useSelectedTimeSlot();
const { bookingForm, setCustomFieldValue, collectFormValues } = useBookingForm();
const { getFormattedTime, getFormattedDayInMonth } = useDateFormatters();

const submitting = ref(false);
const error = ref<string>();
const booked = ref<TimeSlot>();

onMounted(init);

async function submit() {
  submitting.value = true;
  error.value = undefined;
  try {
    const res = await book(collectFormValues());
    if (res.success) booked.value = selectedTimeSlot.value;
    else error.value = "Please check the entered details.";
  } catch (e) {
    if (e instanceof SlotNoLongerAvailableError) {
      error.value = slotNoLongerAvailableText(config.value.locale);
      await getDays();
    } else {
      error.value = "Booking failed.";
    }
  } finally {
    submitting.value = false;
  }
}
</script>

<style scoped>
.grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
.day.selected, .slots .selected { background: #15b79e; color: white; }
.day.today { font-weight: 700; }
</style>

This example renders every field as a text-like input for brevity. The useBookingForm page shows the mapping for select, radio, checkbox and multiselect fields.