From f84fbefe38c1da2607796d537539d2c77466f048 Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Wed, 27 May 2026 14:52:01 -0400 Subject: [PATCH] Managed customer setup handoff and pricing cleanup --- app/(auth)/sign-up/[[...sign-up]]/page.tsx | 14 +- app/admin/[businessId]/page.tsx | 16 + app/admin/actions.ts | 2 + app/admin/page.tsx | 9 +- app/api/stripe/checkout/route.ts | 2 +- app/app/layout.tsx | 103 +++-- app/app/onboarding/page.tsx | 249 +---------- app/buy/page.tsx | 2 +- app/contact/page.tsx | 6 +- app/page.tsx | 456 ++++++--------------- app/pricing/page.tsx | 182 ++++---- app/simulator/page.tsx | 2 +- components/customer-setup-waiting-page.tsx | 98 +++++ components/public-site-footer.tsx | 4 +- components/public-site-nav.tsx | 2 +- lib/admin-dashboard.ts | 14 +- lib/auth.ts | 23 +- lib/customer-setup-handoff.ts | 243 +++++++++++ lib/customer-setup.ts | 58 +++ lib/public-auth-routing.ts | 4 +- tests/legal-pages.test.ts | 4 +- tests/managed-customer-setup.test.ts | 66 +++ tests/pilot-safety.test.ts | 40 +- tests/public-auth-routing.test.ts | 9 +- 24 files changed, 858 insertions(+), 750 deletions(-) create mode 100644 components/customer-setup-waiting-page.tsx create mode 100644 lib/customer-setup-handoff.ts create mode 100644 lib/customer-setup.ts create mode 100644 tests/managed-customer-setup.test.ts diff --git a/app/(auth)/sign-up/[[...sign-up]]/page.tsx b/app/(auth)/sign-up/[[...sign-up]]/page.tsx index 05d6adb..e809dfd 100644 --- a/app/(auth)/sign-up/[[...sign-up]]/page.tsx +++ b/app/(auth)/sign-up/[[...sign-up]]/page.tsx @@ -15,18 +15,18 @@ import { resolveSignedInAppDestination } from '@/lib/public-auth-routing'; function getIntentCopy(intent: string | undefined) { if (intent === 'pilot') { return { - label: 'Start Free Pilot', - title: 'Create your account and start pilot onboarding', + label: 'Start 14-day pilot', + title: 'Create your account and start your 14-day pilot', detail: - 'This path is for a business owner creating a new CallbackCloser account. If you are already signed in, CallbackCloser will send you to onboarding, your dashboard, or the admin new-business flow based on your role.', + 'Create your CallbackCloser account here. Once you are in, we create your workspace, handle the setup for you, and notify you when your Lead Recovery Command Center is ready.', }; } return { label: 'Create Account', title: 'Create your CallbackCloser account', - detail: - 'Create a new owner account here. Founder-operated customer pilot setup is separate and stays inside the admin new-business flow.', + detail: + 'Create a new owner account here. CallbackCloser handles setup for you, then unlocks your workspace when everything is ready.', }; } @@ -46,7 +46,7 @@ export default async function SignUpPage({

{copy.title}

{copy.detail}

- Existing users should sign in. CallbackCloser operators setting up a customer pilot should use the admin new-business flow, not public signup. + Existing users should sign in. We keep customer setup managed, so you do not need to configure the phone or messaging system yourself.

@@ -86,7 +86,7 @@ export default async function SignUpPage({

{copy.title}

{copy.detail}

- Existing users should sign in. CallbackCloser operators setting up a customer pilot should use the admin new-business flow, not public signup. + Existing users should sign in. We keep customer setup managed, so you do not need to configure the phone or messaging system yourself.

diff --git a/app/admin/[businessId]/page.tsx b/app/admin/[businessId]/page.tsx index 04504d1..367c880 100644 --- a/app/admin/[businessId]/page.tsx +++ b/app/admin/[businessId]/page.tsx @@ -31,6 +31,7 @@ import { Select } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { buildAdminCustomerOpenHref } from '@/lib/admin-customer-paths'; import { buildAdminOnboardingConfidence, canDeleteTestBusiness, getDeleteTestBusinessBlockedReason, isBusinessArchived } from '@/lib/admin-dashboard'; +import { customerSetupStatusLabels, shouldShowCustomerSetupWaitingPage } from '@/lib/customer-setup'; import { buildAdminMissedCallValidationTruth, buildAdminOperationalProofs } from '@/lib/admin-operator-proof'; import { buildAdminNextStepGuide, buildAdminSetupPanels } from '@/lib/admin-setup-remediation'; import { @@ -367,6 +368,7 @@ export default async function AdminBusinessDetailPage({ const nextStep = getStepByKey(setupFlow.steps, nextStepGuide.key); const nextStepHref = buildStepPath(business.id, nextStepGuide.key, timelineFilter, activityExpanded); const selectedStep = getStepByKey(setupFlow.steps, selectedStepKey); + const showPendingSetupBanner = shouldShowCustomerSetupWaitingPage(business.provisioningStatus); const created = getQueryValue(searchParams, 'created') === '1'; const saved = getQueryValue(searchParams, 'saved') === '1'; @@ -1136,6 +1138,20 @@ export default async function AdminBusinessDetailPage({ {liveAcknowledged === 'warnings' ? 'Business marked live with explicit warning acknowledgment.' : 'Business marked live after launch checks.'}
) : null} + {showPendingSetupBanner ? ( + + + This business is waiting for founder setup + + The owner should only see the setup-in-progress page until you finish the launch checklist and mark the workspace live. + + + + {customerSetupStatusLabels[business.provisioningStatus]} + Next action: {onboardingConfidence.nextAction} + + + ) : null} {archived ?
Business archived safely. Automation is paused.
: null} {restored ?
Business restored and ready for review.
: null} {error ?
{error}
: null} diff --git a/app/admin/actions.ts b/app/admin/actions.ts index 53c842d..0397899 100644 --- a/app/admin/actions.ts +++ b/app/admin/actions.ts @@ -45,6 +45,7 @@ import { import { logAuditEvent } from '@/lib/audit-log'; import { buildAdminTestSmsTruth, buildTwilioSetupUpdateEventMetadata } from '@/lib/admin-operator-visibility'; import { deriveTwilioNumberSetupModeFromPhoneSetupPath } from '@/lib/business-phone-setup'; +import { sendCustomerReadyNotification } from '@/lib/customer-setup-handoff'; import { db } from '@/lib/db'; import { formatPhoneDetail, listBusinessOperatorEvents, maskSid, recordBusinessOperatorEvent } from '@/lib/operator-events'; import { maskPhoneForAudit, normalizePhoneNumber, normalizePhoneNumberToE164 } from '@/lib/phone'; @@ -1882,6 +1883,7 @@ export async function markBusinessLiveAction(formData: FormData) { } await updateBusinessProvisioningStatus(business.id, BusinessProvisioningStatus.LIVE, null); + await sendCustomerReadyNotification(business.id); await recordBusinessOperatorEvent({ businessId: business.id, type: confidence.canSafelyMarkLive ? 'admin.go_live_marked_safe' : 'admin.go_live_marked_with_warnings', diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 1fbc7a2..7acd775 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,4 +1,5 @@ import Link from 'next/link'; +import { BusinessProvisioningStatus } from '@prisma/client'; import { archiveBusinessAction, @@ -479,10 +480,10 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor }); const summaryStats = [ + { label: 'Pending setup', value: businessRows.filter((row) => row.business.provisioningStatus === BusinessProvisioningStatus.DRAFT).length }, + { label: 'In setup', value: businessRows.filter((row) => row.business.provisioningStatus === BusinessProvisioningStatus.ONBOARDING).length }, { label: 'Needs attention', value: businessRows.filter((row) => row.onboardingConfidence.state === 'needs_attention').length }, - { label: 'Waiting on compliance', value: businessRows.filter((row) => row.onboardingConfidence.state === 'waiting_on_a2p').length }, { label: 'Ready for live', value: businessRows.filter((row) => row.onboardingConfidence.state === 'ready_to_go_live').length }, - { label: 'Live with warnings', value: businessRows.filter((row) => row.onboardingConfidence.state === 'live_with_warnings').length }, ]; const founderResetBusinessCount = businessPickerOptions.length; const founderResetBusinessPreview = businessPickerOptions.slice(0, 4).map((business) => business.name); @@ -548,7 +549,7 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor ) : null} {setupIntent === 'new-business-pilot' ? (
- Customer pilot setup starts here. Public signup is for business owners creating their own account; founder/operator setup for a real customer stays in this admin new-business flow. + New public pilot signups land here waiting for founder setup. Finish the owner details, launch prep, and go-live checks before the customer sees the full workspace.
) : null} @@ -977,6 +978,8 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor {business.name} {overallStatus.label} + {business.provisioningStatus === BusinessProvisioningStatus.DRAFT ? Pending setup : null} + {business.provisioningStatus === BusinessProvisioningStatus.ONBOARDING ? In setup : null} {onboardingConfidence.stateLabel} {business.isTestBusiness ? Test : null} {isBusinessArchived(business) ? Archived : null} diff --git a/app/api/stripe/checkout/route.ts b/app/api/stripe/checkout/route.ts index baa991b..457a09a 100644 --- a/app/api/stripe/checkout/route.ts +++ b/app/api/stripe/checkout/route.ts @@ -31,7 +31,7 @@ export async function POST(request: Request) { const business = await db.business.findUnique({ where: { ownerClerkId: userId } }); if (!business) { - return withCorrelation(NextResponse.redirect(absoluteUrl('/app/onboarding'), { status: 303 })); + return withCorrelation(NextResponse.redirect(absoluteUrl('/app'), { status: 303 })); } const formData = await request.formData(); diff --git a/app/app/layout.tsx b/app/app/layout.tsx index 0c23b95..141e9ed 100644 --- a/app/app/layout.tsx +++ b/app/app/layout.tsx @@ -1,10 +1,15 @@ -import { auth } from '@clerk/nextjs/server'; +import { auth, currentUser } from '@clerk/nextjs/server'; import { redirect } from 'next/navigation'; import Link from 'next/link'; import { AppNav } from '@/components/app-nav'; +import { CustomerSetupWaitingPage } from '@/components/customer-setup-waiting-page'; +import { Badge } from '@/components/ui/badge'; +import { getAdminSession } from '@/lib/admin'; import { getAdminCustomerActingContext } from '@/lib/admin-customer-context'; import { buildAdminCustomerExitHref } from '@/lib/admin-customer-paths'; +import { ensurePendingBusinessForOwner } from '@/lib/customer-setup-handoff'; +import { getCustomerWorkspaceNotice, shouldShowCustomerSetupWaitingPage } from '@/lib/customer-setup'; import { db } from '@/lib/db'; import { getPortfolioDemoBusiness, isPortfolioDemoMode } from '@/lib/portfolio-demo'; import { getCustomerSystemStatus } from '@/lib/system-status'; @@ -49,43 +54,83 @@ export default async function AppLayout({ children }: { children: React.ReactNod redirect('/sign-in'); } - const business = adminCustomerContext + const adminSession = await getAdminSession(); + + const existingBusiness = adminCustomerContext ? adminCustomerContext.business : await db.business.findUnique({ where: { ownerClerkId: userId } }); + if (!adminCustomerContext && adminSession?.isAdmin && !existingBusiness) { + redirect('/admin?intent=new-business-pilot'); + } + const business = + existingBusiness || + (adminCustomerContext + ? null + : await (async () => { + const user = await currentUser(); + const ownerEmail = + (user?.primaryEmailAddressId + ? user.emailAddresses.find((email) => email.id === user.primaryEmailAddressId)?.emailAddress + : user?.emailAddresses[0]?.emailAddress) || null; + + return ensurePendingBusinessForOwner(userId, { + businessName: typeof user?.publicMetadata?.businessName === 'string' ? user.publicMetadata.businessName : null, + ownerEmail, + ownerName: user?.fullName || [user?.firstName, user?.lastName].filter(Boolean).join(' ') || null, + }); + })()); const successfulLeadCount = business ? await db.lead.count({ where: { businessId: business.id, OR: [{ ownerNotifiedAt: { not: null } }, { notifiedAt: { not: null } }] } }) : 0; const systemStatus = business ? getCustomerSystemStatus(business, successfulLeadCount) : null; const dashboardStatus = systemStatus ? getDashboardStatusPresentation(systemStatus) : null; + const workspaceNotice = business ? getCustomerWorkspaceNotice(business.provisioningStatus) : null; + const showWaitingPage = business ? shouldShowCustomerSetupWaitingPage(business.provisioningStatus) : false; return ( -
- - {adminCustomerContext && business ? ( -
-
-
-

Admin customer mode

-

- You are using the real customer pages for {business.name}. -

-
-
- - Back to operator controls - - - Exit customer mode - -
+
+ + {adminCustomerContext && business ? ( +
+
+
+

Admin customer mode

+

+ You are using the real customer pages for {business.name}. +

+
+
+ + Back to operator controls + + + Exit customer mode +
- ) : null} -
{children}
-
- ); +
+ ) : null} +
+ {showWaitingPage && business ? ( + + ) : ( + <> + {workspaceNotice ? ( +
+
+ {workspaceNotice.title} +

{workspaceNotice.detail}

+
+
+ ) : null} + {children} + + )} +
+
+ ); } diff --git a/app/app/onboarding/page.tsx b/app/app/onboarding/page.tsx index 9445dd8..c837ee0 100644 --- a/app/app/onboarding/page.tsx +++ b/app/app/onboarding/page.tsx @@ -1,250 +1,5 @@ -import { auth } from '@clerk/nextjs/server'; import { redirect } from 'next/navigation'; -import { - BusinessPhoneSetupPath, - BusinessProvisioningStatus, - ForwardedCallAnswerMode, - ForwardingVerificationStatus, - ManagedTwilioStatus, - MessagingSetupMode, - MessagingComplianceType, - PortingStatus, - TollFreeVerificationStatus, - TwilioAccountMode, -} from '@prisma/client'; -import { saveOnboardingAction } from '@/app/app/onboarding/actions'; -import { TwilioSetupChecklist } from '@/components/twilio-setup-checklist'; -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 { db } from '@/lib/db'; -import { buildTwilioSetupFlow, businessPhonePathOptions, twilioAccountModeOptions } from '@/lib/twilio-setup'; - -const DEFAULT_POST_ONBOARDING_REDIRECT = '/app/settings'; - -function resolveSafeNextPath(value: string | undefined) { - const nextPath = value?.trim(); - if (!nextPath || !nextPath.startsWith('/') || nextPath.startsWith('//')) { - return DEFAULT_POST_ONBOARDING_REDIRECT; - } - - if (nextPath === '/app') return DEFAULT_POST_ONBOARDING_REDIRECT; - if (!nextPath.startsWith('/app/')) return DEFAULT_POST_ONBOARDING_REDIRECT; - return nextPath; -} - -export default async function OnboardingPage({ - searchParams, -}: { - searchParams?: Record; -}) { - const { userId } = await auth(); - if (!userId) redirect('/sign-in'); - - const existing = await db.business.findUnique({ where: { ownerClerkId: userId } }); - if (existing) redirect('/app/leads'); - const error = typeof searchParams?.error === 'string' ? searchParams.error : undefined; - const nextPath = resolveSafeNextPath(typeof searchParams?.next === 'string' ? searchParams.next : undefined); - - const setupPreviewFlow = buildTwilioSetupFlow({ - business: { - name: 'New business workspace', - publicBusinessPhone: null, - notifyPhone: null, - forwardingNumber: '', - provisioningStatus: BusinessProvisioningStatus.DRAFT, - twilioAccountMode: TwilioAccountMode.BUSINESS_SUBACCOUNT, - phoneSetupPath: BusinessPhoneSetupPath.CURRENT_NUMBER_FORWARDING, - forwardedCallAnswerMode: ForwardedCallAnswerMode.PRESS_1_REQUIRED, - messagingSetupMode: MessagingSetupMode.PER_BUSINESS_TWILIO, - twilioNumberSetupMode: 'NEW_NUMBER', - twilioSubaccountSid: null, - twilioMessagingServiceSid: null, - twilioPrimaryNumberSid: null, - twilioPhoneNumberSid: null, - twilioPrimaryPhoneNumber: null, - twilioPhoneNumber: null, - twilioWebhookSyncedAt: null, - forwardingVerificationStatus: ForwardingVerificationStatus.NOT_STARTED, - forwardingVerifiedAt: null, - forwardingVerificationNote: null, - portingStatus: PortingStatus.NOT_STARTED, - portingNotes: null, - portingCompletedAt: null, - messagingComplianceType: MessagingComplianceType.UNKNOWN, - managedTwilioStatus: ManagedTwilioStatus.DRAFT, - a2pCustomerProfileSid: null, - a2pBrandSid: null, - a2pCampaignSid: null, - a2pFailureReason: null, - a2pApprovedAt: null, - tollFreeVerificationStatus: TollFreeVerificationStatus.NOT_STARTED, - tollFreeVerificationSid: null, - tollFreeVerificationNote: null, - }, - notificationSettings: null, - ownerConnected: true, - successfulLeadCount: 0, - testSmsState: 'not_started', - webhookSnapshot: null, - }); - - return ( -
-
- Activation -
-

Create your business workspace

-

- Start with the business details and number-connection path here. After you save, CallbackCloser opens the same shared setup flow used later for ongoing business control, without auto-provisioning ahead of your chosen account mode. -

-
-
- - - Start setup - - } - steps={setupPreviewFlow.steps} - /> - - - - What happens after this form - Reduce onboarding drag by carrying your number-connection choices directly into the guided control panel. - - -
1. The shared setup flow opens with your chosen account mode and business-number path already saved for this business.
-
2. Nothing gets auto-provisioned before you can review Messaging Service, number assignment, webhooks, and honest A2P status in plain English.
-
3. You send the test SMS, run the missed-call validation, and only mark live after the checklist is actually clear.
-
-
- - - - Business profile and defaults - Set the core business details so we can get your missed-call coverage live fast. - - - {error ? ( -
- {error} -
- ) : null} -
- - - -
-
-

Twilio account mode

-

Choose the account context before CallbackCloser provisions anything for this business.

-
-
- {twilioAccountModeOptions.map((option) => ( - - ))} -
-
-
-
-

Connect your business number

-

Choose whether you want to keep your current public number, port it later, or start with a new CallbackCloser number.

-
-
- {businessPhonePathOptions.map((option) => ( - - ))} -
-

- Porting is tracked manually for now. CallbackCloser still preserves the managed new-number path when that is the right fit. -

-
-
- - -
-
- - -

For current-number forwarding or porting, this is the number customers already call.

-
-
- - -

When CallbackCloser rings a live call through, this is the phone that should answer.

-
-
- - -

Recommended. Ready-to-close lead summaries are sent here.

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
-
-
-
- ); +export default function OnboardingPage() { + redirect('/app'); } diff --git a/app/buy/page.tsx b/app/buy/page.tsx index cd5f453..df36848 100644 --- a/app/buy/page.tsx +++ b/app/buy/page.tsx @@ -34,7 +34,7 @@ export default async function BuyPage({ searchParams }: { searchParams?: Record< const business = await db.business.findUnique({ where: { ownerClerkId: userId } }); if (!business) { - redirect(`/app/onboarding?next=${encodeURIComponent(billingPath)}`); + redirect('/app'); } redirect(billingPath); diff --git a/app/contact/page.tsx b/app/contact/page.tsx index ba165d9..dc31136 100644 --- a/app/contact/page.tsx +++ b/app/contact/page.tsx @@ -40,16 +40,16 @@ export default function ContactPage() { support@callbackcloser.com

-

If you are already ready to try the product, you can also create an account and complete the in-app setup flow.

+

If you are ready to try the product, create your account and we will handle the setup for you.

If you are an active pilot customer, include your business name, texting line, and the recent call or SMS time when reporting an issue.

For SMS consent, STOP or HELP behavior, or public trust-page questions, email support and reference{' '} callbackcloser.com so we can match the request to the live pilot setup.

-

Founder/operator customer pilot setup is separate from public signup and stays inside the admin new-business flow.

+

We keep the launch managed and notify you when your Lead Recovery Command Center is ready.

- Start pilot onboarding + Start 14-day pilot Create account diff --git a/app/page.tsx b/app/page.tsx index 770f652..8133a95 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -7,123 +7,43 @@ import { buttonVariants } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { PUBLIC_CREATE_ACCOUNT_PATH, PUBLIC_START_FREE_PILOT_PATH } from '@/lib/public-auth-routing'; -const roiPoints = [ +const flowSteps = [ { - title: 'Reply before they move on', - description: 'Missed callers hear back fast instead of calling the next business on the list.', + title: 'Missed call comes in', + detail: 'Your customer calls. If the call is missed, CallbackCloser starts the follow-up immediately.', }, { - title: 'Get the details without chasing them', - description: 'You get the job type, urgency, ZIP, and callback timing without manually texting back and forth.', + title: 'Text goes out in seconds', + detail: 'The customer gets a simple reply asking what they need help with and how soon they need it.', }, { - title: 'Know who is worth calling first', - description: 'Qualified leads are handed off with a ready-to-close summary instead of a cold voicemail.', + title: 'The lead gets qualified', + detail: 'CallbackCloser captures service need, urgency, location, and the best callback time without dragging the customer through a long form.', }, { - title: 'One extra job can cover the cost', - description: 'For most service businesses, a single recovered repair or install pays for CallbackCloser.', + title: 'You get a ready-to-call summary', + detail: 'Instead of a cold voicemail, you see a clear lead handoff and know who to call first.', }, ]; -const painPoints = [ - { - title: 'Every missed call can become a lost job', - description: 'When the phone rings and nobody answers, that customer usually needs help now, not tomorrow.', - }, - { - title: 'Customers move on fast', - description: 'If they do not hear back quickly, they call the next shop and you never get the chance to close them.', - }, - { - title: 'Voicemail rarely saves the lead', - description: 'Most callers do not leave enough detail to help you call back with confidence, if they leave one at all.', - }, - { - title: 'Most owners never see the lost revenue', - description: 'Missed calls feel small until you add up how many booked jobs disappear every month.', - }, +const pilotIncludes = [ + '14-day pilot', + 'White-glove setup included', + 'Missed-call SMS recovery', + 'Qualified lead summaries', + 'Owner alerts', + 'Lead Recovery Command Center', + 'One business texting number included', + 'You approve before continuing', ]; -const workflowSteps = [ - { - title: 'A customer calls and you miss it', - description: 'The lead does not have to sit in voicemail while your team is on jobs or with other customers.', - }, - { - title: 'CallbackCloser texts them right away', - description: 'They hear back in seconds, not hours, so you stay in the running for the job.', - }, - { - title: 'We find out what they need', - description: 'The conversation captures the job type, urgency, and location without your team doing the back-and-forth.', - }, - { - title: 'You get a qualified lead to follow up with', - description: 'You get a ready-to-close handoff so the next call is focused on booking the job.', - }, -]; - -const proofStats = [ - { - label: 'Response timing', - value: 'Seconds, not hours', - detail: 'Missed callers hear back quickly while the job is still active and the lead still wants help.', - }, - { - label: 'Lead handoff', - value: 'Qualified before callback', - detail: 'Owners see the job type, urgency, ZIP, and callback context before the next phone call.', - }, - { - label: 'Setup model', - value: 'Managed for you', - detail: 'CallbackCloser handles the texting line, routing support, and activation checklist in one place.', - }, - { - label: 'Public trust', - value: 'Visible and clear', - detail: 'Privacy Policy, Terms & Conditions, Refund Policy, and SMS Consent stay public and easy to review.', - }, -]; - -const screenshotCards = [ - { - label: 'Leads list', - title: 'Recovered leads prioritized for callback', - description: 'See new leads, urgency, location, and follow-up status in one clean queue.', - }, - { - label: 'Conversation detail', - title: 'Full SMS thread with quick follow-up actions', - description: 'Read the conversation, confirm what the caller needs, and move the lead forward fast.', - }, - { - label: 'Business settings', - title: 'Activation checklist, routing, and owner alerts in one place', - description: 'Keep routing, owner alerts, and launch status visible before your team depends on it.', - }, -]; - -const planTeasers = [ - { - name: 'Starter', - description: 'Start turning missed calls into real opportunities with one included business texting number and less admin work.', - }, - { - name: 'Growth', - description: 'Handle more missed-call opportunities and keep follow-up clean as your team gets busier.', - }, - { - name: 'Agency / Multi-location', - description: 'Hands-on rollout planning for teams covering multiple brands or locations.', - }, -]; - -const onboardingSteps = [ - 'We provision your business texting line and connect routing so missed callers are covered fast.', - 'We confirm the first text and lead questions before live traffic starts.', - 'We verify owner notifications and run a missed-call test with you before go-live.', +const trustLinks = [ + { href: '/pricing', label: 'Pricing' }, + { href: '/refund', label: 'Refund' }, + { href: '/privacy', label: 'Privacy' }, + { href: '/terms', label: 'Terms' }, + { href: '/sms-consent', label: 'SMS Consent' }, + { href: '/contact', label: 'Contact' }, ]; export default function LandingPage() { @@ -134,99 +54,68 @@ export default function LandingPage() {
-
+
+ 14-day pilot with white-glove setup
- Built for service businesses that lose jobs to missed calls -
-

- Stop losing jobs from missed calls -

-

- CallbackCloser texts missed callers instantly, qualifies them, and sends you a ready-to-close lead. -

-
-
- - Start Free Pilot - - - Create Account - - - See Demo - -
-
- Reply in seconds - Recover more jobs - Ready-to-close leads - Less admin chasing -
-

Close one extra job and this can pay for itself.

+

+ Turn missed calls into qualified leads automatically +

+

+ CallbackCloser texts missed callers in seconds, collects the job details, and sends you a ready-to-call lead summary. +

- Start Free Pilot creates a new account or takes an existing user to the right next step automatically. - Founder-operated customer pilot setup stays separate in the admin new-business flow. + Start a 14-day pilot. We help set up your missed-call recovery flow and notify you when it is ready.

- -
- {roiPoints.map((point) => ( -
-

{point.title}

-

{point.description}

-
- ))} +
+ + Try the missed-call simulator + + + Start 14-day pilot + +
+
+ Reply in seconds + Qualified before callback + White-glove setup + Live dashboard when ready
- - What you get back - Faster response, fewer cold leads, and a clearer path to closing the job. + + What the owner sees + A short missed-call recovery flow that ends with a ready-to-call lead. -
+

2:14 PM

Missed call
-

Homeowner calls about same-day AC repair while your techs are on jobs.

+

A homeowner calls while your team is on jobs.

-
-

2:14 PM

- Auto-text sent -
-

- CallbackCloser replies right away so the customer does not disappear before you can get back to them. -

-
-
-
-

2:16 PM

- Lead qualified -
-

- The job type, urgency, ZIP, and callback timing come in before you even make the next call. -

+

Customer reply

+

“Repair. Today. John, Knoxville. ASAP.”

-

2:16 PM

- Owner alert sent +

Owner alert

+ Qualified lead
-

Ready-to-close lead

+

John · Repair · Today · Knoxville · ASAP

- AC repair, urgent today, ZIP 78660, asked for an afternoon callback. Your team can call back ready to book the job, not hunt for details. + CallbackCloser hands off a clear summary so the callback can focus on booking the job instead of hunting for details.

-
-

Show the product in 30 seconds

-

- Open the public demo to show the missed-call follow-up, owner alert, and dashboard handoff without login or setup. -

- - Open public demo +
+ + Run the simulator + + + See the product story
@@ -234,187 +123,94 @@ export default function LandingPage() {
-
-
-
- The real problem -

Most missed calls are not just missed calls

-

- They are missed estimates, missed repairs, missed installs, and missed revenue you never get a clean chance to win back. -

-
- -
- {painPoints.map((point) => ( - - - {point.title} - - {point.description} - - ))} -
-
-
- -
+
- Simple follow-up -

What happens when you miss a call

+ How it works +

A simple missed-call recovery flow built for service businesses

- CallbackCloser keeps the handoff simple so you can focus on calling back the right lead and closing the work. + The goal is straightforward: keep the customer engaged, qualify the request quickly, and get the owner to the callback with context.

- {workflowSteps.map((step, index) => ( - + {flowSteps.map((step) => ( + - Step {index + 1} {step.title} - {step.description} + {step.detail} ))}
-
-
-
- Trust and proof -

Proof that missed calls can still turn into paying work

-

- The public site should make the operational value obvious: faster response, cleaner handoff, and trust pages that are - easy to verify before a business ever goes live. -

-
- -
- {proofStats.map((stat) => ( - - - {stat.label} - {stat.value} - - {stat.detail} - - ))} -
- - +
+
+ - White-glove pilot onboarding - Hands-on setup so your missed calls are covered fast and the first test goes cleanly. + Owner alert preview + The handoff is short, practical, and ready for a callback. - -
- {onboardingSteps.map((step, index) => ( -
-

- {index + 1}. {step} -

-
- ))} -

- We help you get live fast, cover the missed-call gap, and confirm the first real handoff before your team depends on it. -

-
-
-

Launch standard

-

- Every rollout is built to give the business a managed texting line, visible trust pages, and a clean first test call. -

-
-

- One business texting number is included in the base service.

-

- SMS Consent, Privacy Policy, Refund, and Terms & Conditions pages stay public before activation.

-

- Owner alerts and callback summaries are verified before launch.

-
-
+ +

Hot missed-call lead

+

Name: John

+

Service: Repair

+

Urgency: Today

+

Location: Knoxville

+

Callback: ASAP

+

Call now: (555) 123-4567

+

View lead: /app/leads/demo

-
-
-
-
- Product preview -

See how missed calls turn into follow-up-ready leads

-

- A quick look at the product surfaces that keep missed-call follow-up organized for the owner and the office. -

-
+
+
+ Pilot offer +

Start with a 14-day pilot

+

+ We're onboarding a small number of local service businesses with hands-on setup. We set up your missed-call recovery flow, verify the first test, and notify you when your account is ready. +

+
-
- {screenshotCards.map((card) => ( - -
-
-
- {card.label} - Inside CallbackCloser -
-
-
-
-
-
-
-
+ - {card.title} - {card.description} + Early pilot pricing + + Early pilot pricing starts at $50 for the first 14 days to cover setup, texting, and usage while we prove the system can recover leads for your business. + + + {pilotIncludes.map((item) => ( +

- {item}

+ ))} +
+ + Start 14-day pilot + + + Create account + +
+
- ))} +
-
-
- - - Simple plan choices - Visitors should understand the offer in under 20 seconds. - - - {planTeasers.map((plan) => ( -
-

{plan.name}

-

{plan.description}

-
- ))} -
- - Start capturing missed leads - -
-
-
- - - - Compliance stays visible - Trust language remains present without dominating the pitch. - - -

- CallbackCloser keeps pricing, refund, Privacy Policy, Terms & Conditions, contact, and SMS Consent pages visible - before a business ever starts a pilot. -

-

- STOP, START, and HELP support remain part of the product flow, and the public consent page still explains message frequency and message/data rates. -

-
- - Review SMS consent - - - Privacy Policy & Terms - -
-
-
+
+ Trust +
+

Everything important stays visible

+

+ Trust pages stay public and easy to review, without taking over the main product story. +

+
+
+ {trustLinks.map((link) => ( + + {link.label} + + ))}
diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx index ff85ca8..7857d1c 100644 --- a/app/pricing/page.tsx +++ b/app/pricing/page.tsx @@ -7,46 +7,23 @@ import { buttonVariants } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { PUBLIC_CREATE_ACCOUNT_PATH, PUBLIC_START_FREE_PILOT_PATH } from '@/lib/public-auth-routing'; -const pricingPlans = [ - { - name: 'Starter', - summary: 'For owner-operators who want missed calls turning back into paying jobs quickly.', - details: [ - 'Includes one business texting number', - 'Includes standard setup and managed provisioning', - 'Text missed callers before they move on', - 'Get a qualified lead instead of a dead voicemail', - 'See recovered leads and follow-up status clearly', - 'White-glove pilot onboarding to get live quickly', - ], - }, - { - name: 'Growth', - summary: 'For growing service teams that need to protect more revenue from missed calls.', - details: [ - 'Everything in Starter', - 'Optional extra numbers and rollout help', - 'More follow-up capacity for busier inbound volume', - 'Priority rollout help so missed calls stay covered', - 'Clear billing visibility as usage grows', - ], - }, - { - name: 'Agency / Multi-location', - summary: 'For operators covering multiple locations, brands, or client accounts.', - details: [ - 'Multi-location rollout planning', - 'Hands-on guidance for multiple phone lines and routing', - 'Custom onboarding and launch sequencing', - 'Contact sales before activation', - ], - }, +const pilotFeatures = [ + '14-day pilot', + 'White-glove setup included', + 'Missed-call SMS recovery', + 'Qualified lead summaries', + 'Owner alerts', + 'Lead Recovery Command Center', + 'One business texting number included', + 'You approve before continuing', ]; -const clarityPoints = [ - 'See your current plan, next billing date, and usage clearly inside the app.', - 'If billing is inactive, you still see captured leads while auto-texting is paused.', - 'If usage is capped, the app tells you plainly instead of leaving you guessing.', +const trustLinks = [ + { href: '/refund', label: 'Refund Policy' }, + { href: '/privacy', label: 'Privacy Policy' }, + { href: '/terms', label: 'Terms & Conditions' }, + { href: '/sms-consent', label: 'SMS Consent' }, + { href: '/contact', label: 'Contact' }, ]; export default function PricingPage() { @@ -56,79 +33,58 @@ export default function PricingPage() {
- Transparent packaging -

Simple pricing for missed-call recovery

+ Pricing +

Start with a 14-day pilot

- CallbackCloser is priced so business owners can understand the value quickly: protect more missed-call revenue without adding more office admin. + CallbackCloser is currently offered as a hands-on pilot for local service businesses that want missed-call recovery set up for them.

- CallbackCloser keeps the public structure simple: Starter, Growth, and Agency / Multi-location. Base service includes one - business texting number, standard setup, and managed provisioning. If you want rollout help before checkout, email{' '} - - support@callbackcloser.com - - . Founder-run customer pilot setup is separate from public signup. + We keep the offer straightforward: you try the simulator, create your account, we handle the setup, and we notify you when your Lead Recovery Command Center is ready.

-
- {pricingPlans.map((plan) => ( - - - {plan.name} - {plan.summary} - - - {plan.details.map((detail) => ( -

- {detail}

- ))} -
- {plan.name === 'Agency / Multi-location' ? ( - - Contact Sales - - ) : ( - - Start Free Pilot - - )} -
-
-
- ))} -
-
- - - Billing clarity inside the app - The billing page is designed to remove the fear of hidden costs and surprise pauses. - - - {clarityPoints.map((point) => ( -
- {point} -
+ + + Early pilot pricing + + Early pilot pricing starts at $50 for the first 14 days to cover setup, texting, and usage while we prove the system can recover leads for your business. + + + + {pilotFeatures.map((feature) => ( +

- {feature}

))} +
+ + Start 14-day pilot + + + Try the missed-call simulator + +
- - - Trust and compliance - Trust stays visible without turning the page into policy copy. - - -

Public pricing, contact, Privacy Policy, Terms & Conditions, refund, and SMS Consent pages remain visible before activation.

-

STOP, START, and HELP handling remain part of the live messaging flow, and the consent page stays public.

-

Businesses remain responsible for lawful texting practices and consent requirements in their market.

+ + + What the pilot includes + Built for a founder-run, done-for-you setup instead of an unfinished DIY rollout. + + +
+ We prepare the texting flow, confirm the first qualification handoff, and keep the launch status visible before you rely on it. +
+
+ Your workspace stays on a setup-in-progress view until the system is ready. Then we notify you and unlock the full dashboard. +
+
+ If you need multi-location rollout help or a custom setup path, contact us before activation so the pilot matches your operating model. +
- + Create account - - Review SMS consent - Talk to us @@ -136,6 +92,38 @@ export default function PricingPage() {
+ +
+ + + Why the pilot is structured this way + The offer is designed to feel clear and founder-operated, not like unfinished plan cards. + + +

We keep the setup managed so you are not asked to run a phone-system project on day one.

+

The simulator shows the full customer experience before you commit.

+

If the pilot proves the missed-call recovery flow is working for your business, we can continue from there with real billing inside the app.

+
+
+ + + + Trust and compliance + Important public pages stay visible without crowding the main offer. + + +

STOP, START, and HELP handling remain part of the live messaging flow, and the consent page stays public.

+

Privacy, terms, refund, and contact information remain available before activation.

+
+ {trustLinks.map((link) => ( + + {link.label} + + ))} +
+
+
+
diff --git a/app/simulator/page.tsx b/app/simulator/page.tsx index 6382a15..674d37e 100644 --- a/app/simulator/page.tsx +++ b/app/simulator/page.tsx @@ -31,7 +31,7 @@ export default function SimulatorPage() {
- Start Free Pilot + Start 14-day pilot View product demo diff --git a/components/customer-setup-waiting-page.tsx b/components/customer-setup-waiting-page.tsx new file mode 100644 index 0000000..7fcf741 --- /dev/null +++ b/components/customer-setup-waiting-page.tsx @@ -0,0 +1,98 @@ +import Link from 'next/link'; +import { type BusinessProvisioningStatus } from '@prisma/client'; + +import { Badge } from '@/components/ui/badge'; +import { buttonVariants } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + customerSetupStatusLabels, + getCustomerSetupStatusDetail, + isGenericManagedSetupBusinessName, +} from '@/lib/customer-setup'; + +export function CustomerSetupWaitingPage({ + businessName, + status, +}: { + businessName: string | null; + status: BusinessProvisioningStatus; +}) { + const showBusinessName = businessName && !isGenericManagedSetupBusinessName(businessName); + + return ( +
+
+ Setup in progress +
+

+ Your missed-call recovery system is being set up +

+

+ We're preparing CallbackCloser for your business. You do not need to configure anything right now. We'll + notify you as soon as your account is ready. +

+
+
+ +
+ + + What happens next + We keep the launch handled for you, then unlock the full workspace when it is ready. + + +
+

1. We review your account

+

We confirm the owner details and queue your business for setup.

+
+
+

2. We prepare the missed-call recovery flow

+

We handle the setup work, test the first handoff, and keep the status honest.

+
+
+

3. You get the ready notice

+

As soon as the workspace is ready, we email you and unlock the Lead Recovery Command Center.

+
+
+
+ + + + Account status + Everything here stays customer-facing and non-technical. + + + {showBusinessName ? ( +
+

Business

+

{businessName}

+
+ ) : null} +
+

Setup status

+
+ {customerSetupStatusLabels[status]} + {getCustomerSetupStatusDetail(status)} +
+
+
+ Need help or want to show a teammate how it works? Email{' '} + + support@callbackcloser.com + + . +
+
+ + Try the missed-call simulator + + + Contact support + +
+
+
+
+
+ ); +} diff --git a/components/public-site-footer.tsx b/components/public-site-footer.tsx index 9355b0a..539de01 100644 --- a/components/public-site-footer.tsx +++ b/components/public-site-footer.tsx @@ -4,7 +4,7 @@ import { PUBLIC_CREATE_ACCOUNT_PATH, PUBLIC_SIGN_IN_PATH, PUBLIC_START_FREE_PILO const footerLinks = [ { href: PUBLIC_CREATE_ACCOUNT_PATH, label: 'Create account' }, - { href: PUBLIC_START_FREE_PILOT_PATH, label: 'Start Free Pilot' }, + { href: PUBLIC_START_FREE_PILOT_PATH, label: 'Start 14-day pilot' }, { href: PUBLIC_SIGN_IN_PATH, label: 'Sign in' }, { href: '/pricing', label: 'Pricing' }, { href: '/demo', label: 'Missed-Call Demo' }, @@ -22,7 +22,7 @@ export function PublicSiteFooter() {

CallbackCloser

Stop missed calls from turning into lost jobs with fast follow-up and clearer owner handoff.

-

White-glove onboarding plus visible Pricing, Privacy Policy, Terms & Conditions, Refund, and SMS Consent trust pages.

+

Try the simulator, start a 14-day pilot, and let CallbackCloser handle the setup before your workspace goes live.

Contact:{' '} diff --git a/components/public-site-nav.tsx b/components/public-site-nav.tsx index b32fa50..8daa527 100644 --- a/components/public-site-nav.tsx +++ b/components/public-site-nav.tsx @@ -44,7 +44,7 @@ export function PublicSiteNav() { Create account - Start Free Pilot + Start 14-day pilot

diff --git a/lib/admin-dashboard.ts b/lib/admin-dashboard.ts index ee97152..c3c9723 100644 --- a/lib/admin-dashboard.ts +++ b/lib/admin-dashboard.ts @@ -83,9 +83,10 @@ type EventCall = Pick email.id === user.primaryEmailAddressId)?.emailAddress + : user?.emailAddresses[0]?.emailAddress) || null; + + business = await ensurePendingBusinessForOwner(userId, { + businessName: typeof user?.publicMetadata?.businessName === 'string' ? user.publicMetadata.businessName : null, + ownerEmail, + ownerName: user?.fullName || [user?.firstName, user?.lastName].filter(Boolean).join(' ') || null, + }); } return business; } diff --git a/lib/customer-setup-handoff.ts b/lib/customer-setup-handoff.ts new file mode 100644 index 0000000..68af994 --- /dev/null +++ b/lib/customer-setup-handoff.ts @@ -0,0 +1,243 @@ +import 'server-only'; + +import { + BusinessPhoneSetupPath, + BusinessProvisioningStatus, + ForwardedCallAnswerMode, + ForwardingVerificationStatus, + ManagedTwilioStatus, + MessagingSetupMode, + SubscriptionStatus, + TwilioAccountMode, + TwilioNumberSetupMode, +} from '@prisma/client'; + +import { ensureBusinessNotificationSettings } from '@/lib/business-notification-settings'; +import { db } from '@/lib/db'; +import { sendTransactionalEmail } from '@/lib/email'; +import { formatPhoneDetail, recordBusinessOperatorEvent } from '@/lib/operator-events'; +import { absoluteUrl } from '@/lib/url'; + +type ManagedSetupOwnerProfile = { + businessName?: string | null; + ownerEmail?: string | null; + ownerName?: string | null; + ownerPhone?: string | null; +}; + +function parseAdminEmailAllowlist(value: string | undefined) { + return Array.from( + new Set( + (value ?? '') + .split(',') + .map((item) => item.trim().toLowerCase()) + .filter(Boolean), + ), + ); +} + +function buildManagedSetupBusinessName(profile: ManagedSetupOwnerProfile) { + const businessName = profile.businessName?.trim(); + if (businessName) return businessName; + + const ownerName = profile.ownerName?.trim(); + if (ownerName) return `${ownerName} Business`; + + return 'New CallbackCloser signup'; +} + +function buildFounderSetupNotification(params: { + adminBusinessUrl: string; + businessName: string; + ownerEmail: string | null; + ownerName: string | null; + ownerPhone: string | null; +}) { + const lines = [ + 'New CallbackCloser signup needs setup.', + '', + `Business: ${params.businessName}`, + `Owner: ${params.ownerName || 'Not provided yet'}`, + `Email: ${params.ownerEmail || 'Not provided yet'}`, + `Phone: ${params.ownerPhone || 'Not provided yet'}`, + '', + 'Open admin to finish setup:', + params.adminBusinessUrl, + ]; + + return { + subject: 'New CallbackCloser signup needs setup', + text: lines.join('\n'), + }; +} + +function buildCustomerReadyNotification(loginUrl: string) { + const lines = [ + 'Your CallbackCloser account is ready.', + '', + 'Your missed-call recovery system is set up and ready to help recover leads. You can now log in to view your Lead Recovery Command Center.', + '', + loginUrl, + ]; + + return { + subject: 'Your CallbackCloser account is ready', + text: lines.join('\n'), + }; +} + +async function notifyFounderOfPendingSignup(params: { + adminBusinessUrl: string; + businessId: string; + businessName: string; + ownerEmail: string | null; + ownerName: string | null; + ownerPhone: string | null; +}) { + const emailRecipients = parseAdminEmailAllowlist(process.env.ADMIN_EMAIL_ALLOWLIST); + const message = buildFounderSetupNotification(params); + + const results = await Promise.all( + emailRecipients.map((recipient) => + sendTransactionalEmail({ + to: recipient, + subject: message.subject, + text: message.text, + }), + ), + ); + + await recordBusinessOperatorEvent({ + businessId: params.businessId, + type: 'onboarding.customer_signup_pending_setup', + category: 'ONBOARDING', + status: 'INFO', + summary: 'New customer signup is waiting for founder setup', + details: { + businessName: params.businessName, + ownerName: params.ownerName, + ownerEmail: params.ownerEmail, + ownerPhone: formatPhoneDetail(params.ownerPhone), + adminBusinessUrl: params.adminBusinessUrl, + founderEmailsConfigured: emailRecipients.length > 0, + founderEmailsAttempted: emailRecipients.length, + founderEmailsDelivered: results.filter((result) => result.ok).length, + }, + }); +} + +export async function ensurePendingBusinessForOwner(ownerClerkId: string, profile: ManagedSetupOwnerProfile = {}) { + const existing = await db.business.findUnique({ + where: { ownerClerkId }, + include: { notificationSettings: true }, + }); + + if (existing) { + if ((profile.ownerName && !existing.ownerName) || (profile.businessName && existing.name === 'New CallbackCloser signup')) { + await db.business.update({ + where: { id: existing.id }, + data: { + ownerName: existing.ownerName || profile.ownerName?.trim() || null, + name: existing.name === 'New CallbackCloser signup' ? buildManagedSetupBusinessName(profile) : existing.name, + }, + }); + } + + await ensureBusinessNotificationSettings(existing, { + ownerEmail: profile.ownerEmail || null, + ownerPhone: profile.ownerPhone || null, + }); + + return db.business.findUniqueOrThrow({ where: { id: existing.id } }); + } + + const business = await db.business.create({ + data: { + ownerClerkId, + name: buildManagedSetupBusinessName(profile), + ownerName: profile.ownerName?.trim() || null, + forwardingNumber: '', + notifyPhone: profile.ownerPhone?.trim() || null, + provisioningStatus: BusinessProvisioningStatus.DRAFT, + twilioAccountMode: TwilioAccountMode.BUSINESS_SUBACCOUNT, + phoneSetupPath: BusinessPhoneSetupPath.NEW_TWILIO_NUMBER, + forwardedCallAnswerMode: ForwardedCallAnswerMode.PRESS_1_REQUIRED, + messagingSetupMode: MessagingSetupMode.PER_BUSINESS_TWILIO, + twilioNumberSetupMode: TwilioNumberSetupMode.NEW_NUMBER, + forwardingVerificationStatus: ForwardingVerificationStatus.NOT_STARTED, + missedCallSeconds: 20, + serviceLabel1: 'Repair', + serviceLabel2: 'Install', + serviceLabel3: 'Maintenance', + timezone: 'America/New_York', + subscriptionStatus: SubscriptionStatus.INACTIVE, + managedTwilioStatus: ManagedTwilioStatus.DRAFT, + managedTwilioStatusUpdatedAt: new Date(), + }, + }); + + await ensureBusinessNotificationSettings(business, { + ownerEmail: profile.ownerEmail || null, + ownerPhone: profile.ownerPhone || null, + }); + + await notifyFounderOfPendingSignup({ + adminBusinessUrl: absoluteUrl(`/admin/${business.id}`), + businessId: business.id, + businessName: business.name, + ownerEmail: profile.ownerEmail?.trim().toLowerCase() || null, + ownerName: profile.ownerName?.trim() || null, + ownerPhone: profile.ownerPhone?.trim() || null, + }); + + return business; +} + +export async function sendCustomerReadyNotification(businessId: string) { + const business = await db.business.findUnique({ + where: { id: businessId }, + include: { notificationSettings: true }, + }); + + if (!business) { + return { ok: false as const, reason: 'business_not_found' as const }; + } + + const ownerEmail = business.notificationSettings?.ownerEmail?.trim().toLowerCase() || null; + const loginUrl = absoluteUrl('/sign-in'); + + if (!ownerEmail) { + await recordBusinessOperatorEvent({ + businessId, + type: 'onboarding.customer_ready_notification_missing_email', + category: 'ONBOARDING', + status: 'WARNING', + summary: 'Customer ready notification skipped because no owner email is saved', + details: { loginUrl }, + }); + + return { ok: false as const, reason: 'missing_owner_email' as const }; + } + + const message = buildCustomerReadyNotification(loginUrl); + const result = await sendTransactionalEmail({ + to: ownerEmail, + subject: message.subject, + text: message.text, + }); + + await recordBusinessOperatorEvent({ + businessId, + type: 'onboarding.customer_ready_notification_sent', + category: 'ONBOARDING', + status: result.ok ? 'SUCCESS' : 'WARNING', + summary: result.ok ? 'Customer ready notification sent' : 'Customer ready notification could not be delivered', + details: { + ownerEmail, + loginUrl, + deliveryStatus: result.ok ? 'sent' : result.reason, + }, + }); + + return result.ok ? { ok: true as const } : { ok: false as const, reason: result.reason }; +} diff --git a/lib/customer-setup.ts b/lib/customer-setup.ts new file mode 100644 index 0000000..aef5afa --- /dev/null +++ b/lib/customer-setup.ts @@ -0,0 +1,58 @@ +import { BusinessProvisioningStatus } from '@prisma/client'; + +export const customerSetupStatusLabels: Record = { + DRAFT: 'Pending setup', + ONBOARDING: 'Setup in progress', + NEEDS_ATTENTION: 'Needs attention', + LIVE: 'Live', + PAUSED: 'Paused', +}; + +export function shouldShowCustomerSetupWaitingPage(status: BusinessProvisioningStatus) { + return status === BusinessProvisioningStatus.DRAFT || status === BusinessProvisioningStatus.ONBOARDING; +} + +export function shouldShowCustomerWorkspaceNotice(status: BusinessProvisioningStatus) { + return status === BusinessProvisioningStatus.NEEDS_ATTENTION || status === BusinessProvisioningStatus.PAUSED; +} + +export function getCustomerSetupStatusDetail(status: BusinessProvisioningStatus) { + switch (status) { + case BusinessProvisioningStatus.DRAFT: + return 'We have your account and are preparing the first setup steps for your business.'; + case BusinessProvisioningStatus.ONBOARDING: + return 'We are connecting your missed-call recovery flow and running the launch checks for you.'; + case BusinessProvisioningStatus.NEEDS_ATTENTION: + return 'Your workspace is saved, but we need a founder follow-up before everything can stay fully active.'; + case BusinessProvisioningStatus.PAUSED: + return 'Missed-call recovery is paused while we work through a support or launch issue.'; + case BusinessProvisioningStatus.LIVE: + default: + return 'Your missed-call recovery system is live and ready to capture leads.'; + } +} + +export function getCustomerWorkspaceNotice(status: BusinessProvisioningStatus) { + if (status === BusinessProvisioningStatus.NEEDS_ATTENTION) { + return { + title: 'Support follow-up is in progress', + detail: 'We found something that needs attention. Your dashboard is still available, and we will follow up directly if anything needs your approval.', + variant: 'destructive' as const, + }; + } + + if (status === BusinessProvisioningStatus.PAUSED) { + return { + title: 'Automation is paused', + detail: 'Your workspace is still available, but missed-call recovery is paused until support clears the current issue.', + variant: 'outline' as const, + }; + } + + return null; +} + +export function isGenericManagedSetupBusinessName(name: string | null | undefined) { + const value = name?.trim().toLowerCase() || ''; + return value === '' || value === 'new callbackcloser signup'; +} diff --git a/lib/public-auth-routing.ts b/lib/public-auth-routing.ts index 73e723d..6ddf6c7 100644 --- a/lib/public-auth-routing.ts +++ b/lib/public-auth-routing.ts @@ -2,7 +2,7 @@ export const PUBLIC_CREATE_ACCOUNT_PATH = '/sign-up?intent=create-account'; export const PUBLIC_START_FREE_PILOT_PATH = '/start-free-pilot'; export const PUBLIC_SIGN_IN_PATH = '/sign-in'; export const OWNER_DASHBOARD_PATH = '/app'; -export const OWNER_ONBOARDING_PATH = '/app/onboarding?source=public-sign-up'; +export const OWNER_SETUP_STATUS_PATH = '/app'; export const ADMIN_NEW_BUSINESS_PILOT_PATH = '/admin?intent=new-business-pilot'; type SignedInRoutingState = { @@ -23,7 +23,7 @@ export function resolveSignedInAppDestination(state: SignedInRoutingState) { return OWNER_DASHBOARD_PATH; } - return OWNER_ONBOARDING_PATH; + return OWNER_SETUP_STATUS_PATH; } export function resolvePublicPilotDestination(state: PublicPilotRoutingState) { diff --git a/tests/legal-pages.test.ts b/tests/legal-pages.test.ts index fba441f..b55c636 100644 --- a/tests/legal-pages.test.ts +++ b/tests/legal-pages.test.ts @@ -72,8 +72,8 @@ test('public-facing surfaces link to trust and contact routes', () => { const footer = read('components/public-site-footer.tsx'); const nav = read('components/public-site-nav.tsx'); - assert.match(home, /href="\/pricing"/); - assert.match(home, /href="\/sms-consent"/); + assert.match(home, /href: '\/pricing'|href="\/pricing"/); + assert.match(home, /href: '\/sms-consent'|href="\/sms-consent"/); assert.match(home, /PUBLIC_CREATE_ACCOUNT_PATH/); assert.match(home, /PUBLIC_START_FREE_PILOT_PATH/); assert.match(nav, /href: '\/demo'/); diff --git a/tests/managed-customer-setup.test.ts b/tests/managed-customer-setup.test.ts new file mode 100644 index 0000000..f438ff2 --- /dev/null +++ b/tests/managed-customer-setup.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +import { BusinessProvisioningStatus } from '@prisma/client'; + +import { + customerSetupStatusLabels, + getCustomerSetupStatusDetail, + getCustomerWorkspaceNotice, + shouldShowCustomerSetupWaitingPage, +} from '../lib/customer-setup.ts'; + +function read(relativePath: string) { + return readFileSync(path.join(process.cwd(), relativePath), 'utf8'); +} + +test('managed setup status helpers keep pending, live, and attention states distinct', () => { + assert.equal(shouldShowCustomerSetupWaitingPage(BusinessProvisioningStatus.DRAFT), true); + assert.equal(shouldShowCustomerSetupWaitingPage(BusinessProvisioningStatus.ONBOARDING), true); + assert.equal(shouldShowCustomerSetupWaitingPage(BusinessProvisioningStatus.LIVE), false); + assert.equal(customerSetupStatusLabels[BusinessProvisioningStatus.DRAFT], 'Pending setup'); + assert.equal(customerSetupStatusLabels[BusinessProvisioningStatus.ONBOARDING], 'Setup in progress'); + assert.match(getCustomerSetupStatusDetail(BusinessProvisioningStatus.DRAFT), /preparing the first setup steps/i); + assert.match(getCustomerSetupStatusDetail(BusinessProvisioningStatus.ONBOARDING), /running the launch checks/i); + assert.match(getCustomerWorkspaceNotice(BusinessProvisioningStatus.NEEDS_ATTENTION)?.title || '', /Support follow-up/i); + assert.match(getCustomerWorkspaceNotice(BusinessProvisioningStatus.PAUSED)?.detail || '', /paused/i); + assert.equal(getCustomerWorkspaceNotice(BusinessProvisioningStatus.LIVE), null); +}); + +test('managed setup handoff is wired through customer and admin surfaces', () => { + const setupHandoff = read('lib/customer-setup-handoff.ts'); + const auth = read('lib/auth.ts'); + const appLayout = read('app/app/layout.tsx'); + const adminHome = read('app/admin/page.tsx'); + const adminDetail = read('app/admin/[businessId]/page.tsx'); + const adminActions = read('app/admin/actions.ts'); + + assert.match(setupHandoff, /type: 'onboarding\.customer_signup_pending_setup'/); + assert.match(setupHandoff, /summary: 'New customer signup is waiting for founder setup'/); + assert.match(setupHandoff, /subject: 'New CallbackCloser signup needs setup'/); + assert.match(setupHandoff, /subject: 'Your CallbackCloser account is ready'/); + assert.match(auth, /ensurePendingBusinessForOwner/); + assert.match(appLayout, /CustomerSetupWaitingPage/); + assert.match(appLayout, /getCustomerWorkspaceNotice/); + assert.match(adminHome, /Pending setup/); + assert.match(adminHome, /In setup/); + assert.match(adminHome, /New public pilot signups land here waiting for founder setup/i); + assert.match(adminDetail, /This business is waiting for founder setup/); + assert.match(adminActions, /await sendCustomerReadyNotification\(business\.id\)/); +}); + +test('pricing page stays focused on the 14-day pilot instead of vague plan tiers', () => { + const pricing = read('app/pricing/page.tsx'); + + assert.match(pricing, /Start with a 14-day pilot/i); + assert.match(pricing, /Early pilot pricing starts at \$50/i); + assert.match(pricing, /White-glove setup included/i); + assert.match(pricing, /Try the missed-call simulator/i); + assert.doesNotMatch(pricing, /Starter/); + assert.doesNotMatch(pricing, /Growth/); + assert.doesNotMatch(pricing, /Agency \/ Multi-location/); + assert.doesNotMatch(pricing, /Founder-run customer pilot setup is separate/i); + assert.doesNotMatch(pricing, /self-serve phone system/i); +}); diff --git a/tests/pilot-safety.test.ts b/tests/pilot-safety.test.ts index 2b6d3e6..db62316 100644 --- a/tests/pilot-safety.test.ts +++ b/tests/pilot-safety.test.ts @@ -28,25 +28,39 @@ test('existing-number selection reports a truthful setup state instead of a fake assert.match(settingsPage, /admin-assisted porting workflow/i); }); -test('onboarding page persists Twilio account mode before the shared setup flow continues', () => { +test('managed setup handoff replaces the old self-serve onboarding route', () => { const onboardingPage = read('app/app/onboarding/page.tsx'); - const onboardingAction = read('app/app/onboarding/actions.ts'); - const twilioSetup = read('lib/twilio-setup.ts'); - - assert.match(onboardingPage, /Twilio account mode/i); - assert.match(onboardingPage, /TwilioSetupChecklist/); - assert.match(onboardingPage, /twilioAccountModeOptions/); - assert.match(onboardingPage, /businessPhonePathOptions/); - assert.match(twilioSetup, /Business subaccount \(recommended\)/i); - assert.match(twilioSetup, /Main account/i); - assert.doesNotMatch(onboardingAction, /provisionPhoneNumber/); + const auth = read('lib/auth.ts'); + const appLayout = read('app/app/layout.tsx'); + const waitingPage = read('components/customer-setup-waiting-page.tsx'); + const setupHandoff = read('lib/customer-setup-handoff.ts'); + const stripeCheckoutRoute = read('app/api/stripe/checkout/route.ts'); + const buyPage = read('app/buy/page.tsx'); + + assert.match(onboardingPage, /redirect\('\/app'\)/); + assert.match(auth, /ensurePendingBusinessForOwner/); + assert.match(appLayout, /CustomerSetupWaitingPage/); + assert.match(waitingPage, /Your missed-call recovery system is being set up/); + assert.match(waitingPage, /You do not need to configure anything right now/i); + assert.match(waitingPage, /Try the missed-call simulator/); + assert.match(setupHandoff, /New CallbackCloser signup needs setup/); + assert.match(setupHandoff, /Your CallbackCloser account is ready/); + assert.match(stripeCheckoutRoute, /absoluteUrl\('\/app'\)/); + assert.match(buyPage, /redirect\('\/app'\)/); }); test('landing page product promise stays aligned to missed-call recovery workflow', () => { const home = read('app/page.tsx'); - assert.match(home, /Stop losing jobs from missed calls/i); - assert.match(home, /ready-to-close lead/i); + assert.match(home, /Turn missed calls into qualified leads automatically/i); + assert.match(home, /Try the missed-call simulator/i); + assert.match(home, /Start 14-day pilot/i); + assert.match(home, /we help set up your missed-call recovery flow and notify you when it is ready/i); + assert.doesNotMatch(home, /Simple plan choices/i); + assert.doesNotMatch(home, /Founder-operated customer pilot setup stays separate/i); + assert.doesNotMatch(home, /Starter/i); + assert.doesNotMatch(home, /Growth/i); + assert.doesNotMatch(home, /Agency \/ Multi-location/i); }); test('message delivery issue helpers flag failed and fallback statuses', () => { diff --git a/tests/public-auth-routing.test.ts b/tests/public-auth-routing.test.ts index f62bf8c..da46354 100644 --- a/tests/public-auth-routing.test.ts +++ b/tests/public-auth-routing.test.ts @@ -6,7 +6,7 @@ import test from 'node:test'; import { ADMIN_NEW_BUSINESS_PILOT_PATH, OWNER_DASHBOARD_PATH, - OWNER_ONBOARDING_PATH, + OWNER_SETUP_STATUS_PATH, resolvePublicPilotDestination, resolveSignedInAppDestination, } from '../lib/public-auth-routing.ts'; @@ -55,13 +55,14 @@ test('clerk auth surfaces use explicit path routing and fallback redirects', () assert.match(signUpPage, /routing="path"/); assert.match(signUpPage, /fallbackRedirectUrl=\{DEFAULT_CLERK_AFTER_AUTH_URL\}/); assert.match(signUpPage, /