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:
@@ -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>
|
||||
Reference in New Issue
Block a user