Implement how it works page

This commit is contained in:
adilallo
2026-05-17 22:40:06 -06:00
parent 450da4d8ab
commit 40ce5064d6
35 changed files with 707 additions and 123 deletions
@@ -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 13 (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",
File diff suppressed because one or more lines are too long
@@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

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();
});
});