Skip to content

Commit c77987a

Browse files
authored
Merge pull request #23 from willwearing/feat/landing-site-custom-domains
Premium landing page, SEO/AEO, DB-driven brands, domain provisioning
2 parents c0383a0 + fc3941c commit c77987a

43 files changed

Lines changed: 2301 additions & 301 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci-deploy.yml

Lines changed: 11 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ concurrency:
1111
cancel-in-progress: true
1212

1313
jobs:
14-
# ─── Tests ───────────────────────────────────────────
1514
test:
1615
name: Test
1716
runs-on: ubuntu-latest
@@ -31,15 +30,20 @@ jobs:
3130
- name: Generate Prisma client
3231
run: cd backend && bun x prisma generate
3332

33+
- name: Type check backend
34+
run: cd backend && bun x tsc -p tsconfig.build.json --noEmit
35+
36+
- name: Type check frontend
37+
run: cd apps/web && bun x tsc --noEmit
38+
3439
- name: Run backend tests
3540
run: cd backend && bun run test
3641

3742
- name: Run frontend tests
3843
run: cd apps/web && bun run test
3944

40-
# ─── Deploy backend to Railway (only on main) ───────
41-
deploy-backend:
42-
name: Deploy Backend (Railway)
45+
deploy:
46+
name: Deploy
4347
needs: test
4448
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
4549
runs-on: ubuntu-latest
@@ -49,35 +53,10 @@ jobs:
4953
- name: Install Railway CLI
5054
run: npm install -g @railway/cli
5155

52-
- name: Deploy to Railway
56+
- name: Deploy backend to Railway
5357
run: railway up --service ${{ vars.RAILWAY_SERVICE_NAME }}
5458
env:
5559
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
5660

57-
# ─── Deploy frontend to Vercel (only on main) ───────
58-
deploy-frontend:
59-
name: Deploy Frontend (Vercel)
60-
needs: test
61-
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
62-
runs-on: ubuntu-latest
63-
steps:
64-
- uses: actions/checkout@v4
65-
66-
- uses: oven-sh/setup-bun@v2
67-
with:
68-
bun-version: "1.3.6"
69-
70-
- name: Install Vercel CLI
71-
run: npm install -g vercel
72-
73-
- name: Pull Vercel env
74-
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
75-
working-directory: apps/web
76-
77-
- name: Build
78-
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
79-
working-directory: apps/web
80-
81-
- name: Deploy
82-
run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
83-
working-directory: apps/web
61+
- name: Deploy frontend to Vercel
62+
run: curl -s -X POST "${{ secrets.VERCEL_DEPLOY_HOOK }}"

apps/web/src/app/(marketing)/page.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { HowItWorks } from "@/components/marketing/how-it-works";
55
import { FAQ } from "@/components/marketing/faq";
66
import { PricingSection } from "@/components/marketing/pricing";
77
import { CTA } from "@/components/marketing/cta";
8-
import { CourseJsonLd, OrganizationJsonLd } from "@/components/seo/json-ld";
8+
import { CourseJsonLd, OrganizationJsonLd, CredentialJsonLd } from "@/components/seo/json-ld";
99

