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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
NEXT_PUBLIC_APP_URL: https://example.com
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/callbackcloser?sslmode=require
DIRECT_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/callbackcloser?sslmode=require
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: pk_test_placeholder
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: pk_test_Y2xlcmsuZXhhbXBsZS5jb20k
CLERK_SECRET_KEY: sk_test_placeholder
STRIPE_SECRET_KEY: sk_test_placeholder
STRIPE_WEBHOOK_SECRET: whsec_placeholder
Expand All @@ -28,7 +28,7 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: npm

- run: npm ci
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,9 @@ Prisma models included:
## Useful Routes

- `/` - landing page
- `/terms` - terms of service
- `/privacy` - privacy policy
- `/refund` - refund policy
- `/sign-in` - Clerk sign-in
- `/sign-up` - Clerk sign-up
- `/app/onboarding` - create business record
Expand Down
23 changes: 22 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { validateServerEnv } from '@/lib/env.server';

import './globals.css';

const CLERK_PREVIEW_FALLBACK_KEY = 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k';

const manrope = Manrope({
subsets: ['latin'],
variable: '--font-sans',
Expand All @@ -16,11 +18,30 @@ export const metadata: Metadata = {
description: 'Missed Call -> Booked Job SMS follow-up',
};

function isLikelyValidClerkPublishableKey(value: string) {
return /^pk_(test|live)_[A-Za-z0-9+/=_-]+$/.test(value);
}

function resolveClerkPublishableKey() {
const configured = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY?.trim() ?? '';
if (configured && isLikelyValidClerkPublishableKey(configured)) {
return configured;
}

const allowPreviewFallback = process.env.NODE_ENV !== 'production' || process.env.VERCEL_ENV === 'preview';
if (allowPreviewFallback) {
return CLERK_PREVIEW_FALLBACK_KEY;
}

return configured;
}

export default function RootLayout({ children }: { children: React.ReactNode }) {
validateServerEnv();
const clerkPublishableKey = resolveClerkPublishableKey();

return (
<ClerkProvider>
<ClerkProvider publishableKey={clerkPublishableKey}>
<html lang="en">
<body className={`${manrope.variable} min-h-screen font-sans`}>{children}</body>
</html>
Expand Down
80 changes: 47 additions & 33 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,39 +6,53 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
export default function LandingPage() {
return (
<main className="container flex min-h-screen items-center py-16">
<div className="grid w-full gap-8 lg:grid-cols-[1.1fr_0.9fr]">
<section className="space-y-6">
<p className="inline-flex rounded-full border bg-card px-3 py-1 text-xs font-semibold uppercase tracking-[0.15em] text-primary">
CallbackCloser
</p>
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">
Missed Call to Booked Job with automated SMS follow-up.
</h1>
<p className="max-w-2xl text-lg text-muted-foreground">
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>
<Link href="/sign-up">
<Button size="lg" variant="outline">
Create Account
</Button>
</Link>
</div>
</section>
<Card className="border-primary/20 bg-white/90 backdrop-blur">
<CardHeader>
<CardTitle>How it works</CardTitle>
<CardDescription>Built for home service businesses using Twilio, Stripe, Clerk, and Prisma.</CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div className="rounded-lg border bg-muted/50 p-4">1. Incoming call hits your Twilio number and forwards to your business line.</div>
<div className="rounded-lg border bg-muted/50 p-4">2. If unanswered, a lead is created and SMS qualification starts automatically.</div>
<div className="rounded-lg border bg-muted/50 p-4">3. Owner gets a summary text and can track leads inside the dashboard.</div>
</CardContent>
</Card>
<div className="grid w-full gap-10">
<div className="grid w-full gap-8 lg:grid-cols-[1.1fr_0.9fr]">
<section className="space-y-6">
<p className="inline-flex rounded-full border bg-card px-3 py-1 text-xs font-semibold uppercase tracking-[0.15em] text-primary">
CallbackCloser
</p>
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">
Missed Call to Booked Job with automated SMS follow-up.
</h1>
<p className="max-w-2xl text-lg text-muted-foreground">
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>
<Link href="/sign-up">
<Button size="lg" variant="outline">
Create Account
</Button>
</Link>
</div>
</section>
<Card className="border-primary/20 bg-white/90 backdrop-blur">
<CardHeader>
<CardTitle>How it works</CardTitle>
<CardDescription>Built for home service businesses using Twilio, Stripe, Clerk, and Prisma.</CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div className="rounded-lg border bg-muted/50 p-4">1. Incoming call hits your Twilio number and forwards to your business line.</div>
<div className="rounded-lg border bg-muted/50 p-4">2. If unanswered, a lead is created and SMS qualification starts automatically.</div>
<div className="rounded-lg border bg-muted/50 p-4">3. Owner gets a summary text and can track leads inside the dashboard.</div>
</CardContent>
</Card>
</div>
<footer className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<span>Legal:</span>
<Link className="underline underline-offset-4" href="/terms">
Terms
</Link>
<Link className="underline underline-offset-4" href="/privacy">
Privacy
</Link>
<Link className="underline underline-offset-4" href="/refund">
Refund
</Link>
</footer>
</div>
</main>
);
Expand Down
51 changes: 51 additions & 0 deletions app/privacy/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Link from 'next/link';

const EFFECTIVE_DATE = 'March 2, 2026';

export default function PrivacyPage() {
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">Privacy Policy</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">Information We Collect</h2>
<p className="text-sm text-muted-foreground">
We collect account details, call/message metadata, lead qualification responses, and billing-related identifiers needed to operate CallbackCloser.
</p>
</section>

<section className="space-y-2">
<h2 className="text-xl font-semibold">How We Use Data</h2>
<p className="text-sm text-muted-foreground">
Data is used to deliver automation workflows, surface leads in the dashboard, maintain service reliability, and support account operations.
</p>
</section>

<section className="space-y-2">
<h2 className="text-xl font-semibold">Data Sharing</h2>
<p className="text-sm text-muted-foreground">
CallbackCloser uses service providers (for example Twilio, Stripe, Clerk, and Neon) solely to provide the platform. We do not sell your data.
</p>
</section>

<section className="space-y-2">
<h2 className="text-xl font-semibold">Data Requests</h2>
<p className="text-sm text-muted-foreground">
For access, correction, or deletion requests, contact support and include your business name and account email.
</p>
</section>

<footer className="text-sm text-muted-foreground">
Contact: <a className="underline" href="mailto:support@callbackcloser.com">support@callbackcloser.com</a> ·{' '}
<Link className="underline" href="/">
Back to home
</Link>
</footer>
</article>
</main>
);
}
51 changes: 51 additions & 0 deletions app/refund/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Link from 'next/link';

const EFFECTIVE_DATE = 'March 2, 2026';

export default function RefundPolicyPage() {
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">Refund Policy</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">Subscription Charges</h2>
<p className="text-sm text-muted-foreground">
CallbackCloser is billed as a recurring subscription through Stripe. Charges apply according to your selected plan and billing cycle.
</p>
</section>

<section className="space-y-2">
<h2 className="text-xl font-semibold">Cancellation Timing</h2>
<p className="text-sm text-muted-foreground">
You may cancel at any time. Cancellation stops future renewals and access continues through the current paid period unless otherwise stated.
</p>
</section>

<section className="space-y-2">
<h2 className="text-xl font-semibold">Refund Requests</h2>
<p className="text-sm text-muted-foreground">
Refunds are reviewed case-by-case for duplicate billing, platform defects, or accidental charges. Approved refunds are issued to the original payment method.
</p>
</section>

<section className="space-y-2">
<h2 className="text-xl font-semibold">How to Request</h2>
<p className="text-sm text-muted-foreground">
Email support with your account email, business name, charge date, and the reason for your request.
</p>
</section>

<footer className="text-sm text-muted-foreground">
Support: <a className="underline" href="mailto:support@callbackcloser.com">support@callbackcloser.com</a> ·{' '}
<Link className="underline" href="/">
Back to home
</Link>
</footer>
</article>
</main>
);
}
51 changes: 51 additions & 0 deletions app/terms/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Link from 'next/link';

const EFFECTIVE_DATE = 'March 2, 2026';

export default function TermsPage() {
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">Terms of Service</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">Service Scope</h2>
<p className="text-sm text-muted-foreground">
CallbackCloser provides automation tools for missed-call follow-up workflows, including SMS messaging and lead tracking.
</p>
</section>

<section className="space-y-2">
<h2 className="text-xl font-semibold">Acceptable Use</h2>
<p className="text-sm text-muted-foreground">
You are responsible for lawful use of the platform, including consent, opt-out compliance, and messaging rules required by your jurisdiction.
</p>
</section>

<section className="space-y-2">
<h2 className="text-xl font-semibold">Billing</h2>
<p className="text-sm text-muted-foreground">
Subscription charges are processed through Stripe. Plan changes and cancellations are handled through the billing portal.
</p>
</section>

<section className="space-y-2">
<h2 className="text-xl font-semibold">Limitation of Liability</h2>
<p className="text-sm text-muted-foreground">
The service is provided as-is. CallbackCloser is not liable for indirect or consequential damages arising from use of the platform.
</p>
</section>

<footer className="text-sm text-muted-foreground">
Questions: <a className="underline" href="mailto:support@callbackcloser.com">support@callbackcloser.com</a> ·{' '}
<Link className="underline" href="/">
Back to home
</Link>
</footer>
</article>
</main>
);
}
34 changes: 34 additions & 0 deletions docs/PRODUCTION_READINESS_GAPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -419,3 +419,37 @@ Dependencies: G4 (recommended)
- `docs/PRODUCTION_READINESS_GAPS.md`
- Commit SHA:
- `fd9ca8e`

- 2026-03-02 - G12 (DONE)
- Branch: `hardening/g12-legal-pages`
- What changed:
- Added public legal pages:
- `app/terms/page.tsx`
- `app/privacy/page.tsx`
- `app/refund/page.tsx`
- Added legal links to the public landing page footer:
- `app/page.tsx`
- Added lightweight route/content coverage test:
- `tests/legal-pages.test.ts`
- Updated route docs:
- `README.md`
- Verification notes:
- Build output confirms static generation for `/terms`, `/privacy`, and `/refund`.
- Landing page now exposes direct legal navigation links for compliance visibility.
- Commands run + results:
- `npm test` -> PASS (34/34)
- `npm run lint` -> PASS
- `npm run build` -> PASS
- `npm run typecheck` -> PASS
- `npm run env:check` -> PASS
- `npm run db:validate` -> PASS
- Files touched:
- `app/terms/page.tsx`
- `app/privacy/page.tsx`
- `app/refund/page.tsx`
- `app/page.tsx`
- `tests/legal-pages.test.ts`
- `README.md`
- `docs/PRODUCTION_READINESS_GAPS.md`
- Commit SHA:
- `91846f3`
18 changes: 18 additions & 0 deletions tests/legal-pages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import test from 'node:test';

function read(relativePath: string) {
return readFileSync(path.join(process.cwd(), relativePath), 'utf8');
}

test('legal public pages exist with required headings', () => {
const terms = read('app/terms/page.tsx');
const privacy = read('app/privacy/page.tsx');
const refund = read('app/refund/page.tsx');

assert.match(terms, /Terms of Service/);
assert.match(privacy, /Privacy Policy/);
assert.match(refund, /Refund Policy/);
});