mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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',
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user