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.
