diff --git a/README.md b/README.md index 94fb231..5ea66be 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/app/app/billing/page.tsx b/app/app/billing/page.tsx index 164c77c..afb207c 100644 --- a/app/app/billing/page.tsx +++ b/app/app/billing/page.tsx @@ -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, @@ -18,12 +19,23 @@ function planPrice(priceId: string | undefined) { return priceId ? 'Configured via Stripe Price ID' : 'Missing env var'; } +function parseRequestedPlan(searchParams?: Record) { + 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 }) { 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] @@ -51,8 +63,29 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec {error ?
{error}
: null} - {checkout === 'success' ?
Stripe checkout completed. Webhook sync may take a few seconds.
: null} - {checkout === 'canceled' ?
Checkout canceled.
: null} + {requestedPlan ? ( +
+ Selected plan: {requestedPlan === 'starter' ? 'Starter' : 'Pro'}. Continue checkout below. +
+ ) : null} + {checkoutSucceeded && subscriptionActive ? ( +
+ Subscription is active. Next steps: connect your Twilio number in Business Settings, then monitor new leads in{' '} + Dashboard. +
+ ) : null} + {checkoutSucceeded && !subscriptionActive ? ( +
+

Stripe checkout completed. Subscription status is still syncing from webhook events.

+

If this does not update shortly, refresh this page and verify `STRIPE_WEBHOOK_SECRET` + webhook endpoint configuration.

+
+ + + +
+
+ ) : null} + {checkoutCanceled ?
Checkout canceled. You can restart anytime below.
: null} @@ -86,7 +119,7 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec
- + Starter Basic missed-call SMS follow-up and dashboard access. @@ -103,7 +136,7 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec - + Pro Higher volume and premium support workflows. diff --git a/app/app/onboarding/actions.ts b/app/app/onboarding/actions.ts index c5c46e7..ff4623f 100644 --- a/app/app/onboarding/actions.ts +++ b/app/app/onboarding/actions.ts @@ -7,12 +7,30 @@ 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')}`); @@ -20,5 +38,5 @@ export async function saveOnboardingAction(formData: FormData) { await upsertBusinessForOwner(userId, parsed.data); revalidatePath('/app'); - redirect('/app/leads'); + redirect(postOnboardingRedirect); } diff --git a/app/app/onboarding/page.tsx b/app/app/onboarding/page.tsx index 2ae97fa..fb873ee 100644 --- a/app/app/onboarding/page.tsx +++ b/app/app/onboarding/page.tsx @@ -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; +}) { 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 (
@@ -27,12 +46,13 @@ export default async function OnboardingPage({ searchParams }: { searchParams?: Set the call forwarding and SMS qualification defaults. - {searchParams?.error ? ( + {error ? (
- {searchParams.error} + {error}
) : null}
+
diff --git a/app/buy/page.tsx b/app/buy/page.tsx new file mode 100644 index 0000000..cd5f453 --- /dev/null +++ b/app/buy/page.tsx @@ -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): 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 }) { + 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); +} diff --git a/app/contact/page.tsx b/app/contact/page.tsx new file mode 100644 index 0000000..ebed43d --- /dev/null +++ b/app/contact/page.tsx @@ -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 ( +
+
+
+

Contact

+

Effective date: {EFFECTIVE_DATE}

+
+ +
+

Support

+

+ Email {SUPPORT_EMAIL} and include your business name, account email, and a brief description of your request. +

+
+ +
+

Billing and Refund Questions

+

+ Include the charge date, last 4 digits of the card (if available), and any relevant Stripe receipt details. +

+
+ +
+

Privacy Requests

+

+ For data access, correction, or deletion requests, include your account email and business identifier so we can verify ownership. +

+
+ +
+ + Back to home + +
+
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index bd4a1d7..5fc5e6e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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.

- - + + - +
@@ -52,6 +52,9 @@ export default function LandingPage() { Refund + + Contact +
diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index 8e20b25..1e0c42b 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -40,7 +40,11 @@ export default function PrivacyPage() {