chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
'use client'
|
||||
|
||||
import { type CSSProperties, useEffect, useRef, useState } from 'react'
|
||||
import { chipBorderShadowRing, cn } from '@sim/emcn'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { preconnect } from 'react-dom'
|
||||
import { DemoForm, type DemoLead } from '@/app/(landing)/demo/components/demo-form'
|
||||
|
||||
const importScheduler = () => import('@/app/(landing)/demo/components/demo-scheduler')
|
||||
|
||||
/**
|
||||
* Warm the entire booking path while the visitor fills the form: preconnect to
|
||||
* app.cal.com, then load the scheduler chunk, Cal.com's embed.js, and the
|
||||
* booker iframe assets (via the embed's `preload` instruction). Fired on first
|
||||
* form focus so nothing Cal.com-related competes with initial page load — the
|
||||
* connection handshake overlaps the chunk import, and it all finishes long
|
||||
* before the visitor submits.
|
||||
*/
|
||||
function preloadScheduler() {
|
||||
preconnect('https://app.cal.com')
|
||||
return importScheduler().then((m) => m.preloadCalEmbed())
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-loaded so the Cal.com embed never enters the initial landing bundle - it
|
||||
* loads only once a visitor reaches the booking step. `loading: () => null` (no
|
||||
* skeleton): the panel is already sized and the slide covers the brief load, so
|
||||
* there is no flash-then-resize.
|
||||
*/
|
||||
const DemoScheduler = dynamic(() => importScheduler().then((m) => m.DemoScheduler), {
|
||||
ssr: false,
|
||||
loading: () => null,
|
||||
})
|
||||
|
||||
interface DemoBookingProps {
|
||||
/** Layout/placement classes (grid cell). Never chrome. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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 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)
|
||||
const [formHeight, setFormHeight] = useState<number>()
|
||||
const formRef = useRef<HTMLDivElement>(null)
|
||||
const showScheduler = lead !== null
|
||||
|
||||
useEffect(() => {
|
||||
const node = formRef.current
|
||||
if (!node) return
|
||||
const measure = () => setFormHeight(node.offsetHeight)
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(node)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative min-w-0 overflow-hidden rounded-lg bg-[var(--surface-2)]',
|
||||
chipBorderShadowRing,
|
||||
'h-[var(--demo-card-h)]',
|
||||
showScheduler && 'max-sm:h-[80svh]',
|
||||
className
|
||||
)}
|
||||
style={{ '--demo-card-h': formHeight ? `${formHeight}px` : undefined } as CSSProperties}
|
||||
>
|
||||
<div
|
||||
className='flex h-full w-full transition-transform duration-200 ease-out motion-reduce:transition-none'
|
||||
style={{ transform: showScheduler ? 'translateX(-100%)' : undefined }}
|
||||
>
|
||||
<div
|
||||
className='w-full min-w-0 shrink-0'
|
||||
inert={showScheduler}
|
||||
onFocusCapture={() => void preloadScheduler()}
|
||||
>
|
||||
<div ref={formRef} className='p-6 max-sm:p-5'>
|
||||
<DemoForm onComplete={setLead} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='h-full w-full min-w-0 shrink-0' inert={!showScheduler}>
|
||||
{lead ? <DemoScheduler lead={lead} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DemoBooking } from './demo-booking'
|
||||
@@ -0,0 +1,350 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useEffect, useId, useState } from 'react'
|
||||
import { Chip, ChipDropdown, ChipInput, ChipTextarea, Label } from '@sim/emcn'
|
||||
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'
|
||||
|
||||
/** Options for the "What can we help you with?" select; the first is the default. */
|
||||
const TOPIC_OPTIONS = [
|
||||
{ value: 'demo', label: 'Book a demo' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Field control height - slightly taller than the 30px in-app chip default and
|
||||
* just under the 36px auth field, so the booking form reads as a roomy landing
|
||||
* surface. Applied to each control's `className`, the sanctioned way to own only
|
||||
* a chip field's height (mirrors `AuthInput`).
|
||||
*/
|
||||
const FIELD_HEIGHT = 'h-[34px]'
|
||||
|
||||
/**
|
||||
* The form's working state. On submit it maps onto the `demo-requests` contract
|
||||
* payload (the sales notification) and the {@link DemoLead} handed to the
|
||||
* scheduler - kept as one object so both mappings read from a single source.
|
||||
*/
|
||||
interface DemoFormState {
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string
|
||||
company: string
|
||||
companySize: string
|
||||
topic: string
|
||||
message: string
|
||||
}
|
||||
|
||||
const INITIAL_STATE: DemoFormState = {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
company: '',
|
||||
companySize: '',
|
||||
topic: TOPIC_OPTIONS[0].value,
|
||||
message: '',
|
||||
}
|
||||
|
||||
/** The captured lead handed to the Cal.com scheduler to prefill the booking. */
|
||||
export interface DemoLead {
|
||||
/** Full name - `${firstName} ${lastName}`. */
|
||||
name: string
|
||||
/** Work email. */
|
||||
email: string
|
||||
/** A readable summary of the company/role/topic, shown on the booking. */
|
||||
notes: string
|
||||
}
|
||||
|
||||
interface DemoFormProps {
|
||||
/** Called with the captured {@link DemoLead} when a valid form is submitted. */
|
||||
onComplete: (lead: DemoLead) => void
|
||||
}
|
||||
|
||||
/** Resolve an option's display label from its value, falling back to the value. */
|
||||
const labelFor = (options: ReadonlyArray<{ value: string; label: string }>, value: string) =>
|
||||
options.find((option) => option.value === value)?.label ?? value
|
||||
|
||||
/** Compose the booking notes from the structured fields, skipping empty optionals. */
|
||||
function buildNotes(form: DemoFormState): string {
|
||||
return [
|
||||
`Company: ${form.company}`,
|
||||
`Company size: ${labelFor(DEMO_REQUEST_COMPANY_SIZE_OPTIONS, form.companySize)}`,
|
||||
form.phone && `Phone: ${form.phone}`,
|
||||
`Topic: ${labelFor(TOPIC_OPTIONS, form.topic)}`,
|
||||
form.message && `Notes: ${form.message}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the sales-notification `details` from the free-form fields the typed
|
||||
* payload doesn't carry on its own - the company name, the topic, and any
|
||||
* message. Always non-empty (company and topic are required), satisfying the
|
||||
* contract's `details` minimum.
|
||||
*/
|
||||
function buildDetails(form: DemoFormState): string {
|
||||
return [
|
||||
`Company: ${form.company}`,
|
||||
`Topic: ${labelFor(TOPIC_OPTIONS, form.topic)}`,
|
||||
form.message && `\nNotes:\n${form.message}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
interface DemoFieldProps {
|
||||
label: string
|
||||
/** Set for native controls (inputs/textarea) to associate the label by `id`. */
|
||||
htmlFor?: string
|
||||
required?: boolean
|
||||
error?: string
|
||||
/** The control. Dropdowns (no `htmlFor`) are wrapped in a labeled group. */
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* A labeled field row matching the chip field rhythm (`gap-[9px]`, muted label,
|
||||
* caption-sized error). Native controls associate via `htmlFor`/`id`; controls
|
||||
* that can't take a label `id` (the dropdowns) become a `role='group'` named by
|
||||
* the label instead, so every field has an accessible name.
|
||||
*/
|
||||
function DemoField({ label, htmlFor, required, error, children }: DemoFieldProps) {
|
||||
const labelId = useId()
|
||||
const isGroup = htmlFor === undefined
|
||||
return (
|
||||
<div
|
||||
className='flex flex-col gap-[9px]'
|
||||
role={isGroup ? 'group' : undefined}
|
||||
aria-labelledby={isGroup ? labelId : undefined}
|
||||
>
|
||||
<Label id={labelId} htmlFor={htmlFor} className='pl-0.5 font-normal text-[var(--text-muted)]'>
|
||||
{label}
|
||||
{required ? (
|
||||
<span aria-hidden className='ml-0.5 text-[var(--text-error)]'>
|
||||
*
|
||||
</span>
|
||||
) : null}
|
||||
</Label>
|
||||
{children}
|
||||
{error ? <p className='pl-0.5 text-[var(--text-error)] text-caption'>{error}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 of the booking card - the demo-request form. Rendered inside the card
|
||||
* chrome owned by {@link DemoBooking}, so it returns just its heading and fields.
|
||||
*
|
||||
* Fields are hand-composed at the slightly-taller {@link FIELD_HEIGHT} (the
|
||||
* sanctioned standalone-field pattern - `Label` + a height-raised chip control),
|
||||
* stacked at the platform `gap-4` rhythm with no divider lines. Optional fields
|
||||
* carry an `(optional)` suffix; the "What can we help you with?" select defaults
|
||||
* to its first option ("Book a demo"); every other field is required. The email
|
||||
* runs through {@link quickValidateEmail}, its error surfaces only once the value
|
||||
* looks like an address attempt, and the primary {@link Chip} stays disabled until
|
||||
* every required field is valid.
|
||||
*
|
||||
* On a valid submit it fires the inbound-demo notification to sales (via
|
||||
* {@link useSubmitDemoRequest}, best-effort - never blocking) and composes a
|
||||
* {@link DemoLead} (name, email, and a notes summary) handed to `onComplete`,
|
||||
* which advances the card to the scheduler.
|
||||
*/
|
||||
export function DemoForm({ onComplete }: DemoFormProps) {
|
||||
const submitDemoRequest = useSubmitDemoRequest()
|
||||
const [form, setForm] = useState<DemoFormState>(INITIAL_STATE)
|
||||
|
||||
const setField = (key: keyof DemoFormState) => (value: string) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
|
||||
/**
|
||||
* Prefill the email from the `?email=` param the landing hero's CTA forwards
|
||||
* (its "Book a demo" GET form). Read once on mount from `window.location` -
|
||||
* not `useSearchParams` - so the page stays statically rendered with no
|
||||
* Suspense bailout; server and first client render are both empty, so there is
|
||||
* no hydration mismatch. Only seeds when the field is still untouched.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const prefill = new URLSearchParams(window.location.search).get('email')?.trim()
|
||||
if (prefill) setForm((prev) => (prev.email ? prev : { ...prev, email: prefill }))
|
||||
}, [])
|
||||
|
||||
const trimmedEmail = form.email.trim()
|
||||
const emailFormatValid = trimmedEmail.length > 0 && quickValidateEmail(trimmedEmail).isValid
|
||||
const emailIsFreeDomain = isFreeEmailDomain(trimmedEmail)
|
||||
const emailIsValid = emailFormatValid && !emailIsFreeDomain
|
||||
const canSubmit =
|
||||
emailIsValid &&
|
||||
form.firstName.trim().length > 0 &&
|
||||
form.lastName.trim().length > 0 &&
|
||||
form.company.trim().length > 0 &&
|
||||
form.companySize.length > 0
|
||||
|
||||
/**
|
||||
* 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('@')
|
||||
? undefined
|
||||
: !emailFormatValid
|
||||
? 'Enter a valid work email address.'
|
||||
: emailIsFreeDomain
|
||||
? 'Please use your work email address.'
|
||||
: undefined
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!canSubmit) return
|
||||
|
||||
// Best-effort sales notification — fire-and-forget so it never blocks scheduling.
|
||||
submitDemoRequest.mutate({
|
||||
firstName: form.firstName.trim(),
|
||||
lastName: form.lastName.trim(),
|
||||
companyEmail: trimmedEmail,
|
||||
phoneNumber: form.phone.trim() || undefined,
|
||||
companySize: form.companySize as DemoRequestBody['companySize'],
|
||||
details: buildDetails(form),
|
||||
})
|
||||
|
||||
onComplete({
|
||||
name: `${form.firstName} ${form.lastName}`.trim(),
|
||||
email: trimmedEmail,
|
||||
notes: buildNotes(form),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 id='demo-form-heading' className='text-[var(--text-primary)] text-xl leading-[1.2]'>
|
||||
Book a demo now
|
||||
</h2>
|
||||
<p className='mt-1.5 text-[var(--text-muted)] text-sm'>
|
||||
Tell us about your team and we'll tailor your demo to what you're building.
|
||||
</p>
|
||||
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}}
|
||||
aria-labelledby='demo-form-heading'
|
||||
className='mt-5 flex flex-col gap-4'
|
||||
noValidate
|
||||
>
|
||||
<div className='grid grid-cols-2 gap-3 max-sm:grid-cols-1'>
|
||||
<DemoField label='First name' htmlFor='demo-first-name' required>
|
||||
<ChipInput
|
||||
id='demo-first-name'
|
||||
className={FIELD_HEIGHT}
|
||||
value={form.firstName}
|
||||
onChange={(event) => setField('firstName')(event.target.value)}
|
||||
placeholder='Jane'
|
||||
autoComplete='given-name'
|
||||
/>
|
||||
</DemoField>
|
||||
<DemoField label='Last name' htmlFor='demo-last-name' required>
|
||||
<ChipInput
|
||||
id='demo-last-name'
|
||||
className={FIELD_HEIGHT}
|
||||
value={form.lastName}
|
||||
onChange={(event) => setField('lastName')(event.target.value)}
|
||||
placeholder='Doe'
|
||||
autoComplete='family-name'
|
||||
/>
|
||||
</DemoField>
|
||||
</div>
|
||||
|
||||
<DemoField label='Work email' htmlFor='demo-email' required error={emailError}>
|
||||
<ChipInput
|
||||
id='demo-email'
|
||||
type='email'
|
||||
className={FIELD_HEIGHT}
|
||||
value={form.email}
|
||||
onChange={(event) => setField('email')(event.target.value)}
|
||||
error={Boolean(emailError)}
|
||||
placeholder='jane@acme.co'
|
||||
autoComplete='email'
|
||||
/>
|
||||
</DemoField>
|
||||
|
||||
<DemoField label='Phone number (optional)' htmlFor='demo-phone'>
|
||||
<ChipInput
|
||||
id='demo-phone'
|
||||
type='tel'
|
||||
className={FIELD_HEIGHT}
|
||||
value={form.phone}
|
||||
onChange={(event) => setField('phone')(event.target.value)}
|
||||
placeholder='+1 (555) 000-0000'
|
||||
autoComplete='tel'
|
||||
/>
|
||||
</DemoField>
|
||||
|
||||
<DemoField label='Company' htmlFor='demo-company' required>
|
||||
<ChipInput
|
||||
id='demo-company'
|
||||
className={FIELD_HEIGHT}
|
||||
value={form.company}
|
||||
onChange={(event) => setField('company')(event.target.value)}
|
||||
placeholder='Acme Inc.'
|
||||
autoComplete='organization'
|
||||
/>
|
||||
</DemoField>
|
||||
|
||||
<DemoField label='Company size' required>
|
||||
<ChipDropdown
|
||||
fullWidth
|
||||
flush
|
||||
className={FIELD_HEIGHT}
|
||||
value={form.companySize || undefined}
|
||||
onChange={setField('companySize')}
|
||||
options={DEMO_REQUEST_COMPANY_SIZE_OPTIONS}
|
||||
placeholder='Select one'
|
||||
/>
|
||||
</DemoField>
|
||||
|
||||
<DemoField label='What can we help you with?'>
|
||||
<ChipDropdown
|
||||
fullWidth
|
||||
flush
|
||||
className={FIELD_HEIGHT}
|
||||
value={form.topic}
|
||||
onChange={setField('topic')}
|
||||
options={TOPIC_OPTIONS}
|
||||
/>
|
||||
</DemoField>
|
||||
|
||||
<DemoField label='Anything else we should know? (optional)' htmlFor='demo-message'>
|
||||
<ChipTextarea
|
||||
id='demo-message'
|
||||
value={form.message}
|
||||
onChange={(event) => setField('message')(event.target.value)}
|
||||
placeholder='What are you hoping to build with Sim?'
|
||||
rows={3}
|
||||
/>
|
||||
</DemoField>
|
||||
|
||||
{/*
|
||||
`Chip` gives its label span `flex-1`; under `fullWidth` that left-aligns the
|
||||
label, so center it with `justify-center` + a `flex-none` span override.
|
||||
*/}
|
||||
<Chip
|
||||
type='submit'
|
||||
variant='primary'
|
||||
flush
|
||||
fullWidth
|
||||
disabled={!canSubmit}
|
||||
className='mt-1 justify-center [&>span]:flex-none'
|
||||
>
|
||||
Continue
|
||||
</Chip>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DemoForm, type DemoLead } from './demo-form'
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Cal, { getCalApi } from '@calcom/embed-react'
|
||||
import type { DemoLead } from '@/app/(landing)/demo/components/demo-form'
|
||||
|
||||
/** The Cal.com event the demo books - set `NEXT_PUBLIC_CAL_LINK` to override. */
|
||||
const CAL_NAMESPACE = 'demo'
|
||||
const CAL_LINK = process.env.NEXT_PUBLIC_CAL_LINK ?? 'team/sim/demo'
|
||||
|
||||
/**
|
||||
* Sim's brand color, matching the `--brand-agent` token. The embed renders in a
|
||||
* cross-origin iframe, so it can't read our CSS vars - it needs the literal hex.
|
||||
*/
|
||||
const CAL_BRAND_COLOR = '#6f3dfa'
|
||||
|
||||
interface DemoSchedulerProps {
|
||||
/** The captured lead used to prefill the Cal.com booking. */
|
||||
lead: DemoLead
|
||||
}
|
||||
|
||||
let calEmbedPreloaded = false
|
||||
|
||||
/**
|
||||
* Warm the Cal.com embed before the scheduler mounts. Loads `embed.js` and
|
||||
* issues the embed's `preload` instruction, which fetches the booker in a
|
||||
* hidden `?preload=true` iframe so its assets are already cached when the real
|
||||
* embed renders on submit. Without this, nothing Cal.com-related starts
|
||||
* downloading until the visitor presses Continue, which is why the calendar
|
||||
* used to take several seconds to appear. Idempotent — repeat calls no-op
|
||||
* while a warm-up is in flight or done, but a failed embed.js load resets the
|
||||
* flag so a later focus can retry.
|
||||
*/
|
||||
export function preloadCalEmbed(): void {
|
||||
if (calEmbedPreloaded) return
|
||||
calEmbedPreloaded = true
|
||||
getCalApi({ namespace: CAL_NAMESPACE })
|
||||
.then((cal) => {
|
||||
cal('preload', { calLink: CAL_LINK })
|
||||
})
|
||||
.catch(() => {
|
||||
calEmbedPreloaded = false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2 of the booking card - the Cal.com scheduler, prefilled from the form's
|
||||
* {@link DemoLead}. Rendered inside the card chrome owned by {@link DemoBooking}
|
||||
* and lazy-loaded, so the embed script never touches the initial landing bundle.
|
||||
*
|
||||
* The embed is pinned to the page's light theme and Sim's brand color, and the
|
||||
* captured name/email/notes prefill the booking so the visitor never retypes. It
|
||||
* fills the panel (`flex-1`), which the parent sizes to the form's height, so the
|
||||
* card stays the same height across the form→calendar transition.
|
||||
*/
|
||||
export function DemoScheduler({ lead }: DemoSchedulerProps) {
|
||||
useEffect(() => {
|
||||
getCalApi({ namespace: CAL_NAMESPACE }).then((cal) => {
|
||||
cal('ui', {
|
||||
hideEventTypeDetails: true,
|
||||
styles: { branding: { brandColor: CAL_BRAND_COLOR } },
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-w-0 flex-col p-6 max-sm:p-5'>
|
||||
<h2 className='text-[var(--text-primary)] text-xl leading-[1.2]'>
|
||||
Pick a time{lead.name ? `, ${lead.name}` : ''}
|
||||
</h2>
|
||||
<p className='mt-1.5 text-[var(--text-muted)] text-sm'>
|
||||
Choose a slot that works for your team and we'll send a calendar invite.
|
||||
</p>
|
||||
<div className='mt-5 min-h-0 flex-1'>
|
||||
<Cal
|
||||
namespace={CAL_NAMESPACE}
|
||||
calLink={CAL_LINK}
|
||||
style={{ width: '100%', height: '100%', overflow: 'auto' }}
|
||||
config={{
|
||||
name: lead.name,
|
||||
email: lead.email,
|
||||
notes: lead.notes,
|
||||
theme: 'light',
|
||||
'ui.color-scheme': 'light',
|
||||
layout: 'month_view',
|
||||
useSlotsViewOnSmallScreen: 'true',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DemoScheduler, preloadCalEmbed } from './demo-scheduler'
|
||||
Reference in New Issue
Block a user