Skip to content

Commit 92d06c7

Browse files
feat(seo): JSON-LD structured data for course pages (#424) (#486)
- Course + Offer + AggregateRating on the public course detail page, built from data the page already fetches (no new queries) - ItemList on the unfiltered catalog; EducationalOrganization on tenant landing pages (both Puck and fallback branches) - Extract the deterministic cheapest-paid product pick (PR #422) into lib/course-pricing.ts and use it on the catalog too, so cards, detail page, and the structured Offer always agree on price - XSS-safe serializer escapes <, >, & as unicode sequences Claude-Session: https://claude.ai/code/session_018NavuM4mERjDbemsms8ST8 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 6b3c294 commit 92d06c7

5 files changed

Lines changed: 224 additions & 22 deletions

File tree

app/[locale]/(public)/courses/[id]/page.tsx

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ import { getTranslations } from 'next-intl/server';
2525
import { getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
2626
import { hasCourseAccess } from '@/lib/services/course-access'
2727
import type { Metadata } from 'next';
28-
import { buildPageMetadata } from '@/lib/seo';
28+
import { buildPageMetadata, getSeoContext } from '@/lib/seo';
29+
import { pickCourseProduct } from '@/lib/course-pricing';
30+
import { JsonLd, courseJsonLd } from '@/lib/structured-data';
2931
import { AutoFreeEnrollButton, FreeEnrollButton } from '@/components/public/free-enroll-button';
3032
import { PlanEnrollButton } from '@/components/public/plan-enroll-button';
3133
import { createAdminClient } from '@/lib/supabase/admin';
@@ -83,7 +85,7 @@ interface Lesson {
8385
}
8486

8587
export default async function CourseDetailsPage(props: {
86-
params: Promise<{ id: string }>;
88+
params: Promise<{ id: string; locale: string }>;
8789
searchParams: Promise<{ enroll?: string }>;
8890
}) {
8991
const [params, searchParams, t, supabase, userId, tenantId] = await Promise.all([
@@ -167,13 +169,14 @@ export default async function CourseDetailsPage(props: {
167169
})()
168170
: Promise.resolve(false);
169171

170-
const [{ data: author }, hasAccess, { data: productCourses }, planCoversCourse, socialProof, { data: lessonRows }] = await Promise.all([
172+
const [{ data: author }, hasAccess, { data: productCourses }, planCoversCourse, socialProof, { data: lessonRows }, seo] = await Promise.all([
171173
authorPromise,
172174
accessPromise,
173175
productCoursesPromise,
174176
planCoveragePromise,
175177
getCourseSocialProof(courseId, tenantId),
176178
lessonsPromise,
179+
getSeoContext(),
177180
]);
178181

179182
const { averageRating, reviewCount, studentCount, recentReviews } = socialProof;
@@ -184,14 +187,9 @@ export default async function CourseDetailsPage(props: {
184187
const estimatedMinutes = (totalLessons * 10) % 60;
185188

186189
type CourseProduct = { price: number | string; currency: string | null };
187-
const linkedProducts = (productCourses ?? [])
188-
.map(({ product }) => product as unknown as CourseProduct | null)
189-
.filter((product): product is CourseProduct => product !== null);
190-
// Deterministic pick: cheapest paid product (catalog cards should agree).
191-
const paidProducts = linkedProducts
192-
.filter((product) => Number(product.price) > 0)
193-
.sort((a, b) => Number(a.price) - Number(b.price));
194-
const courseProduct = paidProducts[0] ?? linkedProducts[0];
190+
const courseProduct = pickCourseProduct(
191+
(productCourses ?? []).map(({ product }) => product as unknown as CourseProduct | null)
192+
);
195193
const isFree = !courseProduct || Number(courseProduct.price) === 0;
196194
const priceDisplay = isFree
197195
? t('pricing.free')
@@ -213,8 +211,27 @@ export default async function CourseDetailsPage(props: {
213211
? new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'long', day: 'numeric' }).format(new Date(course.published_at))
214212
: null;
215213

214+
// schema.org Course rich-result markup. Offer mirrors the deterministic
215+
// product pick above so the structured price always matches the page.
216+
const courseUrl = `${seo.baseUrl}/${params.locale}/courses/${course.course_id}`;
217+
const structuredData = courseJsonLd({
218+
name: course.title,
219+
description: course.description,
220+
url: courseUrl,
221+
image: course.thumbnail_url,
222+
providerName: seo.siteName,
223+
providerUrl: seo.baseUrl,
224+
datePublished: course.published_at,
225+
price: !isFree && courseProduct ? Number(courseProduct.price) : null,
226+
currency: !isFree && courseProduct ? courseProduct.currency : null,
227+
isFree,
228+
averageRating,
229+
reviewCount,
230+
});
231+
216232
return (
217233
<div className="min-h-screen bg-[#09090b] text-zinc-100 font-sans">
234+
<JsonLd data={structuredData} />
218235
{/* Breadcrumbs */}
219236
<nav aria-label="Breadcrumb" className="bg-[#18181b]/50 border-b border-zinc-800">
220237
<div className="container mx-auto px-4 py-3 flex items-center gap-2 text-sm text-zinc-400">

app/[locale]/(public)/courses/page.tsx

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import { CourseSearchBar } from "@/components/shared/course-search-bar";
66
import { Search } from "lucide-react";
77
import { getTranslations } from 'next-intl/server';
88
import type { Metadata } from 'next';
9-
import { buildPageMetadata } from '@/lib/seo';
9+
import { buildPageMetadata, getSeoContext } from '@/lib/seo';
10+
import { pickCourseProduct, type LinkedProduct } from '@/lib/course-pricing';
11+
import { JsonLd, itemListJsonLd } from '@/lib/structured-data';
1012

1113
export const dynamic = 'force-dynamic';
1214

@@ -17,10 +19,13 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
1719
}
1820

1921
export default async function CoursesPage({
22+
params,
2023
searchParams,
2124
}: {
25+
params: Promise<{ locale: string }>
2226
searchParams: Promise<{ search?: string; category?: string }>
2327
}) {
28+
const { locale } = await params;
2429
const { search, category } = await searchParams;
2530
const t = await getTranslations('coursesCatalog');
2631
const tSearch = await getTranslations('courseSearch');
@@ -77,7 +82,7 @@ export default async function CoursesPage({
7782

7883
// Fetch product prices for all courses in one query
7984
const courseIds = courses?.map(c => c.course_id) || [];
80-
let productMap: Record<number, { price: number; currency: string }> = {};
85+
const productMap: Record<number, { price: number; currency: string | null }> = {};
8186

8287
// Published-lesson counts via admin client: the anon role can only read
8388
// preview lessons (RLS), so a nested select would undercount for visitors.
@@ -101,12 +106,19 @@ export default async function CoursesPage({
101106
.in('course_id', courseIds);
102107

103108
if (productCourses) {
109+
const productsByCourse: Record<number, LinkedProduct[]> = {};
104110
for (const pc of productCourses) {
105-
const product = pc.product as any;
106-
if (product && !productMap[pc.course_id]) {
107-
productMap[pc.course_id] = {
108-
price: parseFloat(product.price),
109-
currency: product.currency
111+
const product = pc.product as unknown as LinkedProduct | null;
112+
if (product) (productsByCourse[pc.course_id] ??= []).push(product);
113+
}
114+
// Same deterministic pick as the course detail page (cheapest paid
115+
// product), so cards and detail never show different prices.
116+
for (const [courseId, products] of Object.entries(productsByCourse)) {
117+
const picked = pickCourseProduct(products);
118+
if (picked) {
119+
productMap[Number(courseId)] = {
120+
price: Number(picked.price),
121+
currency: picked.currency
110122
};
111123
}
112124
}
@@ -132,8 +144,19 @@ export default async function CoursesPage({
132144

133145
const hasActiveFilters = sanitizedSearch || category;
134146

147+
// ItemList rich-result markup for the unfiltered catalog only — filtered
148+
// views are ephemeral search results, not the canonical course list.
149+
const { baseUrl } = await getSeoContext();
150+
const catalogStructuredData = !hasActiveFilters && enrichedCourses.length > 0
151+
? itemListJsonLd(enrichedCourses.map((course) => ({
152+
name: course.title,
153+
url: `${baseUrl}/${locale}/courses/${course.course_id}`,
154+
})))
155+
: null;
156+
135157
return (
136158
<div className="min-h-screen bg-[#09090b] text-zinc-100">
159+
{catalogStructuredData && <JsonLd data={catalogStructuredData} />}
137160
<div className="container mx-auto py-16 px-4 md:px-8">
138161
{/* Header */}
139162
<div className="mb-12 space-y-4 max-w-2xl">

app/[locale]/(public)/page.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ import { createClient } from "@/lib/supabase/server";
3535
import { createAdminClient } from "@/lib/supabase/admin";
3636
import { SchoolLandingPage } from "@/components/public/school-landing-page";
3737
import type { Metadata } from "next";
38-
import { buildPageMetadata } from "@/lib/seo";
38+
import { buildPageMetadata, getRequestBaseUrl } from "@/lib/seo";
39+
import { JsonLd, organizationJsonLd } from "@/lib/structured-data";
3940
import { PuckPageRenderer } from "@/components/public/landing-page/puck-page-renderer";
4041
import { getLandingData } from "@/lib/puck/utils/landing-data";
4142

@@ -52,8 +53,13 @@ export default async function LandingPage() {
5253
// Branch to school landing page on subdomains
5354
const tenantId = await getCurrentTenantId()
5455
if (tenantId !== DEFAULT_TENANT_ID) {
55-
const [tenant, supabase] = await Promise.all([getCurrentTenant(), createClient()])
56+
const [tenant, supabase, baseUrl] = await Promise.all([getCurrentTenant(), createClient(), getRequestBaseUrl()])
5657
if (tenant) {
58+
const orgStructuredData = organizationJsonLd({
59+
name: tenant.name,
60+
url: baseUrl,
61+
logo: tenant.logo_url,
62+
})
5763
// Check if tenant has a paid plan with a custom active landing page
5864
if (PAID_PLANS.includes(tenant.plan)) {
5965
const adminClient = createAdminClient()
@@ -66,7 +72,12 @@ export default async function LandingPage() {
6672
.maybeSingle()
6773
if (customPage?.puck_data && typeof customPage.puck_data === 'object') {
6874
const landingData = await getLandingData(tenantId)
69-
return <PuckPageRenderer data={customPage.puck_data as any} landingData={landingData} />
75+
return (
76+
<>
77+
<JsonLd data={orgStructuredData} />
78+
<PuckPageRenderer data={customPage.puck_data as any} landingData={landingData} />
79+
</>
80+
)
7081
}
7182
}
7283

@@ -78,7 +89,12 @@ export default async function LandingPage() {
7889
.eq('status', 'active')
7990
.order('created_at', { ascending: false })
8091
.limit(9)
81-
return <SchoolLandingPage tenant={tenant} products={products ?? []} />
92+
return (
93+
<>
94+
<JsonLd data={orgStructuredData} />
95+
<SchoolLandingPage tenant={tenant} products={products ?? []} />
96+
</>
97+
)
8298
}
8399
}
84100

lib/course-pricing.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export interface LinkedProduct {
2+
price: number | string
3+
currency: string | null
4+
}
5+
6+
/**
7+
* Deterministic product pick for a course linked to multiple products
8+
* (product_courses is many-to-many — never assume one row): the cheapest
9+
* paid product wins, else the first linked product, else null. The catalog
10+
* cards, the course detail page, and the JSON-LD Offer must all agree on
11+
* the price a visitor sees, so they all go through this helper.
12+
*/
13+
export function pickCourseProduct<T extends LinkedProduct>(products: (T | null | undefined)[]): T | null {
14+
const linked = products.filter((product): product is T => product != null)
15+
const paid = linked
16+
.filter((product) => Number(product.price) > 0)
17+
.sort((a, b) => Number(a.price) - Number(b.price))
18+
return paid[0] ?? linked[0] ?? null
19+
}

lib/structured-data.tsx

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import type { ReactElement } from 'react'
2+
3+
/**
4+
* schema.org JSON-LD builders + renderer for public SEO surfaces.
5+
* Builders return plain objects; <JsonLd> serializes them into a
6+
* <script type="application/ld+json"> tag with XSS-safe escaping.
7+
*/
8+
9+
type JsonLdNode = Record<string, unknown>
10+
11+
/**
12+
* Serialize for embedding inside a <script> tag. User-controlled strings
13+
* (course titles, descriptions, tenant names) could contain `</script>` or
14+
* HTML — escape the dangerous characters as unicode sequences, which JSON
15+
* parsers (and search engines) read identically.
16+
*/
17+
export function serializeJsonLd(data: JsonLdNode): string {
18+
return JSON.stringify(data)
19+
.replace(/</g, '\\u003c')
20+
.replace(/>/g, '\\u003e')
21+
.replace(/&/g, '\\u0026')
22+
.replace(/\u2028/g, '\\u2028')
23+
.replace(/\u2029/g, '\\u2029')
24+
}
25+
26+
export function JsonLd({ data }: { data: JsonLdNode }): ReactElement {
27+
return (
28+
<script
29+
type="application/ld+json"
30+
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
31+
/>
32+
)
33+
}
34+
35+
interface CourseJsonLdInput {
36+
name: string
37+
description?: string | null
38+
/** Absolute URL of the course page. */
39+
url: string
40+
image?: string | null
41+
providerName: string
42+
/** Absolute base URL of the tenant site. */
43+
providerUrl: string
44+
datePublished?: string | null
45+
/** Real price/currency of the deterministic product pick; null when free. */
46+
price?: number | null
47+
currency?: string | null
48+
isFree: boolean
49+
/** Only emitted when there is at least one review. */
50+
averageRating?: number | null
51+
reviewCount?: number
52+
}
53+
54+
export function courseJsonLd(input: CourseJsonLdInput): JsonLdNode {
55+
const node: JsonLdNode = {
56+
'@context': 'https://schema.org',
57+
'@type': 'Course',
58+
name: input.name,
59+
url: input.url,
60+
provider: {
61+
'@type': 'EducationalOrganization',
62+
name: input.providerName,
63+
url: input.providerUrl,
64+
},
65+
}
66+
if (input.description) node.description = input.description
67+
if (input.image) node.image = input.image
68+
if (input.datePublished) node.datePublished = input.datePublished
69+
70+
if (input.isFree) {
71+
node.isAccessibleForFree = true
72+
node.offers = { '@type': 'Offer', category: 'Free', price: 0 }
73+
} else if (input.price != null && input.currency) {
74+
node.offers = {
75+
'@type': 'Offer',
76+
category: 'Paid',
77+
price: input.price,
78+
priceCurrency: input.currency.toUpperCase(),
79+
availability: 'https://schema.org/InStock',
80+
url: input.url,
81+
}
82+
}
83+
84+
if (
85+
input.averageRating != null &&
86+
typeof input.reviewCount === 'number' &&
87+
input.reviewCount > 0
88+
) {
89+
node.aggregateRating = {
90+
'@type': 'AggregateRating',
91+
ratingValue: Math.round(input.averageRating * 10) / 10,
92+
reviewCount: input.reviewCount,
93+
bestRating: 5,
94+
worstRating: 1,
95+
}
96+
}
97+
98+
return node
99+
}
100+
101+
export function itemListJsonLd(items: { url: string; name: string }[]): JsonLdNode {
102+
return {
103+
'@context': 'https://schema.org',
104+
'@type': 'ItemList',
105+
itemListElement: items.map((item, index) => ({
106+
'@type': 'ListItem',
107+
position: index + 1,
108+
name: item.name,
109+
url: item.url,
110+
})),
111+
}
112+
}
113+
114+
export function organizationJsonLd(input: {
115+
name: string
116+
url: string
117+
logo?: string | null
118+
}): JsonLdNode {
119+
const node: JsonLdNode = {
120+
'@context': 'https://schema.org',
121+
'@type': 'EducationalOrganization',
122+
name: input.name,
123+
url: input.url,
124+
}
125+
if (input.logo) node.logo = input.logo
126+
return node
127+
}

0 commit comments

Comments
 (0)