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. - - -
- - setPhoneInput(event.target.value)} - placeholder="+1 555 123 4567" - type="tel" - value={phoneInput} - /> -
- -
- - - {session ? ( - - ) : null} -
- -
- CallbackCloser will simulate a missed call for {PUBLIC_SIMULATOR_BUSINESS_NAME} and keep the full demo inside this page. -
- - {errorMessage ?
{errorMessage}
: null} -
-
-
- -
- - - Simulator state machine - Every state below is deterministic and safe for public traffic. - - - {publicSimulatorStages.map((stage, index) => { - const status = session ? getStageStatus(stage, session.stage) : index === 0 ? 'current' : 'pending'; - - return ( -
-
- {index + 1} -
-
-
-

{timelineCopy[stage].label}

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

{timelineCopy[stage].detail}

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

{session.callerPhoneMasked}

-

Masked caller view for public demo safety

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

{message.body}

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

Reply as the caller

-

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

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

Owner alert subject

-

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

-
-
-

Lead summary

-

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

-
-
-

Service

-

{leadSummary?.service || 'Pending'}

-
-
-

Urgency

-

{leadSummary?.urgency || 'Pending'}

-
-
-

Caller

-

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

-
-
-

Next step

-

{leadSummary?.callbackWindow || 'Pending'}

-
-
-
-
-

Owner alert body

-

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

-

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

-
-
-
- -
-

What this proves in a sales conversation

-
-

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

-

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

-

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

-
-
-
-
-
-
- -
-
-
- Final CTA -

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 - - . -

-
-
-
-
- ); -} 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 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\(.\*\)/); });