Skip to content

Getting started with @zaptime/core

Installation

sh
$ npm install @zaptime/core
sh
$ pnpm add @zaptime/core
sh
$ yarn add @zaptime/core

Peer dependency: vue ^3.4. The package is ESM only.

Initialization sequence

Before the calendar can show anything, the remote configuration of the event type has to be fetched and pushed into the composables. The order matters: init() needs the config and the date-fns locale to be set first.

  1. fetchRemoteConfiguration(token, apiBaseUrl?, reservationUuid?)
  2. Push the remote data into core: locations, Stripe config, booking form fields, max guests, reservation (when rescheduling).
  3. Merge the remote configuration under your local config.
  4. loadDateFnsConfig(locale.preset)
  5. setConfig(mergedConfig)
  6. useCalendar().init()

The composable below wraps that sequence and exposes a status you can render against. Copy it into your app.

ts
// useZaptimeInit.ts
import { ref, shallowRef } from "vue";
import {
  fetchRemoteConfiguration,
  mergeObjects,
  useBookingForm,
  useCalendar,
  useConfig,
  useDateFormatters,
  useGuests,
  useLocations,
  useReservationReschedule,
  useStripeConfig,
  type ZaptimeConfig,
} from "@zaptime/core";
import { isSameDay } from "date-fns";

export type ZaptimeInitStatus = "idle" | "loading" | "ready" | "disabled" | "error";

export function useZaptimeInit(localConfig: ZaptimeConfig, calendarId?: string) {
  const status = ref<ZaptimeInitStatus>("idle");
  const error = shallowRef<unknown>(null);
  const eventTypeName = ref("");

  const { setConfig } = useConfig(calendarId);
  const { setLocations } = useLocations(calendarId);
  const { setStripeConfig } = useStripeConfig(calendarId);
  const { setBookingForm } = useBookingForm(calendarId);
  const { setMaxGuests } = useGuests(calendarId);
  const { setSelectedReservation } = useReservationReschedule(calendarId);
  const { loadDateFnsConfig } = useDateFormatters();
  const { init: initCalendar, dayClicked, state } = useCalendar(calendarId);

  async function init() {
    if (!localConfig?.token) {
      status.value = "error";
      error.value = new Error("Zaptime: config.token is required");
      return;
    }

    status.value = "loading";

    const result = await fetchRemoteConfiguration(
      localConfig.token,
      localConfig.apiBaseUrl,
      localConfig.reservationUuid,
    );

    if (result.isErr()) {
      status.value = "error";
      error.value = result.error; // "invalidToken" (also on network failure)
      return;
    }

    const remote = result.value;
    eventTypeName.value = remote.eventTypeName;

    if (remote.disabled) {
      status.value = "disabled";
      return;
    }

    if (remote.reservation) setSelectedReservation(remote.reservation);
    if (remote.locations) setLocations(remote.locations);
    if (remote.stripeConfig) setStripeConfig(remote.stripeConfig);
    if (remote.customFields) setBookingForm(remote.customFields);
    setMaxGuests(remote.maxGuests ?? null);

    // Remote configuration is the base; local keys override it.
    const merged = mergeObjects({ ...remote.configuration }, { ...localConfig }) as ZaptimeConfig;

    await loadDateFnsConfig(merged.locale?.preset || "en");
    setConfig(merged);
    await initCalendar();

    // Rescheduling: preselect the day of the existing reservation.
    if (remote.reservation) {
      const current = new Date(remote.reservation.start);
      const day = state.days.find((d) => d.date && isSameDay(d.date, current));
      if (day) dayClicked(day);
    }

    status.value = "ready";
  }

  return { status, error, eventTypeName, init };
}

Minimal calendar

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

  <template v-else>
    <header>
      <button :disabled="prevDisabled" @click="prev">‹</button>
      <span>{{ monthName }} {{ currentYear }}</span>
      <button :disabled="nextDisabled" @click="next">›</button>
    </header>

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

    <ul>
      <li v-for="slot in state.timeSlots" :key="slot.start">
        <button :aria-pressed="isSelected(slot)" @click="selectTimeSlot(slot)">
          {{ getFormattedTime(slot.start) }}
        </button>
      </li>
    </ul>

    <button v-if="selectedTimeSlot" @click="submit">Book</button>
  </template>
</template>

<script setup lang="ts">
import { onMounted } from "vue";
import { book, useCalendar, useDateFormatters, useSelectedTimeSlot } from "@zaptime/core";
import { useZaptimeInit } from "./useZaptimeInit";

const { status, init } = useZaptimeInit({ token: "<API_TOKEN>" });
const {
  state, prev, next, prevDisabled, nextDisabled, monthName, currentYear,
  dayClicked, dayHasTimeSlot, isSelectedDay, selectTimeSlot, isSelected,
} = useCalendar();
const { selectedTimeSlot } = useSelectedTimeSlot();
const { getFormattedTime } = useDateFormatters();

onMounted(init);

async function submit() {
  await book({ email: "john@doe.test" });
}
</script>

For the booking form, guests, rescheduling, error handling and timezone switching see the full example.

Things core does not tell you

  • init() auto-advances to the next month when the current one has no slots, so state.date can differ from today right after init.
  • useDateFormatters formats in English until loadDateFnsConfig(preset) resolves. Await it before rendering.
  • Changing the timezone with setTimezone does not refetch; call getDays() afterwards because slots can move across days.
  • fetchRemoteConfiguration returns a ts-results-es Result. Branch on isOk() / isErr() and read .value / .error.
  • Two calendars without distinct calendarIds share selection and config.
  • Only one reservation created with reserve can be in flight per page; its refresh interval is global.