feat(scheduling): dezky Scheduling — Calendly-style booking on Stalwart calendars

First-party booking system on top of Stalwart calendars (no third-party
scheduling dependency). Hosts expose public booking pages; visitors pick a
slot computed from the host's live Stalwart free/busy, and confirming writes
the event to the host's calendar and sends a dezky-branded confirmation with
an .ics.

platform-api (services/platform-api/src/scheduling):
- Schemas: Host, StalwartCredential (AES-256-GCM at rest), AvailabilitySchedule,
  EventType, Booking, SlotLock (unique (hostId,startUtc) + TTL).
- StalwartCalendarModule: JMAP gateway (free/busy via Principal/getAvailability,
  event create/delete, scheduleAgent=client) + on-behalf app-password
  provisioning. CredentialCipher for at-rest encryption.
- DST-correct slot engine (Luxon) with unit tests; two-layer double-booking
  guard (atomic SlotLock + live free/busy re-check).
- Booking confirm/cancel/reschedule, branded email + .ics via JMAP submission,
  self-service manage tokens. /api/v1 public + tenant-gated admin routes,
  per-IP rate limiting.

apps/booking: standalone public, whitelabel booking app (booking.dezky.eu) —
path-based tenant resolution, per-tenant brand colour, booking + manage flows.

apps/portal: admin scheduling page (hosts, event types, availability, bookings
with edit/delete + admin cancel/reschedule) and proxy routes.