1010
export default async function LandingPage() {
1111
const brand = await resolvePageBrand();
@@ -17,11 +17,22 @@ export default async function LandingPage() {
1717
subheadline={brand.landing.hero.subheadline}
1818
ctaText={brand.landing.hero.ctaText}
1919
/>
20-
<Features features={brand.landing.features} />
21-
<HowItWorks steps={brand.landing.howItWorks} />
20+
<Features
21+
heading={brand.landing.features.heading}
22+
subheading={brand.landing.features.subheading}
23+
features={brand.landing.features.items}
24+
/>
25+
<HowItWorks
26+
heading={brand.landing.howItWorks.heading}
27+
steps={brand.landing.howItWorks.items}
28+
/>
2229
<PricingSection />
2330
<FAQ items={brand.landing.faq} />
24-
<CTA ctaText={brand.landing.hero.ctaText} />
31+
<CTA
32+
ctaText={brand.landing.hero.ctaText}
33+
headline={brand.landing.bottomCta.headline}
34+
subheadline={brand.landing.bottomCta.subheadline}
35+
/>
2536
<CourseJsonLd
2637
name={`${brand.name} -- Audio Exam Prep`}
2738
description={brand.seo.description}
@@ -34,6 +45,13 @@ export default async function LandingPage() {
3445
description={brand.seo.description}
3546
logoUrl={`https://${brand.domain}${brand.logoUrl}`}
3647
/>
48+
<CredentialJsonLd
49+
name={`${brand.name} Certification Prep`}
50+
description={brand.seo.description}
51+
url={`https://${brand.domain}`}
52+
educationalLevel="Professional"
53+
credentialCategory="Professional Certification"
54+
/>
3755
</div>
3856
);
3957
}

apps/web/src/app/globals.css

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,3 +209,44 @@
209209
min-width: 44px;
210210
}
211211
}
212+
213+
/* ── 4th gradient orb ──────────────────────────────────────────────── */
214+
.gradient-mesh .orb-4 {
215+
@apply absolute -bottom-1/4 -right-1/4 h-[350px] w-[350px] rounded-full opacity-25 blur-3xl;
216+
background: radial-gradient(circle, color-mix(in hsl, var(--primary), transparent 50%) 0%, transparent 60%);
217+
animation: float 14s ease-in-out infinite reverse;
218+
}
219+
220+
/* ── Glow pulse for CTA buttons ──────────────────────────────────── */
221+
.glow-pulse {
222+
box-shadow: 0 0 20px color-mix(in hsl, var(--primary), transparent 50%),
223+
0 0 60px color-mix(in hsl, var(--primary), transparent 70%);
224+
animation: glow 3s ease-in-out infinite;
225+
}
226+
227+
@keyframes glow {
228+
0%, 100% { box-shadow: 0 0 20px color-mix(in hsl, var(--primary), transparent 50%), 0 0 60px color-mix(in hsl, var(--primary), transparent 70%); }
229+
50% { box-shadow: 0 0 30px color-mix(in hsl, var(--primary), transparent 30%), 0 0 80px color-mix(in hsl, var(--primary), transparent 50%); }
230+
}
231+
232+
/* ── Connecting line animation for How It Works ──────────────────── */
233+
@keyframes line-grow {
234+
from { height: 0; }
235+
to { height: 100%; }
236+
}
237+
238+
.animate-line-grow {
239+
animation: line-grow 1.5s ease-out forwards;
240+
}
241+
242+
/* ── Scroll-triggered fade-in sections ───────────────────────────── */
243+
.fade-in-section {
244+
opacity: 0;
245+
transform: translateY(24px);
246+
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
247+
}
248+
249+
.fade-in-section.is-visible {
250+
opacity: 1;
251+
transform: translateY(0);
252+
}

apps/web/src/app/layout.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,22 @@ export async function generateMetadata(): Promise<Metadata> {
2828
keywords: brand.seo.keywords,
2929
openGraph: {
3030
type: "website",
31+
locale: "en_US",
32+
siteName: brand.name,
3133
title: brand.seo.title,
3234
description: brand.seo.description,
33-
...(brand.ogImageUrl ? { images: [{ url: brand.ogImageUrl }] } : {}),
35+
...(brand.ogImageUrl
36+
? { images: [{ url: brand.ogImageUrl, width: 1200, height: 630 }] }
37+
: {}),
38+
},
39+
twitter: {
40+
card: "summary_large_image",
41+
title: brand.seo.title,
42+
description: brand.seo.description,
43+
...(brand.ogImageUrl ? { images: [brand.ogImageUrl] } : {}),
44+
},
45+
alternates: {
46+
canonical: `https://${brand.domain}`,
3447
},
3548
};
3649
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, it, expect } from "vitest";
2+
import { generateLlmsTxt } from "../route";
3+
4+
describe("generateLlmsTxt", () => {
5+
it("returns a string containing brand name", () => {
6+
const result = generateLlmsTxt({
7+
name: "FirefighterPrep",
8+
tagline: "Pass Your Firefighter Exam. Eyes-Free.",
9+
description: "Audio-first adaptive learning for NFPA 1001.",
10+
domain: "firefighterprep.audio",
11+
features: ["Audio-First Learning", "Adaptive Engine"],
12+
});
13+
expect(result).toContain("FirefighterPrep");
14+
expect(result).toContain("firefighterprep.audio");
15+
expect(result).toContain("Audio-First Learning");
16+
});
17+
18+
it("uses markdown-like formatting", () => {
19+
const result = generateLlmsTxt({
20+
name: "Test",
21+
tagline: "Tag",
22+
description: "Desc",
23+
domain: "test.com",
24+
features: ["F1"],
25+
});
26+
expect(result).toContain("# Test");
27+
});
28+
29+
it("includes all standard links", () => {
30+
const result = generateLlmsTxt({
31+
name: "TestBrand",
32+
tagline: "A tagline",
33+
description: "A description",
34+
domain: "example.com",
35+
features: [],
36+
});
37+
expect(result).toContain("https://example.com");
38+
expect(result).toContain("https://example.com/sign-up");
39+
expect(result).toContain("https://example.com/pricing");
40+
});
41+
42+
it("formats features as bullet points", () => {
43+
const result = generateLlmsTxt({
44+
name: "Brand",
45+
tagline: "Tag",
46+
description: "Desc",
47+
domain: "brand.com",
48+
features: ["Feature A", "Feature B", "Feature C"],
49+
});
50+
expect(result).toContain("- Feature A");
51+
expect(result).toContain("- Feature B");
52+
expect(result).toContain("- Feature C");
53+
});
54+
55+
it("includes blockquote tagline", () => {
56+
const result = generateLlmsTxt({
57+
name: "Brand",
58+
tagline: "The best tagline ever",
59+
description: "Desc",
60+
domain: "brand.com",
61+
features: [],
62+
});
63+
expect(result).toContain("> The best tagline ever");
64+
});
65+
});

