diff --git a/apps/booking/nuxt.config.ts b/apps/booking/nuxt.config.ts index 1519add..04ceea5 100644 --- a/apps/booking/nuxt.config.ts +++ b/apps/booking/nuxt.config.ts @@ -18,6 +18,10 @@ export default defineNuxtConfig({ public: { siteUrl: process.env.NUXT_PUBLIC_SITE_URL || (process.env.NODE_ENV === 'production' ? 'https://booking.dezky.eu' : 'http://localhost:3000'), + // Cloudflare Turnstile site-key. When set, the booking form renders a + // captcha widget and sends its token with the booking POST. Server-side + // enforcement is gated independently by TURNSTILE_SECRET on platform-api. + turnstileSiteKey: process.env.NUXT_PUBLIC_TURNSTILE_SITE_KEY || '', }, }, diff --git a/apps/booking/pages/[tenantSlug]/[hostSlug]/[eventTypeSlug].vue b/apps/booking/pages/[tenantSlug]/[hostSlug]/[eventTypeSlug].vue index aafd9c5..ac1311e 100644 --- a/apps/booking/pages/[tenantSlug]/[hostSlug]/[eventTypeSlug].vue +++ b/apps/booking/pages/[tenantSlug]/[hostSlug]/[eventTypeSlug].vue @@ -30,6 +30,71 @@ const submitting = ref(false) const submitError = ref(null) const booking = ref(null) +// Cloudflare Turnstile (optional). Only when a public site-key is configured do +// we load the script + render a widget on the details step; otherwise the form +// behaves exactly as before. We never solve the captcha — the widget produces a +// token that we forward to platform-api, which verifies it server-side. +const turnstileSiteKey = useRuntimeConfig().public.turnstileSiteKey as string +const captchaToken = ref(null) +const captchaEl = ref(null) +let captchaWidgetId: string | null = null +const TURNSTILE_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js' + +function loadTurnstileScript(): Promise { + return new Promise((resolve, reject) => { + if ((window as any).turnstile) return resolve() + const existing = document.querySelector(`script[src^="${TURNSTILE_SRC}"]`) as HTMLScriptElement | null + if (existing) { + existing.addEventListener('load', () => resolve()) + existing.addEventListener('error', () => reject(new Error('turnstile load failed'))) + return + } + const s = document.createElement('script') + s.src = TURNSTILE_SRC + s.async = true + s.defer = true + s.addEventListener('load', () => resolve()) + s.addEventListener('error', () => reject(new Error('turnstile load failed'))) + document.head.appendChild(s) + }) +} + +async function renderCaptcha() { + if (!turnstileSiteKey || !import.meta.client || !captchaEl.value || captchaWidgetId) return + try { + await loadTurnstileScript() + const turnstile = (window as any).turnstile + if (!turnstile || !captchaEl.value) return + captchaWidgetId = turnstile.render(captchaEl.value, { + sitekey: turnstileSiteKey, + callback: (token: string) => { + captchaToken.value = token + }, + 'expired-callback': () => { + captchaToken.value = null + }, + 'error-callback': () => { + captchaToken.value = null + }, + }) + } catch { + // If the widget can't load we leave captchaToken null; the server decides + // whether that's fatal (it is only when TURNSTILE_SECRET is set). + } +} + +function resetCaptcha() { + captchaToken.value = null + if (import.meta.client && captchaWidgetId && (window as any).turnstile) { + ;(window as any).turnstile.reset(captchaWidgetId) + } +} + +// Render the widget whenever the details step becomes visible. +watch(step, (s) => { + if (s === 'details' && turnstileSiteKey) void nextTick(renderCaptcha) +}) + // Whitelabel theming — drive the accent from the tenant's brandColor. watchEffect(() => { if (!import.meta.client || !info.value) return @@ -90,6 +155,7 @@ async function confirm() { attendeeEmail: form.email, attendeeTimezone: visitorTz.value, attendeeNotes: form.notes || undefined, + captchaToken: captchaToken.value || undefined, }, }) } @@ -101,6 +167,9 @@ async function confirm() { await loadSlots() } else if (e?.statusCode === 503) { submitError.value = 'The calendar is temporarily unavailable. Please try again in a moment.' + } else if (e?.statusCode === 403) { + submitError.value = 'Captcha verification failed. Please complete the challenge and try again.' + resetCaptcha() } else { submitError.value = e?.statusMessage || 'Something went wrong. Please try again.' } @@ -222,8 +291,13 @@ useHead(() => ({ title: info.value ? `${info.value.eventType.title} · ${info.va