Skip to content

Commit eb367de

Browse files
authored
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.
1 parent 2c5ee4a commit eb367de

7 files changed

Lines changed: 83 additions & 49 deletions

File tree

apps/sim/app/(landing)/demo/components/demo-booking/demo-booking.tsx

Lines changed: 14 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -24,34 +24,22 @@ interface DemoBookingProps {
2424
}
2525

2626
/**
27-
* The demo page's right column - a two-step booking card and the only client
28-
* island on the page. It owns the card chrome (`rounded-lg`, `--surface-2`,
29-
* {@link chipBorderShadowRing}) and the step.
27+
* The demo page's right column: a two-step booking card and the only client
28+
* island on the page. Owns the card chrome and the step transition.
3029
*
31-
* Both steps live side by side in a sliding track: the form is panel 1, the
32-
* scheduler panel 2. Submitting slides one-way to the scheduler
33-
* (`translateX(-100%)`) at the platform's `duration-200 ease-out` (a refresh
34-
* restarts the flow). The form stays mounted (it drives the card height); the
35-
* off-screen panel is `inert` so it's out of tab/AT order.
30+
* The form (panel 1) and scheduler (panel 2) sit side by side in a sliding
31+
* track; submitting slides one-way to the scheduler at `duration-200 ease-out`.
32+
* The form stays mounted and drives the card height, so the card never resizes
33+
* across the transition; a `ResizeObserver` keeps the pinned height in sync as
34+
* the form grows (inline error, phone breakpoint). The off-screen panel is
35+
* `inert` (out of tab/AT order) and the scheduler lazy-mounts on submit,
36+
* preloaded on first form focus.
3637
*
37-
* The card is pinned to the form's measured height so it never resizes across
38-
* the form→calendar transition (the Cal embed self-sizes its own iframe via
39-
* postMessage, so this is purely to keep the card's height stable). A
40-
* `ResizeObserver` keeps it in sync as the form grows (an inline error, a phone
41-
* breakpoint). The scheduler fills its panel and lazy-mounts on submit (preloaded
42-
* on first form focus).
43-
*
44-
* Exception on phones (`max-sm`): once the scheduler is showing, the form-height
45-
* pin is overridden to `80svh` so the Cal booker gets a real viewport instead of
46-
* being crammed into the short form height - which caged the self-sizing iframe
47-
* behind `overflow:auto` and made its day/time slots tiny and hard to tap. The
48-
* pin is published as the `--demo-card-h` CSS var rather than an inline `height`
49-
* so the `max-sm` class can win (a media-query class can't override an inline
50-
* style height). `svh` keeps the height steady as the mobile URL bar shows/hides,
51-
* so the tap targets never shift.
52-
*
53-
* (Wiring the lead to a backend on submit slots in here - capture it before or
54-
* alongside `setLead`.)
38+
* The pin is published as the `--demo-card-h` CSS var (not an inline `height`)
39+
* so a `max-sm:h-[80svh]` class can override it once the scheduler shows — the
40+
* Cal booker needs a real viewport on phones instead of being crammed into the
41+
* short form height. `svh` keeps tap targets from shifting as the mobile URL bar
42+
* hides/shows.
5543
*/
5644
export function DemoBooking({ className }: DemoBookingProps) {
5745
const [lead, setLead] = useState<DemoLead | null>(null)

apps/sim/app/(landing)/demo/components/demo-form/demo-form.tsx

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
DEMO_REQUEST_COMPANY_SIZE_OPTIONS,
77
type DemoRequestBody,
88
} from '@/lib/api/contracts/demo-requests'
9+
import { isFreeEmailDomain } from '@/lib/messaging/email/free-email'
910
import { quickValidateEmail } from '@/lib/messaging/email/validation'
1011
import { useSubmitDemoRequest } from '@/hooks/queries/demo-requests'
1112

@@ -175,7 +176,9 @@ export function DemoForm({ onComplete }: DemoFormProps) {
175176
}, [])
176177

