From ee8aecc7957f7f93000a37b5e4d566e5f527f7bc Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Tue, 26 May 2026 23:03:16 -0400 Subject: [PATCH] Make public simulator self-contained --- README.md | 13 +- app/demo/page.tsx | 3 + app/simulator/page.tsx | 282 ++---------------- .../simulator/public-simulator-experience.tsx | 247 +++++++++++++++ docs/PRODUCTION_ENV.md | 9 +- lib/missed-call-copy.ts | 54 ++++ lib/missed-call-intake.ts | 196 ++++++++++++ lib/public-simulator.ts | 268 +++++++++++++++++ lib/sms-state-machine.ts | 199 ++---------- tests/public-demo-route.test.ts | 6 +- tests/public-simulator.test.ts | 121 ++++++++ tests/simulator-isolation.test.ts | 13 +- tests/simulator-route.test.ts | 11 +- 13 files changed, 964 insertions(+), 458 deletions(-) create mode 100644 components/simulator/public-simulator-experience.tsx create mode 100644 lib/missed-call-copy.ts create mode 100644 lib/missed-call-intake.ts create mode 100644 lib/public-simulator.ts create mode 100644 tests/public-simulator.test.ts diff --git a/README.md b/README.md index 5c6a557..b1ccc86 100644 --- a/README.md +++ b/README.md @@ -367,25 +367,24 @@ What it demonstrates: 1. A missed call is detected 2. CallbackCloser sends the first recovery text -3. The caller completes the short intake flow +3. The caller completes the full intake flow: service, urgency, name plus location, callback time 4. The lead is qualified and summarized 5. Owner delivery previews are generated 6. The dashboard-ready lead card is shown 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` experience is self-contained and runs entirely in the browser +- It does not require Twilio, login, `SIMULATOR_BUSINESS_ID`, or a configured demo business +- It never sends real SMS or writes customer-facing records +- The optional env-backed simulator helpers remain separate from the public route and can still be used for internal preview workflows if needed Creating a dedicated simulator workspace: - Open `/admin` - Use **Create Demo Business** - The action creates or refreshes a dedicated business named `CallbackCloser Demo` -- The success banner shows the business ID to use for `SIMULATOR_BUSINESS_ID` +- The success banner shows the business ID to use for legacy internal simulator workflows if you still need an isolated backend-assisted demo run - The demo workspace uses a synthetic owner account so it does not replace a real customer workspace tied to a Clerk user ## Production Setup (Vercel) diff --git a/app/demo/page.tsx b/app/demo/page.tsx index 2e692f6..42cc3b0 100644 --- a/app/demo/page.tsx +++ b/app/demo/page.tsx @@ -57,6 +57,9 @@ export default function DemoPage() { Want this running on your number? + + Try interactive simulator + See it in action diff --git a/app/simulator/page.tsx b/app/simulator/page.tsx index 0e788e7..6382a15 100644 --- a/app/simulator/page.tsx +++ b/app/simulator/page.tsx @@ -1,279 +1,49 @@ import type { Metadata } from 'next'; import Link from 'next/link'; -import { MessageDirection, OwnerNotificationChannel } from '@prisma/client'; -import { replyToSimulatorRunAction, startSimulatorRunAction } from '@/app/simulator/actions'; import { PublicSiteFooter } from '@/components/public-site-footer'; import { PublicSiteNav } from '@/components/public-site-nav'; +import { PublicSimulatorExperience } from '@/components/simulator/public-simulator-experience'; 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'; +import { buttonVariants } from '@/components/ui/button'; +import { PUBLIC_START_FREE_PILOT_PATH } from '@/lib/public-auth-routing'; 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: 'Run the self-contained CallbackCloser missed-call simulator and show the full qualification flow from missed call to owner alert preview.', }; -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 +
+
+
+ Public interactive demo +
+
+

Public simulator

+

+ Walk through the exact missed-call qualification flow in one browser tab. No login, no backend setup, and no real customer messaging required. +

+
+
+ + Start Free Pilot - ) : ( - - )} - - 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.'} -

+ + View product demo + +
- - - - 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/simulator/public-simulator-experience.tsx b/components/simulator/public-simulator-experience.tsx new file mode 100644 index 0000000..35f6344 --- /dev/null +++ b/components/simulator/public-simulator-experience.tsx @@ -0,0 +1,247 @@ +'use client'; + +import { 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 { + applyPublicSimulatorReply, + buildPublicSimulatorLeadSummary, + buildPublicSimulatorOwnerAlert, + canReplyToPublicSimulator, + createPublicSimulatorSession, + getPublicSimulatorReplyOptions, + type PublicSimulatorReplyOption, +} from '@/lib/public-simulator'; + +function MessageBubble({ body, role }: { body: string; role: 'system' | 'customer' | 'event' }) { + const roleLabel = role === 'system' ? 'CallbackCloser' : role === 'customer' ? 'Customer reply' : 'Missed call'; + const bubbleClassName = + role === 'system' + ? 'border-primary/30 bg-primary/5' + : role === 'customer' + ? 'border-border bg-background' + : 'border-accent/30 bg-accent/15'; + + return ( +
+

{roleLabel}

+

{body}

+
+ ); +} + +function QuickReplies({ + options, + onChoose, +}: { + options: PublicSimulatorReplyOption[]; + onChoose: (value: string) => void; +}) { + if (options.length === 0) return null; + + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} + +export function PublicSimulatorExperience() { + const [phoneDraft, setPhoneDraft] = useState('+1 (865) 555-0148'); + const [reply, setReply] = useState(''); + const [session, setSession] = useState(() => createPublicSimulatorSession(phoneDraft)); + + const leadSummary = buildPublicSimulatorLeadSummary(session); + const ownerAlert = buildPublicSimulatorOwnerAlert(session); + const canReply = canReplyToPublicSimulator(session.stage); + const quickReplies = getPublicSimulatorReplyOptions(session.stage); + + function restartDemo(nextPhone = phoneDraft) { + setReply(''); + setSession(createPublicSimulatorSession(nextPhone)); + } + + function sendReply(nextReply: string) { + if (!nextReply.trim() || !canReply) return; + setSession((currentSession) => applyPublicSimulatorReply(currentSession, nextReply)); + setReply(''); + } + + return ( +
+
+
+ Self-contained sales demo +
+

Show the missed-call recovery flow without setup

+

+ This interactive preview runs entirely in your browser. It shows the exact intake flow a missed caller sees, then reveals the qualified lead and owner alert your customer would get. +

+
+
+
+
+

Current progress

+

{session.progressLabel}

+
+ {session.qualified ? 'Lead qualified' : 'Intake in progress'} +
+
+
+
+

+ Public `/simulator` is intentionally self-contained. It should stay usable with no Twilio setup, no login, and no backend demo workspace. +

+
+
+ + Start the conversation + + +
+
+ + + + Demo controls + Use any phone number for the preview. It stays masked and never leaves the page. + + +
+ + setPhoneDraft(event.target.value)} + placeholder="+1 (865) 555-0148" + type="tel" + /> +
+
+ +

The demo masks it automatically in the transcript, summary, and alert.

+
+
+ No real SMS is sent. No lead is written to a live workspace. This is a polished preview for sales conversations and public demos. +
+
+
+
+ +
+ + + Missed-call conversation + The demo keeps the full thread moving through service, urgency, name plus location, callback timing, and confirmation. + + + {session.messages.map((message) => ( + + ))} + +
+
+
+

Reply as the customer

+

+ {canReply ? 'Free text always works. Quick replies are here when the flow expects a short choice.' : 'The lead is qualified. Restart any time to run it again.'} +

+
+ {canReply ? Reply open : Complete} +
+ +
{ + event.preventDefault(); + sendReply(reply); + }} + > +
+ + setReply(event.target.value)} + placeholder={canReply ? 'Type the next reply' : 'Demo complete'} + disabled={!canReply} + /> +
+ +
+
+
+
+ +
+ + + Qualified lead summary + The payoff is the structured lead your customer sees in the callback queue. + + +
+ Name + {leadSummary.name} +
+
+ Phone + {leadSummary.phone} +
+
+ Service + {leadSummary.service} +
+
+ Urgency + {leadSummary.urgency} +
+
+ Location / address + {leadSummary.location} +
+
+ Best callback time + {leadSummary.callbackTime} +
+
+ Lead priority + {leadSummary.priority} +
+
+ Status + {leadSummary.status} +
+
+
+ + + + Owner alert preview + Once the callback time is captured, the business gets a clean, ready-to-call alert. + + +
+
{ownerAlert}
+
+
+
+
+
+
+ ); +} diff --git a/docs/PRODUCTION_ENV.md b/docs/PRODUCTION_ENV.md index 8e17fd9..9b475c7 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 toggle. The public `/simulator` page no longer depends on this env var. | +| `SIMULATOR_BUSINESS_ID` | Server-only | Optional | Vercel | Legacy internal simulator business ID. If used, it must point to an isolated demo workspace, never a real customer business. | +| `ENABLE_PUBLIC_SIMULATOR_REAL_SMS` | Server-only | Optional | Vercel | Legacy internal simulator flag for real caller-side SMS. Keep off by default. The public `/simulator` page does not require it. | | `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,8 @@ 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 public `/simulator` sales demo is now self-contained and safe to render without Twilio or demo-workspace configuration. +- The legacy env-backed simulator helpers are optional and should only point at a dedicated demo workspace via `SIMULATOR_BUSINESS_ID`. ## Vercel: Preview vs Production diff --git a/lib/missed-call-copy.ts b/lib/missed-call-copy.ts new file mode 100644 index 0000000..534856b --- /dev/null +++ b/lib/missed-call-copy.ts @@ -0,0 +1,54 @@ +export const MISSED_CALL_SERVICE_EXAMPLES = ['Repair', 'estimate', 'installation', 'emergency', 'anything else'] as const; + +export const MISSED_CALL_URGENCY_OPTIONS = [ + { value: 'Emergency', label: '1 Emergency' }, + { value: 'Today', label: '2 Today' }, + { value: 'This week', label: '3 This week' }, + { value: 'Just getting a quote', label: '4 Just getting a quote' }, +] as const; + +export const MISSED_CALL_CALLBACK_OPTIONS = [ + { value: 'ASAP', label: '1 ASAP' }, + { value: 'Morning', label: '2 Morning' }, + { value: 'Afternoon', label: '3 Afternoon' }, + { value: 'Evening', label: '4 Evening' }, +] as const; + +export function getMissedCallServicePrompt() { + return `Hey, sorry we missed your call. What can we help you with today? + +You can reply with something like: +Repair, estimate, installation, emergency, or anything else. +Reply STOP to opt out.`; +} + +export function getMissedCallUrgencyPrompt() { + return `Got it — how soon do you need help? + +Reply: +1 Emergency +2 Today +3 This week +4 Just getting a quote`; +} + +export function getMissedCallContactLocationPrompt() { + return 'Thanks. What name should we put on the request, and what city/ZIP or service address is this for?'; +} + +export function getMissedCallCallbackPrompt() { + return `What’s the best time for someone to call you back? + +Reply: +1 ASAP +2 Morning +3 Afternoon +4 Evening`; +} + +export function getMissedCallCompletionPrompt(customerName?: string | null) { + const greeting = customerName ? `Thanks, ${customerName} —` : 'Thanks —'; + return `${greeting} we have your request. Someone will reach out as soon as possible. + +If there’s anything important we should know, you can reply here.`; +} diff --git a/lib/missed-call-intake.ts b/lib/missed-call-intake.ts new file mode 100644 index 0000000..5f5b259 --- /dev/null +++ b/lib/missed-call-intake.ts @@ -0,0 +1,196 @@ +export type ParsedContactLocation = { + callerName: string | null; + contactName: string | null; + location: string | null; + zipCode: string | null; +}; + +export type ParsedCallbackPreference = { + callbackRequested: boolean; + bestTime: string | null; +}; + +function normalizeText(text: string) { + return text.trim(); +} + +function normalizeWhitespace(text: string) { + return text.replace(/\s+/g, ' ').trim(); +} + +function lower(text: string) { + return normalizeText(text).toLowerCase(); +} + +function cleanSegment(value: string) { + return value.replace(/^[\s,;:.-]+|[\s,;:.-]+$/g, '').trim(); +} + +function looksLikeName(value: string) { + const trimmed = normalizeWhitespace(value); + if (!trimmed || /\d/.test(trimmed)) return false; + + const tokens = trimmed.split(' '); + if (tokens.length < 1 || tokens.length > 4) return false; + + return tokens.every((token) => /^[A-Za-z][A-Za-z'.-]*$/.test(token)); +} + +function looksLikeLocation(value: string) { + const trimmed = normalizeWhitespace(value); + if (!trimmed) return false; + + if (/\d{5}(?:-\d{4})?/.test(trimmed) || /\d/.test(trimmed)) return true; + if (/\b(st|street|ave|avenue|rd|road|dr|drive|ln|lane|blvd|boulevard|way|hwy|highway|suite|ste|apt|unit)\b/i.test(trimmed)) { + return true; + } + + return trimmed.length >= 3; +} + +export function parseZip(input: string) { + const trimmed = normalizeText(input); + if (!trimmed) return null; + if (/^\d{5}(?:-\d{4})?$/.test(trimmed)) return trimmed; + if (/^[A-Za-z0-9\- ]{3,10}$/.test(trimmed)) return trimmed.toUpperCase(); + return null; +} + +export function parseUrgency(input: string) { + const value = lower(input); + const map: Record = { + '1': 'Emergency', + emergency: 'Emergency', + urgent: 'Emergency', + '2': 'Today', + today: 'Today', + asap: 'Today', + now: 'Today', + '3': 'This week', + week: 'This week', + 'this week': 'This week', + '4': 'Just getting a quote', + quote: 'Just getting a quote', + estimate: 'Just getting a quote', + 'just getting a quote': 'Just getting a quote', + }; + + return map[value] ?? null; +} + +export function parseContactLocation(input: string): ParsedContactLocation { + const text = normalizeWhitespace(input); + if (!text) { + return { + callerName: null, + contactName: null, + location: null, + zipCode: null, + }; + } + + const delimitedMatch = text.match(/^(.+?)(?:\s*(?:,|;|-|–|—)\s*)(.+)$/); + if (delimitedMatch) { + const candidateName = cleanSegment(delimitedMatch[1] || ''); + const candidateLocation = cleanSegment(delimitedMatch[2] || ''); + + if (looksLikeName(candidateName) && looksLikeLocation(candidateLocation)) { + return { + callerName: candidateName, + contactName: candidateName, + location: candidateLocation, + zipCode: parseZip(candidateLocation), + }; + } + } + + const inMatch = text.match(/^(.+?)\s+\bin\b\s+(.+)$/i); + if (inMatch) { + const candidateName = cleanSegment(inMatch[1] || ''); + const candidateLocation = cleanSegment(inMatch[2] || ''); + + if (looksLikeName(candidateName) && looksLikeLocation(candidateLocation)) { + return { + callerName: candidateName, + contactName: candidateName, + location: candidateLocation, + zipCode: parseZip(candidateLocation), + }; + } + } + + if (looksLikeName(text)) { + return { + callerName: text, + contactName: text, + location: null, + zipCode: null, + }; + } + + return { + callerName: null, + contactName: null, + location: text, + zipCode: parseZip(text), + }; +} + +export function parseCallbackPreference(input: string): ParsedCallbackPreference | null { + const value = lower(input); + if (!value) return null; + + const map: Record = { + '1': { callbackRequested: true, bestTime: 'ASAP' }, + asap: { callbackRequested: true, bestTime: 'ASAP' }, + urgent: { callbackRequested: true, bestTime: 'ASAP' }, + now: { callbackRequested: true, bestTime: 'ASAP' }, + soon: { callbackRequested: true, bestTime: 'ASAP' }, + anytime: { callbackRequested: true, bestTime: 'ASAP' }, + '2': { callbackRequested: true, bestTime: 'Morning' }, + morning: { callbackRequested: true, bestTime: 'Morning' }, + am: { callbackRequested: true, bestTime: 'Morning' }, + '3': { callbackRequested: true, bestTime: 'Afternoon' }, + afternoon: { callbackRequested: true, bestTime: 'Afternoon' }, + '4': { callbackRequested: true, bestTime: 'Evening' }, + evening: { callbackRequested: true, bestTime: 'Evening' }, + tonight: { callbackRequested: true, bestTime: 'Evening' }, + yes: { callbackRequested: true, bestTime: 'ASAP' }, + call: { callbackRequested: true, bestTime: 'ASAP' }, + 'call me': { callbackRequested: true, bestTime: 'ASAP' }, + 'please call': { callbackRequested: true, bestTime: 'ASAP' }, + no: { callbackRequested: false, bestTime: 'Text only' }, + 'no callback': { callbackRequested: false, bestTime: 'Text only' }, + 'text only': { callbackRequested: false, bestTime: 'Text only' }, + }; + + if (map[value]) { + return map[value]; + } + + if (value.length >= 2 && value.length <= 80) { + return { callbackRequested: true, bestTime: normalizeText(input) }; + } + + return null; +} + +export function normalizeServiceNeed(input: string) { + const text = normalizeWhitespace(input); + if (!text) return null; + + const value = text.toLowerCase(); + if (value === '1' || value.includes('repair')) return 'Repair'; + if (value === '2' || value.includes('install')) return 'Installation'; + if (value.includes('estimate') || value.includes('quote')) return 'Estimate'; + if (value.includes('emergency')) return 'Emergency'; + + return text; +} + +export function normalizePublicSimulatorPhone(input: string) { + const digits = input.replace(/\D/g, ''); + if (!digits) return 'Private demo caller'; + const lastFour = digits.slice(-4).padStart(4, '0'); + return `(***) ***-${lastFour}`; +} diff --git a/lib/public-simulator.ts b/lib/public-simulator.ts new file mode 100644 index 0000000..316d712 --- /dev/null +++ b/lib/public-simulator.ts @@ -0,0 +1,268 @@ +import { + getMissedCallCallbackPrompt, + getMissedCallCompletionPrompt, + getMissedCallContactLocationPrompt, + getMissedCallServicePrompt, + getMissedCallUrgencyPrompt, + MISSED_CALL_CALLBACK_OPTIONS, + MISSED_CALL_URGENCY_OPTIONS, +} from '@/lib/missed-call-copy'; +import { + normalizePublicSimulatorPhone, + normalizeServiceNeed, + parseCallbackPreference, + parseContactLocation, + parseUrgency, +} from '@/lib/missed-call-intake'; + +// Public /simulator must stay self-contained and must not depend on Twilio, login, or demo-business backend state. +export type PublicSimulatorStage = + | 'waiting_for_service' + | 'waiting_for_urgency' + | 'waiting_for_contact_location' + | 'waiting_for_callback_time' + | 'qualified'; + +export type PublicSimulatorMessage = { + id: string; + role: 'system' | 'customer' | 'event'; + body: string; +}; + +export type PublicSimulatorLead = { + customerName: string | null; + customerPhone: string; + serviceNeed: string | null; + urgency: string | null; + location: string | null; + callbackTime: string | null; + priority: 'Hot lead' | 'Qualified lead' | 'Lead in progress'; + status: 'Ready for callback' | 'Collecting details'; +}; + +export type PublicSimulatorSession = { + lead: PublicSimulatorLead; + messages: PublicSimulatorMessage[]; + progressValue: number; + progressLabel: string; + qualified: boolean; + stage: PublicSimulatorStage; +}; + +export type PublicSimulatorReplyOption = { + label: string; + value: string; +}; + +function buildMessages(customerPhone: string) { + return [ + { + id: 'event-missed-call', + role: 'event' as const, + body: `Missed call from ${customerPhone}. CallbackCloser opens the lead and sends the first text right away.`, + }, + { + id: 'system-service', + role: 'system' as const, + body: getMissedCallServicePrompt(), + }, + ]; +} + +function buildLead(partial?: Partial): PublicSimulatorLead { + const urgency = partial?.urgency ?? null; + return { + customerName: partial?.customerName ?? null, + customerPhone: partial?.customerPhone ?? '(***) ***-0148', + serviceNeed: partial?.serviceNeed ?? null, + urgency, + location: partial?.location ?? null, + callbackTime: partial?.callbackTime ?? null, + priority: urgency === 'Emergency' || urgency === 'Today' ? 'Hot lead' : urgency ? 'Qualified lead' : 'Lead in progress', + status: partial?.callbackTime ? 'Ready for callback' : 'Collecting details', + }; +} + +function buildProgress(stage: PublicSimulatorStage) { + if (stage === 'waiting_for_service') return { progressValue: 20, progressLabel: 'Step 1 of 5: service need' }; + if (stage === 'waiting_for_urgency') return { progressValue: 40, progressLabel: 'Step 2 of 5: urgency' }; + if (stage === 'waiting_for_contact_location') return { progressValue: 60, progressLabel: 'Step 3 of 5: name and location' }; + if (stage === 'waiting_for_callback_time') return { progressValue: 80, progressLabel: 'Step 4 of 5: callback time' }; + return { progressValue: 100, progressLabel: 'Step 5 of 5: lead qualified' }; +} + +function appendMessage(session: PublicSimulatorSession, message: Omit) { + return { + ...session, + messages: [...session.messages, { ...message, id: `${message.role}-${session.messages.length + 1}` }], + }; +} + +function completeLead(lead: PublicSimulatorLead): PublicSimulatorLead { + return { + ...lead, + priority: lead.urgency === 'Emergency' || lead.urgency === 'Today' ? 'Hot lead' : 'Qualified lead', + status: 'Ready for callback', + }; +} + +export function createPublicSimulatorSession(phoneInput = '+1 (865) 555-0148'): PublicSimulatorSession { + const customerPhone = normalizePublicSimulatorPhone(phoneInput); + const stage: PublicSimulatorStage = 'waiting_for_service'; + + return { + lead: buildLead({ customerPhone }), + messages: buildMessages(customerPhone), + qualified: false, + stage, + ...buildProgress(stage), + }; +} + +export function getPublicSimulatorReplyOptions(stage: PublicSimulatorStage): PublicSimulatorReplyOption[] { + if (stage === 'waiting_for_urgency') { + return MISSED_CALL_URGENCY_OPTIONS.map((option) => ({ label: option.label, value: option.value })); + } + + if (stage === 'waiting_for_callback_time') { + return MISSED_CALL_CALLBACK_OPTIONS.map((option) => ({ label: option.label, value: option.value })); + } + + return []; +} + +export function canReplyToPublicSimulator(stage: PublicSimulatorStage) { + return stage !== 'qualified'; +} + +export function applyPublicSimulatorReply(session: PublicSimulatorSession, reply: string) { + const trimmedReply = reply.trim(); + if (!trimmedReply) return session; + + let nextSession = appendMessage(session, { + role: 'customer', + body: trimmedReply, + }); + + if (session.stage === 'waiting_for_service') { + const serviceNeed = normalizeServiceNeed(trimmedReply) || trimmedReply; + const stage: PublicSimulatorStage = 'waiting_for_urgency'; + nextSession = appendMessage( + { + ...nextSession, + lead: buildLead({ ...nextSession.lead, serviceNeed }), + stage, + qualified: false, + ...buildProgress(stage), + }, + { + role: 'system', + body: getMissedCallUrgencyPrompt(), + } + ); + + return nextSession; + } + + if (session.stage === 'waiting_for_urgency') { + const urgency = parseUrgency(trimmedReply) || trimmedReply; + const stage: PublicSimulatorStage = 'waiting_for_contact_location'; + nextSession = appendMessage( + { + ...nextSession, + lead: buildLead({ ...nextSession.lead, urgency }), + stage, + qualified: false, + ...buildProgress(stage), + }, + { + role: 'system', + body: getMissedCallContactLocationPrompt(), + } + ); + + return nextSession; + } + + if (session.stage === 'waiting_for_contact_location') { + const parsed = parseContactLocation(trimmedReply); + const stage: PublicSimulatorStage = 'waiting_for_callback_time'; + nextSession = appendMessage( + { + ...nextSession, + lead: buildLead({ + ...nextSession.lead, + customerName: parsed.callerName || parsed.contactName || nextSession.lead.customerName, + location: parsed.location || trimmedReply, + }), + stage, + qualified: false, + ...buildProgress(stage), + }, + { + role: 'system', + body: getMissedCallCallbackPrompt(), + } + ); + + return nextSession; + } + + if (session.stage === 'waiting_for_callback_time') { + const callback = parseCallbackPreference(trimmedReply); + const callbackTime = callback?.bestTime || trimmedReply; + const finalLead = completeLead( + buildLead({ + ...nextSession.lead, + callbackTime, + }) + ); + const stage: PublicSimulatorStage = 'qualified'; + + nextSession = appendMessage( + { + ...nextSession, + lead: finalLead, + stage, + qualified: true, + ...buildProgress(stage), + }, + { + role: 'system', + body: getMissedCallCompletionPrompt(finalLead.customerName), + } + ); + + return nextSession; + } + + return session; +} + +export function buildPublicSimulatorLeadSummary(session: PublicSimulatorSession) { + return { + name: session.lead.customerName || 'Name pending', + phone: session.lead.customerPhone, + service: session.lead.serviceNeed || 'Service pending', + urgency: session.lead.urgency || 'Urgency pending', + location: session.lead.location || 'Location pending', + callbackTime: session.lead.callbackTime || 'Callback time pending', + priority: session.lead.priority, + status: session.lead.status, + }; +} + +export function buildPublicSimulatorOwnerAlert(session: PublicSimulatorSession) { + const summary = buildPublicSimulatorLeadSummary(session); + + return `🔥 Hot missed-call lead + +Name: ${summary.name} +Service: ${summary.service} +Urgency: ${summary.urgency} +Location: ${summary.location} +Callback: ${summary.callbackTime} + +Call now: ${summary.phone} +View lead: /app/leads/demo-missed-call-lead`; +} diff --git a/lib/sms-state-machine.ts b/lib/sms-state-machine.ts index 36e8c61..33a8f11 100644 --- a/lib/sms-state-machine.ts +++ b/lib/sms-state-machine.ts @@ -1,6 +1,19 @@ import type { Lead } from '@prisma/client'; import { SmsConversationState } from '@prisma/client'; +import { + getMissedCallCallbackPrompt, + getMissedCallCompletionPrompt, + getMissedCallContactLocationPrompt, + getMissedCallServicePrompt, + getMissedCallUrgencyPrompt, +} from '@/lib/missed-call-copy'; +import { + parseCallbackPreference, + parseContactLocation, + parseUrgency, +} from '@/lib/missed-call-intake'; + type LeadFieldUpdates = { serviceType?: string | null; serviceRequested?: string | null; @@ -34,77 +47,28 @@ function normalizeText(text: string) { return text.trim(); } -function normalizeWhitespace(text: string) { - return text.replace(/\s+/g, ' ').trim(); -} - function lower(text: string) { return normalizeText(text).toLowerCase(); } -function cleanSegment(value: string) { - return value.replace(/^[\s,;:.-]+|[\s,;:.-]+$/g, '').trim(); -} - -function looksLikeName(value: string) { - const trimmed = normalizeWhitespace(value); - if (!trimmed || /\d/.test(trimmed)) return false; - - const tokens = trimmed.split(' '); - if (tokens.length < 1 || tokens.length > 4) return false; - - return tokens.every((token) => /^[A-Za-z][A-Za-z'.-]*$/.test(token)); -} - -function looksLikeLocation(value: string) { - const trimmed = normalizeWhitespace(value); - if (!trimmed) return false; - - if (/\d{5}(?:-\d{4})?/.test(trimmed) || /\d/.test(trimmed)) return true; - if (/\b(st|street|ave|avenue|rd|road|dr|drive|ln|lane|blvd|boulevard|way|hwy|highway|suite|ste|apt|unit)\b/i.test(trimmed)) { - return true; - } - - return trimmed.length >= 3; -} - export function getServicePrompt(_business: BusinessPromptConfig) { - return `Hey, sorry we missed your call. What can we help you with today? - -You can reply with something like: -Repair, estimate, installation, emergency, or anything else. -Reply STOP to opt out.`; + return getMissedCallServicePrompt(); } export function getUrgencyPrompt() { - return `Got it — how soon do you need help? - -Reply: -1 Emergency -2 Today -3 This week -4 Just getting a quote`; + return getMissedCallUrgencyPrompt(); } export function getContactLocationPrompt() { - return 'Thanks. What name should we put on the request, and what city/ZIP or service address is this for?'; + return getMissedCallContactLocationPrompt(); } export function getBestTimePrompt() { - return `What’s the best time for someone to call you back? - -Reply: -1 ASAP -2 Morning -3 Afternoon -4 Evening`; + return getMissedCallCallbackPrompt(); } export function getCompletionPrompt(customerName?: string | null) { - const greeting = customerName ? `Thanks, ${customerName} —` : 'Thanks —'; - return `${greeting} we have your request. Someone will reach out as soon as possible. - -If there’s anything important we should know, you can reply here.`; + return getMissedCallCompletionPrompt(customerName); } function parseService(input: string, business: BusinessPromptConfig) { @@ -118,133 +82,6 @@ function parseService(input: string, business: BusinessPromptConfig) { return trimmed; } -function parseUrgency(input: string) { - const value = lower(input); - const map: Record = { - '1': 'Emergency', - emergency: 'Emergency', - urgent: 'Emergency', - '2': 'Today', - today: 'Today', - asap: 'Today', - now: 'Today', - '3': 'This week', - week: 'This week', - 'this week': 'This week', - '4': 'Just getting a quote', - quote: 'Just getting a quote', - estimate: 'Just getting a quote', - 'just getting a quote': 'Just getting a quote', - }; - - return map[value] ?? null; -} - -function parseZip(input: string) { - const trimmed = normalizeText(input); - if (!trimmed) return null; - if (/^\d{5}(?:-\d{4})?$/.test(trimmed)) return trimmed; - if (/^[A-Za-z0-9\- ]{3,10}$/.test(trimmed)) return trimmed.toUpperCase(); - return null; -} - -function parseContactLocation(input: string) { - const text = normalizeWhitespace(input); - if (!text) { - return { - callerName: null, - contactName: null, - location: null, - zipCode: null, - }; - } - - const delimitedMatch = text.match(/^(.+?)(?:\s*(?:,|;|-|–|—)\s*)(.+)$/); - if (delimitedMatch) { - const candidateName = cleanSegment(delimitedMatch[1] || ''); - const candidateLocation = cleanSegment(delimitedMatch[2] || ''); - - if (looksLikeName(candidateName) && looksLikeLocation(candidateLocation)) { - return { - callerName: candidateName, - contactName: candidateName, - location: candidateLocation, - zipCode: parseZip(candidateLocation), - }; - } - } - - const inMatch = text.match(/^(.+?)\s+\bin\b\s+(.+)$/i); - if (inMatch) { - const candidateName = cleanSegment(inMatch[1] || ''); - const candidateLocation = cleanSegment(inMatch[2] || ''); - - if (looksLikeName(candidateName) && looksLikeLocation(candidateLocation)) { - return { - callerName: candidateName, - contactName: candidateName, - location: candidateLocation, - zipCode: parseZip(candidateLocation), - }; - } - } - - if (looksLikeName(text)) { - return { - callerName: text, - contactName: text, - location: null, - zipCode: null, - }; - } - - return { - callerName: null, - contactName: null, - location: text, - zipCode: parseZip(text), - }; -} - -function parseCallbackPreference(input: string) { - const value = lower(input); - if (!value) return null; - - const map: Record = { - '1': { callbackRequested: true, bestTime: 'ASAP' }, - asap: { callbackRequested: true, bestTime: 'ASAP' }, - urgent: { callbackRequested: true, bestTime: 'ASAP' }, - now: { callbackRequested: true, bestTime: 'ASAP' }, - soon: { callbackRequested: true, bestTime: 'ASAP' }, - anytime: { callbackRequested: true, bestTime: 'ASAP' }, - '2': { callbackRequested: true, bestTime: 'Morning' }, - morning: { callbackRequested: true, bestTime: 'Morning' }, - am: { callbackRequested: true, bestTime: 'Morning' }, - '3': { callbackRequested: true, bestTime: 'Afternoon' }, - afternoon: { callbackRequested: true, bestTime: 'Afternoon' }, - '4': { callbackRequested: true, bestTime: 'Evening' }, - evening: { callbackRequested: true, bestTime: 'Evening' }, - tonight: { callbackRequested: true, bestTime: 'Evening' }, - yes: { callbackRequested: true, bestTime: 'ASAP' }, - call: { callbackRequested: true, bestTime: 'ASAP' }, - 'call me': { callbackRequested: true, bestTime: 'ASAP' }, - 'please call': { callbackRequested: true, bestTime: 'ASAP' }, - no: { callbackRequested: false, bestTime: 'Text only' }, - 'no callback': { callbackRequested: false, bestTime: 'Text only' }, - 'text only': { callbackRequested: false, bestTime: 'Text only' }, - }; - - if (map[value]) { - return map[value]; - } - - if (value.length >= 2 && value.length <= 80) { - return { callbackRequested: true, bestTime: normalizeText(input) }; - } - - return null; -} - export function advanceLeadConversation( lead: Pick, body: string, diff --git a/tests/public-demo-route.test.ts b/tests/public-demo-route.test.ts index 4bd13f6..794ebdd 100644 --- a/tests/public-demo-route.test.ts +++ b/tests/public-demo-route.test.ts @@ -10,7 +10,6 @@ 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 demoData = read('lib/demo-data.ts'); const middleware = read('middleware.ts'); @@ -19,13 +18,12 @@ test('public demo route is auth-free and built from isolated demo data', () => { assert.match(demoPage, /Stop losing jobs when you miss the call/i); 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(demoPage, /Try interactive simulator/); 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.doesNotMatch(simulatorPage, /ENABLE_PUBLIC_SIMULATOR_REAL_SMS|SIMULATOR_BUSINESS_ID|getSimulatorBusiness|replyToSimulatorRunAction|startSimulatorRunAction/); assert.doesNotMatch(middleware, /\/demo\(.\*\)/); assert.match(demoData, /Jamie Carter/); assert.match(demoData, /New HVAC lead/); diff --git a/tests/public-simulator.test.ts b/tests/public-simulator.test.ts new file mode 100644 index 0000000..2c808e2 --- /dev/null +++ b/tests/public-simulator.test.ts @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + applyPublicSimulatorReply, + buildPublicSimulatorLeadSummary, + buildPublicSimulatorOwnerAlert, + canReplyToPublicSimulator, + createPublicSimulatorSession, + getPublicSimulatorReplyOptions, +} from '../lib/public-simulator.ts'; + +test('public simulator starts with the missed-call recovery prompt and stays self-contained', () => { + const session = createPublicSimulatorSession('+1 (865) 555-0148'); + + assert.equal(session.stage, 'waiting_for_service'); + assert.equal(session.messages[0]?.role, 'event'); + assert.match(session.messages[0]?.body ?? '', /Missed call from \(\*\*\*\) \*\*\*-0148/i); + assert.match(session.messages[1]?.body ?? '', /What can we help you with today/i); + assert.equal(session.qualified, false); + assert.equal(canReplyToPublicSimulator(session.stage), true); +}); + +test('service reply advances to urgency prompt', () => { + const session = applyPublicSimulatorReply(createPublicSimulatorSession(), 'repair'); + + assert.equal(session.stage, 'waiting_for_urgency'); + assert.equal(session.lead.serviceNeed, 'Repair'); + assert.match(session.messages.at(-1)?.body ?? '', /how soon do you need help/i); +}); + +test('urgency reply advances to name and location prompt and does not stop after urgency', () => { + let session = createPublicSimulatorSession(); + session = applyPublicSimulatorReply(session, 'repair'); + session = applyPublicSimulatorReply(session, '2'); + + assert.equal(session.stage, 'waiting_for_contact_location'); + assert.equal(session.lead.urgency, 'Today'); + assert.match(session.messages.at(-1)?.body ?? '', /what name should we put on the request/i); + assert.equal(canReplyToPublicSimulator(session.stage), true); +}); + +test('name and location reply advances to callback time prompt', () => { + let session = createPublicSimulatorSession(); + session = applyPublicSimulatorReply(session, 'repair'); + session = applyPublicSimulatorReply(session, '2'); + session = applyPublicSimulatorReply(session, 'Sarah Miller - 123 Main St, Oak Ridge'); + + assert.equal(session.stage, 'waiting_for_callback_time'); + assert.equal(session.lead.customerName, 'Sarah Miller'); + assert.equal(session.lead.location, '123 Main St, Oak Ridge'); + assert.match(session.messages.at(-1)?.body ?? '', /best time for someone to call you back/i); +}); + +test('callback reply completes the flow, shows confirmation, summary, and owner alert preview', () => { + let session = createPublicSimulatorSession(); + session = applyPublicSimulatorReply(session, 'repair'); + session = applyPublicSimulatorReply(session, '2'); + session = applyPublicSimulatorReply(session, 'Sarah Miller - 123 Main St, Oak Ridge'); + session = applyPublicSimulatorReply(session, '1'); + + const summary = buildPublicSimulatorLeadSummary(session); + const ownerAlert = buildPublicSimulatorOwnerAlert(session); + + assert.equal(session.stage, 'qualified'); + assert.equal(session.qualified, true); + assert.match(session.messages.at(-1)?.body ?? '', /Thanks, Sarah Miller/i); + assert.equal(summary.status, 'Ready for callback'); + assert.equal(summary.callbackTime, 'ASAP'); + assert.equal(summary.priority, 'Hot lead'); + assert.match(ownerAlert, /🔥 Hot missed-call lead/); + assert.match(ownerAlert, /Name: Sarah Miller/); + assert.match(ownerAlert, /View lead: \/app\/leads\/demo-missed-call-lead/); +}); + +test('free-text replies work for service and callback time', () => { + let session = createPublicSimulatorSession(); + session = applyPublicSimulatorReply(session, 'Need someone to look at a leaking water heater'); + session = applyPublicSimulatorReply(session, 'This week'); + session = applyPublicSimulatorReply(session, 'Caleb, 37769'); + session = applyPublicSimulatorReply(session, 'after 5pm'); + + const summary = buildPublicSimulatorLeadSummary(session); + + assert.equal(summary.service, 'Need someone to look at a leaking water heater'); + assert.equal(summary.urgency, 'This week'); + assert.equal(summary.name, 'Caleb'); + assert.equal(summary.location, '37769'); + assert.equal(summary.callbackTime, 'after 5pm'); +}); + +test('quick reply options appear for urgency and callback steps', () => { + let session = createPublicSimulatorSession(); + session = applyPublicSimulatorReply(session, 'repair'); + assert.deepEqual( + getPublicSimulatorReplyOptions(session.stage).map((option) => option.value), + ['Emergency', 'Today', 'This week', 'Just getting a quote'] + ); + + session = applyPublicSimulatorReply(session, '2'); + session = applyPublicSimulatorReply(session, 'Jordan, Knoxville'); + assert.deepEqual( + getPublicSimulatorReplyOptions(session.stage).map((option) => option.value), + ['ASAP', 'Morning', 'Afternoon', 'Evening'] + ); +}); + +test('restart produces a fresh session after completion', () => { + let session = createPublicSimulatorSession('+1 (865) 555-0199'); + session = applyPublicSimulatorReply(session, 'repair'); + session = applyPublicSimulatorReply(session, '2'); + session = applyPublicSimulatorReply(session, 'Jordan, Knoxville'); + session = applyPublicSimulatorReply(session, '1'); + + const restarted = createPublicSimulatorSession('+1 (865) 555-0101'); + + assert.equal(restarted.stage, 'waiting_for_service'); + assert.equal(restarted.qualified, false); + assert.equal(restarted.messages.length, 2); + assert.match(restarted.messages[0]?.body ?? '', /0101/); +}); diff --git a/tests/simulator-isolation.test.ts b/tests/simulator-isolation.test.ts index 4368a13..539f0cf 100644 --- a/tests/simulator-isolation.test.ts +++ b/tests/simulator-isolation.test.ts @@ -9,13 +9,24 @@ function read(relativePath: string) { test('public simulator is gated and isolated from real customer delivery', () => { const simulatorPage = read('app/simulator/page.tsx'); + const publicSimulator = read('lib/public-simulator.ts'); const simulatorActions = read('app/simulator/actions.ts'); const simulatorLib = read('lib/simulator.ts'); const ownerNotifications = read('lib/owner-notifications.ts'); const schema = read('prisma/schema.prisma'); - assert.match(simulatorPage, /Missed-call simulator/); + assert.match(simulatorPage, /PublicSimulatorExperience/); + assert.doesNotMatch(simulatorPage, /isPublicSimulatorEnabled|getSimulatorBusiness|startSimulatorRunAction|replyToSimulatorRunAction/); + assert.doesNotMatch(simulatorPage, /SIMULATOR_BUSINESS_ID|ENABLE_PUBLIC_SIMULATOR_REAL_SMS|The public simulator is not configured/); + + assert.match(publicSimulator, /must stay self-contained/i); + assert.doesNotMatch(publicSimulator, /db\./); + assert.doesNotMatch(publicSimulator, /from ['"]@\/lib\/simulator['"]/); + assert.doesNotMatch(publicSimulator, /SimulatorRun/); + assert.doesNotMatch(publicSimulator, /processLeadInboundReply|startMissedCallRecovery/); + assert.match(simulatorActions, /isPublicSimulatorEnabled/); + assert.match(simulatorActions, /getSimulatorBusiness/); assert.match(simulatorLib, /SIMULATOR_BUSINESS_ID/); assert.match(simulatorLib, /ENABLE_PUBLIC_MISSED_CALL_SIMULATOR/); assert.match(simulatorLib, /ENABLE_PUBLIC_SIMULATOR_REAL_SMS/); diff --git a/tests/simulator-route.test.ts b/tests/simulator-route.test.ts index 658eddc..81270f4 100644 --- a/tests/simulator-route.test.ts +++ b/tests/simulator-route.test.ts @@ -15,14 +15,15 @@ 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 its own metadata and self-contained public experience', () => { const simulatorPage = read('app/simulator/page.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(simulatorPage, /Run the self-contained CallbackCloser missed-call simulator/i); + assert.doesNotMatch(simulatorPage, /The public simulator is not configured on this environment yet\./); + assert.doesNotMatch(simulatorPage, /Preview mode is active/); + assert.doesNotMatch(simulatorPage, /Real SMS mode is active/); assert.doesNotMatch(middleware, /\/simulator\(.\*\)/); });