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:
1 parent
18abeb56dd
commit
9ea3f9fd0a
27 files changed
+656
-419
No files matched your search
+173
-25
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -22,10 +22,12 @@ const ContentContainerContainer = memo<ContentContainerProps>(
|
||||
leadingImageSrc,
|
||||
leadingImageAlt,
|
||||
showLeadingImage: showLeadingImageProp = true,
|
||||
showByline: showBylineProp = true,
|
||||
}) => {
|
||||
const size = sizeProp;
|
||||
const tone = toneProp;
|
||||
const showLeadingImage = showLeadingImageProp;
|
||||
const showByline = showBylineProp;
|
||||
const width =
|
||||
widthProp ?? (size === "useCase" ? "100%" : "200px");
|
||||
const onLight = tone === "onLight";
|
||||
@@ -119,6 +121,7 @@ const ContentContainerContainer = memo<ContentContainerProps>(
|
||||
iconImage={iconImage}
|
||||
iconAlt={iconAlt}
|
||||
showLeadingImage={showLeadingImage}
|
||||
showByline={showByline}
|
||||
containerClasses={containerClasses}
|
||||
contentGapClasses={contentGapClasses}
|
||||
textGapClasses={textGapClasses}
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface ContentContainerProps {
|
||||
leadingImageAlt?: string;
|
||||
/** When false, omits the icon row above the title. Default true. */
|
||||
showLeadingImage?: boolean;
|
||||
/** When false, omits author and date. Default true. */
|
||||
showByline?: boolean;
|
||||
}
|
||||
|
||||
export interface ContentContainerViewProps {
|
||||
@@ -32,6 +34,7 @@ export interface ContentContainerViewProps {
|
||||
iconImage: string;
|
||||
iconAlt: string;
|
||||
showLeadingImage: boolean;
|
||||
showByline: boolean;
|
||||
containerClasses: string;
|
||||
contentGapClasses: string;
|
||||
textGapClasses: string;
|
||||
|
||||
@@ -8,6 +8,7 @@ function ContentContainerView({
|
||||
iconImage,
|
||||
iconAlt,
|
||||
showLeadingImage,
|
||||
showByline,
|
||||
containerClasses,
|
||||
contentGapClasses,
|
||||
textGapClasses,
|
||||
@@ -18,10 +19,7 @@ function ContentContainerView({
|
||||
formattedDate,
|
||||
}: ContentContainerViewProps) {
|
||||
return (
|
||||
<div
|
||||
className={containerClasses}
|
||||
style={size === "xs" ? {} : { width }}
|
||||
>
|
||||
<div className={containerClasses} style={size === "xs" ? {} : { width }}>
|
||||
{/* Content Container - gap between icon and text */}
|
||||
<div className={contentGapClasses}>
|
||||
{showLeadingImage ? (
|
||||
@@ -45,13 +43,12 @@ function ContentContainerView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 items-end gap-[var(--space-200)]">
|
||||
{/* Author Name */}
|
||||
<span className={authorClasses}>{post.frontmatter.author}</span>
|
||||
|
||||
{/* Date */}
|
||||
<span className={dateClasses}>{formattedDate}</span>
|
||||
</div>
|
||||
{showByline ? (
|
||||
<div className="flex min-w-0 items-end gap-[var(--space-200)]">
|
||||
<span className={authorClasses}>{post.frontmatter.author}</span>
|
||||
<span className={dateClasses}>{formattedDate}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
|
||||
className = "",
|
||||
variant: variantProp = "vertical",
|
||||
sizing: sizingProp = "fluid",
|
||||
href,
|
||||
showByline = true,
|
||||
showLeadingImage = true,
|
||||
leadingImageAlt,
|
||||
}) => {
|
||||
const variant = variantProp;
|
||||
const sizing = sizingProp;
|
||||
@@ -70,6 +74,10 @@ const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
|
||||
sizing={sizing}
|
||||
backgroundImage={backgroundImage}
|
||||
backgroundImageSmd={backgroundImageSmd}
|
||||
href={href ?? `/blog/${post.slug}`}
|
||||
showByline={showByline}
|
||||
showLeadingImage={showLeadingImage}
|
||||
leadingImageAlt={leadingImageAlt}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -20,6 +20,14 @@ export interface ContentThumbnailTemplateProps {
|
||||
*/
|
||||
sizing?: ContentThumbnailTemplateSizingValue;
|
||||
slugOrder?: string[];
|
||||
/** Overrides the default `/blog/[slug]` destination. */
|
||||
href?: string;
|
||||
/** Author and date. Learn hides these until byline dates are approved. */
|
||||
showByline?: boolean;
|
||||
/** Tag mark above the title. Decorative on catalog cards. */
|
||||
showLeadingImage?: boolean;
|
||||
/** Alt for the tag mark. Empty when the mark repeats the title. */
|
||||
leadingImageAlt?: string;
|
||||
}
|
||||
|
||||
export interface ContentThumbnailTemplateViewProps {
|
||||
@@ -30,4 +38,8 @@ export interface ContentThumbnailTemplateViewProps {
|
||||
backgroundImage: string;
|
||||
/** Wide-viewport image source for variant="responsive" (≥smd). */
|
||||
backgroundImageSmd?: string;
|
||||
href: string;
|
||||
showByline: boolean;
|
||||
showLeadingImage: boolean;
|
||||
leadingImageAlt?: string;
|
||||
}
|
||||
@@ -10,15 +10,16 @@ function ContentThumbnailTemplateView({
|
||||
sizing,
|
||||
backgroundImage,
|
||||
backgroundImageSmd,
|
||||
href,
|
||||
showByline,
|
||||
showLeadingImage,
|
||||
leadingImageAlt,
|
||||
}: ContentThumbnailTemplateViewProps) {
|
||||
if (variant === "responsive") {
|
||||
// Single card; <picture> swaps the orientation-specific image at smd
|
||||
// (530px), aspect-ratio and content positioning switch via Tailwind.
|
||||
return (
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className={`group block w-full ${className}`}
|
||||
>
|
||||
<Link href={href} className={`group block w-full ${className}`}>
|
||||
<div className="relative aspect-[320/225.5] w-full overflow-hidden smd:aspect-[260/390]">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<picture>
|
||||
@@ -37,7 +38,13 @@ function ContentThumbnailTemplateView({
|
||||
</picture>
|
||||
</div>
|
||||
<div className="absolute left-[4.375%] top-[6.099%] z-20 w-[71.875%] max-h-[48%] overflow-hidden smd:left-[6.923%] smd:top-[4.615%] smd:w-[76.923%]">
|
||||
<ContentContainer post={post} size="xs" />
|
||||
<ContentContainer
|
||||
post={post}
|
||||
size="xs"
|
||||
showByline={showByline}
|
||||
showLeadingImage={showLeadingImage}
|
||||
leadingImageAlt={leadingImageAlt}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -47,10 +54,7 @@ function ContentThumbnailTemplateView({
|
||||
if (variant === "vertical") {
|
||||
if (sizing === "fixed") {
|
||||
return (
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className={`group block ${className}`}
|
||||
>
|
||||
<Link href={href} className={`group block ${className}`}>
|
||||
<div className="relative h-[390px] w-[260px] overflow-hidden">
|
||||
<div className="absolute inset-0 z-0">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
@@ -61,7 +65,14 @@ function ContentThumbnailTemplateView({
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute left-[18px] top-[18px] z-20 w-[200px]">
|
||||
<ContentContainer post={post} width="200px" size="xs" />
|
||||
<ContentContainer
|
||||
post={post}
|
||||
width="200px"
|
||||
size="xs"
|
||||
showByline={showByline}
|
||||
showLeadingImage={showLeadingImage}
|
||||
leadingImageAlt={leadingImageAlt}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -69,10 +80,7 @@ function ContentThumbnailTemplateView({
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className={`group block w-full ${className}`}
|
||||
>
|
||||
<Link href={href} className={`group block w-full ${className}`}>
|
||||
<div className="relative aspect-[260/390] w-full overflow-hidden">
|
||||
<div className="absolute inset-0 z-0">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
@@ -83,7 +91,13 @@ function ContentThumbnailTemplateView({
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute left-[6.923%] top-[4.615%] z-20 w-[76.923%] max-h-[48%] overflow-hidden">
|
||||
<ContentContainer post={post} size="xs" />
|
||||
<ContentContainer
|
||||
post={post}
|
||||
size="xs"
|
||||
showByline={showByline}
|
||||
showLeadingImage={showLeadingImage}
|
||||
leadingImageAlt={leadingImageAlt}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -92,7 +106,7 @@ function ContentThumbnailTemplateView({
|
||||
|
||||
if (sizing === "fixed") {
|
||||
return (
|
||||
<Link href={`/blog/${post.slug}`} className={`group block ${className}`}>
|
||||
<Link href={href} className={`group block ${className}`}>
|
||||
<div className="relative h-[225.5px] w-[320px] overflow-hidden">
|
||||
<div className="absolute inset-0 z-0">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
@@ -103,7 +117,14 @@ function ContentThumbnailTemplateView({
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute left-[14px] top-[13.75px] z-20 w-[230px]">
|
||||
<ContentContainer post={post} width="230px" size="xs" />
|
||||
<ContentContainer
|
||||
post={post}
|
||||
width="230px"
|
||||
size="xs"
|
||||
showByline={showByline}
|
||||
showLeadingImage={showLeadingImage}
|
||||
leadingImageAlt={leadingImageAlt}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -111,10 +132,7 @@ function ContentThumbnailTemplateView({
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className={`group block w-full ${className}`}
|
||||
>
|
||||
<Link href={href} className={`group block w-full ${className}`}>
|
||||
<div className="relative aspect-[320/225.5] w-full overflow-hidden">
|
||||
<div className="absolute inset-0 z-0">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
@@ -125,7 +143,13 @@ function ContentThumbnailTemplateView({
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute left-[4.375%] top-[6.099%] z-20 w-[71.875%] max-h-[48%] overflow-hidden">
|
||||
<ContentContainer post={post} size="xs" />
|
||||
<ContentContainer
|
||||
post={post}
|
||||
size="xs"
|
||||
showByline={showByline}
|
||||
showLeadingImage={showLeadingImage}
|
||||
leadingImageAlt={leadingImageAlt}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface FaqAccordionItem {
|
||||
export interface FaqAccordionProps {
|
||||
title: string;
|
||||
items: FaqAccordionItem[];
|
||||
/** In-page target, such as the Learn FAQ. */
|
||||
id?: string;
|
||||
size?: AccordionSizeValue;
|
||||
/** Layout accordion size from `lg` (default **m**, Figma 22135-890258). */
|
||||
lgSize?: AccordionSizeValue;
|
||||
|
||||
@@ -17,10 +17,12 @@ function FaqAccordionView({
|
||||
lgSize,
|
||||
xlSize,
|
||||
headingId,
|
||||
id,
|
||||
className = "",
|
||||
}: FaqAccordionViewProps) {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
aria-labelledby={headingId}
|
||||
className={`bg-[var(--color-surface-default-secondary)] px-[var(--space-100)] py-[var(--space-800)] md:px-[var(--space-2400)] md:py-[var(--space-1800)] lg:px-[var(--space-4000)] lg:py-[var(--space-2400)] ${className}`.trim()}
|
||||
>
|
||||
@@ -40,6 +42,7 @@ function FaqAccordionView({
|
||||
size={size}
|
||||
lgSize={lgSize}
|
||||
xlSize={xlSize}
|
||||
defaultOpen={index === 0}
|
||||
>
|
||||
{item.answer}
|
||||
</LayoutAccordion>
|
||||
|
||||
+74
-66
@@ -1,11 +1,13 @@
|
||||
import { Inter, Bricolage_Grotesque } from "next/font/google";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import { headers } from "next/headers";
|
||||
import messages from "../messages/en/index";
|
||||
import { ASSETS, getAssetPath } from "../lib/assetUtils";
|
||||
import {
|
||||
defaultShareImages,
|
||||
metadataBaseUrl,
|
||||
originFromHost,
|
||||
resolveRequestHost,
|
||||
} from "../lib/siteMetadata";
|
||||
import "./globals.css";
|
||||
|
||||
@@ -46,72 +48,80 @@ export const viewport: Viewport = {
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: homeMeta.title,
|
||||
template: messages.metadata.titleTemplate,
|
||||
},
|
||||
description: homeMeta.description,
|
||||
keywords: [...homeMeta.keywords],
|
||||
authors: [{ name: "Media Economies Design Lab" }],
|
||||
creator: "Media Economies Design Lab",
|
||||
publisher: "Media Economies Design Lab",
|
||||
formatDetection: {
|
||||
email: false,
|
||||
address: false,
|
||||
telephone: false,
|
||||
},
|
||||
metadataBase: metadataBaseUrl(),
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: getAssetPath(ASSETS.LOGO), type: "image/svg+xml" },
|
||||
{ url: "/favicon.ico", sizes: "any" },
|
||||
{
|
||||
url: "/favicon-32x32.png",
|
||||
sizes: "32x32",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
url: "/favicon-16x16.png",
|
||||
sizes: "16x16",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
apple: [
|
||||
{
|
||||
url: "/apple-touch-icon.png",
|
||||
sizes: "180x180",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
openGraph: {
|
||||
title: homeMeta.title,
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const headerStore = await headers();
|
||||
const host = resolveRequestHost(
|
||||
headerStore.get("x-forwarded-host"),
|
||||
headerStore.get("host"),
|
||||
);
|
||||
|
||||
return {
|
||||
title: {
|
||||
default: homeMeta.title,
|
||||
template: messages.metadata.titleTemplate,
|
||||
},
|
||||
description: homeMeta.description,
|
||||
url: "/",
|
||||
siteName: messages.metadata.siteName,
|
||||
locale: "en_US",
|
||||
type: "website",
|
||||
images: defaultShareImages(),
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: homeMeta.title,
|
||||
description: homeMeta.description,
|
||||
images: defaultShareImages().map((image) => image.url),
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
keywords: [...homeMeta.keywords],
|
||||
authors: [{ name: "Media Economies Design Lab" }],
|
||||
creator: "Media Economies Design Lab",
|
||||
publisher: "Media Economies Design Lab",
|
||||
formatDetection: {
|
||||
email: false,
|
||||
address: false,
|
||||
telephone: false,
|
||||
},
|
||||
metadataBase: new URL(originFromHost(host)),
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: getAssetPath(ASSETS.LOGO), type: "image/svg+xml" },
|
||||
{ url: "/favicon.ico", sizes: "any" },
|
||||
{
|
||||
url: "/favicon-32x32.png",
|
||||
sizes: "32x32",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
url: "/favicon-16x16.png",
|
||||
sizes: "16x16",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
apple: [
|
||||
{
|
||||
url: "/apple-touch-icon.png",
|
||||
sizes: "180x180",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
openGraph: {
|
||||
title: homeMeta.title,
|
||||
description: homeMeta.description,
|
||||
url: "/",
|
||||
siteName: messages.metadata.siteName,
|
||||
locale: "en_US",
|
||||
type: "website",
|
||||
images: defaultShareImages(),
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: homeMeta.title,
|
||||
description: homeMeta.description,
|
||||
images: defaultShareImages().map((image) => image.url),
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-video-preview": -1,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-video-preview": -1,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
// Load messages for the default locale (single locale setup)
|
||||
@@ -138,9 +148,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
type="image/png"
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${inter.variable} ${bricolageGrotesque.variable}`}
|
||||
>
|
||||
<body className={`${inter.variable} ${bricolageGrotesque.variable}`}>
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-[1920px] flex-col">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user