Skip to content

Commit 8317d5c

Browse files
authored
fix(growth): improve acquisition discoverability (#129)
* fix(growth): improve acquisition discoverability * test(web): cover mobile course builder layout
1 parent 7ed1313 commit 8317d5c

19 files changed

Lines changed: 654 additions & 120 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { expect, test, type Locator } from "@playwright/test";
2+
3+
async function expectFullyWithinViewport(locator: Locator) {
4+
const bounds = await locator.evaluate((element) => {
5+
const rect = element.getBoundingClientRect();
6+
return {
7+
left: rect.left,
8+
right: rect.right,
9+
viewportWidth: document.documentElement.clientWidth,
10+
};
11+
});
12+
13+
expect(bounds.left).toBeGreaterThanOrEqual(0);
14+
expect(bounds.right).toBeLessThanOrEqual(bounds.viewportWidth);
15+
}
16+
17+
test.describe("AI course builder responsive layout", () => {
18+
for (const width of [320, 390, 768, 1280]) {
19+
test(`keeps workflow content visible at ${width}px`, async ({ page }) => {
20+
await page.setViewportSize({ width, height: 900 });
21+
await page.goto("/ai-course-builder");
22+
23+
await expectFullyWithinViewport(
24+
page.getByRole("heading", {
25+
level: 2,
26+
name: "From source material to a live course",
27+
}),
28+
);
29+
await expectFullyWithinViewport(page.getByText("Terminal", { exact: true }));
30+
31+
for (const card of await page.locator("ol > li").all()) {
32+
await expectFullyWithinViewport(card);
33+
}
34+
});
35+
}
36+
});

apps/web/e2e/pricing.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ test.describe("Pricing page", () => {
66
});
77

88
test("renders pricing heading and plan details", async ({ page }) => {
9-
await expect(page.locator("#pricing h2")).toBeVisible();
9+
await expect(page.locator("#pricing h1")).toBeVisible();
1010
const priceSignals = page.getByText(/\$\d+|70\/30/);
1111
await expect(priceSignals.first()).toBeVisible();
1212
});

apps/web/e2e/seo-smoke.spec.ts

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,31 @@ test.describe("SEO Smoke Tests", () => {
5353
);
5454
});
5555

