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,6 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.git
|
||||
dist
|
||||
*.log
|
||||
@@ -0,0 +1,7 @@
|
||||
# This app uses pnpm (pnpm-lock.yaml). Ignore stray npm lockfiles.
|
||||
package-lock.json
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.data
|
||||
*.log
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
@@ -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; }
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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">
|
||||
Couldn’t verify availability. <button class="link" @click="loadSlots">Retry</button>
|
||||
</p>
|
||||
<p v-else-if="slotsState === 'error'" class="err">Couldn’t 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>You’re 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Generated
+7087
File diff suppressed because it is too large
Load Diff
@@ -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 |
+7
@@ -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 })
|
||||
})
|
||||
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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',
|
||||
]
|
||||
@@ -62,6 +62,7 @@ const ADMIN_NAV: NavRow[] = [
|
||||
{ id: 'users', label: 'Users & groups', icon: 'users', href: '/admin/users' },
|
||||
{ id: 'mail', label: 'Mail settings', icon: 'mail', href: '/admin/mail' },
|
||||
{ id: 'meetings', label: 'Meetings', icon: 'video', href: '/admin/meetings' },
|
||||
{ id: 'scheduling', label: 'Scheduling', icon: 'calendar', href: '/admin/scheduling' },
|
||||
{ id: 'chat', label: 'Chat', icon: 'chat', href: '/admin/chat' },
|
||||
{ id: 'domains', label: 'Domains', icon: 'globe', href: '/admin/domains' },
|
||||
{ id: 'storage', label: 'Storage', icon: 'database', href: '/admin/storage' },
|
||||
@@ -121,6 +122,7 @@ const currentId = computed(() => {
|
||||
if (p.startsWith('/admin/users')) return 'users'
|
||||
if (p.startsWith('/admin/mail')) return 'mail'
|
||||
if (p.startsWith('/admin/meetings')) return 'meetings'
|
||||
if (p.startsWith('/admin/scheduling')) return 'scheduling'
|
||||
if (p.startsWith('/admin/chat')) return 'chat'
|
||||
if (p.startsWith('/admin/domains')) return 'domains'
|
||||
if (p.startsWith('/admin/storage')) return 'storage'
|
||||
|
||||
@@ -0,0 +1,715 @@
|
||||
<script setup lang="ts">
|
||||
// dezky Scheduling admin. Make a workspace user bookable (auto-provisions their
|
||||
// Stalwart calendar credential), then configure event types + availability and
|
||||
// view bookings. Public pages live on booking.dezky.eu/:tenant/:host/:eventType.
|
||||
|
||||
interface Host {
|
||||
_id: string
|
||||
email: string
|
||||
displayName: string
|
||||
slug: string
|
||||
timezone: string
|
||||
isActive: boolean
|
||||
defaultCalendarId?: string
|
||||
}
|
||||
interface EventType {
|
||||
_id: string
|
||||
slug: string
|
||||
title: string
|
||||
description?: string
|
||||
durationMinutes: number
|
||||
slotIntervalMinutes: number
|
||||
bufferBeforeMinutes: number
|
||||
bufferAfterMinutes: number
|
||||
minimumNoticeMinutes: number
|
||||
maximumDaysInFuture: number
|
||||
availabilityScheduleId: string
|
||||
locationType: string
|
||||
isActive: boolean
|
||||
}
|
||||
interface MinuteInterval { startMinute: number; endMinute: number }
|
||||
interface WeeklyRule { dayOfWeek: number; intervals: MinuteInterval[] }
|
||||
interface Availability { _id: string; name: string; timezone: string; weeklyRules: WeeklyRule[] }
|
||||
interface Booking {
|
||||
_id: string
|
||||
status: string
|
||||
startUtc: string
|
||||
endUtc: string
|
||||
attendeeName: string
|
||||
attendeeEmail: string
|
||||
eventTypeId: string
|
||||
}
|
||||
|
||||
const toast = useToast()
|
||||
const { tenant } = useTenant()
|
||||
const slug = computed(() => tenant.value?.slug ?? '')
|
||||
const { request } = useApiFetch()
|
||||
const bookingBase = 'https://booking.dezky.local'
|
||||
|
||||
const base = computed(() => `/api/tenants/${slug.value}/scheduling`)
|
||||
|
||||
function toastErr(err: unknown, title: string) {
|
||||
const e = err as { data?: { message?: string | string[] }; statusMessage?: string; message?: string }
|
||||
const m = e?.data?.message ?? e?.statusMessage ?? e?.message ?? 'Unknown error'
|
||||
toast.bad(title, Array.isArray(m) ? m.join(', ') : String(m))
|
||||
}
|
||||
|
||||
const minToTime = (m: number) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`
|
||||
|
||||
// Generic confirm dialog (deletes + cancellations route through this).
|
||||
const confirmBox = reactive({ open: false, title: '', message: '', confirmLabel: 'Confirm', busy: false, action: null as null | (() => Promise<void>) })
|
||||
function askConfirm(opts: { title: string; message: string; confirmLabel?: string; action: () => Promise<void> }) {
|
||||
Object.assign(confirmBox, { confirmLabel: 'Confirm', ...opts, open: true, busy: false })
|
||||
}
|
||||
async function runConfirm() {
|
||||
if (!confirmBox.action) return
|
||||
confirmBox.busy = true
|
||||
try {
|
||||
await confirmBox.action()
|
||||
confirmBox.open = false
|
||||
} catch (err) {
|
||||
toastErr(err, 'Action failed')
|
||||
} finally {
|
||||
confirmBox.busy = false
|
||||
}
|
||||
}
|
||||
|
||||
const { data: hosts, refresh: refreshHosts } = await useFetch<Host[]>(() => `${base.value}/hosts`, {
|
||||
key: 'sched-hosts',
|
||||
default: () => [],
|
||||
immediate: !!slug.value,
|
||||
watch: [slug],
|
||||
})
|
||||
|
||||
const selectedHostId = ref<string | null>(null)
|
||||
const selectedHost = computed(() => hosts.value?.find((h) => h._id === selectedHostId.value) ?? null)
|
||||
const detailTab = ref<'event-types' | 'availability' | 'bookings'>('event-types')
|
||||
|
||||
const eventTypes = ref<EventType[]>([])
|
||||
const availability = ref<Availability[]>([])
|
||||
const bookings = ref<Booking[]>([])
|
||||
|
||||
async function selectHost(id: string) {
|
||||
selectedHostId.value = id
|
||||
detailTab.value = 'event-types'
|
||||
await loadHostData()
|
||||
}
|
||||
async function loadHostData() {
|
||||
if (!selectedHostId.value) return
|
||||
try {
|
||||
const id = selectedHostId.value
|
||||
;[eventTypes.value, availability.value, bookings.value] = await Promise.all([
|
||||
request(`${base.value}/hosts/${id}/event-types`) as Promise<EventType[]>,
|
||||
request(`${base.value}/hosts/${id}/availability`) as Promise<Availability[]>,
|
||||
request(`${base.value}/bookings?hostId=${id}`) as Promise<Booking[]>,
|
||||
])
|
||||
} catch (err) {
|
||||
toastErr(err, 'Could not load host data')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workspace users (bookable candidates need a mailbox) ──
|
||||
interface WsUser { _id: string; name: string; email: string; mailboxAddress?: string }
|
||||
const { data: users } = await useFetch<WsUser[]>(() => `/api/tenants/${slug.value}/users`, {
|
||||
key: 'sched-users', default: () => [], immediate: !!slug.value, watch: [slug],
|
||||
})
|
||||
const hostEmails = computed(() => new Set((hosts.value ?? []).map((h) => h.email)))
|
||||
const takenSlugs = computed(() => new Set((hosts.value ?? []).map((h) => h.slug)))
|
||||
// Only users with a mailbox, and not already a host.
|
||||
const availableUsers = computed(() =>
|
||||
(users.value ?? []).filter((u) => !!u.mailboxAddress && !hostEmails.value.has(u.mailboxAddress!)),
|
||||
)
|
||||
|
||||
const SLUG_RE = /^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/
|
||||
function slugify(name: string): string {
|
||||
return name.toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/, '')
|
||||
}
|
||||
function freeSlug(baseSlug: string): string {
|
||||
if (!baseSlug || !takenSlugs.value.has(baseSlug)) return baseSlug
|
||||
let i = 2
|
||||
while (takenSlugs.value.has(`${baseSlug}-${i}`)) i++
|
||||
return `${baseSlug}-${i}`
|
||||
}
|
||||
|
||||
// ── Create host ──
|
||||
const hostOpen = ref(false)
|
||||
const hostBusy = ref(false)
|
||||
const hostForm = reactive({ userId: '', slug: '', timezone: 'Europe/Copenhagen' })
|
||||
const slugTaken = computed(() => takenSlugs.value.has(hostForm.slug))
|
||||
const slugValid = computed(() => SLUG_RE.test(hostForm.slug))
|
||||
function openHost() {
|
||||
const first = availableUsers.value[0]
|
||||
hostForm.userId = first?._id ?? ''
|
||||
hostForm.slug = first ? freeSlug(slugify(first.name)) : ''
|
||||
hostForm.timezone = 'Europe/Copenhagen'
|
||||
hostOpen.value = true
|
||||
}
|
||||
// Re-derive a free slug from the chosen user's display name.
|
||||
function onHostUserChange() {
|
||||
const u = availableUsers.value.find((x) => x._id === hostForm.userId)
|
||||
if (u) hostForm.slug = freeSlug(slugify(u.name))
|
||||
}
|
||||
async function submitHost() {
|
||||
if (!hostForm.userId || !slugValid.value || slugTaken.value) return
|
||||
hostBusy.value = true
|
||||
try {
|
||||
await request(`${base.value}/hosts`, { method: 'POST', body: { ...hostForm } })
|
||||
toast.ok('Host created', 'Calendar access was provisioned automatically.')
|
||||
hostOpen.value = false
|
||||
await refreshHosts()
|
||||
} catch (err) {
|
||||
toastErr(err, 'Could not create host')
|
||||
} finally {
|
||||
hostBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create availability ──
|
||||
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
const availOpen = ref(false)
|
||||
const availBusy = ref(false)
|
||||
const availForm = reactive({
|
||||
name: 'Working hours',
|
||||
timezone: 'Europe/Copenhagen',
|
||||
days: DAYS.map((_, i) => ({ enabled: i >= 1 && i <= 5, start: '09:00', end: '17:00' })),
|
||||
})
|
||||
const availEditingId = ref<string | null>(null)
|
||||
function openAvail(a?: Availability) {
|
||||
if (a) {
|
||||
availEditingId.value = a._id
|
||||
availForm.name = a.name
|
||||
availForm.timezone = a.timezone
|
||||
availForm.days = DAYS.map((_, i) => {
|
||||
const intv = a.weeklyRules.find((r) => r.dayOfWeek === i)?.intervals?.[0]
|
||||
return intv
|
||||
? { enabled: true, start: minToTime(intv.startMinute), end: minToTime(intv.endMinute) }
|
||||
: { enabled: false, start: '09:00', end: '17:00' }
|
||||
})
|
||||
} else {
|
||||
availEditingId.value = null
|
||||
availForm.name = 'Working hours'
|
||||
availForm.timezone = selectedHost.value?.timezone ?? 'Europe/Copenhagen'
|
||||
availForm.days = DAYS.map((_, i) => ({ enabled: i >= 1 && i <= 5, start: '09:00', end: '17:00' }))
|
||||
}
|
||||
availOpen.value = true
|
||||
}
|
||||
const timeToMin = (t: string) => { const [h, m] = t.split(':').map(Number); return h * 60 + m }
|
||||
async function submitAvail() {
|
||||
if (!selectedHostId.value) return
|
||||
availBusy.value = true
|
||||
try {
|
||||
const weeklyRules: WeeklyRule[] = availForm.days
|
||||
.map((d, i) => ({ dayOfWeek: i, enabled: d.enabled, start: d.start, end: d.end }))
|
||||
.filter((d) => d.enabled && timeToMin(d.end) > timeToMin(d.start))
|
||||
.map((d) => ({ dayOfWeek: d.dayOfWeek, intervals: [{ startMinute: timeToMin(d.start), endMinute: timeToMin(d.end) }] }))
|
||||
const body = { name: availForm.name, timezone: availForm.timezone, weeklyRules }
|
||||
if (availEditingId.value) {
|
||||
await request(`${base.value}/availability/${availEditingId.value}`, { method: 'PATCH', body })
|
||||
toast.ok('Availability updated')
|
||||
} else {
|
||||
await request(`${base.value}/hosts/${selectedHostId.value}/availability`, { method: 'POST', body })
|
||||
toast.ok('Availability saved')
|
||||
}
|
||||
availOpen.value = false
|
||||
await loadHostData()
|
||||
} catch (err) {
|
||||
toastErr(err, 'Could not save availability')
|
||||
} finally {
|
||||
availBusy.value = false
|
||||
}
|
||||
}
|
||||
function deleteAvail(a: Availability) {
|
||||
askConfirm({
|
||||
title: 'Delete availability schedule',
|
||||
message: `Delete “${a.name}”? Event types using it must be reassigned or deleted first.`,
|
||||
confirmLabel: 'Delete',
|
||||
action: async () => {
|
||||
await request(`${base.value}/availability/${a._id}`, { method: 'DELETE' })
|
||||
toast.ok('Schedule deleted')
|
||||
await loadHostData()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function cancelBookingAdmin(b: Booking) {
|
||||
askConfirm({
|
||||
title: 'Cancel booking',
|
||||
message: `Cancel ${b.attendeeName}’s booking? They’ll be emailed a cancellation and the calendar event is removed.`,
|
||||
confirmLabel: 'Cancel booking',
|
||||
action: async () => {
|
||||
await request(`${base.value}/bookings/${b._id}/cancel`, { method: 'POST', body: { reason: 'Cancelled by host' } })
|
||||
toast.ok('Booking cancelled')
|
||||
await loadHostData()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Create event type ──
|
||||
const etOpen = ref(false)
|
||||
const etBusy = ref(false)
|
||||
const etForm = reactive({
|
||||
title: '',
|
||||
slug: '',
|
||||
durationMinutes: 30,
|
||||
slotIntervalMinutes: 15,
|
||||
bufferBeforeMinutes: 0,
|
||||
bufferAfterMinutes: 0,
|
||||
minimumNoticeMinutes: 60,
|
||||
maximumDaysInFuture: 60,
|
||||
availabilityScheduleId: '',
|
||||
locationType: 'jitsi',
|
||||
})
|
||||
const etEditingId = ref<string | null>(null)
|
||||
const etSlugTouched = ref(false)
|
||||
const etSlugValid = computed(() => SLUG_RE.test(etForm.slug))
|
||||
const etValid = computed(() => etForm.title.trim().length > 0 && etSlugValid.value && !!etForm.availabilityScheduleId)
|
||||
function openEt(et?: EventType) {
|
||||
if (et) {
|
||||
etEditingId.value = et._id
|
||||
Object.assign(etForm, {
|
||||
title: et.title, slug: et.slug, durationMinutes: et.durationMinutes,
|
||||
slotIntervalMinutes: et.slotIntervalMinutes, bufferBeforeMinutes: et.bufferBeforeMinutes,
|
||||
bufferAfterMinutes: et.bufferAfterMinutes, minimumNoticeMinutes: et.minimumNoticeMinutes,
|
||||
maximumDaysInFuture: et.maximumDaysInFuture, availabilityScheduleId: et.availabilityScheduleId,
|
||||
locationType: et.locationType,
|
||||
})
|
||||
etSlugTouched.value = true // don't auto-rewrite an existing slug
|
||||
} else {
|
||||
etEditingId.value = null
|
||||
Object.assign(etForm, {
|
||||
title: '', slug: '', durationMinutes: 30, slotIntervalMinutes: 15,
|
||||
bufferBeforeMinutes: 0, bufferAfterMinutes: 0, minimumNoticeMinutes: 60,
|
||||
maximumDaysInFuture: 60, availabilityScheduleId: availability.value[0]?._id ?? '', locationType: 'jitsi',
|
||||
})
|
||||
etSlugTouched.value = false
|
||||
}
|
||||
etOpen.value = true
|
||||
}
|
||||
// Auto-derive the slug from the title until the user edits the slug themselves.
|
||||
function onEtTitle() {
|
||||
if (!etSlugTouched.value) etForm.slug = slugify(etForm.title)
|
||||
}
|
||||
async function submitEt() {
|
||||
if (!selectedHostId.value || !etValid.value) return
|
||||
etBusy.value = true
|
||||
try {
|
||||
if (etEditingId.value) {
|
||||
// slug is immutable after creation (public links would break) → omit it.
|
||||
const { slug: _slug, ...patch } = { ...etForm }
|
||||
await request(`${base.value}/event-types/${etEditingId.value}`, { method: 'PATCH', body: patch })
|
||||
toast.ok('Event type updated')
|
||||
} else {
|
||||
await request(`${base.value}/hosts/${selectedHostId.value}/event-types`, { method: 'POST', body: { ...etForm } })
|
||||
toast.ok('Event type created')
|
||||
}
|
||||
etOpen.value = false
|
||||
await loadHostData()
|
||||
} catch (err) {
|
||||
toastErr(err, etEditingId.value ? 'Could not update event type' : 'Could not create event type')
|
||||
} finally {
|
||||
etBusy.value = false
|
||||
}
|
||||
}
|
||||
function deleteEt(et: EventType) {
|
||||
askConfirm({
|
||||
title: 'Delete event type',
|
||||
message: `Delete “${et.title}”? Its public page stops accepting new bookings. Existing bookings are unaffected.`,
|
||||
confirmLabel: 'Delete',
|
||||
action: async () => {
|
||||
await request(`${base.value}/event-types/${et._id}`, { method: 'DELETE' })
|
||||
toast.ok('Event type deleted')
|
||||
await loadHostData()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Reschedule a booking (admin) ──
|
||||
interface DaySlots { key: string; label: string; slots: { startUtc: string }[] }
|
||||
const reschedule = reactive({
|
||||
open: false,
|
||||
busy: false,
|
||||
booking: null as Booking | null,
|
||||
hostTz: 'Europe/Copenhagen',
|
||||
state: 'idle' as 'idle' | 'loading' | 'ready' | 'error',
|
||||
days: [] as DaySlots[],
|
||||
selectedKey: '',
|
||||
})
|
||||
const reschedDay = computed(() => reschedule.days.find((d) => d.key === reschedule.selectedKey) ?? null)
|
||||
|
||||
async function openReschedule(b: Booking) {
|
||||
const et = eventTypes.value.find((e) => e._id === b.eventTypeId)
|
||||
if (!et || !selectedHost.value) {
|
||||
toast.bad('Cannot reschedule', 'The event type for this booking no longer exists.')
|
||||
return
|
||||
}
|
||||
Object.assign(reschedule, {
|
||||
open: true, busy: false, booking: b, hostTz: selectedHost.value.timezone, state: 'loading', days: [], selectedKey: '',
|
||||
})
|
||||
try {
|
||||
const from = new Date().toISOString()
|
||||
const to = new Date(Date.now() + 14 * 86_400_000).toISOString()
|
||||
const res = (await request(
|
||||
`/api/scheduling-slots/${slug.value}/${selectedHost.value.slug}/${et.slug}?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&timezone=${encodeURIComponent(reschedule.hostTz)}`,
|
||||
)) as { slots: { startUtc: string }[] }
|
||||
reschedule.days = groupByDay(res.slots, reschedule.hostTz)
|
||||
reschedule.selectedKey = reschedule.days[0]?.key ?? ''
|
||||
reschedule.state = 'ready'
|
||||
} catch (err) {
|
||||
reschedule.state = 'error'
|
||||
toastErr(err, 'Could not load times')
|
||||
}
|
||||
}
|
||||
function groupByDay(slots: { startUtc: string }[], tz: string): DaySlots[] {
|
||||
const keyFmt = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' })
|
||||
const labelFmt = new Intl.DateTimeFormat('en-GB', { timeZone: tz, weekday: 'short', day: 'numeric', month: 'short' })
|
||||
const map = new Map<string, DaySlots>()
|
||||
for (const s of slots) {
|
||||
const d = new Date(s.startUtc)
|
||||
const key = keyFmt.format(d)
|
||||
if (!map.has(key)) map.set(key, { key, label: labelFmt.format(d), slots: [] })
|
||||
map.get(key)!.slots.push(s)
|
||||
}
|
||||
return [...map.values()]
|
||||
}
|
||||
function reschedTime(iso: string): string {
|
||||
return new Intl.DateTimeFormat('en-GB', { timeZone: reschedule.hostTz, hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(iso))
|
||||
}
|
||||
async function confirmReschedule(startUtc: string) {
|
||||
if (!reschedule.booking) return
|
||||
reschedule.busy = true
|
||||
try {
|
||||
await request(`${base.value}/bookings/${reschedule.booking._id}/reschedule`, { method: 'POST', body: { startUtc } })
|
||||
toast.ok('Booking rescheduled', 'The attendee has been emailed the new time.')
|
||||
reschedule.open = false
|
||||
await loadHostData()
|
||||
} catch (err) {
|
||||
toastErr(err, 'Could not reschedule')
|
||||
} finally {
|
||||
reschedule.busy = false
|
||||
}
|
||||
}
|
||||
|
||||
function publicUrl(et: EventType): string {
|
||||
return `${bookingBase}/${slug.value}/${selectedHost.value?.slug}/${et.slug}`
|
||||
}
|
||||
async function copyUrl(et: EventType) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(publicUrl(et))
|
||||
toast.ok('Link copied')
|
||||
} catch {
|
||||
toast.bad('Copy failed', publicUrl(et))
|
||||
}
|
||||
}
|
||||
|
||||
const fmtDateTime = (iso: string) =>
|
||||
new Date(iso).toLocaleString('en-GB', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
const detailTabs = computed(() => [
|
||||
{ value: 'event-types', label: 'Event types', count: eventTypes.value.length },
|
||||
{ value: 'availability', label: 'Availability', count: availability.value.length },
|
||||
{ value: 'bookings', label: 'Bookings', count: bookings.value.length },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader eyebrow="Workspace" title="Scheduling" subtitle="Public booking pages backed by your team's calendars.">
|
||||
<template #actions>
|
||||
<UiButton variant="primary" @click="openHost">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
Add bookable host
|
||||
</UiButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="content">
|
||||
<Card v-if="!hosts || !hosts.length" class="notice">
|
||||
No bookable hosts yet. Add a host to create their booking pages — calendar access is provisioned automatically.
|
||||
</Card>
|
||||
|
||||
<div v-else class="layout">
|
||||
<!-- Hosts list -->
|
||||
<aside class="hosts">
|
||||
<button
|
||||
v-for="h in hosts"
|
||||
:key="h._id"
|
||||
class="hostrow"
|
||||
:class="{ active: h._id === selectedHostId }"
|
||||
@click="selectHost(h._id)"
|
||||
>
|
||||
<div class="hname">{{ h.displayName }}</div>
|
||||
<div class="hmail">{{ h.email }}</div>
|
||||
<Badge :tone="h.isActive ? 'ok' : 'neutral'" dot>{{ h.isActive ? 'active' : 'inactive' }}</Badge>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<!-- Detail -->
|
||||
<section class="detail">
|
||||
<Card v-if="!selectedHost" class="notice">Select a host to manage their event types and availability.</Card>
|
||||
<template v-else>
|
||||
<Tabs v-model="detailTab" :items="detailTabs" />
|
||||
|
||||
<!-- Event types -->
|
||||
<div v-if="detailTab === 'event-types'" class="pane">
|
||||
<div class="panehead">
|
||||
<span class="mute">{{ eventTypes.length }} event type(s)</span>
|
||||
<UiButton variant="secondary" :disabled="!availability.length" @click="openEt">New event type</UiButton>
|
||||
</div>
|
||||
<p v-if="!availability.length" class="hint">Create an availability schedule first.</p>
|
||||
<Card v-for="et in eventTypes" :key="et._id" class="item">
|
||||
<div class="itemmain">
|
||||
<div class="ititle">{{ et.title }} <span class="mute">· {{ et.durationMinutes }} min</span></div>
|
||||
<a class="link" :href="publicUrl(et)" target="_blank" rel="noopener">{{ publicUrl(et) }}</a>
|
||||
</div>
|
||||
<div class="itemactions">
|
||||
<UiButton variant="ghost" @click="copyUrl(et)"><UiIcon name="copy" :size="14" /> Copy</UiButton>
|
||||
<UiButton variant="ghost" @click="openEt(et)">Edit</UiButton>
|
||||
<UiButton variant="ghost" @click="deleteEt(et)">Delete</UiButton>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Availability -->
|
||||
<div v-else-if="detailTab === 'availability'" class="pane">
|
||||
<div class="panehead">
|
||||
<span class="mute">{{ availability.length }} schedule(s)</span>
|
||||
<UiButton variant="secondary" @click="openAvail">New schedule</UiButton>
|
||||
</div>
|
||||
<Card v-for="a in availability" :key="a._id" class="item">
|
||||
<div class="itemmain">
|
||||
<div class="ititle">{{ a.name }} <span class="mute">· {{ a.timezone }}</span></div>
|
||||
<div class="mute small">
|
||||
{{ a.weeklyRules.map((r) => DAYS[r.dayOfWeek]).join(', ') || 'No days set' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="itemactions">
|
||||
<UiButton variant="ghost" @click="openAvail(a)">Edit</UiButton>
|
||||
<UiButton variant="ghost" @click="deleteAvail(a)">Delete</UiButton>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Bookings -->
|
||||
<div v-else class="pane">
|
||||
<Card v-if="!bookings.length" class="notice">No bookings yet.</Card>
|
||||
<Card v-for="b in bookings" :key="b._id" class="item">
|
||||
<div class="itemmain">
|
||||
<div class="ititle">{{ b.attendeeName }} <span class="mute">· {{ b.attendeeEmail }}</span></div>
|
||||
<div class="mute small">{{ fmtDateTime(b.startUtc) }} – {{ fmtDateTime(b.endUtc) }}</div>
|
||||
</div>
|
||||
<div class="itemactions">
|
||||
<Badge :tone="b.status === 'confirmed' ? 'ok' : b.status === 'cancelled' ? 'bad' : 'neutral'">{{ b.status }}</Badge>
|
||||
<UiButton v-if="b.status === 'confirmed'" variant="ghost" @click="openReschedule(b)">Reschedule</UiButton>
|
||||
<UiButton v-if="b.status === 'confirmed'" variant="ghost" @click="cancelBookingAdmin(b)">Cancel</UiButton>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add host modal -->
|
||||
<Modal :open="hostOpen" eyebrow="Scheduling" title="Add bookable host" size="md" @close="hostOpen = false">
|
||||
<div class="form-stack">
|
||||
<p v-if="!availableUsers.length" class="hint">
|
||||
No eligible users. A bookable host needs a workspace mailbox (and isn't already a host) — add a user with a mailbox under Users & groups first.
|
||||
</p>
|
||||
<template v-else>
|
||||
<label class="field"><Eyebrow>User</Eyebrow>
|
||||
<select class="input" v-model="hostForm.userId" @change="onHostUserChange">
|
||||
<option v-for="u in availableUsers" :key="u._id" :value="u._id">{{ u.name }} — {{ u.mailboxAddress }}</option>
|
||||
</select>
|
||||
<span class="slughint">Calendar access is provisioned automatically — no connect step.</span>
|
||||
</label>
|
||||
<label class="field"><Eyebrow>URL slug</Eyebrow>
|
||||
<input class="input" v-model="hostForm.slug" placeholder="anne" />
|
||||
<span class="slughint" :class="{ bad: !!hostForm.slug && (slugTaken || !slugValid) }">
|
||||
<template v-if="!hostForm.slug">Used in the public booking link.</template>
|
||||
<template v-else-if="!slugValid">Lowercase letters, numbers and hyphens (2–40 chars).</template>
|
||||
<template v-else-if="slugTaken">“{{ hostForm.slug }}” is already taken.</template>
|
||||
<template v-else>booking.dezky.local/{{ slug }}/{{ hostForm.slug }} · available ✓</template>
|
||||
</span>
|
||||
</label>
|
||||
<label class="field"><Eyebrow>Timezone</Eyebrow>
|
||||
<select class="input" v-model="hostForm.timezone">
|
||||
<option>Europe/Copenhagen</option><option>Europe/London</option><option>Europe/Berlin</option><option>UTC</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="hostOpen = false">Cancel</UiButton>
|
||||
<UiButton
|
||||
variant="primary"
|
||||
:disabled="hostBusy || !availableUsers.length || !hostForm.userId || slugTaken || !slugValid"
|
||||
@click="submitHost"
|
||||
>{{ hostBusy ? 'Provisioning…' : 'Create host' }}</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Availability modal -->
|
||||
<Modal :open="availOpen" eyebrow="Scheduling" :title="availEditingId ? 'Edit availability schedule' : 'New availability schedule'" size="md" @close="availOpen = false">
|
||||
<div class="form-stack">
|
||||
<label class="field"><Eyebrow>Name</Eyebrow><input class="input" v-model="availForm.name" /></label>
|
||||
<label class="field"><Eyebrow>Timezone</Eyebrow>
|
||||
<select class="input" v-model="availForm.timezone">
|
||||
<option>Europe/Copenhagen</option><option>Europe/London</option><option>Europe/Berlin</option><option>UTC</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="weekgrid">
|
||||
<div v-for="(d, i) in availForm.days" :key="i" class="weekrow">
|
||||
<label class="daytoggle"><input type="checkbox" v-model="d.enabled" /> {{ DAYS[i] }}</label>
|
||||
<input class="input time" type="time" v-model="d.start" :disabled="!d.enabled" />
|
||||
<span class="dash">–</span>
|
||||
<input class="input time" type="time" v-model="d.end" :disabled="!d.enabled" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="availOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" :disabled="availBusy" @click="submitAvail">{{ availBusy ? 'Saving…' : 'Save' }}</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Event type modal -->
|
||||
<Modal :open="etOpen" eyebrow="Scheduling" :title="etEditingId ? 'Edit event type' : 'New event type'" size="md" @close="etOpen = false">
|
||||
<div class="form-stack">
|
||||
<label class="field"><Eyebrow>Title</Eyebrow><input class="input" v-model="etForm.title" @input="onEtTitle" placeholder="30-min consultation" /></label>
|
||||
<label class="field"><Eyebrow>URL slug</Eyebrow>
|
||||
<input class="input" v-model="etForm.slug" :disabled="!!etEditingId" @input="etSlugTouched = true" placeholder="consult" />
|
||||
<span class="slughint" :class="{ bad: !etEditingId && !!etForm.slug && !etSlugValid }">
|
||||
<template v-if="etEditingId">The slug can’t change after creation (it would break public links).</template>
|
||||
<template v-else-if="!etForm.slug">Part of the public booking link.</template>
|
||||
<template v-else-if="!etSlugValid">Lowercase letters, numbers and hyphens (2–40 chars).</template>
|
||||
<template v-else>/{{ selectedHost?.slug }}/{{ etForm.slug }}</template>
|
||||
</span>
|
||||
</label>
|
||||
<div class="row2">
|
||||
<label class="field"><Eyebrow>Duration (min)</Eyebrow><input class="input" type="number" v-model.number="etForm.durationMinutes" /></label>
|
||||
<label class="field"><Eyebrow>Slot step (min)</Eyebrow><input class="input" type="number" v-model.number="etForm.slotIntervalMinutes" /></label>
|
||||
</div>
|
||||
<div class="row2">
|
||||
<label class="field"><Eyebrow>Buffer before</Eyebrow><input class="input" type="number" v-model.number="etForm.bufferBeforeMinutes" /></label>
|
||||
<label class="field"><Eyebrow>Buffer after</Eyebrow><input class="input" type="number" v-model.number="etForm.bufferAfterMinutes" /></label>
|
||||
</div>
|
||||
<div class="row2">
|
||||
<label class="field"><Eyebrow>Min notice (min)</Eyebrow><input class="input" type="number" v-model.number="etForm.minimumNoticeMinutes" /></label>
|
||||
<label class="field"><Eyebrow>Horizon (days)</Eyebrow><input class="input" type="number" v-model.number="etForm.maximumDaysInFuture" /></label>
|
||||
</div>
|
||||
<label class="field"><Eyebrow>Availability schedule</Eyebrow>
|
||||
<select class="input" v-model="etForm.availabilityScheduleId">
|
||||
<option v-for="a in availability" :key="a._id" :value="a._id">{{ a.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field"><Eyebrow>Location</Eyebrow>
|
||||
<select class="input" v-model="etForm.locationType">
|
||||
<option value="jitsi">dezky Meet (video)</option><option value="phone">Phone</option>
|
||||
<option value="in_person">In person</option><option value="custom">Custom</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="etOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" :disabled="etBusy || !etValid" @click="submitEt">{{ etBusy ? 'Saving…' : etEditingId ? 'Save changes' : 'Create' }}</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Reschedule modal -->
|
||||
<Modal :open="reschedule.open" eyebrow="Scheduling" title="Reschedule booking" size="md" @close="reschedule.open = false">
|
||||
<div class="form-stack">
|
||||
<p v-if="reschedule.booking" class="mute small">
|
||||
{{ reschedule.booking.attendeeName }} — currently {{ fmtDateTime(reschedule.booking.startUtc) }}.
|
||||
Pick a new time (shown in {{ reschedule.hostTz }}).
|
||||
</p>
|
||||
<p v-if="reschedule.state === 'loading'" class="mute">Loading times…</p>
|
||||
<p v-else-if="reschedule.state === 'error'" class="err">Couldn’t load available times.</p>
|
||||
<template v-else-if="reschedule.state === 'ready'">
|
||||
<p v-if="!reschedule.days.length" class="mute">No available times in the next 14 days.</p>
|
||||
<template v-else>
|
||||
<div class="days">
|
||||
<button
|
||||
v-for="d in reschedule.days"
|
||||
:key="d.key"
|
||||
class="day"
|
||||
:class="{ active: d.key === reschedule.selectedKey }"
|
||||
@click="reschedule.selectedKey = d.key"
|
||||
>
|
||||
<span class="dlabel">{{ d.label }}</span>
|
||||
<span class="dcount">{{ d.slots.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="reschedDay" class="slotgrid">
|
||||
<button
|
||||
v-for="s in reschedDay.slots"
|
||||
:key="s.startUtc"
|
||||
class="slotbtn"
|
||||
:disabled="reschedule.busy"
|
||||
@click="confirmReschedule(s.startUtc)"
|
||||
>{{ reschedTime(s.startUtc) }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="reschedule.open = false">Close</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="confirmBox.open"
|
||||
eyebrow="Scheduling"
|
||||
:title="confirmBox.title"
|
||||
:confirm-label="confirmBox.confirmLabel"
|
||||
:busy="confirmBox.busy"
|
||||
tone="danger"
|
||||
@close="confirmBox.open = false"
|
||||
@confirm="runConfirm"
|
||||
>
|
||||
{{ confirmBox.message }}
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content { padding: 20px 40px 64px; }
|
||||
.notice { display: flex; align-items: center; gap: 10px; color: var(--text-mute); padding: 18px; }
|
||||
.layout { display: grid; grid-template-columns: 280px 1fr; gap: 18px; }
|
||||
.hosts { display: flex; flex-direction: column; gap: 8px; }
|
||||
.hostrow { text-align: left; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; cursor: pointer; display: flex; flex-direction: column; gap: 4px; }
|
||||
.hostrow.active { border-color: var(--text); box-shadow: inset 0 0 0 1px var(--text); }
|
||||
.hname { font-weight: 600; }
|
||||
.hmail { font-size: 12px; color: var(--text-mute); }
|
||||
.detail { min-width: 0; }
|
||||
.pane { margin-top: 14px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.panehead { display: flex; align-items: center; justify-content: space-between; }
|
||||
.item { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; }
|
||||
.itemmain { min-width: 0; }
|
||||
.itemactions { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
|
||||
.ititle { font-weight: 600; }
|
||||
.link { font-size: 12px; color: var(--text-mute); word-break: break-all; }
|
||||
.mute { color: var(--text-mute); }
|
||||
.small { font-size: 12px; }
|
||||
.hint { color: var(--warn, #9a6a00); font-size: 13px; }
|
||||
.slughint { font-size: 12px; color: var(--text-mute); }
|
||||
.slughint.bad { color: var(--bad, #c0362c); }
|
||||
.err { color: var(--bad, #c0362c); font-size: 13px; }
|
||||
.days { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 6px; }
|
||||
.day { flex: 0 0 auto; min-width: 78px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
||||
.day.active { border-color: var(--text); box-shadow: inset 0 0 0 1px var(--text); }
|
||||
.dlabel { font-size: 12px; }
|
||||
.dcount { font-size: 11px; color: var(--text-mute); }
|
||||
.slotgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(78px, 1fr)); gap: 8px; margin-top: 12px; }
|
||||
.slotbtn { height: 38px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); font-size: 14px; font-weight: 600; color: var(--text); cursor: pointer; }
|
||||
.slotbtn:hover:not(:disabled) { border-color: var(--text); }
|
||||
.slotbtn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.form-stack { display: flex; flex-direction: column; gap: 14px; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.input { height: 36px; padding: 0 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; font-size: 13px; color: var(--text); }
|
||||
.weekgrid { display: flex; flex-direction: column; gap: 8px; }
|
||||
.weekrow { display: grid; grid-template-columns: 90px 1fr auto 1fr; align-items: center; gap: 8px; }
|
||||
.daytoggle { display: flex; align-items: center; gap: 6px; font-size: 13px; }
|
||||
.time { width: 100%; }
|
||||
.dash { text-align: center; color: var(--text-mute); }
|
||||
</style>
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
// Read-only proxy to the public slots endpoint, used by the admin reschedule
|
||||
// picker so an operator rebooks against the same live free/busy a customer sees.
|
||||
// The upstream is unauthenticated, so no token is forwarded.
|
||||
export default defineEventHandler(async (event) => {
|
||||
const tenantSlug = getRouterParam(event, 'tenantSlug')!
|
||||
const hostSlug = getRouterParam(event, 'hostSlug')!
|
||||
const eventTypeSlug = getRouterParam(event, 'eventTypeSlug')!
|
||||
const query = getQuery(event)
|
||||
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||
const path = `/api/v1/public/${encodeURIComponent(tenantSlug)}/${encodeURIComponent(hostSlug)}/${encodeURIComponent(eventTypeSlug)}/slots`
|
||||
try {
|
||||
return await $fetch(base + path, { query })
|
||||
} 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) })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
// Catch-all proxy for the dezky Scheduling admin API. Forwards any method under
|
||||
// /api/tenants/:slug/scheduling/** to platform-api's
|
||||
// /api/v1/tenants/:slug/scheduling/** with the signed-in user's access token;
|
||||
// platform-api enforces tenant membership. Upstream status codes are preserved
|
||||
// so the admin UI sees real 400/403/404/409 responses.
|
||||
|
||||
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await getUserSession(event).catch(() => null)
|
||||
const accessToken = (session as { accessToken?: string } | null)?.accessToken
|
||||
if (!accessToken) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
|
||||
}
|
||||
const slug = getRouterParam(event, 'slug')
|
||||
const path = getRouterParam(event, 'path') ?? ''
|
||||
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||
const method = event.method
|
||||
const query = getQuery(event)
|
||||
const body = ['POST', 'PUT', 'PATCH'].includes(method) ? await readBody(event).catch(() => undefined) : undefined
|
||||
|
||||
try {
|
||||
return await $fetch(`${base}/api/v1/tenants/${slug}/scheduling/${path}`, {
|
||||
method: method as any,
|
||||
query,
|
||||
body,
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
} 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) })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user