fix(demo): unblock Cal.com booking embed and align work-email validation (#5335)

* fix(demo): unblock Cal.com booking embed and align work-email validation

- Exclude /demo from cross-origin isolation headers (COEP credentialless /
  COOP same-origin) that degraded the third-party Cal.com booking iframe,
  mirroring the existing Google Drive Picker exclusion; the booker no longer
  loads under a Storage-Access handshake that often never finished
- Pin the Cal embed theme/layout in the inline config to fix a dark-on-light
  theme race; keep cal('ui') to UI-only settings
- Gate the demo form's Continue on the same work-email rule the server
  enforces via a shared isFreeEmailDomain helper, bundle-isolated in
  lib/messaging/email/free-email.ts so it doesn't bloat other email bundles
- Trim redundant comments in the demo components

* fix(demo): match /demo subroutes in permissive COEP rule

Align the permissive-headers positive match with the strict-COEP negative
lookahead so any future /demo subroute still receives the permissive
COEP/COOP policy instead of falling through to no headers.

* fix(demo): use demo.* in permissive COEP rule (valid route source)

The demo(/.*)? form introduced a nested capturing group, which Next's
path-to-regexp route-source parser rejects ('Capturing groups are not
allowed'), failing the build. demo.* mirrors the strict-rule lookahead's
demo prefix without a nested group, matching the existing w/.* style.
This commit is contained in:
Waleed
2026-07-01 14:41:48 -07:00
committed by GitHub
parent 2c5ee4a8db
commit eb367def31
7 changed files with 83 additions and 49 deletions
@@ -24,34 +24,22 @@ interface DemoBookingProps {
}
/**
* The demo page's right column - a two-step booking card and the only client
* island on the page. It owns the card chrome (`rounded-lg`, `--surface-2`,
* {@link chipBorderShadowRing}) and the step.
* The demo page's right column: a two-step booking card and the only client
* island on the page. Owns the card chrome and the step transition.
*
* Both steps live side by side in a sliding track: the form is panel 1, the
* scheduler panel 2. Submitting slides one-way to the scheduler
* (`translateX(-100%)`) at the platform's `duration-200 ease-out` (a refresh
* restarts the flow). The form stays mounted (it drives the card height); the
* off-screen panel is `inert` so it's out of tab/AT order.
* The form (panel 1) and scheduler (panel 2) sit side by side in a sliding
* track; submitting slides one-way to the scheduler at `duration-200 ease-out`.
* The form stays mounted and drives the card height, so the card never resizes
* across the transition; a `ResizeObserver` keeps the pinned height in sync as
* the form grows (inline error, phone breakpoint). The off-screen panel is
* `inert` (out of tab/AT order) and the scheduler lazy-mounts on submit,
* preloaded on first form focus.
*
* The card is pinned to the form's measured height so it never resizes across
* the form→calendar transition (the Cal embed self-sizes its own iframe via
* postMessage, so this is purely to keep the card's height stable). A
* `ResizeObserver` keeps it in sync as the form grows (an inline error, a phone
* breakpoint). The scheduler fills its panel and lazy-mounts on submit (preloaded
* on first form focus).
*
* Exception on phones (`max-sm`): once the scheduler is showing, the form-height
* pin is overridden to `80svh` so the Cal booker gets a real viewport instead of
* being crammed into the short form height - which caged the self-sizing iframe
* behind `overflow:auto` and made its day/time slots tiny and hard to tap. The
* pin is published as the `--demo-card-h` CSS var rather than an inline `height`
* so the `max-sm` class can win (a media-query class can't override an inline
* style height). `svh` keeps the height steady as the mobile URL bar shows/hides,
* so the tap targets never shift.
*
* (Wiring the lead to a backend on submit slots in here - capture it before or
* alongside `setLead`.)
* The pin is published as the `--demo-card-h` CSS var (not an inline `height`)
* so a `max-sm:h-[80svh]` class can override it once the scheduler shows — the
* Cal booker needs a real viewport on phones instead of being crammed into the
* short form height. `svh` keeps tap targets from shifting as the mobile URL bar
* hides/shows.
*/
export function DemoBooking({ className }: DemoBookingProps) {
const [lead, setLead] = useState<DemoLead | null>(null)
@@ -6,6 +6,7 @@ import {
DEMO_REQUEST_COMPANY_SIZE_OPTIONS,
type DemoRequestBody,
} from '@/lib/api/contracts/demo-requests'
import { isFreeEmailDomain } from '@/lib/messaging/email/free-email'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { useSubmitDemoRequest } from '@/hooks/queries/demo-requests'
@@ -175,7 +176,9 @@ export function DemoForm({ onComplete }: DemoFormProps) {
}, [])
const trimmedEmail = form.email.trim()
const emailIsValid = trimmedEmail.length > 0 && quickValidateEmail(trimmedEmail).isValid
const emailFormatValid = trimmedEmail.length > 0 && quickValidateEmail(trimmedEmail).isValid
const emailIsFreeDomain = isFreeEmailDomain(trimmedEmail)
const emailIsValid = emailFormatValid && !emailIsFreeDomain
const canSubmit =
emailIsValid &&
form.firstName.trim().length > 0 &&
@@ -184,20 +187,23 @@ export function DemoForm({ onComplete }: DemoFormProps) {
form.companySize.length > 0
/**
* Only surface a format error once the value looks like an address attempt
* (contains `@`) so the field doesn't flash an error on the first keystroke.
* Surface an error only once the value looks like an address attempt (contains
* `@`) so the field doesn't flash on the first keystroke, and distinguish a
* malformed address from a personal one so the visitor knows to switch to a
* work email — matching the server's work-email requirement.
*/
const emailError =
form.email.includes('@') && !emailIsValid ? 'Enter a valid work email address.' : undefined
const emailError = !form.email.includes('@')
? undefined
: !emailFormatValid
? 'Enter a valid work email address.'
: emailIsFreeDomain
? 'Please use your work email address.'
: undefined
const handleSubmit = () => {
if (!canSubmit) return
// Notify sales of the inbound demo (route emails the sales inbox, replying to
// the visitor - no email is sent to the visitor). Fire-and-forget so a failed
// or rate-limited notification never blocks the visitor from scheduling; the
// company-size value originates from the contract's own options, so it is a
// valid payload value.
// Best-effort sales notification — fire-and-forget so it never blocks scheduling.
submitDemoRequest.mutate({
firstName: form.firstName.trim(),
lastName: form.lastName.trim(),
@@ -33,9 +33,7 @@ export function DemoScheduler({ lead }: DemoSchedulerProps) {
useEffect(() => {
getCalApi({ namespace: CAL_NAMESPACE }).then((cal) => {
cal('ui', {
theme: 'light',
hideEventTypeDetails: true,
layout: 'month_view',
styles: { branding: { brandColor: CAL_BRAND_COLOR } },
})
})
@@ -58,6 +56,8 @@ export function DemoScheduler({ lead }: DemoSchedulerProps) {
name: lead.name,
email: lead.email,
notes: lead.notes,
theme: 'light',
'ui.color-scheme': 'light',
layout: 'month_view',
useSlotsViewOnSmallScreen: 'true',
}}
+2 -7
View File
@@ -1,11 +1,9 @@
import freeEmailDomains from 'free-email-domains'
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { isFreeEmailDomain } from '@/lib/messaging/email/free-email'
import { NO_EMAIL_HEADER_CONTROL_CHARS_REGEX } from '@/lib/messaging/email/utils'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
const FREE_EMAIL_DOMAINS = new Set(freeEmailDomains)
export const DEMO_REQUEST_COMPANY_SIZE_VALUES = [
'1_10',
'11_50',
@@ -46,10 +44,7 @@ export const demoRequestSchema = z.object({
.max(320)
.transform((value) => value.toLowerCase())
.refine((value) => quickValidateEmail(value).isValid, 'Enter a valid work email')
.refine((value) => {
const domain = value.split('@')[1]
return domain ? !FREE_EMAIL_DOMAINS.has(domain) : true
}, 'Please use your work email address'),
.refine((value) => !isFreeEmailDomain(value), 'Please use your work email address'),
phoneNumber: z
.string()
.trim()
@@ -0,0 +1,27 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { isFreeEmailDomain } from './free-email'
describe('isFreeEmailDomain', () => {
it('returns true for known free/personal providers', () => {
expect(isFreeEmailDomain('jane@gmail.com')).toBe(true)
expect(isFreeEmailDomain('jane@yahoo.com')).toBe(true)
expect(isFreeEmailDomain('jane@hotmail.com')).toBe(true)
})
it('returns false for work domains', () => {
expect(isFreeEmailDomain('jane@acme.co')).toBe(false)
expect(isFreeEmailDomain('jane@sim.ai')).toBe(false)
})
it('is case-insensitive on the domain', () => {
expect(isFreeEmailDomain('Jane@GMAIL.com')).toBe(true)
})
it('returns false when there is no domain', () => {
expect(isFreeEmailDomain('jane')).toBe(false)
expect(isFreeEmailDomain('')).toBe(false)
})
})
@@ -0,0 +1,17 @@
import freeEmailDomains from 'free-email-domains'
const FREE_EMAIL_DOMAINS = new Set(freeEmailDomains)
/**
* True when the email's domain is a known free/personal provider (Gmail, Yahoo,
* …) rather than a work address. Shared by the demo-request schema and form so
* client gating and server validation agree on what counts as a work email.
*
* Isolated in its own module (not `validation.ts`) so the sizable domain list
* only enters bundles that need the work-email check, not every consumer of
* {@link quickValidateEmail}.
*/
export function isFreeEmailDomain(email: string): boolean {
const domain = email.split('@')[1]?.toLowerCase()
return domain ? FREE_EMAIL_DOMAINS.has(domain) : false
}
+5 -4
View File
@@ -206,8 +206,9 @@ const nextConfig: NextConfig = {
],
},
{
// Exclude Vercel internal resources and static assets from strict COEP, Google Drive Picker to prevent 'refused to connect' issue
source: '/((?!_next|_vercel|api|favicon.ico|w/.*|workspace/.*|api/tools/drive).*)',
// Exclude Vercel internal resources and static assets from strict COEP, Google Drive Picker
// and the /demo Cal.com booking embed to prevent 'refused to connect' / slow-load issues
source: '/((?!_next|_vercel|api|favicon.ico|w/.*|workspace/.*|api/tools/drive|demo).*)',
headers: [
{
key: 'Cross-Origin-Embedder-Policy',
@@ -220,8 +221,8 @@ const nextConfig: NextConfig = {
],
},
{
// For main app routes, Google Drive Picker, and Vercel resources - use permissive policies
source: '/(w/.*|workspace/.*|api/tools/drive|_next/.*|_vercel/.*)',
// For main app routes, Google Drive Picker, the /demo Cal.com embed, and Vercel resources - use permissive policies
source: '/(w/.*|workspace/.*|api/tools/drive|demo.*|_next/.*|_vercel/.*)',
headers: [
{
key: 'Cross-Origin-Embedder-Policy',