diff --git a/apps/sim/app/(landing)/demo/components/demo-booking/demo-booking.tsx b/apps/sim/app/(landing)/demo/components/demo-booking/demo-booking.tsx index 681858f1a55..4b7a499704d 100644 --- a/apps/sim/app/(landing)/demo/components/demo-booking/demo-booking.tsx +++ b/apps/sim/app/(landing)/demo/components/demo-booking/demo-booking.tsx @@ -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(null) diff --git a/apps/sim/app/(landing)/demo/components/demo-form/demo-form.tsx b/apps/sim/app/(landing)/demo/components/demo-form/demo-form.tsx index dba60f052af..370e1ecb436 100644 --- a/apps/sim/app/(landing)/demo/components/demo-form/demo-form.tsx +++ b/apps/sim/app/(landing)/demo/components/demo-form/demo-form.tsx @@ -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(), diff --git a/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx b/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx index eea5a3d0b9d..c86d0ea9fbf 100644 --- a/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx +++ b/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx @@ -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', }} diff --git a/apps/sim/lib/api/contracts/demo-requests.ts b/apps/sim/lib/api/contracts/demo-requests.ts index 55fe54d4dbd..47fb32a7088 100644 --- a/apps/sim/lib/api/contracts/demo-requests.ts +++ b/apps/sim/lib/api/contracts/demo-requests.ts @@ -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() diff --git a/apps/sim/lib/messaging/email/free-email.test.ts b/apps/sim/lib/messaging/email/free-email.test.ts new file mode 100644 index 00000000000..206bd8b015b --- /dev/null +++ b/apps/sim/lib/messaging/email/free-email.test.ts @@ -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) + }) +}) diff --git a/apps/sim/lib/messaging/email/free-email.ts b/apps/sim/lib/messaging/email/free-email.ts new file mode 100644 index 00000000000..07a2f2b5002 --- /dev/null +++ b/apps/sim/lib/messaging/email/free-email.ts @@ -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 +} diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 534ff891176..fd14072bbf6 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -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',