diff --git a/README.md b/README.md
index 1762a8f..eae0244 100644
--- a/README.md
+++ b/README.md
@@ -372,7 +372,7 @@ 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
@@ -383,18 +383,20 @@ Simulator safety:
- 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
+- The public `/simulator` experience runs entirely in the browser and does not require `SIMULATOR_BUSINESS_ID` or a configured demo business
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
+- 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/simulator/page.tsx b/app/simulator/page.tsx
index 5ba3b39..6382a15 100644
--- a/app/simulator/page.tsx
+++ b/app/simulator/page.tsx
@@ -1,12 +1,16 @@
import type { Metadata } from 'next';
+import Link from 'next/link';
-import { PublicSimulatorExperience } from '@/components/demo/public-simulator-experience';
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 { 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: 'Interactive preview mode for the public CallbackCloser missed-call simulator with a safe, on-page lead recovery demo.',
+ description: 'Run the self-contained CallbackCloser missed-call simulator and show the full qualification flow from missed call to owner alert preview.',
};
export default function SimulatorPage() {
@@ -14,8 +18,32 @@ export default function SimulatorPage() {
-
-
+
+
+
+ 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
+
+
+ View product demo
+
+
+
+
+
+
+
+
+
diff --git a/components/demo/public-simulator-experience.tsx b/components/demo/public-simulator-experience.tsx
deleted file mode 100644
index c03541a..0000000
--- a/components/demo/public-simulator-experience.tsx
+++ /dev/null
@@ -1,413 +0,0 @@
-'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_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.
-
-
- Want the quick overview first?{' '}
-
- See the visual demo
-
- . This page is the hands-on version.
-
-
-
-
-
-
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.
-
-
-
- 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.'}
{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
-
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 starting a pilot for your service business.
-
-
-
-
- Start 14-Day Pilot
-
-
- Need a quick walkthrough before you start?{' '}
-
- Contact support
-
- .
-
+ 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.
+
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}
+
+
+
+
+
+
+
+
+
+
+ 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 7a8c74e..24192a2 100644
--- a/docs/PRODUCTION_ENV.md
+++ b/docs/PRODUCTION_ENV.md
@@ -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 legacy simulator backend 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
index 1cd6a12..bab00c7 100644
--- a/lib/public-simulator.ts
+++ b/lib/public-simulator.ts
@@ -1,378 +1,268 @@
-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';
+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;
- label: string;
- kind: 'event' | 'assistant' | 'caller';
- timestamp: 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 = {
- callerPhone: string;
- callerPhoneDisplay: string;
- callerPhoneMasked: string;
- completed: boolean;
- issueSummary: string | null;
- selectedService: PublicSimulatorService | null;
- selectedUrgency: PublicSimulatorUrgency | null;
+ lead: PublicSimulatorLead;
+ messages: PublicSimulatorMessage[];
+ progressValue: number;
+ progressLabel: string;
+ qualified: boolean;
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'];
+export type PublicSimulatorReplyOption = {
+ label: string;
+ value: string;
+};
-function nextTimestamp(messageCount: number) {
- return TIMESTAMP_SEQUENCE[Math.min(messageCount, TIMESTAMP_SEQUENCE.length - 1)];
+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 appendMessage(
- session: PublicSimulatorSession,
- message: Omit,
-): PublicSimulatorSession {
- const nextMessage: PublicSimulatorMessage = {
- ...message,
- id: `sim-message-${session.transcript.length + 1}`,
- timestamp: nextTimestamp(session.transcript.length),
+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,
- transcript: [...session.transcript, nextMessage],
+ messages: [...session.messages, { ...message, id: `${message.role}-${session.messages.length + 1}` }],
};
}
-function normalizeWhitespace(value: string) {
- return value.replace(/\s+/g, ' ').trim();
+function completeLead(lead: PublicSimulatorLead): PublicSimulatorLead {
+ return {
+ ...lead,
+ priority: lead.urgency === 'Emergency' || lead.urgency === 'Today' ? 'Hot lead' : 'Qualified lead',
+ status: 'Ready for callback',
+ };
}
-function toSentenceCase(value: string) {
- const trimmed = normalizeWhitespace(value);
- if (!trimmed) return '';
- return trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
-}
+export function createPublicSimulatorSession(phoneInput = '+1 (865) 555-0148'): PublicSimulatorSession {
+ const customerPhone = normalizePublicSimulatorPhone(phoneInput);
+ const stage: PublicSimulatorStage = 'waiting_for_service';
-function formatCustomReply(value: string) {
- const trimmed = toSentenceCase(value.replace(/[.!?]+$/, ''));
- return trimmed || 'Customer asked for help';
+ return {
+ lead: buildLead({ customerPhone }),
+ messages: buildMessages(customerPhone),
+ qualified: false,
+ stage,
+ ...buildProgress(stage),
+ };
}
-export function maskPublicSimulatorPhone(value: string | null | undefined) {
- const digits = (value || '').replace(/\D/g, '');
- if (digits.length >= 10) {
- const lastFour = digits.slice(-4);
- return `(***) ***-${lastFour}`;
+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 (digits.length >= 4) {
- return `***-${digits.slice(-4)}`;
+ if (stage === 'waiting_for_callback_time') {
+ return MISSED_CALL_CALLBACK_OPTIONS.map((option) => ({ label: option.label, value: option.value }));
}
- 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';
+ return stage !== 'qualified';
}
-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);
+export function applyPublicSimulatorReply(session: PublicSimulatorSession, reply: string) {
+ const trimmedReply = reply.trim();
+ if (!trimmedReply) return session;
- 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),
- },
- ],
- };
-}
+ let nextSession = appendMessage(session, {
+ role: 'customer',
+ body: trimmedReply,
+ });
-export function advancePublicSimulatorSession(session: PublicSimulatorSession): PublicSimulatorSession {
- if (session.stage === 'started') {
- return appendMessage(
+ if (session.stage === 'waiting_for_service') {
+ const serviceNeed = normalizeServiceNeed(trimmedReply) || trimmedReply;
+ const stage: PublicSimulatorStage = 'waiting_for_urgency';
+ nextSession = appendMessage(
{
- ...session,
- stage: 'missed_call_received',
+ ...nextSession,
+ lead: buildLead({ ...nextSession.lead, serviceNeed }),
+ stage,
+ qualified: false,
+ ...buildProgress(stage),
},
{
- 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',
+ role: 'system',
+ body: getMissedCallUrgencyPrompt(),
},
);
+
+ return nextSession;
}
- if (session.stage === 'missed_call_received') {
- return appendMessage(
+ if (session.stage === 'waiting_for_urgency') {
+ const urgency = parseUrgency(trimmedReply) || trimmedReply;
+ const stage: PublicSimulatorStage = 'waiting_for_contact_location';
+ nextSession = appendMessage(
{
- ...session,
- stage: 'first_message_shown',
+ ...nextSession,
+ lead: buildLead({ ...nextSession.lead, urgency }),
+ stage,
+ qualified: false,
+ ...buildProgress(stage),
},
{
- body: 'Sorry we missed your call. What do you need help with today: repair, install, maintenance, or emergency service?',
- kind: 'assistant',
- label: 'CallbackCloser',
+ role: 'system',
+ body: getMissedCallContactLocationPrompt(),
},
);
+
+ return nextSession;
}
- if (session.stage === 'urgency_captured') {
- return appendMessage(
+ if (session.stage === 'waiting_for_contact_location') {
+ const parsed = parseContactLocation(trimmedReply);
+ const stage: PublicSimulatorStage = 'waiting_for_callback_time';
+ nextSession = appendMessage(
{
- ...session,
- stage: 'owner_alert_ready',
+ ...nextSession,
+ lead: buildLead({
+ ...nextSession.lead,
+ customerName: parsed.callerName || parsed.contactName || nextSession.lead.customerName,
+ location: parsed.location || trimmedReply,
+ }),
+ stage,
+ qualified: false,
+ ...buildProgress(stage),
},
{
- 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',
+ role: 'system',
+ body: getMissedCallCallbackPrompt(),
},
);
- }
-
- 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';
+ return nextSession;
}
- 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,
- });
+ 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';
- return appendMessage(
+ nextSession = appendMessage(
{
- ...withReply,
- issueSummary,
- selectedService,
- stage: 'service_captured',
+ ...nextSession,
+ lead: finalLead,
+ stage,
+ qualified: true,
+ ...buildProgress(stage),
},
{
- body: 'Thanks. How urgent is this: today, this week, or are you just getting a quote?',
- kind: 'assistant',
- label: 'CallbackCloser',
+ role: 'system',
+ body: getMissedCallCompletionPrompt(finalLead.customerName),
},
);
- }
- const selectedUrgency = inferUrgency(trimmedReply);
- const withReply = appendMessage(session, {
- body: trimmedReply,
- kind: 'caller',
- label: session.callerPhoneMasked,
- });
+ return nextSession;
+ }
- 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',
- },
- );
+ return session;
}
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,
+ 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 leadSummary = buildPublicSimulatorLeadSummary(session);
+ const summary = 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`,
- };
+ 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 e0cc82a..b90f3cd 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 { existsSync, readFileSync } from 'node:fs';
+import { readFileSync } from 'node:fs';
import path from 'node:path';
import test from 'node:test';
@@ -7,10 +7,10 @@ function read(relativePath: string) {
return readFileSync(path.join(process.cwd(), relativePath), 'utf8');
}
-test('public demo route is auth-free and built from isolated demo data', () => {
+test('public demo route is auth-free and links into the self-contained simulator', () => {
const demoPage = read('app/demo/page.tsx');
const simulatorPage = read('app/simulator/page.tsx');
- const simulatorExperience = read('components/demo/public-simulator-experience.tsx');
+ const simulatorExperience = read('components/simulator/public-simulator-experience.tsx');
const demoData = read('lib/demo-data.ts');
const middleware = read('middleware.ts');
@@ -23,13 +23,15 @@ 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(simulatorExperience, /PUBLIC_START_FREE_PILOT_PATH/);
- assert.match(simulatorExperience, /Start 14-Day Pilot/);
+
+ assert.match(simulatorPage, /PUBLIC_START_FREE_PILOT_PATH/);
+ assert.match(simulatorExperience, /Self-contained sales demo/);
+ assert.match(simulatorExperience, /No real SMS is sent\./);
assert.match(demoPage, /href="\/simulator"/);
- assert.doesNotMatch(simulatorPage, /ENABLE_PUBLIC_SIMULATOR_REAL_SMS|SIMULATOR_BUSINESS_ID|db\./);
+ assert.doesNotMatch(simulatorPage, /ENABLE_PUBLIC_SIMULATOR_REAL_SMS|SIMULATOR_BUSINESS_ID|getSimulatorBusiness|replyToSimulatorRunAction|startSimulatorRunAction/);
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/);
assert.match(demoData, /Ready for callback/);
diff --git a/tests/public-simulator-flow.test.ts b/tests/public-simulator-flow.test.ts
index 67362fa..36e401b 100644
--- a/tests/public-simulator-flow.test.ts
+++ b/tests/public-simulator-flow.test.ts
@@ -2,63 +2,58 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
- advancePublicSimulatorSession,
applyPublicSimulatorReply,
+ buildPublicSimulatorLeadSummary,
buildPublicSimulatorOwnerAlert,
canReplyToPublicSimulator,
- getPublicSimulatorQuickReplies,
- maskPublicSimulatorPhone,
- startPublicSimulatorSession,
+ createPublicSimulatorSession,
+ getPublicSimulatorReplyOptions,
} 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/);
+test('public simulator masks the caller number and opens on the service prompt', () => {
+ const session = createPublicSimulatorSession('+1 (865) 555-0148');
- 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/);
+ assert.equal(session.stage, 'waiting_for_service');
+ assert.equal(session.lead.customerPhone, '(***) ***-0148');
+ assert.match(session.messages[0]?.body ?? '', /Missed call from \(\*\*\*\) \*\*\*-0148/);
+ assert.match(session.messages[1]?.body ?? '', /What can we help you with today/i);
+ assert.equal(canReplyToPublicSimulator(session.stage), true);
});
-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');
+test('public simulator keeps moving through urgency, name plus location, and callback time', () => {
+ let session = createPublicSimulatorSession('+1 (865) 555-0199');
+ session = applyPublicSimulatorReply(session, 'AC repair, the unit is not cooling');
+
+ assert.equal(session.stage, 'waiting_for_urgency');
+ assert.deepEqual(
+ getPublicSimulatorReplyOptions(session.stage).map((option) => option.value),
+ ['Emergency', 'Today', 'This week', 'Just getting a quote'],
+ );
+
+ session = applyPublicSimulatorReply(session, 'Today');
+ assert.equal(session.stage, 'waiting_for_contact_location');
+ assert.match(session.messages.at(-1)?.body ?? '', /what name should we put on the request/i);
+
+ session = applyPublicSimulatorReply(session, 'Jamie Carter, Knoxville');
+ assert.equal(session.stage, 'waiting_for_callback_time');
+ assert.equal(session.lead.customerName, 'Jamie Carter');
+ assert.equal(session.lead.location, 'Knoxville');
+
+ session = applyPublicSimulatorReply(session, 'Afternoon');
+ assert.equal(session.stage, 'qualified');
+ assert.equal(session.qualified, true);
+
+ const summary = buildPublicSimulatorLeadSummary(session);
+ const ownerAlert = buildPublicSimulatorOwnerAlert(session);
+
+ assert.equal(summary.phone, '(***) ***-0199');
+ assert.equal(summary.service, 'Repair');
+ assert.equal(summary.urgency, 'Today');
+ assert.equal(summary.location, 'Knoxville');
+ assert.equal(summary.callbackTime, 'Afternoon');
+ assert.equal(summary.status, 'Ready for callback');
+ assert.match(ownerAlert, /🔥 Hot missed-call lead/);
+ assert.match(ownerAlert, /Name: Jamie Carter/);
+ assert.match(ownerAlert, /Callback: Afternoon/);
+ assert.match(ownerAlert, /\(\*\*\*\) \*\*\*-0199/);
});
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 4102a5b..6de1fda 100644
--- a/tests/simulator-isolation.test.ts
+++ b/tests/simulator-isolation.test.ts
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
-import { existsSync, readFileSync } from 'node:fs';
+import { readFileSync } from 'node:fs';
import path from 'node:path';
import test from 'node:test';
@@ -7,20 +7,29 @@ function read(relativePath: string) {
return readFileSync(path.join(process.cwd(), relativePath), 'utf8');
}
-test('public simulator is isolated from real customer delivery and runs entirely in demo logic', () => {
+test('public simulator is isolated from real customer delivery and keeps legacy backend helpers separate', () => {
const simulatorPage = read('app/simulator/page.tsx');
- const simulatorExperience = read('components/demo/public-simulator-experience.tsx');
- const publicSimulatorLib = read('lib/public-simulator.ts');
+ const publicSimulator = read('lib/public-simulator.ts');
+ const simulatorLib = read('lib/simulator.ts');
const ownerNotifications = read('lib/owner-notifications.ts');
const schema = read('prisma/schema.prisma');
- 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(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.match(publicSimulator, /waiting_for_contact_location/);
+ assert.match(publicSimulator, /waiting_for_callback_time/);
+ assert.doesNotMatch(publicSimulator, /db\./);
+ assert.doesNotMatch(publicSimulator, /from ['"]@\/lib\/simulator['"]/);
+ assert.doesNotMatch(publicSimulator, /SimulatorRun/);
+ assert.doesNotMatch(publicSimulator, /processLeadInboundReply|startMissedCallRecovery/);
+
+ 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(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 8cae0c6..3f7ed18 100644
--- a/tests/simulator-route.test.ts
+++ b/tests/simulator-route.test.ts
@@ -7,7 +7,7 @@ function read(relativePath: string) {
return readFileSync(path.join(process.cwd(), relativePath), 'utf8');
}
-test('homepage and nav both point See Demo directly to /demo', () => {
+test('homepage and nav point visitors to both the demo and simulator routes', () => {
const home = read('app/page.tsx');
const nav = read('components/public-site-nav.tsx');
@@ -16,17 +16,20 @@ test('homepage and nav both point See Demo directly to /demo', () => {
assert.match(nav, /href: '\/simulator'/);
});
-test('simulator route is a distinct public page with a safe interactive preview', () => {
+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 simulatorExperience = read('components/demo/public-simulator-experience.tsx');
+ const simulatorExperience = read('components/simulator/public-simulator-experience.tsx');
const middleware = read('middleware.ts');
assert.match(simulatorPage, /Missed-Call Simulator \| CallbackCloser/);
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.match(simulatorExperience, /Start 14-Day Pilot/);
+ assert.match(simulatorPage, /Run the self-contained CallbackCloser missed-call simulator/i);
+ assert.match(simulatorExperience, /Show the missed-call recovery flow without setup/);
+ assert.match(simulatorExperience, /Lead qualified/);
+ assert.match(simulatorExperience, /No real SMS is sent/);
+ 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(simulatorExperience, /Demo number unavailable|not configured on this environment yet|Real SMS mode is active/);
assert.doesNotMatch(middleware, /\/simulator\(.\*\)/);
});