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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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
- Lead dashboard + filters + lead detail transcript + status updates
- Stripe billing page + checkout + billing portal
- Public purchase entry route (`/buy`) for external marketing-site links
- Stripe webhook sync for subscription status gating
- SMS compliance commands (`STOP` / `START` / `HELP`) with DB-backed opt-out state
- Call recording enabled on forwarded calls + recording metadata captured on callbacks
Expand Down Expand Up @@ -303,12 +304,27 @@ Prisma models included:
9. Optionally set `DEBUG_ENV_ENDPOINT_TOKEN`, then verify app URL resolution:
- `https://YOUR_DOMAIN/api/debug/env?token=YOUR_DEBUG_ENV_ENDPOINT_TOKEN`

## External Buy Link

Use this URL for the Buy CTA on `getrelayworks.com`:

- `https://YOUR_DOMAIN/buy`

Optional plan-specific links:

- `https://YOUR_DOMAIN/buy?plan=starter`
- `https://YOUR_DOMAIN/buy?plan=pro`

`/buy` handles auth/onboarding redirects and lands the user on `/app/billing`.

## Useful Routes

- `/` - landing page
- `/buy` - external purchase entry (redirects through auth/onboarding to billing)
- `/terms` - terms of service
- `/privacy` - privacy policy
- `/refund` - refund policy
- `/contact` - public support/contact page
- `/sign-in` - Clerk sign-in
- `/sign-up` - Clerk sign-up
- `/app/onboarding` - create business record
Expand Down
41 changes: 37 additions & 4 deletions app/app/billing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { requireBusiness } from '@/lib/auth';
import { db } from '@/lib/db';
import { getPortfolioDemoBlockedCount, isPortfolioDemoMode } from '@/lib/portfolio-demo';
import { isSubscriptionActive } from '@/lib/subscription';
import { getConversationUsageForBusiness, resolveUsageTierFromSubscription } from '@/lib/usage';
import {
describeAutomationBlockReason,
Expand All @@ -18,12 +19,23 @@ function planPrice(priceId: string | undefined) {
return priceId ? 'Configured via Stripe Price ID' : 'Missing env var';
}

function parseRequestedPlan(searchParams?: Record<string, string | string[] | undefined>) {
const rawPlan = searchParams?.plan;
const normalized = typeof rawPlan === 'string' ? rawPlan.trim().toLowerCase() : '';
if (normalized === 'starter' || normalized === 'pro') return normalized;
return null;
}

export default async function BillingPage({ searchParams }: { searchParams?: Record<string, string | string[] | undefined> }) {
const business = await requireBusiness();
const starterPriceId = process.env.STRIPE_PRICE_STARTER;
const proPriceId = process.env.STRIPE_PRICE_PRO;
const error = typeof searchParams?.error === 'string' ? searchParams.error : undefined;
const checkout = typeof searchParams?.checkout === 'string' ? searchParams.checkout : undefined;
const requestedPlan = parseRequestedPlan(searchParams);
const subscriptionActive = isSubscriptionActive(business.subscriptionStatus);
const checkoutSucceeded = checkout === 'success';
const checkoutCanceled = checkout === 'canceled';
const demoMode = isPortfolioDemoMode();
const [blockedCount, usage] = demoMode
? [getPortfolioDemoBlockedCount(), null]
Expand Down Expand Up @@ -51,8 +63,29 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec
</div>

{error ? <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">{error}</div> : null}
{checkout === 'success' ? <div className="rounded-md border border-accent bg-accent/40 p-3 text-sm">Stripe checkout completed. Webhook sync may take a few seconds.</div> : null}
{checkout === 'canceled' ? <div className="rounded-md border bg-muted/40 p-3 text-sm">Checkout canceled.</div> : null}
{requestedPlan ? (
<div className="rounded-md border border-primary/30 bg-primary/5 p-3 text-sm">
Selected plan: <strong>{requestedPlan === 'starter' ? 'Starter' : 'Pro'}</strong>. Continue checkout below.
</div>
) : null}
{checkoutSucceeded && subscriptionActive ? (
<div className="rounded-md border border-accent bg-accent/40 p-3 text-sm">
Subscription is active. Next steps: connect your Twilio number in <Link className="underline" href="/app/settings">Business Settings</Link>, then monitor new leads in{' '}
<Link className="underline" href="/app/leads">Dashboard</Link>.
</div>
) : null}
{checkoutSucceeded && !subscriptionActive ? (
<div className="space-y-2 rounded-md border border-primary/30 bg-primary/5 p-3 text-sm">
<p>Stripe checkout completed. Subscription status is still syncing from webhook events.</p>
<p className="text-muted-foreground">If this does not update shortly, refresh this page and verify `STRIPE_WEBHOOK_SECRET` + webhook endpoint configuration.</p>
<div>
<Link href="/app/billing">
<Button size="sm" variant="outline">Refresh Status</Button>
</Link>
</div>
</div>
) : null}
{checkoutCanceled ? <div className="rounded-md border bg-muted/40 p-3 text-sm">Checkout canceled. You can restart anytime below.</div> : null}

<Card>
<CardHeader>
Expand Down Expand Up @@ -86,7 +119,7 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec
</Card>

<div id="plan-options" className="grid gap-6 md:grid-cols-2">
<Card>
<Card className={requestedPlan === 'starter' ? 'border-primary/40 bg-primary/5' : ''}>
<CardHeader>
<CardTitle>Starter</CardTitle>
<CardDescription>Basic missed-call SMS follow-up and dashboard access.</CardDescription>
Expand All @@ -103,7 +136,7 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec
</CardFooter>
</Card>

<Card>
<Card className={requestedPlan === 'pro' ? 'border-primary/40 bg-primary/5' : ''}>
<CardHeader>
<CardTitle>Pro</CardTitle>
<CardDescription>Higher volume and premium support workflows.</CardDescription>
Expand Down
20 changes: 19 additions & 1 deletion app/app/onboarding/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,36 @@ import { revalidatePath } from 'next/cache';
import { upsertBusinessForOwner } from '@/lib/business';
import { onboardingSchema } from '@/lib/validators';

const DEFAULT_POST_ONBOARDING_REDIRECT = '/app/leads';

function resolveSafePostOnboardingRedirectPath(value: FormDataEntryValue | null) {
if (typeof value !== 'string') return DEFAULT_POST_ONBOARDING_REDIRECT;

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 async function saveOnboardingAction(formData: FormData) {
const { userId } = await auth();
if (!userId) {
redirect('/sign-in');
}

const postOnboardingRedirect = resolveSafePostOnboardingRedirectPath(formData.get('next'));

const parsed = onboardingSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
redirect(`/app/onboarding?error=${encodeURIComponent(parsed.error.issues[0]?.message || 'Invalid form data')}`);
}

await upsertBusinessForOwner(userId, parsed.data);
revalidatePath('/app');
redirect('/app/leads');
redirect(postOnboardingRedirect);
}
26 changes: 23 additions & 3 deletions app/app/onboarding/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,31 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { db } from '@/lib/db';

export default async function OnboardingPage({ searchParams }: { searchParams?: { error?: string } }) {
const DEFAULT_POST_ONBOARDING_REDIRECT = '/app/leads';

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<string, string | string[] | undefined>;
}) {
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);

return (
<div className="mx-auto max-w-3xl space-y-6">
Expand All @@ -27,12 +46,13 @@ export default async function OnboardingPage({ searchParams }: { searchParams?:
<CardDescription>Set the call forwarding and SMS qualification defaults.</CardDescription>
</CardHeader>
<CardContent>
{searchParams?.error ? (
{error ? (
<div className="mb-4 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
{searchParams.error}
{error}
</div>
) : null}
<form action={saveOnboardingAction} className="grid gap-4 sm:grid-cols-2">
<input type="hidden" name="next" value={nextPath} />
<div className="sm:col-span-2">
<Label htmlFor="name">Business name</Label>
<Input id="name" name="name" required placeholder="Acme Plumbing" />
Expand Down
41 changes: 41 additions & 0 deletions app/buy/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { auth } from '@clerk/nextjs/server';
import { redirect } from 'next/navigation';

import { db } from '@/lib/db';

type PlanParam = 'starter' | 'pro';

function parsePlan(searchParams?: Record<string, string | string[] | undefined>): PlanParam | null {
const raw = searchParams?.plan;
const value = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
if (value === 'starter' || value === 'pro') return value;
return null;
}

function buildBuyPath(plan: PlanParam | null) {
if (!plan) return '/buy';
return `/buy?plan=${encodeURIComponent(plan)}`;
}

function buildBillingPath(plan: PlanParam | null) {
if (!plan) return '/app/billing';
return `/app/billing?plan=${encodeURIComponent(plan)}`;
}

export default async function BuyPage({ searchParams }: { searchParams?: Record<string, string | string[] | undefined> }) {
const plan = parsePlan(searchParams);
const buyPath = buildBuyPath(plan);
const billingPath = buildBillingPath(plan);
const { userId } = await auth();

if (!userId) {
redirect(`/sign-up?redirect_url=${encodeURIComponent(buyPath)}`);
}

const business = await db.business.findUnique({ where: { ownerClerkId: userId } });
if (!business) {
redirect(`/app/onboarding?next=${encodeURIComponent(billingPath)}`);
}

redirect(billingPath);
}
44 changes: 44 additions & 0 deletions app/contact/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import Link from 'next/link';

const EFFECTIVE_DATE = 'March 2, 2026';
const SUPPORT_EMAIL = 'support@callbackcloser.com';

export default function ContactPage() {
return (
<main className="container py-12">
<article className="mx-auto max-w-3xl space-y-8">
<header className="space-y-2">
<h1 className="text-3xl font-semibold tracking-tight">Contact</h1>
<p className="text-sm text-muted-foreground">Effective date: {EFFECTIVE_DATE}</p>
</header>

<section className="space-y-2">
<h2 className="text-xl font-semibold">Support</h2>
<p className="text-sm text-muted-foreground">
Email <a className="underline" href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a> and include your business name, account email, and a brief description of your request.
</p>
</section>

<section className="space-y-2">
<h2 className="text-xl font-semibold">Billing and Refund Questions</h2>
<p className="text-sm text-muted-foreground">
Include the charge date, last 4 digits of the card (if available), and any relevant Stripe receipt details.
</p>
</section>

<section className="space-y-2">
<h2 className="text-xl font-semibold">Privacy Requests</h2>
<p className="text-sm text-muted-foreground">
For data access, correction, or deletion requests, include your account email and business identifier so we can verify ownership.
</p>
</section>

<footer className="text-sm text-muted-foreground">
<Link className="underline" href="/">
Back to home
</Link>
</footer>
</article>
</main>
);
}
11 changes: 7 additions & 4 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ export default function LandingPage() {
When a customer calls and nobody answers, CallbackCloser texts them instantly, captures the job details, and alerts the owner with a lead summary.
</p>
<div className="flex flex-wrap gap-3">
<Link href="/app/leads">
<Button size="lg">Open App</Button>
<Link href="/buy">
<Button size="lg">Buy CallbackCloser</Button>
</Link>
<Link href="/sign-up">
<Link href="/app/leads">
<Button size="lg" variant="outline">
Create Account
Open App
</Button>
</Link>
</div>
Expand Down Expand Up @@ -52,6 +52,9 @@ export default function LandingPage() {
<Link className="underline underline-offset-4" href="/refund">
Refund
</Link>
<Link className="underline underline-offset-4" href="/contact">
Contact
</Link>
</footer>
</div>
</main>
Expand Down
6 changes: 5 additions & 1 deletion app/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ export default function PrivacyPage() {
</section>

<footer className="text-sm text-muted-foreground">
Contact: <a className="underline" href="mailto:support@callbackcloser.com">support@callbackcloser.com</a> ·{' '}
Support: <a className="underline" href="mailto:support@callbackcloser.com">support@callbackcloser.com</a> ·{' '}
<Link className="underline" href="/contact">
Contact
</Link>
{' '}·{' '}
<Link className="underline" href="/">
Back to home
</Link>
Expand Down
4 changes: 4 additions & 0 deletions app/refund/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ export default function RefundPolicyPage() {

<footer className="text-sm text-muted-foreground">
Support: <a className="underline" href="mailto:support@callbackcloser.com">support@callbackcloser.com</a> ·{' '}
<Link className="underline" href="/contact">
Contact
</Link>
{' '}·{' '}
<Link className="underline" href="/">
Back to home
</Link>
Expand Down
4 changes: 4 additions & 0 deletions app/terms/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ export default function TermsPage() {

<footer className="text-sm text-muted-foreground">
Questions: <a className="underline" href="mailto:support@callbackcloser.com">support@callbackcloser.com</a> ·{' '}
<Link className="underline" href="/contact">
Contact
</Link>
{' '}·{' '}
<Link className="underline" href="/">
Back to home
</Link>
Expand Down
Loading