From 614c631118bec2df657aa5deeb0caccb56fc5bd6 Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Tue, 26 May 2026 14:17:06 -0400 Subject: [PATCH 1/2] Build safe public simulator demo --- app/simulator/actions.ts | 115 ----- app/simulator/page.tsx | 268 +----------- .../demo/public-simulator-experience.tsx | 405 ++++++++++++++++++ lib/public-simulator.ts | 378 ++++++++++++++++ tests/public-demo-route.test.ts | 13 +- tests/public-simulator-flow.test.ts | 64 +++ tests/simulator-isolation.test.ts | 23 +- tests/simulator-route.test.ts | 12 +- 8 files changed, 877 insertions(+), 401 deletions(-) delete mode 100644 app/simulator/actions.ts create mode 100644 components/demo/public-simulator-experience.tsx create mode 100644 lib/public-simulator.ts create mode 100644 tests/public-simulator-flow.test.ts diff --git a/app/simulator/actions.ts b/app/simulator/actions.ts deleted file mode 100644 index 9a77213..0000000 --- a/app/simulator/actions.ts +++ /dev/null @@ -1,115 +0,0 @@ -'use server'; - -import { SubscriptionStatus } from '@prisma/client'; -import { redirect } from 'next/navigation'; - -import { db } from '@/lib/db'; -import { normalizePhoneNumber } from '@/lib/phone'; -import { processLeadInboundReply, startMissedCallRecovery } from '@/lib/missed-call-flow'; -import { canSendRealSimulatorSms, createSimulatorPublicId, getSimulatorBusiness, getSimulatorRun, isPublicSimulatorEnabled } from '@/lib/simulator'; - -function getSimulatorRedirect(publicId?: string | null, error?: string, status?: string, notice?: string) { - const params = new URLSearchParams(); - if (publicId) params.set('run', publicId); - if (error) params.set('error', error); - if (status) params.set('status', status); - if (notice) params.set('notice', notice); - const query = params.toString(); - return query ? `/simulator?${query}` : '/simulator'; -} - -export async function startSimulatorRunAction(formData: FormData) { - if (!isPublicSimulatorEnabled()) { - redirect(getSimulatorRedirect(null, 'The public simulator is not enabled on this environment.')); - } - - const business = await getSimulatorBusiness(); - if (!business) { - redirect(getSimulatorRedirect(null, 'Simulator business is not configured yet.')); - } - - const phoneRaw = typeof formData.get('phone') === 'string' ? String(formData.get('phone')) : ''; - const callerPhone = normalizePhoneNumber(phoneRaw) || phoneRaw.trim(); - if (!callerPhone) { - redirect(getSimulatorRedirect(null, 'Enter a phone number to start the simulator.')); - } - - const realSmsEnabled = canSendRealSimulatorSms(business); - const transport = realSmsEnabled ? 'twilio' : 'simulated'; - const notice = realSmsEnabled - ? 'Simulator started. CallbackCloser should text the number you entered from the demo business texting line.' - : 'Preview mode active. This simulator run updates the transcript and owner alerts on-page, but it does not text your phone until the demo texting line is configured for real SMS delivery.'; - - const call = await db.call.create({ - data: { - businessId: business.id, - twilioCallSid: `SIMCALL_${createSimulatorPublicId()}`, - fromPhone: callerPhone, - fromPhoneNormalized: callerPhone, - toPhone: business.twilioPrimaryPhoneNumber || business.twilioPhoneNumber || '+10000000000', - toPhoneNormalized: business.twilioPrimaryPhoneNumber || business.twilioPhoneNumber || '+10000000000', - status: 'SIMULATED_MISSED', - missed: true, - answered: false, - dialCallStatus: 'no-answer', - isSimulator: true, - rawPayload: { source: 'public_simulator' }, - }, - }); - - const { lead } = await startMissedCallRecovery({ - business, - callerPhone, - callId: call.id, - isSimulator: true, - transport, - forceAutomation: true, - }); - - const publicId = createSimulatorPublicId(); - await db.simulatorRun.create({ - data: { - publicId, - businessId: business.id, - leadId: lead.id, - callerPhone, - status: 'ACTIVE', - }, - }); - - redirect(getSimulatorRedirect(publicId, undefined, realSmsEnabled ? 'sms-sent' : 'preview-started', notice)); -} - -export async function replyToSimulatorRunAction(formData: FormData) { - const publicId = typeof formData.get('publicId') === 'string' ? String(formData.get('publicId')) : ''; - const body = typeof formData.get('body') === 'string' ? String(formData.get('body')).trim() : ''; - if (!publicId) { - redirect(getSimulatorRedirect(null, 'Simulator run not found.')); - } - if (!body) { - redirect(getSimulatorRedirect(publicId, 'Enter a reply to continue the intake flow.')); - } - - const run = await getSimulatorRun(publicId); - if (!run) { - redirect(getSimulatorRedirect(null, 'Simulator run not found.')); - } - - await processLeadInboundReply({ - business: { - ...run.business, - ownerClerkId: 'simulator', - notifyPhone: null, - subscriptionStatus: SubscriptionStatus.ACTIVE, - provisioningStatus: 'LIVE', - archivedAt: null, - }, - leadId: run.leadId, - body, - fromPhone: run.callerPhone, - toPhone: run.business.twilioPrimaryPhoneNumber || run.business.twilioPhoneNumber || '+10000000000', - transport: 'simulated', - }); - - redirect(getSimulatorRedirect(publicId, undefined, 'reply-saved')); -} diff --git a/app/simulator/page.tsx b/app/simulator/page.tsx index 0e788e7..5ba3b39 100644 --- a/app/simulator/page.tsx +++ b/app/simulator/page.tsx @@ -1,279 +1,21 @@ import type { Metadata } from 'next'; -import Link from 'next/link'; -import { MessageDirection, OwnerNotificationChannel } from '@prisma/client'; -import { replyToSimulatorRunAction, startSimulatorRunAction } from '@/app/simulator/actions'; +import { PublicSimulatorExperience } from '@/components/demo/public-simulator-experience'; import { PublicSiteFooter } from '@/components/public-site-footer'; import { PublicSiteNav } from '@/components/public-site-nav'; -import { Badge } from '@/components/ui/badge'; -import { Button, buttonVariants } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { formatDateTime, getLeadStatusBadgeVariant, leadReadinessLabels, leadStatusLabels } from '@/lib/lead-presenters'; -import { PUBLIC_CREATE_ACCOUNT_PATH, PUBLIC_START_FREE_PILOT_PATH } from '@/lib/public-auth-routing'; -import { canSendRealSimulatorSms, getSimulatorBusiness, getSimulatorRun, isPlaceholderSimulatorNumber, isPublicSimulatorEnabled } from '@/lib/simulator'; export const metadata: Metadata = { title: 'Missed-Call Simulator | CallbackCloser', - description: 'Run the public CallbackCloser missed-call simulator and see the full recovery loop from missed call to qualified owner alert.', + description: 'Interactive preview mode for the public CallbackCloser missed-call simulator with a safe, on-page lead recovery demo.', }; -export const dynamic = 'force-dynamic'; - -function timelineStep(label: string, complete: boolean, detail: string) { - return { label, complete, detail }; -} - -export default async function SimulatorPage({ - searchParams, -}: { - searchParams?: Record; -}) { - const runPublicId = typeof searchParams?.run === 'string' ? searchParams.run : null; - const error = typeof searchParams?.error === 'string' ? searchParams.error : null; - const notice = typeof searchParams?.notice === 'string' ? searchParams.notice : null; - const status = typeof searchParams?.status === 'string' ? searchParams.status : null; - const enabled = isPublicSimulatorEnabled(); - const business = await getSimulatorBusiness(); - const run = runPublicId ? await getSimulatorRun(runPublicId) : null; - - const lead = run?.lead ?? null; - const latestOwnerSms = lead?.ownerNotifications.find((notification) => notification.channel === OwnerNotificationChannel.SMS) ?? null; - const latestOwnerEmail = lead?.ownerNotifications.find((notification) => notification.channel === OwnerNotificationChannel.EMAIL) ?? null; - const inAppNotification = lead?.ownerNotifications.find((notification) => notification.channel === OwnerNotificationChannel.IN_APP) ?? null; - const transcript = lead?.messages ?? []; - const demoNumber = business?.twilioPrimaryPhoneNumber || business?.twilioPhoneNumber || null; - const realSmsEnabled = canSendRealSimulatorSms(business); - const usingPlaceholderTextingLine = isPlaceholderSimulatorNumber(demoNumber); - - const timeline = lead - ? [ - timelineStep('Missed call detected', Boolean(lead.call || lead.createdAt), 'CallbackCloser records the missed call and opens a lead.'), - timelineStep( - 'Recovery text sent', - transcript.some((message) => message.direction === MessageDirection.OUTBOUND), - 'The caller gets the first SMS immediately so the lead does not go cold.' - ), - timelineStep( - 'Caller replied', - transcript.some((message) => message.direction === MessageDirection.INBOUND), - 'The intake thread captures service details directly from the caller.' - ), - timelineStep( - 'Lead qualified', - Boolean(lead.qualifiedAt), - 'Service type plus urgency or callback intent is enough to mark the lead ready.' - ), - timelineStep( - 'Owner notified', - Boolean(lead.notifiedAt || latestOwnerSms || latestOwnerEmail || inAppNotification), - 'Owner delivery fans out to SMS, email, and in-app preview without repeating alerts.' - ), - timelineStep( - 'Dashboard updated', - Boolean(lead.status === 'NOTIFIED' || inAppNotification), - 'The qualified lead appears with structured details and a ready-to-call summary.' - ), - ] - : []; - +export default function SimulatorPage() { return (
-
-
-
- Missed-call simulator -
-

See the full CallbackCloser lead loop in minutes

-

- Trigger a missed call, watch the recovery text go out, complete the intake, and see how the owner gets a qualified lead. -

-
-
- {demoNumber ? ( - - Call demo number - - ) : ( - - )} - - Start Free Pilot - -
-

- Use your own phone number to start a private simulator run, or call the demo line if one is configured for this environment. -

-
-

{realSmsEnabled ? 'Real SMS mode is active' : 'Preview mode is active'}

-

- {realSmsEnabled - ? 'CallbackCloser will text the phone number you enter from the demo business texting line, then continue the rest of the flow in this page.' - : usingPlaceholderTextingLine - ? 'This demo workspace is still using the safe placeholder texting line, so CallbackCloser will show the recovery text and owner alerts on this page instead of sending a real SMS.' - : 'Real SMS mode is not enabled for this environment yet, so CallbackCloser will show the recovery text and owner alerts on this page instead of sending a real SMS.'} -

-
-
- - - - Start the simulator - Enter a phone number and CallbackCloser will open a dedicated demo lead for this run. - - - {!enabled || !business ? ( -
- The public simulator is not configured on this environment yet. -
- ) : ( -
-
- - -
- -
- )} - {notice ? ( -
- {notice} -
- ) : null} - {error ?
{error}
: null} -
-
-
- - {run && lead ? ( -
- - - Live simulator timeline - Each step below mirrors the actual missed-call recovery flow. - - - {timeline.map((step, index) => ( -
-
{index + 1}
-
-
-

{step.label}

- {step.complete ? 'Complete' : 'Pending'} -
-

{step.detail}

-
-
- ))} -
-
- - - - Simulated owner alerts - SMS, email, and dashboard previews all update off the same qualified lead. - - -
-

SMS preview

-

{latestOwnerSms?.body || 'Owner SMS alert will appear after the lead qualifies.'}

-
-
-

Email preview

-

{latestOwnerEmail?.subject || 'Owner email subject will appear once qualification is complete.'}

-

{latestOwnerEmail?.body || 'Email body preview will populate after qualification.'}

-
-
-
-

Dashboard lead card preview

- {leadStatusLabels[lead.status]} - - {leadReadinessLabels[lead.readiness]} - -
-

{lead.summary || 'Lead summary will appear as soon as enough detail is collected.'}

-

- Qualified at: {formatDateTime(lead.qualifiedAt)} ยท Owner notified: {formatDateTime(lead.notifiedAt)} -

-
-
-
- - - - Continue the caller intake - Reply as the caller to move the simulator through qualification and owner delivery. - - -
- {transcript.length === 0 ? ( -

No simulator messages yet.

- ) : ( - transcript.map((message) => ( -
-
- {message.direction === MessageDirection.OUTBOUND ? 'CallbackCloser' : 'Caller reply'} - {formatDateTime(message.createdAt)} -
-

{message.body}

-
- )) - )} -
- -
-
-

Suggested reply prompts

-
-

- Water heater repair

-

- Today

-

- 78704

-

- Afternoon

-

- Pat Morgan

-
-
- - {lead.smsState !== 'COMPLETED' ? ( -
- -
- - -
- -
- ) : ( -
- Intake complete. The lead is qualified, the owner previews are populated, and the dashboard card is ready. -
- )} - -
- - Create account - - - Start Free Pilot - - - View pricing - -
-
-
-
-
- ) : null} +
+
diff --git a/components/demo/public-simulator-experience.tsx b/components/demo/public-simulator-experience.tsx new file mode 100644 index 0000000..f4d4550 --- /dev/null +++ b/components/demo/public-simulator-experience.tsx @@ -0,0 +1,405 @@ +'use client'; + +import Link from 'next/link'; +import { startTransition, useEffect, useState } from 'react'; + +import { Badge } from '@/components/ui/badge'; +import { Button, buttonVariants } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + PUBLIC_SIMULATOR_BUSINESS_NAME, + PUBLIC_SIMULATOR_DEMO_PHONE, + advancePublicSimulatorSession, + applyPublicSimulatorReply, + buildPublicSimulatorLeadSummary, + buildPublicSimulatorOwnerAlert, + canReplyToPublicSimulator, + getPublicSimulatorQuickReplies, + publicSimulatorStages, + startPublicSimulatorSession, + type PublicSimulatorSession, +} from '@/lib/public-simulator'; +import { PUBLIC_CREATE_ACCOUNT_PATH, PUBLIC_START_FREE_PILOT_PATH } from '@/lib/public-auth-routing'; +import { cn } from '@/lib/utils'; + +const timelineCopy: Record<(typeof publicSimulatorStages)[number], { detail: string; label: string }> = { + started: { + detail: 'A private demo run is created for this browser session only.', + label: 'Run started', + }, + missed_call_received: { + detail: 'CallbackCloser detects the missed call and opens a safe preview lead.', + label: 'Missed call received', + }, + first_message_shown: { + detail: 'The recovery text appears on-page instead of sending a real SMS.', + label: 'First message shown', + }, + service_captured: { + detail: 'The visitor reply has been captured and categorized by service intent.', + label: 'Service captured', + }, + urgency_captured: { + detail: 'The callback priority is now clear enough to qualify the lead.', + label: 'Urgency captured', + }, + owner_alert_ready: { + detail: 'The business handoff summary and owner alert are ready to review.', + label: 'Owner alert ready', + }, + completed: { + detail: 'The demo run is complete and ready for the sales CTA.', + label: 'Completed', + }, +}; + +function getStageStatus(stage: (typeof publicSimulatorStages)[number], currentStage: (typeof publicSimulatorStages)[number]) { + const stageIndex = publicSimulatorStages.indexOf(stage); + const currentIndex = publicSimulatorStages.indexOf(currentStage); + + if (stageIndex < currentIndex) return 'complete'; + if (stageIndex === currentIndex) return 'current'; + return 'pending'; +} + +function getReplyPlaceholder(session: PublicSimulatorSession | null) { + if (!session) return 'Type a caller reply'; + if (session.stage === 'first_message_shown') return 'Example: AC repair, my unit is not cooling'; + if (session.stage === 'service_captured') return 'Example: Today'; + return 'Type a caller reply'; +} + +export function PublicSimulatorExperience() { + const [phoneInput, setPhoneInput] = useState(PUBLIC_SIMULATOR_DEMO_PHONE); + const [replyInput, setReplyInput] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [session, setSession] = useState(null); + + useEffect(() => { + if (!session) return; + if (session.stage !== 'started' && session.stage !== 'missed_call_received' && session.stage !== 'urgency_captured' && session.stage !== 'owner_alert_ready') { + return; + } + + const timeout = window.setTimeout(() => { + startTransition(() => { + setSession((currentSession) => (currentSession ? advancePublicSimulatorSession(currentSession) : currentSession)); + }); + }, 700); + + return () => window.clearTimeout(timeout); + }, [session]); + + const leadSummary = session ? buildPublicSimulatorLeadSummary(session) : null; + const ownerAlert = session ? buildPublicSimulatorOwnerAlert(session) : null; + const quickReplies = session ? getPublicSimulatorQuickReplies(session.stage) : []; + const canReply = session ? canReplyToPublicSimulator(session.stage) : false; + + function handleStart() { + const nextSession = startPublicSimulatorSession(phoneInput); + if (!nextSession) { + setErrorMessage('Enter a phone number or use the demo phone number to start the preview.'); + return; + } + + setErrorMessage(''); + setReplyInput(''); + startTransition(() => { + setSession(nextSession); + }); + } + + function handleReply(nextReply: string) { + if (!session || !canReply) return; + + const trimmedReply = nextReply.trim(); + if (!trimmedReply) { + setErrorMessage('Enter a reply or use one of the quick replies to continue the demo.'); + return; + } + + setErrorMessage(''); + setReplyInput(''); + startTransition(() => { + setSession((currentSession) => (currentSession ? applyPublicSimulatorReply(currentSession, trimmedReply) : currentSession)); + }); + } + + function handleReset() { + setErrorMessage(''); + setReplyInput(''); + setSession(null); + setPhoneInput(PUBLIC_SIMULATOR_DEMO_PHONE); + } + + return ( +
+
+
+ Interactive preview mode +
+

See how CallbackCloser would recover a missed call

+

+ Start a private demo run, watch the missed-call text flow appear instantly, and see exactly what the business would get back. +

+
+ +
+
+

Safety

+

No real SMS will be sent in this demo

+
+
+

Experience

+

Private demo run for each visitor

+
+
+

Outcome

+

Lead summary plus owner alert preview

+
+
+ +
+

Interactive preview mode

+

+ This public simulator stays on-page only. No Twilio calls, no real customer leads, and no login are required to walk through the missed-call recovery flow. +

+
+
+ + + + Start the missed-call simulator + Enter a phone number or use the demo phone number to create a private preview run. + + +
+ + setPhoneInput(event.target.value)} + placeholder="+1 555 123 4567" + type="tel" + value={phoneInput} + /> +
+ +
+ + + {session ? ( + + ) : null} +
+ +
+ CallbackCloser will simulate a missed call for {PUBLIC_SIMULATOR_BUSINESS_NAME} and keep the full demo inside this page. +
+ + {errorMessage ?
{errorMessage}
: null} +
+
+
+ +
+ + + Simulator state machine + Every state below is deterministic and safe for public traffic. + + + {publicSimulatorStages.map((stage, index) => { + const status = session ? getStageStatus(stage, session.stage) : index === 0 ? 'current' : 'pending'; + + return ( +
+
+ {index + 1} +
+
+
+

{timelineCopy[stage].label}

+ + {status === 'complete' ? 'Complete' : status === 'current' ? 'Current' : 'Pending'} + +
+

{timelineCopy[stage].detail}

+
+
+ ); + })} +
+
+ + + +
+
+
+ Realistic conversation transcript + {session?.completed ? 'Complete' : 'Live preview'} +
+ The demo thread updates on-page as if CallbackCloser were handling the missed call live. +
+ {session ? ( +
+

{session.callerPhoneMasked}

+

Masked caller view for public demo safety

+
+ ) : null} +
+
+ +
+ {session ? ( + session.transcript.map((message) => ( +
+
+ {message.label} + {message.timestamp} +
+

{message.body}

+
+ )) + ) : ( +
+ Start a private demo run to reveal the missed call event, recovery text, and owner handoff. +
+ )} + +
+
+
+

Reply as the caller

+

Type a reply or use the quick replies to keep the demo moving.

+
+ {canReply ? 'Input enabled' : 'Waiting'} +
+ +
+ {quickReplies.map((reply) => ( + + ))} +
+ +
+ setReplyInput(event.target.value)} + placeholder={getReplyPlaceholder(session)} + value={replyInput} + /> + +
+
+
+ +
+ + +
+ What the business sees + + {session?.stage === 'owner_alert_ready' || session?.stage === 'completed' ? 'Owner alert ready' : 'Waiting on qualification'} + +
+ Owner alert preview plus the lead summary card that would appear in CallbackCloser. +
+ +
+

Owner alert subject

+

{ownerAlert?.subject || `${PUBLIC_SIMULATOR_BUSINESS_NAME}: lead preview pending`}

+
+
+

Lead summary

+

{leadSummary?.issue || 'Waiting for the caller to explain what they need.'}

+
+
+

Service

+

{leadSummary?.service || 'Pending'}

+
+
+

Urgency

+

{leadSummary?.urgency || 'Pending'}

+
+
+

Caller

+

{leadSummary?.callerPhone || 'Private demo caller'}

+
+
+

Next step

+

{leadSummary?.callbackWindow || 'Pending'}

+
+
+
+
+

Owner alert body

+

{ownerAlert?.body || 'CallbackCloser will prepare the owner alert after service and urgency are captured.'}

+

{ownerAlert?.note || 'Demo-only preview. No real customer, owner, or Twilio traffic is involved.'}

+
+
+
+ +
+

What this proves in a sales conversation

+
+

- The caller gets a fast response even when the business misses the call.

+

- The business gets the service type and urgency before the callback.

+

- The public demo stays safe because everything is simulated inside the browser.

+
+
+
+
+
+
+ +
+
+
+ Final CTA +

Show this flow on your own number next

+

+ The interactive preview shows how CallbackCloser recovers a missed call, qualifies the lead, and prepares the owner follow-up. The next step is getting that flow live for your business. +

+
+
+ + Start Free Pilot + + + Create Account + + + Contact support + +
+
+
+
+ ); +} diff --git a/lib/public-simulator.ts b/lib/public-simulator.ts new file mode 100644 index 0000000..1cd6a12 --- /dev/null +++ b/lib/public-simulator.ts @@ -0,0 +1,378 @@ +import { formatPhoneForDisplay, normalizePhoneNumberToE164 } from '@/lib/phone'; + +export const publicSimulatorStages = [ + 'started', + 'missed_call_received', + 'first_message_shown', + 'service_captured', + 'urgency_captured', + 'owner_alert_ready', + 'completed', +] as const; + +export type PublicSimulatorStage = (typeof publicSimulatorStages)[number]; + +export type PublicSimulatorQuickReply = + | 'Repair' + | 'Install' + | 'Maintenance' + | 'Emergency' + | 'Today' + | 'This week' + | 'Just getting a quote'; + +export type PublicSimulatorService = 'Repair' | 'Install' | 'Maintenance' | 'Emergency' | 'General service'; + +export type PublicSimulatorUrgency = 'Emergency' | 'Today' | 'This week' | 'Just getting a quote'; + +export type PublicSimulatorMessage = { + id: string; + body: string; + label: string; + kind: 'event' | 'assistant' | 'caller'; + timestamp: string; +}; + +export type PublicSimulatorSession = { + callerPhone: string; + callerPhoneDisplay: string; + callerPhoneMasked: string; + completed: boolean; + issueSummary: string | null; + selectedService: PublicSimulatorService | null; + selectedUrgency: PublicSimulatorUrgency | null; + stage: PublicSimulatorStage; + transcript: PublicSimulatorMessage[]; +}; + +export const PUBLIC_SIMULATOR_DEMO_PHONE = '+1 (865) 555-0148'; +export const PUBLIC_SIMULATOR_BUSINESS_NAME = 'Northside Home Services'; + +const SERVICE_REPLY_OPTIONS: PublicSimulatorQuickReply[] = ['Repair', 'Install', 'Maintenance', 'Emergency']; +const URGENCY_REPLY_OPTIONS: PublicSimulatorQuickReply[] = ['Today', 'This week', 'Just getting a quote']; +const TIMESTAMP_SEQUENCE = ['2:14 PM', '2:14 PM', '2:15 PM', '2:15 PM', '2:16 PM', '2:16 PM', '2:17 PM', '2:17 PM']; + +function nextTimestamp(messageCount: number) { + return TIMESTAMP_SEQUENCE[Math.min(messageCount, TIMESTAMP_SEQUENCE.length - 1)]; +} + +function appendMessage( + session: PublicSimulatorSession, + message: Omit, +): PublicSimulatorSession { + const nextMessage: PublicSimulatorMessage = { + ...message, + id: `sim-message-${session.transcript.length + 1}`, + timestamp: nextTimestamp(session.transcript.length), + }; + + return { + ...session, + transcript: [...session.transcript, nextMessage], + }; +} + +function normalizeWhitespace(value: string) { + return value.replace(/\s+/g, ' ').trim(); +} + +function toSentenceCase(value: string) { + const trimmed = normalizeWhitespace(value); + if (!trimmed) return ''; + return trimmed.charAt(0).toUpperCase() + trimmed.slice(1); +} + +function formatCustomReply(value: string) { + const trimmed = toSentenceCase(value.replace(/[.!?]+$/, '')); + return trimmed || 'Customer asked for help'; +} + +export function maskPublicSimulatorPhone(value: string | null | undefined) { + const digits = (value || '').replace(/\D/g, ''); + if (digits.length >= 10) { + const lastFour = digits.slice(-4); + return `(***) ***-${lastFour}`; + } + + if (digits.length >= 4) { + return `***-${digits.slice(-4)}`; + } + + return 'Private demo caller'; +} + +export function getPublicSimulatorQuickReplies(stage: PublicSimulatorStage) { + if (stage === 'first_message_shown') return SERVICE_REPLY_OPTIONS; + if (stage === 'service_captured') return URGENCY_REPLY_OPTIONS; + return []; +} + +export function canReplyToPublicSimulator(stage: PublicSimulatorStage) { + return stage === 'first_message_shown' || stage === 'service_captured'; +} + +export function startPublicSimulatorSession(phoneInput: string): PublicSimulatorSession | null { + const normalized = normalizePhoneNumberToE164(phoneInput) || normalizeWhitespace(phoneInput); + if (!normalized) return null; + + const display = formatPhoneForDisplay(normalized); + const masked = maskPublicSimulatorPhone(normalized); + + return { + callerPhone: normalized, + callerPhoneDisplay: display, + callerPhoneMasked: masked, + completed: false, + issueSummary: null, + selectedService: null, + selectedUrgency: null, + stage: 'started', + transcript: [ + { + id: 'sim-message-1', + body: `Private demo run started for ${masked}. No real SMS will be sent.`, + kind: 'event', + label: 'Interactive preview mode', + timestamp: nextTimestamp(0), + }, + ], + }; +} + +export function advancePublicSimulatorSession(session: PublicSimulatorSession): PublicSimulatorSession { + if (session.stage === 'started') { + return appendMessage( + { + ...session, + stage: 'missed_call_received', + }, + { + body: `${PUBLIC_SIMULATOR_BUSINESS_NAME} missed a call from ${session.callerPhoneMasked}. CallbackCloser opened a private demo run for this visitor only.`, + kind: 'event', + label: 'Missed call received', + }, + ); + } + + if (session.stage === 'missed_call_received') { + return appendMessage( + { + ...session, + stage: 'first_message_shown', + }, + { + body: 'Sorry we missed your call. What do you need help with today: repair, install, maintenance, or emergency service?', + kind: 'assistant', + label: 'CallbackCloser', + }, + ); + } + + if (session.stage === 'urgency_captured') { + return appendMessage( + { + ...session, + stage: 'owner_alert_ready', + }, + { + body: 'Qualified lead summary ready. The business would now get the alert, full transcript, and callback context without any real delivery leaving this page.', + kind: 'event', + label: 'Owner alert ready', + }, + ); + } + + if (session.stage === 'owner_alert_ready') { + return { + ...session, + completed: true, + stage: 'completed', + }; + } + + return session; +} + +function inferService(value: string): PublicSimulatorService { + const normalized = normalizeWhitespace(value).toLowerCase(); + + if ( + normalized.includes('emergency') || + normalized.includes('urgent') || + normalized.includes('asap') || + normalized.includes('no heat') || + normalized.includes('no cool') || + normalized.includes('not working') || + normalized.includes('stopped working') + ) { + return 'Emergency'; + } + + if ( + normalized.includes('install') || + normalized.includes('replace') || + normalized.includes('replacement') || + normalized.includes('new unit') + ) { + return 'Install'; + } + + if ( + normalized.includes('maintenance') || + normalized.includes('tune') || + normalized.includes('service plan') || + normalized.includes('checkup') + ) { + return 'Maintenance'; + } + + if ( + normalized.includes('repair') || + normalized.includes('fix') || + normalized.includes('leak') || + normalized.includes('diagnostic') || + normalized.includes('diagnosis') + ) { + return 'Repair'; + } + + return 'General service'; +} + +function inferUrgency(value: string): PublicSimulatorUrgency { + const normalized = normalizeWhitespace(value).toLowerCase(); + + if ( + normalized.includes('emergency') || + normalized.includes('urgent') || + normalized.includes('asap') || + normalized.includes('now') || + normalized.includes('immediately') + ) { + return 'Emergency'; + } + + if (normalized.includes('today') || normalized.includes('this afternoon') || normalized.includes('tonight')) { + return 'Today'; + } + + if ( + normalized.includes('quote') || + normalized.includes('estimate') || + normalized.includes('pricing') || + normalized.includes('price') + ) { + return 'Just getting a quote'; + } + + if ( + normalized.includes('week') || + normalized.includes('later') || + normalized.includes('next') || + normalized.includes('schedule') + ) { + return 'This week'; + } + + return 'Today'; +} + +function summarizeIssue(service: PublicSimulatorService, rawReply: string) { + const normalized = normalizeWhitespace(rawReply); + if (!normalized) return 'Customer asked for service help'; + + if (SERVICE_REPLY_OPTIONS.includes(normalized as PublicSimulatorQuickReply)) { + if (service === 'Emergency') return 'Emergency service request'; + if (service === 'Install') return 'New install request'; + if (service === 'Maintenance') return 'Maintenance visit request'; + if (service === 'Repair') return 'Repair request'; + } + + return formatCustomReply(normalized); +} + +export function applyPublicSimulatorReply(session: PublicSimulatorSession, reply: string): PublicSimulatorSession { + const trimmedReply = normalizeWhitespace(reply); + if (!trimmedReply || !canReplyToPublicSimulator(session.stage)) { + return session; + } + + if (session.stage === 'first_message_shown') { + const selectedService = inferService(trimmedReply); + const issueSummary = summarizeIssue(selectedService, trimmedReply); + + const withReply = appendMessage(session, { + body: trimmedReply, + kind: 'caller', + label: session.callerPhoneMasked, + }); + + return appendMessage( + { + ...withReply, + issueSummary, + selectedService, + stage: 'service_captured', + }, + { + body: 'Thanks. How urgent is this: today, this week, or are you just getting a quote?', + kind: 'assistant', + label: 'CallbackCloser', + }, + ); + } + + const selectedUrgency = inferUrgency(trimmedReply); + const withReply = appendMessage(session, { + body: trimmedReply, + kind: 'caller', + label: session.callerPhoneMasked, + }); + + return appendMessage( + { + ...withReply, + selectedUrgency, + stage: 'urgency_captured', + }, + { + body: 'Perfect. CallbackCloser has enough detail to build the lead summary and owner alert preview.', + kind: 'assistant', + label: 'CallbackCloser', + }, + ); +} + +export function buildPublicSimulatorLeadSummary(session: PublicSimulatorSession) { + const service = session.selectedService || 'Pending'; + const urgency = session.selectedUrgency || 'Pending'; + const issue = session.issueSummary || 'Waiting for caller details'; + + return { + callbackWindow: + urgency === 'Emergency' + ? 'Call immediately' + : urgency === 'Today' + ? 'Call back today' + : urgency === 'This week' + ? 'Schedule this week' + : 'Follow up with pricing details', + callerPhone: session.callerPhoneMasked, + issue, + service, + status: session.stage === 'completed' ? 'Ready for callback' : 'Qualifying lead', + urgency, + }; +} + +export function buildPublicSimulatorOwnerAlert(session: PublicSimulatorSession) { + const leadSummary = buildPublicSimulatorLeadSummary(session); + + return { + body: `New missed-call lead: ${leadSummary.service} request from ${leadSummary.callerPhone}. ${leadSummary.issue}. Urgency: ${leadSummary.urgency}. ${leadSummary.callbackWindow}. Demo only - no real SMS sent.`, + headline: 'New missed-call lead', + leadSummary, + note: 'This owner alert is rendered on-page only for the public simulator.', + subject: `${PUBLIC_SIMULATOR_BUSINESS_NAME}: ${leadSummary.service} lead ready`, + }; +} diff --git a/tests/public-demo-route.test.ts b/tests/public-demo-route.test.ts index 4bd13f6..9fed40c 100644 --- a/tests/public-demo-route.test.ts +++ b/tests/public-demo-route.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; import test from 'node:test'; @@ -10,7 +10,7 @@ function read(relativePath: string) { test('public demo route is auth-free and built from isolated demo data', () => { const demoPage = read('app/demo/page.tsx'); const simulatorPage = read('app/simulator/page.tsx'); - const simulatorActions = read('app/simulator/actions.ts'); + const simulatorExperience = read('components/demo/public-simulator-experience.tsx'); const demoData = read('lib/demo-data.ts'); const middleware = read('middleware.ts'); @@ -22,10 +22,11 @@ test('public demo route is auth-free and built from isolated demo data', () => { assert.match(demoData, /No login, no real customer data, no live Twilio traffic/i); assert.match(demoPage, /from ['"]@\/lib\/demo-data['"]/); assert.doesNotMatch(demoPage, /requireBusiness|getBusinessForOwnerClerkId|db\./); - assert.match(simulatorPage, /PUBLIC_START_FREE_PILOT_PATH/); - assert.match(simulatorPage, /PUBLIC_CREATE_ACCOUNT_PATH/); - assert.doesNotMatch(simulatorPage, /ENABLE_PUBLIC_SIMULATOR_REAL_SMS|SIMULATOR_BUSINESS_ID/); - assert.doesNotMatch(simulatorActions, /ENABLE_PUBLIC_SIMULATOR_REAL_SMS/); + assert.match(simulatorExperience, /PUBLIC_START_FREE_PILOT_PATH/); + assert.match(simulatorExperience, /PUBLIC_CREATE_ACCOUNT_PATH/); + assert.doesNotMatch(simulatorPage, /ENABLE_PUBLIC_SIMULATOR_REAL_SMS|SIMULATOR_BUSINESS_ID|db\./); + assert.doesNotMatch(simulatorExperience, /ENABLE_PUBLIC_SIMULATOR_REAL_SMS|SIMULATOR_BUSINESS_ID|db\.|startMissedCallRecovery/); + assert.equal(existsSync(path.join(process.cwd(), 'app/simulator/actions.ts')), false); assert.doesNotMatch(middleware, /\/demo\(.\*\)/); assert.match(demoData, /Jamie Carter/); assert.match(demoData, /New HVAC lead/); diff --git a/tests/public-simulator-flow.test.ts b/tests/public-simulator-flow.test.ts new file mode 100644 index 0000000..67362fa --- /dev/null +++ b/tests/public-simulator-flow.test.ts @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + advancePublicSimulatorSession, + applyPublicSimulatorReply, + buildPublicSimulatorOwnerAlert, + canReplyToPublicSimulator, + getPublicSimulatorQuickReplies, + maskPublicSimulatorPhone, + startPublicSimulatorSession, +} from '@/lib/public-simulator'; + +test('public simulator starts with a masked private run and auto-advances into the first text', () => { + const startedSession = startPublicSimulatorSession('+1 (865) 555-0148'); + assert.ok(startedSession); + assert.equal(startedSession.stage, 'started'); + assert.equal(startedSession.callerPhoneMasked, '(***) ***-0148'); + assert.match(startedSession.transcript[0]?.body ?? '', /No real SMS will be sent/); + + const missedCallSession = advancePublicSimulatorSession(startedSession); + assert.equal(missedCallSession.stage, 'missed_call_received'); + assert.match(missedCallSession.transcript[1]?.body ?? '', /missed a call/); + + const firstMessageSession = advancePublicSimulatorSession(missedCallSession); + assert.equal(firstMessageSession.stage, 'first_message_shown'); + assert.match(firstMessageSession.transcript[2]?.body ?? '', /What do you need help with today/); + assert.deepEqual(getPublicSimulatorQuickReplies(firstMessageSession.stage), ['Repair', 'Install', 'Maintenance', 'Emergency']); + assert.equal(canReplyToPublicSimulator(firstMessageSession.stage), true); +}); + +test('public simulator captures service and urgency, then prepares the owner alert preview', () => { + const startedSession = startPublicSimulatorSession('+1 (865) 555-0199'); + assert.ok(startedSession); + + const firstMessageSession = advancePublicSimulatorSession(advancePublicSimulatorSession(startedSession)); + const serviceCapturedSession = applyPublicSimulatorReply(firstMessageSession, 'AC repair, the unit is not cooling'); + assert.equal(serviceCapturedSession.stage, 'service_captured'); + assert.equal(serviceCapturedSession.selectedService, 'Repair'); + assert.match(serviceCapturedSession.issueSummary ?? '', /AC repair, the unit is not cooling/i); + assert.deepEqual(getPublicSimulatorQuickReplies(serviceCapturedSession.stage), ['Today', 'This week', 'Just getting a quote']); + + const urgencyCapturedSession = applyPublicSimulatorReply(serviceCapturedSession, 'Today'); + assert.equal(urgencyCapturedSession.stage, 'urgency_captured'); + assert.equal(urgencyCapturedSession.selectedUrgency, 'Today'); + + const ownerAlertReadySession = advancePublicSimulatorSession(urgencyCapturedSession); + assert.equal(ownerAlertReadySession.stage, 'owner_alert_ready'); + + const completedSession = advancePublicSimulatorSession(ownerAlertReadySession); + assert.equal(completedSession.stage, 'completed'); + assert.equal(completedSession.completed, true); + + const ownerAlert = buildPublicSimulatorOwnerAlert(completedSession); + assert.match(ownerAlert.subject, /Northside Home Services: Repair lead ready/); + assert.match(ownerAlert.body, /Demo only - no real SMS sent/); + assert.match(ownerAlert.body, /\(\*\*\*\) \*\*\*-0199/); +}); + +test('public simulator phone masking avoids showing the full caller number', () => { + assert.equal(maskPublicSimulatorPhone('+18655550148'), '(***) ***-0148'); + assert.equal(maskPublicSimulatorPhone('5550148'), '***-0148'); + assert.equal(maskPublicSimulatorPhone(''), 'Private demo caller'); +}); diff --git a/tests/simulator-isolation.test.ts b/tests/simulator-isolation.test.ts index 4368a13..4102a5b 100644 --- a/tests/simulator-isolation.test.ts +++ b/tests/simulator-isolation.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; import test from 'node:test'; @@ -7,21 +7,20 @@ function read(relativePath: string) { return readFileSync(path.join(process.cwd(), relativePath), 'utf8'); } -test('public simulator is gated and isolated from real customer delivery', () => { +test('public simulator is isolated from real customer delivery and runs entirely in demo logic', () => { const simulatorPage = read('app/simulator/page.tsx'); - const simulatorActions = read('app/simulator/actions.ts'); - const simulatorLib = read('lib/simulator.ts'); + const simulatorExperience = read('components/demo/public-simulator-experience.tsx'); + const publicSimulatorLib = read('lib/public-simulator.ts'); const ownerNotifications = read('lib/owner-notifications.ts'); const schema = read('prisma/schema.prisma'); - assert.match(simulatorPage, /Missed-call simulator/); - assert.match(simulatorActions, /isPublicSimulatorEnabled/); - assert.match(simulatorLib, /SIMULATOR_BUSINESS_ID/); - assert.match(simulatorLib, /ENABLE_PUBLIC_MISSED_CALL_SIMULATOR/); - assert.match(simulatorLib, /ENABLE_PUBLIC_SIMULATOR_REAL_SMS/); - assert.match(simulatorLib, /canSendRealSimulatorSms/); - assert.match(simulatorActions, /isSimulator:\s*true/); - assert.match(simulatorActions, /Preview mode active\./); + assert.match(simulatorExperience, /Interactive preview mode/); + assert.match(publicSimulatorLib, /publicSimulatorStages/); + assert.match(publicSimulatorLib, /No real SMS will be sent\./); + assert.match(publicSimulatorLib, /maskPublicSimulatorPhone/); + assert.doesNotMatch(simulatorPage, /getSimulatorRun|startSimulatorRunAction|replyToSimulatorRunAction|db\./); + assert.doesNotMatch(simulatorExperience, /db\.|twilio|startMissedCallRecovery|processLeadInboundReply/); + assert.equal(existsSync(path.join(process.cwd(), 'app/simulator/actions.ts')), false); assert.match(ownerNotifications, /if \(lead\.isSimulator\)/); assert.match(schema, /isSimulator\s+Boolean\s+@default\(false\)/); assert.match(schema, /model SimulatorRun/); diff --git a/tests/simulator-route.test.ts b/tests/simulator-route.test.ts index 658eddc..cee94e2 100644 --- a/tests/simulator-route.test.ts +++ b/tests/simulator-route.test.ts @@ -15,14 +15,16 @@ test('homepage and nav both point See Demo directly to /demo', () => { assert.match(nav, /href: '\/demo'/); }); -test('simulator route is a distinct public page with its own metadata and disabled-state copy', () => { +test('simulator route is a distinct public page with a safe interactive preview', () => { const simulatorPage = read('app/simulator/page.tsx'); + const simulatorExperience = read('components/demo/public-simulator-experience.tsx'); const middleware = read('middleware.ts'); assert.match(simulatorPage, /Missed-Call Simulator \| CallbackCloser/); - assert.match(simulatorPage, /See the full CallbackCloser lead loop in minutes/); - assert.match(simulatorPage, /The public simulator is not configured on this environment yet\./); - assert.match(simulatorPage, /Preview mode is active/); - assert.match(simulatorPage, /Real SMS mode is active/); + assert.match(simulatorPage, /PublicSimulatorExperience/); + assert.match(simulatorExperience, /Interactive preview mode/); + assert.match(simulatorExperience, /No real SMS will be sent in this demo/); + assert.match(simulatorExperience, /See how CallbackCloser would recover a missed call/); + assert.doesNotMatch(simulatorExperience, /Demo number unavailable|not configured on this environment yet|Real SMS mode is active/); assert.doesNotMatch(middleware, /\/simulator\(.\*\)/); }); From 1a9d56629ed5399d0db2421c25dd1880f0d959fb Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Tue, 26 May 2026 14:37:56 -0400 Subject: [PATCH 2/2] Polish public simulator sales flow --- .env.example | 6 ++-- README.md | 25 +++++++++++----- app/demo/page.tsx | 30 ++++++++++--------- app/icon.svg | 6 ++++ .../demo/public-simulator-experience.tsx | 30 ++++++++++++------- components/public-site-nav.tsx | 1 + docs/PRODUCTION_ENV.md | 8 ++--- tests/favicon-route.test.ts | 8 +++++ tests/public-demo-route.test.ts | 4 ++- tests/simulator-route.test.ts | 2 ++ 10 files changed, 80 insertions(+), 40 deletions(-) create mode 100644 app/icon.svg create mode 100644 tests/favicon-route.test.ts diff --git a/.env.example b/.env.example index be8a744..d37e0eb 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,8 @@ DIRECT_DATABASE_URL= # Clerk # Required in production +# For local development, use Clerk test/dev keys that allow localhost redirects. +# Production-domain-restricted keys can trigger browser console origin errors on http://localhost:3000. NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= CLERK_SECRET_KEY= NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in @@ -46,8 +48,8 @@ FOUNDER_CLERK_USER_ID= RESEND_API_KEY= CALLBACKCLOSER_FROM_EMAIL= -# Optional public simulator -# Keep these disabled in production unless the public simulator is intentionally enabled. +# Optional legacy backend simulator tooling +# The current public /simulator page is self-contained and does not require these values. ENABLE_PUBLIC_MISSED_CALL_SIMULATOR= SIMULATOR_BUSINESS_ID= ENABLE_PUBLIC_SIMULATOR_REAL_SMS= diff --git a/README.md b/README.md index 5c6a557..1762a8f 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ When a customer calls a business's Twilio number and the forwarded call is misse - Twilio SMS webhook (`/api/twilio/sms`) with lead qualification steps - Qualified-lead delivery with idempotent SMS/email/in-app owner notifications - Lead dashboard + filters + lead detail transcript + status updates -- Public missed-call simulator (`/simulator`) with isolated demo lead runs +- Public missed-call simulator (`/simulator`) with a self-contained interactive preview flow - Stripe billing page + checkout + billing portal - Public purchase entry route (`/buy`) for external marketing-site links - Stripe webhook sync for subscription status gating @@ -74,10 +74,14 @@ Required categories: - Stripe keys + price IDs + webhook secret - Twilio credentials + webhook auth token - Optional owner email delivery (`RESEND_API_KEY`, `CALLBACKCLOSER_FROM_EMAIL`) -- Optional public simulator config (`ENABLE_PUBLIC_MISSED_CALL_SIMULATOR`, `SIMULATOR_BUSINESS_ID`, `ENABLE_PUBLIC_SIMULATOR_REAL_SMS`) - Database URL - Optional rate-limit tuning vars (defaults are built in) +Local note: + +- Use Clerk test/dev keys for localhost work. +- If you point `.env.local` at production-domain-restricted Clerk keys, public pages can still render, but Clerk will log browser origin errors on `http://localhost:3000`. + ### 4. Run Prisma migrations / generate client This repo includes a Prisma migration at `prisma/migrations/20260222000000_init/migration.sql`. @@ -150,6 +154,7 @@ Turn it off after smoke testing by unsetting `ALLOW_FOUNDER_BILLING_BYPASS` or s - `https://YOUR_DOMAIN/sign-in` - `https://YOUR_DOMAIN/sign-up` 4. Ensure your app origin(s) are allowed in Clerk. +5. For localhost development, prefer Clerk test/dev keys. Production keys that are locked to `callbackcloser.com` will reject browser requests from `http://localhost:3000`. ## Stripe Setup (Required) @@ -374,11 +379,15 @@ What it demonstrates: Simulator safety: -- The simulator is enabled only when `ENABLE_PUBLIC_MISSED_CALL_SIMULATOR=true` -- It uses the business identified by `SIMULATOR_BUSINESS_ID` -- Simulator records are isolated with `Lead.isSimulator`, `Call.isSimulator`, `Message.isSimulator`, and `SimulatorRun` -- Owner notifications for simulator runs are stored as preview records and do not send to real customer destinations -- If `ENABLE_PUBLIC_SIMULATOR_REAL_SMS=true`, the simulator can send the caller-side SMS to the supplied demo number, but owner delivery remains simulated +- The public `/simulator` page is self-contained and does not require Twilio, login, or backend simulator config +- No real SMS is sent and no Twilio calls are made +- Public visitor input stays inside the browser demo and does not create customer-facing records +- Caller numbers are masked in the UI before the owner-alert preview is shown + +Legacy internal simulator backend: + +- The repo still includes isolated simulator-record models and env flags for internal/admin demo tooling +- Those flags are not required for the current public `/simulator` experience Creating a dedicated simulator workspace: @@ -474,7 +483,7 @@ Use this checklist before sending paid traffic to `callbackcloser.com` or allowi - `/privacy` - privacy policy - `/refund` - refund policy - `/contact` - public support/contact page -- `/simulator` - public missed-call simulator +- `/simulator` - public interactive missed-call simulator - `/sign-in` - Clerk sign-in - `/sign-up` - Clerk sign-up - `/app/onboarding` - create business record diff --git a/app/demo/page.tsx b/app/demo/page.tsx index 2e692f6..be140fa 100644 --- a/app/demo/page.tsx +++ b/app/demo/page.tsx @@ -54,14 +54,16 @@ export default function DemoPage() {

- - Want this running on your number? + + Try the interactive simulator - See it in action + See the quick overview
-

Get this live on your number fast, without changing how your team already works.

+

+ This page is the fast visual walkthrough. The simulator lets you reply like the caller and see the full handoff yourself. +

Demo only: fake business, fake callers, and no live customer or Twilio data.

@@ -103,7 +105,7 @@ export default function DemoPage() {
- Live-looking product view + Quick overview

Here's what happens after a missed HVAC call

They call. You miss it. We text them right away, ask what they need, and send you the lead while they're still ready to book. @@ -220,11 +222,11 @@ export default function DemoPage() {

{demoOwnerAlert.summary}

- - Want this on your business? + + Try the interactive simulator - See it on your number + Start 14-Day Pilot
@@ -257,17 +259,17 @@ export default function DemoPage() {
Close with a demo, not a long explanation
-

Want this on your business?

+

Ready to try the missed-call flow yourself?

- We can set it up on your number and get you live fast. + Use the simulator for the hands-on experience, then start a pilot when you're ready to put it on your number.

- - See it on your number + + Try the interactive simulator - - Book a quick setup call + + Start 14-Day Pilot
diff --git a/app/icon.svg b/app/icon.svg new file mode 100644 index 0000000..2b03944 --- /dev/null +++ b/app/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/components/demo/public-simulator-experience.tsx b/components/demo/public-simulator-experience.tsx index f4d4550..c03541a 100644 --- a/components/demo/public-simulator-experience.tsx +++ b/components/demo/public-simulator-experience.tsx @@ -21,7 +21,7 @@ import { startPublicSimulatorSession, type PublicSimulatorSession, } from '@/lib/public-simulator'; -import { PUBLIC_CREATE_ACCOUNT_PATH, PUBLIC_START_FREE_PILOT_PATH } from '@/lib/public-auth-routing'; +import { PUBLIC_START_FREE_PILOT_PATH } from '@/lib/public-auth-routing'; import { cn } from '@/lib/utils'; const timelineCopy: Record<(typeof publicSimulatorStages)[number], { detail: string; label: string }> = { @@ -144,6 +144,13 @@ export function PublicSimulatorExperience() {

Start a private demo run, watch the missed-call text flow appear instantly, and see exactly what the business would get back.

+

+ Want the quick overview first?{' '} + + See the visual demo + + . This page is the hands-on version. +

@@ -382,21 +389,22 @@ export function PublicSimulatorExperience() {
Final CTA -

Show this flow on your own number next

+

Turn missed calls into booked jobs on your number next

- The interactive preview shows how CallbackCloser recovers a missed call, qualifies the lead, and prepares the owner follow-up. The next step is getting that flow live for your business. + The interactive preview shows how CallbackCloser recovers a missed call, qualifies the lead, and prepares the owner follow-up. The next step is starting a pilot for your service business.

-
+
- Start Free Pilot - - - Create Account - - - Contact support + Start 14-Day Pilot +

+ Need a quick walkthrough before you start?{' '} + + Contact support + + . +

diff --git a/components/public-site-nav.tsx b/components/public-site-nav.tsx index f82be4e..b32fa50 100644 --- a/components/public-site-nav.tsx +++ b/components/public-site-nav.tsx @@ -6,6 +6,7 @@ import { PUBLIC_CREATE_ACCOUNT_PATH, PUBLIC_SIGN_IN_PATH, PUBLIC_START_FREE_PILO const primaryLinks = [ { href: '/demo', label: 'Demo' }, + { href: '/simulator', label: 'Simulator' }, { href: '/pricing', label: 'Pricing' }, { href: '/contact', label: 'Contact' }, { href: '/sms-consent', label: 'SMS Consent' }, diff --git a/docs/PRODUCTION_ENV.md b/docs/PRODUCTION_ENV.md index 8e17fd9..7a8c74e 100644 --- a/docs/PRODUCTION_ENV.md +++ b/docs/PRODUCTION_ENV.md @@ -34,9 +34,9 @@ This project uses `NEXT_PUBLIC_APP_URL` as the single canonical app origin for s | `DEBUG_ENV_ENDPOINT_TOKEN` | Server-only | Optional | Vercel | Protects `/api/debug/env` in production. If unset, the endpoint returns `404` in production. | | `PORTFOLIO_DEMO_MODE` | Server-only | Optional | Local / Vercel | Enables demo data/auth bypass mode for portfolio/demo screenshots. Keep disabled in production unless intentionally using demo mode. | | `ALLOW_PRODUCTION_DEMO_MODE` | Server-only | Optional (break-glass only) | Vercel | Required only when intentionally running demo mode in production. If unset while `PORTFOLIO_DEMO_MODE` is enabled in production, startup is blocked. | -| `ENABLE_PUBLIC_MISSED_CALL_SIMULATOR` | Server-only | Optional | Vercel | Enables the public `/simulator` route. Keep disabled unless the simulator business is intentionally configured. | -| `SIMULATOR_BUSINESS_ID` | Server-only | Optional | Vercel | Business record used by the public simulator. Must point to an isolated demo workspace, never a real customer business. | -| `ENABLE_PUBLIC_SIMULATOR_REAL_SMS` | Server-only | Optional | Vercel | Allows the simulator to send real caller-side SMS from the simulator business number when a non-placeholder texting line exists. Keep off by default. | +| `ENABLE_PUBLIC_MISSED_CALL_SIMULATOR` | Server-only | Optional | Vercel | Legacy/internal simulator backend flag. The current public `/simulator` page does not require it. | +| `SIMULATOR_BUSINESS_ID` | Server-only | Optional | Vercel | Legacy/internal simulator backend business ID for isolated admin/demo tooling. Never point this at a real customer business. | +| `ENABLE_PUBLIC_SIMULATOR_REAL_SMS` | Server-only | Optional | Vercel | Legacy/internal simulator backend SMS flag. Keep off by default. The current public `/simulator` page does not send real SMS. | | `RATE_LIMIT_WINDOW_MS` | Server-only | Optional | Vercel | Shared rate-limit window in milliseconds. Default `60000`. | | `RATE_LIMIT_TWILIO_AUTH_MAX` | Server-only | Optional | Vercel | Max Twilio webhook requests per window for valid/authorized traffic. Default `240`. | | `RATE_LIMIT_TWILIO_UNAUTH_MAX` | Server-only | Optional | Vercel | Max Twilio webhook requests per window for unauthorized traffic. Default `40`. | @@ -68,7 +68,7 @@ The app now validates required server env vars at runtime in production via `lib - `NEXT_PUBLIC_APP_URL` is the canonical value and should be set explicitly. If it is missing/invalid, the app can temporarily fall back to Vercel system env vars (`VERCEL_URL` / `VERCEL_PROJECT_PRODUCTION_URL`) to avoid auth-page crashes, but webhook/redirect behavior should still use an explicit `NEXT_PUBLIC_APP_URL`. - `/admin` access depends on either `FOUNDER_CLERK_USER_ID` or `ADMIN_EMAIL_ALLOWLIST`; do not leave admin authorization implicit. - Owner email alerts are optional, but if you intend to advertise email delivery you must set both `RESEND_API_KEY` and `CALLBACKCLOSER_FROM_EMAIL`. -- The public simulator is optional and should only point at a dedicated demo workspace via `SIMULATOR_BUSINESS_ID`. +- The legacy simulator backend should only point at a dedicated demo workspace via `SIMULATOR_BUSINESS_ID`. ## Vercel: Preview vs Production diff --git a/tests/favicon-route.test.ts b/tests/favicon-route.test.ts new file mode 100644 index 0000000..de0047d --- /dev/null +++ b/tests/favicon-route.test.ts @@ -0,0 +1,8 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +test('app icon exists so public pages do not rely on a missing default favicon', () => { + assert.equal(existsSync(path.join(process.cwd(), 'app/icon.svg')), true); +}); diff --git a/tests/public-demo-route.test.ts b/tests/public-demo-route.test.ts index 9fed40c..e0cc82a 100644 --- a/tests/public-demo-route.test.ts +++ b/tests/public-demo-route.test.ts @@ -17,13 +17,15 @@ test('public demo route is auth-free and built from isolated demo data', () => { assert.match(demoPage, /Live Product Demo \| CallbackCloser/); assert.match(demoPage, /PublicDemoReplay/); assert.match(demoPage, /Stop losing jobs when you miss the call/i); + assert.match(demoPage, /Try the interactive simulator/); assert.match(demoPage, /This is exactly what your customer sees after you miss their call/i); assert.match(demoPage, /Demo only: fake business, fake callers, and no live customer or Twilio data\./); assert.match(demoData, /No login, no real customer data, no live Twilio traffic/i); assert.match(demoPage, /from ['"]@\/lib\/demo-data['"]/); assert.doesNotMatch(demoPage, /requireBusiness|getBusinessForOwnerClerkId|db\./); assert.match(simulatorExperience, /PUBLIC_START_FREE_PILOT_PATH/); - assert.match(simulatorExperience, /PUBLIC_CREATE_ACCOUNT_PATH/); + assert.match(simulatorExperience, /Start 14-Day Pilot/); + assert.match(demoPage, /href="\/simulator"/); assert.doesNotMatch(simulatorPage, /ENABLE_PUBLIC_SIMULATOR_REAL_SMS|SIMULATOR_BUSINESS_ID|db\./); assert.doesNotMatch(simulatorExperience, /ENABLE_PUBLIC_SIMULATOR_REAL_SMS|SIMULATOR_BUSINESS_ID|db\.|startMissedCallRecovery/); assert.equal(existsSync(path.join(process.cwd(), 'app/simulator/actions.ts')), false); diff --git a/tests/simulator-route.test.ts b/tests/simulator-route.test.ts index cee94e2..8cae0c6 100644 --- a/tests/simulator-route.test.ts +++ b/tests/simulator-route.test.ts @@ -13,6 +13,7 @@ test('homepage and nav both point See Demo directly to /demo', () => { assert.match(home, /href="\/demo"/); assert.match(nav, /href: '\/demo'/); + assert.match(nav, /href: '\/simulator'/); }); test('simulator route is a distinct public page with a safe interactive preview', () => { @@ -25,6 +26,7 @@ test('simulator route is a distinct public page with a safe interactive preview' assert.match(simulatorExperience, /Interactive preview mode/); assert.match(simulatorExperience, /No real SMS will be sent in this demo/); assert.match(simulatorExperience, /See how CallbackCloser would recover a missed call/); + assert.match(simulatorExperience, /Start 14-Day Pilot/); assert.doesNotMatch(simulatorExperience, /Demo number unavailable|not configured on this environment yet|Real SMS mode is active/); assert.doesNotMatch(middleware, /\/simulator\(.\*\)/); });