177178
const trimmedEmail = form.email.trim()
178-
const emailIsValid = trimmedEmail.length > 0 && quickValidateEmail(trimmedEmail).isValid
179+
const emailFormatValid = trimmedEmail.length > 0 && quickValidateEmail(trimmedEmail).isValid
180+
const emailIsFreeDomain = isFreeEmailDomain(trimmedEmail)
181+
const emailIsValid = emailFormatValid && !emailIsFreeDomain
179182
const canSubmit =
180183
emailIsValid &&
181184
form.firstName.trim().length > 0 &&
@@ -184,20 +187,23 @@ export function DemoForm({ onComplete }: DemoFormProps) {
184187
form.companySize.length > 0
185188

186189
/**
187-
* Only surface a format error once the value looks like an address attempt
188-
* (contains `@`) so the field doesn't flash an error on the first keystroke.
190+
* Surface an error only once the value looks like an address attempt (contains
191+
* `@`) so the field doesn't flash on the first keystroke, and distinguish a
192+
* malformed address from a personal one so the visitor knows to switch to a
193+
* work email — matching the server's work-email requirement.
189194
*/
190-
const emailError =
191-
form.email.includes('@') && !emailIsValid ? 'Enter a valid work email address.' : undefined
195+
const emailError = !form.email.includes('@')
196+
? undefined
197+
: !emailFormatValid
198+
? 'Enter a valid work email address.'
199+
: emailIsFreeDomain
200+
? 'Please use your work email address.'
201+
: undefined
192202

193203
const handleSubmit = () => {
194204
if (!canSubmit) return
195205

196-
// Notify sales of the inbound demo (route emails the sales inbox, replying to
197-
// the visitor - no email is sent to the visitor). Fire-and-forget so a failed
198-
// or rate-limited notification never blocks the visitor from scheduling; the
199-
// company-size value originates from the contract's own options, so it is a
200-
// valid payload value.
206+
// Best-effort sales notification — fire-and-forget so it never blocks scheduling.
201207
submitDemoRequest.mutate({
202208
firstName: form.firstName.trim(),
203209
lastName: form.lastName.trim(),

apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,7 @@ export function DemoScheduler({ lead }: DemoSchedulerProps) {
3333
useEffect(() => {
3434
getCalApi({ namespace: CAL_NAMESPACE }).then((cal) => {
3535
cal('ui', {
36-
theme: 'light',
3736
hideEventTypeDetails: true,
38-
layout: 'month_view',
3937
styles: { branding: { brandColor: CAL_BRAND_COLOR } },
4038
})
4139
})
@@ -58,6 +56,8 @@ export function DemoScheduler({ lead }: DemoSchedulerProps) {
5856
name: lead.name,
5957
email: lead.email,
6058
notes: lead.notes,
59+
theme: 'light',
60+
'ui.color-scheme': 'light',
6161
layout: 'month_view',
6262
useSlotsViewOnSmallScreen: 'true',
6363
}}

apps/sim/lib/api/contracts/demo-requests.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
import freeEmailDomains from 'free-email-domains'
21
import { z } from 'zod'
32
import { defineRouteContract } from '@/lib/api/contracts/types'
3+
import { isFreeEmailDomain } from '@/lib/messaging/email/free-email'
44
import { NO_EMAIL_HEADER_CONTROL_CHARS_REGEX } from '@/lib/messaging/email/utils'
55
import { quickValidateEmail } from '@/lib/messaging/email/validation'
66

7-
const FREE_EMAIL_DOMAINS = new Set(freeEmailDomains)
8-
97
export const DEMO_REQUEST_COMPANY_SIZE_VALUES = [
108
'1_10',
119
'11_50',
@@ -46,10 +44,7 @@ export const demoRequestSchema = z.object({
4644
.max(320)
4745
.transform((value) => value.toLowerCase())
4846
.refine((value) => quickValidateEmail(value).isValid, 'Enter a valid work email')
49-
.refine((value) => {
50-
const domain = value.split('@')[1]
51-
return domain ? !FREE_EMAIL_DOMAINS.has(domain) : true
52-
}, 'Please use your work email address'),
47+
.refine((value) => !isFreeEmailDomain(value), 'Please use your work email address'),
5348
phoneNumber: z
5449
.string()
5550
.trim()
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { isFreeEmailDomain } from './free-email'
6+
7+
describe('isFreeEmailDomain', () => {
8+
it('returns true for known free/personal providers', () => {
9+
expect(isFreeEmailDomain('jane@gmail.com')).toBe(true)
10+
expect(isFreeEmailDomain('jane@yahoo.com')).toBe(true)
11+
expect(isFreeEmailDomain('jane@hotmail.com')).toBe(true)
12+
})
13+
14+
it('returns false for work domains', () => {
15+
expect(isFreeEmailDomain('jane@acme.co')).toBe(false)
16+
expect(isFreeEmailDomain('jane@sim.ai')).toBe(false)
17+
})
18+
19+
it('is case-insensitive on the domain', () => {
20+
expect(isFreeEmailDomain('Jane@GMAIL.com')).toBe(true)
21+
})
22+
23+
it('returns false when there is no domain', () => {
24+
expect(isFreeEmailDomain('jane')).toBe(false)
25+
expect(isFreeEmailDomain('')).toBe(false)
26+
})
27+
})
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import freeEmailDomains from 'free-email-domains'
2+
3+
const FREE_EMAIL_DOMAINS = new Set(freeEmailDomains)
4+
5+
/**
6+
* True when the email's domain is a known free/personal provider (Gmail, Yahoo,
7+
* …) rather than a work address. Shared by the demo-request schema and form so
8+
* client gating and server validation agree on what counts as a work email.
9+
*
10+
* Isolated in its own module (not `validation.ts`) so the sizable domain list
11+
* only enters bundles that need the work-email check, not every consumer of
12+
* {@link quickValidateEmail}.
13+
*/
14+
export function isFreeEmailDomain(email: string): boolean {
15+
const domain = email.split('@')[1]?.toLowerCase()
16+
return domain ? FREE_EMAIL_DOMAINS.has(domain) : false
17+
}

apps/sim/next.config.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,9 @@ const nextConfig: NextConfig = {
206206
],
207207
},
208208
{
209-
// Exclude Vercel internal resources and static assets from strict COEP, Google Drive Picker to prevent 'refused to connect' issue
210-
source: '/((?!_next|_vercel|api|favicon.ico|w/.*|workspace/.*|api/tools/drive).*)',
209+
// Exclude Vercel internal resources and static assets from strict COEP, Google Drive Picker
210+
// and the /demo Cal.com booking embed to prevent 'refused to connect' / slow-load issues
211+
source: '/((?!_next|_vercel|api|favicon.ico|w/.*|workspace/.*|api/tools/drive|demo).*)',
211212
headers: [
212213
{
213214
key: 'Cross-Origin-Embedder-Policy',
@@ -220,8 +221,8 @@ const nextConfig: NextConfig = {
220221
],
221222
},
222223
{
223-
// For main app routes, Google Drive Picker, and Vercel resources - use permissive policies
224-
source: '/(w/.*|workspace/.*|api/tools/drive|_next/.*|_vercel/.*)',
224+
// For main app routes, Google Drive Picker, the /demo Cal.com embed, and Vercel resources - use permissive policies
225+
source: '/(w/.*|workspace/.*|api/tools/drive|demo.*|_next/.*|_vercel/.*)',
225226
headers: [
226227
{
227228
key: 'Cross-Origin-Embedder-Policy',

0 commit comments

Comments
 (0)