Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
# App
# Required in production. Use your deployed app origin, e.g. https://callbackcloser.com
NEXT_PUBLIC_APP_URL=

# Database (Postgres)
DATABASE_URL=
DIRECT_DATABASE_URL=

# Clerk
# Required in production
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
# Optional comma-separated admin emails allowed into /admin
ADMIN_EMAIL_ALLOWLIST=

# Stripe
# Required in production
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
Expand Down Expand Up @@ -43,6 +47,7 @@ RESEND_API_KEY=
CALLBACKCLOSER_FROM_EMAIL=

# Optional public simulator
# Keep these disabled in production unless the public simulator is intentionally enabled.
ENABLE_PUBLIC_MISSED_CALL_SIMULATOR=
SIMULATOR_BUSINESS_ID=
ENABLE_PUBLIC_SIMULATOR_REAL_SMS=
Expand Down
35 changes: 32 additions & 3 deletions app/(auth)/sign-in/[[...sign-in]]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,42 @@
import { auth } from '@clerk/nextjs/server';
import { SignIn } from '@clerk/nextjs';
import { redirect } from 'next/navigation';

import { getAdminSession } from '@/lib/admin';
import { getBusinessForOwnerClerkId } from '@/lib/business-access';
import { DEFAULT_CLERK_AFTER_AUTH_URL, getClerkAuthUrls } from '@/lib/clerk-config';
import { resolveSignedInAppDestination } from '@/lib/public-auth-routing';

export default async function SignInPage() {
const { userId } = await auth();
if (userId) {
const [adminSession, business] = await Promise.all([
getAdminSession(),
getBusinessForOwnerClerkId(userId),
]);

redirect(
resolveSignedInAppDestination({
isAdmin: Boolean(adminSession?.isAdmin),
hasBusiness: Boolean(business),
})
);
}

export default function SignInPage() {
const { signInUrl, signUpUrl } = getClerkAuthUrls();

return (
<main className="container flex min-h-screen items-center justify-center py-16">
<SignIn path={signInUrl} routing="path" signUpUrl={signUpUrl} fallbackRedirectUrl={DEFAULT_CLERK_AFTER_AUTH_URL} />
<main className="container grid min-h-screen gap-8 py-16 lg:grid-cols-[0.9fr_1.1fr] lg:items-center">
<section className="space-y-4">
<p className="text-sm font-medium text-muted-foreground">Existing users</p>
<h1 className="text-3xl font-semibold tracking-tight">Sign in to your CallbackCloser workspace</h1>
<p className="text-muted-foreground">
Sign in is for existing owners and operators. If you need a new business workspace, create an account or start a free pilot from the public site instead.
</p>
</section>
<div className="flex justify-center lg:justify-end">
<SignIn path={signInUrl} routing="path" signUpUrl={signUpUrl} fallbackRedirectUrl={DEFAULT_CLERK_AFTER_AUTH_URL} />
</div>
</main>
);
}
60 changes: 57 additions & 3 deletions app/(auth)/sign-up/[[...sign-up]]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,67 @@
import { auth } from '@clerk/nextjs/server';
import { SignUp } from '@clerk/nextjs';
import { redirect } from 'next/navigation';

import { getAdminSession } from '@/lib/admin';
import { getBusinessForOwnerClerkId } from '@/lib/business-access';
import { DEFAULT_CLERK_AFTER_AUTH_URL, getClerkAuthUrls } from '@/lib/clerk-config';
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',
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.',
};
}

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.',
};
}

export default async function SignUpPage({
searchParams,
}: {
searchParams?: Record<string, string | string[] | undefined>;
}) {
const { userId } = await auth();
if (userId) {
const [adminSession, business] = await Promise.all([
getAdminSession(),
getBusinessForOwnerClerkId(userId),
]);

redirect(
resolveSignedInAppDestination({
isAdmin: Boolean(adminSession?.isAdmin),
hasBusiness: Boolean(business),
})
);
}

export default function SignUpPage() {
const { signInUrl, signUpUrl } = getClerkAuthUrls();
const intent = typeof searchParams?.intent === 'string' ? searchParams.intent : undefined;
const copy = getIntentCopy(intent);

return (
<main className="container flex min-h-screen items-center justify-center py-16">
<SignUp path={signUpUrl} routing="path" signInUrl={signInUrl} fallbackRedirectUrl={DEFAULT_CLERK_AFTER_AUTH_URL} />
<main className="container grid min-h-screen gap-8 py-16 lg:grid-cols-[0.9fr_1.1fr] lg:items-center">
<section className="space-y-4">
<p className="text-sm font-medium text-muted-foreground">{copy.label}</p>
<h1 className="text-3xl font-semibold tracking-tight">{copy.title}</h1>
<p className="text-muted-foreground">{copy.detail}</p>
<p className="text-sm text-muted-foreground">
Existing users should sign in. CallbackCloser operators setting up a customer pilot should use the admin new-business flow, not public signup.
</p>
</section>
<div className="flex justify-center lg:justify-end">
<SignUp path={signUpUrl} routing="path" signInUrl={signInUrl} fallbackRedirectUrl={DEFAULT_CLERK_AFTER_AUTH_URL} />
</div>
</main>
);
}
6 changes: 6 additions & 0 deletions app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor
const admin = await requireAdmin();
const createdDemo = getQueryValue(searchParams, 'createdDemo') === '1';
const createdBusinessId = getQueryValue(searchParams, 'createdBusinessId');
const setupIntent = getQueryValue(searchParams, 'intent');
const selectedBusinessId = getQueryValue(searchParams, 'businessId');
const archived = getQueryValue(searchParams, 'archived') === '1';
const deleted = getQueryValue(searchParams, 'deleted') === '1';
Expand Down Expand Up @@ -528,6 +529,11 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor
support workspace below.
</div>
) : null}
{setupIntent === 'new-business-pilot' ? (
<div className="rounded-md border border-primary/30 bg-primary/5 p-3 text-sm">
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.
</div>
) : null}

