feat(scheduling): pluggable captcha (Turnstile) on public booking

This commit is contained in:
Ronni Baslund
2026-06-07 09:02:35 +02:00
parent e1a77b085f
commit e33b7f18a3
7 changed files with 197 additions and 2 deletions
@@ -30,6 +30,71 @@ const submitting = ref(false)
const submitError = ref<string | null>(null)
const booking = ref<PublicBooking | null>(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<string | null>(null)
const captchaEl = ref<HTMLElement | null>(null)
let captchaWidgetId: string | null = null
const TURNSTILE_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
function loadTurnstileScript(): Promise<void> {
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
<label class="bk-label" for="notes">Notes (optional)</label>
<textarea id="notes" v-model="form.notes" class="bk-input" maxlength="2000" />
</div>
<div v-if="turnstileSiteKey" ref="captchaEl" class="captcha" />
<p v-if="submitError" class="err">{{ submitError }}</p>
<button type="submit" class="bk-btn bk-btn--primary" :disabled="submitting">
<button
type="submit"
class="bk-btn bk-btn--primary"
:disabled="submitting || (!!turnstileSiteKey && !captchaToken)"
>
{{ submitting ? 'Confirming…' : 'Confirm booking' }}
</button>
</form>
@@ -271,6 +345,7 @@ useHead(() => ({ title: info.value ? `${info.value.eventType.title} · ${info.va
.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; }
.captcha { min-height: 65px; }
.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; }