infra: booking dev service in docker-compose; scheduling env vars.
This commit is contained in:
Ronni Baslund
2026-06-07 00:17:36 +02:00
parent aee8f13899
commit 5ed3d2bc5f
62 changed files with 13633 additions and 1 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
.nuxt
.output
.git
dist
*.log
+7
View File
@@ -0,0 +1,7 @@
# This app uses pnpm (pnpm-lock.yaml). Ignore stray npm lockfiles.
package-lock.json
node_modules
.nuxt
.output
.data
*.log
+22
View File
@@ -0,0 +1,22 @@
# Production image for the dezky booking app (Nuxt 4 SSR).
# Build context = this directory (apps/booking).
# syntax=docker/dockerfile:1
FROM node:22-alpine AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
ENV NUXT_PUBLIC_SITE_URL=https://booking.dezky.eu
# Set PLATFORM_API_INTERNAL_URL at deploy time to reach platform-api.
COPY --from=build /app/.output ./.output
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
+3
View File
@@ -0,0 +1,3 @@
<template>
<NuxtPage />
</template>
+48
View File
@@ -0,0 +1,48 @@
/* Booking app base styles + design tokens. The accent is whitelabel: each
booking page sets --accent from the tenant's brandColor at runtime (see the
booking page's :style binding); this file only provides the fallback. */
:root {
--accent: #1a1a1a;
--accent-contrast: #ffffff;
--bg: #f6f6f7;
--surface: #ffffff;
--text: #1a1a1a;
--text-mute: #6b6b70;
--border: #e7e7ea;
--radius: 14px;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.06);
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
-webkit-font-smoothing: antialiased;
line-height: 1.5;
}
a { color: inherit; }
button { font-family: inherit; }
/* Shared primitives used across pages */
.bk-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
height: 44px; padding: 0 20px; border-radius: 10px; border: 1px solid transparent;
font-size: 15px; font-weight: 600; cursor: pointer; transition: filter .15s, opacity .15s;
}
.bk-btn--primary { background: var(--accent); color: var(--accent-contrast); }
.bk-btn--primary:hover { filter: brightness(1.06); }
.bk-btn--ghost { background: transparent; border-color: var(--border); color: var(--text); }
.bk-btn--ghost:hover { background: #00000008; }
.bk-btn:disabled { opacity: .5; cursor: not-allowed; }
.bk-field { display: flex; flex-direction: column; gap: 6px; }
.bk-label { font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--text-mute); }
.bk-input {
height: 44px; padding: 0 14px; border: 1px solid var(--border); border-radius: 10px;
background: var(--surface); font-size: 15px; color: var(--text); width: 100%;
}
.bk-input:focus { outline: 2px solid var(--accent); outline-offset: 0; border-color: transparent; }
textarea.bk-input { height: auto; padding: 12px 14px; resize: vertical; min-height: 84px; }
+41
View File
@@ -0,0 +1,41 @@
// Nuxt 4 config for the dezky public booking app (booking.dezky.eu).
//
// Fully public — NO OIDC, no sessions. It only ever calls the unauthenticated
// /api/v1/public/* endpoints on platform-api, proxied through this app's own
// nitro server routes so the internal API hostname never reaches the browser.
// Whitelabel: every page renders the tenant's branding (name + brandColor),
// fetched per request — there is no dezky-fixed accent.
export default defineNuxtConfig({
compatibilityDate: '2026-01-01',
devtools: { enabled: true },
css: ['~/assets/styles/base.css'],
runtimeConfig: {
// Server-only: how nitro reaches platform-api inside the docker network.
platformApiUrl: process.env.PLATFORM_API_INTERNAL_URL || 'http://platform-api:3001',
public: {
siteUrl: process.env.NUXT_PUBLIC_SITE_URL
|| (process.env.NODE_ENV === 'production' ? 'https://booking.dezky.eu' : 'http://localhost:3000'),
},
},
app: {
head: {
htmlAttrs: { lang: 'en' },
link: [{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }],
meta: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'robots', content: 'noindex' }, // booking pages aren't for search indexing
],
},
},
vite: {
server: {
allowedHosts: ['booking.dezky.local'],
hmr: process.env.DEZKY_TRAEFIK === '1' ? { protocol: 'wss', clientPort: 443 } : undefined,
},
},
})
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@dezky/booking",
"version": "0.0.1",
"private": true,
"description": "dezky Scheduling — public booking app (booking.dezky.eu). Unauthenticated, whitelabel per tenant.",
"scripts": {
"dev": "TMPDIR=/tmp nuxt dev --host 0.0.0.0 --port 3000",
"build": "nuxt build",
"start": "node .output/server/index.mjs",
"generate": "nuxt generate",
"preview": "nuxt preview",
"typecheck": "nuxt typecheck"
},
"dependencies": {
"luxon": "^3.5.0",
"nuxt": "^4.4.7",
"vue": "^3.5.0",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@types/luxon": "^3.4.2",
"@types/node": "^20.0.0",
"typescript": "^5.6.0",
"vue-tsc": "^3.2.6"
},
"packageManager": "pnpm@9.12.0"
}
@@ -0,0 +1,285 @@
<script setup lang="ts">
import type { PublicBooking, PublicInfo, SlotsResponse } from '~/types/booking'
const route = useRoute()
const tenantSlug = route.params.tenantSlug as string
const hostSlug = route.params.hostSlug as string
const eventTypeSlug = route.params.eventTypeSlug as string
const apiBase = `/api/public/${encodeURIComponent(tenantSlug)}/${encodeURIComponent(hostSlug)}/${encodeURIComponent(eventTypeSlug)}`
// Reschedule mode: the manage page links here with ?reschedule=<manageToken>.
// In this mode confirming moves the existing booking to the chosen slot.
const rescheduleToken = (route.query.reschedule as string | undefined) || undefined
// Event-type + host + branding (SSR).
const { data: info, error } = await useFetch<PublicInfo>(apiBase)
const visitorTz = ref('UTC')
const days = computed(() => nextDays(visitorTz.value, 14))
const selectedDate = ref<string | null>(null)
const slots = ref<SlotsResponse['slots']>([])
const slotsState = ref<'idle' | 'loading' | 'ready' | 'retry' | 'error'>('idle')
type Step = 'pick' | 'details' | 'done'
const step = ref<Step>('pick')
const selectedSlot = ref<{ startUtc: string; endUtc: string } | null>(null)
const form = reactive({ name: '', email: '', notes: '' })
const submitting = ref(false)
const submitError = ref<string | null>(null)
const booking = ref<PublicBooking | null>(null)
// Whitelabel theming — drive the accent from the tenant's brandColor.
watchEffect(() => {
if (!import.meta.client || !info.value) return
const accent = info.value.branding.brandColor || '#1a1a1a'
const el = document.documentElement
el.style.setProperty('--accent', accent)
el.style.setProperty('--accent-contrast', readableOn(accent))
})
onMounted(() => {
visitorTz.value = detectTimezone()
if (!selectedDate.value) selectedDate.value = days.value[0]?.iso ?? null
void loadSlots()
})
watch([selectedDate, visitorTz], () => loadSlots())
async function loadSlots() {
if (!selectedDate.value) return
slotsState.value = 'loading'
try {
const w = dayWindowUtc(selectedDate.value, visitorTz.value)
const res = await $fetch<SlotsResponse>(`${apiBase}/slots`, {
query: { from: w.from, to: w.to, timezone: visitorTz.value },
})
slots.value = res.slots
slotsState.value = 'ready'
} catch (e: any) {
slotsState.value = e?.statusCode === 503 ? 'retry' : 'error'
slots.value = []
}
}
function pickSlot(s: { startUtc: string; endUtc: string }) {
selectedSlot.value = s
submitError.value = null
// Reschedule carries the attendee over — no details form needed.
if (rescheduleToken) void confirm()
else step.value = 'details'
}
async function confirm() {
if (!selectedSlot.value) return
submitting.value = true
submitError.value = null
try {
if (rescheduleToken) {
booking.value = await $fetch<PublicBooking>(`/api/public/bookings/${encodeURIComponent(rescheduleToken)}/reschedule`, {
method: 'POST',
body: { startUtc: selectedSlot.value.startUtc },
})
} else {
booking.value = await $fetch<PublicBooking>(`${apiBase}/bookings`, {
method: 'POST',
body: {
startUtc: selectedSlot.value.startUtc,
attendeeName: form.name,
attendeeEmail: form.email,
attendeeTimezone: visitorTz.value,
attendeeNotes: form.notes || undefined,
},
})
}
step.value = 'done'
} catch (e: any) {
if (e?.statusCode === 409) {
submitError.value = 'That time was just taken. Please pick another slot.'
step.value = 'pick'
await loadSlots()
} else if (e?.statusCode === 503) {
submitError.value = 'The calendar is temporarily unavailable. Please try again in a moment.'
} else {
submitError.value = e?.statusMessage || 'Something went wrong. Please try again.'
}
} finally {
submitting.value = false
}
}
const addToCalUrl = computed(() =>
booking.value
? googleCalUrl({
title: `${booking.value.eventType.title} with ${booking.value.host.displayName}`,
startUtc: booking.value.startUtc,
endUtc: booking.value.endUtc,
location: booking.value.locationUrl ?? undefined,
})
: '#',
)
const manageUrl = computed(() => (booking.value ? `/manage/${booking.value.manageToken}` : '#'))
// Timezone switcher options — ensure the visitor's own zone is present.
const tzOptions = computed(() =>
COMMON_TIMEZONES.includes(visitorTz.value) ? COMMON_TIMEZONES : [visitorTz.value, ...COMMON_TIMEZONES],
)
// User-facing location label — "jitsi" is the technical enum; show "dezky Meet".
function locationLabel(t: string): string {
return (({ jitsi: 'dezky Meet', phone: 'Phone', in_person: 'In person', custom: 'Custom' }) as Record<string, string>)[t] ?? t
}
function readableOn(hex: string): string {
const c = hex.replace('#', '')
if (c.length < 6) return '#ffffff'
const r = parseInt(c.slice(0, 2), 16)
const g = parseInt(c.slice(2, 4), 16)
const b = parseInt(c.slice(4, 6), 16)
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6 ? '#1a1a1a' : '#ffffff'
}
useHead(() => ({ title: info.value ? `${info.value.eventType.title} · ${info.value.branding.name}` : 'Book a time' }))
</script>
<template>
<main class="wrap">
<div v-if="error || !info" class="card empty">
<h1>Booking page not found</h1>
<p class="mute">This link may be incorrect or no longer active.</p>
</div>
<div v-else class="card">
<!-- Header -->
<header class="head">
<div class="brand">{{ info.branding.name }}</div>
<h1 class="title">{{ info.eventType.title }}</h1>
<p class="meta">
{{ info.host.displayName }} · {{ info.eventType.durationMinutes }} min
<span v-if="info.eventType.locationType"> · {{ locationLabel(info.eventType.locationType) }}</span>
</p>
<p v-if="info.eventType.description" class="desc">{{ info.eventType.description }}</p>
</header>
<!-- Step: pick -->
<section v-if="step === 'pick'" class="step">
<div v-if="rescheduleToken" class="rebanner">Choose a new time your details carry over.</div>
<div class="tzrow">
<label class="bk-label" for="tz">Times shown in</label>
<select id="tz" v-model="visitorTz" class="bk-input tz">
<option v-for="tz in tzOptions" :key="tz" :value="tz">{{ tz }}</option>
</select>
</div>
<div class="days">
<button
v-for="d in days"
:key="d.iso"
class="day"
:class="{ active: d.iso === selectedDate }"
@click="selectedDate = d.iso"
>
<span class="dow">{{ d.weekday }}</span>
<span class="dnum">{{ d.day }}</span>
<span class="dmon">{{ d.month }}</span>
</button>
</div>
<p v-if="submitError" class="err">{{ submitError }}</p>
<div class="slots">
<p v-if="slotsState === 'loading'" class="mute">Loading times</p>
<p v-else-if="slotsState === 'retry'" class="err">
Couldnt verify availability. <button class="link" @click="loadSlots">Retry</button>
</p>
<p v-else-if="slotsState === 'error'" class="err">Couldnt load times. <button class="link" @click="loadSlots">Retry</button></p>
<p v-else-if="!slots.length" class="mute">No times available on this day.</p>
<div v-else class="slotgrid">
<button v-for="s in slots" :key="s.startUtc" class="slot" @click="pickSlot(s)">
{{ fmtTime(s.startUtc, visitorTz) }}
</button>
</div>
</div>
</section>
<!-- Step: details -->
<section v-else-if="step === 'details'" class="step">
<button class="back" @click="step = 'pick'"> Back</button>
<p class="chosen">{{ selectedSlot ? fmtDateLong(selectedSlot.startUtc, visitorTz) : '' }} ·
{{ selectedSlot ? fmtTime(selectedSlot.startUtc, visitorTz) : '' }} ({{ visitorTz }})</p>
<form class="form" @submit.prevent="confirm">
<div class="bk-field">
<label class="bk-label" for="name">Your name</label>
<input id="name" v-model="form.name" class="bk-input" required maxlength="120" />
</div>
<div class="bk-field">
<label class="bk-label" for="email">Email</label>
<input id="email" v-model="form.email" type="email" class="bk-input" required maxlength="254" />
</div>
<div class="bk-field">
<label class="bk-label" for="notes">Notes (optional)</label>
<textarea id="notes" v-model="form.notes" class="bk-input" maxlength="2000" />
</div>
<p v-if="submitError" class="err">{{ submitError }}</p>
<button type="submit" class="bk-btn bk-btn--primary" :disabled="submitting">
{{ submitting ? 'Confirming…' : 'Confirm booking' }}
</button>
</form>
</section>
<!-- Step: done -->
<section v-else class="step done">
<div class="tick" aria-hidden="true"></div>
<h2>Youre booked</h2>
<p class="chosen" v-if="booking">{{ fmtRange(booking.startUtc, booking.endUtc, booking.attendeeTimezone) }}</p>
<p class="mute">A confirmation with a calendar invite has been sent to {{ booking?.attendeeEmail }}.</p>
<p v-if="booking?.locationUrl" class="loc">Location: <a :href="booking.locationUrl">{{ booking.locationUrl }}</a></p>
<div class="actions">
<a class="bk-btn bk-btn--primary" :href="addToCalUrl" target="_blank" rel="noopener">Add to Google Calendar</a>
<NuxtLink class="bk-btn bk-btn--ghost" :to="manageUrl">Manage booking</NuxtLink>
</div>
</section>
</div>
<p class="powered" v-if="info">Powered by {{ info.branding.name }}</p>
</main>
</template>
<style scoped>
.wrap { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 32px 16px; }
.card { width: 100%; max-width: 560px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
.card::before { content: ''; display: block; height: 6px; background: var(--accent); }
.head { padding: 28px 28px 0; }
.brand { font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--text-mute); }
.title { font-size: 24px; margin: 6px 0 4px; }
.meta { color: var(--text-mute); margin: 0; font-size: 14px; }
.desc { margin: 14px 0 0; color: var(--text); font-size: 15px; }
.step { padding: 22px 28px 28px; }
.rebanner { background: #fef6e6; color: #9a6a00; font-size: 13px; padding: 8px 12px; border-radius: 8px; margin-bottom: 14px; }
.tzrow { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
.tz { width: auto; height: 38px; flex: 1; }
.days { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; }
.day { flex: 0 0 auto; width: 64px; padding: 10px 0; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 2px; }
.day.active { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
.dow { font-size: 11px; text-transform: uppercase; color: var(--text-mute); }
.dnum { font-size: 18px; font-weight: 600; }
.dmon { font-size: 11px; color: var(--text-mute); }
.slots { margin-top: 18px; min-height: 80px; }
.slotgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 8px; }
.slot { height: 44px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); font-size: 15px; font-weight: 600; cursor: pointer; }
.slot:hover { border-color: var(--accent); }
.form { display: flex; flex-direction: column; gap: 16px; margin-top: 14px; }
.chosen { font-weight: 600; margin: 0 0 6px; }
.back { background: none; border: none; color: var(--text-mute); cursor: pointer; padding: 0 0 12px; font-size: 14px; }
.done { text-align: center; }
.tick { width: 56px; height: 56px; border-radius: 50%; background: var(--accent); color: var(--accent-contrast); font-size: 28px; display: flex; align-items: center; justify-content: center; margin: 4px auto 12px; }
.actions { display: flex; gap: 10px; justify-content: center; margin-top: 20px; flex-wrap: wrap; }
.loc { font-size: 14px; }
.mute { color: var(--text-mute); }
.err { color: #c0362c; font-size: 14px; }
.link { background: none; border: none; color: var(--accent); cursor: pointer; text-decoration: underline; padding: 0; font: inherit; }
.empty { padding: 40px; text-align: center; }
.powered { margin-top: 16px; color: #aaa; font-size: 12px; }
</style>
+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
// Root of booking.dezky.eu carries no tenant context — booking links are always
// /:tenantSlug/:hostSlug/:eventTypeSlug. Show a neutral placeholder.
useHead({ title: 'dezky Scheduling' })
</script>
<template>
<main class="wrap">
<div class="card">
<div class="brand">dezky · scheduling</div>
<h1>Booking</h1>
<p class="mute">Open the specific booking link you were given to choose a time.</p>
</div>
</main>
</template>
<style scoped>
.wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 32px 16px; }
.card { max-width: 460px; text-align: center; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 40px 28px; }
.brand { font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--text-mute); }
h1 { font-size: 24px; margin: 8px 0; }
.mute { color: var(--text-mute); }
</style>
+118
View File
@@ -0,0 +1,118 @@
<script setup lang="ts">
import type { PublicBooking } from '~/types/booking'
const route = useRoute()
const token = route.params.token as string
const { data: booking, error, refresh } = await useFetch<PublicBooking>(`/api/public/bookings/${encodeURIComponent(token)}`)
const cancelling = ref(false)
const cancelReason = ref('')
const showCancel = ref(false)
const actionError = ref<string | null>(null)
watchEffect(() => {
if (!import.meta.client || !booking.value) return
const accent = booking.value.branding.brandColor || '#1a1a1a'
document.documentElement.style.setProperty('--accent', accent)
})
const rescheduleUrl = computed(() =>
booking.value
? `/${booking.value.branding.tenantSlug}/${booking.value.host.slug}/${booking.value.eventType.slug}?reschedule=${booking.value.manageToken}`
: '#',
)
async function doCancel() {
cancelling.value = true
actionError.value = null
try {
booking.value = await $fetch<PublicBooking>(`/api/public/bookings/${encodeURIComponent(token)}/cancel`, {
method: 'POST',
body: { reason: cancelReason.value || undefined },
})
showCancel.value = false
} catch (e: any) {
actionError.value = e?.statusMessage || 'Could not cancel. Please try again.'
} finally {
cancelling.value = false
}
}
function statusLabel(s: string): string {
return { confirmed: 'Confirmed', cancelled: 'Cancelled', rescheduled: 'Rescheduled', pending: 'Pending' }[s] ?? s
}
useHead({ title: 'Manage booking' })
</script>
<template>
<main class="wrap">
<div v-if="error || !booking" class="card empty">
<h1>Booking not found</h1>
<p class="mute">This link may be incorrect or the booking was removed.</p>
</div>
<div v-else class="card">
<header class="head">
<div class="brand">{{ booking.branding.name }}</div>
<h1 class="title">{{ booking.eventType.title }}</h1>
<p class="meta">with {{ booking.host.displayName }}</p>
</header>
<section class="step">
<div class="status" :class="booking.status">{{ statusLabel(booking.status) }}</div>
<p class="when">{{ fmtRange(booking.startUtc, booking.endUtc, booking.attendeeTimezone) }}</p>
<p v-if="booking.locationUrl" class="loc">Location: <a :href="booking.locationUrl">{{ booking.locationUrl }}</a></p>
<template v-if="booking.status === 'confirmed'">
<div v-if="!showCancel" class="actions">
<NuxtLink class="bk-btn bk-btn--primary" :to="rescheduleUrl">Reschedule</NuxtLink>
<button class="bk-btn bk-btn--ghost" @click="showCancel = true">Cancel booking</button>
</div>
<div v-else class="cancelbox">
<div class="bk-field">
<label class="bk-label" for="reason">Reason (optional)</label>
<input id="reason" v-model="cancelReason" class="bk-input" maxlength="500" />
</div>
<p v-if="actionError" class="err">{{ actionError }}</p>
<div class="actions">
<button class="bk-btn bk-btn--ghost" @click="showCancel = false" :disabled="cancelling">Keep it</button>
<button class="bk-btn bk-btn--primary danger" @click="doCancel" :disabled="cancelling">
{{ cancelling ? 'Cancelling…' : 'Confirm cancellation' }}
</button>
</div>
</div>
</template>
<p v-else-if="booking.status === 'cancelled'" class="mute">This booking has been cancelled.</p>
<p v-else-if="booking.status === 'rescheduled'" class="mute">This booking was rescheduled to a new time.</p>
</section>
<p class="powered">Powered by {{ booking.branding.name }}</p>
</div>
</main>
</template>
<style scoped>
.wrap { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 32px 16px; }
.card { width: 100%; max-width: 520px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
.card::before { content: ''; display: block; height: 6px; background: var(--accent); }
.head { padding: 28px 28px 0; }
.brand { font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--text-mute); }
.title { font-size: 22px; margin: 6px 0 4px; }
.meta { color: var(--text-mute); margin: 0; font-size: 14px; }
.step { padding: 22px 28px 28px; }
.status { display: inline-block; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; padding: 4px 10px; border-radius: 999px; background: #eee; color: #444; }
.status.confirmed { background: #e6f6ec; color: #1d7a44; }
.status.cancelled { background: #fdeceb; color: #c0362c; }
.status.rescheduled { background: #fef6e6; color: #9a6a00; }
.when { font-weight: 600; margin: 14px 0 4px; }
.loc { font-size: 14px; }
.actions { display: flex; gap: 10px; margin-top: 20px; flex-wrap: wrap; }
.cancelbox { margin-top: 20px; display: flex; flex-direction: column; gap: 14px; }
.danger { background: #c0362c; }
.mute { color: var(--text-mute); margin-top: 16px; }
.err { color: #c0362c; font-size: 14px; }
.empty { padding: 40px; text-align: center; }
.powered { text-align: center; color: #aaa; font-size: 12px; padding: 0 0 20px; }
</style>
+7087
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#1a1a1a"/><path d="M9 8v16M9 13h11a4 4 0 0 1 0 8H9" fill="none" stroke="#D4FF3A" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 257 B

@@ -0,0 +1,7 @@
export default defineEventHandler(async (event) => {
const tenantSlug = getRouterParam(event, 'tenantSlug')!
const hostSlug = getRouterParam(event, 'hostSlug')!
const eventTypeSlug = getRouterParam(event, 'eventTypeSlug')!
const body = await readBody(event)
return forward(event, 'POST', `/api/v1/public/${seg(tenantSlug)}/${seg(hostSlug)}/${seg(eventTypeSlug)}/bookings`, { body })
})
@@ -0,0 +1,7 @@
export default defineEventHandler(async (event) => {
const tenantSlug = getRouterParam(event, 'tenantSlug')!
const hostSlug = getRouterParam(event, 'hostSlug')!
const eventTypeSlug = getRouterParam(event, 'eventTypeSlug')!
const body = await readBody(event)
return forward(event, 'POST', `/api/v1/public/${seg(tenantSlug)}/${seg(hostSlug)}/${seg(eventTypeSlug)}/holds`, { body })
})
@@ -0,0 +1,6 @@
export default defineEventHandler(async (event) => {
const tenantSlug = getRouterParam(event, 'tenantSlug')!
const hostSlug = getRouterParam(event, 'hostSlug')!
const eventTypeSlug = getRouterParam(event, 'eventTypeSlug')!
return forward(event, 'GET', `/api/v1/public/${seg(tenantSlug)}/${seg(hostSlug)}/${seg(eventTypeSlug)}`)
})
@@ -0,0 +1,7 @@
export default defineEventHandler(async (event) => {
const tenantSlug = getRouterParam(event, 'tenantSlug')!
const hostSlug = getRouterParam(event, 'hostSlug')!
const eventTypeSlug = getRouterParam(event, 'eventTypeSlug')!
const query = getQuery(event)
return forward(event, 'GET', `/api/v1/public/${seg(tenantSlug)}/${seg(hostSlug)}/${seg(eventTypeSlug)}/slots`, { query })
})
@@ -0,0 +1,5 @@
export default defineEventHandler(async (event) => {
const token = getRouterParam(event, 'token')!
const body = await readBody(event).catch(() => ({}))
return forward(event, 'POST', `/api/v1/public/bookings/${seg(token)}/cancel`, { body })
})
@@ -0,0 +1,4 @@
export default defineEventHandler(async (event) => {
const token = getRouterParam(event, 'token')!
return forward(event, 'GET', `/api/v1/public/bookings/${seg(token)}`)
})
@@ -0,0 +1,5 @@
export default defineEventHandler(async (event) => {
const token = getRouterParam(event, 'token')!
const body = await readBody(event)
return forward(event, 'POST', `/api/v1/public/bookings/${seg(token)}/reschedule`, { body })
})
+23
View File
@@ -0,0 +1,23 @@
import type { H3Event } from 'h3'
// Forward a request to platform-api's public scheduling API, preserving upstream
// status codes + messages so the client sees a real 404/409/503 instead of a
// generic 500. The internal API hostname never reaches the browser.
export async function forward(
_event: H3Event,
method: 'GET' | 'POST',
path: string,
opts: { query?: Record<string, any>; body?: any } = {},
): Promise<any> {
const base = useRuntimeConfig().platformApiUrl
try {
return await $fetch(base + path, { method, query: opts.query, body: opts.body })
} catch (err: any) {
const status = err?.response?.status ?? 502
const raw = err?.data?.message ?? err?.response?._data?.message ?? 'Upstream error'
throw createError({ statusCode: status, statusMessage: Array.isArray(raw) ? raw.join(', ') : String(raw) })
}
}
// Encode a single path segment safely.
export const seg = (s: string) => encodeURIComponent(s)
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}
+42
View File
@@ -0,0 +1,42 @@
// Shapes returned by the public scheduling API (mirrors platform-api's
// PublicSchedulingController response mappers).
export interface Branding {
tenantSlug: string
name: string
brandColor: string | null
}
export interface PublicInfo {
branding: Branding
host: { slug: string; displayName: string; timezone: string }
eventType: {
slug: string
title: string
description: string | null
durationMinutes: number
locationType: string
}
}
export interface SlotsResponse {
timezone: string
durationMinutes: number
slots: Array<{ startUtc: string; endUtc: string }>
}
export interface PublicBooking {
manageToken: string
status: 'pending' | 'confirmed' | 'cancelled' | 'rescheduled'
startUtc: string
endUtc: string
attendeeName: string
attendeeEmail: string
attendeeTimezone: string
attendeeNotes: string | null
locationType: string | null
locationUrl: string | null
branding: Branding
host: { slug: string; displayName: string; timezone: string }
eventType: { slug: string; title: string; durationMinutes: number }
}
+67
View File
@@ -0,0 +1,67 @@
import { DateTime } from 'luxon'
// Visitor timezone detection (browser); falls back to UTC on the server / when
// unavailable. All slot times are transmitted as UTC and rendered in this zone.
export function detectTimezone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
} catch {
return 'UTC'
}
}
// UTC [from, to) covering one local calendar day in `tz`.
export function dayWindowUtc(isoDate: string, tz: string): { from: string; to: string } {
const start = DateTime.fromISO(isoDate, { zone: tz }).startOf('day')
return { from: start.toUTC().toISO()!, to: start.plus({ days: 1 }).toUTC().toISO()! }
}
export interface DayOption {
iso: string // yyyy-MM-dd in tz
weekday: string
day: string
month: string
}
// The next `count` calendar days starting today, in the visitor's tz.
export function nextDays(tz: string, count: number): DayOption[] {
const today = DateTime.now().setZone(tz).startOf('day')
return Array.from({ length: count }, (_, i) => {
const d = today.plus({ days: i })
return { iso: d.toFormat('yyyy-MM-dd'), weekday: d.toFormat('ccc'), day: d.toFormat('d'), month: d.toFormat('LLL') }
})
}
export function fmtTime(utc: string, tz: string): string {
return DateTime.fromISO(utc).setZone(tz).toFormat('HH:mm')
}
export function fmtDateLong(utc: string, tz: string): string {
return DateTime.fromISO(utc).setZone(tz).toFormat('cccc d LLLL yyyy')
}
export function fmtRange(startUtc: string, endUtc: string, tz: string): string {
const s = DateTime.fromISO(startUtc).setZone(tz)
const e = DateTime.fromISO(endUtc).setZone(tz)
return `${s.toFormat('cccc d LLLL')} · ${s.toFormat('HH:mm')}${e.toFormat('HH:mm')} (${tz})`
}
// Google Calendar "add to calendar" template link (UTC basic format).
export function googleCalUrl(p: { title: string; startUtc: string; endUtc: string; details?: string; location?: string }): string {
const fmt = (iso: string) => DateTime.fromISO(iso).toUTC().toFormat("yyyyLLdd'T'HHmmss'Z'")
const params = new URLSearchParams({
action: 'TEMPLATE',
text: p.title,
dates: `${fmt(p.startUtc)}/${fmt(p.endUtc)}`,
})
if (p.details) params.set('details', p.details)
if (p.location) params.set('location', p.location)
return `https://calendar.google.com/calendar/render?${params.toString()}`
}
// Common IANA zones for the manual switcher (visitor tz is added if missing).
export const COMMON_TIMEZONES = [
'Europe/Copenhagen', 'Europe/London', 'Europe/Berlin', 'Europe/Paris', 'Europe/Madrid',
'Europe/Stockholm', 'Europe/Helsinki', 'America/New_York', 'America/Chicago', 'America/Los_Angeles',
'Asia/Dubai', 'Asia/Kolkata', 'Asia/Singapore', 'Asia/Tokyo', 'Australia/Sydney', 'UTC',
]