<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
<Card className="border-primary/20 bg-primary/5">
Expand Down
6 changes: 6 additions & 0 deletions app/api/twilio/provision-number/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { auth } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';

import { db } from '@/lib/db';
import { getConfiguredAppBaseUrl } from '@/lib/env.server';
import { getCorrelationIdFromRequest, withCorrelationIdHeader } from '@/lib/observability';
import { isAllowedRequestOrigin } from '@/lib/request-origin';
import { logTwilioError, logTwilioWarn } from '@/lib/twilio-logging';
import { getTwilioProvisioningBlockReason, provisionPhoneNumber } from '@/lib/twilio-provision';

Expand Down Expand Up @@ -35,6 +37,10 @@ export async function POST(request: Request) {
const correlationId = getCorrelationIdFromRequest(request);
const withCorrelation = (response: NextResponse) => withCorrelationIdHeader(response, correlationId);

if (process.env.NODE_ENV === 'production' && !isAllowedRequestOrigin(request, getConfiguredAppBaseUrl())) {
return withCorrelation(NextResponse.json({ error: 'Invalid request origin' }, { status: 403 }));
}

const { userId } = await auth();
if (!userId) {
return withCorrelation(NextResponse.json({ error: 'Unauthorized' }, { status: 401 }));
Expand Down
9 changes: 7 additions & 2 deletions app/contact/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { PublicSiteFooter } from '@/components/public-site-footer';
import { PublicSiteNav } from '@/components/public-site-nav';
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 outreachInputs = [
'Business name and service type',
Expand Down Expand Up @@ -45,11 +46,15 @@ export default function ContactPage() {
For SMS consent, STOP or HELP behavior, or public trust-page questions, email support and reference{' '}
<span className="font-medium text-foreground">callbackcloser.com</span> so we can match the request to the live pilot setup.
</p>
<p>Founder/operator customer pilot setup is separate from public signup and stays inside the admin new-business flow.</p>
<div className="flex flex-wrap gap-3 pt-2">
<Link className={buttonVariants()} href="/sign-up">
<Link className={buttonVariants()} href={PUBLIC_START_FREE_PILOT_PATH}>
Start pilot onboarding
</Link>
<Link className={buttonVariants({ variant: 'outline' })} href="/pricing">
<Link className={buttonVariants({ variant: 'outline' })} href={PUBLIC_CREATE_ACCOUNT_PATH}>
Create account
</Link>
<Link className={buttonVariants({ variant: 'ghost' })} href="/pricing">
View pricing
</Link>
<Link className={buttonVariants({ variant: 'ghost' })} href="/sms-consent">
Expand Down
6 changes: 4 additions & 2 deletions app/demo/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
demoTrustPoints,
demoWorkflowSteps,
} from '@/lib/demo-data';
import { PUBLIC_START_FREE_PILOT_PATH } from '@/lib/public-auth-routing';
import { cn } from '@/lib/utils';

export const metadata: Metadata = {
Expand Down Expand Up @@ -61,6 +62,7 @@ export default function DemoPage() {
</Link>
</div>
<p className="text-sm text-muted-foreground">Get this live on your number fast, without changing how your team already works.</p>
<p className="text-xs text-muted-foreground">Demo only: fake business, fake callers, and no live customer or Twilio data.</p>
</div>

<div className="grid gap-3 sm:grid-cols-3">
Expand Down Expand Up @@ -221,7 +223,7 @@ export default function DemoPage() {
<Link className={buttonVariants()} href="/contact">
Want this on your business?
</Link>
<Link className={buttonVariants({ variant: 'outline' })} href="/sign-up">
<Link className={buttonVariants({ variant: 'outline' })} href={PUBLIC_START_FREE_PILOT_PATH}>
See it on your number
</Link>
</div>
Expand Down Expand Up @@ -261,7 +263,7 @@ export default function DemoPage() {
</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-3">
<Link className={buttonVariants({ size: 'lg' })} href="/sign-up">
<Link className={buttonVariants({ size: 'lg' })} href={PUBLIC_START_FREE_PILOT_PATH}>
See it on your number
</Link>
<Link className={buttonVariants({ size: 'lg', variant: 'outline' })} href="/contact">
Expand Down
15 changes: 10 additions & 5 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { PublicSiteNav } from '@/components/public-site-nav';
import { Badge } from '@/components/ui/badge';
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 = [
{
Expand Down Expand Up @@ -145,14 +146,14 @@ export default function LandingPage() {
</p>
</div>
<div className="flex flex-wrap gap-3">
<Link className={buttonVariants({ size: 'lg' })} href="/sign-up">
<Link className={buttonVariants({ size: 'lg' })} href={PUBLIC_START_FREE_PILOT_PATH}>
Start Free Pilot
</Link>
<Link className={buttonVariants({ size: 'lg', variant: 'outline' })} href="/demo">
See Demo
<Link className={buttonVariants({ size: 'lg', variant: 'outline' })} href={PUBLIC_CREATE_ACCOUNT_PATH}>
Create Account
</Link>
<Link className={buttonVariants({ size: 'lg', variant: 'ghost' })} href="/pricing">
Pricing
<Link className={buttonVariants({ size: 'lg', variant: 'ghost' })} href="/demo">
See Demo
</Link>
</div>
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
Expand All @@ -162,6 +163,10 @@ export default function LandingPage() {
<span className="rounded-full border bg-card px-3 py-1">Less admin chasing</span>
</div>
<p className="text-base font-medium text-foreground">Close one extra job and this can pay for itself.</p>
<p className="max-w-2xl text-sm text-muted-foreground">
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.
</p>
</div>

<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
Expand Down
8 changes: 6 additions & 2 deletions app/pricing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { PublicSiteNav } from '@/components/public-site-nav';
import { Badge } from '@/components/ui/badge';
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 = [
{
Expand Down Expand Up @@ -66,7 +67,7 @@ export default function PricingPage() {
<a className="underline underline-offset-4" href="mailto:support@callbackcloser.com">
support@callbackcloser.com
</a>
.
. Founder-run customer pilot setup is separate from public signup.
</p>
</section>

Expand All @@ -87,7 +88,7 @@ export default function PricingPage() {
Contact Sales
</Link>
) : (
<Link className={buttonVariants()} href="/sign-up">
<Link className={buttonVariants()} href={PUBLIC_START_FREE_PILOT_PATH}>
Start Free Pilot
</Link>
)}
Expand Down Expand Up @@ -122,6 +123,9 @@ export default function PricingPage() {
<p>STOP, START, and HELP handling remain part of the live messaging flow, and the consent page stays public.</p>
<p>Businesses remain responsible for lawful texting practices and consent requirements in their market.</p>
<div className="flex flex-wrap gap-3 pt-2">
<Link className={buttonVariants({ size: 'sm', variant: 'outline' })} href={PUBLIC_CREATE_ACCOUNT_PATH}>
Create account
</Link>
<Link className={buttonVariants({ size: 'sm' })} href="/sms-consent">
Review SMS consent
</Link>
Expand Down
2 changes: 1 addition & 1 deletion app/simulator/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export async function startSimulatorRunAction(formData: FormData) {
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 a real demo texting line is assigned and ENABLE_PUBLIC_SIMULATOR_REAL_SMS=true.';
: '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: {
Expand Down
15 changes: 8 additions & 7 deletions app/simulator/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
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 = {
Expand Down Expand Up @@ -101,7 +102,7 @@ export default async function SimulatorPage({
Demo number unavailable
</Button>
)}
<Link className={buttonVariants({ size: 'lg', variant: 'outline' })} href="/sign-up">
<Link className={buttonVariants({ size: 'lg', variant: 'outline' })} href={PUBLIC_START_FREE_PILOT_PATH}>
Start Free Pilot
</Link>
</div>
Expand All @@ -115,7 +116,7 @@ export default async function SimulatorPage({
? '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.'
: 'ENABLE_PUBLIC_SIMULATOR_REAL_SMS is still off, 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.'}
</p>
</div>
</div>
Expand Down Expand Up @@ -258,14 +259,14 @@ export default async function SimulatorPage({
)}

<div className="flex flex-wrap gap-3">
<Link className={buttonVariants()} href="/sign-up">
Start trial
<Link className={buttonVariants()} href={PUBLIC_CREATE_ACCOUNT_PATH}>
Create account
</Link>
<Link className={buttonVariants({ variant: 'outline' })} href="/contact">
Request setup
<Link className={buttonVariants({ variant: 'outline' })} href={PUBLIC_START_FREE_PILOT_PATH}>
Start Free Pilot
</Link>
<Link className={buttonVariants({ variant: 'ghost' })} href="/pricing">
Book demo
View pricing
</Link>
</div>
</div>
Expand Down
Loading
Loading