Implement how it works page

This commit is contained in:
adilallo committed 2026-05-17 22:40:06 -06:00
1 parent 450da4d8ab
commit 40ce5064d6
35 files changed
+707 -123

No files matched your search

@@ -0,0 +1,43 @@
/**
* Figma: "A Guide to CommunityRule" body ornaments (22078:791901)
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22078-791901
*
* - 19003:23575 — concentric circles, right (`how-shape-2.svg`)
* - 19003:23576 — loop mark, left (`how-shape-1.svg`)
*/
import {
getAssetPath,
howItWorksOrnamentLeftPath,
howItWorksOrnamentRightPath,
} from "../../../../lib/assetUtils";
export default function HowItWorksDecorativeShapes() {
return (
<>
<div
aria-hidden
className="pointer-events-none absolute z-0 hidden aspect-square md:block left-[84.86%] right-[-9.28%] top-[clamp(200px,20vw,255px)]"
data-node-id="19003:23575"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getAssetPath(howItWorksOrnamentRightPath())}
alt=""
className="pointer-events-none size-full max-w-none object-cover"
/>
</div>
<div
aria-hidden
className="pointer-events-none absolute z-0 hidden aspect-square md:block left-[-1.66%] right-[88.38%] top-[clamp(520px,55vw,811px)]"
data-node-id="19003:23576"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getAssetPath(howItWorksOrnamentLeftPath())}
alt=""
className="pointer-events-none size-full max-w-none object-cover"
/>
</div>
</>
);
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Figma: "How Community Rule works" (22078:806964)
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22078-806964
*/
import type { Metadata } from "next";
import dynamic from "next/dynamic";
import messages from "../../../messages/en/index";
import { getAllBlogPosts } from "../../../lib/content";
import {
buildHowItWorksSyntheticPost,
HOW_IT_WORKS_SENTINEL_SLUG,
} from "../../../lib/howItWorksSyntheticPost";
import ContentBanner from "../../components/sections/ContentBanner";
import HowItWorksDecorativeShapes from "./_components/HowItWorksDecorativeShapes";
import AskOrganizer from "../../components/sections/AskOrganizer";
import "../blog/blog.css";
const RelatedArticles = dynamic(
() => import("../../components/sections/RelatedArticles"),
{
loading: () => (
<section className="py-[var(--spacing-scale-032)] min-h-[400px]" />
),
ssr: true,
},
);
export async function generateMetadata(): Promise<Metadata> {
const meta = messages.metadata.howItWorks;
const page = messages.pages.howItWorks;
return {
title: meta.title,
description: meta.description,
keywords: meta.keywords,
openGraph: {
title: page.banner.title,
description: page.banner.description,
type: "website",
siteName: "CommunityRule",
},
};
}
export default function HowItWorksPage() {
const page = messages.pages.howItWorks;
const syntheticPost = buildHowItWorksSyntheticPost(page);
const allPosts = getAllBlogPosts();
const relatedPosts = allPosts.slice(0, 8);
const slugOrder = allPosts.map((post) => post.slug);
const askOrganizerData = {
title: messages.pages.home.askOrganizer.title,
subtitle: messages.pages.home.askOrganizer.subtitle,
buttonText: messages.pages.home.askOrganizer.buttonText,
};
const structuredData = {
"@context": "https://schema.org",
"@type": "WebPage",
name: page.banner.title,
description: page.banner.description,
url: "https://communityrule.com/how-it-works",
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: page.banner.title,
item: "https://communityrule.com/how-it-works",
},
],
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(structuredData),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(breadcrumbData),
}}
/>
<div className="relative min-h-screen overflow-x-hidden bg-transparent">
<ContentBanner post={syntheticPost} variant="guide" />
<div className="relative w-full">
<HowItWorksDecorativeShapes />
<article className="relative z-10 p-[var(--spacing-scale-024)] sm:py-[var(--spacing-scale-032)]">
<div
className="post-body -mt-[var(--spacing-scale-048)] text-[var(--color-content-default-primary)] text-[16px] leading-[24px] sm:text-[18px] sm:leading-[130%] lg:text-[24px] lg:leading-[32px] xl:text-[32px] xl:leading-[40px] sm:mx-auto sm:max-w-[390px] md:max-w-[472px] lg:max-w-[700px] xl:max-w-[904px]"
dangerouslySetInnerHTML={{ __html: syntheticPost.htmlContent }}
/>
</article>
</div>
<RelatedArticles
relatedPosts={relatedPosts}
currentPostSlug={HOW_IT_WORKS_SENTINEL_SLUG}
slugOrder={slugOrder}
headingSurface="onLight"
heading={page.relatedArticles.title}
/>
<AskOrganizer {...askOrganizerData} />
</div>
</>
);
}
+1
View File
@@ -76,6 +76,7 @@ export default function Page() {
iconColor: "orange",
},
],
seeHowItWorksHref: t("cardSteps.buttons.seeHowItWorksHref"),
};
const featureGridData = {
@@ -6,7 +6,13 @@ import ContentContainerView from "./ContentContainer.view";
import type { ContentContainerProps } from "./ContentContainer.types";
const ContentContainerContainer = memo<ContentContainerProps>(
({ post, width = "200px", size: sizeProp = "responsive" }) => {
({
post,
width = "200px",
size: sizeProp = "responsive",
leadingImageSrc,
leadingImageAlt,
}) => {
const size = sizeProp;
// Get the corresponding icon based on the same logic as background images
const getIconImage = (slug: string): string => {
@@ -30,7 +36,9 @@ const ContentContainerContainer = memo<ContentContainerProps>(
return icons[finalIndex];
};
const iconImage = getIconImage(post.slug);
const iconImage = leadingImageSrc ?? getIconImage(post.slug);
const iconAlt =
leadingImageAlt ?? `Icon for ${post.frontmatter.title}`;
// Format date
const formattedDate = new Date(post.frontmatter.date).toLocaleDateString(
@@ -83,6 +91,7 @@ const ContentContainerContainer = memo<ContentContainerProps>(
width={width}
size={size}
iconImage={iconImage}
iconAlt={iconAlt}
containerClasses={containerClasses}
contentGapClasses={contentGapClasses}
textGapClasses={textGapClasses}
@@ -9,6 +9,10 @@ export interface ContentContainerProps {
* Content container size.
*/
size?: ContentContainerSizeValue;
/** When set, replaces the default slug-based thumbnail icon. */
leadingImageSrc?: string;
/** Alt text for `leadingImageSrc`; defaults to post title. */
leadingImageAlt?: string;
}
export interface ContentContainerViewProps {
@@ -16,6 +20,7 @@ export interface ContentContainerViewProps {
width: string;
size: "xs" | "responsive";
iconImage: string;
iconAlt: string;
containerClasses: string;
contentGapClasses: string;
textGapClasses: string;
@@ -6,6 +6,7 @@ function ContentContainerView({
width,
size,
iconImage,
iconAlt,
containerClasses,
contentGapClasses,
textGapClasses,
@@ -27,7 +28,7 @@ function ContentContainerView({
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={iconImage}
alt={`Icon for ${post.frontmatter.title}`}
alt={iconAlt}
className="w-[60px] h-[30px] object-contain"
/>
</div>
@@ -10,7 +10,7 @@ import type { CardStepsProps } from "./CardSteps.types";
* Composes **`cards/Step`** (Figma Card / Step), not **`progress/Stepper`**.
*/
const CardStepsContainer = memo<CardStepsProps>(
({ title, subtitle, steps, headingDesktopLines }) => {
({ title, subtitle, steps, headingDesktopLines, seeHowItWorksHref }) => {
const schemaData = useSchemaData({
type: "HowTo",
name: title,
@@ -29,6 +29,7 @@ const CardStepsContainer = memo<CardStepsProps>(
subtitle={subtitle}
steps={steps}
headingDesktopLines={headingDesktopLines}
seeHowItWorksHref={seeHowItWorksHref}
schemaJson={schemaJson}
/>
);
@@ -11,6 +11,8 @@ export interface CardStepsProps {
steps: CardStepsItem[];
/** Large-screen heading split: line 1–3 (e.g. How / CommunityRule / helps). */
headingDesktopLines?: readonly [string, string, string];
/** When set, the section CTA renders as a link. */
seeHowItWorksHref?: string;
}
export interface CardStepsViewProps extends CardStepsProps {
@@ -11,6 +11,7 @@ function CardStepsView({
subtitle,
steps,
headingDesktopLines,
seeHowItWorksHref,
schemaJson,
}: CardStepsViewProps) {
const t = useTranslation();
@@ -47,7 +48,12 @@ function CardStepsView({
</div>
<div className="text-center">
<Button buttonType="outline" palette="default" size="large">
<Button
buttonType="outline"
palette="default"
size="large"
href={seeHowItWorksHref}
>
{t("cardSteps.buttons.seeHowItWorks")}
</Button>
</div>
-84
View File
@@ -1,84 +0,0 @@
"use client";
import { memo } from "react";
import { getAssetPath } from "../../../lib/assetUtils";
import ContentContainer from "../content/ContentContainer";
import type { BlogPost } from "../../../lib/content";
interface ContentBannerProps {
post: BlogPost;
}
const ContentBanner = memo<ContentBannerProps>(({ post }) => {
// Get article-specific horizontal thumbnail (small) and banner (md+)
const getBackgroundImage = (post: BlogPost): string => {
if (post.frontmatter?.thumbnail?.horizontal) {
return `/content/blog/${post.frontmatter.thumbnail.horizontal}`;
}
// Fallback to default image
return getAssetPath("assets/Content_Banner.svg");
};
const getBannerImageMd = (post: BlogPost): string => {
// Use banner.horizontal when provided; fallback to horizontal thumbnail
if (post.frontmatter?.banner?.horizontal) {
return `/content/blog/${post.frontmatter.banner.horizontal}`;
}
// Fallback to horizontal thumbnail, then default banner
if (post.frontmatter?.thumbnail?.horizontal) {
return `/content/blog/${post.frontmatter.thumbnail.horizontal}`;
}
return getAssetPath("assets/Content_Banner_2.svg");
};
const backgroundImage = getBackgroundImage(post);
const bannerImageMd = getBannerImageMd(post);
return (
<div className="pt-[var(--measures-spacing-016)] md:pt-[var(--measures-spacing-008)] lg:pt-[50px] xl:pt-[112px] h-[275px] sm:h-[326px] md:h-[224px] lg:h-[358.4px] xl:h-[504px] relative w-full sm:overflow-hidden">
{/* Background SVG - Default to sm breakpoint */}
<div
className="absolute inset-0 w-full h-full bg-cover bg-no-repeat aspect-[320/225.5]"
style={{
backgroundImage: `url(${backgroundImage})`,
backgroundPosition: "center bottom",
}}
/>
{/* Background SVG - md breakpoint and above (article banner image) */}
<div
className="absolute inset-0 w-full h-full bg-cover bg-no-repeat aspect-[640/224] md:block hidden"
style={{
backgroundImage: `url(${bannerImageMd})`,
backgroundPosition: "center bottom",
}}
/>
{/* Content Container */}
<div
className="
relative z-10 h-full
flex flex-col
pl-[var(--measures-spacing-016)] md:pl-[var(--measures-spacing-024)] lg:pl-[var(--measures-spacing-064)]
pr-[96px] md:pr-[350px]
/* default: normal flow, top-aligned */
justify-start
/* only at md: take out of flow and center vertically */
md:absolute md:inset-x-0 md:top-1/2 md:-translate-y-1/2 md:w-full md:h-auto
/* after md (lg+): snap back to normal flow/top align */
lg:static lg:translate-y-0 lg:top-auto lg:h-full lg:justify-start
"
>
{/* ContentContainer with post data */}
<ContentContainer post={post} size="responsive" />
</div>
</div>
);
});
ContentBanner.displayName = "ContentBanner";
export default ContentBanner;
@@ -0,0 +1,56 @@
"use client";
import { memo } from "react";
import { getAssetPath } from "../../../../lib/assetUtils";
import type { BlogPost } from "../../../../lib/content";
import ContentBannerView from "./ContentBanner.view";
import type { ContentBannerProps } from "./ContentBanner.types";
/** Figma: Section / ContentBanner — article (blog) and guide (content page template 22078:791901). */
const ContentBannerContainer = memo<ContentBannerProps>(
({
post,
variant: variantProp = "article",
leadingImageSrc,
leadingImageAlt,
}) => {
const variant = variantProp;
const getBackgroundImage = (blogPost: BlogPost): string => {
if (blogPost.frontmatter?.thumbnail?.horizontal) {
return `/content/blog/${blogPost.frontmatter.thumbnail.horizontal}`;
}
return getAssetPath("assets/Content_Banner.svg");
};
const getBannerImageMd = (blogPost: BlogPost): string => {
if (blogPost.frontmatter?.banner?.horizontal) {
return `/content/blog/${blogPost.frontmatter.banner.horizontal}`;
}
if (blogPost.frontmatter?.thumbnail?.horizontal) {
return `/content/blog/${blogPost.frontmatter.thumbnail.horizontal}`;
}
return getAssetPath("assets/Content_Banner_2.svg");
};
const backgroundImageSm =
variant === "article" ? getBackgroundImage(post) : undefined;
const backgroundImageMd =
variant === "article" ? getBannerImageMd(post) : undefined;
return (
<ContentBannerView
variant={variant}
post={post}
leadingImageSrc={leadingImageSrc}
leadingImageAlt={leadingImageAlt}
backgroundImageSm={backgroundImageSm}
backgroundImageMd={backgroundImageMd}
/>
);
},
);
ContentBannerContainer.displayName = "ContentBanner";
export default ContentBannerContainer;
@@ -0,0 +1,24 @@
import type { BlogPost } from "../../../../lib/content";
export type ContentBannerVariant = "article" | "guide";
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).
*/
variant?: ContentBannerVariant;
/** Article variant only: replaces slug-based thumbnail icon in ContentContainer. */
leadingImageSrc?: string;
leadingImageAlt?: string;
}
export interface ContentBannerViewProps {
variant: ContentBannerVariant;
post: BlogPost;
leadingImageSrc?: string;
leadingImageAlt?: string;
backgroundImageSm?: string;
backgroundImageMd?: string;
}
@@ -0,0 +1,128 @@
"use client";
import { memo } from "react";
import ContentContainer from "../../content/ContentContainer";
import {
getAssetPath,
guideBannerLogoArrowPath,
} from "../../../../lib/assetUtils";
import type { ContentBannerViewProps } from "./ContentBanner.types";
/**
* Figma: ContentBanner on content page template (22078:791901) — left column
* title + description; logo mark (22078:806960) in right column.
*/
function ContentBannerGuideView({
post,
}: Pick<ContentBannerViewProps, "post">) {
const { title, description } = post.frontmatter;
return (
<section
className="relative w-full overflow-clip px-[var(--spacing-scale-020)] py-[var(--spacing-scale-024)] sm:px-[var(--spacing-scale-032)] sm:py-[var(--spacing-scale-032)] lg:px-[var(--spacing-scale-048)] lg:py-[var(--spacing-scale-040)]"
aria-labelledby="content-banner-title"
>
<div
className="mx-auto flex w-full max-w-[1024px] flex-col items-start gap-[var(--spacing-scale-024)] md:flex-row md:items-center md:gap-[var(--spacing-scale-032)]"
data-node-id="19189:9358"
>
<div
className="flex w-full max-w-[365px] shrink-0 flex-col items-start justify-center gap-[var(--spacing-scale-024)]"
data-node-id="19189:9171"
>
<div className="flex w-full flex-col items-start gap-[var(--measures-spacing-016)]">
<div className="flex w-full flex-col items-start gap-[var(--measures-spacing-004)] text-left text-[var(--color-content-default-primary)]">
<h1
id="content-banner-title"
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>
<div
className="flex w-full shrink-0 items-center justify-center md:flex-1"
data-node-id="22078:806960"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getAssetPath(guideBannerLogoArrowPath())}
alt=""
aria-hidden
className="h-[clamp(120px,20vw,171px)] w-[clamp(120px,20vw,172px)] max-w-none shrink-0 object-contain"
/>
</div>
</div>
</section>
);
}
function ContentBannerArticleView({
post,
leadingImageSrc,
leadingImageAlt,
backgroundImageSm,
backgroundImageMd,
}: ContentBannerViewProps) {
if (!backgroundImageSm || !backgroundImageMd) {
return null;
}
return (
<div className="relative h-[275px] w-full pt-[var(--measures-spacing-016)] sm:h-[326px] sm:overflow-hidden md:h-[224px] md:pt-[var(--measures-spacing-008)] lg:h-[358.4px] lg:pt-[50px] xl:h-[504px] xl:pt-[112px]">
<div
className="absolute inset-0 aspect-[320/225.5] h-full w-full bg-cover bg-no-repeat"
style={{
backgroundImage: `url(${backgroundImageSm})`,
backgroundPosition: "center bottom",
}}
/>
<div
className="absolute inset-0 hidden aspect-[640/224] h-full w-full bg-cover bg-no-repeat md:block"
style={{
backgroundImage: `url(${backgroundImageMd})`,
backgroundPosition: "center bottom",
}}
/>
<div
className="
relative z-10 flex h-full flex-col
pl-[var(--measures-spacing-016)] pr-[96px]
justify-start
md:absolute md:inset-x-0 md:top-1/2 md:h-auto md:w-full md:-translate-y-1/2
md:pl-[var(--measures-spacing-024)] md:pr-[350px]
lg:static lg:top-auto lg:h-full lg:translate-y-0 lg:justify-start
lg:pl-[var(--measures-spacing-064)]
"
>
<ContentContainer
post={post}
size="responsive"
leadingImageSrc={leadingImageSrc}
leadingImageAlt={leadingImageAlt}
/>
</div>
</div>
);
}
function ContentBannerView(props: ContentBannerViewProps) {
if (props.variant === "guide") {
return <ContentBannerGuideView post={props.post} />;
}
return <ContentBannerArticleView {...props} />;
}
ContentBannerView.displayName = "ContentBannerView";
export default memo(ContentBannerView);
@@ -0,0 +1,5 @@
export { default } from "./ContentBanner.container";
export type {
ContentBannerProps,
ContentBannerVariant,
} from "./ContentBanner.types";
@@ -12,6 +12,8 @@ const RelatedArticlesContainer = memo<RelatedArticlesProps>(
currentPostSlug,
slugOrder = [],
variant = "default",
headingSurface = "onDark",
heading,
}) => {
const messages = useMessages();
// Memoize filtered posts to prevent unnecessary re-computations
@@ -116,6 +118,8 @@ const RelatedArticlesContainer = memo<RelatedArticlesProps>(
getProgressStyle={getProgressStyle}
onMouseDown={handleMouseDown}
variant={variant}
headingSurface={headingSurface}
heading={heading}
useCasesHeadingLines={useCasesHeadingLines}
/>
);
@@ -2,6 +2,9 @@ import type { BlogPost } from "../../../../lib/content";
export type RelatedArticlesVariant = "default" | "useCases";
/** Heading contrast when the section sits on a dark vs light page background. */
export type RelatedArticlesHeadingSurface = "onDark" | "onLight";
export interface RelatedArticlesProps {
relatedPosts: BlogPost[];
currentPostSlug: string;
@@ -12,6 +15,10 @@ export interface RelatedArticlesProps {
* **`lg`** [**20711-14231**](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=20711-14231&m=dev) (shell + card row gutter / padding).
*/
variant?: RelatedArticlesVariant;
/** Default `onDark` (blog). Use `onLight` on transparent / light marketing pages. */
headingSurface?: RelatedArticlesHeadingSurface;
/** Overrides the default “Related Articles” heading. */
heading?: string;
}
export interface RelatedArticlesViewProps {
@@ -22,6 +29,8 @@ export interface RelatedArticlesViewProps {
getProgressStyle: (_index: number) => React.CSSProperties;
onMouseDown?: (_e: React.MouseEvent<HTMLDivElement>) => void;
variant?: RelatedArticlesVariant;
headingSurface?: RelatedArticlesHeadingSurface;
heading?: string;
/** Stacked title lines (`pages.useCases.relatedArticles.title`) when `variant="useCases"`. */
useCasesHeadingLines?: readonly string[];
}
@@ -9,6 +9,8 @@ export function RelatedArticlesView({
getProgressStyle,
onMouseDown,
variant = "default",
headingSurface = "onDark",
heading = "Related Articles",
useCasesHeadingLines,
}: RelatedArticlesViewProps) {
if (filteredPosts.length === 0) {
@@ -49,8 +51,14 @@ export function RelatedArticlesView({
</span>
</h2>
) : (
<h2 className="text-center text-[32px] font-medium leading-[110%] text-[var(--color-content-inverse-primary)] lg:text-[44px]">
Related Articles
<h2
className={`text-center text-[32px] font-medium leading-[110%] lg:text-[44px] ${
headingSurface === "onLight"
? "text-[var(--color-content-default-primary)]"
: "text-[var(--color-content-inverse-primary)]"
}`}
>
{heading}
</h2>
)}
@@ -10,6 +10,7 @@ const ContentLockupContainer = memo<ContentLockupProps>(
subtitle,
description,
ctaText,
ctaHref,
buttonClassName = "",
variant: variantProp = "hero",
linkText,
@@ -166,6 +167,7 @@ const ContentLockupContainer = memo<ContentLockupProps>(
subtitle={subtitle}
description={description}
ctaText={ctaText}
ctaHref={ctaHref}
buttonClassName={buttonClassName}
variant={variant}
linkText={linkText}
@@ -10,6 +10,7 @@ function ContentLockupView({
subtitle,
description,
ctaText,
ctaHref,
buttonClassName,
variant,
linkText,
@@ -111,6 +112,7 @@ function ContentLockupView({
buttonType="filled"
palette={variant === "hero" ? "default" : "inverse"}
size="small"
href={ctaHref}
>
{ctaText}
</Button>
@@ -122,6 +124,7 @@ function ContentLockupView({
palette={variant === "hero" ? "default" : "inverse"}
size="large"
className={buttonClassName}
href={ctaHref}
>
{ctaText}
</Button>
@@ -132,6 +135,7 @@ function ContentLockupView({
buttonType="filled"
palette={variant === "hero" ? "default" : "inverse"}
size="xlarge"
href={ctaHref}
>
{ctaText}
</Button>
+19
View File
@@ -49,6 +49,25 @@ export function quoteStatementShapePath(): string {
return "assets/shapes/shape-qoute.svg";
}
/**
* How-it-works body ornaments (`public/assets/shapes/how-shape-*.svg`,
* Figma **22078:791901**).
*/
export function howItWorksOrnamentRightPath(): string {
return "assets/shapes/how-shape-2.svg";
}
export function howItWorksOrnamentLeftPath(): string {
return "assets/shapes/how-shape-1.svg";
}
/**
* Guide ContentBanner logo mark (Figma **22078:806960**).
*/
export function guideBannerLogoArrowPath(): string {
return "assets/shapes/guide-banner-logo-arrow.svg";
}
/**
* Asset paths for common components
*/
+31
View File
@@ -0,0 +1,31 @@
import type { BlogPost } from "./content";
import { markdownToHtml } from "./content";
import type howItWorksPage from "../messages/en/pages/howItWorks.json";
export const HOW_IT_WORKS_SENTINEL_SLUG = "__how-it-works__";
type HowItWorksMessages = typeof howItWorksPage;
/**
* Builds a {@link BlogPost}-shaped object for static marketing pages that reuse
* blog article chrome (`ContentBanner`, `.post-body`) without a markdown file.
*/
export function buildHowItWorksSyntheticPost(
page: HowItWorksMessages,
): BlogPost {
const { banner, bodyMarkdown } = page;
return {
slug: HOW_IT_WORKS_SENTINEL_SLUG,
frontmatter: {
title: banner.title,
description: banner.description,
author: banner.author,
date: banner.date,
},
content: bodyMarkdown,
htmlContent: markdownToHtml(bodyMarkdown),
filePath: "messages/en/pages/howItWorks.json",
lastModified: new Date(banner.date),
};
}
+2 -1
View File
@@ -2,6 +2,7 @@
"_comment": "CardSteps section defaults (shared across pages)",
"titleLg": "How CommunityRule helps",
"buttons": {
"seeHowItWorks": "See how it works"
"seeHowItWorks": "See how it works",
"seeHowItWorksHref": "/how-it-works"
}
}
+2
View File
@@ -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 howItWorks from "./pages/howItWorks.json";
import monitor from "./pages/monitor.json";
import login from "./pages/login.json";
import profile from "./pages/profile.json";
@@ -80,6 +81,7 @@ export default {
learn,
about,
useCases,
howItWorks,
monitor,
login,
profile,
+10
View File
@@ -19,5 +19,15 @@
"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.",
"keywords": [
"how it works",
"community governance",
"operating manual",
"decision-making"
]
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
"subtitle": "with clarity",
"description": "Help your community make important decisions in a way that reflects its unique values.",
"ctaText": "Learn how CommunityRule works",
"ctaHref": "#"
"ctaHref": "/how-it-works"
},
"cardSteps": {
"title": "How CommunityRule works",
+12
View File
@@ -0,0 +1,12 @@
{
"banner": {
"title": "A Guide to CommunityRule",
"description": "CommunityRule is a modular governance toolkit designed to help democratic groups build, customize, and publish their own Operating Manual.",
"author": "CommunityRule",
"date": "2026-01-15"
},
"relatedArticles": {
"title": "Related articles"
},
"bodyMarkdown": "Whether you organize a mutual aid network, manage an open-source project, or run a worker-owned cooperative, unwritten rules are a liability. Implicit structures cause volunteer burnout, endless meetings, and shadow hierarchies where the loudest voices quietly hold the most power.\n\nCommunityRule makes the implicit explicit. It is a modular governance toolkit providing a library of proven patterns that you can mix, match, and export to keep your community safe, equitable, and highly functional.\n\n**How the Platform Works: From Chaos to Clarity**\n\nThe CommunityRule experience is a guided drafting process that takes you from a blank page to a fully functioning governance document.\n\nYou begin by selecting an organizational archetype that matches your mission. The tool then recommends a baseline set of governance patterns. For instance, a mutual aid group receives recommendations prioritized for rapid physical safety, while an open-source project sees patterns optimized for asynchronous code review and protecting maintainer bandwidth.\n\nNext, you customize. You are never locked into a template. You can explore the pattern library to swap out tools, adjust how much authority is granted to different roles, and define your specific safety protocols.\n\nFinally, you export your completed Operating Manual. The platform generates a clean, structured document that you can host on your website, embed in your GitHub repository, or print as a welcome packet for new members. Your group now has a single source of truth.\n\n**The Four Pillars of Governance**\n\nTo build a resilient Operating Manual, you configure settings across four load-bearing pillars. When these are clearly defined, your members are freed from procedural debates and can focus entirely on your shared mission.\n\n**Membership Patterns**\n\nThis pillar defines the boundaries of your community: who is allowed inside, how they join, and what is required to maintain standing.\n\nYou select patterns that align onboarding with your culture. A massive online network might choose an \"Open Access\" pattern to maximize growth, while an activist coalition handling sensitive work might implement \"Exclusionary Vouching,\" requiring new entrants to be personally verified by existing members. Just as importantly, this pillar helps you define clear exit paths. By establishing how members can safely take a leave of absence or transition to an alumni role, you protect the health of the individual and prevent vital institutional knowledge from vanishing when someone burns out.\n\n**Decision-Making Patterns**\n\nThis acts as your organizational engine, defining exactly how proposals turn into executed actions without trapping your group in endless deliberation.\n\nThe key is matching the weight of the decision to the right process. Requiring everyone to vote on daily operations paralyzes a group, but allowing one person to unilaterally change core values destroys trust. You solve this by assigning different patterns to different scopes of work. You might use a \"Do-ocracy\" pattern for administrative tasks, empowering whoever is actually doing the work to make the call without asking permission. For strategic choices, you might implement a \"Supermajority\" pattern, ensuring significant changes have overwhelming buy-in before enactment. This distribution of authority lets your group move fast on the small things and move together on the big things.\n\n**Conflict Management**\n\nConflict is inevitable; toxicity and organizational collapse are preventable. This pillar helps you design repair pathways before a dispute escalates into a crisis.\n\nHealthy communities rely on repair and accountability rather than corporate punishment. You select conflict strategies based on how much external authority is required to solve a problem. For interpersonal friction, \"Restorative Practices\" or \"Peer Mediation\" provide a structured environment and a neutral facilitator, but leave the final resolution to the disputing parties so they co-create their own repair plan. For severe Code of Conduct violations, a \"Conflict Resolution Council\" introduces trusted elders who investigate and issue formal recommendations. Having these patterns pre-selected removes panic and bias from the resolution process.\n\n**Communication Methods**\n\nWhere you talk dictates how you govern. This pillar helps you map your digital and physical infrastructure to ensure you use the right tool for the right conversation.\n\nMixing social banter, secure logistics, and formal voting in one chaotic group chat is a recipe for disaster. You will define the logistics and behavioral norms for each platform your group uses. You might designate Signal strictly for tactical coordination where disappearing messages are mandatory, use Discord for casual onboarding, and reserve a forum like Discourse exclusively for deep, asynchronous policy deliberation.\n\nCrucially, you attach a specific Code oLine truncated
}
@@ -0,0 +1,3 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 172 171" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M172 85.5L0 0L43.043 85.5L0 171L172 85.5ZM86.0861 67.7374C89.5979 67.7585 93.025 68.811 95.9358 70.7624C98.8467 72.7139 101.111 75.4769 102.444 78.7039C103.777 81.9308 104.12 85.4773 103.428 88.8969C102.736 92.3165 101.041 95.4564 98.556 97.9213C96.0712 100.386 92.9078 102.066 89.4639 102.749C86.02 103.432 82.4496 103.088 79.2022 101.76C75.9547 100.432 73.1754 98.1797 71.2141 95.2863C69.2528 92.3929 68.1972 88.988 68.1802 85.5C68.1688 83.1624 68.6243 80.8458 69.5203 78.6842C70.4164 76.5226 71.7353 74.559 73.4005 72.907C75.0658 71.2551 77.0445 69.9476 79.2219 69.0602C81.3994 68.1728 83.7325 67.7232 86.0861 67.7374Z" fill="var(--fill-0, #FEFCC9)"/>
</svg>

After

Width:  |  Height:  |  Size: 855 B

+9
View File
@@ -0,0 +1,9 @@
<svg width="119" height="136" viewBox="0 0 119 136" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<rect x="-17" width="136" height="136" fill="url(#pattern0_22895_32040)"/>
<defs>
<pattern id="pattern0_22895_32040" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_22895_32040" transform="scale(0.00132979)"/>
</pattern>
<image id="image0_22895_32040" width="752" height="752" preserveAspectRatio="none" xlink:href="data:image/png;base64,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 truncated
</defs>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg width="155" height="250" viewBox="0 0 155 250" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<rect width="250" height="250" fill="url(#pattern0_22895_32039)"/>
<defs>
<pattern id="pattern0_22895_32039" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_22895_32039" transform="scale(0.000984252)"/>
</pattern>
<image id="image0_22895_32039" width="1016" height="1016" preserveAspectRatio="none" xlink:href="data:image/png;base64,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 truncated
</defs>
</svg>

After

Width:  |  Height:  |  Size: 69 KiB

+38 -2
View File
@@ -1,3 +1,4 @@
import React from "react";
import ContentBanner from "../../app/components/sections/ContentBanner";
const mockBlogPost = {
@@ -20,6 +21,21 @@ const mockBlogPost = {
"<p>This is the main content of the sample article.</p><p>It has multiple paragraphs.</p>",
};
const guidePost = {
slug: "__how-it-works__",
frontmatter: {
title: "A Guide to CommunityRule",
description:
"CommunityRule is a modular governance toolkit designed to help democratic groups build, customize, and publish their own Operating Manual.",
author: "CommunityRule",
date: "2026-01-15",
},
content: "",
htmlContent: "",
filePath: "messages/en/pages/howItWorks.json",
lastModified: new Date("2026-01-15"),
};
export default {
title: "Components/Sections/ContentBanner",
component: ContentBanner,
@@ -27,24 +43,44 @@ export default {
docs: {
description: {
component:
"The ContentBanner component displays the header information for blog articles, including title, description, author, and date.\n\nImages: sm uses thumbnail.horizontal; md+ uses banner.horizontal when provided, otherwise falls back to thumbnail.horizontal; final fallback is assets/Content_Banner_2.svg.\n\nNote: page background colors are applied at the blog page level using a hex color from frontmatter (background.color), not inside this component. Thumbnail and banner images should be uploaded via the content pipeline to public/content/blog/ and referenced in frontmatter.",
"Section / ContentBanner — `article` variant for blog posts (thumbnail/banner imagery, icon, author, date); `guide` variant for static content pages (left: title + description, right: logo mark — Figma 22078:791901 + 22078:806960).",
},
},
layout: "fullscreen",
},
argTypes: {
post: {
control: "object",
description: "Blog post object with frontmatter and content",
},
variant: {
control: "select",
options: ["article", "guide"],
},
},
};
export const Default = {
export const Article = {
args: {
post: mockBlogPost,
variant: "article",
},
};
export const Guide = {
args: {
post: guidePost,
variant: "guide",
},
decorators: [
(Story) => (
<div className="bg-[var(--color-surface-default-primary)]">
<Story />
</div>
),
],
};
export const NoBannerFallbackToThumbnail = {
args: {
post: {
+22
View File
@@ -60,4 +60,26 @@ describe("ContentBanner", () => {
render(<ContentBanner post={mockPost} />);
expect(screen.getByText("Test description")).toBeInTheDocument();
});
it("renders guide variant with left-aligned copy and logo mark", () => {
const { container } = render(
<ContentBanner post={mockPost} variant="guide" />,
);
expect(
screen.getByRole("heading", { name: "Test Article" }),
).toBeInTheDocument();
expect(screen.getByText("Test description")).toBeInTheDocument();
expect(screen.queryByText("Test Author")).not.toBeInTheDocument();
const bannerRow = container.querySelector('[data-node-id="19189:9358"]');
expect(bannerRow).toHaveClass("md:flex-row");
const logoMark = container.querySelector(
'[data-node-id="22078:806960"] img',
);
expect(logoMark).toHaveAttribute(
"src",
expect.stringContaining("guide-banner-logo-arrow.svg"),
);
});
});
+6 -4
View File
@@ -15,11 +15,13 @@ test.describe("Critical User Journeys", () => {
).toBeVisible();
// 3. User clicks CTA to learn more
const learnButton = page
.locator('button:has-text("Learn how CommunityRule works")')
const learnCta = page
.getByRole("link", { name: /Learn how CommunityRule works/i })
.or(page.getByRole("button", { name: /Learn how CommunityRule works/i }))
.first();
if ((await learnButton.count()) > 0 && (await learnButton.isVisible())) {
await learnButton.click();
if ((await learnCta.count()) > 0 && (await learnCta.isVisible())) {
await learnCta.click();
await expect(page).toHaveURL(/\/how-it-works/);
}
// 4. User scrolls to CardSteps section (home)
+26 -24
View File
@@ -61,27 +61,28 @@ test.describe("Edge Cases and Error Scenarios", () => {
// Page should continue to function
await expect(page.locator("text=Collaborate")).toBeVisible();
const learnButtons = page.locator(
'button:has-text("Learn how CommunityRule works")',
);
const buttonCount = await learnButtons.count();
let visibleButton = null;
const learnCta = page
.getByRole("link", { name: /Learn how CommunityRule works/i })
.or(page.getByRole("button", { name: /Learn how CommunityRule works/i }));
const ctaCount = await learnCta.count();
let visibleCta = null;
for (let i = 0; i < buttonCount; i++) {
const button = learnButtons.nth(i);
if (await button.isVisible()) {
visibleButton = button;
for (let i = 0; i < ctaCount; i++) {
const cta = learnCta.nth(i);
if (await cta.isVisible()) {
visibleCta = cta;
break;
}
}
if (!visibleButton) {
if (!visibleCta) {
throw new Error(
'No visible "Learn how CommunityRule works" button found',
'No visible "Learn how CommunityRule works" CTA found',
);
}
await visibleButton.click();
await visibleCta.click();
await expect(page).toHaveURL(/\/how-it-works/);
});
test("handles missing images gracefully", async ({ page }) => {
@@ -97,26 +98,27 @@ test.describe("Edge Cases and Error Scenarios", () => {
// Page should still function without images
await expect(page.locator("text=Collaborate")).toBeVisible();
const learnButtons = page.locator(
'button:has-text("Learn how CommunityRule works")',
);
const buttonCount = await learnButtons.count();
let visibleButton = null;
const learnCta = page
.getByRole("link", { name: /Learn how CommunityRule works/i })
.or(page.getByRole("button", { name: /Learn how CommunityRule works/i }));
const ctaCount = await learnCta.count();
let visibleCta = null;
for (let i = 0; i < buttonCount; i++) {
const button = learnButtons.nth(i);
if (await button.isVisible()) {
visibleButton = button;
for (let i = 0; i < ctaCount; i++) {
const cta = learnCta.nth(i);
if (await cta.isVisible()) {
visibleCta = cta;
break;
}
}
if (!visibleButton) {
if (!visibleCta) {
throw new Error(
'No visible "Learn how CommunityRule works" button found',
'No visible "Learn how CommunityRule works" CTA found',
);
}
await visibleButton.click();
await visibleCta.click();
await expect(page).toHaveURL(/\/how-it-works/);
});
});
+4
View File
@@ -68,6 +68,10 @@ describe("Page", () => {
expect(
screen.getAllByText("Learn how CommunityRule works").length,
).toBeGreaterThan(0);
const learnLinks = screen.getAllByRole("link", {
name: "Learn how CommunityRule works",
});
expect(learnLinks[0]).toHaveAttribute("href", "/how-it-works");
});
test("renders CardSteps section with correct data", async () => {
+66
View File
@@ -0,0 +1,66 @@
import { describe, test, expect, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { renderWithProviders as render } from "../utils/test-utils";
import HowItWorksPage from "../../app/(marketing)/how-it-works/page";
import messages from "../../messages/en/index";
vi.mock("next/dynamic", () => ({
default: (importFn) => {
const Component = vi.fn(() => (
<section data-testid="related-articles">Related articles</section>
));
return Component;
},
}));
vi.mock("../../app/components/sections/ContentBanner", () => ({
default: ({ post, variant }) => (
<section data-testid="content-banner" data-variant={variant}>
<h1>{post.frontmatter.title}</h1>
<p>{post.frontmatter.description}</p>
</section>
),
}));
vi.mock("../../app/components/sections/AskOrganizer", () => ({
default: ({ title, subtitle, buttonText }) => (
<section data-testid="ask-organizer">
<h2>{title}</h2>
<p>{subtitle}</p>
<button type="button">{buttonText}</button>
</section>
),
}));
describe("HowItWorksPage", () => {
const page = messages.pages.howItWorks;
test("renders banner, body sections, related articles, and ask organizer", async () => {
render(<HowItWorksPage />);
expect(screen.getByTestId("content-banner")).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: page.banner.title }),
).toBeInTheDocument();
expect(screen.getByTestId("content-banner")).toHaveAttribute(
"data-variant",
"guide",
);
expect(screen.getByText(page.banner.description)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId("related-articles")).toBeInTheDocument();
});
expect(screen.getByTestId("ask-organizer")).toBeInTheDocument();
expect(
screen.getByText(messages.pages.home.askOrganizer.title),
).toBeInTheDocument();
expect(
screen.getByText(/How the Platform Works: From Chaos to Clarity/),
).toBeInTheDocument();
expect(
screen.getByText(/mutual aid network, manage an open-source project/),
).toBeInTheDocument();
});
});