Merge pull request 'Put the use-case index on Learn and match the guide grid' (#91) from adilallo/feature/CR-223-learn-and-use-cases into main
Reviewed-on: #91
This commit was merged in pull request #91.
This commit is contained in:
commit
0b6e8b00dd
28 files changed
+754
-430
No files matched your search
+188
-26
@@ -1,59 +1,221 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
import messages from "../../../messages/en/index";
|
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 { 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 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 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", {
|
export const metadata: Metadata = routeMetadata("/learn", {
|
||||||
title: messages.metadata.learn.title,
|
title: messages.metadata.learn.title,
|
||||||
|
description: messages.metadata.learn.description,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const CASE_STUDY_TILE_RADIUS_CLASS = "rounded-[23.093px]";
|
||||||
|
|
||||||
|
/** Figma card grid: 1 column below 640, 2 from 640, 3 from 1024, 4 from 1440. */
|
||||||
|
const ARTICLE_GRID_CLASS = [
|
||||||
|
"flex flex-col gap-[var(--space-050)]",
|
||||||
|
"md:flex-row md:flex-wrap md:justify-center md:gap-[var(--space-400)]",
|
||||||
|
"md:px-[var(--space-1200)] md:py-[var(--space-600)]",
|
||||||
|
"lg:px-[var(--space-1800)] lg:pt-[var(--space-800)] lg:pb-[var(--space-1800)]",
|
||||||
|
"[&>*]:min-w-0 [&>*]:w-full",
|
||||||
|
"md:[&>*]:w-[calc((100%-var(--space-400))/2)]",
|
||||||
|
"lg:[&>*]:w-[calc((100%-2*var(--space-400))/3)]",
|
||||||
|
"xl:[&>*]:w-[calc((100%-3*var(--space-400))/4)]",
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
const GUIDE_TAG_SRC = "/content/blog/how-communityrule-works-tag.svg";
|
||||||
|
|
||||||
|
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() {
|
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 = {
|
const postsBySlug = new Map(
|
||||||
title: t("pages.learn.contentLockup.title"),
|
getAllBlogPosts().map((post) => [post.slug, post]),
|
||||||
subtitle: t("pages.learn.contentLockup.subtitle"),
|
);
|
||||||
variant: "learn" as const,
|
const guidePost: BlogPost = {
|
||||||
alignment: "left" as const,
|
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 = {
|
const caseStudyLinks = page.caseStudyTiles.links;
|
||||||
title: t("pages.learn.askOrganizer.title"),
|
const caseStudySurfaces = ["lavender", "neutral", "rose"] as const;
|
||||||
subtitle: t("pages.learn.askOrganizer.subtitle"),
|
const caseStudyAlts = [
|
||||||
description: t("pages.learn.askOrganizer.description"),
|
page.caseStudyTiles.mutualAidColoradoAlt,
|
||||||
buttonText: t("pages.learn.askOrganizer.buttonText"),
|
page.caseStudyTiles.foodNotBombsAlt,
|
||||||
variant: "centered" as const,
|
page.caseStudyTiles.boulderStreetMedicsAlt,
|
||||||
};
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--color-surface-default-primary)]">
|
<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
|
<div
|
||||||
data-testid="learn-article-grid"
|
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)]"
|
className={ARTICLE_GRID_CLASS}
|
||||||
>
|
>
|
||||||
{allPosts.map((post) => (
|
<ContentThumbnailTemplate
|
||||||
|
post={guidePost}
|
||||||
|
variant="responsive"
|
||||||
|
href={page.guideCard.href}
|
||||||
|
showByline={false}
|
||||||
|
leadingImageSrc={GUIDE_TAG_SRC}
|
||||||
|
leadingImageAlt=""
|
||||||
|
/>
|
||||||
|
{articlePosts.map((post) => (
|
||||||
<ContentThumbnailTemplate
|
<ContentThumbnailTemplate
|
||||||
key={post.slug}
|
key={post.slug}
|
||||||
post={post}
|
post={post}
|
||||||
variant="responsive"
|
variant="responsive"
|
||||||
|
showByline={false}
|
||||||
|
leadingImageAlt=""
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,9 @@ export function generateStaticParams() {
|
|||||||
return USE_CASE_DETAIL_SLUGS.map((slug) => ({ slug }));
|
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;
|
const { slug } = await params;
|
||||||
if (!isUseCaseDetailSlug(slug)) {
|
if (!isUseCaseDetailSlug(slug)) {
|
||||||
return {};
|
return {};
|
||||||
@@ -59,7 +61,8 @@ export default async function UseCaseDetailPage({ params }: PageProps) {
|
|||||||
const syntheticPost = buildUseCaseSyntheticPost(slug, detail);
|
const syntheticPost = buildUseCaseSyntheticPost(slug, detail);
|
||||||
const { ruleCard, askOrganizer } = entry;
|
const { ruleCard, askOrganizer } = entry;
|
||||||
|
|
||||||
const askVariant = (askOrganizer.variant ?? "use-case-detail") as AskOrganizerVariant;
|
const askVariant = (askOrganizer.variant ??
|
||||||
|
"use-case-detail") as AskOrganizerVariant;
|
||||||
|
|
||||||
const structuredData = {
|
const structuredData = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
@@ -87,8 +90,8 @@ export default async function UseCaseDetailPage({ params }: PageProps) {
|
|||||||
{
|
{
|
||||||
"@type": "ListItem",
|
"@type": "ListItem",
|
||||||
position: 2,
|
position: 2,
|
||||||
name: "Use cases",
|
name: "Learn",
|
||||||
item: "https://communityrule.com/use-cases",
|
item: "https://communityrule.com/learn",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@type": "ListItem",
|
"@type": "ListItem",
|
||||||
|
|||||||
@@ -1,211 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import { redirect } from "next/navigation";
|
||||||
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";
|
|
||||||
|
|
||||||
const RelatedArticles = dynamic(
|
/** The use-cases index lives on Learn. Pilot pages stay at /use-cases/[slug]. */
|
||||||
() => import("../../components/sections/RelatedArticles"),
|
export default function UseCasesPage(): never {
|
||||||
{
|
redirect("/learn");
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -22,10 +22,12 @@ const ContentContainerContainer = memo<ContentContainerProps>(
|
|||||||
leadingImageSrc,
|
leadingImageSrc,
|
||||||
leadingImageAlt,
|
leadingImageAlt,
|
||||||
showLeadingImage: showLeadingImageProp = true,
|
showLeadingImage: showLeadingImageProp = true,
|
||||||
|
showByline: showBylineProp = true,
|
||||||
}) => {
|
}) => {
|
||||||
const size = sizeProp;
|
const size = sizeProp;
|
||||||
const tone = toneProp;
|
const tone = toneProp;
|
||||||
const showLeadingImage = showLeadingImageProp;
|
const showLeadingImage = showLeadingImageProp;
|
||||||
|
const showByline = showBylineProp;
|
||||||
const width =
|
const width =
|
||||||
widthProp ?? (size === "useCase" ? "100%" : "200px");
|
widthProp ?? (size === "useCase" ? "100%" : "200px");
|
||||||
const onLight = tone === "onLight";
|
const onLight = tone === "onLight";
|
||||||
@@ -119,6 +121,7 @@ const ContentContainerContainer = memo<ContentContainerProps>(
|
|||||||
iconImage={iconImage}
|
iconImage={iconImage}
|
||||||
iconAlt={iconAlt}
|
iconAlt={iconAlt}
|
||||||
showLeadingImage={showLeadingImage}
|
showLeadingImage={showLeadingImage}
|
||||||
|
showByline={showByline}
|
||||||
containerClasses={containerClasses}
|
containerClasses={containerClasses}
|
||||||
contentGapClasses={contentGapClasses}
|
contentGapClasses={contentGapClasses}
|
||||||
textGapClasses={textGapClasses}
|
textGapClasses={textGapClasses}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface ContentContainerProps {
|
|||||||
leadingImageAlt?: string;
|
leadingImageAlt?: string;
|
||||||
/** When false, omits the icon row above the title. Default true. */
|
/** When false, omits the icon row above the title. Default true. */
|
||||||
showLeadingImage?: boolean;
|
showLeadingImage?: boolean;
|
||||||
|
/** When false, omits author and date. Default true. */
|
||||||
|
showByline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContentContainerViewProps {
|
export interface ContentContainerViewProps {
|
||||||
@@ -32,6 +34,7 @@ export interface ContentContainerViewProps {
|
|||||||
iconImage: string;
|
iconImage: string;
|
||||||
iconAlt: string;
|
iconAlt: string;
|
||||||
showLeadingImage: boolean;
|
showLeadingImage: boolean;
|
||||||
|
showByline: boolean;
|
||||||
containerClasses: string;
|
containerClasses: string;
|
||||||
contentGapClasses: string;
|
contentGapClasses: string;
|
||||||
textGapClasses: string;
|
textGapClasses: string;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ function ContentContainerView({
|
|||||||
iconImage,
|
iconImage,
|
||||||
iconAlt,
|
iconAlt,
|
||||||
showLeadingImage,
|
showLeadingImage,
|
||||||
|
showByline,
|
||||||
containerClasses,
|
containerClasses,
|
||||||
contentGapClasses,
|
contentGapClasses,
|
||||||
textGapClasses,
|
textGapClasses,
|
||||||
@@ -18,10 +19,7 @@ function ContentContainerView({
|
|||||||
formattedDate,
|
formattedDate,
|
||||||
}: ContentContainerViewProps) {
|
}: ContentContainerViewProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={containerClasses} style={size === "xs" ? {} : { width }}>
|
||||||
className={containerClasses}
|
|
||||||
style={size === "xs" ? {} : { width }}
|
|
||||||
>
|
|
||||||
{/* Content Container - gap between icon and text */}
|
{/* Content Container - gap between icon and text */}
|
||||||
<div className={contentGapClasses}>
|
<div className={contentGapClasses}>
|
||||||
{showLeadingImage ? (
|
{showLeadingImage ? (
|
||||||
@@ -45,13 +43,12 @@ function ContentContainerView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-w-0 items-end gap-[var(--space-200)]">
|
{showByline ? (
|
||||||
{/* Author Name */}
|
<div className="flex min-w-0 items-end gap-[var(--space-200)]">
|
||||||
<span className={authorClasses}>{post.frontmatter.author}</span>
|
<span className={authorClasses}>{post.frontmatter.author}</span>
|
||||||
|
<span className={dateClasses}>{formattedDate}</span>
|
||||||
{/* Date */}
|
</div>
|
||||||
<span className={dateClasses}>{formattedDate}</span>
|
) : null}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -20,6 +20,11 @@ const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
|
|||||||
className = "",
|
className = "",
|
||||||
variant: variantProp = "vertical",
|
variant: variantProp = "vertical",
|
||||||
sizing: sizingProp = "fluid",
|
sizing: sizingProp = "fluid",
|
||||||
|
href,
|
||||||
|
showByline = true,
|
||||||
|
showLeadingImage = true,
|
||||||
|
leadingImageAlt,
|
||||||
|
leadingImageSrc,
|
||||||
}) => {
|
}) => {
|
||||||
const variant = variantProp;
|
const variant = variantProp;
|
||||||
const sizing = sizingProp;
|
const sizing = sizingProp;
|
||||||
@@ -52,7 +57,7 @@ const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// For "responsive", emit both orientations so the <picture> source can
|
// For "responsive", emit both orientations so the <picture> source can
|
||||||
// swap at smd without a second card in the DOM.
|
// swap at md (640px) without a second card in the DOM.
|
||||||
const backgroundImage =
|
const backgroundImage =
|
||||||
variant === "responsive"
|
variant === "responsive"
|
||||||
? getBackgroundImage(post, "horizontal")
|
? getBackgroundImage(post, "horizontal")
|
||||||
@@ -70,6 +75,11 @@ const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
|
|||||||
sizing={sizing}
|
sizing={sizing}
|
||||||
backgroundImage={backgroundImage}
|
backgroundImage={backgroundImage}
|
||||||
backgroundImageSmd={backgroundImageSmd}
|
backgroundImageSmd={backgroundImageSmd}
|
||||||
|
href={href ?? `/blog/${post.slug}`}
|
||||||
|
showByline={showByline}
|
||||||
|
showLeadingImage={showLeadingImage}
|
||||||
|
leadingImageAlt={leadingImageAlt}
|
||||||
|
leadingImageSrc={leadingImageSrc}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ export interface ContentThumbnailTemplateProps {
|
|||||||
post: BlogPost;
|
post: BlogPost;
|
||||||
className?: string;
|
className?: string;
|
||||||
/**
|
/**
|
||||||
* vertical | horizontal — single layout. responsive — horizontal at <smd,
|
* vertical | horizontal — single layout. responsive — horizontal below md
|
||||||
* vertical at ≥smd (Learn grid); single card, viewport-swapped via <picture>.
|
* (640px), vertical from md up (Learn grid); one card, swapped via <picture>.
|
||||||
*/
|
*/
|
||||||
variant?: ContentThumbnailTemplateVariantValue;
|
variant?: ContentThumbnailTemplateVariantValue;
|
||||||
/**
|
/**
|
||||||
@@ -20,6 +20,16 @@ export interface ContentThumbnailTemplateProps {
|
|||||||
*/
|
*/
|
||||||
sizing?: ContentThumbnailTemplateSizingValue;
|
sizing?: ContentThumbnailTemplateSizingValue;
|
||||||
slugOrder?: string[];
|
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;
|
||||||
|
/** Tag mark source. Falls back to the catalog tag for the post slug. */
|
||||||
|
leadingImageSrc?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContentThumbnailTemplateViewProps {
|
export interface ContentThumbnailTemplateViewProps {
|
||||||
@@ -30,4 +40,9 @@ export interface ContentThumbnailTemplateViewProps {
|
|||||||
backgroundImage: string;
|
backgroundImage: string;
|
||||||
/** Wide-viewport image source for variant="responsive" (≥smd). */
|
/** Wide-viewport image source for variant="responsive" (≥smd). */
|
||||||
backgroundImageSmd?: string;
|
backgroundImageSmd?: string;
|
||||||
|
href: string;
|
||||||
|
showByline: boolean;
|
||||||
|
showLeadingImage: boolean;
|
||||||
|
leadingImageAlt?: string;
|
||||||
|
leadingImageSrc?: string;
|
||||||
}
|
}
|
||||||
@@ -10,21 +10,23 @@ function ContentThumbnailTemplateView({
|
|||||||
sizing,
|
sizing,
|
||||||
backgroundImage,
|
backgroundImage,
|
||||||
backgroundImageSmd,
|
backgroundImageSmd,
|
||||||
|
href,
|
||||||
|
showByline,
|
||||||
|
showLeadingImage,
|
||||||
|
leadingImageAlt,
|
||||||
|
leadingImageSrc,
|
||||||
}: ContentThumbnailTemplateViewProps) {
|
}: ContentThumbnailTemplateViewProps) {
|
||||||
if (variant === "responsive") {
|
if (variant === "responsive") {
|
||||||
// Single card; <picture> swaps the orientation-specific image at smd
|
// Single card; <picture> swaps to the vertical image at md (640px),
|
||||||
// (530px), aspect-ratio and content positioning switch via Tailwind.
|
// which is where Learn's grid goes from one landscape card to two.
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link href={href} className={`group block w-full ${className}`}>
|
||||||
href={`/blog/${post.slug}`}
|
<div className="relative aspect-[320/225.5] w-full overflow-hidden md:aspect-[260/390]">
|
||||||
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">
|
<div className="absolute inset-0 z-0">
|
||||||
<picture>
|
<picture>
|
||||||
{backgroundImageSmd ? (
|
{backgroundImageSmd ? (
|
||||||
<source
|
<source
|
||||||
media="(min-width: 530px)"
|
media="(min-width: 640px)"
|
||||||
srcSet={backgroundImageSmd}
|
srcSet={backgroundImageSmd}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -36,8 +38,15 @@ function ContentThumbnailTemplateView({
|
|||||||
/>
|
/>
|
||||||
</picture>
|
</picture>
|
||||||
</div>
|
</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%]">
|
<div className="absolute left-[4.375%] top-[6.099%] z-20 w-[71.875%] max-h-[68%] overflow-hidden md:left-[6.923%] md:top-[4.615%] md:max-h-[48%] md:w-[76.923%]">
|
||||||
<ContentContainer post={post} size="xs" />
|
<ContentContainer
|
||||||
|
post={post}
|
||||||
|
size="xs"
|
||||||
|
showByline={showByline}
|
||||||
|
showLeadingImage={showLeadingImage}
|
||||||
|
leadingImageAlt={leadingImageAlt}
|
||||||
|
leadingImageSrc={leadingImageSrc}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -47,10 +56,7 @@ function ContentThumbnailTemplateView({
|
|||||||
if (variant === "vertical") {
|
if (variant === "vertical") {
|
||||||
if (sizing === "fixed") {
|
if (sizing === "fixed") {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link href={href} className={`group block ${className}`}>
|
||||||
href={`/blog/${post.slug}`}
|
|
||||||
className={`group block ${className}`}
|
|
||||||
>
|
|
||||||
<div className="relative h-[390px] w-[260px] overflow-hidden">
|
<div className="relative h-[390px] w-[260px] overflow-hidden">
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
@@ -61,7 +67,15 @@ function ContentThumbnailTemplateView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute left-[18px] top-[18px] z-20 w-[200px]">
|
<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}
|
||||||
|
leadingImageSrc={leadingImageSrc}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -69,10 +83,7 @@ function ContentThumbnailTemplateView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link href={href} className={`group block w-full ${className}`}>
|
||||||
href={`/blog/${post.slug}`}
|
|
||||||
className={`group block w-full ${className}`}
|
|
||||||
>
|
|
||||||
<div className="relative aspect-[260/390] w-full overflow-hidden">
|
<div className="relative aspect-[260/390] w-full overflow-hidden">
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
@@ -83,7 +94,14 @@ function ContentThumbnailTemplateView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute left-[6.923%] top-[4.615%] z-20 w-[76.923%] max-h-[48%] overflow-hidden">
|
<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}
|
||||||
|
leadingImageSrc={leadingImageSrc}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -92,7 +110,7 @@ function ContentThumbnailTemplateView({
|
|||||||
|
|
||||||
if (sizing === "fixed") {
|
if (sizing === "fixed") {
|
||||||
return (
|
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="relative h-[225.5px] w-[320px] overflow-hidden">
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
@@ -103,7 +121,15 @@ function ContentThumbnailTemplateView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute left-[14px] top-[13.75px] z-20 w-[230px]">
|
<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}
|
||||||
|
leadingImageSrc={leadingImageSrc}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -111,10 +137,7 @@ function ContentThumbnailTemplateView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link href={href} className={`group block w-full ${className}`}>
|
||||||
href={`/blog/${post.slug}`}
|
|
||||||
className={`group block w-full ${className}`}
|
|
||||||
>
|
|
||||||
<div className="relative aspect-[320/225.5] w-full overflow-hidden">
|
<div className="relative aspect-[320/225.5] w-full overflow-hidden">
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
@@ -125,7 +148,14 @@ function ContentThumbnailTemplateView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute left-[4.375%] top-[6.099%] z-20 w-[71.875%] max-h-[48%] overflow-hidden">
|
<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}
|
||||||
|
leadingImageSrc={leadingImageSrc}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export interface FaqAccordionItem {
|
|||||||
export interface FaqAccordionProps {
|
export interface FaqAccordionProps {
|
||||||
title: string;
|
title: string;
|
||||||
items: FaqAccordionItem[];
|
items: FaqAccordionItem[];
|
||||||
|
/** In-page target, such as the Learn FAQ. */
|
||||||
|
id?: string;
|
||||||
size?: AccordionSizeValue;
|
size?: AccordionSizeValue;
|
||||||
/** Layout accordion size from `lg` (default **m**, Figma 22135-890258). */
|
/** Layout accordion size from `lg` (default **m**, Figma 22135-890258). */
|
||||||
lgSize?: AccordionSizeValue;
|
lgSize?: AccordionSizeValue;
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ function FaqAccordionView({
|
|||||||
lgSize,
|
lgSize,
|
||||||
xlSize,
|
xlSize,
|
||||||
headingId,
|
headingId,
|
||||||
|
id,
|
||||||
className = "",
|
className = "",
|
||||||
}: FaqAccordionViewProps) {
|
}: FaqAccordionViewProps) {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
|
id={id}
|
||||||
aria-labelledby={headingId}
|
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()}
|
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}
|
size={size}
|
||||||
lgSize={lgSize}
|
lgSize={lgSize}
|
||||||
xlSize={xlSize}
|
xlSize={xlSize}
|
||||||
|
defaultOpen={index === 0}
|
||||||
>
|
>
|
||||||
{item.answer}
|
{item.answer}
|
||||||
</LayoutAccordion>
|
</LayoutAccordion>
|
||||||
|
|||||||
+74
-66
@@ -1,11 +1,13 @@
|
|||||||
import { Inter, Bricolage_Grotesque } from "next/font/google";
|
import { Inter, Bricolage_Grotesque } from "next/font/google";
|
||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { headers } from "next/headers";
|
||||||
import messages from "../messages/en/index";
|
import messages from "../messages/en/index";
|
||||||
import { ASSETS, getAssetPath } from "../lib/assetUtils";
|
import { ASSETS, getAssetPath } from "../lib/assetUtils";
|
||||||
import {
|
import {
|
||||||
defaultShareImages,
|
defaultShareImages,
|
||||||
metadataBaseUrl,
|
originFromHost,
|
||||||
|
resolveRequestHost,
|
||||||
} from "../lib/siteMetadata";
|
} from "../lib/siteMetadata";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
@@ -46,72 +48,80 @@ export const viewport: Viewport = {
|
|||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
title: {
|
const headerStore = await headers();
|
||||||
default: homeMeta.title,
|
const host = resolveRequestHost(
|
||||||
template: messages.metadata.titleTemplate,
|
headerStore.get("x-forwarded-host"),
|
||||||
},
|
headerStore.get("host"),
|
||||||
description: homeMeta.description,
|
);
|
||||||
keywords: [...homeMeta.keywords],
|
|
||||||
authors: [{ name: "Media Economies Design Lab" }],
|
return {
|
||||||
creator: "Media Economies Design Lab",
|
title: {
|
||||||
publisher: "Media Economies Design Lab",
|
default: homeMeta.title,
|
||||||
formatDetection: {
|
template: messages.metadata.titleTemplate,
|
||||||
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,
|
|
||||||
description: homeMeta.description,
|
description: homeMeta.description,
|
||||||
url: "/",
|
keywords: [...homeMeta.keywords],
|
||||||
siteName: messages.metadata.siteName,
|
authors: [{ name: "Media Economies Design Lab" }],
|
||||||
locale: "en_US",
|
creator: "Media Economies Design Lab",
|
||||||
type: "website",
|
publisher: "Media Economies Design Lab",
|
||||||
images: defaultShareImages(),
|
formatDetection: {
|
||||||
},
|
email: false,
|
||||||
twitter: {
|
address: false,
|
||||||
card: "summary_large_image",
|
telephone: false,
|
||||||
title: homeMeta.title,
|
},
|
||||||
description: homeMeta.description,
|
metadataBase: new URL(originFromHost(host)),
|
||||||
images: defaultShareImages().map((image) => image.url),
|
icons: {
|
||||||
},
|
icon: [
|
||||||
robots: {
|
{ url: getAssetPath(ASSETS.LOGO), type: "image/svg+xml" },
|
||||||
index: true,
|
{ url: "/favicon.ico", sizes: "any" },
|
||||||
follow: true,
|
{
|
||||||
googleBot: {
|
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,
|
index: true,
|
||||||
follow: true,
|
follow: true,
|
||||||
"max-video-preview": -1,
|
googleBot: {
|
||||||
"max-image-preview": "large",
|
index: true,
|
||||||
"max-snippet": -1,
|
follow: true,
|
||||||
|
"max-video-preview": -1,
|
||||||
|
"max-image-preview": "large",
|
||||||
|
"max-snippet": -1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
// Load messages for the default locale (single locale setup)
|
// Load messages for the default locale (single locale setup)
|
||||||
@@ -138,9 +148,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
type="image/png"
|
type="image/png"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body className={`${inter.variable} ${bricolageGrotesque.variable}`}>
|
||||||
className={`${inter.variable} ${bricolageGrotesque.variable}`}
|
|
||||||
>
|
|
||||||
<div className="mx-auto flex min-h-screen w-full max-w-[1920px] flex-col">
|
<div className="mx-auto flex min-h-screen w-full max-w-[1920px] flex-col">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ const STATIC_PUBLIC_PATHS = [
|
|||||||
"/learn",
|
"/learn",
|
||||||
"/templates",
|
"/templates",
|
||||||
"/blog",
|
"/blog",
|
||||||
"/use-cases",
|
|
||||||
"/how-it-works",
|
"/how-it-works",
|
||||||
"/privacy",
|
"/privacy",
|
||||||
"/terms",
|
"/terms",
|
||||||
|
|||||||
+20
-7
@@ -153,9 +153,7 @@ export function articleShareImages(
|
|||||||
slug: string,
|
slug: string,
|
||||||
alt: string,
|
alt: string,
|
||||||
): ShareImage[] | undefined {
|
): ShareImage[] | undefined {
|
||||||
if (
|
if (!(CONTENT_CATALOG_SLUG_ORDER as readonly string[]).includes(slug)) {
|
||||||
!(CONTENT_CATALOG_SLUG_ORDER as readonly string[]).includes(slug)
|
|
||||||
) {
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return [shareImage(contentBlogOgPath(slug), alt)];
|
return [shareImage(contentBlogOgPath(slug), alt)];
|
||||||
@@ -241,15 +239,30 @@ export function routeMetadata(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link unfurlers. Staging stays out of search indexes, and these agents can
|
||||||
|
* still fetch the page so a shared link can show its image.
|
||||||
|
*/
|
||||||
|
const UNFURL_USER_AGENTS = [
|
||||||
|
"Twitterbot",
|
||||||
|
"facebookexternalhit",
|
||||||
|
"Slackbot",
|
||||||
|
"Discordbot",
|
||||||
|
"LinkedInBot",
|
||||||
|
"TelegramBot",
|
||||||
|
"WhatsApp",
|
||||||
|
"Bluesky",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export function buildRobots(
|
export function buildRobots(
|
||||||
host: string | null | undefined,
|
host: string | null | undefined,
|
||||||
): MetadataRoute.Robots {
|
): MetadataRoute.Robots {
|
||||||
if (!isIndexableHost(host)) {
|
if (!isIndexableHost(host)) {
|
||||||
return {
|
return {
|
||||||
rules: {
|
rules: [
|
||||||
userAgent: "*",
|
{ userAgent: "*", disallow: "/" },
|
||||||
disallow: "/",
|
{ userAgent: [...UNFURL_USER_AGENTS], allow: "/" },
|
||||||
},
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+42
-11
@@ -28,7 +28,8 @@
|
|||||||
"title": "Workshop inquiries"
|
"title": "Workshop inquiries"
|
||||||
},
|
},
|
||||||
"learn": {
|
"learn": {
|
||||||
"title": "Learn"
|
"title": "Learn",
|
||||||
|
"description": "See how groups use CommunityRule, who it is for, and practical guides for organizing together."
|
||||||
},
|
},
|
||||||
"templates": {
|
"templates": {
|
||||||
"title": "Templates"
|
"title": "Templates"
|
||||||
@@ -69,34 +70,64 @@
|
|||||||
"mutualAidColorado": {
|
"mutualAidColorado": {
|
||||||
"title": "Mutual Aid Colorado",
|
"title": "Mutual Aid Colorado",
|
||||||
"description": "How Mutual Aid Colorado used CommunityRule to clarify resource sharing, volunteer coordination, and decision-making.",
|
"description": "How Mutual Aid Colorado used CommunityRule to clarify resource sharing, volunteer coordination, and decision-making.",
|
||||||
"keywords": ["mutual aid", "use case", "community governance", "operating manual"]
|
"keywords": [
|
||||||
|
"mutual aid",
|
||||||
|
"use case",
|
||||||
|
"community governance",
|
||||||
|
"operating manual"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"foodNotBombs": {
|
"foodNotBombs": {
|
||||||
"title": "Food Not Bombs Boulder",
|
"title": "Food Not Bombs Boulder",
|
||||||
"description": "How Food Not Bombs Boulder used CommunityRule to translate implicit organizing norms into explicit democratic processes.",
|
"description": "How Food Not Bombs Boulder used CommunityRule to translate implicit organizing norms into explicit democratic processes.",
|
||||||
"keywords": ["food not bombs", "use case", "community governance", "operating manual"]
|
"keywords": [
|
||||||
|
"food not bombs",
|
||||||
|
"use case",
|
||||||
|
"community governance",
|
||||||
|
"operating manual"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"boulderCountyStreetMedics": {
|
"boulderCountyStreetMedics": {
|
||||||
"title": "Boulder County Street Medics",
|
"title": "Boulder Street Medics",
|
||||||
"description": "How CommunityRule helped Boulder County Street Medics define operational process on the streets and off.",
|
"description": "How CommunityRule helped Boulder Street Medics define operational process on the streets and off.",
|
||||||
"keywords": ["street medics", "use case", "community governance", "operating manual"]
|
"keywords": [
|
||||||
|
"street medics",
|
||||||
|
"use case",
|
||||||
|
"community governance",
|
||||||
|
"operating manual"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"useCasesCompletedRule": {
|
"useCasesCompletedRule": {
|
||||||
"mutualAidColorado": {
|
"mutualAidColorado": {
|
||||||
"title": "Mutual Aid Colorado community rule",
|
"title": "Mutual Aid Colorado community rule",
|
||||||
"description": "Read the completed community rule Mutual Aid Colorado built with CommunityRule.",
|
"description": "Read the completed community rule Mutual Aid Colorado built with CommunityRule.",
|
||||||
"keywords": ["mutual aid", "community rule", "operating manual", "use case"]
|
"keywords": [
|
||||||
|
"mutual aid",
|
||||||
|
"community rule",
|
||||||
|
"operating manual",
|
||||||
|
"use case"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"foodNotBombs": {
|
"foodNotBombs": {
|
||||||
"title": "Food Not Bombs Boulder community rule",
|
"title": "Food Not Bombs Boulder community rule",
|
||||||
"description": "Read the completed community rule Food Not Bombs Boulder built with CommunityRule.",
|
"description": "Read the completed community rule Food Not Bombs Boulder built with CommunityRule.",
|
||||||
"keywords": ["food not bombs", "community rule", "operating manual", "use case"]
|
"keywords": [
|
||||||
|
"food not bombs",
|
||||||
|
"community rule",
|
||||||
|
"operating manual",
|
||||||
|
"use case"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"boulderCountyStreetMedics": {
|
"boulderCountyStreetMedics": {
|
||||||
"title": "Boulder County Street Medics community rule",
|
"title": "Boulder Street Medics community rule",
|
||||||
"description": "Read the completed community rule Boulder County Street Medics built with CommunityRule.",
|
"description": "Read the completed community rule Boulder Street Medics built with CommunityRule.",
|
||||||
"keywords": ["street medics", "community rule", "operating manual", "use case"]
|
"keywords": [
|
||||||
|
"street medics",
|
||||||
|
"community rule",
|
||||||
|
"operating manual",
|
||||||
|
"use case"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"howItWorks": {
|
"howItWorks": {
|
||||||
|
|||||||
@@ -1,13 +1,147 @@
|
|||||||
{
|
{
|
||||||
"_comment": "Learn page specific content",
|
"_comment": "Learn page. The use-cases index redirects here. Pilot pages stay at /use-cases/[slug]. Guide and article bylines stay off until their dates are approved.",
|
||||||
|
"pageHeader": {
|
||||||
|
"title": ["See how groups use", "CommunityRule"]
|
||||||
|
},
|
||||||
|
"caseStudyTiles": {
|
||||||
|
"mutualAidColoradoAlt": "Mutual Aid Colorado logo",
|
||||||
|
"foodNotBombsAlt": "Food Not Bombs logo",
|
||||||
|
"boulderStreetMedicsAlt": "Boulder Street Medics logo",
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"href": "/use-cases/mutual-aid-colorado",
|
||||||
|
"ariaLabel": "View Mutual Aid Colorado use case"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/use-cases/food-not-bombs",
|
||||||
|
"ariaLabel": "View Food Not Bombs use case"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/use-cases/boulder-county-street-medics",
|
||||||
|
"ariaLabel": "View Boulder Street Medics use case"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"groups": {
|
||||||
|
"title": "Who is this for?",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"title": "Worker cooperatives",
|
||||||
|
"description": "Employee-owned businesses often need to clarify how power is shared, decisions are made and how processes operate within their organizations."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Mutual aid communities",
|
||||||
|
"description": "Mutual aid communities must define how resources are shared, decisions are made and volunteer coordination operates within their organizations."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Open source projects",
|
||||||
|
"description": "Agree on how contributions are managed, technical decisions are made and community participation operates within their development communities."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "DAOs",
|
||||||
|
"description": "Document token-based voting process, proposal patterns and how community governance operates within their blockchain ecosystems."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tripleStep": {
|
||||||
|
"heading": "Get recommendations that will make organizing easier",
|
||||||
|
"ctaText": "Create rule",
|
||||||
|
"ctaHref": "/create/informational",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"title": "Get your community together",
|
||||||
|
"body": "If you're just getting started, you might begin with shared values, a decision-making plan and a conflict management process. Keeping it simple ensures your Rule remains practical rather than overwhelming."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Define how your community should operate",
|
||||||
|
"body": "Involving everyone in shaping these agreements through group discussions, workshops or a tool like CommunityRule can help build collective buy-in."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Have a durable impact",
|
||||||
|
"body": "Consider treating guidelines as a living document that evolves with your community's needs. A little structure early on helps keep your community effective and sustainable. CommunityRule makes it easy to track and share these changes as your community evolves."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"contentLockup": {
|
"contentLockup": {
|
||||||
"title": "Organizing is hard",
|
"title": "Organizing is hard",
|
||||||
"subtitle": "Find answers to your questions and see how other groups have solved similar challenges."
|
"subtitle": "Find answers to your questions and see how other communities have solved similar challenges.",
|
||||||
|
"ctaText": "Read the guide",
|
||||||
|
"ctaHref": "/how-it-works"
|
||||||
|
},
|
||||||
|
"guideCard": {
|
||||||
|
"title": "When your community sits down to write its Rule",
|
||||||
|
"summary": "Sketch your Rule in CommunityRule, then make it your community's own over five short meetings.",
|
||||||
|
"href": "/how-it-works"
|
||||||
|
},
|
||||||
|
"articleOrder": [
|
||||||
|
"resolving-active-conflicts",
|
||||||
|
"operational-security-mutual-aid",
|
||||||
|
"making-decisions-without-hierarchy",
|
||||||
|
"integrating-new-members-without-dilution",
|
||||||
|
"avoiding-burnout-sustainability-in-the-ruins",
|
||||||
|
"how-chaos-concentrates-control",
|
||||||
|
"digital-mediation-and-the-death-of-nuance",
|
||||||
|
"knowledge-management-and-institutional-amnesia"
|
||||||
|
],
|
||||||
|
"articleSummaries": {
|
||||||
|
"resolving-active-conflicts": "How a conflict gets managed, starting with two people in a room",
|
||||||
|
"operational-security-mutual-aid": "Keeping your community's tools safe starts with who holds the keys",
|
||||||
|
"making-decisions-without-hierarchy": "No bosses still means deciding who decides",
|
||||||
|
"integrating-new-members-without-dilution": "Growing without watering down what your community is for",
|
||||||
|
"avoiding-burnout-sustainability-in-the-ruins": "When the same few people carry everything",
|
||||||
|
"how-chaos-concentrates-control": "With no leaders, somebody still holds the keys",
|
||||||
|
"digital-mediation-and-the-death-of-nuance": "What corporate platforms cost a community and what to use instead",
|
||||||
|
"knowledge-management-and-institutional-amnesia": "Keeping what your community knows when people move on"
|
||||||
|
},
|
||||||
|
"tripleTextBlock": {
|
||||||
|
"title": "Why horizontal communities need CommunityRule",
|
||||||
|
"ctaText": "Set up your community",
|
||||||
|
"ctaHref": "/create/informational",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"title": "Share leadership and prevent burnout",
|
||||||
|
"description": "Rotating roles and setting clear expectations for contributions can prevent a few people from taking on too much. A living document outlining responsibilities and community values helps distribute labor fairly and avoids misunderstandings.",
|
||||||
|
"descriptionSecondary": "A transportation assistance community avoided burnout by introducing a weekly shift system, making participation more sustainable."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Transparent, inclusive decisions",
|
||||||
|
"description": "Figuring out how your team makes decisions right from the start saves a lot of headaches. You can use full consensus, majority rules or let specific people make the call. Being open about this and checking in often keeps everyone involved and prevents secret power struggles. As an example, one disaster relief community sped things up by using a \"consensus-minus-one\" rule. It kept them aligned but stopped a single person from bringing everything to a halt."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Address conflicts before they grow",
|
||||||
|
"description": "A basic conflict management process can prevent disputes from escalating. Clear guidelines for resource distribution reduce misunderstandings and maintain trust. Assume good faith and use structured discussions to resolve issues.",
|
||||||
|
"descriptionSecondary": "Members noticed frustration when some folks felt unheard in decisions about new projects. By adding an anonymous feedback box, they created a space to address concerns early and make adjustments before conflicts escalated."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"title": "Get answers to your questions",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"title": "Do we have to start from a blank page?",
|
||||||
|
"answer": "No. Your community starts from a deck of hundreds of cards on values, decision-making, membership, communication and conflict, and the deck keeps growing. Each card is one way to answer a question. Your community picks the cards closest to what it does today, combines them and rewrites what does not fit. Or start from one of thirteen templates. They run from consensus to sortition."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Who can see our Rule?",
|
||||||
|
"answer": "Only the people you invite. Every Rule starts private. It becomes public only if you share a link to it or choose to make it public and featured. Check with your group first. If your work involves people who could be put at risk, leave out rosters, meeting places and legal names anyway."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Do we have to follow it?",
|
||||||
|
"answer": "You follow it because your group agreed to it. Members can point to the same words when things get hard. Whether any part of a Rule holds up in court depends on where you are and what it says. A Rule sits beside your bylaws if you have them, and any law or regulator that applies to you still applies. Talk to a lawyer before your group relies on a card like binding arbitration."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "We are in the middle of a conflict right now. Will this help?",
|
||||||
|
"answer": "Only partly. A whole Rule is hard to write during a dispute because people write rules to win the fight in front of them. What helps now is agreeing how your group will handle this one dispute, ideally with a facilitator from outside the group. If someone has been harmed, their safety comes first. Write your Rule once things settle and it will help with the next one. The guides on conflict are a good place to start."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Who makes this and what does it cost?",
|
||||||
|
"answer": "The Media Economies Design Lab at the University of Colorado Boulder makes CommunityRule. The tool is free for every group and its code is open source under GPL-3.0. The guides, the zines, the findings and the rest of the site are under CC BY-SA 4.0. You can copy, adapt and republish them if you give credit and share your version under the same license."
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"askOrganizer": {
|
"askOrganizer": {
|
||||||
"title": "Still have questions?",
|
"title": "Still have questions?",
|
||||||
"subtitle": "Get answers from an experienced organizer",
|
"subtitle": "Get answers from an experienced organizer",
|
||||||
"description": "Our community of organizers is here to help you navigate the challenges of building and maintaining effective community organizations.",
|
|
||||||
"buttonText": "Ask an organizer"
|
"buttonText": "Ask an organizer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"caseStudyTiles": {
|
"caseStudyTiles": {
|
||||||
"mutualAidColoradoAlt": "Mutual Aid Colorado logo",
|
"mutualAidColoradoAlt": "Mutual Aid Colorado logo",
|
||||||
"foodNotBombsAlt": "Food Not Bombs logo",
|
"foodNotBombsAlt": "Food Not Bombs logo",
|
||||||
"boulderCountyStreetMedicsAlt": "Boulder County Street Medics logo",
|
"boulderCountyStreetMedicsAlt": "Boulder Street Medics logo",
|
||||||
"links": [
|
"links": [
|
||||||
{
|
{
|
||||||
"href": "/use-cases/mutual-aid-colorado",
|
"href": "/use-cases/mutual-aid-colorado",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"href": "/use-cases/boulder-county-street-medics",
|
"href": "/use-cases/boulder-county-street-medics",
|
||||||
"ariaLabel": "View Boulder County Street Medics use case"
|
"ariaLabel": "View Boulder Street Medics use case"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,23 +10,23 @@
|
|||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"title": "Mutual Aid, Not Charity",
|
"title": "Mutual Aid, Not Charity",
|
||||||
"body": "We act in political solidarity rather than charity. We are building systems of community care and resilience, recognizing that our liberation is bound together.\n\n“Change needs all of us.”\n\nFood Not Bombs is not a charity. It is a project of solidarity. Charity is vertical. It moves from those who have to those who have not and maintains the hierarchy between them. Solidarity is horizontal. It moves between equals who recognize that our liberation is bound together. We do not help the poor. We share resources among community members because access to food is a human right rather than a privilege of wealth."
|
"body": "We act in political solidarity rather than charity. We are building systems of community care and resilience, recognizing that our liberation is bound together."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Local Autonomy",
|
"title": "Local Autonomy",
|
||||||
"body": "We fiercely protect the autonomy of individual local chapters. We exist to support and connect local work, not to dictate it, ensuring we do not transform into the very top-down bureaucracies we aim to subvert.\n\n“Everyone contributes, no one controls.”\n\nWe operate without bosses or managers. This does not mean we are disorganized. It means we are self-organized. Authority in this chapter is temporary and task-specific rather than permanent or personal. We believe the people doing the work should make the decisions about that work. By distributing responsibility we prevent burnout and ensure the movement survives beyond any single leader."
|
"body": "We fiercely protect the autonomy of individual local chapters. We exist to support and connect local work, not to dictate it, ensuring we do not transform into the very top-down bureaucracies we aim to subvert."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Action Over Bureaucracy",
|
"title": "Action Over Bureaucracy",
|
||||||
"body": "We prioritize moving physical goods and supporting local groups efficiently over unnecessary administration. We use our explicit agreements to prevent operational bottlenecks and keep the work moving.\n\n“Relationships over algorithms.”\n\nWe use digital tools to coordinate but we build power in the physical world. An algorithm cannot cook a meal and a group chat cannot look someone in the eye. We prioritize face-to-face interaction and physical presence at distributions. We resist the temptation to let digital metrics replace tangible impact."
|
"body": "We prioritize moving physical goods and supporting local groups efficiently over unnecessary administration. We use our explicit agreements to prevent operational bottlenecks and keep the work moving."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Durable Commons",
|
"title": "Durable Commons",
|
||||||
"body": "We explicitly document our resource sharing protocols and logistical agreements. This ensures that knowledge regarding supply routes and shared assets is accessible to any participating group, functioning as a true commons rather than being hoarded by a few central organizers.\n\n“Rescued food doesn't add carbon.”\n\nOur logistics are an ecological intervention. Food waste is a major driver of climate change. By intercepting food that would otherwise be discarded and redirecting it to hungry neighbors we close the loop. We view food recovery as stewarding a resource that the industrial food system has abandoned. Every pound of food we rescue is a pound of carbon kept out of the atmosphere."
|
"body": "We explicitly document our resource sharing protocols and logistical agreements. This ensures that knowledge regarding supply routes and shared assets is accessible to any participating community, functioning as a true commons rather than being hoarded by a few central organizers."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Decentralized Power",
|
"title": "Decentralized Power",
|
||||||
"body": "We are committed to building massive material power without centralizing authority. We engineer our structures to distribute responsibility and prevent the consolidation of control within the network.\n\n“Collective giving builds collective power.”\n\nOur budget is labor rather than capital. We rely on the time and care of our volunteers rather than large grants or corporate sponsorships. This independence gives us the political freedom to operate according to our values. When we pool our small individual capacities we create a collective power that money cannot buy."
|
"body": "We are committed to building massive material power without centralizing authority. We engineer our structures to distribute responsibility and prevent the consolidation of control within the network."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -408,8 +408,8 @@
|
|||||||
},
|
},
|
||||||
"boulderCountyStreetMedics": {
|
"boulderCountyStreetMedics": {
|
||||||
"pageBackground": "var(--color-surface-invert-brand-red)",
|
"pageBackground": "var(--color-surface-invert-brand-red)",
|
||||||
"title": "BoCo Street Medics",
|
"title": "Boulder Street Medics",
|
||||||
"summary": "Boulder County Street Medics is a grassroots, volunteer-run organization focused on providing first aid and medical support to marginalized communities and activists in the Boulder area.",
|
"summary": "Boulder Street Medics is a grassroots, volunteer-run organization focused on providing first aid and medical support to marginalized communities and activists in the Boulder area.",
|
||||||
"document": {
|
"document": {
|
||||||
"sections": [
|
"sections": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"src": "assets/case-study/case-study-mutual-aid.svg",
|
"src": "assets/case-study/case-study-mutual-aid.svg",
|
||||||
"alt": "Mutual Aid Colorado logo"
|
"alt": "Mutual Aid Colorado logo"
|
||||||
},
|
},
|
||||||
"bodyMarkdown": "Coordinating a statewide network of autonomous mutual aid groups introduces complex logistical challenges. When an organization scales to managing regional supply chains and shared physical infrastructure like print shops informal networking is no longer sufficient. Without explicit agreements regional hubs risk transforming into the very top-down bureaucracies they aim to subvert. Mutual Aid Colorado utilized CommunityRule to address this tension directly. The platform provided a modular vocabulary to design a legible governance framework. This allowed the network to establish reliable statewide coordination while fiercely protecting the autonomy of individual local chapters.\n\nA core advantage of this modular approach is the ability to engineer a polycentric system capable of operating at multiple scales. Mutual Aid Colorado used the platform to differentiate the decision-making speeds required for various projects. Managing a statewide logistics operation requires rapid coordination to move physical goods efficiently. Conversely establishing the shared values of the broader network necessitates a slow and deeply participatory consensus model. By explicitly mapping these different arenas the organization ensures that the volunteers running the print shop can make immediate operational choices without waiting for a statewide assembly to approve their paper orders.\n\nUltimately formalizing these structural relationships transforms a loose coalition into a highly resilient piece of civic infrastructure. Regional organizing frequently struggles with operational bottlenecks as the immense labor of coordinating across vast geographies falls onto a few central nodes. By documenting their resource sharing protocols and logistical agreements through CommunityRule the network constructed a durable commons. This explicit architecture ensures that knowledge regarding supply routes and shared assets is accessible to any participating group. It provides a practical blueprint for how distributed networks can build massive material power without centralizing authority.",
|
"bodyMarkdown": "Coordinating a statewide network of autonomous mutual aid groups introduces complex logistical challenges. When an organization scales to managing regional supply chains and shared physical infrastructure like print shops informal networking is no longer sufficient. Without explicit agreements regional hubs risk transforming into the very top-down bureaucracies they aim to subvert. Mutual Aid Colorado utilized CommunityRule to address this tension directly. The platform provided a modular vocabulary to design a legible governance framework. This allowed the network to establish reliable statewide coordination while fiercely protecting the autonomy of individual local chapters.\n\nA core advantage of this modular approach is the ability to engineer a polycentric system capable of operating at multiple scales. Mutual Aid Colorado used the platform to differentiate the decision-making speeds required for various projects. Managing a statewide logistics operation requires rapid coordination to move physical goods efficiently. Conversely establishing the shared values of the broader network necessitates a slow and deeply participatory consensus model. By explicitly mapping these different arenas the organization ensures that the volunteers running the print shop can make immediate operational choices without waiting for a statewide assembly to approve their paper orders.\n\nUltimately formalizing these structural relationships transforms a loose coalition into a highly resilient piece of civic infrastructure. Regional organizing frequently struggles with operational bottlenecks as the immense labor of coordinating across vast geographies falls onto a few central nodes. By documenting their resource sharing protocols and logistical agreements through CommunityRule the network constructed a durable commons. This explicit architecture ensures that knowledge regarding supply routes and shared assets is accessible to any participating community. It provides a practical blueprint for how distributed networks can build massive material power without centralizing authority.",
|
||||||
"bodyFooter": {
|
"bodyFooter": {
|
||||||
"title": "Ready to document how your group operates?",
|
"title": "Ready to document how your group operates?",
|
||||||
"description": "Start from proven mutual aid patterns and customize them for your community."
|
"description": "Start from proven mutual aid patterns and customize them for your community."
|
||||||
@@ -62,20 +62,20 @@
|
|||||||
"boulderCountyStreetMedics": {
|
"boulderCountyStreetMedics": {
|
||||||
"pageBackground": "var(--color-content-default-brand-red)",
|
"pageBackground": "var(--color-content-default-brand-red)",
|
||||||
"banner": {
|
"banner": {
|
||||||
"title": "Boulder County Street Medics",
|
"title": "Boulder Street Medics",
|
||||||
"description": "CommunityRule helped the Street Medics define their operational process both on the streets and off.",
|
"description": "CommunityRule helped the Street Medics define their operational process both on the streets and off.",
|
||||||
"author": "CommunityRule",
|
"author": "CommunityRule",
|
||||||
"date": "2025-04-15"
|
"date": "2025-04-15"
|
||||||
},
|
},
|
||||||
"ruleCard": {
|
"ruleCard": {
|
||||||
"title": "BoCo Street Medics",
|
"title": "Boulder Street Medics",
|
||||||
"description": "Boulder County Street Medics is a grassroots, volunteer-run organization focused on providing first aid and medical support to marginalized communities and activists in the Boulder area.",
|
"description": "Boulder Street Medics is a grassroots, volunteer-run organization focused on providing first aid and medical support to marginalized communities and activists in the Boulder area.",
|
||||||
"backgroundColor": "bg-[var(--color-surface-invert-brand-red)]",
|
"backgroundColor": "bg-[var(--color-surface-invert-brand-red)]",
|
||||||
"iconPath": "assets/case-study/case-study-boulder-county-street-medics.svg"
|
"iconPath": "assets/case-study/case-study-boulder-county-street-medics.svg"
|
||||||
},
|
},
|
||||||
"leadingImage": {
|
"leadingImage": {
|
||||||
"src": "assets/case-study/case-study-boulder-county-street-medics.svg",
|
"src": "assets/case-study/case-study-boulder-county-street-medics.svg",
|
||||||
"alt": "Boulder County Street Medics logo"
|
"alt": "Boulder Street Medics logo"
|
||||||
},
|
},
|
||||||
"bodyMarkdown": "When communities like the BoCo Street Medics operate in high-stakes, legally precarious environments, the classic \"tyranny of structurelessness\" isn't just an academic critique. It is a massive operational risk. What these medics recognized is that relying on implicit norms and unspoken hierarchies quickly leads to burnout, fragmented trust, and compromised safety. This is exactly where a tool like CommunityRule becomes vital. By providing a legible, modular framework for democratic design, it allowed the collective to step back from the adrenaline of the streets and intentionally translate their core values of radical solidarity into explicit, accountable processes. They didn't have to reinvent the wheel of governance because they could select and adapt proven patterns to fit their unique reality.\n\nWhat CommunityRule facilitates so beautifully is the understanding that a single community can and often must operate at different speeds of democracy. The BoCo Street Medics utilized this modular approach to map out their distinct operational modes, making it crystal clear to every member when to rely on the slow, deeply participatory \"15-Day Lazy Consensus\" for strategic policy, and when to pivot to a rigid Incident Command System for tactical safety during a deployment. By charting these different pathways, the medics created a constitution that balances the deep, deliberate work of egalitarian community-building with the rapid, authoritative action required to keep people safe on the ground.\n\nUltimately, formalizing these processes through CommunityRule is an act of institutional care. It shifts the burden of conflict management, onboarding, and decision-making away from the exhausted shoulders of a few founders and distributes it into a resilient, shared architecture. By defining clear boundaries around membership access, escalation ladders for conflict, and even mandated leaves of absence, the BoCo Street Medics have built a culture of true stewardship. They've proven that radical, grassroots work doesn't have to be chaotic. When we design our governance with intention, we build a solidarity that can actually outlast us.",
|
"bodyMarkdown": "When communities like the BoCo Street Medics operate in high-stakes, legally precarious environments, the classic \"tyranny of structurelessness\" isn't just an academic critique. It is a massive operational risk. What these medics recognized is that relying on implicit norms and unspoken hierarchies quickly leads to burnout, fragmented trust, and compromised safety. This is exactly where a tool like CommunityRule becomes vital. By providing a legible, modular framework for democratic design, it allowed the collective to step back from the adrenaline of the streets and intentionally translate their core values of radical solidarity into explicit, accountable processes. They didn't have to reinvent the wheel of governance because they could select and adapt proven patterns to fit their unique reality.\n\nWhat CommunityRule facilitates so beautifully is the understanding that a single community can and often must operate at different speeds of democracy. The BoCo Street Medics utilized this modular approach to map out their distinct operational modes, making it crystal clear to every member when to rely on the slow, deeply participatory \"15-Day Lazy Consensus\" for strategic policy, and when to pivot to a rigid Incident Command System for tactical safety during a deployment. By charting these different pathways, the medics created a constitution that balances the deep, deliberate work of egalitarian community-building with the rapid, authoritative action required to keep people safe on the ground.\n\nUltimately, formalizing these processes through CommunityRule is an act of institutional care. It shifts the burden of conflict management, onboarding, and decision-making away from the exhausted shoulders of a few founders and distributes it into a resilient, shared architecture. By defining clear boundaries around membership access, escalation ladders for conflict, and even mandated leaves of absence, the BoCo Street Medics have built a culture of true stewardship. They've proven that radical, grassroots work doesn't have to be chaotic. When we design our governance with intention, we build a solidarity that can actually outlast us.",
|
||||||
"bodyFooter": {
|
"bodyFooter": {
|
||||||
|
|||||||
@@ -173,6 +173,11 @@ const nextConfig = {
|
|||||||
destination: "/create/decision-approaches",
|
destination: "/create/decision-approaches",
|
||||||
permanent: true,
|
permanent: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: "/use-cases",
|
||||||
|
destination: "/learn",
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<svg width="320" height="226" viewBox="0 0 320 226" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="320" height="226" fill="#FEFCC9"/>
|
||||||
|
<g filter="url(#ruleShadow)" transform="translate(284 18)">
|
||||||
|
<rect width="32" height="43.66" rx="2.704" fill="#FEFCC9"/>
|
||||||
|
<path d="M1.352 9.156H30.648" stroke="#141414" stroke-width="0.15"/>
|
||||||
|
<path d="M6.55 4.2L3.4 2.6L4.2 4.2L3.4 5.8L6.55 4.2Z" fill="#141414"/>
|
||||||
|
<circle cx="4.85" cy="4.2" r="0.55" fill="#141414"/>
|
||||||
|
<path d="M9.2 3.4H26.4M9.2 5.3H22.8" stroke="#141414" stroke-width="0.35" stroke-linecap="round"/>
|
||||||
|
<g fill="none" stroke="#141414" stroke-width="0.25">
|
||||||
|
<rect x="2.2" y="11.2" width="8.2" height="2.4" rx="1.2"/>
|
||||||
|
<rect x="11.2" y="11.2" width="6.4" height="2.4" rx="1.2"/>
|
||||||
|
<rect x="18.4" y="11.2" width="7.6" height="2.4" rx="1.2"/>
|
||||||
|
<rect x="2.2" y="15.6" width="10.4" height="2.4" rx="1.2"/>
|
||||||
|
<rect x="13.4" y="15.6" width="9.2" height="2.4" rx="1.2"/>
|
||||||
|
<rect x="2.2" y="18.6" width="11.8" height="2.4" rx="1.2"/>
|
||||||
|
<rect x="2.2" y="22.4" width="7.2" height="2.4" rx="1.2"/>
|
||||||
|
<rect x="10.2" y="22.4" width="6.6" height="2.4" rx="1.2"/>
|
||||||
|
<rect x="2.2" y="26.8" width="14.6" height="2.4" rx="1.2"/>
|
||||||
|
<rect x="17.6" y="26.8" width="6.2" height="2.4" rx="1.2"/>
|
||||||
|
<rect x="2.2" y="29.8" width="5.4" height="2.4" rx="1.2"/>
|
||||||
|
<rect x="8.4" y="29.8" width="8.8" height="2.4" rx="1.2"/>
|
||||||
|
<rect x="2.2" y="33.8" width="9.4" height="2.4" rx="1.2"/>
|
||||||
|
<rect x="12.4" y="33.8" width="8.2" height="2.4" rx="1.2"/>
|
||||||
|
<rect x="2.2" y="37.6" width="27.2" height="3.6" rx="0.4"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="ruleShadow" x="-4" y="-4" width="40" height="52" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feDropShadow dx="0" dy="0" stdDeviation="1.35" flood-color="#000000" flood-opacity="0.1"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg width="61" height="30" viewBox="0 0 61 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="61" height="30" fill="#080033"/>
|
||||||
|
<circle cx="10" cy="10" r="6" fill="#D93529"/>
|
||||||
|
<path d="M57 20.3103C57 23.4527 53.6421 26 49.5 26C45.3579 26 42 23.4527 42 20.3103C42 17.168 45.3579 4 49.5 4C53.6421 4 57 17.168 57 20.3103Z" fill="#16A9C4"/>
|
||||||
|
<rect width="10.625" height="10.625" transform="matrix(0.8 0.6 -0.8 0.6 31 3.20361)" fill="#0BA960"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M25 26C25 21.0294 20.9705 17 16 17C11.0294 17 7 21.0294 7 26H25Z" fill="url(#paint0_linear_19000_22890)"/>
|
||||||
|
<rect x="29" y="18" width="4" height="8.25" fill="#7771C2"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear_19000_22890" x1="9.475" y1="17.855" x2="13.2947" y2="27.6322" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#7871C3"/>
|
||||||
|
<stop offset="1" stop-color="#B099FA"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 906 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg width="260" height="390" viewBox="0 0 260 390" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="260" height="390" fill="#FEFCC9"/>
|
||||||
|
<g filter="url(#ruleShadow)">
|
||||||
|
<rect x="90" y="376.493" width="150" height="198.415" rx="6.338" fill="#FEFCC9"/>
|
||||||
|
<path d="M96.338 412.408H227.662" stroke="#141414" stroke-width="0.264"/>
|
||||||
|
<svg x="96.338" y="383.623" width="34.331" height="34.331" viewBox="0 0 34.331 34.331">
|
||||||
|
<path d="M25.2202 17.1654L6.99838 8.05449L11.5584 17.1654L6.99838 26.2763L25.2202 17.1654ZM16.1184 15.2726C16.4905 15.2749 16.8535 15.387 17.1619 15.595C17.4703 15.8029 17.7102 16.0973 17.8514 16.4412C17.9927 16.7851 18.0289 17.163 17.9556 17.5274C17.8823 17.8918 17.7027 18.2264 17.4395 18.489C17.1762 18.7517 16.8411 18.9307 16.4763 19.0035C16.1114 19.0763 15.7332 19.0396 15.3891 18.8981C15.0451 18.7566 14.7506 18.5166 14.5429 18.2082C14.3351 17.8999 14.2233 17.5371 14.2214 17.1654C14.2202 16.9163 14.2685 16.6694 14.3634 16.4391C14.4584 16.2088 14.5981 15.9995 14.7745 15.8235C14.9509 15.6475 15.1605 15.5081 15.3912 15.4136C15.6219 15.319 15.8691 15.2711 16.1184 15.2726Z" fill="#141414"/>
|
||||||
|
</svg>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="ruleShadow" x="80" y="366" width="176" height="40" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feDropShadow dx="0" dy="0" stdDeviation="3.169" flood-color="#000000" flood-opacity="0.1"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
+57
-12
@@ -69,42 +69,87 @@ describe("LearnPage", () => {
|
|||||||
vi.mocked(getAllBlogPosts).mockReturnValue(mockPosts);
|
vi.mocked(getAllBlogPosts).mockReturnValue(mockPosts);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders content lockup and ask organizer copy", () => {
|
it("renders the learn sections, with the guide first and the FAQ open", () => {
|
||||||
render(<LearnPage />);
|
render(<LearnPage />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: /See how groups use/ }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: "View Boulder Street Medics use case" }),
|
||||||
|
).toHaveAttribute("href", "/use-cases/boulder-county-street-medics");
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Who is this for?" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText("Organizing is hard")).toBeInTheDocument();
|
expect(screen.getByText("Organizing is hard")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(
|
screen.getByText(
|
||||||
/Find answers to your questions and see how other groups/,
|
/Find answers to your questions and see how other communities/,
|
||||||
),
|
),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
const guideLinks = screen.getAllByRole("link", { name: "Read the guide" });
|
||||||
|
expect(guideLinks.length).toBeGreaterThan(0);
|
||||||
|
for (const link of guideLinks) {
|
||||||
|
expect(link).toHaveAttribute("href", "/how-it-works");
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", {
|
||||||
|
name: "Why horizontal communities need CommunityRule",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Transparent, inclusive decisions"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
const faq = screen.getByRole("region", {
|
||||||
|
name: "Get answers to your questions",
|
||||||
|
});
|
||||||
|
expect(faq).toHaveAttribute("id", "faq");
|
||||||
|
expect(
|
||||||
|
within(faq).getByRole("button", {
|
||||||
|
name: "Do we have to start from a blank page?",
|
||||||
|
}),
|
||||||
|
).toHaveAttribute("aria-expanded", "true");
|
||||||
|
|
||||||
expect(screen.getByTestId("ask-organizer")).toBeInTheDocument();
|
expect(screen.getByTestId("ask-organizer")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Still have questions?")).toBeInTheDocument();
|
expect(screen.getByText("Still have questions?")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders one card per post (single responsive grid, no duplication)", () => {
|
it("renders the guide and one card per post, without a duplicate grid", () => {
|
||||||
render(<LearnPage />);
|
render(<LearnPage />);
|
||||||
|
|
||||||
const grid = screen.getByTestId("learn-article-grid");
|
const grid = screen.getByTestId("learn-article-grid");
|
||||||
expect(grid).toHaveClass("smd:justify-center", "smd:flex-wrap");
|
expect(grid).toHaveClass("md:justify-center", "md:flex-wrap");
|
||||||
|
expect(grid).toHaveClass(
|
||||||
|
"lg:[&>*]:w-[calc((100%-2*var(--space-400))/3)]",
|
||||||
|
);
|
||||||
|
expect(grid).toHaveClass(
|
||||||
|
"xl:[&>*]:w-[calc((100%-3*var(--space-400))/4)]",
|
||||||
|
);
|
||||||
|
expect(grid.className).not.toContain("/5)]");
|
||||||
|
|
||||||
const links = within(grid).getAllByRole("link");
|
const links = within(grid).getAllByRole("link");
|
||||||
expect(links).toHaveLength(mockPosts.length);
|
expect(links).toHaveLength(mockPosts.length + 1);
|
||||||
|
|
||||||
expect(links[0]).toHaveAttribute(
|
expect(links[0]).toHaveAttribute("href", "/how-it-works");
|
||||||
|
expect(links[1]).toHaveAttribute(
|
||||||
"href",
|
"href",
|
||||||
"/blog/resolving-active-conflicts",
|
"/blog/resolving-active-conflicts",
|
||||||
);
|
);
|
||||||
expect(links[1]).toHaveAttribute(
|
expect(links[2]).toHaveAttribute(
|
||||||
"href",
|
"href",
|
||||||
"/blog/operational-security-mutual-aid",
|
"/blog/operational-security-mutual-aid",
|
||||||
);
|
);
|
||||||
|
expect(within(grid).queryByText("April 15, 2025")).not.toBeInTheDocument();
|
||||||
|
|
||||||
// <picture> with a smd source provides the orientation swap without a
|
const sources = grid.querySelectorAll(
|
||||||
// duplicate card per breakpoint.
|
"picture source[media='(min-width: 640px)']",
|
||||||
const sources = grid?.querySelectorAll(
|
|
||||||
"picture source[media='(min-width: 530px)']",
|
|
||||||
);
|
);
|
||||||
expect(sources?.length).toBe(mockPosts.length);
|
expect(sources.length).toBe(mockPosts.length + 1);
|
||||||
|
|
||||||
|
const icons = grid.querySelectorAll("img");
|
||||||
|
for (const icon of icons) {
|
||||||
|
expect(icon).toHaveAttribute("alt", "");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -69,6 +69,14 @@ describe("UseCaseCompletedRulePage", () => {
|
|||||||
screen.getByRole("heading", { name: fixture.title }),
|
screen.getByRole("heading", { name: fixture.title }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
if (slug === "mutual-aid-colorado") {
|
if (slug === "mutual-aid-colorado") {
|
||||||
|
expect(
|
||||||
|
screen.getByText(/political solidarity rather than charity/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText(/Food Not Bombs is not a charity/),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
}
|
||||||
|
if (slug === "food-not-bombs") {
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/Food Not Bombs is not a charity/),
|
screen.getByText(/Food Not Bombs is not a charity/),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,35 +1,19 @@
|
|||||||
import { describe, test, expect, vi } from "vitest";
|
import { describe, test, expect, vi } from "vitest";
|
||||||
import { screen } from "@testing-library/react";
|
|
||||||
import { renderWithProviders as render } from "../utils/test-utils";
|
|
||||||
import UseCasesPage from "../../app/(marketing)/use-cases/page";
|
import UseCasesPage from "../../app/(marketing)/use-cases/page";
|
||||||
import messages from "../../messages/en/index";
|
|
||||||
|
|
||||||
vi.mock("next/dynamic", () => ({
|
const { redirect } = vi.hoisted(() => ({
|
||||||
default: () => {
|
redirect: vi.fn((url) => {
|
||||||
const Component = vi.fn(() => (
|
throw new Error(`NEXT_REDIRECT:${url}`);
|
||||||
<section data-testid="related-articles">Related articles</section>
|
}),
|
||||||
));
|
|
||||||
return Component;
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("next/link", () => ({
|
vi.mock("next/navigation", () => ({
|
||||||
default: ({ children, href, ...props }) => (
|
redirect,
|
||||||
<a href={href} {...props}>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("UseCasesPage", () => {
|
describe("UseCasesPage", () => {
|
||||||
const links = messages.pages.useCases.caseStudyTiles.links;
|
test("sends the use cases index to Learn", () => {
|
||||||
|
expect(() => UseCasesPage()).toThrow("NEXT_REDIRECT:/learn");
|
||||||
test("renders case study tiles as links to detail pages", () => {
|
expect(redirect).toHaveBeenCalledWith("/learn");
|
||||||
render(<UseCasesPage />);
|
|
||||||
|
|
||||||
for (const link of links) {
|
|
||||||
const anchor = screen.getByRole("link", { name: link.ariaLabel });
|
|
||||||
expect(anchor).toHaveAttribute("href", link.href);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -14,7 +14,7 @@ describe("buildPublicSitemap", () => {
|
|||||||
expect(urls).toContain(sitemapUrl("/learn"));
|
expect(urls).toContain(sitemapUrl("/learn"));
|
||||||
expect(urls).toContain(sitemapUrl("/templates"));
|
expect(urls).toContain(sitemapUrl("/templates"));
|
||||||
expect(urls).toContain(sitemapUrl("/blog"));
|
expect(urls).toContain(sitemapUrl("/blog"));
|
||||||
expect(urls).toContain(sitemapUrl("/use-cases"));
|
expect(urls).not.toContain(sitemapUrl("/use-cases"));
|
||||||
expect(urls).toContain(sitemapUrl("/how-it-works"));
|
expect(urls).toContain(sitemapUrl("/how-it-works"));
|
||||||
expect(urls).toContain(sitemapUrl("/privacy"));
|
expect(urls).toContain(sitemapUrl("/privacy"));
|
||||||
expect(urls).toContain(sitemapUrl("/terms"));
|
expect(urls).toContain(sitemapUrl("/terms"));
|
||||||
|
|||||||
@@ -146,13 +146,27 @@ describe("originFromHost", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("buildRobots", () => {
|
describe("buildRobots", () => {
|
||||||
it("disallows crawlers on staging and unknown hosts", () => {
|
it("disallows crawlers on staging and unknown hosts, and still lets link unfurlers fetch the page", () => {
|
||||||
expect(buildRobots("staging.communityrule.info")).toEqual({
|
const staging = buildRobots("staging.communityrule.info");
|
||||||
rules: { userAgent: "*", disallow: "/" },
|
expect(staging).toEqual({
|
||||||
});
|
rules: [
|
||||||
expect(buildRobots("localhost:3000")).toEqual({
|
{ userAgent: "*", disallow: "/" },
|
||||||
rules: { userAgent: "*", disallow: "/" },
|
{
|
||||||
|
userAgent: [
|
||||||
|
"Twitterbot",
|
||||||
|
"facebookexternalhit",
|
||||||
|
"Slackbot",
|
||||||
|
"Discordbot",
|
||||||
|
"LinkedInBot",
|
||||||
|
"TelegramBot",
|
||||||
|
"WhatsApp",
|
||||||
|
"Bluesky",
|
||||||
|
],
|
||||||
|
allow: "/",
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
expect(buildRobots("localhost:3000")).toEqual(staging);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows production hosts and advertises the sitemap", () => {
|
it("allows production hosts and advertises the sitemap", () => {
|
||||||
@@ -168,10 +182,9 @@ describe("buildRobots", () => {
|
|||||||
|
|
||||||
it("honors SITE_INDEXING overrides", () => {
|
it("honors SITE_INDEXING overrides", () => {
|
||||||
process.env.SITE_INDEXING = "false";
|
process.env.SITE_INDEXING = "false";
|
||||||
expect(buildRobots("communityrule.info").rules).toEqual({
|
expect(buildRobots("communityrule.info").rules).toEqual(
|
||||||
userAgent: "*",
|
buildRobots("staging.communityrule.info").rules,
|
||||||
disallow: "/",
|
);
|
||||||
});
|
|
||||||
|
|
||||||
process.env.SITE_INDEXING = "true";
|
process.env.SITE_INDEXING = "true";
|
||||||
expect(buildRobots("staging.communityrule.info").sitemap).toBe(
|
expect(buildRobots("staging.communityrule.info").sitemap).toBe(
|
||||||
|
|||||||
Reference in new issue
Block a user