Move the use-case index onto Learn so the pilot stories stay reachable, and make share images resolve on the host that serves the page.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilalloandCursor committed 2026-11-03 20:28:04 -07:00
1 parent 18abeb56dd
commit 9ea3f9fd0a
27 files changed
+656 -419

No files matched your search

+173 -25
View File
@@ -1,59 +1,207 @@
import type { Metadata } from "next";
import Link from "next/link";
import messages from "../../../messages/en/index";
import { getTranslation } from "../../../lib/i18n/getTranslation";
import { getAllBlogPosts, type BlogPost } from "../../../lib/content";
import { routeMetadata } from "../../../lib/siteMetadata";
import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate";
import PageHeader from "../../components/type/PageHeader";
import CaseStudy from "../../components/cards/CaseStudy";
import UseCasesOrgs from "../../components/sections/UseCasesOrgs";
import Groups from "../../components/sections/Groups";
import type { GroupsItem } from "../../components/sections/Groups";
import TripleStep from "../../components/type/TripleStep";
import ContentLockup from "../../components/type/ContentLockup";
import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate";
import TripleTextBlock from "../../components/type/TripleTextBlock";
import type { TripleTextBlockColumn } from "../../components/type/TripleTextBlock";
import FaqAccordion from "../../components/sections/Accordion";
import AskOrganizer from "../../components/sections/AskOrganizer";
import { getAllBlogPosts } from "../../../lib/content";
import WorkerCoopMark from "../../../public/assets/vector/worker-coop.svg";
import MutualAidMark from "../../../public/assets/vector/mutual-aid.svg";
import OpenSourceMark from "../../../public/assets/vector/open-source.svg";
import DaoMark from "../../../public/assets/vector/dao.svg";
/**
* Figma: "Learn" (22083:854797). Use-cases index sections, the guides, and
* the FAQ share this page. Pilot stories stay at /use-cases/[slug].
*/
export const metadata: Metadata = routeMetadata("/learn", {
title: messages.metadata.learn.title,
description: messages.metadata.learn.description,
});
const CASE_STUDY_TILE_RADIUS_CLASS = "rounded-[23.093px]";
const CASE_STUDY_LINK_CLASS = [
CASE_STUDY_TILE_RADIUS_CLASS,
"block shrink-0 cursor-pointer outline-none transition-[opacity,transform] duration-200",
"hover:opacity-95",
"focus-visible:ring-2 focus-visible:ring-[var(--color-border-default-brand-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]",
"active:scale-[0.98]",
].join(" ");
const GROUP_MARKS = [
WorkerCoopMark,
MutualAidMark,
OpenSourceMark,
DaoMark,
] as const;
function asArray<T>(value: unknown): T[] {
return Array.isArray(value) ? value : [];
}
export default function LearnPage() {
const allPosts = getAllBlogPosts();
const page = messages.pages.learn;
const summaries = page.articleSummaries as Record<string, string>;
const t = (key: string) => getTranslation(messages, key);
const groupItems: GroupsItem[] = page.groups.items.map((item, index) => {
const Mark = GROUP_MARKS[index] ?? GROUP_MARKS[0];
return {
...item,
icon: (
<Mark
aria-hidden
className="block size-9 shrink-0"
width={36}
height={36}
/>
),
};
});
const contentLockupData = {
title: t("pages.learn.contentLockup.title"),
subtitle: t("pages.learn.contentLockup.subtitle"),
variant: "learn" as const,
alignment: "left" as const,
const postsBySlug = new Map(
getAllBlogPosts().map((post) => [post.slug, post]),
);
const guidePost: BlogPost = {
slug: "how-communityrule-works",
frontmatter: {
title: page.guideCard.title,
description: page.guideCard.summary,
author: "",
date: "2026-11-01",
thumbnail: {
vertical: "how-communityrule-works-vertical.svg",
horizontal: "how-communityrule-works-horizontal.svg",
},
},
content: "",
htmlContent: "",
filePath: "messages/en/pages/learn.json#guideCard",
lastModified: new Date("2026-11-01"),
};
const articlePosts = page.articleOrder.flatMap((slug) => {
const post = postsBySlug.get(slug);
if (!post) return [];
const summary = summaries[slug];
if (!summary || summary === post.frontmatter.description) return [post];
return [
{
...post,
frontmatter: { ...post.frontmatter, description: summary },
},
];
});
const askOrganizerData = {
title: t("pages.learn.askOrganizer.title"),
subtitle: t("pages.learn.askOrganizer.subtitle"),
description: t("pages.learn.askOrganizer.description"),
buttonText: t("pages.learn.askOrganizer.buttonText"),
variant: "centered" as const,
};
const caseStudyLinks = page.caseStudyTiles.links;
const caseStudySurfaces = ["lavender", "neutral", "rose"] as const;
const caseStudyAlts = [
page.caseStudyTiles.mutualAidColoradoAlt,
page.caseStudyTiles.foodNotBombsAlt,
page.caseStudyTiles.boulderStreetMedicsAlt,
];
return (
<div className="min-h-screen bg-[var(--color-surface-default-primary)]">
<ContentLockup {...contentLockupData} />
<PageHeader
title={page.pageHeader.title}
headingAlign="center"
sectionMinimal
singleLineTitleFromLg
/>
<UseCasesOrgs>
{caseStudySurfaces.map((surface, index) => {
const link = caseStudyLinks[index];
const card = (
<CaseStudy surface={surface} imageAlt={caseStudyAlts[index]} />
);
if (!link?.href) {
return <div key={surface}>{card}</div>;
}
return (
<Link
key={surface}
href={link.href}
aria-label={link.ariaLabel}
className={CASE_STUDY_LINK_CLASS}
>
{card}
</Link>
);
})}
</UseCasesOrgs>
<Groups title={page.groups.title} items={groupItems} />
<TripleStep
heading={page.tripleStep.heading}
steps={asArray<{ title: string; body: string }>(page.tripleStep.steps)}
ctaText={page.tripleStep.ctaText}
ctaHref={page.tripleStep.ctaHref}
/>
<ContentLockup
title={page.contentLockup.title}
subtitle={page.contentLockup.subtitle}
ctaText={page.contentLockup.ctaText}
ctaHref={page.contentLockup.ctaHref}
variant="learn"
alignment="left"
headingLevel="h2"
/>
{/*
* Single responsive render: ContentThumbnailTemplate variant="responsive"
* uses <picture> to swap horizontal/vertical art at smd (530px). Wrap and
* center leftover cards so a short last row is not left-aligned.
*/}
<div
data-testid="learn-article-grid"
className="flex flex-col space-y-[var(--space-050)] sm:space-y-[var(--space-200)] sm:px-[var(--space-500)] sm:pt-[var(--space-600)] sm:pb-[var(--space-600)] smd:flex-row smd:flex-wrap smd:justify-center smd:gap-[var(--space-200)] smd:space-y-0 smd:px-[var(--space-500)] smd:pt-[var(--space-600)] smd:pb-[var(--space-600)] md:gap-[var(--space-400)] md:px-[var(--space-800)] xmd:gap-[var(--space-300)] lg:gap-[var(--space-400)] lg:px-[var(--space-1800)] lg:pt-[var(--space-800)] lg:pb-[var(--space-1800)] lg2:gap-x-[var(--space-400)] lg2:gap-y-[var(--space-600)] xl:gap-x-[var(--space-400)] xl:gap-y-[var(--space-400)] [&>*]:min-w-0 [&>*]:w-full smd:[&>*]:w-[calc((100%-var(--space-200))/2)] md:[&>*]:w-[calc((100%-var(--space-400))/2)] xmd:[&>*]:w-[calc((100%-2*var(--space-300))/3)] lg:[&>*]:w-[calc((100%-2*var(--space-400))/3)] lg2:[&>*]:w-[calc((100%-3*var(--space-400))/4)] xl:[&>*]:w-[calc((100%-4*var(--space-400))/5)]"
>
{allPosts.map((post) => (
<ContentThumbnailTemplate
post={guidePost}
variant="responsive"
href={page.guideCard.href}
showByline={false}
showLeadingImage={false}
leadingImageAlt=""
/>
{articlePosts.map((post) => (
<ContentThumbnailTemplate
key={post.slug}
post={post}
variant="responsive"
showByline={false}
leadingImageAlt=""
/>
))}
</div>
<AskOrganizer {...askOrganizerData} />
<TripleTextBlock
layoutPreset="useCases"
title={page.tripleTextBlock.title}
ctaText={page.tripleTextBlock.ctaText}
ctaHref={page.tripleTextBlock.ctaHref}
columns={asArray<TripleTextBlockColumn>(page.tripleTextBlock.columns)}
/>
<FaqAccordion id="faq" title={page.faq.title} items={page.faq.items} />
<section className="bg-[var(--color-surface-default-primary)]">
<AskOrganizer
title={page.askOrganizer.title}
subtitle={page.askOrganizer.subtitle}
buttonText={page.askOrganizer.buttonText}
variant="centered"
/>
</section>
</div>
);
}
+7 -4
View File
@@ -26,7 +26,9 @@ export function generateStaticParams() {
return USE_CASE_DETAIL_SLUGS.map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
const { slug } = await params;
if (!isUseCaseDetailSlug(slug)) {
return {};
@@ -59,7 +61,8 @@ export default async function UseCaseDetailPage({ params }: PageProps) {
const syntheticPost = buildUseCaseSyntheticPost(slug, detail);
const { ruleCard, askOrganizer } = entry;
const askVariant = (askOrganizer.variant ?? "use-case-detail") as AskOrganizerVariant;
const askVariant = (askOrganizer.variant ??
"use-case-detail") as AskOrganizerVariant;
const structuredData = {
"@context": "https://schema.org",
@@ -87,8 +90,8 @@ export default async function UseCaseDetailPage({ params }: PageProps) {
{
"@type": "ListItem",
position: 2,
name: "Use cases",
item: "https://communityrule.com/use-cases",
name: "Learn",
item: "https://communityrule.com/learn",
},
{
"@type": "ListItem",
+4 -209
View File
@@ -1,211 +1,6 @@
import type { Metadata } from "next";
import dynamic from "next/dynamic";
import Link from "next/link";
import { Suspense } from "react";
import messages from "../../../messages/en/index";
import { CONTENT_CATALOG_SLUG_ORDER } from "../../../lib/assetUtils";
import { getAllBlogPosts, getRelatedBlogPosts } from "../../../lib/content";
import { routeMetadata } from "../../../lib/siteMetadata";
import PageHeader from "../../components/type/PageHeader";
import CaseStudy from "../../components/cards/CaseStudy";
import UseCasesOrgs from "../../components/sections/UseCasesOrgs";
import QuoteBlock from "../../components/sections/QuoteBlock";
import Groups from "../../components/sections/Groups";
import type { GroupsItem } from "../../components/sections/Groups";
import TripleStep from "../../components/type/TripleStep";
import TripleTextBlock from "../../components/type/TripleTextBlock";
import type { TripleTextBlockColumn } from "../../components/type/TripleTextBlock";
import AskOrganizer from "../../components/sections/AskOrganizer";
import { MarketingRuleStackSection } from "../_components/MarketingRuleStackSection";
import WorkerCoopMark from "../../../public/assets/vector/worker-coop.svg";
import MutualAidMark from "../../../public/assets/vector/mutual-aid.svg";
import OpenSourceMark from "../../../public/assets/vector/open-source.svg";
import DaoMark from "../../../public/assets/vector/dao.svg";
import { redirect } from "next/navigation";
const RelatedArticles = dynamic(
() => import("../../components/sections/RelatedArticles"),
{
loading: () => (
<section className="min-h-[400px] bg-black py-[var(--space-800)]" />
),
ssr: true,
},
);
function asArray<T>(value: unknown): T[] {
return Array.isArray(value) ? value : [];
}
const CASE_STUDY_TILE_RADIUS_CLASS = "rounded-[23.093px]";
const CASE_STUDY_LINK_CLASS = [
CASE_STUDY_TILE_RADIUS_CLASS,
"block shrink-0 cursor-pointer outline-none transition-[opacity,transform] duration-200",
"hover:opacity-95",
"focus-visible:ring-2 focus-visible:ring-[var(--color-border-default-brand-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]",
"active:scale-[0.98]",
].join(" ");
/** Matches `pages.useCases.groups.items` order ↔ inlined vector mark components. */
const USE_CASES_GROUP_MARKS = [
WorkerCoopMark,
MutualAidMark,
OpenSourceMark,
DaoMark,
] as const;
const USE_CASES_RELATED_SENTINEL_SLUG = "__use-cases-page__";
export async function generateMetadata(): Promise<Metadata> {
const title = messages.metadata.useCases.title;
const description = messages.metadata.useCases.description;
const keywords = messages.metadata.useCases.keywords;
return routeMetadata("/use-cases", {
title,
description,
keywords,
openGraph: {
title,
description,
type: "website",
siteName: messages.metadata.siteName,
},
});
}
export default function UseCasesPage() {
const page = messages.pages.useCases;
const tripleColumns = asArray<TripleTextBlockColumn>(page.tripleTextBlock.columns);
const groupItemsRaw = asArray<{ title: string; description: string }>(
page.groups.items,
);
const groupItems: GroupsItem[] = groupItemsRaw.map((item, index) => {
const Mark = USE_CASES_GROUP_MARKS[index] ?? USE_CASES_GROUP_MARKS[0];
return {
...item,
icon: (
<Mark
aria-hidden
className="block size-9 shrink-0"
width={36}
height={36}
/>
),
};
});
const askOrganizerData = {
title: page.askOrganizer.title,
subtitle: page.askOrganizer.subtitle,
buttonText: page.askOrganizer.buttonText,
};
const allPosts = getAllBlogPosts();
const relatedPosts = getRelatedBlogPosts(
USE_CASES_RELATED_SENTINEL_SLUG,
CONTENT_CATALOG_SLUG_ORDER.slice(0, 3),
);
const slugOrder = allPosts.map((p) => p.slug);
const tripleStepSteps = asArray<{ title: string; body: string }>(
page.tripleStep.steps,
);
const caseStudyLinks = asArray<{ href: string; ariaLabel: string }>(
page.caseStudyTiles.links,
);
const caseStudySurfaces = ["lavender", "neutral", "rose"] as const;
const caseStudyAlts = [
page.caseStudyTiles.mutualAidColoradoAlt,
page.caseStudyTiles.foodNotBombsAlt,
page.caseStudyTiles.boulderCountyStreetMedicsAlt,
];
return (
<div className="min-h-screen bg-[var(--color-surface-default-primary)]">
<PageHeader
title={page.pageHeader.title}
headingAlign="center"
sectionMinimal
singleLineTitleFromLg
/>
<UseCasesOrgs>
{caseStudySurfaces.map((surface, index) => {
const link = caseStudyLinks[index];
const card = (
<CaseStudy surface={surface} imageAlt={caseStudyAlts[index]} />
);
if (!link?.href) {
return <div key={surface}>{card}</div>;
}
return (
<Link
key={surface}
href={link.href}
aria-label={link.ariaLabel}
className={CASE_STUDY_LINK_CLASS}
>
{card}
</Link>
);
})}
</UseCasesOrgs>
<QuoteBlock
variant="statement"
id="use-cases-statement-quote"
quote={page.quote.paragraph1}
quoteSecondary={page.quote.paragraph2}
/>
<Groups title={page.groups.title} items={groupItems} />
<TripleStep
heading={page.tripleStep.heading}
steps={tripleStepSteps}
ctaText={page.tripleStep.ctaText}
ctaHref={page.tripleStep.ctaHref}
/>
<div className="bg-[var(--color-surface-default-primary)]">
<Suspense
fallback={
<section className="min-h-[400px] py-[var(--space-800)]" />
}
>
<MarketingRuleStackSection
translationNamespace="pages.useCases.ruleStack"
twoColumnsFromMd
/>
</Suspense>
</div>
<TripleTextBlock
layoutPreset="useCases"
title={page.tripleTextBlock.title}
ctaText={page.tripleTextBlock.ctaText}
ctaHref={page.tripleTextBlock.ctaHref}
columns={tripleColumns}
/>
<div className="bg-black">
<RelatedArticles
relatedPosts={relatedPosts}
currentPostSlug={USE_CASES_RELATED_SENTINEL_SLUG}
slugOrder={slugOrder}
variant="useCases"
/>
</div>
<section className="bg-[var(--color-surface-default-primary)]">
<AskOrganizer {...askOrganizerData} />
</section>
</div>
);
/** The use-cases index lives on Learn. Pilot pages stay at /use-cases/[slug]. */
export default function UseCasesPage(): never {
redirect("/learn");
}