apps/web/src/app/llms.txt/route.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { resolvePageBrand } from "@/lib/brand/resolve";
2+
3+
export interface LlmsTxtInput {
4+
name: string;
5+
tagline: string;
6+
description: string;
7+
domain: string;
8+
features: string[];
9+
}
10+
11+
export function generateLlmsTxt(input: LlmsTxtInput): string {
12+
const lines = [
13+
`# ${input.name}`,
14+
"",
15+
`> ${input.tagline}`,
16+
"",
17+
input.description,
18+
"",
19+
`## What ${input.name} Offers`,
20+
"",
21+
...input.features.map((f) => `- ${f}`),
22+
"",
23+
`## Links`,
24+
"",
25+
`- Website: https://${input.domain}`,
26+
`- Sign Up: https://${input.domain}/sign-up`,
27+
`- Pricing: https://${input.domain}/pricing`,
28+
];
29+
return lines.join("\n");
30+
}
31+
32+
export async function GET() {
33+
const brand = await resolvePageBrand();
34+
35+
// Handle both shapes: { heading, subheading, items } (current) or raw array (legacy)
36+
const featureItems = brand.landing.features.items || brand.landing.features;
37+
const features = Array.isArray(featureItems)
38+
? featureItems.map((f: any) => `${f.title}: ${f.description}`)
39+
: [];
40+
41+
const content = generateLlmsTxt({
42+
name: brand.name,
43+
tagline: brand.tagline,
44+
description: brand.seo.description,
45+
domain: brand.domain,
46+
features,
47+
});
48+
49+
return new Response(content, {
50+
headers: {
51+
"Content-Type": "text/plain; charset=utf-8",
52+
"Cache-Control": "public, max-age=86400",
53+
},
54+
});
55+
}

apps/web/src/app/robots.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,31 +7,27 @@ async function getBaseUrl(): Promise<string> {
77
const headersList = await headers();
88
const hostname = headersList.get("host") || "";
99
if (hostname && hostname !== "localhost") {
10-
const brand = await resolveBrand(hostname);
10+
const cookieHeader = headersList.get("cookie");
11+
const brand = await resolveBrand(hostname, cookieHeader);
1112
return `https://${brand.domain}`;
1213
}
1314
} catch {
14-
// static export or build time — fall back to env
15+
// static export or build time
1516
}
16-
return process.env.NEXT_PUBLIC_SITE_URL || "https://electricianprep.audio";
17+
return process.env.NEXT_PUBLIC_SITE_URL || "https://firefighterprep.audio";
1718
}
1819

1920
export default async function robots(): Promise<MetadataRoute.Robots> {
2021
const BASE_URL = await getBaseUrl();
2122
return {
2223
rules: [
23-
{
24-
userAgent: "*",
25-
allow: "/",
26-
disallow: [
27-
"/dashboard",
28-
"/study",
29-
"/browse",
30-
"/settings",
31-
"/diagnostic",
32-
"/auth",
33-
],
34-
},
24+
{ userAgent: "*", allow: "/", disallow: ["/dashboard", "/study", "/browse", "/settings", "/diagnostic", "/auth"] },
25+
{ userAgent: "ChatGPT-User", allow: "/" },
26+
{ userAgent: "Applebot", allow: "/" },
27+
{ userAgent: "GPTBot", disallow: "/" },
28+
{ userAgent: "Google-Extended", disallow: "/" },
29+
{ userAgent: "CCBot", disallow: "/" },
30+
{ userAgent: "anthropic-ai", disallow: "/" },
3531
],
3632
sitemap: `${BASE_URL}/sitemap.xml`,
3733
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, it, expect } from "vitest";
2+
import { render, screen } from "@testing-library/react";
3+
import { CTA } from "../cta";
4+
5+
describe("CTA", () => {
6+
it("renders headline and subheadline from props", () => {
7+
render(
8+
<CTA ctaText="Start Now" headline="Ready?" subheadline="Join thousands." />,
9+
);
10+
expect(screen.getByText("Ready?")).toBeTruthy();
11+
expect(screen.getByText("Join thousands.")).toBeTruthy();
12+
});
13+
14+
it("renders CTA button with correct text", () => {
15+
render(
16+
<CTA ctaText="Get Started" headline="Go" subheadline="Sub" />,
17+
);
18+
const link = screen.getByRole("link", { name: /get started/i });
19+
expect(link).toBeTruthy();
20+
expect(link.getAttribute("href")).toBe("/sign-up");
21+
});
22+
23+
it("renders free-to-start subtext", () => {
24+
render(
25+
<CTA ctaText="Go" headline="H" subheadline="S" />,
26+
);
27+
expect(screen.getByText(/free to start/i)).toBeTruthy();
28+
});
29+
});

0 commit comments

Comments
 (0)