Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 16 additions & 10 deletions apps/sim/app/(landing)/demo/components/demo-form/demo-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 &&
Expand All @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
})
})
Expand All @@ -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',
Comment thread
waleedlatif1 marked this conversation as resolved.
useSlotsViewOnSmallScreen: 'true',
}}
Expand Down
9 changes: 2 additions & 7 deletions apps/sim/lib/api/contracts/demo-requests.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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()
Expand Down
27 changes: 27 additions & 0 deletions apps/sim/lib/messaging/email/free-email.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
17 changes: 17 additions & 0 deletions apps/sim/lib/messaging/email/free-email.ts
Original file line number Diff line number Diff line change
@@ -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
}
9 changes: 5 additions & 4 deletions apps/sim/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
Loading