From 7a307938fda853081d6c7ba5662fb4ef506d8f5c Mon Sep 17 00:00:00 2001 From: andrewmuratov Date: Fri, 2 Oct 2026 18:28:00 -0400 Subject: [PATCH 1/3] feat(marketing): add registry-driven university editions Co-authored-by: Codex --- scripts/build-seo-pages.ts | 85 ++- scripts/check-seo-output.ts | 51 +- src/components/MarketingLandingImpl.tsx | 494 +----------- src/components/UniversityMarketingHome.tsx | 356 +++++++++ src/components/university-marketing-home.css | 749 +++++++++++++++++++ src/main.tsx | 22 + src/routes/_app.index.tsx | 30 +- src/universities/registry.ts | 65 +- tests/auth-branding.test.ts | 6 +- tests/ecosystem-consistency.test.ts | 18 +- tests/marketing-home.test.ts | 60 +- universities.json | 346 ++++++++- vite.config.ts | 40 +- 13 files changed, 1713 insertions(+), 609 deletions(-) create mode 100644 src/components/UniversityMarketingHome.tsx create mode 100644 src/components/university-marketing-home.css diff --git a/scripts/build-seo-pages.ts b/scripts/build-seo-pages.ts index f6f18193..551e74f0 100644 --- a/scripts/build-seo-pages.ts +++ b/scripts/build-seo-pages.ts @@ -6,7 +6,24 @@ const SITE_ORIGIN = "https://gapwise.ca"; const GITHUB_ORGANIZATION = "https://github.com/GapwiseHQ"; const GITHUB_CORE = `${GITHUB_ORGANIZATION}/gapwise`; +type UniversityMarketing = { + campusName: string; + headline: string; + description: string; + seoTitle: string; + seoDescription: string; +}; + const universitiesManifest = JSON.parse(await readFile("universities.json", "utf8")) as { + sites: Array<{ + role: string; + canonicalHost: string; + universityId?: string; + campusId?: string; + name?: string; + shortName?: string; + presentation?: { marketing?: UniversityMarketing }; + }>; universities: Array<{ id: string; name: string; @@ -15,6 +32,7 @@ const universitiesManifest = JSON.parse(await readFile("universities.json", "utf hosts: string[]; campuses: string[]; enabledFeatures?: { routing?: boolean }; + marketing: UniversityMarketing; }>; }; const supportedUniversityCount = universitiesManifest.universities.length; @@ -30,48 +48,37 @@ export type UniversityContext = { origin: string; routable: boolean; campusName?: string; + marketing: UniversityMarketing; }; +const uoftUniversity = universitiesManifest.universities.find( + (university) => university.id === "uoft", +)!; +const uoftHub = universitiesManifest.sites.find( + (site) => site.role === "university-hub" && site.universityId === "uoft", +)!; export const UOFT_CONTEXT: UniversityContext = { id: "uoft", - name: "University of Toronto", - shortName: "U of T", - origin: "https://uoft.gapwise.ca", + name: uoftUniversity.name, + shortName: uoftUniversity.shortName, + origin: `https://${uoftHub.canonicalHost}`, routable: true, + marketing: uoftHub.presentation?.marketing ?? uoftUniversity.marketing, }; -const UOFT_CAMPUS_EDITIONS = [ - { - campusId: "utm", - context: { - ...UOFT_CONTEXT, - name: "University of Toronto Mississauga", - shortName: "UTM", - origin: "https://utm.gapwise.ca", - campusName: "Mississauga campus", - }, - }, - { - campusId: "utsg", - context: { - ...UOFT_CONTEXT, - name: "University of Toronto St. George", - shortName: "UTSG", - origin: "https://utsg.gapwise.ca", - campusName: "St. George campus", - }, - }, - { - campusId: "utsc", +const UOFT_CAMPUS_EDITIONS = universitiesManifest.sites + .filter((site) => site.role === "campus-edition" && site.universityId === "uoft") + .map((site) => ({ + campusId: site.campusId!, context: { ...UOFT_CONTEXT, - name: "University of Toronto Scarborough", - shortName: "UTSC", - origin: "https://utsc.gapwise.ca", - campusName: "Scarborough campus", + name: site.name!, + shortName: site.shortName!, + origin: `https://${site.canonicalHost}`, + campusName: site.presentation!.marketing!.campusName, + marketing: site.presentation!.marketing!, }, - }, -] as const satisfies ReadonlyArray<{ campusId: string; context: UniversityContext }>; + })) satisfies ReadonlyArray<{ campusId: string; context: UniversityContext }>; function socialImageUrl(uniContext?: UniversityContext) { if (!uniContext) return `${SITE_ORIGIN}/og-gapwise.png`; @@ -380,6 +387,7 @@ function metadata(page: SeoPage, uniContext?: UniversityContext) { : ""; return ` + ${title} @@ -400,7 +408,8 @@ function metadata(page: SeoPage, uniContext?: UniversityContext) { - ${schema}`; + ${schema} + `; } function fallback(page: SeoPage, uniContext?: UniversityContext) { @@ -560,11 +569,10 @@ function getUniversityPage(page: SeoPage, uni: UniversityContext): SeoPage { if (page.path === "/") { return { path: "/", - title: `Gapwise for ${uni.name} — Timetable & Campus Navigation`, - description: `Gapwise is a free and open-source timetable, campus navigation, and student planning platform for ${uni.name} students.`, - heading: "Make the time between classes count.", - detail: - "Import your class schedule, understand the usable time between classes, and explore source-backed campus maps. Guest mode and a demo work without an account.", + title: uni.marketing.seoTitle, + description: uni.marketing.seoDescription, + heading: uni.marketing.headline, + detail: uni.marketing.description, sections: [ { title: "Your timetable, connected to campus context", @@ -693,6 +701,7 @@ for (const uni of universitiesManifest.universities) { shortName: uni.shortName, origin: uniOrigin, routable: Boolean(uni.enabledFeatures?.routing), + marketing: uni.marketing, }; const uniSitemapUrl = `${uniOrigin}/sitemap.xml`; diff --git a/scripts/check-seo-output.ts b/scripts/check-seo-output.ts index 8845454b..9d97aaf0 100644 --- a/scripts/check-seo-output.ts +++ b/scripts/check-seo-output.ts @@ -41,7 +41,21 @@ function requireReference( } const universityManifest = JSON.parse(await readFile("universities.json", "utf8")) as { - universities: Array<{ id: string; name: string; hosts: string[]; status: string }>; + sites: Array<{ + role: string; + universityId?: string; + campusId?: string; + name?: string; + canonicalHost: string; + presentation?: { marketing?: { seoTitle: string; seoDescription: string } }; + }>; + universities: Array<{ + id: string; + name: string; + hosts: string[]; + status: string; + marketing: { seoTitle: string; seoDescription: string }; + }>; }; const supportedUniversityEntries = universityManifest.universities.filter( (university) => university.status === "supported", @@ -185,23 +199,15 @@ requireText(robots, "Disallow: /api/", "robots.txt"); requireText(robots, "Disallow: /oauth/", "robots.txt"); requireText(robots, "Sitemap: https://gapwise.ca/sitemap.xml", "robots.txt"); -const UOFT_CAMPUS_EDITIONS = [ - { - id: "utm", - name: "University of Toronto Mississauga", - origin: "https://utm.gapwise.ca", - }, - { - id: "utsg", - name: "University of Toronto St. George", - origin: "https://utsg.gapwise.ca", - }, - { - id: "utsc", - name: "University of Toronto Scarborough", - origin: "https://utsc.gapwise.ca", - }, -] as const; +const UOFT_CAMPUS_EDITIONS = universityManifest.sites + .filter((site) => site.role === "campus-edition" && site.universityId === "uoft") + .map((site) => ({ + id: site.campusId!, + name: site.name!, + origin: `https://${site.canonicalHost}`, + seoTitle: site.presentation!.marketing!.seoTitle, + seoDescription: site.presentation!.marketing!.seoDescription, + })); const CAMPUS_EDITION_PATHS = [ "/about", @@ -229,7 +235,7 @@ for (const campus of UOFT_CAMPUS_EDITIONS) { requireText( campusHome, - `Gapwise for ${campus.name} — Timetable & Campus Navigation`, + `${escapeHtml(campus.seoTitle)}`, `${campus.id} homepage title`, ); requireText( @@ -250,7 +256,7 @@ for (const campus of UOFT_CAMPUS_EDITIONS) { requireText(campusRobots, `Sitemap: ${campus.origin}/sitemap.xml`, `${campus.id} robots.txt`); requireText(campusSitemap, `${campus.origin}/`, `${campus.id} sitemap`); - const expectedDescription = `Gapwise is a free and open-source timetable, campus navigation, and student planning platform for ${campus.name} students.`; + const expectedDescription = escapeHtml(campus.seoDescription); requireText( campusHome, `name="description" content="${expectedDescription}"`, @@ -462,6 +468,9 @@ const ALL_UNIVERSITY_IDS = supportedUniversityEntries.map((university) => univer const ALL_UNIVERSITY_NAMES = Object.fromEntries( supportedUniversityEntries.map((university) => [university.id, university.name]), ) as Record; +const ALL_UNIVERSITY_TITLES = Object.fromEntries( + supportedUniversityEntries.map((university) => [university.id, university.marketing.seoTitle]), +) as Record; const ALL_UNIVERSITY_ORIGINS = Object.fromEntries( supportedUniversityEntries.map((university) => [university.id, `https://${university.hosts[0]}`]), ) as Record; @@ -517,7 +526,7 @@ for (const uniId of ALL_UNIVERSITY_IDS) { ``, `${uniId} twitter:image:alt`, ); - const expectedTitle = `Gapwise for ${escapedName} — Timetable & Campus Navigation`; + const expectedTitle = escapeHtml(ALL_UNIVERSITY_TITLES[uniId]!); requireText(html, ``, `${uniId} og:title`); requireText(html, `${expectedTitle}`, `${uniId} title`); diff --git a/src/components/MarketingLandingImpl.tsx b/src/components/MarketingLandingImpl.tsx index 4a7f22b0..15984677 100644 --- a/src/components/MarketingLandingImpl.tsx +++ b/src/components/MarketingLandingImpl.tsx @@ -1,490 +1,12 @@ -import { Link } from "@tanstack/react-router"; -import { useEffect, useRef, useState } from "react"; -import { UploadPanel } from "@/components/UploadPanel"; -import type { MarketingLandingProps } from "./MarketingLanding"; +import { activeSite } from "@/universities/registry"; import { GlobalMarketingHome } from "./GlobalMarketingHome"; -import { - activeUniversity, - activeSite, - SITES, - supportedUniversities, - canonicalUrlForUniversity, -} from "@/universities/registry"; -import "./marketing-landing.css"; - -function ProductMark() { - return