Create use cases pages
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Figma: use case detail (22015:42619)
|
||||
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22015-42619
|
||||
*/
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import messages from "../../../../messages/en/index";
|
||||
import {
|
||||
buildUseCaseSyntheticPost,
|
||||
getUseCaseDetailEntry,
|
||||
isUseCaseDetailSlug,
|
||||
USE_CASE_DETAIL_SLUGS,
|
||||
useCaseContentKeyForSlug,
|
||||
} from "../../../../lib/useCaseSyntheticPost";
|
||||
import ContentBanner from "../../../components/sections/ContentBanner";
|
||||
import AskOrganizer from "../../../components/sections/AskOrganizer";
|
||||
import type { AskOrganizerVariant } from "../../../components/sections/AskOrganizer/AskOrganizer.types";
|
||||
import "../use-cases.css";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ slug: string }>;
|
||||
};
|
||||
|
||||
export function generateStaticParams() {
|
||||
return USE_CASE_DETAIL_SLUGS.map((slug) => ({ slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
if (!isUseCaseDetailSlug(slug)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const contentKey = useCaseContentKeyForSlug(slug);
|
||||
const meta = messages.metadata.useCasesDetail[contentKey];
|
||||
|
||||
return {
|
||||
title: meta.title,
|
||||
description: meta.description,
|
||||
keywords: meta.keywords,
|
||||
openGraph: {
|
||||
title: meta.title,
|
||||
description: meta.description,
|
||||
type: "website",
|
||||
siteName: "CommunityRule",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function UseCaseDetailPage({ params }: PageProps) {
|
||||
const { slug } = await params;
|
||||
if (!isUseCaseDetailSlug(slug)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const detail = messages.pages.useCasesDetail;
|
||||
const entry = getUseCaseDetailEntry(slug, detail);
|
||||
const syntheticPost = buildUseCaseSyntheticPost(slug, detail);
|
||||
const { ruleCard, askOrganizer } = entry;
|
||||
|
||||
const askVariant = (askOrganizer.variant ?? "use-case-detail") as AskOrganizerVariant;
|
||||
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: entry.banner.title,
|
||||
description: entry.banner.description,
|
||||
url: `https://communityrule.com/use-cases/${slug}`,
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: "CommunityRule",
|
||||
url: "https://communityrule.com",
|
||||
},
|
||||
};
|
||||
|
||||
const breadcrumbData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 1,
|
||||
name: "Home",
|
||||
item: "https://communityrule.com",
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: "Use cases",
|
||||
item: "https://communityrule.com/use-cases",
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 3,
|
||||
name: entry.banner.title,
|
||||
item: `https://communityrule.com/use-cases/${slug}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(structuredData),
|
||||
}}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(breadcrumbData),
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="min-h-screen"
|
||||
style={{ background: entry.pageBackground }}
|
||||
>
|
||||
<ContentBanner
|
||||
post={syntheticPost}
|
||||
variant="useCase"
|
||||
rulePreview={{
|
||||
title: ruleCard.title,
|
||||
description: ruleCard.description,
|
||||
backgroundColor: ruleCard.backgroundColor,
|
||||
iconPath: ruleCard.iconPath,
|
||||
}}
|
||||
/>
|
||||
<article
|
||||
data-figma-node="22015:42622"
|
||||
className="flex w-full items-center justify-center self-stretch px-[var(--spacing-scale-024)] py-[var(--spacing-scale-032)] sm:px-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-048)] lg:px-[var(--spacing-scale-064)] xl:px-[256px]"
|
||||
>
|
||||
<div
|
||||
className="use-case-body"
|
||||
dangerouslySetInnerHTML={{ __html: syntheticPost.htmlContent }}
|
||||
/>
|
||||
</article>
|
||||
|
||||
<AskOrganizer
|
||||
title={askOrganizer.title}
|
||||
subtitle={askOrganizer.subtitle}
|
||||
buttonText={askOrganizer.buttonText}
|
||||
variant={askVariant}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import messages from "../../../messages/en/index";
|
||||
import { getAllBlogPosts } from "../../../lib/content";
|
||||
@@ -30,6 +31,16 @@ 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-transform duration-200",
|
||||
"hover:scale-[1.02] 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 ↔ `public/assets/vector/*.svg`. */
|
||||
const USE_CASES_GROUP_VECTOR_SLUGS = [
|
||||
"worker-coop",
|
||||
@@ -99,6 +110,16 @@ export default function UseCasesPage() {
|
||||
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
|
||||
@@ -109,18 +130,27 @@ export default function UseCasesPage() {
|
||||
/>
|
||||
|
||||
<UseCasesOrgs>
|
||||
<CaseStudy
|
||||
surface="lavender"
|
||||
imageAlt={page.caseStudyTiles.mutualAidColoradoAlt}
|
||||
/>
|
||||
<CaseStudy
|
||||
surface="neutral"
|
||||
imageAlt={page.caseStudyTiles.foodNotBombsAlt}
|
||||
/>
|
||||
<CaseStudy
|
||||
surface="rose"
|
||||
imageAlt={page.caseStudyTiles.boulderCountyStreetMedicsAlt}
|
||||
/>
|
||||
{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
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Figma: use case detail body text (22015:42622 / 22015:42623)
|
||||
* X Large/Paragraph — Inter 24px / 32px, inverse primary on brand surface.
|
||||
*/
|
||||
.use-case-body {
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
font-family: var(--font-inter, Inter, sans-serif);
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
line-height: 130%;
|
||||
color: var(--color-content-inverse-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.use-case-body {
|
||||
font-size: 24px;
|
||||
line-height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.use-case-body p {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
.use-case-body p + p {
|
||||
margin-block-start: 1em;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.use-case-body p + p {
|
||||
margin-block-start: 32px;
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ const RuleContainer = memo<RuleProps>(
|
||||
bottomLinks,
|
||||
recommended = false,
|
||||
templateGridFigmaShell = false,
|
||||
fluidWidth = false,
|
||||
}) => {
|
||||
const size = sizeProp ?? "L";
|
||||
|
||||
@@ -100,6 +101,7 @@ const RuleContainer = memo<RuleProps>(
|
||||
bottomLinks={bottomLinks}
|
||||
recommended={recommended}
|
||||
templateGridFigmaShell={templateGridFigmaShell}
|
||||
fluidWidth={fluidWidth}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface RuleProps {
|
||||
* Marketing **GovernanceTemplateGrid** / RuleStack shell (Figma [22085:860413](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-860413&m=dev); card shell **18375:22616**).
|
||||
*/
|
||||
templateGridFigmaShell?: boolean;
|
||||
/** When true, expanded cards fill their container instead of a fixed Figma width. */
|
||||
fluidWidth?: boolean;
|
||||
}
|
||||
|
||||
export interface RuleViewProps {
|
||||
@@ -95,4 +97,5 @@ export interface RuleViewProps {
|
||||
bottomLinks?: RuleBottomLink[];
|
||||
recommended?: boolean;
|
||||
templateGridFigmaShell?: boolean;
|
||||
fluidWidth?: boolean;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export function RuleView({
|
||||
bottomLinks,
|
||||
recommended = false,
|
||||
templateGridFigmaShell = false,
|
||||
fluidWidth = false,
|
||||
}: RuleViewProps) {
|
||||
const t = useTranslation("ruleCard");
|
||||
const ariaLabel = t("ariaLabel")?.replace("{title}", title) || title;
|
||||
@@ -74,13 +75,16 @@ export function RuleView({
|
||||
: isMedium
|
||||
? "gap-[12px]"
|
||||
: "gap-[18px]"; // XS and S: 18px gap
|
||||
const cardWidth = expanded
|
||||
? isLarge
|
||||
? "w-[568px]"
|
||||
: isMedium
|
||||
? "w-[398px]"
|
||||
: "" // XS and S: no fixed width
|
||||
: "";
|
||||
const cardWidth =
|
||||
fluidWidth && expanded
|
||||
? ""
|
||||
: expanded
|
||||
? isLarge
|
||||
? "w-[568px]"
|
||||
: isMedium
|
||||
? "w-[398px]"
|
||||
: ""
|
||||
: "";
|
||||
|
||||
// Logo/Icon dimensions (inner circle) after Figma header `pl-1 pr-2 py-2` in icon cell
|
||||
// (Card / Rule — e.g. `22143:900771` / `19706:12110`); outer column width holds padding + this.
|
||||
@@ -363,9 +367,11 @@ export function RuleView({
|
||||
(onDescriptionClick &&
|
||||
typeof descriptionEmptyHint === "string")) && (
|
||||
<div
|
||||
className={`relative w-full shrink-0 border-b border-solid border-[var(--color-content-invert-primary)] pb-[16px] ${
|
||||
expanded && (isLarge || isMedium) ? "px-0" : "px-[12px]"
|
||||
}`}
|
||||
className={`relative w-full shrink-0 ${
|
||||
categories && categories.length > 0
|
||||
? "border-b border-solid border-[var(--color-content-invert-primary)] pb-[16px]"
|
||||
: ""
|
||||
} ${expanded && (isLarge || isMedium) ? "px-0" : "px-[12px]"}`}
|
||||
>
|
||||
{onDescriptionClick ? (
|
||||
<InlineTextButton
|
||||
|
||||
@@ -10,10 +10,21 @@ const ContentContainerContainer = memo<ContentContainerProps>(
|
||||
post,
|
||||
width = "200px",
|
||||
size: sizeProp = "responsive",
|
||||
tone: toneProp = "inverse",
|
||||
leadingImageSrc,
|
||||
leadingImageAlt,
|
||||
showLeadingImage: showLeadingImageProp = true,
|
||||
}) => {
|
||||
const size = sizeProp;
|
||||
const tone = toneProp;
|
||||
const showLeadingImage = showLeadingImageProp;
|
||||
const onLight = tone === "onLight";
|
||||
const titleColor = onLight
|
||||
? "text-[var(--color-content-default-primary)] group-hover:text-[var(--color-content-default-brand-primary)]"
|
||||
: "text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200";
|
||||
const bodyColor = onLight
|
||||
? "text-[var(--color-content-default-secondary)]"
|
||||
: "text-[var(--color-content-inverse-brand-royal)]";
|
||||
// Get the corresponding icon based on the same logic as background images
|
||||
const getIconImage = (slug: string): string => {
|
||||
const icons = [
|
||||
@@ -67,31 +78,33 @@ const ContentContainerContainer = memo<ContentContainerProps>(
|
||||
|
||||
const titleClasses =
|
||||
size === "xs"
|
||||
? "font-bricolage font-medium text-[18px] leading-[120%] text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200 transition-colors"
|
||||
: "font-bricolage font-medium text-[18px] leading-[120%] sm:text-[24px] sm:leading-[24px] md:text-[32px] md:leading-[110%] lg:text-[44px] lg:leading-[110%] xl:text-[64px] xl:leading-[110%] text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200 transition-colors";
|
||||
? `font-bricolage font-medium text-[18px] leading-[120%] transition-colors ${titleColor}`
|
||||
: `font-bricolage font-medium text-[18px] leading-[120%] sm:text-[24px] sm:leading-[24px] md:text-[32px] md:leading-[110%] lg:text-[44px] lg:leading-[110%] xl:text-[64px] xl:leading-[110%] transition-colors ${titleColor}`;
|
||||
|
||||
const descriptionClasses =
|
||||
size === "xs"
|
||||
? "font-inter font-normal text-[12px] leading-[16px] text-[var(--color-content-inverse-brand-royal)] max-w-md"
|
||||
: "font-inter font-normal text-[12px] leading-[16px] sm:text-[14px] sm:leading-[20px] md:text-[14px] md:leading-[20px] lg:text-[18px] lg:leading-[130%] xl:text-[24px] xl:leading-[32px] text-[var(--color-content-inverse-brand-royal)]";
|
||||
? `font-inter font-normal text-[12px] leading-[16px] max-w-md ${bodyColor}`
|
||||
: `font-inter font-normal text-[12px] leading-[16px] sm:text-[14px] sm:leading-[20px] md:text-[14px] md:leading-[20px] lg:text-[18px] lg:leading-[130%] xl:text-[24px] xl:leading-[32px] ${bodyColor}`;
|
||||
|
||||
const authorClasses =
|
||||
size === "xs"
|
||||
? "font-inter font-normal text-[10px] leading-[14px] text-[var(--color-content-inverse-brand-royal)]"
|
||||
: "font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] text-[var(--color-content-inverse-brand-royal)]";
|
||||
? `font-inter font-normal text-[10px] leading-[14px] ${bodyColor}`
|
||||
: `font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] ${bodyColor}`;
|
||||
|
||||
const dateClasses =
|
||||
size === "xs"
|
||||
? "font-inter font-normal text-[10px] leading-[14px] text-[var(--color-content-inverse-brand-royal)]"
|
||||
: "font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] text-[var(--color-content-inverse-brand-royal)]";
|
||||
? `font-inter font-normal text-[10px] leading-[14px] ${bodyColor}`
|
||||
: `font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] ${bodyColor}`;
|
||||
|
||||
return (
|
||||
<ContentContainerView
|
||||
post={post}
|
||||
width={width}
|
||||
size={size}
|
||||
tone={tone}
|
||||
iconImage={iconImage}
|
||||
iconAlt={iconAlt}
|
||||
showLeadingImage={showLeadingImage}
|
||||
containerClasses={containerClasses}
|
||||
contentGapClasses={contentGapClasses}
|
||||
textGapClasses={textGapClasses}
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { BlogPost } from "../../../../lib/content";
|
||||
|
||||
export type ContentContainerSizeValue = "xs" | "responsive";
|
||||
|
||||
/** `inverse` — blog hero on imagery; `onLight` — marketing pages on default surface. */
|
||||
export type ContentContainerToneValue = "inverse" | "onLight";
|
||||
|
||||
export interface ContentContainerProps {
|
||||
post: BlogPost;
|
||||
width?: string;
|
||||
@@ -9,18 +12,26 @@ export interface ContentContainerProps {
|
||||
* Content container size.
|
||||
*/
|
||||
size?: ContentContainerSizeValue;
|
||||
/**
|
||||
* Text color tokens. Default `inverse` (royal on dark/imagery).
|
||||
*/
|
||||
tone?: ContentContainerToneValue;
|
||||
/** When set, replaces the default slug-based thumbnail icon. */
|
||||
leadingImageSrc?: string;
|
||||
/** Alt text for `leadingImageSrc`; defaults to post title. */
|
||||
leadingImageAlt?: string;
|
||||
/** When false, omits the icon row above the title. Default true. */
|
||||
showLeadingImage?: boolean;
|
||||
}
|
||||
|
||||
export interface ContentContainerViewProps {
|
||||
post: BlogPost;
|
||||
width: string;
|
||||
size: "xs" | "responsive";
|
||||
tone: ContentContainerToneValue;
|
||||
iconImage: string;
|
||||
iconAlt: string;
|
||||
showLeadingImage: boolean;
|
||||
containerClasses: string;
|
||||
contentGapClasses: string;
|
||||
textGapClasses: string;
|
||||
|
||||
@@ -7,6 +7,7 @@ function ContentContainerView({
|
||||
size,
|
||||
iconImage,
|
||||
iconAlt,
|
||||
showLeadingImage,
|
||||
containerClasses,
|
||||
contentGapClasses,
|
||||
textGapClasses,
|
||||
@@ -23,15 +24,16 @@ function ContentContainerView({
|
||||
>
|
||||
{/* Content Container - gap between icon and text */}
|
||||
<div className={contentGapClasses}>
|
||||
{/* Icon */}
|
||||
<div className="w-[60px] h-[30px] flex items-center justify-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={iconImage}
|
||||
alt={iconAlt}
|
||||
className="w-[60px] h-[30px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
{showLeadingImage ? (
|
||||
<div className="flex h-[30px] w-[60px] items-center justify-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={iconImage}
|
||||
alt={iconAlt}
|
||||
className="h-[30px] w-[60px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Text Container */}
|
||||
<div className={textGapClasses}>
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
} from "./AskOrganizer.types";
|
||||
|
||||
const VARIANT_STYLES: Record<
|
||||
"centered" | "left-aligned" | "compact" | "inverse",
|
||||
AskOrganizerVariant,
|
||||
{ container: string; buttonContainer: string }
|
||||
> = {
|
||||
centered: {
|
||||
@@ -30,9 +30,13 @@ const VARIANT_STYLES: Record<
|
||||
container: "text-center",
|
||||
buttonContainer: "flex justify-center",
|
||||
},
|
||||
"use-case-detail": {
|
||||
container: "w-full text-center",
|
||||
buttonContainer: "flex w-full justify-center",
|
||||
},
|
||||
};
|
||||
|
||||
/** Figma **Section/AskOrganizer** [18116:15960](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=18116-15960&m=dev) (`lg` shell + type + button). */
|
||||
/** Figma **Section/AskOrganizer** [18116:15960](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=18116-15960&m=dev) (`lg` shell + type + button). Use-case detail: [22015:42624](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22015-42624&m=dev). */
|
||||
const AskOrganizerContainer = memo<AskOrganizerProps>(
|
||||
({
|
||||
title,
|
||||
@@ -57,12 +61,16 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
|
||||
const sectionPadding =
|
||||
resolvedVariant === "compact"
|
||||
? "py-[var(--spacing-scale-016)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-032)]"
|
||||
: "py-[var(--spacing-scale-096)] px-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-064)]";
|
||||
: resolvedVariant === "use-case-detail"
|
||||
? "w-full py-[var(--spacing-scale-096)] px-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-064)]"
|
||||
: "py-[var(--spacing-scale-096)] px-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-064)]";
|
||||
|
||||
const contentGap =
|
||||
resolvedVariant === "compact"
|
||||
? "gap-[var(--spacing-scale-020)]"
|
||||
: "gap-[var(--spacing-scale-040)]";
|
||||
: resolvedVariant === "use-case-detail"
|
||||
? "gap-[var(--spacing-scale-040)]"
|
||||
: "gap-[var(--spacing-scale-040)]";
|
||||
|
||||
const labelledBy = title ? "ask-organizer-headline" : undefined;
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ export type AskOrganizerVariant =
|
||||
| "centered"
|
||||
| "left-aligned"
|
||||
| "compact"
|
||||
| "inverse";
|
||||
| "inverse"
|
||||
| "use-case-detail";
|
||||
|
||||
export interface AskOrganizerProps {
|
||||
title?: string;
|
||||
|
||||
@@ -21,6 +21,13 @@ function AskOrganizerView({
|
||||
}: AskOrganizerViewProps) {
|
||||
const t = useTranslation();
|
||||
const ariaLabel = t("askOrganizer.ariaLabel");
|
||||
const isUseCaseDetail = variant === "use-case-detail";
|
||||
const lockupVariant =
|
||||
variant === "inverse" || isUseCaseDetail ? "ask-inverse" : "ask";
|
||||
const lockupAlignment =
|
||||
variant === "left-aligned" ? "left" : "center";
|
||||
const buttonPalette =
|
||||
variant === "inverse" || isUseCaseDetail ? "inverse" : "default";
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -28,16 +35,18 @@ function AskOrganizerView({
|
||||
aria-labelledby={labelledBy}
|
||||
aria-label={labelledBy ? undefined : ariaLabel}
|
||||
tabIndex={-1}
|
||||
data-figma-node="18116-15960"
|
||||
data-figma-node={isUseCaseDetail ? "22015-42624" : "18116-15960"}
|
||||
>
|
||||
<div className={`flex flex-col ${contentGap}`}>
|
||||
<div
|
||||
className={`mx-auto flex w-full min-w-[358px] max-w-[1280px] flex-col ${contentGap} ${isUseCaseDetail ? "items-center" : ""}`}
|
||||
>
|
||||
{/* Content Lockup */}
|
||||
<ContentLockup
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
description={description}
|
||||
variant={variant === "inverse" ? "ask-inverse" : "ask"}
|
||||
alignment={variant === "left-aligned" ? "left" : "center"}
|
||||
variant={lockupVariant}
|
||||
alignment={lockupAlignment}
|
||||
titleId={labelledBy}
|
||||
/>
|
||||
|
||||
@@ -49,7 +58,7 @@ function AskOrganizerView({
|
||||
{...(buttonHref ? { href: buttonHref } : {})}
|
||||
size="large"
|
||||
buttonType="filled"
|
||||
palette={variant === "inverse" ? "inverse" : "default"}
|
||||
palette={buttonPalette}
|
||||
className="!px-[var(--spacing-scale-016)] !py-[var(--spacing-scale-012)]"
|
||||
onClick={onContactClick}
|
||||
ariaLabel={ariaLabel}
|
||||
|
||||
@@ -13,6 +13,8 @@ const ContentBannerContainer = memo<ContentBannerProps>(
|
||||
variant: variantProp = "article",
|
||||
leadingImageSrc,
|
||||
leadingImageAlt,
|
||||
rulePreview,
|
||||
contentTone,
|
||||
}) => {
|
||||
const variant = variantProp;
|
||||
|
||||
@@ -46,6 +48,8 @@ const ContentBannerContainer = memo<ContentBannerProps>(
|
||||
leadingImageAlt={leadingImageAlt}
|
||||
backgroundImageSm={backgroundImageSm}
|
||||
backgroundImageMd={backgroundImageMd}
|
||||
rulePreview={rulePreview}
|
||||
contentTone={contentTone}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
import type { BlogPost } from "../../../../lib/content";
|
||||
import type { ContentContainerToneValue } from "../../content/ContentContainer/ContentContainer.types";
|
||||
|
||||
export type ContentBannerVariant = "article" | "guide";
|
||||
export type ContentBannerVariant = "article" | "guide" | "useCase";
|
||||
|
||||
/** Rule column for `useCase` variant (Figma 22015:42621). */
|
||||
export interface ContentBannerRulePreview {
|
||||
title: string;
|
||||
description: string;
|
||||
backgroundColor: string;
|
||||
iconPath: string;
|
||||
}
|
||||
|
||||
export interface ContentBannerProps {
|
||||
post: BlogPost;
|
||||
/**
|
||||
* `article` — blog post hero with thumbnail/banner imagery and metadata.
|
||||
* `guide` — static guide pages (Figma ContentBanner on content page template).
|
||||
* `useCase` — use case detail: ContentContainer + Rule preview.
|
||||
*/
|
||||
variant?: ContentBannerVariant;
|
||||
/** Article variant only: replaces slug-based thumbnail icon in ContentContainer. */
|
||||
/** Article / useCase: replaces slug-based thumbnail icon in ContentContainer. */
|
||||
leadingImageSrc?: string;
|
||||
leadingImageAlt?: string;
|
||||
/** `useCase` only: expanded Rule preview in the right column. */
|
||||
rulePreview?: ContentBannerRulePreview;
|
||||
/** `useCase` only: ContentContainer text tokens (default `onLight`). */
|
||||
contentTone?: ContentContainerToneValue;
|
||||
}
|
||||
|
||||
export interface ContentBannerViewProps {
|
||||
@@ -21,4 +35,6 @@ export interface ContentBannerViewProps {
|
||||
leadingImageAlt?: string;
|
||||
backgroundImageSm?: string;
|
||||
backgroundImageMd?: string;
|
||||
rulePreview?: ContentBannerRulePreview;
|
||||
contentTone?: ContentContainerToneValue;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { memo } from "react";
|
||||
import ContentContainer from "../../content/ContentContainer";
|
||||
import Rule from "../../cards/Rule";
|
||||
import {
|
||||
getAssetPath,
|
||||
guideBannerLogoArrowPath,
|
||||
@@ -115,11 +117,91 @@ function ContentBannerArticleView({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Figma: use case detail ContentBanner (22015:42621) — copy left, Rule preview right.
|
||||
*/
|
||||
function ContentBannerUseCaseView({
|
||||
post,
|
||||
rulePreview,
|
||||
}: Pick<ContentBannerViewProps, "post" | "rulePreview">) {
|
||||
if (!rulePreview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { title, description, author, date } = post.frontmatter;
|
||||
const formattedDate = new Date(date).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
});
|
||||
|
||||
return (
|
||||
<section
|
||||
className="relative w-full overflow-clip"
|
||||
aria-label={title}
|
||||
>
|
||||
<div
|
||||
data-figma-node="22015:42621"
|
||||
className="mx-auto flex w-full max-w-[1024px] flex-col items-center gap-[var(--space-800)] px-[var(--space-1200)] py-[var(--space-1000)] md:flex-row md:items-center"
|
||||
>
|
||||
<div
|
||||
data-node-id="19189:9171"
|
||||
className="flex w-full max-w-[365px] shrink-0 flex-col gap-[var(--spacing-scale-024)]"
|
||||
>
|
||||
<div className="flex w-full flex-col gap-[var(--measures-spacing-016)]">
|
||||
<div className="flex w-full flex-col gap-[var(--measures-spacing-004)] text-[var(--color-content-inverse-brand-royal)]">
|
||||
<h1 className="w-full font-bricolage font-medium text-[32px] leading-[110%] sm:text-[40px] lg:text-[44px]">
|
||||
{title}
|
||||
</h1>
|
||||
{description ? (
|
||||
<p className="w-full font-inter font-normal text-[16px] leading-[130%] sm:text-[18px]">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-end gap-[var(--measures-spacing-008)] font-inter text-[14px] leading-[20px] text-[var(--color-content-inverse-brand-royal)]">
|
||||
<span>{author}</span>
|
||||
<span>{formattedDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 w-full flex-1">
|
||||
<Rule
|
||||
title={rulePreview.title}
|
||||
description={rulePreview.description}
|
||||
expanded
|
||||
fluidWidth
|
||||
size="L"
|
||||
templateGridFigmaShell
|
||||
backgroundColor={rulePreview.backgroundColor}
|
||||
className="pointer-events-none w-full select-none rounded-[24px]"
|
||||
icon={
|
||||
<Image
|
||||
src={getAssetPath(rulePreview.iconPath)}
|
||||
alt=""
|
||||
width={103}
|
||||
height={103}
|
||||
draggable={false}
|
||||
unoptimized={rulePreview.iconPath.endsWith(".svg")}
|
||||
className="aspect-square size-full max-h-[103px] max-w-[103px] object-contain mix-blend-luminosity"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentBannerView(props: ContentBannerViewProps) {
|
||||
if (props.variant === "guide") {
|
||||
return <ContentBannerGuideView post={props.post} />;
|
||||
}
|
||||
|
||||
if (props.variant === "useCase") {
|
||||
return <ContentBannerUseCaseView {...props} />;
|
||||
}
|
||||
|
||||
return <ContentBannerArticleView {...props} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -118,14 +118,14 @@ const ContentLockupContainer = memo<ContentLockupProps>(
|
||||
"w-[16px] h-[16px] md:w-[20px] md:h-[20px] lg:w-[24px] lg:h-[24px]",
|
||||
},
|
||||
"ask-inverse": {
|
||||
container: "flex flex-col gap-[var(--spacing-scale-008)] relative z-10",
|
||||
container: "flex flex-col gap-[var(--spacing-scale-008)] relative z-10 w-full",
|
||||
textContainer: "flex flex-col gap-[var(--spacing-scale-008)]",
|
||||
titleGroup: "flex flex-col gap-[var(--spacing-scale-008)]",
|
||||
titleGroup: "flex flex-col gap-[var(--spacing-scale-008)] w-full",
|
||||
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
|
||||
title:
|
||||
"font-bricolage-grotesque font-medium text-[36px] leading-[110%] tracking-[0] lg:text-[44px] lg:leading-[1.1] text-[var(--color-content-inverse-primary)]",
|
||||
subtitle:
|
||||
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-inverse-primary)]",
|
||||
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-invert-secondary)]",
|
||||
shape:
|
||||
"w-[16px] h-[16px] md:w-[20px] md:h-[20px] lg:w-[24px] lg:h-[24px]",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { BlogPost } from "./content";
|
||||
import { markdownToHtml } from "./content";
|
||||
import type useCasesDetail from "../messages/en/pages/useCasesDetail.json";
|
||||
|
||||
export const USE_CASE_DETAIL_SLUGS = [
|
||||
"mutual-aid-colorado",
|
||||
"food-not-bombs",
|
||||
"boulder-county-street-medics",
|
||||
] as const;
|
||||
|
||||
export type UseCaseDetailSlug = (typeof USE_CASE_DETAIL_SLUGS)[number];
|
||||
|
||||
type UseCasesDetailMessages = typeof useCasesDetail;
|
||||
|
||||
export type UseCaseDetailContentKey = keyof UseCasesDetailMessages;
|
||||
|
||||
const SLUG_TO_CONTENT_KEY: Record<UseCaseDetailSlug, UseCaseDetailContentKey> = {
|
||||
"mutual-aid-colorado": "mutualAidColorado",
|
||||
"food-not-bombs": "foodNotBombs",
|
||||
"boulder-county-street-medics": "boulderCountyStreetMedics",
|
||||
};
|
||||
|
||||
export function isUseCaseDetailSlug(slug: string): slug is UseCaseDetailSlug {
|
||||
return (USE_CASE_DETAIL_SLUGS as readonly string[]).includes(slug);
|
||||
}
|
||||
|
||||
export function useCaseContentKeyForSlug(
|
||||
slug: UseCaseDetailSlug,
|
||||
): UseCaseDetailContentKey {
|
||||
return SLUG_TO_CONTENT_KEY[slug];
|
||||
}
|
||||
|
||||
export function buildUseCaseSyntheticPost(
|
||||
slug: UseCaseDetailSlug,
|
||||
detail: UseCasesDetailMessages,
|
||||
): BlogPost {
|
||||
const contentKey = useCaseContentKeyForSlug(slug);
|
||||
const entry = detail[contentKey];
|
||||
const { banner, bodyMarkdown } = entry;
|
||||
|
||||
return {
|
||||
slug: `__use-case__:${slug}`,
|
||||
frontmatter: {
|
||||
title: banner.title,
|
||||
description: banner.description,
|
||||
author: banner.author,
|
||||
date: banner.date,
|
||||
},
|
||||
content: bodyMarkdown,
|
||||
htmlContent: markdownToHtml(bodyMarkdown),
|
||||
filePath: `messages/en/pages/useCasesDetail.json#${contentKey}`,
|
||||
lastModified: new Date(banner.date),
|
||||
};
|
||||
}
|
||||
|
||||
export function getUseCaseDetailEntry(
|
||||
slug: UseCaseDetailSlug,
|
||||
detail: UseCasesDetailMessages,
|
||||
) {
|
||||
return detail[useCaseContentKeyForSlug(slug)];
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import templates from "./pages/templates.json";
|
||||
import learn from "./pages/learn.json";
|
||||
import about from "./pages/about.json";
|
||||
import useCases from "./pages/useCases.json";
|
||||
import useCasesDetail from "./pages/useCasesDetail.json";
|
||||
import howItWorks from "./pages/howItWorks.json";
|
||||
import monitor from "./pages/monitor.json";
|
||||
import login from "./pages/login.json";
|
||||
@@ -81,6 +82,7 @@ export default {
|
||||
learn,
|
||||
about,
|
||||
useCases,
|
||||
useCasesDetail,
|
||||
howItWorks,
|
||||
monitor,
|
||||
login,
|
||||
|
||||
@@ -20,6 +20,23 @@
|
||||
"operating manual"
|
||||
]
|
||||
},
|
||||
"useCasesDetail": {
|
||||
"mutualAidColorado": {
|
||||
"title": "Mutual Aid Colorado — CommunityRule",
|
||||
"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"]
|
||||
},
|
||||
"foodNotBombs": {
|
||||
"title": "Food Not Bombs Boulder — CommunityRule",
|
||||
"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"]
|
||||
},
|
||||
"boulderCountyStreetMedics": {
|
||||
"title": "Boulder County Street Medics — CommunityRule",
|
||||
"description": "How CommunityRule helped Boulder County Street Medics define operational process on the streets and off.",
|
||||
"keywords": ["street medics", "use case", "community governance", "operating manual"]
|
||||
}
|
||||
},
|
||||
"howItWorks": {
|
||||
"title": "A Guide to CommunityRule — CommunityRule",
|
||||
"description": "CommunityRule is a modular governance toolkit designed to help democratic groups build, customize, and publish their own Operating Manual.",
|
||||
|
||||
@@ -5,7 +5,21 @@
|
||||
"caseStudyTiles": {
|
||||
"mutualAidColoradoAlt": "Mutual Aid Colorado logo",
|
||||
"foodNotBombsAlt": "Food Not Bombs logo",
|
||||
"boulderCountyStreetMedicsAlt": "Boulder County Street Medics logo"
|
||||
"boulderCountyStreetMedicsAlt": "Boulder County 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 County Street Medics use case"
|
||||
}
|
||||
]
|
||||
},
|
||||
"quote": {
|
||||
"paragraph1": "Most communities don't fail because of a lack of passion, they fail because of undefined processes.",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"mutualAidColorado": {
|
||||
"pageBackground": "var(--color-content-default-brand-lavender)",
|
||||
"banner": {
|
||||
"title": "Mutual Aid Colorado",
|
||||
"description": "How a statewide mutual aid network used CommunityRule to clarify resource sharing, volunteer coordination, and decision-making before a crisis.",
|
||||
"author": "CommunityRule",
|
||||
"date": "2026-01-10"
|
||||
},
|
||||
"ruleCard": {
|
||||
"title": "Mutual Aid Colorado",
|
||||
"description": "Mutual Aid Colorado is a statewide network that empowers frontline community efforts by connecting independent mutual aid groups and building shared logistical infrastructure like supply chains and print shops.",
|
||||
"backgroundColor": "bg-[var(--color-surface-invert-brand-lavender)]",
|
||||
"iconPath": "assets/case-study/case-study-mutual-aid.svg"
|
||||
},
|
||||
"leadingImage": {
|
||||
"src": "assets/case-study/case-study-mutual-aid.svg",
|
||||
"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.",
|
||||
"bodyFooter": {
|
||||
"title": "Ready to document how your group operates?",
|
||||
"description": "Start from proven mutual aid patterns and customize them for your community."
|
||||
},
|
||||
"askOrganizer": {
|
||||
"title": "Still have questions?",
|
||||
"subtitle": "Get answers from an experienced organizer",
|
||||
"buttonText": "Ask an Organizer",
|
||||
"variant": "use-case-detail"
|
||||
}
|
||||
},
|
||||
"foodNotBombs": {
|
||||
"pageBackground": "var(--color-surface-invert-secondary)",
|
||||
"banner": {
|
||||
"title": "Food Not Bombs Boulder",
|
||||
"description": "Food Not Bombs used CommunityRule to translate implicit organizing norms into explicit democratic processes.",
|
||||
"author": "CommunityRule",
|
||||
"date": "2025-04-15"
|
||||
},
|
||||
"ruleCard": {
|
||||
"title": "Food Not Bombs Boulder",
|
||||
"description": "Food Not Bombs Boulder is a mutual aid collective that recovers surplus food to share free, public meals with the community, protesting war and poverty while advocating for food as a fundamental human right.",
|
||||
"backgroundColor": "bg-[var(--color-surface-invert-secondary)]",
|
||||
"iconPath": "assets/use-cases/case-study-food-not-bombs.png"
|
||||
},
|
||||
"leadingImage": {
|
||||
"src": "assets/use-cases/case-study-food-not-bombs.png",
|
||||
"alt": "Food Not Bombs logo"
|
||||
},
|
||||
"bodyMarkdown": "Food Not Bombs operates on a fundamentally decentralized model. However it is a well documented phenomenon that organizations relying entirely on informal networks often develop unspoken power dynamics. When rules remain unstated the individuals with the most experience or social capital tend to direct operations by default. The Boulder chapter utilized CommunityRule to address this structural vulnerability. The platform offers a modular approach to organizational design that allows groups to select and adapt established governance patterns. By applying this toolkit the chapter successfully translated their implicit cultural norms into an explicit operating manual without adopting a rigid corporate hierarchy.\n\nA central advantage of this approach is the ability to construct a polycentric governance system. Rather than relying on a single central committee the organization distributes authority across multiple overlapping domains. CommunityRule helped the Boulder chapter map out these distinct operational needs by defining different speeds of decision making. For example broad constitutional amendments require a slow lazy consensus period to ensure comprehensive agreement. In contrast daily logistical tasks are delegated to autonomous working groups like the Finance team. This structural differentiation allows the chapter to maintain democratic participation while efficiently managing the routine demands of food recovery and distribution.\n\nFinally formalizing these operational agreements provides a critical foundation for organizational continuity. Grassroots initiatives frequently experience high participant turnover which can lead to a rapid loss of operational knowledge. By using CommunityRule to document their financial protocols and conflict escalation pathways in a legible format the chapter created a highly resilient shared resource. This explicit documentation functions as a stabilizing technology that outlasts the tenure of any individual founder or core member. It offers a practical template for how horizontal organizations can balance their ideological commitments to shared leadership with the practical necessity of maintaining a reliable infrastructure over time.",
|
||||
"bodyFooter": {
|
||||
"title": "Ready to document how your group operates?",
|
||||
"description": "Adapt open-source and mutual aid patterns for your local chapter."
|
||||
},
|
||||
"askOrganizer": {
|
||||
"title": "Still have questions?",
|
||||
"subtitle": "Get answers from an experienced organizer",
|
||||
"buttonText": "Ask an Organizer",
|
||||
"variant": "use-case-detail"
|
||||
}
|
||||
},
|
||||
"boulderCountyStreetMedics": {
|
||||
"pageBackground": "var(--color-content-default-brand-red)",
|
||||
"banner": {
|
||||
"title": "Boulder County Street Medics",
|
||||
"description": "CommunityRule helped the Street Medics define their operational process both on the streets and off.",
|
||||
"author": "CommunityRule",
|
||||
"date": "2025-04-15"
|
||||
},
|
||||
"ruleCard": {
|
||||
"title": "BoCo 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.",
|
||||
"backgroundColor": "bg-[var(--color-surface-invert-brand-red)]",
|
||||
"iconPath": "assets/use-cases/case-study-boulder-county-street-medics.png"
|
||||
},
|
||||
"leadingImage": {
|
||||
"src": "assets/use-cases/case-study-boulder-county-street-medics.png",
|
||||
"alt": "Boulder County 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.",
|
||||
"bodyFooter": {
|
||||
"title": "Ready to document how your group operates?",
|
||||
"description": "Build safety-first governance that volunteers can read before they deploy."
|
||||
},
|
||||
"askOrganizer": {
|
||||
"title": "Still have questions?",
|
||||
"subtitle": "Get answers from an experienced organizer",
|
||||
"buttonText": "Ask an Organizer",
|
||||
"variant": "use-case-detail"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export default {
|
||||
},
|
||||
variant: {
|
||||
control: { type: "select" },
|
||||
options: ["centered", "left-aligned", "compact", "inverse"],
|
||||
options: ["centered", "left-aligned", "compact", "inverse", "use-case-detail"],
|
||||
description: "Layout variant for the component",
|
||||
},
|
||||
onContactClick: {
|
||||
@@ -84,6 +84,26 @@ export const Inverse = {
|
||||
},
|
||||
};
|
||||
|
||||
export const UseCaseDetail = {
|
||||
args: {
|
||||
title: "Still have questions?",
|
||||
subtitle: "Get answers from an experienced organizer",
|
||||
buttonText: "Ask an Organizer",
|
||||
variant: "use-case-detail",
|
||||
onContactClick: (data) => console.log("Contact clicked:", data),
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div
|
||||
className="min-h-[360px] w-full"
|
||||
style={{ background: "var(--color-content-default-brand-lavender)" }}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/** Legacy: CTA is a link (no inquiry modal). */
|
||||
export const LinkCta = {
|
||||
args: {
|
||||
|
||||
@@ -55,7 +55,11 @@ export default {
|
||||
},
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["article", "guide"],
|
||||
options: ["article", "guide", "useCase"],
|
||||
},
|
||||
rulePreview: {
|
||||
control: "object",
|
||||
description: "useCase variant only",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -67,6 +71,34 @@ export const Article = {
|
||||
},
|
||||
};
|
||||
|
||||
const useCaseRulePreview = {
|
||||
title: "Mutual Aid Colorado Operating Manual",
|
||||
description:
|
||||
"Shared values, resource distribution, volunteer shifts, and consensus-minus-one decisions.",
|
||||
backgroundColor: "bg-[var(--color-surface-invert-brand-lavender)]",
|
||||
iconPath: "assets/case-study/case-study-mutual-aid.svg",
|
||||
};
|
||||
|
||||
export const UseCase = {
|
||||
args: {
|
||||
post: guidePost,
|
||||
variant: "useCase",
|
||||
rulePreview: useCaseRulePreview,
|
||||
leadingImageAlt: "Mutual Aid Colorado logo",
|
||||
contentTone: "onLight",
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div
|
||||
className="min-h-screen"
|
||||
style={{ background: "var(--color-content-default-brand-lavender)" }}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export const Guide = {
|
||||
args: {
|
||||
post: guidePost,
|
||||
|
||||
@@ -81,4 +81,21 @@ describe("AskOrganizer (behavioral tests)", () => {
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("use-case-detail variant uses inverse lockup and figma node", () => {
|
||||
const { container } = render(
|
||||
<AskOrganizer
|
||||
title="Still have questions?"
|
||||
subtitle="Get answers from an experienced organizer"
|
||||
buttonText="Ask an Organizer"
|
||||
variant="use-case-detail"
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
container.querySelector('[data-figma-node="22015-42624"]'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Still have questions?" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderWithProviders as render } from "../utils/test-utils";
|
||||
import ContentBanner from "../../app/components/sections/ContentBanner";
|
||||
import type { BlogPost } from "../../lib/content";
|
||||
|
||||
@@ -61,6 +62,29 @@ describe("ContentBanner", () => {
|
||||
expect(screen.getByText("Test description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders useCase variant with ContentContainer copy and rule preview", () => {
|
||||
const { container } = render(
|
||||
<ContentBanner
|
||||
post={mockPost}
|
||||
variant="useCase"
|
||||
rulePreview={{
|
||||
title: "Sample Operating Manual",
|
||||
description: "Governance preview for the case study.",
|
||||
backgroundColor: "bg-[var(--color-surface-invert-brand-lavender)]",
|
||||
iconPath: "assets/case-study/case-study-mutual-aid.svg",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Test Article" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Sample Operating Manual")).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector('[data-figma-node="22015:42621"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders guide variant with left-aligned copy and logo mark", () => {
|
||||
const { container } = render(
|
||||
<ContentBanner post={mockPost} variant="guide" />,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, test, expect, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { renderWithProviders as render } from "../utils/test-utils";
|
||||
import UseCaseDetailPage from "../../app/(marketing)/use-cases/[slug]/page";
|
||||
import messages from "../../messages/en/index";
|
||||
import { USE_CASE_DETAIL_SLUGS } from "../../lib/useCaseSyntheticPost";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
notFound: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../app/components/sections/ContentBanner", () => ({
|
||||
default: ({ post, variant, rulePreview }) => (
|
||||
<section data-testid="content-banner" data-variant={variant}>
|
||||
<h1>{post.frontmatter.title}</h1>
|
||||
<p>{post.frontmatter.description}</p>
|
||||
{rulePreview ? (
|
||||
<>
|
||||
<p>{rulePreview.title}</p>
|
||||
<p>{rulePreview.description}</p>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../app/components/sections/AskOrganizer", () => ({
|
||||
default: ({ title, subtitle, buttonText, variant }) => (
|
||||
<section data-testid="ask-organizer" data-variant={variant}>
|
||||
<h2>{title}</h2>
|
||||
<p>{subtitle}</p>
|
||||
<button type="button">{buttonText}</button>
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("UseCaseDetailPage", () => {
|
||||
test.each(USE_CASE_DETAIL_SLUGS)(
|
||||
"renders banner, body, footer, and ask organizer for %s",
|
||||
async (slug) => {
|
||||
const detail = messages.pages.useCasesDetail;
|
||||
const contentKey =
|
||||
slug === "mutual-aid-colorado"
|
||||
? "mutualAidColorado"
|
||||
: slug === "food-not-bombs"
|
||||
? "foodNotBombs"
|
||||
: "boulderCountyStreetMedics";
|
||||
const entry = detail[contentKey];
|
||||
|
||||
render(
|
||||
await UseCaseDetailPage({
|
||||
params: Promise.resolve({ slug }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("content-banner")).toHaveAttribute(
|
||||
"data-variant",
|
||||
"useCase",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: entry.banner.title }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(entry.ruleCard.description)).toBeInTheDocument();
|
||||
|
||||
const bodySnippet =
|
||||
slug === "mutual-aid-colorado"
|
||||
? /Coordinating a statewide network/
|
||||
: slug === "food-not-bombs"
|
||||
? /Food Not Bombs operates on a fundamentally decentralized model/
|
||||
: /When communities like the BoCo Street Medics operate/;
|
||||
expect(screen.getByText(bodySnippet)).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
document.querySelector('[data-figma-node="22015:42622"]'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("ask-organizer")).toHaveAttribute(
|
||||
"data-variant",
|
||||
"use-case-detail",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /ask an organizer/i }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
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 messages from "../../messages/en/index";
|
||||
|
||||
vi.mock("next/dynamic", () => ({
|
||||
default: () => {
|
||||
const Component = vi.fn(() => (
|
||||
<section data-testid="related-articles">Related articles</section>
|
||||
));
|
||||
return Component;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href, ...props }) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("UseCasesPage", () => {
|
||||
const links = messages.pages.useCases.caseStudyTiles.links;
|
||||
|
||||
test("renders case study tiles as links to detail pages", () => {
|
||||
render(<UseCasesPage />);
|
||||
|
||||
for (const link of links) {
|
||||
const anchor = screen.getByRole("link", { name: link.ariaLabel });
|
||||
expect(anchor).toHaveAttribute("href", link.href);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -130,6 +130,14 @@ describe("Rule Component", () => {
|
||||
expect(card).toHaveClass("w-[568px]");
|
||||
});
|
||||
|
||||
it("fluidWidth expanded cards fill the container", () => {
|
||||
render(<Rule {...defaultProps} expanded={true} size="L" fluidWidth />);
|
||||
|
||||
const card = screen.getByRole("button");
|
||||
expect(card).not.toHaveClass("w-[568px]");
|
||||
expect(card).toHaveClass("w-full");
|
||||
});
|
||||
|
||||
it("applies proper accessibility attributes", () => {
|
||||
render(<Rule {...defaultProps} expanded={true} />);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user