diff --git a/.env.example b/.env.example
index be8a744..d37e0eb 100644
--- a/.env.example
+++ b/.env.example
@@ -8,6 +8,8 @@ DIRECT_DATABASE_URL=
# Clerk
# Required in production
+# For local development, use Clerk test/dev keys that allow localhost redirects.
+# Production-domain-restricted keys can trigger browser console origin errors on http://localhost:3000.
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
@@ -46,8 +48,8 @@ FOUNDER_CLERK_USER_ID=
RESEND_API_KEY=
CALLBACKCLOSER_FROM_EMAIL=
-# Optional public simulator
-# Keep these disabled in production unless the public simulator is intentionally enabled.
+# Optional legacy backend simulator tooling
+# The current public /simulator page is self-contained and does not require these values.
ENABLE_PUBLIC_MISSED_CALL_SIMULATOR=
SIMULATOR_BUSINESS_ID=
ENABLE_PUBLIC_SIMULATOR_REAL_SMS=
diff --git a/README.md b/README.md
index 5c6a557..1762a8f 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ When a customer calls a business's Twilio number and the forwarded call is misse
- Twilio SMS webhook (`/api/twilio/sms`) with lead qualification steps
- Qualified-lead delivery with idempotent SMS/email/in-app owner notifications
- Lead dashboard + filters + lead detail transcript + status updates
-- Public missed-call simulator (`/simulator`) with isolated demo lead runs
+- Public missed-call simulator (`/simulator`) with a self-contained interactive preview flow
- Stripe billing page + checkout + billing portal
- Public purchase entry route (`/buy`) for external marketing-site links
- Stripe webhook sync for subscription status gating
@@ -74,10 +74,14 @@ Required categories:
- Stripe keys + price IDs + webhook secret
- Twilio credentials + webhook auth token
- Optional owner email delivery (`RESEND_API_KEY`, `CALLBACKCLOSER_FROM_EMAIL`)
-- Optional public simulator config (`ENABLE_PUBLIC_MISSED_CALL_SIMULATOR`, `SIMULATOR_BUSINESS_ID`, `ENABLE_PUBLIC_SIMULATOR_REAL_SMS`)
- Database URL
- Optional rate-limit tuning vars (defaults are built in)
+Local note:
+
+- Use Clerk test/dev keys for localhost work.
+- If you point `.env.local` at production-domain-restricted Clerk keys, public pages can still render, but Clerk will log browser origin errors on `http://localhost:3000`.
+
### 4. Run Prisma migrations / generate client
This repo includes a Prisma migration at `prisma/migrations/20260222000000_init/migration.sql`.
@@ -150,6 +154,7 @@ Turn it off after smoke testing by unsetting `ALLOW_FOUNDER_BILLING_BYPASS` or s
- `https://YOUR_DOMAIN/sign-in`
- `https://YOUR_DOMAIN/sign-up`
4. Ensure your app origin(s) are allowed in Clerk.
+5. For localhost development, prefer Clerk test/dev keys. Production keys that are locked to `callbackcloser.com` will reject browser requests from `http://localhost:3000`.
## Stripe Setup (Required)
@@ -374,11 +379,15 @@ What it demonstrates:
Simulator safety:
-- The simulator is enabled only when `ENABLE_PUBLIC_MISSED_CALL_SIMULATOR=true`
-- It uses the business identified by `SIMULATOR_BUSINESS_ID`
-- Simulator records are isolated with `Lead.isSimulator`, `Call.isSimulator`, `Message.isSimulator`, and `SimulatorRun`
-- Owner notifications for simulator runs are stored as preview records and do not send to real customer destinations
-- If `ENABLE_PUBLIC_SIMULATOR_REAL_SMS=true`, the simulator can send the caller-side SMS to the supplied demo number, but owner delivery remains simulated
+- The public `/simulator` page is self-contained and does not require Twilio, login, or backend simulator config
+- No real SMS is sent and no Twilio calls are made
+- Public visitor input stays inside the browser demo and does not create customer-facing records
+- Caller numbers are masked in the UI before the owner-alert preview is shown
+
+Legacy internal simulator backend:
+
+- The repo still includes isolated simulator-record models and env flags for internal/admin demo tooling
+- Those flags are not required for the current public `/simulator` experience
Creating a dedicated simulator workspace:
@@ -474,7 +483,7 @@ Use this checklist before sending paid traffic to `callbackcloser.com` or allowi
- `/privacy` - privacy policy
- `/refund` - refund policy
- `/contact` - public support/contact page
-- `/simulator` - public missed-call simulator
+- `/simulator` - public interactive missed-call simulator
- `/sign-in` - Clerk sign-in
- `/sign-up` - Clerk sign-up
- `/app/onboarding` - create business record
diff --git a/app/demo/page.tsx b/app/demo/page.tsx
index 2e692f6..be140fa 100644
--- a/app/demo/page.tsx
+++ b/app/demo/page.tsx
@@ -54,14 +54,16 @@ export default function DemoPage() {
-
- Want this running on your number?
+
+ Try the interactive simulator
- See it in action
+ See the quick overview
-
Get this live on your number fast, without changing how your team already works.
+
+ This page is the fast visual walkthrough. The simulator lets you reply like the caller and see the full handoff yourself.
+
Demo only: fake business, fake callers, and no live customer or Twilio data.
@@ -103,7 +105,7 @@ export default function DemoPage() {
- Live-looking product view
+ Quick overview
Here's what happens after a missed HVAC call
They call. You miss it. We text them right away, ask what they need, and send you the lead while they're still ready to book.
@@ -220,11 +222,11 @@ export default function DemoPage() {
{demoOwnerAlert.summary}
-
- Want this on your business?
+
+ Try the interactive simulator
- See it on your number
+ Start 14-Day Pilot
@@ -257,17 +259,17 @@ export default function DemoPage() {
Close with a demo, not a long explanation
-
Want this on your business?
+
Ready to try the missed-call flow yourself?
- We can set it up on your number and get you live fast.
+ Use the simulator for the hands-on experience, then start a pilot when you're ready to put it on your number.
-
- See it on your number
+
+ Try the interactive simulator
-
- Book a quick setup call
+
+ Start 14-Day Pilot
diff --git a/app/icon.svg b/app/icon.svg
new file mode 100644
index 0000000..2b03944
--- /dev/null
+++ b/app/icon.svg
@@ -0,0 +1,6 @@
+
diff --git a/app/simulator/actions.ts b/app/simulator/actions.ts
deleted file mode 100644
index 9a77213..0000000
--- a/app/simulator/actions.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-'use server';
-
-import { SubscriptionStatus } from '@prisma/client';
-import { redirect } from 'next/navigation';
-
-import { db } from '@/lib/db';
-import { normalizePhoneNumber } from '@/lib/phone';
-import { processLeadInboundReply, startMissedCallRecovery } from '@/lib/missed-call-flow';
-import { canSendRealSimulatorSms, createSimulatorPublicId, getSimulatorBusiness, getSimulatorRun, isPublicSimulatorEnabled } from '@/lib/simulator';
-
-function getSimulatorRedirect(publicId?: string | null, error?: string, status?: string, notice?: string) {
- const params = new URLSearchParams();
- if (publicId) params.set('run', publicId);
- if (error) params.set('error', error);
- if (status) params.set('status', status);
- if (notice) params.set('notice', notice);
- const query = params.toString();
- return query ? `/simulator?${query}` : '/simulator';
-}
-
-export async function startSimulatorRunAction(formData: FormData) {
- if (!isPublicSimulatorEnabled()) {
- redirect(getSimulatorRedirect(null, 'The public simulator is not enabled on this environment.'));
- }
-
- const business = await getSimulatorBusiness();
- if (!business) {
- redirect(getSimulatorRedirect(null, 'Simulator business is not configured yet.'));
- }
-
- const phoneRaw = typeof formData.get('phone') === 'string' ? String(formData.get('phone')) : '';
- const callerPhone = normalizePhoneNumber(phoneRaw) || phoneRaw.trim();
- if (!callerPhone) {
- redirect(getSimulatorRedirect(null, 'Enter a phone number to start the simulator.'));
- }
-
- const realSmsEnabled = canSendRealSimulatorSms(business);
- const transport = realSmsEnabled ? 'twilio' : 'simulated';
- const notice = realSmsEnabled
- ? 'Simulator started. CallbackCloser should text the number you entered from the demo business texting line.'
- : 'Preview mode active. This simulator run updates the transcript and owner alerts on-page, but it does not text your phone until the demo texting line is configured for real SMS delivery.';
-
- const call = await db.call.create({
- data: {
- businessId: business.id,
- twilioCallSid: `SIMCALL_${createSimulatorPublicId()}`,
- fromPhone: callerPhone,
- fromPhoneNormalized: callerPhone,
- toPhone: business.twilioPrimaryPhoneNumber || business.twilioPhoneNumber || '+10000000000',
- toPhoneNormalized: business.twilioPrimaryPhoneNumber || business.twilioPhoneNumber || '+10000000000',
- status: 'SIMULATED_MISSED',
- missed: true,
- answered: false,
- dialCallStatus: 'no-answer',
- isSimulator: true,
- rawPayload: { source: 'public_simulator' },
- },
- });
-
- const { lead } = await startMissedCallRecovery({
- business,
- callerPhone,
- callId: call.id,
- isSimulator: true,
- transport,
- forceAutomation: true,
- });
-
- const publicId = createSimulatorPublicId();
- await db.simulatorRun.create({
- data: {
- publicId,
- businessId: business.id,
- leadId: lead.id,
- callerPhone,
- status: 'ACTIVE',
- },
- });
-
- redirect(getSimulatorRedirect(publicId, undefined, realSmsEnabled ? 'sms-sent' : 'preview-started', notice));
-}
-
-export async function replyToSimulatorRunAction(formData: FormData) {
- const publicId = typeof formData.get('publicId') === 'string' ? String(formData.get('publicId')) : '';
- const body = typeof formData.get('body') === 'string' ? String(formData.get('body')).trim() : '';
- if (!publicId) {
- redirect(getSimulatorRedirect(null, 'Simulator run not found.'));
- }
- if (!body) {
- redirect(getSimulatorRedirect(publicId, 'Enter a reply to continue the intake flow.'));
- }
-
- const run = await getSimulatorRun(publicId);
- if (!run) {
- redirect(getSimulatorRedirect(null, 'Simulator run not found.'));
- }
-
- await processLeadInboundReply({
- business: {
- ...run.business,
- ownerClerkId: 'simulator',
- notifyPhone: null,
- subscriptionStatus: SubscriptionStatus.ACTIVE,
- provisioningStatus: 'LIVE',
- archivedAt: null,
- },
- leadId: run.leadId,
- body,
- fromPhone: run.callerPhone,
- toPhone: run.business.twilioPrimaryPhoneNumber || run.business.twilioPhoneNumber || '+10000000000',
- transport: 'simulated',
- });
-
- redirect(getSimulatorRedirect(publicId, undefined, 'reply-saved'));
-}
diff --git a/app/simulator/page.tsx b/app/simulator/page.tsx
index 0e788e7..5ba3b39 100644
--- a/app/simulator/page.tsx
+++ b/app/simulator/page.tsx
@@ -1,279 +1,21 @@
import type { Metadata } from 'next';
-import Link from 'next/link';
-import { MessageDirection, OwnerNotificationChannel } from '@prisma/client';
-import { replyToSimulatorRunAction, startSimulatorRunAction } from '@/app/simulator/actions';
+import { PublicSimulatorExperience } from '@/components/demo/public-simulator-experience';
import { PublicSiteFooter } from '@/components/public-site-footer';
import { PublicSiteNav } from '@/components/public-site-nav';
-import { Badge } from '@/components/ui/badge';
-import { Button, buttonVariants } from '@/components/ui/button';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-import { formatDateTime, getLeadStatusBadgeVariant, leadReadinessLabels, leadStatusLabels } from '@/lib/lead-presenters';
-import { PUBLIC_CREATE_ACCOUNT_PATH, PUBLIC_START_FREE_PILOT_PATH } from '@/lib/public-auth-routing';
-import { canSendRealSimulatorSms, getSimulatorBusiness, getSimulatorRun, isPlaceholderSimulatorNumber, isPublicSimulatorEnabled } from '@/lib/simulator';
export const metadata: Metadata = {
title: 'Missed-Call Simulator | CallbackCloser',
- description: 'Run the public CallbackCloser missed-call simulator and see the full recovery loop from missed call to qualified owner alert.',
+ description: 'Interactive preview mode for the public CallbackCloser missed-call simulator with a safe, on-page lead recovery demo.',
};
-export const dynamic = 'force-dynamic';
-
-function timelineStep(label: string, complete: boolean, detail: string) {
- return { label, complete, detail };
-}
-
-export default async function SimulatorPage({
- searchParams,
-}: {
- searchParams?: Record;
-}) {
- const runPublicId = typeof searchParams?.run === 'string' ? searchParams.run : null;
- const error = typeof searchParams?.error === 'string' ? searchParams.error : null;
- const notice = typeof searchParams?.notice === 'string' ? searchParams.notice : null;
- const status = typeof searchParams?.status === 'string' ? searchParams.status : null;
- const enabled = isPublicSimulatorEnabled();
- const business = await getSimulatorBusiness();
- const run = runPublicId ? await getSimulatorRun(runPublicId) : null;
-
- const lead = run?.lead ?? null;
- const latestOwnerSms = lead?.ownerNotifications.find((notification) => notification.channel === OwnerNotificationChannel.SMS) ?? null;
- const latestOwnerEmail = lead?.ownerNotifications.find((notification) => notification.channel === OwnerNotificationChannel.EMAIL) ?? null;
- const inAppNotification = lead?.ownerNotifications.find((notification) => notification.channel === OwnerNotificationChannel.IN_APP) ?? null;
- const transcript = lead?.messages ?? [];
- const demoNumber = business?.twilioPrimaryPhoneNumber || business?.twilioPhoneNumber || null;
- const realSmsEnabled = canSendRealSimulatorSms(business);
- const usingPlaceholderTextingLine = isPlaceholderSimulatorNumber(demoNumber);
-
- const timeline = lead
- ? [
- timelineStep('Missed call detected', Boolean(lead.call || lead.createdAt), 'CallbackCloser records the missed call and opens a lead.'),
- timelineStep(
- 'Recovery text sent',
- transcript.some((message) => message.direction === MessageDirection.OUTBOUND),
- 'The caller gets the first SMS immediately so the lead does not go cold.'
- ),
- timelineStep(
- 'Caller replied',
- transcript.some((message) => message.direction === MessageDirection.INBOUND),
- 'The intake thread captures service details directly from the caller.'
- ),
- timelineStep(
- 'Lead qualified',
- Boolean(lead.qualifiedAt),
- 'Service type plus urgency or callback intent is enough to mark the lead ready.'
- ),
- timelineStep(
- 'Owner notified',
- Boolean(lead.notifiedAt || latestOwnerSms || latestOwnerEmail || inAppNotification),
- 'Owner delivery fans out to SMS, email, and in-app preview without repeating alerts.'
- ),
- timelineStep(
- 'Dashboard updated',
- Boolean(lead.status === 'NOTIFIED' || inAppNotification),
- 'The qualified lead appears with structured details and a ready-to-call summary.'
- ),
- ]
- : [];
-
+export default function SimulatorPage() {
return (
-
-
-
- Missed-call simulator
-
-
See the full CallbackCloser lead loop in minutes
-
- Trigger a missed call, watch the recovery text go out, complete the intake, and see how the owner gets a qualified lead.
-
- Use your own phone number to start a private simulator run, or call the demo line if one is configured for this environment.
-
-
-
{realSmsEnabled ? 'Real SMS mode is active' : 'Preview mode is active'}
-
- {realSmsEnabled
- ? 'CallbackCloser will text the phone number you enter from the demo business texting line, then continue the rest of the flow in this page.'
- : usingPlaceholderTextingLine
- ? 'This demo workspace is still using the safe placeholder texting line, so CallbackCloser will show the recovery text and owner alerts on this page instead of sending a real SMS.'
- : 'Real SMS mode is not enabled for this environment yet, so CallbackCloser will show the recovery text and owner alerts on this page instead of sending a real SMS.'}
-
-
-
-
-
-
- Start the simulator
- Enter a phone number and CallbackCloser will open a dedicated demo lead for this run.
-
-
- {!enabled || !business ? (
-
- The public simulator is not configured on this environment yet.
-
- ) : (
-
- )}
- {notice ? (
-
- {notice}
-
- ) : null}
- {error ?
{error}
: null}
-
-
-
-
- {run && lead ? (
-
-
-
- Live simulator timeline
- Each step below mirrors the actual missed-call recovery flow.
-
-
- {timeline.map((step, index) => (
-
-
{index + 1}
-
-
-
{step.label}
- {step.complete ? 'Complete' : 'Pending'}
-
-
{step.detail}
-
-
- ))}
-
-
-
-
-
- Simulated owner alerts
- SMS, email, and dashboard previews all update off the same qualified lead.
-
-
-
-
SMS preview
-
{latestOwnerSms?.body || 'Owner SMS alert will appear after the lead qualifies.'}
-
-
-
Email preview
-
{latestOwnerEmail?.subject || 'Owner email subject will appear once qualification is complete.'}
-
{latestOwnerEmail?.body || 'Email body preview will populate after qualification.'}
-
-
-
- ) : null}
+
+
diff --git a/components/demo/public-simulator-experience.tsx b/components/demo/public-simulator-experience.tsx
new file mode 100644
index 0000000..c03541a
--- /dev/null
+++ b/components/demo/public-simulator-experience.tsx
@@ -0,0 +1,413 @@
+'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
+
+ .
+
+
+
+
+
+ );
+}
diff --git a/components/public-site-nav.tsx b/components/public-site-nav.tsx
index f82be4e..b32fa50 100644
--- a/components/public-site-nav.tsx
+++ b/components/public-site-nav.tsx
@@ -6,6 +6,7 @@ import { PUBLIC_CREATE_ACCOUNT_PATH, PUBLIC_SIGN_IN_PATH, PUBLIC_START_FREE_PILO
const primaryLinks = [
{ href: '/demo', label: 'Demo' },
+ { href: '/simulator', label: 'Simulator' },
{ href: '/pricing', label: 'Pricing' },
{ href: '/contact', label: 'Contact' },
{ href: '/sms-consent', label: 'SMS Consent' },
diff --git a/docs/PRODUCTION_ENV.md b/docs/PRODUCTION_ENV.md
index 8e17fd9..7a8c74e 100644
--- a/docs/PRODUCTION_ENV.md
+++ b/docs/PRODUCTION_ENV.md
@@ -34,9 +34,9 @@ This project uses `NEXT_PUBLIC_APP_URL` as the single canonical app origin for s
| `DEBUG_ENV_ENDPOINT_TOKEN` | Server-only | Optional | Vercel | Protects `/api/debug/env` in production. If unset, the endpoint returns `404` in production. |
| `PORTFOLIO_DEMO_MODE` | Server-only | Optional | Local / Vercel | Enables demo data/auth bypass mode for portfolio/demo screenshots. Keep disabled in production unless intentionally using demo mode. |
| `ALLOW_PRODUCTION_DEMO_MODE` | Server-only | Optional (break-glass only) | Vercel | Required only when intentionally running demo mode in production. If unset while `PORTFOLIO_DEMO_MODE` is enabled in production, startup is blocked. |
-| `ENABLE_PUBLIC_MISSED_CALL_SIMULATOR` | Server-only | Optional | Vercel | Enables the public `/simulator` route. Keep disabled unless the simulator business is intentionally configured. |
-| `SIMULATOR_BUSINESS_ID` | Server-only | Optional | Vercel | Business record used by the public simulator. Must point to an isolated demo workspace, never a real customer business. |
-| `ENABLE_PUBLIC_SIMULATOR_REAL_SMS` | Server-only | Optional | Vercel | Allows the simulator to send real caller-side SMS from the simulator business number when a non-placeholder texting line exists. Keep off by default. |
+| `ENABLE_PUBLIC_MISSED_CALL_SIMULATOR` | Server-only | Optional | Vercel | Legacy/internal simulator backend flag. The current public `/simulator` page does not require it. |
+| `SIMULATOR_BUSINESS_ID` | Server-only | Optional | Vercel | Legacy/internal simulator backend business ID for isolated admin/demo tooling. Never point this at a real customer business. |
+| `ENABLE_PUBLIC_SIMULATOR_REAL_SMS` | Server-only | Optional | Vercel | Legacy/internal simulator backend SMS flag. Keep off by default. The current public `/simulator` page does not send real SMS. |
| `RATE_LIMIT_WINDOW_MS` | Server-only | Optional | Vercel | Shared rate-limit window in milliseconds. Default `60000`. |
| `RATE_LIMIT_TWILIO_AUTH_MAX` | Server-only | Optional | Vercel | Max Twilio webhook requests per window for valid/authorized traffic. Default `240`. |
| `RATE_LIMIT_TWILIO_UNAUTH_MAX` | Server-only | Optional | Vercel | Max Twilio webhook requests per window for unauthorized traffic. Default `40`. |
@@ -68,7 +68,7 @@ The app now validates required server env vars at runtime in production via `lib
- `NEXT_PUBLIC_APP_URL` is the canonical value and should be set explicitly. If it is missing/invalid, the app can temporarily fall back to Vercel system env vars (`VERCEL_URL` / `VERCEL_PROJECT_PRODUCTION_URL`) to avoid auth-page crashes, but webhook/redirect behavior should still use an explicit `NEXT_PUBLIC_APP_URL`.
- `/admin` access depends on either `FOUNDER_CLERK_USER_ID` or `ADMIN_EMAIL_ALLOWLIST`; do not leave admin authorization implicit.
- Owner email alerts are optional, but if you intend to advertise email delivery you must set both `RESEND_API_KEY` and `CALLBACKCLOSER_FROM_EMAIL`.
-- The public simulator is optional and should only point at a dedicated demo workspace via `SIMULATOR_BUSINESS_ID`.
+- The legacy simulator backend should only point at a dedicated demo workspace via `SIMULATOR_BUSINESS_ID`.
## Vercel: Preview vs Production
diff --git a/lib/public-simulator.ts b/lib/public-simulator.ts
new file mode 100644
index 0000000..1cd6a12
--- /dev/null
+++ b/lib/public-simulator.ts
@@ -0,0 +1,378 @@
+import { formatPhoneForDisplay, normalizePhoneNumberToE164 } from '@/lib/phone';
+
+export const publicSimulatorStages = [
+ 'started',
+ 'missed_call_received',
+ 'first_message_shown',
+ 'service_captured',
+ 'urgency_captured',
+ 'owner_alert_ready',
+ 'completed',
+] as const;
+
+export type PublicSimulatorStage = (typeof publicSimulatorStages)[number];
+
+export type PublicSimulatorQuickReply =
+ | 'Repair'
+ | 'Install'
+ | 'Maintenance'
+ | 'Emergency'
+ | 'Today'
+ | 'This week'
+ | 'Just getting a quote';
+
+export type PublicSimulatorService = 'Repair' | 'Install' | 'Maintenance' | 'Emergency' | 'General service';
+
+export type PublicSimulatorUrgency = 'Emergency' | 'Today' | 'This week' | 'Just getting a quote';
+
+export type PublicSimulatorMessage = {
+ id: string;
+ body: string;
+ label: string;
+ kind: 'event' | 'assistant' | 'caller';
+ timestamp: string;
+};
+
+export type PublicSimulatorSession = {
+ callerPhone: string;
+ callerPhoneDisplay: string;
+ callerPhoneMasked: string;
+ completed: boolean;
+ issueSummary: string | null;
+ selectedService: PublicSimulatorService | null;
+ selectedUrgency: PublicSimulatorUrgency | null;
+ stage: PublicSimulatorStage;
+ transcript: PublicSimulatorMessage[];
+};
+
+export const PUBLIC_SIMULATOR_DEMO_PHONE = '+1 (865) 555-0148';
+export const PUBLIC_SIMULATOR_BUSINESS_NAME = 'Northside Home Services';
+
+const SERVICE_REPLY_OPTIONS: PublicSimulatorQuickReply[] = ['Repair', 'Install', 'Maintenance', 'Emergency'];
+const URGENCY_REPLY_OPTIONS: PublicSimulatorQuickReply[] = ['Today', 'This week', 'Just getting a quote'];
+const TIMESTAMP_SEQUENCE = ['2:14 PM', '2:14 PM', '2:15 PM', '2:15 PM', '2:16 PM', '2:16 PM', '2:17 PM', '2:17 PM'];
+
+function nextTimestamp(messageCount: number) {
+ return TIMESTAMP_SEQUENCE[Math.min(messageCount, TIMESTAMP_SEQUENCE.length - 1)];
+}
+
+function appendMessage(
+ session: PublicSimulatorSession,
+ message: Omit,
+): PublicSimulatorSession {
+ const nextMessage: PublicSimulatorMessage = {
+ ...message,
+ id: `sim-message-${session.transcript.length + 1}`,
+ timestamp: nextTimestamp(session.transcript.length),
+ };
+
+ return {
+ ...session,
+ transcript: [...session.transcript, nextMessage],
+ };
+}
+
+function normalizeWhitespace(value: string) {
+ return value.replace(/\s+/g, ' ').trim();
+}
+
+function toSentenceCase(value: string) {
+ const trimmed = normalizeWhitespace(value);
+ if (!trimmed) return '';
+ return trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
+}
+
+function formatCustomReply(value: string) {
+ const trimmed = toSentenceCase(value.replace(/[.!?]+$/, ''));
+ return trimmed || 'Customer asked for help';
+}
+
+export function maskPublicSimulatorPhone(value: string | null | undefined) {
+ const digits = (value || '').replace(/\D/g, '');
+ if (digits.length >= 10) {
+ const lastFour = digits.slice(-4);
+ return `(***) ***-${lastFour}`;
+ }
+
+ if (digits.length >= 4) {
+ return `***-${digits.slice(-4)}`;
+ }
+
+ return 'Private demo caller';
+}
+
+export function getPublicSimulatorQuickReplies(stage: PublicSimulatorStage) {
+ if (stage === 'first_message_shown') return SERVICE_REPLY_OPTIONS;
+ if (stage === 'service_captured') return URGENCY_REPLY_OPTIONS;
+ return [];
+}
+
+export function canReplyToPublicSimulator(stage: PublicSimulatorStage) {
+ return stage === 'first_message_shown' || stage === 'service_captured';
+}
+
+export function startPublicSimulatorSession(phoneInput: string): PublicSimulatorSession | null {
+ const normalized = normalizePhoneNumberToE164(phoneInput) || normalizeWhitespace(phoneInput);
+ if (!normalized) return null;
+
+ const display = formatPhoneForDisplay(normalized);
+ const masked = maskPublicSimulatorPhone(normalized);
+
+ return {
+ callerPhone: normalized,
+ callerPhoneDisplay: display,
+ callerPhoneMasked: masked,
+ completed: false,
+ issueSummary: null,
+ selectedService: null,
+ selectedUrgency: null,
+ stage: 'started',
+ transcript: [
+ {
+ id: 'sim-message-1',
+ body: `Private demo run started for ${masked}. No real SMS will be sent.`,
+ kind: 'event',
+ label: 'Interactive preview mode',
+ timestamp: nextTimestamp(0),
+ },
+ ],
+ };
+}
+
+export function advancePublicSimulatorSession(session: PublicSimulatorSession): PublicSimulatorSession {
+ if (session.stage === 'started') {
+ return appendMessage(
+ {
+ ...session,
+ stage: 'missed_call_received',
+ },
+ {
+ body: `${PUBLIC_SIMULATOR_BUSINESS_NAME} missed a call from ${session.callerPhoneMasked}. CallbackCloser opened a private demo run for this visitor only.`,
+ kind: 'event',
+ label: 'Missed call received',
+ },
+ );
+ }
+
+ if (session.stage === 'missed_call_received') {
+ return appendMessage(
+ {
+ ...session,
+ stage: 'first_message_shown',
+ },
+ {
+ body: 'Sorry we missed your call. What do you need help with today: repair, install, maintenance, or emergency service?',
+ kind: 'assistant',
+ label: 'CallbackCloser',
+ },
+ );
+ }
+
+ if (session.stage === 'urgency_captured') {
+ return appendMessage(
+ {
+ ...session,
+ stage: 'owner_alert_ready',
+ },
+ {
+ body: 'Qualified lead summary ready. The business would now get the alert, full transcript, and callback context without any real delivery leaving this page.',
+ kind: 'event',
+ label: 'Owner alert ready',
+ },
+ );
+ }
+
+ if (session.stage === 'owner_alert_ready') {
+ return {
+ ...session,
+ completed: true,
+ stage: 'completed',
+ };
+ }
+
+ return session;
+}
+
+function inferService(value: string): PublicSimulatorService {
+ const normalized = normalizeWhitespace(value).toLowerCase();
+
+ if (
+ normalized.includes('emergency') ||
+ normalized.includes('urgent') ||
+ normalized.includes('asap') ||
+ normalized.includes('no heat') ||
+ normalized.includes('no cool') ||
+ normalized.includes('not working') ||
+ normalized.includes('stopped working')
+ ) {
+ return 'Emergency';
+ }
+
+ if (
+ normalized.includes('install') ||
+ normalized.includes('replace') ||
+ normalized.includes('replacement') ||
+ normalized.includes('new unit')
+ ) {
+ return 'Install';
+ }
+
+ if (
+ normalized.includes('maintenance') ||
+ normalized.includes('tune') ||
+ normalized.includes('service plan') ||
+ normalized.includes('checkup')
+ ) {
+ return 'Maintenance';
+ }
+
+ if (
+ normalized.includes('repair') ||
+ normalized.includes('fix') ||
+ normalized.includes('leak') ||
+ normalized.includes('diagnostic') ||
+ normalized.includes('diagnosis')
+ ) {
+ return 'Repair';
+ }
+
+ return 'General service';
+}
+
+function inferUrgency(value: string): PublicSimulatorUrgency {
+ const normalized = normalizeWhitespace(value).toLowerCase();
+
+ if (
+ normalized.includes('emergency') ||
+ normalized.includes('urgent') ||
+ normalized.includes('asap') ||
+ normalized.includes('now') ||
+ normalized.includes('immediately')
+ ) {
+ return 'Emergency';
+ }
+
+ if (normalized.includes('today') || normalized.includes('this afternoon') || normalized.includes('tonight')) {
+ return 'Today';
+ }
+
+ if (
+ normalized.includes('quote') ||
+ normalized.includes('estimate') ||
+ normalized.includes('pricing') ||
+ normalized.includes('price')
+ ) {
+ return 'Just getting a quote';
+ }
+
+ if (
+ normalized.includes('week') ||
+ normalized.includes('later') ||
+ normalized.includes('next') ||
+ normalized.includes('schedule')
+ ) {
+ return 'This week';
+ }
+
+ return 'Today';
+}
+
+function summarizeIssue(service: PublicSimulatorService, rawReply: string) {
+ const normalized = normalizeWhitespace(rawReply);
+ if (!normalized) return 'Customer asked for service help';
+
+ if (SERVICE_REPLY_OPTIONS.includes(normalized as PublicSimulatorQuickReply)) {
+ if (service === 'Emergency') return 'Emergency service request';
+ if (service === 'Install') return 'New install request';
+ if (service === 'Maintenance') return 'Maintenance visit request';
+ if (service === 'Repair') return 'Repair request';
+ }
+
+ return formatCustomReply(normalized);
+}
+
+export function applyPublicSimulatorReply(session: PublicSimulatorSession, reply: string): PublicSimulatorSession {
+ const trimmedReply = normalizeWhitespace(reply);
+ if (!trimmedReply || !canReplyToPublicSimulator(session.stage)) {
+ return session;
+ }
+
+ if (session.stage === 'first_message_shown') {
+ const selectedService = inferService(trimmedReply);
+ const issueSummary = summarizeIssue(selectedService, trimmedReply);
+
+ const withReply = appendMessage(session, {
+ body: trimmedReply,
+ kind: 'caller',
+ label: session.callerPhoneMasked,
+ });
+
+ return appendMessage(
+ {
+ ...withReply,
+ issueSummary,
+ selectedService,
+ stage: 'service_captured',
+ },
+ {
+ body: 'Thanks. How urgent is this: today, this week, or are you just getting a quote?',
+ kind: 'assistant',
+ label: 'CallbackCloser',
+ },
+ );
+ }
+
+ const selectedUrgency = inferUrgency(trimmedReply);
+ const withReply = appendMessage(session, {
+ body: trimmedReply,
+ kind: 'caller',
+ label: session.callerPhoneMasked,
+ });
+
+ return appendMessage(
+ {
+ ...withReply,
+ selectedUrgency,
+ stage: 'urgency_captured',
+ },
+ {
+ body: 'Perfect. CallbackCloser has enough detail to build the lead summary and owner alert preview.',
+ kind: 'assistant',
+ label: 'CallbackCloser',
+ },
+ );
+}
+
+export function buildPublicSimulatorLeadSummary(session: PublicSimulatorSession) {
+ const service = session.selectedService || 'Pending';
+ const urgency = session.selectedUrgency || 'Pending';
+ const issue = session.issueSummary || 'Waiting for caller details';
+
+ return {
+ callbackWindow:
+ urgency === 'Emergency'
+ ? 'Call immediately'
+ : urgency === 'Today'
+ ? 'Call back today'
+ : urgency === 'This week'
+ ? 'Schedule this week'
+ : 'Follow up with pricing details',
+ callerPhone: session.callerPhoneMasked,
+ issue,
+ service,
+ status: session.stage === 'completed' ? 'Ready for callback' : 'Qualifying lead',
+ urgency,
+ };
+}
+
+export function buildPublicSimulatorOwnerAlert(session: PublicSimulatorSession) {
+ const leadSummary = buildPublicSimulatorLeadSummary(session);
+
+ return {
+ body: `New missed-call lead: ${leadSummary.service} request from ${leadSummary.callerPhone}. ${leadSummary.issue}. Urgency: ${leadSummary.urgency}. ${leadSummary.callbackWindow}. Demo only - no real SMS sent.`,
+ headline: 'New missed-call lead',
+ leadSummary,
+ note: 'This owner alert is rendered on-page only for the public simulator.',
+ subject: `${PUBLIC_SIMULATOR_BUSINESS_NAME}: ${leadSummary.service} lead ready`,
+ };
+}
diff --git a/tests/favicon-route.test.ts b/tests/favicon-route.test.ts
new file mode 100644
index 0000000..de0047d
--- /dev/null
+++ b/tests/favicon-route.test.ts
@@ -0,0 +1,8 @@
+import assert from 'node:assert/strict';
+import { existsSync } from 'node:fs';
+import path from 'node:path';
+import test from 'node:test';
+
+test('app icon exists so public pages do not rely on a missing default favicon', () => {
+ assert.equal(existsSync(path.join(process.cwd(), 'app/icon.svg')), true);
+});
diff --git a/tests/public-demo-route.test.ts b/tests/public-demo-route.test.ts
index 4bd13f6..e0cc82a 100644
--- a/tests/public-demo-route.test.ts
+++ b/tests/public-demo-route.test.ts
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
-import { readFileSync } from 'node:fs';
+import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import test from 'node:test';
@@ -10,22 +10,25 @@ function read(relativePath: string) {
test('public demo route is auth-free and built from isolated demo data', () => {
const demoPage = read('app/demo/page.tsx');
const simulatorPage = read('app/simulator/page.tsx');
- const simulatorActions = read('app/simulator/actions.ts');
+ const simulatorExperience = read('components/demo/public-simulator-experience.tsx');
const demoData = read('lib/demo-data.ts');
const middleware = read('middleware.ts');
assert.match(demoPage, /Live Product Demo \| CallbackCloser/);
assert.match(demoPage, /PublicDemoReplay/);
assert.match(demoPage, /Stop losing jobs when you miss the call/i);
+ assert.match(demoPage, /Try the interactive simulator/);
assert.match(demoPage, /This is exactly what your customer sees after you miss their call/i);
assert.match(demoPage, /Demo only: fake business, fake callers, and no live customer or Twilio data\./);
assert.match(demoData, /No login, no real customer data, no live Twilio traffic/i);
assert.match(demoPage, /from ['"]@\/lib\/demo-data['"]/);
assert.doesNotMatch(demoPage, /requireBusiness|getBusinessForOwnerClerkId|db\./);
- assert.match(simulatorPage, /PUBLIC_START_FREE_PILOT_PATH/);
- assert.match(simulatorPage, /PUBLIC_CREATE_ACCOUNT_PATH/);
- assert.doesNotMatch(simulatorPage, /ENABLE_PUBLIC_SIMULATOR_REAL_SMS|SIMULATOR_BUSINESS_ID/);
- assert.doesNotMatch(simulatorActions, /ENABLE_PUBLIC_SIMULATOR_REAL_SMS/);
+ assert.match(simulatorExperience, /PUBLIC_START_FREE_PILOT_PATH/);
+ assert.match(simulatorExperience, /Start 14-Day Pilot/);
+ assert.match(demoPage, /href="\/simulator"/);
+ assert.doesNotMatch(simulatorPage, /ENABLE_PUBLIC_SIMULATOR_REAL_SMS|SIMULATOR_BUSINESS_ID|db\./);
+ assert.doesNotMatch(simulatorExperience, /ENABLE_PUBLIC_SIMULATOR_REAL_SMS|SIMULATOR_BUSINESS_ID|db\.|startMissedCallRecovery/);
+ assert.equal(existsSync(path.join(process.cwd(), 'app/simulator/actions.ts')), false);
assert.doesNotMatch(middleware, /\/demo\(.\*\)/);
assert.match(demoData, /Jamie Carter/);
assert.match(demoData, /New HVAC lead/);
diff --git a/tests/public-simulator-flow.test.ts b/tests/public-simulator-flow.test.ts
new file mode 100644
index 0000000..67362fa
--- /dev/null
+++ b/tests/public-simulator-flow.test.ts
@@ -0,0 +1,64 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ advancePublicSimulatorSession,
+ applyPublicSimulatorReply,
+ buildPublicSimulatorOwnerAlert,
+ canReplyToPublicSimulator,
+ getPublicSimulatorQuickReplies,
+ maskPublicSimulatorPhone,
+ startPublicSimulatorSession,
+} from '@/lib/public-simulator';
+
+test('public simulator starts with a masked private run and auto-advances into the first text', () => {
+ const startedSession = startPublicSimulatorSession('+1 (865) 555-0148');
+ assert.ok(startedSession);
+ assert.equal(startedSession.stage, 'started');
+ assert.equal(startedSession.callerPhoneMasked, '(***) ***-0148');
+ assert.match(startedSession.transcript[0]?.body ?? '', /No real SMS will be sent/);
+
+ const missedCallSession = advancePublicSimulatorSession(startedSession);
+ assert.equal(missedCallSession.stage, 'missed_call_received');
+ assert.match(missedCallSession.transcript[1]?.body ?? '', /missed a call/);
+
+ const firstMessageSession = advancePublicSimulatorSession(missedCallSession);
+ assert.equal(firstMessageSession.stage, 'first_message_shown');
+ assert.match(firstMessageSession.transcript[2]?.body ?? '', /What do you need help with today/);
+ assert.deepEqual(getPublicSimulatorQuickReplies(firstMessageSession.stage), ['Repair', 'Install', 'Maintenance', 'Emergency']);
+ assert.equal(canReplyToPublicSimulator(firstMessageSession.stage), true);
+});
+
+test('public simulator captures service and urgency, then prepares the owner alert preview', () => {
+ const startedSession = startPublicSimulatorSession('+1 (865) 555-0199');
+ assert.ok(startedSession);
+
+ const firstMessageSession = advancePublicSimulatorSession(advancePublicSimulatorSession(startedSession));
+ const serviceCapturedSession = applyPublicSimulatorReply(firstMessageSession, 'AC repair, the unit is not cooling');
+ assert.equal(serviceCapturedSession.stage, 'service_captured');
+ assert.equal(serviceCapturedSession.selectedService, 'Repair');
+ assert.match(serviceCapturedSession.issueSummary ?? '', /AC repair, the unit is not cooling/i);
+ assert.deepEqual(getPublicSimulatorQuickReplies(serviceCapturedSession.stage), ['Today', 'This week', 'Just getting a quote']);
+
+ const urgencyCapturedSession = applyPublicSimulatorReply(serviceCapturedSession, 'Today');
+ assert.equal(urgencyCapturedSession.stage, 'urgency_captured');
+ assert.equal(urgencyCapturedSession.selectedUrgency, 'Today');
+
+ const ownerAlertReadySession = advancePublicSimulatorSession(urgencyCapturedSession);
+ assert.equal(ownerAlertReadySession.stage, 'owner_alert_ready');
+
+ const completedSession = advancePublicSimulatorSession(ownerAlertReadySession);
+ assert.equal(completedSession.stage, 'completed');
+ assert.equal(completedSession.completed, true);
+
+ const ownerAlert = buildPublicSimulatorOwnerAlert(completedSession);
+ assert.match(ownerAlert.subject, /Northside Home Services: Repair lead ready/);
+ assert.match(ownerAlert.body, /Demo only - no real SMS sent/);
+ assert.match(ownerAlert.body, /\(\*\*\*\) \*\*\*-0199/);
+});
+
+test('public simulator phone masking avoids showing the full caller number', () => {
+ assert.equal(maskPublicSimulatorPhone('+18655550148'), '(***) ***-0148');
+ assert.equal(maskPublicSimulatorPhone('5550148'), '***-0148');
+ assert.equal(maskPublicSimulatorPhone(''), 'Private demo caller');
+});
diff --git a/tests/simulator-isolation.test.ts b/tests/simulator-isolation.test.ts
index 4368a13..4102a5b 100644
--- a/tests/simulator-isolation.test.ts
+++ b/tests/simulator-isolation.test.ts
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
-import { readFileSync } from 'node:fs';
+import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import test from 'node:test';
@@ -7,21 +7,20 @@ function read(relativePath: string) {
return readFileSync(path.join(process.cwd(), relativePath), 'utf8');
}
-test('public simulator is gated and isolated from real customer delivery', () => {
+test('public simulator is isolated from real customer delivery and runs entirely in demo logic', () => {
const simulatorPage = read('app/simulator/page.tsx');
- const simulatorActions = read('app/simulator/actions.ts');
- const simulatorLib = read('lib/simulator.ts');
+ const simulatorExperience = read('components/demo/public-simulator-experience.tsx');
+ const publicSimulatorLib = read('lib/public-simulator.ts');
const ownerNotifications = read('lib/owner-notifications.ts');
const schema = read('prisma/schema.prisma');
- assert.match(simulatorPage, /Missed-call simulator/);
- assert.match(simulatorActions, /isPublicSimulatorEnabled/);
- assert.match(simulatorLib, /SIMULATOR_BUSINESS_ID/);
- assert.match(simulatorLib, /ENABLE_PUBLIC_MISSED_CALL_SIMULATOR/);
- assert.match(simulatorLib, /ENABLE_PUBLIC_SIMULATOR_REAL_SMS/);
- assert.match(simulatorLib, /canSendRealSimulatorSms/);
- assert.match(simulatorActions, /isSimulator:\s*true/);
- assert.match(simulatorActions, /Preview mode active\./);
+ assert.match(simulatorExperience, /Interactive preview mode/);
+ assert.match(publicSimulatorLib, /publicSimulatorStages/);
+ assert.match(publicSimulatorLib, /No real SMS will be sent\./);
+ assert.match(publicSimulatorLib, /maskPublicSimulatorPhone/);
+ assert.doesNotMatch(simulatorPage, /getSimulatorRun|startSimulatorRunAction|replyToSimulatorRunAction|db\./);
+ assert.doesNotMatch(simulatorExperience, /db\.|twilio|startMissedCallRecovery|processLeadInboundReply/);
+ assert.equal(existsSync(path.join(process.cwd(), 'app/simulator/actions.ts')), false);
assert.match(ownerNotifications, /if \(lead\.isSimulator\)/);
assert.match(schema, /isSimulator\s+Boolean\s+@default\(false\)/);
assert.match(schema, /model SimulatorRun/);
diff --git a/tests/simulator-route.test.ts b/tests/simulator-route.test.ts
index 658eddc..8cae0c6 100644
--- a/tests/simulator-route.test.ts
+++ b/tests/simulator-route.test.ts
@@ -13,16 +13,20 @@ test('homepage and nav both point See Demo directly to /demo', () => {
assert.match(home, /href="\/demo"/);
assert.match(nav, /href: '\/demo'/);
+ assert.match(nav, /href: '\/simulator'/);
});
-test('simulator route is a distinct public page with its own metadata and disabled-state copy', () => {
+test('simulator route is a distinct public page with a safe interactive preview', () => {
const simulatorPage = read('app/simulator/page.tsx');
+ const simulatorExperience = read('components/demo/public-simulator-experience.tsx');
const middleware = read('middleware.ts');
assert.match(simulatorPage, /Missed-Call Simulator \| CallbackCloser/);
- assert.match(simulatorPage, /See the full CallbackCloser lead loop in minutes/);
- assert.match(simulatorPage, /The public simulator is not configured on this environment yet\./);
- assert.match(simulatorPage, /Preview mode is active/);
- assert.match(simulatorPage, /Real SMS mode is active/);
+ assert.match(simulatorPage, /PublicSimulatorExperience/);
+ assert.match(simulatorExperience, /Interactive preview mode/);
+ assert.match(simulatorExperience, /No real SMS will be sent in this demo/);
+ assert.match(simulatorExperience, /See how CallbackCloser would recover a missed call/);
+ assert.match(simulatorExperience, /Start 14-Day Pilot/);
+ assert.doesNotMatch(simulatorExperience, /Demo number unavailable|not configured on this environment yet|Real SMS mode is active/);
assert.doesNotMatch(middleware, /\/simulator\(.\*\)/);
});