56+
test("AI course builder stays usable on desktop and mobile", async ({ page }) => {
57+
const runtimeErrors: string[] = [];
58+
page.on("console", (message) => {
59+
if (message.type() === "error") runtimeErrors.push(message.text());
60+
});
61+
page.on("pageerror", (error) => runtimeErrors.push(error.message));
62+
63+
for (const viewport of [
64+
{ width: 1280, height: 900 },
65+
{ width: 390, height: 844 },
66+
]) {
67+
await page.setViewportSize(viewport);
68+
await page.goto("/ai-course-builder");
69+
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
70+
await expect(page.getByRole("link", { name: "Build your first course" })).toBeVisible();
71+
expect(
72+
await page.evaluate(
73+
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
74+
),
75+
).toBe(true);
76+
}
77+
78+
expect(runtimeErrors).toEqual([]);
79+
});
80+
5681
test("docs pages have meta tags", async ({ page }) => {
5782
await page.goto("/docs/cli");
5883
const title = await page.title();
@@ -65,14 +90,22 @@ test.describe("SEO Smoke Tests", () => {
6590
test("homepage has JSON-LD structured data", async ({ page }) => {
6691
await page.goto("/");
6792
const jsonLd = await page.locator('script[type="application/ld+json"]').allTextContents();
68-
// JSON-LD may not be present on all brand configs — skip if absent
93+
// JSON-LD can vary across brand configurations.
6994
if (jsonLd.length === 0) {
7095
test.skip(true, "No JSON-LD on this brand's homepage");
7196
return;
7297
}
7398
// Verify at least one schema parses as valid JSON
7499
const data = JSON.parse(jsonLd[0]);
75100
expect(data["@context"]).toBe("https://schema.org");
101+
102+
const schemas = jsonLd.map((value) => JSON.parse(value));
103+
const schemaTypes = schemas.map((schema) => schema["@type"]);
104+
expect(schemaTypes.filter((type) => type === "SoftwareApplication")).toHaveLength(1);
105+
expect(schemaTypes).not.toContain("Course");
106+
expect(schemaTypes).not.toContain("EducationalOccupationalCredential");
107+
const website = schemas.find((schema) => schema["@type"] === "WebSite");
108+
expect(website?.potentialAction).toBeUndefined();
76109
});
77110

78111
test("sitemap.xml returns valid XML", async ({ request }) => {
@@ -93,16 +126,22 @@ test.describe("SEO Smoke Tests", () => {
93126
expect(body).toContain("Allow: /");
94127
// Crawlers need access to private pages to read their noindex metadata.
95128
expect(body).not.toContain("Disallow:");
129+
for (const userAgent of [
130+
"OAI-SearchBot",
131+
"ClaudeBot",
132+
"Claude-User",
133+
"Claude-SearchBot",
134+
"PerplexityBot",
135+
"Perplexity-User",
136+
]) {
137+
expect(body).toContain(`User-Agent: ${userAgent}`);
138+
}
96139
});
97140

98141
test("llms.txt is accessible", async ({ request }) => {
99142
const res = await request.get("/llms.txt");
100-
// llms.txt may 500 in dev if brand resolution fails — skip in that case
101-
if (res.status() === 500) {
102-
test.skip(true, "llms.txt route errors in dev (brand resolution)");
103-
return;
104-
}
105143
expect(res.status()).toBe(200);
144+
expect(res.headers()["content-type"]).toContain("text/markdown");
106145
const body = await res.text();
107146
expect(body.toLowerCase()).toContain("graspful");
108147
});

apps/web/public/llms.txt

Lines changed: 0 additions & 39 deletions
This file was deleted.

apps/web/src/__tests__/components/marketing/pricing.test.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,16 @@ describe("PricingSection", () => {
6666
expect(ctaLink?.tagName).toBe("A");
6767
expect(ctaLink?.querySelector("button")).toBeNull();
6868
});
69+
70+
it("can render the section heading as the page h1", () => {
71+
render(
72+
<ThemeProvider>
73+
<BrandProvider brand={firefighterBrand}>
74+
<PricingSection headingLevel="h1" />
75+
</BrandProvider>
76+
</ThemeProvider>,
77+
);
78+
79+
expect(screen.getByRole("heading", { level: 1, name: "Simple Pricing" })).toBeDefined();
80+
});
6981
});

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge";
66
import { getPublicAcademyCatalog } from "@/lib/public-academies";
77

88
export const metadata: Metadata = {
9-
title: "Academies | Graspful",
9+
title: { absolute: "Academies | Graspful" },
1010
description:
1111
"Browse public academies and their course tracks across the Graspful network.",
1212
alternates: { canonical: "https://graspful.ai/academies" },

apps/web/src/app/(marketing)/ai-course-builder/page.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,8 @@ export default function AiCourseBuilderPage() {
165165

166166
<section className="mx-auto max-w-6xl px-6 py-20 md:py-28">
167167
<div className="grid gap-10 lg:grid-cols-[0.85fr_1.15fr] lg:items-start">
168-
<div>
169-
<p className="text-sm font-semibold text-primary">The workflow</p>
168+
<div className="min-w-0">
169+
<p className="text-sm font-semibold text-sky-700 dark:text-sky-300">The workflow</p>
170170
<h2 className="mt-2 text-3xl font-bold tracking-[-0.035em] sm:text-4xl">
171171
From source material to a live course
172172
</h2>
@@ -228,13 +228,13 @@ graspful review course.yaml
228228
<section className="border-y border-border/40 bg-muted/35">
229229
<div className="mx-auto grid max-w-6xl gap-12 px-6 py-20 md:py-28 lg:grid-cols-2 lg:items-center">
230230
<div>
231-
<p className="text-sm font-semibold text-primary">
231+
<p className="text-sm font-semibold text-sky-700 dark:text-sky-300">
232232
Built for outcomes
233233
</p>
234234
<h2 className="mt-2 text-3xl font-bold tracking-[-0.035em] sm:text-4xl">
235235
Generation is only the first step
236236
</h2>
237-
<p className="mt-4 leading-7 text-muted-foreground">
237+
<p className="mt-4 leading-7 text-foreground/80">
238238
A course needs a coherent learning sequence, enough practice, and
239239
evidence that each learner has mastered the foundations. Graspful
240240
checks the course before publishing and adapts it after launch.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { MarketingNav } from "@/components/marketing/nav";
33
import { MarketingFooter } from "@/components/marketing/footer";
44
import { MarketingThemeForcer } from "@/components/marketing/theme-forcer";
55

6-
const forceLight = `(function(){var s=localStorage.getItem("theme-preference");if(!s){document.documentElement.classList.remove("dark");document.documentElement.style.colorScheme="light";document.body.style.background="#fff";document.body.style.color="#0F172A"}})()`;
6+
const forceLight = `(function(){var s=localStorage.getItem("theme-preference");if(!s){document.documentElement.classList.remove("dark");document.documentElement.style.colorScheme="light"}})()`;
77

88
export default function MarketingLayout({
99
children,

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

Lines changed: 9 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@ import { ScrollDepthTracker } from "@/components/marketing/tracking";
1010
import {
1111
CourseJsonLd,
1212
OrganizationJsonLd,
13-
CredentialJsonLd,
1413
WebSiteJsonLd,
15-
SoftwareApplicationJsonLd,
1614
FAQPageJsonLd,
1715
} from "@/components/seo/json-ld";
1816

@@ -21,7 +19,7 @@ export async function generateMetadata(): Promise<Metadata> {
2119
const url = `https://${brand.domain}`;
2220

2321
return {
24-
title: brand.seo.title,
22+
title: { absolute: brand.seo.title },
2523
description: brand.seo.description,
2624
keywords: brand.seo.keywords,
2725
openGraph: {
@@ -115,38 +113,23 @@ export default async function LandingPage() {
115113
url={url}
116114
description={brand.seo.description}
117115
/>
118-
<SoftwareApplicationJsonLd
119-
name={brand.name}
120-
description={brand.seo.description}
121-
url={url}
122-
applicationCategory="EducationalApplication"
123-
operatingSystem="Web"
124-
offers={{ price: 0, priceCurrency: "USD" }}
125-
/>
126116
{brand.landing.faq.length > 0 && (
127117
<FAQPageJsonLd items={brand.landing.faq} />
128118
)}
129-
<CourseJsonLd
130-
name={brand.name}
131-
description={brand.seo.description}
132-
provider={brand.name}
133-
url={url}
134-
/>
119+
{!isGraspful && (
120+
<CourseJsonLd
121+
name={brand.name}
122+
description={brand.seo.description}
123+
provider={brand.name}
124+
url={url}
125+
/>
126+
)}
135127
<OrganizationJsonLd
136128
name={brand.name}
137129
url={url}
138130
description={brand.seo.description}
139131
logoUrl={`${url}${brand.logoUrl}`}
140132
/>
141-
{isGraspful && (
142-
<CredentialJsonLd
143-
name={`${brand.name} Certification Prep`}
144-
description={brand.seo.description}
145-
url={url}
146-
educationalLevel="Professional"
147-
credentialCategory="Professional Certification"
148-
/>
149-
)}
150133
<ScrollDepthTracker />
151134
</div>
152135
);

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export async function generateMetadata(): Promise<Metadata> {
1111
title: "Pricing",
1212
description: `Plans and pricing for ${brand.name}. Free to create courses. 70/30 revenue share when learners pay.`,
1313
openGraph: {
14-
title: `Pricing ${brand.name}`,
14+
title: `Pricing: ${brand.name}`,
1515
description: `Free to create. 70/30 revenue share when learners pay. Plans and pricing for ${brand.name}.`,
1616
url,
1717
images: brand.ogImageUrl
@@ -27,7 +27,7 @@ export async function generateMetadata(): Promise<Metadata> {
2727
},
2828
twitter: {
2929
card: "summary_large_image",
30-
title: `Pricing ${brand.name}`,
30+
title: `Pricing: ${brand.name}`,
3131
description: `Free to create. 70/30 revenue share when learners pay.`,
3232
images: brand.ogImageUrl ? [brand.ogImageUrl] : [],
3333
},
@@ -40,7 +40,7 @@ export async function generateMetadata(): Promise<Metadata> {
4040
export default function PricingPage() {
4141
return (
4242
<div className="bg-background text-foreground">
43-
<PricingSection />
43+
<PricingSection headingLevel="h1" />
4444
<div className="mx-auto max-w-3xl px-6 pb-16 text-center">
4545
<p className="text-sm text-muted-foreground">
4646
Ready to start?{" "}

0 commit comments

Comments
 (0)