From b6b9b6360885028560705302cab68418772cbdbe Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Wed, 13 May 2026 23:08:36 -0600 Subject: [PATCH 1/8] Implement about page --- app/(marketing)/about/page.tsx | 73 ++++++++ .../asset/Shapes/Shapes.container.tsx | 16 ++ app/components/asset/Shapes/Shapes.types.ts | 6 + app/components/asset/Shapes/Shapes.view.tsx | 34 ++++ app/components/asset/Shapes/index.tsx | 2 + app/components/cards/Stat/Stat.container.tsx | 15 ++ app/components/cards/Stat/Stat.types.ts | 13 ++ app/components/cards/Stat/Stat.view.tsx | 46 +++++ app/components/cards/Stat/index.tsx | 2 + .../layout/Accordion/Accordion.container.tsx | 50 ++++++ .../layout/Accordion/Accordion.types.ts | 34 ++++ .../layout/Accordion/Accordion.view.tsx | 164 ++++++++++++++++++ app/components/layout/Accordion/index.tsx | 2 + .../AskOrganizerInquiryModal.view.tsx | 1 + app/components/navigation/Footer.tsx | 2 +- .../navigation/Top/Top.container.tsx | 2 +- .../Accordion/Accordion.container.tsx | 32 ++++ .../sections/Accordion/Accordion.types.ts | 25 +++ .../sections/Accordion/Accordion.view.tsx | 53 ++++++ app/components/sections/Accordion/index.tsx | 2 + .../AskOrganizer/AskOrganizer.container.tsx | 2 +- .../AskOrganizer/AskOrganizer.view.tsx | 2 +- .../sections/Book/Book.container.tsx | 18 ++ app/components/sections/Book/Book.types.ts | 13 ++ app/components/sections/Book/Book.view.tsx | 65 +++++++ app/components/sections/Book/index.tsx | 2 + .../QuoteBlock/QuoteBlock.container.tsx | 39 ++++- .../sections/QuoteBlock/QuoteBlock.types.ts | 19 +- .../sections/QuoteBlock/QuoteBlock.view.tsx | 33 ++++ .../QuoteBlock/QuoteStatementDecor.tsx | 41 +++++ app/components/sections/QuoteBlock/index.tsx | 5 +- .../sections/Stats/Stats.container.tsx | 18 ++ app/components/sections/Stats/Stats.types.ts | 20 +++ app/components/sections/Stats/Stats.view.tsx | 103 +++++++++++ app/components/sections/Stats/index.tsx | 2 + .../AboutHeader/AboutHeader.container.tsx | 18 ++ .../type/AboutHeader/AboutHeader.types.ts | 12 ++ .../type/AboutHeader/AboutHeader.view.tsx | 67 +++++++ app/components/type/AboutHeader/index.tsx | 2 + .../ContentLockup/ContentLockup.container.tsx | 44 ++++- .../type/ContentLockup/ContentLockup.types.ts | 9 + .../type/ContentLockup/ContentLockup.view.tsx | 10 +- .../TripleTextBlock.container.tsx | 18 ++ .../TripleTextBlock/TripleTextBlock.types.ts | 23 +++ .../TripleTextBlock/TripleTextBlock.view.tsx | 114 ++++++++++++ app/components/type/TripleTextBlock/index.tsx | 5 + docs/figma-component-registry.md | 19 +- lib/assetUtils.ts | 18 ++ lib/propNormalization.ts | 1 + messages/en/index.ts | 2 + messages/en/pages/about.json | 108 ++++++++++++ public/assets/communityrules-cover.svg | 21 +++ public/assets/shapes/shape-qoute.svg | 11 ++ public/assets/shapes/stat-shape-1.svg | 15 ++ public/assets/shapes/stat-shape-2.svg | 19 ++ public/assets/shapes/stat-shape-3.svg | 15 ++ public/assets/shapes/stat-shape-4.svg | 19 ++ public/assets/vector/about.svg | 15 ++ stories/layout/Accordion.stories.tsx | 33 ++++ stories/sections/QuoteBlock.stories.js | 28 ++- stories/sections/Stats.stories.tsx | 37 ++++ stories/type/AboutHeader.stories.tsx | 37 ++++ tests/components/cards/Stat.test.tsx | 19 ++ tests/components/layout/Accordion.test.tsx | 22 +++ tests/components/sections/Stats.test.tsx | 28 +++ tests/components/type/AboutHeader.test.tsx | 20 +++ .../components/type/TripleTextBlock.test.tsx | 51 ++++++ tests/unit/QuoteBlock.test.jsx | 37 ++++ tests/unit/organizerInquiryPostRoute.test.ts | 9 +- 69 files changed, 1834 insertions(+), 28 deletions(-) create mode 100644 app/(marketing)/about/page.tsx create mode 100644 app/components/asset/Shapes/Shapes.container.tsx create mode 100644 app/components/asset/Shapes/Shapes.types.ts create mode 100644 app/components/asset/Shapes/Shapes.view.tsx create mode 100644 app/components/asset/Shapes/index.tsx create mode 100644 app/components/cards/Stat/Stat.container.tsx create mode 100644 app/components/cards/Stat/Stat.types.ts create mode 100644 app/components/cards/Stat/Stat.view.tsx create mode 100644 app/components/cards/Stat/index.tsx create mode 100644 app/components/layout/Accordion/Accordion.container.tsx create mode 100644 app/components/layout/Accordion/Accordion.types.ts create mode 100644 app/components/layout/Accordion/Accordion.view.tsx create mode 100644 app/components/layout/Accordion/index.tsx create mode 100644 app/components/sections/Accordion/Accordion.container.tsx create mode 100644 app/components/sections/Accordion/Accordion.types.ts create mode 100644 app/components/sections/Accordion/Accordion.view.tsx create mode 100644 app/components/sections/Accordion/index.tsx create mode 100644 app/components/sections/Book/Book.container.tsx create mode 100644 app/components/sections/Book/Book.types.ts create mode 100644 app/components/sections/Book/Book.view.tsx create mode 100644 app/components/sections/Book/index.tsx create mode 100644 app/components/sections/QuoteBlock/QuoteStatementDecor.tsx create mode 100644 app/components/sections/Stats/Stats.container.tsx create mode 100644 app/components/sections/Stats/Stats.types.ts create mode 100644 app/components/sections/Stats/Stats.view.tsx create mode 100644 app/components/sections/Stats/index.tsx create mode 100644 app/components/type/AboutHeader/AboutHeader.container.tsx create mode 100644 app/components/type/AboutHeader/AboutHeader.types.ts create mode 100644 app/components/type/AboutHeader/AboutHeader.view.tsx create mode 100644 app/components/type/AboutHeader/index.tsx create mode 100644 app/components/type/TripleTextBlock/TripleTextBlock.container.tsx create mode 100644 app/components/type/TripleTextBlock/TripleTextBlock.types.ts create mode 100644 app/components/type/TripleTextBlock/TripleTextBlock.view.tsx create mode 100644 app/components/type/TripleTextBlock/index.tsx create mode 100644 messages/en/pages/about.json create mode 100644 public/assets/communityrules-cover.svg create mode 100644 public/assets/shapes/shape-qoute.svg create mode 100644 public/assets/shapes/stat-shape-1.svg create mode 100644 public/assets/shapes/stat-shape-2.svg create mode 100644 public/assets/shapes/stat-shape-3.svg create mode 100644 public/assets/shapes/stat-shape-4.svg create mode 100644 public/assets/vector/about.svg create mode 100644 stories/layout/Accordion.stories.tsx create mode 100644 stories/sections/Stats.stories.tsx create mode 100644 stories/type/AboutHeader.stories.tsx create mode 100644 tests/components/cards/Stat.test.tsx create mode 100644 tests/components/layout/Accordion.test.tsx create mode 100644 tests/components/sections/Stats.test.tsx create mode 100644 tests/components/type/AboutHeader.test.tsx create mode 100644 tests/components/type/TripleTextBlock.test.tsx diff --git a/app/(marketing)/about/page.tsx b/app/(marketing)/about/page.tsx new file mode 100644 index 0000000..57ab556 --- /dev/null +++ b/app/(marketing)/about/page.tsx @@ -0,0 +1,73 @@ +import messages from "../../../messages/en/index"; +import { getTranslation } from "../../../lib/i18n/getTranslation"; +import AboutHeader from "../../components/type/AboutHeader"; +import type { AboutHeaderSegment } from "../../components/type/AboutHeader"; +import Stats from "../../components/sections/Stats"; +import type { StatItem } from "../../components/sections/Stats"; +import TripleTextBlock from "../../components/type/TripleTextBlock"; +import type { TripleTextBlockColumn } from "../../components/type/TripleTextBlock"; +import Book from "../../components/sections/Book"; +import FaqAccordion from "../../components/sections/Accordion"; +import type { FaqAccordionItem } from "../../components/sections/Accordion"; +import QuoteBlock from "../../components/sections/QuoteBlock"; +import AskOrganizer from "../../components/sections/AskOrganizer"; + +function asArray(value: unknown): T[] { + return Array.isArray(value) ? value : []; +} + +export default function AboutPage() { + const t = (key: string) => getTranslation(messages, key); + + const page = messages.pages.about; + + const headerSegments = asArray(page.aboutHeader.segments); + const statsItems = asArray(page.stats.items); + + const statsAsOf = + typeof page.stats.asOf === "string" + ? page.stats.asOf + : String(page.stats.asOf ?? ""); + + const faqItems = asArray(page.faq.items); + const tripleColumns = asArray(page.tripleTextBlock.columns); + + const askOrganizerData = { + title: t("pages.home.askOrganizer.title"), + subtitle: t("pages.home.askOrganizer.subtitle"), + buttonText: t("pages.home.askOrganizer.buttonText"), + }; + + return ( +
+ + ({ + ...item, + asOf: statsAsOf, + }))} + /> + + + + +
+ +
+
+ ); +} diff --git a/app/components/asset/Shapes/Shapes.container.tsx b/app/components/asset/Shapes/Shapes.container.tsx new file mode 100644 index 0000000..92d2e48 --- /dev/null +++ b/app/components/asset/Shapes/Shapes.container.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { memo } from "react"; +import ShapesView from "./Shapes.view"; +import type { ShapesProps } from "./Shapes.types"; + +/** + * Figma: "Shapes" (22851-36508) — **Card / Stat** decorative shapes (`assets/shapes/stat-shape-*.svg`). + */ +const ShapesContainer = memo((props) => { + return ; +}); + +ShapesContainer.displayName = "Shapes"; + +export default ShapesContainer; diff --git a/app/components/asset/Shapes/Shapes.types.ts b/app/components/asset/Shapes/Shapes.types.ts new file mode 100644 index 0000000..4763037 --- /dev/null +++ b/app/components/asset/Shapes/Shapes.types.ts @@ -0,0 +1,6 @@ +export type StatShapeVariant = "yellow" | "purple" | "green" | "orange"; + +export interface ShapesProps { + variant?: StatShapeVariant; + className?: string; +} diff --git a/app/components/asset/Shapes/Shapes.view.tsx b/app/components/asset/Shapes/Shapes.view.tsx new file mode 100644 index 0000000..49c8819 --- /dev/null +++ b/app/components/asset/Shapes/Shapes.view.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { memo } from "react"; +import { getAssetPath, statShapeAssetPath } from "../../../../lib/assetUtils"; +import type { ShapesProps, StatShapeVariant } from "./Shapes.types"; + +/** Figma **Card / Stat** color variants → `stat-shape-{1..4}.svg`. */ +const SHAPE_INDEX_BY_VARIANT: Record = { + yellow: 1, + purple: 2, + green: 3, + orange: 4, +}; + +/** + * Figma: "Shapes" (22851-36508) — decorative stat card art (SVG under `assets/shapes/`). + */ +function ShapesView({ variant = "yellow", className = "" }: ShapesProps) { + const src = getAssetPath(statShapeAssetPath(SHAPE_INDEX_BY_VARIANT[variant])); + + return ( + /* eslint-disable-next-line @next/next/no-img-element -- dynamic path from getAssetPath */ + + ); +} + +ShapesView.displayName = "ShapesView"; + +export default memo(ShapesView); diff --git a/app/components/asset/Shapes/index.tsx b/app/components/asset/Shapes/index.tsx new file mode 100644 index 0000000..8aaa4ff --- /dev/null +++ b/app/components/asset/Shapes/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./Shapes.container"; +export type { ShapesProps, StatShapeVariant } from "./Shapes.types"; diff --git a/app/components/cards/Stat/Stat.container.tsx b/app/components/cards/Stat/Stat.container.tsx new file mode 100644 index 0000000..67e5dda --- /dev/null +++ b/app/components/cards/Stat/Stat.container.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { memo } from "react"; +import StatView from "./Stat.view"; +import type { StatProps } from "./Stat.types"; + +const StatContainer = memo( + ({ shapeVariant: shapeVariantProp = "yellow", ...props }) => { + return ; + }, +); + +StatContainer.displayName = "Stat"; + +export default StatContainer; diff --git a/app/components/cards/Stat/Stat.types.ts b/app/components/cards/Stat/Stat.types.ts new file mode 100644 index 0000000..8454cca --- /dev/null +++ b/app/components/cards/Stat/Stat.types.ts @@ -0,0 +1,13 @@ +import type { StatShapeVariant } from "../../asset/Shapes"; + +export interface StatProps { + value: string; + label: string; + asOf?: string; + shapeVariant?: StatShapeVariant; + className?: string; +} + +export interface StatViewProps extends StatProps { + shapeVariant: StatShapeVariant; +} diff --git a/app/components/cards/Stat/Stat.view.tsx b/app/components/cards/Stat/Stat.view.tsx new file mode 100644 index 0000000..9de2830 --- /dev/null +++ b/app/components/cards/Stat/Stat.view.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { memo } from "react"; +import Shapes from "../../asset/Shapes"; +import type { StatViewProps } from "./Stat.types"; + +/** + * Figma: "Card / Stat" (21598-18215). Full width of grid column at desktop. + */ +function StatView({ + value, + label, + asOf, + shapeVariant, + className = "", +}: StatViewProps) { + return ( +
+
+
+ +

+ {value} +

+
+

+ {label} +

+
+ {asOf ? ( +

+ {asOf} +

+ ) : null} +
+ ); +} + +StatView.displayName = "StatView"; + +export default memo(StatView); diff --git a/app/components/cards/Stat/index.tsx b/app/components/cards/Stat/index.tsx new file mode 100644 index 0000000..d1693cc --- /dev/null +++ b/app/components/cards/Stat/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./Stat.container"; +export type { StatProps } from "./Stat.types"; diff --git a/app/components/layout/Accordion/Accordion.container.tsx b/app/components/layout/Accordion/Accordion.container.tsx new file mode 100644 index 0000000..d67bdfc --- /dev/null +++ b/app/components/layout/Accordion/Accordion.container.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { memo, useCallback, useId, useState } from "react"; +import AccordionView from "./Accordion.view"; +import type { AccordionProps, AccordionSizeValue } from "./Accordion.types"; + +/** + * Figma: "Layout / Accordion" (21842-2813); Medium 22135-890258; optional `lgSize` / `xlSize` stacking (FAQ **s**→**m** `lg`; **l** `xl`, 22135-890328). + */ +const AccordionContainer = memo( + ({ + title, + subhead, + children, + size: sizeProp = "l", + lgSize, + xlSize, + defaultOpen = false, + className = "", + }) => { + const size: AccordionSizeValue = sizeProp; + const [isOpen, setIsOpen] = useState(defaultOpen); + const panelId = useId(); + const buttonId = useId(); + + const onToggle = useCallback(() => { + setIsOpen((open) => !open); + }, []); + + return ( + + ); + }, +); + +AccordionContainer.displayName = "Accordion"; + +export default AccordionContainer; diff --git a/app/components/layout/Accordion/Accordion.types.ts b/app/components/layout/Accordion/Accordion.types.ts new file mode 100644 index 0000000..e0169ad --- /dev/null +++ b/app/components/layout/Accordion/Accordion.types.ts @@ -0,0 +1,34 @@ +import type { ReactNode } from "react"; + +export type AccordionSizeValue = "s" | "m" | "l"; + +export interface AccordionProps { + title: string; + subhead?: string; + children?: ReactNode; + size?: AccordionSizeValue; + /** + * From `lg` up, use this size’s header / type / panel styles (e.g. FAQ: `s` + `lgSize="m"`). + */ + lgSize?: AccordionSizeValue; + /** + * From `xl` up, override with this size (e.g. FAQ: `xlSize="l"` at wide desktop — Figma **22135:890328**). + */ + xlSize?: AccordionSizeValue; + defaultOpen?: boolean; + className?: string; +} + +export interface AccordionViewProps { + title: string; + subhead?: string; + children?: ReactNode; + size: AccordionSizeValue; + lgSize?: AccordionSizeValue; + xlSize?: AccordionSizeValue; + isOpen: boolean; + panelId: string; + buttonId: string; + onToggle: () => void; + className: string; +} diff --git a/app/components/layout/Accordion/Accordion.view.tsx b/app/components/layout/Accordion/Accordion.view.tsx new file mode 100644 index 0000000..31622f8 --- /dev/null +++ b/app/components/layout/Accordion/Accordion.view.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { memo } from "react"; +import Icon from "../../asset/icon/Icon"; +import Divider from "../../utility/Divider"; +import type { AccordionSizeValue, AccordionViewProps } from "./Accordion.types"; + +const SIZE_CLASSES: Record< + AccordionSizeValue, + { header: string; title: string; subhead: string } +> = { + s: { + header: + "gap-[var(--spacing-scale-016)] px-[var(--spacing-scale-016)] py-[var(--spacing-scale-020)] items-center", + title: "text-[14px] font-medium leading-[18px]", + subhead: "text-[12px] leading-[14px]", + }, + /** Figma: Layout / Accordion — Medium (22135-890258; header gap/px/py + Large/Label 18/24). */ + m: { + header: + "gap-[var(--spacing-scale-024)] px-[var(--spacing-scale-016)] py-[var(--spacing-scale-024)] items-center", + title: "text-[18px] font-medium leading-6", + subhead: "text-[14px] leading-[18px]", + }, + l: { + header: + "gap-[var(--spacing-scale-048)] px-[var(--spacing-scale-016)] py-[var(--spacing-scale-032)] items-center", + /** Figma Large: X Large Label 24 Regular, lh 28 (21842-2869). */ + title: "text-[24px] font-normal leading-7", + subhead: "text-[18px] leading-6", + }, +}; + +const PANEL_CLASSES: Record = { + s: "px-[var(--spacing-scale-016)] pb-[var(--spacing-scale-020)] font-inter text-[14px] font-normal leading-5 text-[var(--color-content-default-secondary,#d2d2d2)]", + m: "px-[var(--spacing-scale-016)] pb-[var(--spacing-scale-024)] font-inter text-[16px] font-normal leading-6 text-[var(--color-content-default-secondary,#d2d2d2)]", + l: "px-[var(--spacing-scale-016)] pb-[var(--spacing-scale-032)] font-inter text-[18px] font-normal leading-[26px] text-[var(--color-content-default-secondary,#d2d2d2)]", +}; + +function withLgClasses(base: string, lg: string): string { + const prefixed = lg + .trim() + .split(/\s+/) + .filter(Boolean) + .map((c) => `lg:${c}`) + .join(" "); + return `${base} ${prefixed}`.trim(); +} + +function withXlClasses(base: string, xl: string): string { + const prefixed = xl + .trim() + .split(/\s+/) + .filter(Boolean) + .map((c) => `xl:${c}`) + .join(" "); + return `${base} ${prefixed}`.trim(); +} + +function resolvedLayoutClasses( + size: AccordionSizeValue, + lgSize: AccordionSizeValue | undefined, + xlSize: AccordionSizeValue | undefined, +): { header: string; title: string; subhead: string; panel: string } { + const sm = SIZE_CLASSES[size]; + let header = sm.header; + let title = sm.title; + let subhead = sm.subhead; + let panel = PANEL_CLASSES[size]; + + if (lgSize && lgSize !== size) { + const lg = SIZE_CLASSES[lgSize]; + header = withLgClasses(header, lg.header); + title = withLgClasses(title, lg.title); + subhead = withLgClasses(subhead, lg.subhead); + panel = withLgClasses(panel, PANEL_CLASSES[lgSize]); + } + + if ( + xlSize === undefined || + xlSize === size || + xlSize === lgSize + ) { + return { header, title, subhead, panel }; + } + + const xls = SIZE_CLASSES[xlSize]; + header = withXlClasses(header, xls.header); + title = withXlClasses(title, xls.title); + subhead = withXlClasses(subhead, xls.subhead); + panel = withXlClasses(panel, PANEL_CLASSES[xlSize]); + + return { header, title, subhead, panel }; +} + +/** + * Figma: "Layout / Accordion" (21842-2813); Medium 22135-890258; FAQ **s**→**m** `lg`, **l** `xl` (22135-890328) via `lgSize` / `xlSize`. + */ +function AccordionView({ + title, + subhead, + children, + size, + lgSize, + xlSize, + isOpen, + panelId, + buttonId, + onToggle, + className, +}: AccordionViewProps) { + const sizeClass = resolvedLayoutClasses(size, lgSize, xlSize); + + return ( +
+

+ +

+ {isOpen && children ? ( +
+ {children} +
+ ) : null} + +
+ ); +} + +AccordionView.displayName = "AccordionView"; + +export default memo(AccordionView); diff --git a/app/components/layout/Accordion/index.tsx b/app/components/layout/Accordion/index.tsx new file mode 100644 index 0000000..cb1a20a --- /dev/null +++ b/app/components/layout/Accordion/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./Accordion.container"; +export type { AccordionProps, AccordionSizeValue } from "./Accordion.types"; diff --git a/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.view.tsx b/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.view.tsx index 95c2d27..906c3c3 100644 --- a/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.view.tsx +++ b/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.view.tsx @@ -81,6 +81,7 @@ export function AskOrganizerInquiryModalView({ { {t("navigation.learn")} {t("navigation.about")} diff --git a/app/components/navigation/Top/Top.container.tsx b/app/components/navigation/Top/Top.container.tsx index 13344b9..87aad64 100644 --- a/app/components/navigation/Top/Top.container.tsx +++ b/app/components/navigation/Top/Top.container.tsx @@ -79,7 +79,7 @@ const TopContainer = memo( const navigationItems = [ { href: "#", text: t("navigation.useCases"), extraPadding: true }, { href: "/learn", text: t("navigation.learn") }, - { href: "#", text: t("navigation.about") }, + { href: "/about", text: t("navigation.about") }, ]; const renderNavigationItems = (size: NavSize) => { diff --git a/app/components/sections/Accordion/Accordion.container.tsx b/app/components/sections/Accordion/Accordion.container.tsx new file mode 100644 index 0000000..c3db272 --- /dev/null +++ b/app/components/sections/Accordion/Accordion.container.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { memo, useId } from "react"; +import FaqAccordionView from "./Accordion.view"; +import type { FaqAccordionProps, FaqAccordionViewProps } from "./Accordion.types"; +import type { AccordionSizeValue } from "../../layout/Accordion"; + +/** + * Figma: "Sections / Accordion" (22130-889248). Rows: **s** / **m** at `lg` (22135-890258); **Large** (`l`) at `xl` (22135:890328). + */ +const FaqAccordionContainer = memo( + ({ size: sizeProp = "s", lgSize: lgSizeProp = "m", xlSize: xlSizeProp = "l", ...props }) => { + const headingId = useId(); + const size: AccordionSizeValue = sizeProp; + const lgSize: AccordionSizeValue = lgSizeProp; + const xlSize: AccordionSizeValue = xlSizeProp; + + const viewProps: FaqAccordionViewProps = { + ...props, + size, + lgSize, + xlSize, + headingId, + }; + + return ; + }, +); + +FaqAccordionContainer.displayName = "FaqAccordion"; + +export default FaqAccordionContainer; diff --git a/app/components/sections/Accordion/Accordion.types.ts b/app/components/sections/Accordion/Accordion.types.ts new file mode 100644 index 0000000..9f9db9d --- /dev/null +++ b/app/components/sections/Accordion/Accordion.types.ts @@ -0,0 +1,25 @@ +import type { AccordionSizeValue } from "../../layout/Accordion"; + +export interface FaqAccordionItem { + title: string; + answer: string; + subhead?: string; +} + +export interface FaqAccordionProps { + title: string; + items: FaqAccordionItem[]; + size?: AccordionSizeValue; + /** Layout accordion size from `lg` (default **m**, Figma 22135-890258). */ + lgSize?: AccordionSizeValue; + /** Layout accordion size from `xl` (default **l**, Figma 22135:890328 Large). */ + xlSize?: AccordionSizeValue; + className?: string; +} + +export interface FaqAccordionViewProps extends FaqAccordionProps { + headingId: string; + size: AccordionSizeValue; + lgSize: AccordionSizeValue; + xlSize: AccordionSizeValue; +} diff --git a/app/components/sections/Accordion/Accordion.view.tsx b/app/components/sections/Accordion/Accordion.view.tsx new file mode 100644 index 0000000..6069a88 --- /dev/null +++ b/app/components/sections/Accordion/Accordion.view.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { memo } from "react"; +import LayoutAccordion from "../../layout/Accordion"; +import type { FaqAccordionViewProps } from "./Accordion.types"; + +/** + * Figma: "Sections / Accordion" (22130-889248; mobile FAQ 22132-889380). **xl** rows **Large** via `xlSize` (22135:890328). + * Section title: Large Heading (32px, lh 40) below `lg`; X Large Heading (36px, lh 44) at `lg`; XX Large Heading (40px, lh 52) at `xl` (Figma desktop frame 22135:890398). + */ +function FaqAccordionView({ + title, + items, + size, + lgSize, + xlSize, + headingId, + className = "", +}: FaqAccordionViewProps) { + return ( +
+
+

+ {title} +

+
+ {items.map((item, index) => ( + + {item.answer} + + ))} +
+
+
+ ); +} + +FaqAccordionView.displayName = "FaqAccordionView"; + +export default memo(FaqAccordionView); diff --git a/app/components/sections/Accordion/index.tsx b/app/components/sections/Accordion/index.tsx new file mode 100644 index 0000000..31be7cd --- /dev/null +++ b/app/components/sections/Accordion/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./Accordion.container"; +export type { FaqAccordionProps, FaqAccordionItem } from "./Accordion.types"; diff --git a/app/components/sections/AskOrganizer/AskOrganizer.container.tsx b/app/components/sections/AskOrganizer/AskOrganizer.container.tsx index 136795c..9d3a8a3 100644 --- a/app/components/sections/AskOrganizer/AskOrganizer.container.tsx +++ b/app/components/sections/AskOrganizer/AskOrganizer.container.tsx @@ -56,7 +56,7 @@ const AskOrganizerContainer = memo( 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-032)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-096)] 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" diff --git a/app/components/sections/AskOrganizer/AskOrganizer.view.tsx b/app/components/sections/AskOrganizer/AskOrganizer.view.tsx index 4a99236..09430d4 100644 --- a/app/components/sections/AskOrganizer/AskOrganizer.view.tsx +++ b/app/components/sections/AskOrganizer/AskOrganizer.view.tsx @@ -47,7 +47,7 @@ function AskOrganizerView({ size="large" buttonType="filled" palette={variant === "inverse" ? "inverse" : "default"} - className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]" + className="md:!px-[var(--spacing-scale-020)] md:!py-[var(--spacing-scale-012)] md:!text-[24px] md:!leading-[28px]" onClick={onContactClick} ariaLabel={ariaLabel} data-testid="ask-organizer-cta" diff --git a/app/components/sections/Book/Book.container.tsx b/app/components/sections/Book/Book.container.tsx new file mode 100644 index 0000000..c3ca4aa --- /dev/null +++ b/app/components/sections/Book/Book.container.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { memo, useId } from "react"; +import BookView from "./Book.view"; +import type { BookProps } from "./Book.types"; + +/** + * Figma: "Sections / Book" frame **22135:889706** (see Book.view.tsx). + */ +const BookContainer = memo((props) => { + const headingId = useId(); + + return ; +}); + +BookContainer.displayName = "Book"; + +export default BookContainer; diff --git a/app/components/sections/Book/Book.types.ts b/app/components/sections/Book/Book.types.ts new file mode 100644 index 0000000..724a299 --- /dev/null +++ b/app/components/sections/Book/Book.types.ts @@ -0,0 +1,13 @@ +export interface BookProps { + title: string; + description: string; + buttonText: string; + buttonHref?: string; + imageSrc?: string; + imageAlt?: string; + className?: string; +} + +export interface BookViewProps extends BookProps { + headingId: string; +} diff --git a/app/components/sections/Book/Book.view.tsx b/app/components/sections/Book/Book.view.tsx new file mode 100644 index 0000000..b752a63 --- /dev/null +++ b/app/components/sections/Book/Book.view.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { memo } from "react"; +import { ASSETS, getAssetPath } from "../../../../lib/assetUtils"; +import Button from "../../buttons/Button"; +import ContentLockup from "../../type/ContentLockup"; +import type { BookViewProps } from "./Book.types"; + +/** + * Figma: "Sections / Book" outer **22135:889706** (1440+: **Content Card Horizontal** 22135:890130): card `max-width` **1280px**, inner padding **scale/048**, gutter **scale/032** (`Content Lockup`: Small/Display 32 lh 1.1 Medium; body X Large / Paragraph **24 lh 32**). Section inset lg **scale/160** / **064** unchanged. + */ +function BookView({ + title, + description, + buttonText, + buttonHref, + imageSrc, + imageAlt, + headingId, + className = "", +}: BookViewProps) { + const coverSrc = imageSrc ?? getAssetPath(ASSETS.COMMUNITYRULES_COVER); + + return ( +
+
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element -- marketing cover art */} + {imageAlt +
+
+ + +
+
+
+
+ ); +} + +BookView.displayName = "BookView"; + +export default memo(BookView); diff --git a/app/components/sections/Book/index.tsx b/app/components/sections/Book/index.tsx new file mode 100644 index 0000000..e2e649a --- /dev/null +++ b/app/components/sections/Book/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./Book.container"; +export type { BookProps } from "./Book.types"; diff --git a/app/components/sections/QuoteBlock/QuoteBlock.container.tsx b/app/components/sections/QuoteBlock/QuoteBlock.container.tsx index 19737bf..9f9d732 100644 --- a/app/components/sections/QuoteBlock/QuoteBlock.container.tsx +++ b/app/components/sections/QuoteBlock/QuoteBlock.container.tsx @@ -5,11 +5,13 @@ import { logger } from "../../../../lib/logger"; import QuoteBlockView from "./QuoteBlock.view"; import type { QuoteBlockProps, VariantConfig } from "./QuoteBlock.types"; +/** Figma: portrait variants standard | compact | extended; statement = Section/Quote (22137:890679, copy scale 22135:889716 from md). */ const QuoteBlockContainer = memo( ({ variant: variantProp = "standard", className = "", quote = "The rules of decision-making must be open and available to everyone, and this can happen only if they are formalized.", + quoteSecondary, author = "Jo Freeman", source = "The Tyranny of Structurelessness", avatarSrc = "/assets/Quote_Avatar.svg", @@ -69,12 +71,29 @@ const QuoteBlockContainer = memo( "text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]", showDecor: true, }, + statement: { + container: + "flex w-full flex-col items-center px-[var(--spacing-scale-032)] py-[var(--spacing-scale-048)] md:px-[var(--spacing-scale-096)] md:py-[var(--space-1200)]", + card: "", + gap: "", + avatarGap: "", + avatar: "", + quote: "", + author: "", + source: "", + showDecor: false, + statementLayout: true, + }, }; const config = variants[variant] || variants.standard; // Use provided ID or generate a stable one based on content - const baseId = id || `quote-${author.toLowerCase().replace(/\s+/g, "-")}`; + const baseId = + id || + (variant === "statement" + ? "statement-quote" + : `quote-${author.toLowerCase().replace(/\s+/g, "-")}`); const quoteId = `${baseId}-content`; const authorId = `${baseId}-author`; @@ -105,7 +124,22 @@ const QuoteBlockContainer = memo( }; // Validate required props - if (!quote || !author) { + if (variant === "statement") { + if (!quote?.trim() || !quoteSecondary?.trim()) { + logger.error( + "QuoteBlock: statement variant requires non-empty quote and quoteSecondary", + ); + if (onError) { + onError({ + type: "missing_props", + message: + "QuoteBlock statement variant requires quote and quoteSecondary", + quote: !!quote?.trim() && !!quoteSecondary?.trim(), + }); + } + return null; + } + } else if (!quote || !author) { logger.error("QuoteBlock: Missing required props (quote or author)"); if (onError) { onError({ @@ -125,6 +159,7 @@ const QuoteBlockContainer = memo( +
+ +
+

+ {quote} +

+

+ {quoteSecondary} +

+
+
+ + ); + } + return (
#grain\')'; + +const QuoteStatementDecor = memo<{ className?: string }>(({ className = "" }) => { + const src = getAssetPath(quoteStatementShapePath()); + const bg = `url("${src}")`; + + return ( +
+ ); +}); + +QuoteStatementDecor.displayName = "QuoteStatementDecor"; + +export default QuoteStatementDecor; diff --git a/app/components/sections/QuoteBlock/index.tsx b/app/components/sections/QuoteBlock/index.tsx index 1858882..680ee4f 100644 --- a/app/components/sections/QuoteBlock/index.tsx +++ b/app/components/sections/QuoteBlock/index.tsx @@ -1,2 +1,5 @@ export { default } from "./QuoteBlock.container"; -export type { QuoteBlockProps } from "./QuoteBlock.types"; +export type { + QuoteBlockProps, + QuoteBlockVariantValue, +} from "./QuoteBlock.types"; diff --git a/app/components/sections/Stats/Stats.container.tsx b/app/components/sections/Stats/Stats.container.tsx new file mode 100644 index 0000000..dc0afd2 --- /dev/null +++ b/app/components/sections/Stats/Stats.container.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { memo, useId } from "react"; +import StatsView from "./Stats.view"; +import type { StatsProps } from "./Stats.types"; + +/** + * Figma: "Sections / Stats" (22132-889500; mobile frame 22137-891194). + */ +const StatsContainer = memo((props) => { + const headingId = useId(); + + return ; +}); + +StatsContainer.displayName = "Stats"; + +export default StatsContainer; diff --git a/app/components/sections/Stats/Stats.types.ts b/app/components/sections/Stats/Stats.types.ts new file mode 100644 index 0000000..da700e4 --- /dev/null +++ b/app/components/sections/Stats/Stats.types.ts @@ -0,0 +1,20 @@ +import type { StatShapeVariant } from "../../asset/Shapes"; + +export interface StatItem { + value: string; + label: string; + asOf?: string; + shapeVariant?: StatShapeVariant; +} + +export interface StatsProps { + titlePrefix?: string; + titleEmphasis?: string; + titleSuffix?: string; + items: StatItem[]; + className?: string; +} + +export interface StatsViewProps extends StatsProps { + headingId: string; +} diff --git a/app/components/sections/Stats/Stats.view.tsx b/app/components/sections/Stats/Stats.view.tsx new file mode 100644 index 0000000..7cc8fbb --- /dev/null +++ b/app/components/sections/Stats/Stats.view.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { memo } from "react"; +import Stat from "../../cards/Stat"; +import type { StatsViewProps } from "./Stats.types"; + +/** First word vs remainder for mobile two-tone title line (Sections / Stats, 22132:889582). */ +function splitLeadingWord(phrase: string): { leading: string; rest: string } { + const t = phrase.trim(); + const idx = t.indexOf(" "); + if (idx === -1) { + return { leading: t, rest: "" }; + } + return { leading: t.slice(0, idx), rest: t.slice(idx + 1).trimEnd() }; +} + +/** + * Figma: "Sections / Stats" (22132-889500; md 22137-890674 / mobile 22137-891194 / 22132:889576). Four-up from `lg`; cards fill grid columns; md + lg staggers per Figma; title md nudge reset at lg. Section inset uses spacing-scale-160 at lg. + */ +function StatsView({ + titlePrefix, + titleEmphasis, + titleSuffix, + items, + headingId, + className = "", +}: StatsViewProps) { + const { leading: suffixLead, rest: suffixTail } = titleSuffix + ? splitLeadingWord(titleSuffix) + : { leading: "", rest: "" }; + + return ( +
+
+

+ + {titlePrefix ? ( + + {titlePrefix}{" "} + + ) : null} + {titleEmphasis ? ( + + {titleEmphasis} + + ) : null} + + {titleSuffix ? ( + <> + {" "} + + + {suffixLead} + {suffixTail ? "\u00a0" : null} + + {suffixTail ? ( + {suffixTail} + ) : null} + + + ) : null} +

+
    + {items.map((item, index) => { + /* Figma mobile Card / Stat rows: 182px (1st, 4th) vs 260px (2nd, 3rd) */ + const isShortCard = index === 0 || index === items.length - 1; + const heightClass = isShortCard + ? "!h-[182px] !min-h-0" + : "!h-[260px] !min-h-0"; + + /* md 2-col stagger (22137:890674); lg 4-col stagger (22132:889576). */ + let staggerClass = ""; + if (index % 2 === 1) { + staggerClass = "md:-translate-y-4 lg:-translate-y-4"; + } else if (index === 0) { + staggerClass = "md:translate-y-24 lg:translate-y-4"; + } else if (index === 2) { + staggerClass = "md:translate-y-4 lg:translate-y-8"; + } else { + staggerClass = "md:translate-y-4 lg:translate-y-4"; + } + + return ( +
  • + +
  • + ); + })} +
+
+
+ ); +} + +StatsView.displayName = "StatsView"; + +export default memo(StatsView); diff --git a/app/components/sections/Stats/index.tsx b/app/components/sections/Stats/index.tsx new file mode 100644 index 0000000..ccb043e --- /dev/null +++ b/app/components/sections/Stats/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./Stats.container"; +export type { StatsProps, StatItem } from "./Stats.types"; diff --git a/app/components/type/AboutHeader/AboutHeader.container.tsx b/app/components/type/AboutHeader/AboutHeader.container.tsx new file mode 100644 index 0000000..71e64ec --- /dev/null +++ b/app/components/type/AboutHeader/AboutHeader.container.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { memo, useId } from "react"; +import AboutHeaderView from "./AboutHeader.view"; +import type { AboutHeaderProps } from "./AboutHeader.types"; + +/** + * Figma: "Type / AboutHeader" (22135-889654). + */ +const AboutHeaderContainer = memo((props) => { + const titleId = useId(); + + return ; +}); + +AboutHeaderContainer.displayName = "AboutHeader"; + +export default AboutHeaderContainer; diff --git a/app/components/type/AboutHeader/AboutHeader.types.ts b/app/components/type/AboutHeader/AboutHeader.types.ts new file mode 100644 index 0000000..974fde7 --- /dev/null +++ b/app/components/type/AboutHeader/AboutHeader.types.ts @@ -0,0 +1,12 @@ +export type AboutHeaderSegment = + | { type: "word"; text: string } + | { type: "icon"; icon: "arrow" | "about" }; + +export interface AboutHeaderProps { + segments: AboutHeaderSegment[]; + className?: string; +} + +export interface AboutHeaderViewProps extends AboutHeaderProps { + titleId: string; +} diff --git a/app/components/type/AboutHeader/AboutHeader.view.tsx b/app/components/type/AboutHeader/AboutHeader.view.tsx new file mode 100644 index 0000000..226e715 --- /dev/null +++ b/app/components/type/AboutHeader/AboutHeader.view.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { memo } from "react"; +import { ASSETS, getAssetPath, vectorMarkPath } from "../../../../lib/assetUtils"; +import ContentLockup from "../ContentLockup"; +import type { AboutHeaderViewProps } from "./AboutHeader.types"; + +function assetRelativeForInlineIcon(icon: "arrow" | "about"): string { + return icon === "arrow" ? ASSETS.LOGO : vectorMarkPath("about"); +} + +/** + * Figma: "Type / AboutHeader" (22135-889654). + */ +function AboutHeaderView({ + segments, + titleId, + className = "", +}: AboutHeaderViewProps) { + return ( +
+ { + if (segment.type === "word") { + return ( + + {segment.text} + + ); + } + + return ( + + {/* eslint-disable-next-line @next/next/no-img-element -- decorative inline vector */} + + + ); + })} + /> +
+ ); +} + +AboutHeaderView.displayName = "AboutHeaderView"; + +export default memo(AboutHeaderView); diff --git a/app/components/type/AboutHeader/index.tsx b/app/components/type/AboutHeader/index.tsx new file mode 100644 index 0000000..0cd2553 --- /dev/null +++ b/app/components/type/AboutHeader/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./AboutHeader.container"; +export type { AboutHeaderProps, AboutHeaderSegment } from "./AboutHeader.types"; diff --git a/app/components/type/ContentLockup/ContentLockup.container.tsx b/app/components/type/ContentLockup/ContentLockup.container.tsx index b177734..c519ff6 100644 --- a/app/components/type/ContentLockup/ContentLockup.container.tsx +++ b/app/components/type/ContentLockup/ContentLockup.container.tsx @@ -16,6 +16,7 @@ const ContentLockupContainer = memo( linkHref, alignment: alignmentProp = "center", titleId, + titleContent, }) => { const variant = variantProp; const alignment = alignmentProp; @@ -48,10 +49,44 @@ const ContentLockupContainer = memo( subtitle: "font-space-grotesk font-normal text-[20px] leading-[130%] tracking-[0] text-[var(--color-content-default-primary)]", description: - "font-inter font-normal text-[16px] leading-[140%] lg:text-[18px] lg:leading-[150%] xl:text-[20px] xl:leading-[160%] text-[var(--color-content-secondary)]", + "font-inter font-normal text-[16px] leading-[140%] lg:text-[18px] lg:leading-[150%] xl:text-[20px] xl:leading-[160%] text-[var(--color-content-default-secondary)]", shape: "w-[20px] h-[20px] md:w-[24px] md:h-[24px] lg:w-[28px] lg:h-[28px]", }, + about: { + container: + "flex flex-col gap-[var(--spacing-scale-020)] lg:gap-[var(--spacing-scale-016)] relative z-10", + textContainer: + "flex flex-col gap-[var(--spacing-scale-008)] lg:gap-[var(--spacing-scale-004)]", + titleGroup: + "flex flex-col gap-[var(--spacing-scale-008)] lg:gap-[var(--spacing-scale-004)]", + titleContainer: + "flex flex-wrap items-center gap-[var(--spacing-scale-008)] md:gap-[var(--spacing-scale-010)]", + title: + "font-bricolage-grotesque font-medium text-[32px] leading-[1.1] md:text-[52px] xl:text-[length:var(--spacing-scale-064)] xl:leading-[length:var(--spacing-scale-064)] text-[var(--color-content-default-primary,white)]", + subtitle: + "font-bricolage-grotesque font-medium text-[32px] leading-[1.1] lg:text-[18px] lg:leading-[22px] xl:text-[32px] xl:leading-[1.1] text-[var(--color-content-default-primary,white)]", + description: + "font-inter font-normal text-[length:var(--text-x-large-paragraph)] leading-[length:var(--text-x-large-paragraph--line-height)] tracking-[var(--text-x-large-paragraph--letter-spacing)] text-[var(--color-content-default-secondary)] whitespace-pre-line lg:text-[14px] lg:leading-[20px] lg:tracking-[0] xl:text-[length:var(--text-x-large-paragraph)] xl:leading-[length:var(--text-x-large-paragraph--line-height)] xl:tracking-[var(--text-x-large-paragraph--letter-spacing)]", + shape: + "w-[20px] h-[20px] md:w-[28px] md:h-[28px]", + }, + book: { + container: + "flex flex-col gap-[var(--spacing-scale-016)] lg:gap-[var(--spacing-scale-020)] relative z-10", + textContainer: + "flex flex-col gap-[var(--spacing-scale-004)] lg:gap-[var(--spacing-scale-008)]", + titleGroup: "flex flex-col gap-0", + titleContainer: "flex flex-wrap items-center gap-[var(--spacing-scale-008)]", + title: + "font-bricolage-grotesque font-medium text-[18px] leading-[22px] lg:text-[length:var(--spacing-scale-020)] lg:leading-[24px] xl:text-[length:var(--spacing-scale-032)] xl:leading-[1.1] text-[var(--color-content-default-primary)]", + subtitle: + "font-bricolage-grotesque font-medium text-[18px] leading-[22px] lg:text-[length:var(--spacing-scale-020)] lg:leading-[24px] xl:text-[length:var(--spacing-scale-032)] xl:leading-[1.1] text-[var(--color-content-default-primary)]", + description: + "font-inter font-normal text-[length:var(--sizing-350)] leading-[20px] text-[var(--color-content-default-secondary)] whitespace-pre-line lg:text-[length:var(--sizing-400)] lg:leading-[24px] xl:text-[length:var(--spacing-scale-024)] xl:leading-[length:var(--text-x-large-paragraph--line-height)]", + shape: + "w-[20px] h-[20px] md:w-[28px] md:h-[28px]", + }, learn: { container: "flex flex-col gap-[var(--spacing-scale-012)] relative z-10 pt-[var(--spacing-scale-016)] pb-[var(--spacing-scale-016)] px-[var(--spacing-scale-020)] sm:pt-[var(--spacing-scale-040)] sm:pb-0 md:pt-[var(--spacing-scale-056)] md:px-[var(--spacing-scale-032)] lg:pt-[var(--spacing-scale-056)] lg:px-[var(--spacing-scale-064)]", @@ -65,7 +100,7 @@ const ContentLockupContainer = memo( subtitle: "font-space-grotesk font-normal text-[16px] leading-[24px] tracking-[0] lg:text-[24px] lg:leading-[28px] text-[var(--color-content-default-primary)]", description: - "font-inter font-normal text-[16px] leading-[140%] lg:text-[18px] lg:leading-[150%] xl:text-[20px] xl:leading-[160%] text-[var(--color-content-secondary)]", + "font-inter font-normal text-[16px] leading-[140%] lg:text-[18px] lg:leading-[150%] xl:text-[20px] xl:leading-[160%] text-[var(--color-content-default-secondary)]", shape: "w-[20px] h-[20px] md:w-[24px] md:h-[24px] lg:w-[28px] lg:h-[28px]", }, @@ -75,7 +110,7 @@ const ContentLockupContainer = memo( titleGroup: "flex flex-col gap-[var(--spacing-scale-008)]", titleContainer: "flex gap-[var(--spacing-scale-008)] items-center", title: - "font-bricolage-grotesque font-medium text-[36px] leading-[110%] tracking-[0] md:text-[44px] md:leading-[110%] xl:text-[52px] xl:leading-[110%] text-[var(--color-content-default-brand-primary)]", + "font-bricolage-grotesque font-medium text-[36px] leading-[110%] tracking-[0] md:text-[52px] md:leading-[110%] text-[var(--color-content-default-brand-primary)]", subtitle: "font-inter font-normal text-[18px] leading-[130%] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-default-primary)]", shape: @@ -87,7 +122,7 @@ const ContentLockupContainer = memo( titleGroup: "flex flex-col gap-[var(--spacing-scale-008)]", titleContainer: "flex gap-[var(--spacing-scale-008)] items-center", title: - "font-bricolage-grotesque font-medium text-[36px] leading-[110%] tracking-[0] md:text-[44px] md:leading-[110%] xl:text-[52px] xl:leading-[110%] text-[var(--color-content-inverse-primary)]", + "font-bricolage-grotesque font-medium text-[36px] leading-[110%] tracking-[0] md:text-[52px] md:leading-[110%] 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)]", shape: @@ -137,6 +172,7 @@ const ContentLockupContainer = memo( linkHref={linkHref} alignment={alignment} titleId={titleId} + titleContent={titleContent} styles={styles} /> ); diff --git a/app/components/type/ContentLockup/ContentLockup.types.ts b/app/components/type/ContentLockup/ContentLockup.types.ts index de4332a..6edee3f 100644 --- a/app/components/type/ContentLockup/ContentLockup.types.ts +++ b/app/components/type/ContentLockup/ContentLockup.types.ts @@ -1,7 +1,11 @@ +import type { ReactNode } from "react"; + export type ContentLockupVariantValue = | "hero" | "feature" | "learn" + | "about" + | "book" | "ask" | "ask-inverse" | "modal" @@ -31,6 +35,8 @@ export interface ContentLockupProps { * Useful when a parent section uses aria-labelledby. */ titleId?: string; + /** Replaces the default title string when inline title markup is required. */ + titleContent?: ReactNode; } export interface VariantStyle { @@ -55,6 +61,8 @@ export interface ContentLockupViewProps { | "hero" | "feature" | "learn" + | "about" + | "book" | "ask" | "ask-inverse" | "modal" @@ -63,5 +71,6 @@ export interface ContentLockupViewProps { linkHref?: string; alignment: "center" | "left"; titleId?: string; + titleContent?: ReactNode; styles: VariantStyle; } diff --git a/app/components/type/ContentLockup/ContentLockup.view.tsx b/app/components/type/ContentLockup/ContentLockup.view.tsx index 7e70c87..0a6ed33 100644 --- a/app/components/type/ContentLockup/ContentLockup.view.tsx +++ b/app/components/type/ContentLockup/ContentLockup.view.tsx @@ -16,6 +16,7 @@ function ContentLockupView({ linkHref, alignment, titleId, + titleContent, styles, }: ContentLockupViewProps) { return ( @@ -57,7 +58,14 @@ function ContentLockupView({
{/* Title container */}
- {title ? ( + {titleContent ? ( +

+ {titleContent} +

+ ) : title ? (

{title}

diff --git a/app/components/type/TripleTextBlock/TripleTextBlock.container.tsx b/app/components/type/TripleTextBlock/TripleTextBlock.container.tsx new file mode 100644 index 0000000..17717c2 --- /dev/null +++ b/app/components/type/TripleTextBlock/TripleTextBlock.container.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { memo, useId } from "react"; +import TripleTextBlockView from "./TripleTextBlock.view"; +import type { TripleTextBlockProps } from "./TripleTextBlock.types"; + +/** + * Figma: "Type / TripleTextBlock" stacked 22137:890676; lg 22128:888715; xl 22135:889705. + */ +const TripleTextBlockContainer = memo((props) => { + const headingId = useId(); + + return ; +}); + +TripleTextBlockContainer.displayName = "TripleTextBlock"; + +export default TripleTextBlockContainer; diff --git a/app/components/type/TripleTextBlock/TripleTextBlock.types.ts b/app/components/type/TripleTextBlock/TripleTextBlock.types.ts new file mode 100644 index 0000000..1f085fb --- /dev/null +++ b/app/components/type/TripleTextBlock/TripleTextBlock.types.ts @@ -0,0 +1,23 @@ +export interface TripleTextBlockColumn { + title: string; + description: string; + /** + * lg+ three-column layout (Figma 22128:888715). When either `lgTitle` or `lgDescription` + * is set, stacked breakpoints use `title`/`description` and lg uses these (missing side falls back). + */ + lgTitle?: string; + lgDescription?: string; +} + +export interface TripleTextBlockProps { + /** Section heading above the column stack (e.g. About page). Omit when matching a headerless Figma frame. */ + title?: string; + columns: TripleTextBlockColumn[]; + ctaText?: string; + ctaHref?: string; + className?: string; +} + +export interface TripleTextBlockViewProps extends TripleTextBlockProps { + headingId: string; +} diff --git a/app/components/type/TripleTextBlock/TripleTextBlock.view.tsx b/app/components/type/TripleTextBlock/TripleTextBlock.view.tsx new file mode 100644 index 0000000..011b078 --- /dev/null +++ b/app/components/type/TripleTextBlock/TripleTextBlock.view.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { memo } from "react"; +import Button from "../../buttons/Button"; +import ContentLockup from "../ContentLockup"; +import type { + TripleTextBlockColumn, + TripleTextBlockViewProps, +} from "./TripleTextBlock.types"; + +function columnUsesLargeBreakpointCopy(column: TripleTextBlockColumn): boolean { + return column.lgTitle !== undefined || column.lgDescription !== undefined; +} + +function TripleTextBlockColumnLockup({ + column, +}: { + column: TripleTextBlockColumn; +}) { + const dual = columnUsesLargeBreakpointCopy(column); + const lgSubtitle = column.lgTitle ?? column.title; + const lgBody = column.lgDescription ?? column.description; + + if (!dual) { + return ( + + ); + } + + return ( + <> +
+ +
+
+ +
+ + ); +} + +/** + * Figma: "Type / TripleTextBlock" stacked **22137:890676**; lg 3-col **22128-888715**; xl typography + horizontal inset scale/160 **22135:889705** (Subtitle 32 Small/Display, Body X Large/Paragraph 24 / lh 32; section px scale/160, py scale/064). + */ +function TripleTextBlockView({ + title = "", + columns, + ctaText, + ctaHref, + headingId, + className = "", +}: TripleTextBlockViewProps) { + const sectionTitle = title.trim(); + const hasSectionTitle = sectionTitle.length > 0; + + return ( +
+
+ {hasSectionTitle ? ( +

+ {sectionTitle} +

+ ) : null} +
+ {columns.map((column, index) => ( +
+ +
+ ))} +
+ {ctaText ? ( +
+ +
+ ) : null} +
+
+ ); +} + +TripleTextBlockView.displayName = "TripleTextBlockView"; + +export default memo(TripleTextBlockView); diff --git a/app/components/type/TripleTextBlock/index.tsx b/app/components/type/TripleTextBlock/index.tsx new file mode 100644 index 0000000..2373866 --- /dev/null +++ b/app/components/type/TripleTextBlock/index.tsx @@ -0,0 +1,5 @@ +export { default } from "./TripleTextBlock.container"; +export type { + TripleTextBlockProps, + TripleTextBlockColumn, +} from "./TripleTextBlock.types"; diff --git a/docs/figma-component-registry.md b/docs/figma-component-registry.md index 9ee0590..653b2bd 100644 --- a/docs/figma-component-registry.md +++ b/docs/figma-component-registry.md @@ -7,7 +7,7 @@ Quick map from the Figma file **Community Rule System** (`agv0VBLiBlcnSAaiAORgPR | [Utility](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=20515-15809) | `utility/` | Create chrome, tag, scroll, sidebar, dividers, etc. | | [Asset](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=1240-9089) | **`app/components/asset/`**, **`public/assets/template-mark/`**, **`public/assets/vector/`** | Components under **`asset/`**; flat kebab **`*.svg`** in **`template-mark/`** & **`vector/`** (see conventions below). | | [Button](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=497-3016) | `buttons/` | PascalCase package per primitive — **`Button/`**, **`InlineTextButton/`** (see conventions below). | -| [Card](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=17865-24349) | `cards/` | One PascalCase package per surface—**`Selection/`** (Figma **Card / CardSelection**), **`CardStack/`**, **`Rule/`** (Figma **Card / Rule**), **`Icon/`**, **`Mini/`**, **`Step/`** (Figma **Card / Step**), **`TemplateReviewCard/`** (see conventions below). | +| [Card](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=17865-24349) | `cards/` | One PascalCase package per surface—**`Selection/`** (Figma **Card / CardSelection**), **`CardStack/`**, **`Rule/`** (Figma **Card / Rule**), **`Icon/`**, **`Mini/`**, **`Stat/`** (Figma **Card / Stat**), **`Step/`** (Figma **Card / Step**), **`TemplateReviewCard/`** (see conventions below). | | [Control](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=5944-58611) | `controls/` | Checkbox, radio, text field, select, toggle, switch, incrementer, upload, multi-select, chip, … (see **Control conventions** below). **`InfoMessageBox`** canonical here. | | [Layout](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=21836-20542) | `layout/` | **`List/`**, **`ListEntry/`**, **`ListItem/`** + **`listSizeLayout.ts`**. **Tabs** / **Accordion** are in Figma only—**not** in code yet (see **Layout conventions**). | | [Modals](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=5944-47704) | `modals/` | Alert, Create, Dialog, Login, Tooltip, **`ModalHeader`** / **`ModalFooter`** (see **Modals conventions**). | @@ -59,7 +59,7 @@ Inventory aligns with [**CR-104**](https://linear.app/community-rule/issue/CR-10 ## Layout conventions (Figma [“Layout”](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=21836-20542) canvas) -Tracks [**CR-104**](https://linear.app/community-rule/issue/CR-104/backlog-design-system-component-cleanup) §6: **inventory only**. **Do not** add **`Tabs`**, **`Accordion`**, or other Layout primitives until a shipped surface needs them and design is agreed—**no scaffold components** for Figma-only patterns. +Tracks [**CR-104**](https://linear.app/community-rule/issue/CR-104/backlog-design-system-component-cleanup) §6: **inventory only**. **`Accordion/`** shipped for **`/about`**; **`Tabs`** and other Layout primitives stay **Figma-only** until a shipped surface needs them—**no scaffold components** for parity alone. | Figma (typical) | Code (`app/components/layout/`) | Notes | | --- | --- | --- | @@ -69,7 +69,7 @@ Tracks [**CR-104**](https://linear.app/community-rule/issue/CR-104/backlog-desig | Shared list sizing | **`listSizeLayout.ts`** | Layout constants / classes shared by **`List`** and **`ListEntry`**. | | List edit | — | No **`ListEdit`** package in this repo today; editing flows may be screen-local or future work—confirm in Figma vs product before introducing a shared primitive. | | Tabs | — | **Not implemented.** | -| Accordion | — | **Not implemented.** | +| Accordion | **`Accordion/`** | **`Accordion.container.tsx`**, **`Accordion.view.tsx`**, **`Accordion.types.ts`** — disclosure row used on **`/about`** FAQ (`sections/Accordion`). | **Coverage note:** Figma’s Base / List matrix may be larger than **`List`** / **`ListEntry`** props—parity is **incremental**, not assumed 1:1. @@ -144,13 +144,16 @@ Inventory aligns with [**CR-104**](https://linear.app/community-rule/issue/CR-10 | Card steps (SectionCardSteps) | **`CardSteps/`** | Composes **`cards/Step`** (Figma **Card / Step** — not **`progress/Stepper`**). | | Rule stack | **`RuleStack/`** | | | Feature grid | **`FeatureGrid/`** | | -| Quote block | **`QuoteBlock/`** | | +| Quote block | **`QuoteBlock/`** | Includes **`statement`** (Section/Quote **22137:890679**), shipped **`/about`** under FAQ; **`standard` / `compact` / `extended`** remain portrait + attribution. | | Ask organizer | **`AskOrganizer/`** | | +| Stats (about metrics) | **`Stats/`** | Composes **`cards/Stat`** + **`asset/Shapes`**; shipped on **`/about`**. | +| Book promo | **`Book/`** | **`/about`** download band — **`ContentLockup`** + **`Button`**. | +| FAQ accordion | **`Accordion/`** | Section wrapper over **`layout/Accordion`**; **`/about`**. | | Related content | **`RelatedArticles/`** | Article list / cards — confirm naming vs Figma “related slider” frames. | | Template grid (governance) | **`GovernanceTemplateGrid/`** | **`GovernanceTemplateGridSkeleton`** colocated. | | Section index / number | **`SectionNumber.tsx`** | Single module. | -**Gaps / TBD (§10, confirm with design / roadmap):** **PageHeader**, **CardGroup**, **Section Accordion**, **Section / Stats** (hero metrics distinct from **`cards/Step`** — [**CR-59**](https://linear.app/community-rule/issue/CR-59/card-stat)), **Related slider** (vs **`RelatedArticles`** parity), **About header**, **triple-step** / text blocks, **orgs** strip, and other Figma-only compositions. +**Gaps / TBD (§10, confirm with design / roadmap):** **PageHeader**, **CardGroup**, **Related slider** (vs **`RelatedArticles`** parity), **orgs** strip, and other Figma-only compositions. - **Pattern:** Prefer **`container` / `view` / `types`** + **`index.tsx`** for **new** section composites. Older or small surfaces may stay a **single `*.tsx`** at **`sections/`** root (**`ContentBanner`**, **`SectionNumber`**) — match neighbors when extending. @@ -163,7 +166,9 @@ Inventory aligns with [**CR-104**](https://linear.app/community-rule/issue/CR-10 | Figma (typical) | Code (`app/components/type/`) | Notes | | --- | --- | --- | | Section header (1 vs 3 lines, responsive sizes) | **`SectionHeader/`** | Figma [**17411:10981**](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=17411-10981). **`SectionHeader.tsx`** + **`index.tsx`**; **`default`** / **`multi-line`**; optional **`stackedDesktopLines`**. Composed by **`sections/CardSteps`**, **`sections/RuleStack`**, etc. | -| Header lockup / content lockup | **`HeaderLockup/`**, **`ContentLockup/`** | **`container` / `view` / `types`** + **`index.tsx`** where split. | +| Header lockup / content lockup | **`HeaderLockup/`**, **`ContentLockup/`** | **`container` / `view` / `types`** + **`index.tsx`** where split. **`ContentLockup`** **`about`** variant + optional **`titleContent`** for **`AboutHeader`**. | +| About header (inline word + shape lockup) | **`AboutHeader/`** | **`/about`** hero; composes **`ContentLockup`**. | +| Triple text block | **`TripleTextBlock/`** | **`/about`** (columns only; optional title/CTA omitted per page). | | Type / Numbered List (+ item) | **`NumberedList/`** | **`container` / `view` / `types`** + **`index.tsx`**. | | `.utility/Input label` (often filed under Utility in Figma) | **`InputLabel/`** | See also **Utility conventions** — **`InputLabel`** is canonical under **`type/`**. | | “Community Rule” published body (Sections canvas) | **`CommunityRule/`** | Composes **`Section`** + **`TextBlock`**. Category + entries; optional entry **`blocks`**; plain **`body`** splits on blank lines. | @@ -177,6 +182,7 @@ Inventory aligns with [**CR-104**](https://linear.app/community-rule/issue/CR-10 - **Pattern:** follow the **container / view / types** split (**`Selection/`**, **`CardStack/`**, **`Rule/`**, **`Icon/`**, **`Mini/`**) unless a component stays a single module (**`Step/`** uses one **`Step.tsx`** + **`index.tsx`** only). - **`Rule/`** — Figma **Card / Rule**. **`Rule.container.tsx`**, **`Rule.view.tsx`**, **`Rule.types.ts`**. - **`Selection/`** — Figma **Card / CardSelection** (e.g. `16775:28762`): optional recommended/selected **`Tag`**, label, support text. Stacked layout uses `orientation="horizontal"`; row + info icon + tag right uses `orientation="vertical"`. +- **`Stat/`** — Figma **Card / Stat** (metric tile + decorative shape). Composed by **`sections/Stats`** on **`/about`**. - **`Step/`** — Figma **Card / Step** (numbered step tile + text). Shipped on the home page via **`sections/CardSteps`**. Not the **Progress / Stepper** wizard control. - **`CardStack/`** — selectable stacks + expand affordance for create-flow method pickers (**Figma may still say “Utility / CardStack”;** code lives here). - **`TemplateReviewCard/`** — template review grid + chip detail modal (**`TemplateChipDetailModal`** colocated in the package). @@ -188,5 +194,6 @@ Inventory aligns with [**CR-104**](https://linear.app/community-rule/issue/CR-10 - **`public/assets/vector/.svg`** — Figma Asset / Vector marks (same kebab **`slug`** convention as **`public/assets/template-mark/`**). Use **`vectorMarkPath(slug)`** in **`lib/assetUtils.ts`**. - **`asset/Logo`** — Community Rule **`Logo`** component (folder PascalCase, like **`Avatar/`**). - **`asset/Avatar`** + **`asset/AvatarContainer`** — paired circular image stacks (e.g. top nav). Fuller DS Avatar behavior (**initials**, upload routing, …) tracked as **[CR-58](https://linear.app/community-rule/issue/CR-58)**. +- **`asset/Shapes/`** — decorative blobs for **`cards/Stat`** and About header inline art (Figma **Shapes**). *Update this when you add a new top-level `app/components/*` package or a new Figma canvas.* diff --git a/lib/assetUtils.ts b/lib/assetUtils.ts index b348c26..ba7391d 100644 --- a/lib/assetUtils.ts +++ b/lib/assetUtils.ts @@ -34,6 +34,21 @@ export function vectorMarkPath(slug: string): string { return `assets/vector/${slug}.svg`; } +/** + * Stat card decorative shapes in `public/assets/shapes/` + * (`stat-shape-1.svg` … `stat-shape-4.svg`, kebab-case — Figma **Card / Stat**). + */ +export function statShapeAssetPath(index: 1 | 2 | 3 | 4): string { + return `assets/shapes/stat-shape-${index}.svg`; +} + +/** + * Statement / Section-Quote flanking ornaments (`public/assets/shapes/shape-qoute.svg`). + */ +export function quoteStatementShapePath(): string { + return "assets/shapes/shape-qoute.svg"; +} + /** * Asset paths for common components */ @@ -65,6 +80,9 @@ export const ASSETS = { CONTENT_SHAPE_1: "assets/Content_Shape_1.svg", CONTENT_SHAPE_2: "assets/Content_Shape_2.svg", + /** Sections / Book cover (Figma **22137:891197**). */ + COMMUNITYRULES_COVER: "assets/communityrules-cover.svg", + // Alert icons ICON_ALERT: "assets/Icon_Alert.svg", ICON_CLOSE: "assets/Icon_Close.svg", diff --git a/lib/propNormalization.ts b/lib/propNormalization.ts index fd56eef..a1b84b6 100644 --- a/lib/propNormalization.ts +++ b/lib/propNormalization.ts @@ -141,6 +141,7 @@ export const QUOTE_BLOCK_VARIANT_OPTIONS = [ "compact", "standard", "extended", + "statement", ] as const; export type QuoteBlockVariantValue = (typeof QUOTE_BLOCK_VARIANT_OPTIONS)[number]; diff --git a/messages/en/index.ts b/messages/en/index.ts index 56a5a77..9100546 100644 --- a/messages/en/index.ts +++ b/messages/en/index.ts @@ -15,6 +15,7 @@ import webVitalsDashboard from "./components/webVitalsDashboard.json"; import home from "./pages/home.json"; import templates from "./pages/templates.json"; import learn from "./pages/learn.json"; +import about from "./pages/about.json"; import monitor from "./pages/monitor.json"; import login from "./pages/login.json"; import profile from "./pages/profile.json"; @@ -76,6 +77,7 @@ export default { home, templates, learn, + about, monitor, login, profile, diff --git a/messages/en/pages/about.json b/messages/en/pages/about.json new file mode 100644 index 0000000..1e7e08b --- /dev/null +++ b/messages/en/pages/about.json @@ -0,0 +1,108 @@ +{ + "_comment": "About page content. book.buttonHref is \"#\" as a stub until the book download endpoint or asset URL is wired.", + "aboutHeader": { + "segments": [ + { "type": "word", "text": "CommunityRule" }, + { "type": "icon", "icon": "arrow" }, + { "type": "word", "text": "is" }, + { "type": "word", "text": "a" }, + { "type": "word", "text": "tool" }, + { "type": "word", "text": "that" }, + { "type": "word", "text": "helps" }, + { "type": "word", "text": "groups" }, + { "type": "icon", "icon": "about" }, + { "type": "word", "text": "define" }, + { "type": "word", "text": "who" }, + { "type": "word", "text": "they" }, + { "type": "word", "text": "want" }, + { "type": "word", "text": "to" }, + { "type": "word", "text": "be" } + ] + }, + "stats": { + "titlePrefix": "From", + "titleEmphasis": "projects", + "titleSuffix": "to communities", + "asOf": "as of June 30, 2024", + "items": [ + { + "value": "420M+", + "label": "open source projects", + "shapeVariant": "yellow" + }, + { + "value": "27%", + "label": "year over year growth in open source", + "shapeVariant": "purple" + }, + { + "value": "45%", + "label": "adults in the US participate in community service", + "shapeVariant": "green" + }, + { + "value": "8000+", + "label": "mutual aid groups in the U.S.", + "shapeVariant": "orange" + } + ] + }, + "tripleTextBlock": { + "columns": [ + { + "title": "Share Leadership and Prevent Burnout", + "description": "Rotating roles and setting clear expectations for contributions can prevent a few people from taking on too much. A living document outlining responsibilities and group values helps distribute labor fairly and avoids misunderstandings.\n\nA transportation assistance group avoided burnout by introducing a weekly shift system, making participation more sustainable.", + "lgTitle": "Frameworks for Community Governance", + "lgDescription": "CommunityRule provides customizable templates that help communities define decision-making structures, leadership roles, and processes for collective action." + }, + { + "title": "Establish Transparent, Inclusive Decision-Making", + "description": "Deciding early on how your group makes choices—whether consensus, majority vote, or delegation—can prevent confusion. Transparency and regular check-ins help avoid unspoken hierarchies and keep everyone engaged.\n\nA disaster relief group sped up decisions by adopting a “consensus-minus-one” model, preventing gridlock while maintaining group alignment.", + "lgTitle": "Empowering Self-Governance", + "lgDescription": "The platform enables communities, mutual aid groups, and open-source projects to foster transparency and accountability without relying on top-down authority." + }, + { + "title": "Address Conflicts Before They Grow", + "description": "A basic conflict resolution process can prevent disputes from escalating. Clear guidelines for resource distribution reduce misunderstandings and maintain trust. Assume good faith and use structured discussions to resolve issues.\n\nMembers noticed frustration when some folks felt unheard in decisions about new projects. By anonymous feedback box, they created a space to address concerns early and make adjustments before conflicts escalated.", + "lgTitle": "Flexible & Open-Source Approach", + "lgDescription": "Users can modify existing governance templates or create their own, making it a versatile tool for diverse community needs, from co-ops to digital collectives." + } + ] + }, + "book": { + "title": "Get the Community Rules Book", + "description": "Community Rules is a simple tool to help make great communities even better and healthier. It includes nine templates for organizational structures that communities can choose from, combine, or react against.", + "buttonText": "Download Book", + "buttonHref": "#", + "imageAlt": "Structure Before Crisis book cover" + }, + "faq": { + "title": "Get answers to your questions", + "items": [ + { + "title": "What is CommunityRule, and who is it for?", + "answer": "CommunityRule helps groups write operating manuals that reflect their values and decision-making needs." + }, + { + "title": "How does CommunityRule help with governance?", + "answer": "Templates and guided prompts help teams document roles, decision processes, and conflict resolution before issues escalate." + }, + { + "title": "Do we need to have formal governance to use CommunityRule?", + "answer": "No. Many groups start with informal practices and use CommunityRule to clarify expectations as they grow." + }, + { + "title": "What kinds of templates does CommunityRule offer?", + "answer": "The library includes structures such as consensus clusters, elected boards, do-ocracy, and other community-tested models." + }, + { + "title": "Is CommunityRule free to use?", + "answer": "Yes. You can explore templates and draft rules without a subscription." + } + ] + }, + "quote": { + "paragraph1": "Too many of our communities adopt default governance practices that rely on unchecked authority without even basic democratic features.", + "paragraph2": "Community Rule helps communities establish better norms for decision-making, stewardship, and culture." + } +} diff --git a/public/assets/communityrules-cover.svg b/public/assets/communityrules-cover.svg new file mode 100644 index 0000000..4e021bd --- /dev/null +++ b/public/assets/communityrules-cover.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/shapes/shape-qoute.svg b/public/assets/shapes/shape-qoute.svg new file mode 100644 index 0000000..b00252f --- /dev/null +++ b/public/assets/shapes/shape-qoute.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/assets/shapes/stat-shape-1.svg b/public/assets/shapes/stat-shape-1.svg new file mode 100644 index 0000000..9319688 --- /dev/null +++ b/public/assets/shapes/stat-shape-1.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/assets/shapes/stat-shape-2.svg b/public/assets/shapes/stat-shape-2.svg new file mode 100644 index 0000000..673315b --- /dev/null +++ b/public/assets/shapes/stat-shape-2.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/shapes/stat-shape-3.svg b/public/assets/shapes/stat-shape-3.svg new file mode 100644 index 0000000..9c96dbc --- /dev/null +++ b/public/assets/shapes/stat-shape-3.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/assets/shapes/stat-shape-4.svg b/public/assets/shapes/stat-shape-4.svg new file mode 100644 index 0000000..b186157 --- /dev/null +++ b/public/assets/shapes/stat-shape-4.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/vector/about.svg b/public/assets/vector/about.svg new file mode 100644 index 0000000..aec5c45 --- /dev/null +++ b/public/assets/vector/about.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/stories/layout/Accordion.stories.tsx b/stories/layout/Accordion.stories.tsx new file mode 100644 index 0000000..f34e558 --- /dev/null +++ b/stories/layout/Accordion.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import Accordion from "../../app/components/layout/Accordion"; + +const meta: Meta = { + title: "Components/Layout/Accordion", + component: Accordion, + parameters: { + layout: "padded", + backgrounds: { default: "dark" }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "What is CommunityRule, and who is it for?", + children: "CommunityRule helps groups write operating manuals.", + size: "l", + }, +}; + +/** FAQ-style: small header below `lg`, medium at `lg` (Figma 22135-890258). */ +export const SmallWithMediumAtLg: Story = { + args: { + title: "What is CommunityRule, and who is it for?", + children: "CommunityRule helps groups write operating manuals.", + size: "s", + lgSize: "m", + }, +}; diff --git a/stories/sections/QuoteBlock.stories.js b/stories/sections/QuoteBlock.stories.js index 3ae18bf..139bb66 100644 --- a/stories/sections/QuoteBlock.stories.js +++ b/stories/sections/QuoteBlock.stories.js @@ -11,7 +11,7 @@ export default { A responsive quote section component that displays inspirational governance quotes with author attribution and decorative geometric elements. ## Features -- **Three variants**: compact, standard, and extended layouts +- **Four variants**: compact, standard, extended, and **statement** (Section/Quote yellow band, dual paragraphs) - **Responsive design**: Adapts across all breakpoints - **Error handling**: Graceful fallbacks for image loading failures - **Accessibility**: WCAG 2.1 AA compliant with proper ARIA labels @@ -34,12 +34,17 @@ A responsive quote section component that displays inspirational governance quot argTypes: { variant: { control: { type: "select" }, - options: ["compact", "standard", "extended"], + options: ["compact", "standard", "extended", "statement"], description: "Layout variant for different use cases", }, quote: { control: { type: "text" }, - description: "The quote text to display", + description: + "Main quote / first paragraph (for `statement`, pair with quoteSecondary)", + }, + quoteSecondary: { + control: { type: "text" }, + description: "Second paragraph when `variant` is `statement`", }, author: { control: { type: "text" }, @@ -124,7 +129,22 @@ export const AllVariants = { }, }; -// Error state simulation +// Statement band (About page / Figma Section/Quote) +export const StatementAbout = { + args: { + variant: "statement", + id: "story-statement-quote", + quote: + "Too many of our communities adopt default governance practices that rely on unchecked authority without even basic democratic features.", + quoteSecondary: + "Community Rule helps communities establish better norms for decision-making, stewardship, and culture.", + }, + parameters: { + backgrounds: { default: "dark" }, + }, +}; + +// Error state simulation (avatar load failure) export const ErrorState = { args: { variant: "standard", diff --git a/stories/sections/Stats.stories.tsx b/stories/sections/Stats.stories.tsx new file mode 100644 index 0000000..a1dcb24 --- /dev/null +++ b/stories/sections/Stats.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import Stats from "../../app/components/sections/Stats"; + +const meta: Meta = { + title: "Components/Sections/Stats", + component: Stats, + parameters: { + layout: "fullscreen", + backgrounds: { default: "dark" }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + titlePrefix: "From", + titleEmphasis: "projects", + titleSuffix: "to communities", + items: [ + { + value: "420M+", + label: "open source projects", + asOf: "as of June 30, 2024", + shapeVariant: "yellow", + }, + { + value: "27%", + label: "year over year growth in open source", + asOf: "as of June 30, 2024", + shapeVariant: "purple", + }, + ], + }, +}; diff --git a/stories/type/AboutHeader.stories.tsx b/stories/type/AboutHeader.stories.tsx new file mode 100644 index 0000000..dd20cab --- /dev/null +++ b/stories/type/AboutHeader.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import AboutHeader from "../../app/components/type/AboutHeader"; + +const meta: Meta = { + title: "Components/Type/AboutHeader", + component: AboutHeader, + parameters: { + layout: "fullscreen", + backgrounds: { default: "dark" }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + segments: [ + { type: "word", text: "CommunityRule" }, + { type: "icon", icon: "arrow" }, + { type: "word", text: "is" }, + { type: "word", text: "a" }, + { type: "word", text: "tool" }, + { type: "word", text: "that" }, + { type: "word", text: "helps" }, + { type: "word", text: "groups" }, + { type: "icon", icon: "about" }, + { type: "word", text: "define" }, + { type: "word", text: "who" }, + { type: "word", text: "they" }, + { type: "word", text: "want" }, + { type: "word", text: "to" }, + { type: "word", text: "be" }, + ], + }, +}; diff --git a/tests/components/cards/Stat.test.tsx b/tests/components/cards/Stat.test.tsx new file mode 100644 index 0000000..e059510 --- /dev/null +++ b/tests/components/cards/Stat.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import Stat from "../../../app/components/cards/Stat"; + +describe("Stat", () => { + it("renders value and label", () => { + render( + , + ); + + expect(screen.getByText("420M+")).toBeInTheDocument(); + expect(screen.getByText("open source projects")).toBeInTheDocument(); + expect(screen.getByText("as of June 30, 2024")).toBeInTheDocument(); + }); +}); diff --git a/tests/components/layout/Accordion.test.tsx b/tests/components/layout/Accordion.test.tsx new file mode 100644 index 0000000..f45d643 --- /dev/null +++ b/tests/components/layout/Accordion.test.tsx @@ -0,0 +1,22 @@ +import userEvent from "@testing-library/user-event"; +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import Accordion from "../../../app/components/layout/Accordion"; + +describe("Accordion", () => { + it("toggles panel content", async () => { + const user = userEvent.setup(); + + render( + + Answer copy + , + ); + + expect(screen.queryByText("Answer copy")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Question" })); + + expect(screen.getByText("Answer copy")).toBeInTheDocument(); + }); +}); diff --git a/tests/components/sections/Stats.test.tsx b/tests/components/sections/Stats.test.tsx new file mode 100644 index 0000000..2e14f68 --- /dev/null +++ b/tests/components/sections/Stats.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import Stats from "../../../app/components/sections/Stats"; + +describe("Stats", () => { + it("renders heading and stat cards", () => { + render( + , + ); + + expect( + screen.getByRole("heading", { name: /From projects to communities/i }), + ).toBeInTheDocument(); + expect(screen.getByText("27%")).toBeInTheDocument(); + }); +}); diff --git a/tests/components/type/AboutHeader.test.tsx b/tests/components/type/AboutHeader.test.tsx new file mode 100644 index 0000000..8b60162 --- /dev/null +++ b/tests/components/type/AboutHeader.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import AboutHeader from "../../../app/components/type/AboutHeader"; + +describe("AboutHeader", () => { + it("renders segmented headline", () => { + render( + , + ); + + expect( + screen.getByRole("heading", { name: /CommunityRule helps/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/tests/components/type/TripleTextBlock.test.tsx b/tests/components/type/TripleTextBlock.test.tsx new file mode 100644 index 0000000..0d101f1 --- /dev/null +++ b/tests/components/type/TripleTextBlock.test.tsx @@ -0,0 +1,51 @@ +import "@testing-library/jest-dom/vitest"; +import { describe, expect, it } from "vitest"; +import TripleTextBlock from "../../../app/components/type/TripleTextBlock"; +import { + renderWithProviders as render, + screen, +} from "../../utils/test-utils"; + +describe("TripleTextBlock", () => { + it("renders stacked and lg copy when lgTitle/lgDescription provided", () => { + render( + , + ); + + expect( + screen.getByRole("heading", { name: "Stacked headline" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Wide headline" }), + ).toBeInTheDocument(); + expect(screen.getByText("Long stacked body.")).toBeInTheDocument(); + expect(screen.getByText("Short wide body.")).toBeInTheDocument(); + }); + + it("renders a single column variant when lg fields omitted", () => { + render( + , + ); + + expect(screen.getAllByRole("heading", { name: "Only headline" })).toHaveLength( + 1, + ); + expect(screen.getByText("Only body.")).toBeInTheDocument(); + }); +}); diff --git a/tests/unit/QuoteBlock.test.jsx b/tests/unit/QuoteBlock.test.jsx index 2b000f4..911eb7a 100644 --- a/tests/unit/QuoteBlock.test.jsx +++ b/tests/unit/QuoteBlock.test.jsx @@ -222,4 +222,41 @@ describe("QuoteBlock Component", () => { screen.queryByText("The Tyranny of Structurelessness"), ).not.toBeInTheDocument(); }); + + test("statement variant renders dual paragraphs without attribution", () => { + render( + , + ); + + const region = screen.getByRole("region", { + name: /first paragraph of the statement/i, + }); + expect(region).toBeInTheDocument(); + expect( + screen.getByText("Second paragraph of the statement."), + ).toBeInTheDocument(); + expect(screen.queryByRole("cite")).not.toBeInTheDocument(); + }); + + test("statement variant logs when quoteSecondary is missing", () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + render( + , + ); + + expect(consoleSpy).toHaveBeenCalledWith( + "QuoteBlock: statement variant requires non-empty quote and quoteSecondary", + ); + + consoleSpy.mockRestore(); + }); }); diff --git a/tests/unit/organizerInquiryPostRoute.test.ts b/tests/unit/organizerInquiryPostRoute.test.ts index a2aeda1..a969532 100644 --- a/tests/unit/organizerInquiryPostRoute.test.ts +++ b/tests/unit/organizerInquiryPostRoute.test.ts @@ -9,11 +9,16 @@ vi.mock("../../lib/server/mail", () => ({ })); const rateLimitKeyMock = vi.hoisted(() => - vi.fn(() => ({ ok: true as const })), + vi.fn( + (_key: string, _minIntervalMs: number): { ok: true } | { ok: false; retryAfterMs: number } => ({ + ok: true, + }), + ), ); vi.mock("../../lib/server/rateLimit", () => ({ - rateLimitKey: (...args: unknown[]) => rateLimitKeyMock(...args), + rateLimitKey: (key: string, minIntervalMs: number) => + rateLimitKeyMock(key, minIntervalMs), })); import { POST } from "../../app/api/organizer-inquiry/route"; -- 2.43.0 From 450da4d8ab6ce996b3f963685099d5863e732c73 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Sun, 17 May 2026 21:41:54 -0600 Subject: [PATCH 2/8] Implement use cases page --- .../_components/MarketingRuleStackSection.tsx | 18 +- app/(marketing)/use-cases/page.tsx | 177 ++++++++++++++++++ app/components/asset/icon/Icon.tsx | 9 + .../asset/icon/numeric-1-circle.svg | 3 + .../asset/icon/numeric-2-circle.svg | 3 + .../asset/icon/numeric-3-circle.svg | 3 + .../cards/CaseStudy/CaseStudy.container.tsx | 16 ++ .../cards/CaseStudy/CaseStudy.types.ts | 16 ++ .../cards/CaseStudy/CaseStudy.view.tsx | 57 ++++++ app/components/cards/CaseStudy/index.tsx | 3 + app/components/cards/Icon/Icon.container.tsx | 10 +- app/components/cards/Icon/Icon.types.ts | 8 + app/components/cards/Icon/Icon.view.tsx | 33 ++-- app/components/cards/Rule/Rule.container.tsx | 2 + app/components/cards/Rule/Rule.types.ts | 5 + app/components/cards/Rule/Rule.view.tsx | 68 +++++-- app/components/navigation/Footer.tsx | 2 +- .../navigation/Top/Top.container.tsx | 9 +- app/components/navigation/Top/Top.types.ts | 6 +- .../AskOrganizer/AskOrganizer.container.tsx | 1 + .../AskOrganizer/AskOrganizer.view.tsx | 7 +- .../GovernanceTemplateGrid.tsx | 31 ++- .../GovernanceTemplateGridSkeleton.tsx | 24 ++- .../sections/Groups/Groups.container.tsx | 27 +++ .../sections/Groups/Groups.types.ts | 17 ++ .../sections/Groups/Groups.view.tsx | 44 +++++ app/components/sections/Groups/index.tsx | 2 + .../QuoteBlock/QuoteBlock.container.tsx | 11 +- .../sections/QuoteBlock/QuoteBlock.types.ts | 8 +- .../sections/QuoteBlock/QuoteBlock.view.tsx | 23 ++- .../QuoteBlock/QuoteStatementDecor.tsx | 2 +- .../RelatedArticles.container.tsx | 16 +- .../RelatedArticles/RelatedArticles.types.ts | 11 ++ .../RelatedArticles/RelatedArticles.view.tsx | 53 +++++- .../sections/RelatedArticles/index.tsx | 5 +- .../RuleStack/RuleStack.container.tsx | 4 +- .../sections/RuleStack/RuleStack.types.ts | 11 ++ .../sections/RuleStack/RuleStack.view.tsx | 13 +- .../UseCasesOrgs/UseCasesOrgs.container.tsx | 17 ++ .../UseCasesOrgs/UseCasesOrgs.types.ts | 8 + .../UseCasesOrgs/UseCasesOrgs.view.tsx | 21 +++ .../sections/UseCasesOrgs/index.tsx | 2 + .../ContentLockup/ContentLockup.container.tsx | 4 +- .../type/PageHeader/PageHeader.container.tsx | 46 +++++ .../type/PageHeader/PageHeader.types.ts | 24 +++ .../type/PageHeader/PageHeader.view.tsx | 106 +++++++++++ app/components/type/PageHeader/index.tsx | 2 + .../type/SectionHeader/SectionHeader.tsx | 18 +- .../type/TripleStep/TripleStep.container.tsx | 19 ++ .../type/TripleStep/TripleStep.types.ts | 16 ++ .../type/TripleStep/TripleStep.view.tsx | 95 ++++++++++ app/components/type/TripleStep/index.tsx | 2 + .../TripleTextBlock.container.tsx | 3 +- .../TripleTextBlock/TripleTextBlock.types.ts | 8 + .../TripleTextBlock/TripleTextBlock.view.tsx | 78 +++++++- docs/figma-component-registry.md | 9 +- messages/en/index.ts | 2 + messages/en/metadata.json | 10 + messages/en/pages/useCases.json | 90 +++++++++ .../case-study/case-study-mutual-aid.svg | 75 ++++++++ public/assets/shapes/triple-step.svg | 9 + stories/cards/CaseStudy.stories.js | 37 ++++ stories/cards/Icon.stories.js | 3 +- stories/sections/Groups.stories.js | 93 +++++++++ stories/sections/QuoteBlock.stories.js | 9 +- stories/sections/UseCasesOrgs.stories.js | 33 ++++ stories/type/PageHeader.stories.js | 43 +++++ stories/type/TripleStep.stories.js | 40 ++++ tests/components/Icon.test.tsx | 13 ++ tests/components/RelatedArticles.test.tsx | 23 ++- tests/components/cards/CaseStudy.test.tsx | 31 +++ tests/components/sections/Groups.test.tsx | 44 +++++ .../components/sections/UseCasesOrgs.test.tsx | 20 ++ tests/components/type/PageHeader.test.tsx | 72 +++++++ tests/components/type/TripleStep.test.tsx | 32 ++++ .../components/type/TripleTextBlock.test.tsx | 41 ++++ tests/unit/QuoteBlock.test.jsx | 6 +- tests/unit/RuleStack.test.jsx | 26 +++ 78 files changed, 1870 insertions(+), 118 deletions(-) create mode 100644 app/(marketing)/use-cases/page.tsx create mode 100644 app/components/asset/icon/numeric-1-circle.svg create mode 100644 app/components/asset/icon/numeric-2-circle.svg create mode 100644 app/components/asset/icon/numeric-3-circle.svg create mode 100644 app/components/cards/CaseStudy/CaseStudy.container.tsx create mode 100644 app/components/cards/CaseStudy/CaseStudy.types.ts create mode 100644 app/components/cards/CaseStudy/CaseStudy.view.tsx create mode 100644 app/components/cards/CaseStudy/index.tsx create mode 100644 app/components/sections/Groups/Groups.container.tsx create mode 100644 app/components/sections/Groups/Groups.types.ts create mode 100644 app/components/sections/Groups/Groups.view.tsx create mode 100644 app/components/sections/Groups/index.tsx create mode 100644 app/components/sections/UseCasesOrgs/UseCasesOrgs.container.tsx create mode 100644 app/components/sections/UseCasesOrgs/UseCasesOrgs.types.ts create mode 100644 app/components/sections/UseCasesOrgs/UseCasesOrgs.view.tsx create mode 100644 app/components/sections/UseCasesOrgs/index.tsx create mode 100644 app/components/type/PageHeader/PageHeader.container.tsx create mode 100644 app/components/type/PageHeader/PageHeader.types.ts create mode 100644 app/components/type/PageHeader/PageHeader.view.tsx create mode 100644 app/components/type/PageHeader/index.tsx create mode 100644 app/components/type/TripleStep/TripleStep.container.tsx create mode 100644 app/components/type/TripleStep/TripleStep.types.ts create mode 100644 app/components/type/TripleStep/TripleStep.view.tsx create mode 100644 app/components/type/TripleStep/index.tsx create mode 100644 messages/en/pages/useCases.json create mode 100644 public/assets/case-study/case-study-mutual-aid.svg create mode 100644 public/assets/shapes/triple-step.svg create mode 100644 stories/cards/CaseStudy.stories.js create mode 100644 stories/sections/Groups.stories.js create mode 100644 stories/sections/UseCasesOrgs.stories.js create mode 100644 stories/type/PageHeader.stories.js create mode 100644 stories/type/TripleStep.stories.js create mode 100644 tests/components/cards/CaseStudy.test.tsx create mode 100644 tests/components/sections/Groups.test.tsx create mode 100644 tests/components/sections/UseCasesOrgs.test.tsx create mode 100644 tests/components/type/PageHeader.test.tsx create mode 100644 tests/components/type/TripleStep.test.tsx diff --git a/app/(marketing)/_components/MarketingRuleStackSection.tsx b/app/(marketing)/_components/MarketingRuleStackSection.tsx index fb372bb..14f55a7 100644 --- a/app/(marketing)/_components/MarketingRuleStackSection.tsx +++ b/app/(marketing)/_components/MarketingRuleStackSection.tsx @@ -10,14 +10,28 @@ const RuleStack = dynamic(() => import("../../components/sections/RuleStack"), { ssr: true, }); +type MarketingRuleStackSectionProps = { + translationNamespace?: string; + twoColumnsFromMd?: boolean; +}; + /** * Server-loaded “Popular templates” row so the first paint has card data without a client fetch. */ -export async function MarketingRuleStackSection() { +export async function MarketingRuleStackSection({ + translationNamespace, + twoColumnsFromMd, +}: MarketingRuleStackSectionProps = {}) { const rows = await listRuleTemplatesFromDb(); const initialGridEntries = gridEntriesForSlugOrderWithCatalogFallback( rows, GOVERNANCE_TEMPLATE_HOME_SLUGS, ); - return ; + return ( + + ); } diff --git a/app/(marketing)/use-cases/page.tsx b/app/(marketing)/use-cases/page.tsx new file mode 100644 index 0000000..0d4d155 --- /dev/null +++ b/app/(marketing)/use-cases/page.tsx @@ -0,0 +1,177 @@ +import type { Metadata } from "next"; +import dynamic from "next/dynamic"; +import { Suspense } from "react"; +import messages from "../../../messages/en/index"; +import { getAllBlogPosts } from "../../../lib/content"; +import PageHeader from "../../components/type/PageHeader"; +import CaseStudy from "../../components/cards/CaseStudy"; +import UseCasesOrgs from "../../components/sections/UseCasesOrgs"; +import QuoteBlock from "../../components/sections/QuoteBlock"; +import Groups from "../../components/sections/Groups"; +import type { GroupsItem } from "../../components/sections/Groups"; +import TripleStep from "../../components/type/TripleStep"; +import TripleTextBlock from "../../components/type/TripleTextBlock"; +import type { TripleTextBlockColumn } from "../../components/type/TripleTextBlock"; +import AskOrganizer from "../../components/sections/AskOrganizer"; +import { MarketingRuleStackSection } from "../_components/MarketingRuleStackSection"; +import { getAssetPath, vectorMarkPath } from "../../../lib/assetUtils"; + +const RelatedArticles = dynamic( + () => import("../../components/sections/RelatedArticles"), + { + loading: () => ( +
+ ), + ssr: true, + }, +); + +function asArray(value: unknown): T[] { + return Array.isArray(value) ? value : []; +} + +/** Matches `pages.useCases.groups.items` order ↔ `public/assets/vector/*.svg`. */ +const USE_CASES_GROUP_VECTOR_SLUGS = [ + "worker-coop", + "mutual-aid", + "open-source", + "dao", +] as const; + +const USE_CASES_RELATED_SENTINEL_SLUG = "__use-cases-page__"; + +export async function generateMetadata(): Promise { + const title = messages.metadata.useCases.title; + const description = messages.metadata.useCases.description; + const keywords = messages.metadata.useCases.keywords; + + return { + title, + description, + keywords, + openGraph: { + title, + description, + type: "website", + siteName: "CommunityRule", + }, + }; +} + +export default function UseCasesPage() { + const page = messages.pages.useCases; + + const tripleColumns = asArray(page.tripleTextBlock.columns); + const groupItemsRaw = asArray<{ title: string; description: string }>( + page.groups.items, + ); + + const groupItems: GroupsItem[] = groupItemsRaw.map((item, index) => ({ + ...item, + icon: ( + /* eslint-disable-next-line @next/next/no-img-element -- small vector marks from `public/assets/vector` */ + + ), + })); + + const askOrganizerData = { + title: page.askOrganizer.title, + subtitle: page.askOrganizer.subtitle, + buttonText: page.askOrganizer.buttonText, + }; + + const allPosts = getAllBlogPosts(); + const relatedPosts = allPosts.slice(0, 8); + const slugOrder = allPosts.map((p) => p.slug); + + const tripleStepSteps = asArray<{ title: string; body: string }>( + page.tripleStep.steps, + ); + + return ( +
+ + + + + + + + + + + + + + +
+ + } + > + + +
+ + + +
+ +
+ +
+ +
+
+ ); +} diff --git a/app/components/asset/icon/Icon.tsx b/app/components/asset/icon/Icon.tsx index 2beb379..d2fa1bb 100644 --- a/app/components/asset/icon/Icon.tsx +++ b/app/components/asset/icon/Icon.tsx @@ -13,6 +13,9 @@ import ImageGlyphIcon from "./image.svg"; import LogOutIcon from "./log_out.svg"; import MailIcon from "./mail.svg"; import MarkdownCopyIcon from "./markdown_copy.svg"; +import Numeric1CircleIcon from "./numeric-1-circle.svg"; +import Numeric2CircleIcon from "./numeric-2-circle.svg"; +import Numeric3CircleIcon from "./numeric-3-circle.svg"; import NumberIcon from "./number.svg"; import PictureAsPdfIcon from "./picture_as_pdf.svg"; import TagsIcon from "./tags.svg"; @@ -31,6 +34,9 @@ export const ICON_NAME_OPTIONS = [ "log_out", "mail", "markdown_copy", + "numeric_1_circle", + "numeric_2_circle", + "numeric_3_circle", "number", "picture_as_pdf", "tags", @@ -57,6 +63,9 @@ const iconMap: Record = { log_out: LogOutIcon, mail: MailIcon, markdown_copy: MarkdownCopyIcon, + numeric_1_circle: Numeric1CircleIcon, + numeric_2_circle: Numeric2CircleIcon, + numeric_3_circle: Numeric3CircleIcon, number: NumberIcon, picture_as_pdf: PictureAsPdfIcon, tags: TagsIcon, diff --git a/app/components/asset/icon/numeric-1-circle.svg b/app/components/asset/icon/numeric-1-circle.svg new file mode 100644 index 0000000..bddae8f --- /dev/null +++ b/app/components/asset/icon/numeric-1-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/components/asset/icon/numeric-2-circle.svg b/app/components/asset/icon/numeric-2-circle.svg new file mode 100644 index 0000000..56ea809 --- /dev/null +++ b/app/components/asset/icon/numeric-2-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/components/asset/icon/numeric-3-circle.svg b/app/components/asset/icon/numeric-3-circle.svg new file mode 100644 index 0000000..6e4ebcc --- /dev/null +++ b/app/components/asset/icon/numeric-3-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/components/cards/CaseStudy/CaseStudy.container.tsx b/app/components/cards/CaseStudy/CaseStudy.container.tsx new file mode 100644 index 0000000..b753c8c --- /dev/null +++ b/app/components/cards/CaseStudy/CaseStudy.container.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { memo } from "react"; +import CaseStudyView from "./CaseStudy.view"; +import type { CaseStudyProps } from "./CaseStudy.types"; + +/** + * Figma: Section org lockup ([22112-871524](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22112-871524)): **Card / CaseStudy** — MAC vector (`assets/case-study/`), FNB/BCSM rasters (**21993‑32352** / **32353**). + */ +const CaseStudyContainer = memo((props) => { + return ; +}); + +CaseStudyContainer.displayName = "CaseStudy"; + +export default CaseStudyContainer; diff --git a/app/components/cards/CaseStudy/CaseStudy.types.ts b/app/components/cards/CaseStudy/CaseStudy.types.ts new file mode 100644 index 0000000..4a014b6 --- /dev/null +++ b/app/components/cards/CaseStudy/CaseStudy.types.ts @@ -0,0 +1,16 @@ +import type { ReactNode } from "react"; + +export const CASE_STUDY_SURFACE_OPTIONS = ["lavender", "neutral", "rose"] as const; + +export type CaseStudySurfaceValue = (typeof CASE_STUDY_SURFACE_OPTIONS)[number]; + +export interface CaseStudyProps { + surface: CaseStudySurfaceValue; + /** + * Alt text for built-in raster art (`public/assets/use-cases/`) when **`visual`** is omitted. + */ + imageAlt?: string; + /** Overrides built-in raster with custom slot content when provided. */ + visual?: ReactNode; + className?: string; +} diff --git a/app/components/cards/CaseStudy/CaseStudy.view.tsx b/app/components/cards/CaseStudy/CaseStudy.view.tsx new file mode 100644 index 0000000..b05663c --- /dev/null +++ b/app/components/cards/CaseStudy/CaseStudy.view.tsx @@ -0,0 +1,57 @@ +"use client"; + +import Image from "next/image"; +import { memo } from "react"; +import type { CaseStudyProps } from "./CaseStudy.types"; + +const SURFACE_CLASS: Record = { + lavender: "bg-[var(--color-surface-invert-brand-lavender)]", + neutral: "bg-[var(--color-surface-invert-secondary)]", + rose: "bg-[var(--color-surface-invert-brand-red)]", +}; + +/** Default art per tile: PNG composites (FNB/BCSM) or vector Mutual Aid logo. */ +const SURFACE_ART: Record = { + lavender: "/assets/case-study/case-study-mutual-aid.svg", + neutral: "/assets/use-cases/case-study-food-not-bombs.png", + rose: "/assets/use-cases/case-study-boulder-county-street-medics.png", +}; + +/** Figma: ~23px corner (“Card / CaseStudy” shells). */ +const CASE_TILE_RADIUS_CLASS = "rounded-[23.093px]"; + +function CaseStudyView({ + surface, + imageAlt = "", + visual, + className = "", +}: CaseStudyProps) { + return ( +
+ {visual ? ( +
{visual}
+ ) : ( + {imageAlt} + )} +
+ ); +} + +CaseStudyView.displayName = "CaseStudyView"; + +export default memo(CaseStudyView); diff --git a/app/components/cards/CaseStudy/index.tsx b/app/components/cards/CaseStudy/index.tsx new file mode 100644 index 0000000..b5efa5c --- /dev/null +++ b/app/components/cards/CaseStudy/index.tsx @@ -0,0 +1,3 @@ +export { default } from "./CaseStudy.container"; +export type { CaseStudyProps, CaseStudySurfaceValue } from "./CaseStudy.types"; +export { CASE_STUDY_SURFACE_OPTIONS } from "./CaseStudy.types"; diff --git a/app/components/cards/Icon/Icon.container.tsx b/app/components/cards/Icon/Icon.container.tsx index 442f4fb..0ae8718 100644 --- a/app/components/cards/Icon/Icon.container.tsx +++ b/app/components/cards/Icon/Icon.container.tsx @@ -1,16 +1,20 @@ "use client"; -import { memo } from "react"; +import { memo, useId } from "react"; import { IconView } from "./Icon.view"; import type { IconProps } from "./Icon.types"; const IconContainer = memo( - ({ icon, title, description, className = "", onClick }) => { + ({ icon, title, description, className = "", onClick, interactive: interactiveProp = true }) => { + const layoutTitleId = useId(); + const handleClick = () => { + if (!interactiveProp) return; if (onClick) onClick(); }; const handleKeyDown = (event: React.KeyboardEvent) => { + if (!interactiveProp) return; if (event.key === "Enter" || event.key === " ") { event.preventDefault(); handleClick(); @@ -23,6 +27,8 @@ const IconContainer = memo( title={title} description={description} className={className} + interactive={interactiveProp} + layoutTitleId={layoutTitleId} onClick={handleClick} onKeyDown={handleKeyDown} /> diff --git a/app/components/cards/Icon/Icon.types.ts b/app/components/cards/Icon/Icon.types.ts index 28f84f6..02ac529 100644 --- a/app/components/cards/Icon/Icon.types.ts +++ b/app/components/cards/Icon/Icon.types.ts @@ -4,6 +4,11 @@ export interface IconProps { description: string; className?: string; onClick?: () => void; + /** + * When false, renders a static tile (no button semantics or focus ring). + * @default true + */ + interactive?: boolean; } export interface IconViewProps { @@ -11,6 +16,9 @@ export interface IconViewProps { title: string; description: string; className: string; + interactive: boolean; + /** Stable id for `aria-labelledby` when `interactive` is false. */ + layoutTitleId: string; onClick: () => void; onKeyDown: (event: React.KeyboardEvent) => void; } diff --git a/app/components/cards/Icon/Icon.view.tsx b/app/components/cards/Icon/Icon.view.tsx index 7a2e1c0..c93c4ba 100644 --- a/app/components/cards/Icon/Icon.view.tsx +++ b/app/components/cards/Icon/Icon.view.tsx @@ -7,30 +7,41 @@ export function IconView({ title, description, className, + interactive, + layoutTitleId, onClick, onKeyDown, }: IconViewProps) { + const interactionClass = interactive + ? "cursor-pointer transition-all duration-200 hover:scale-[1.02] hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-brand-primary)] focus:ring-offset-2" + : "cursor-default"; + return (
{/* Icon */} -
+
{icon}
- {/* Title - Centered with auto space above and below */} -

+ {/* Title — Figma XX Large / Label (32 / 36) */} +

{title}

- {/* Description */} -

+ {/* Body: X Small / Paragraph (12/16) per Figma; 14/20 on md– {description}

diff --git a/app/components/cards/Rule/Rule.container.tsx b/app/components/cards/Rule/Rule.container.tsx index c206dbe..021ce98 100644 --- a/app/components/cards/Rule/Rule.container.tsx +++ b/app/components/cards/Rule/Rule.container.tsx @@ -43,6 +43,7 @@ const RuleContainer = memo( bottomStatusLabel, bottomLinks, recommended = false, + templateGridFigmaShell = false, }) => { const size = sizeProp ?? "L"; @@ -98,6 +99,7 @@ const RuleContainer = memo( bottomStatusLabel={bottomStatusLabel} bottomLinks={bottomLinks} recommended={recommended} + templateGridFigmaShell={templateGridFigmaShell} /> ); }, diff --git a/app/components/cards/Rule/Rule.types.ts b/app/components/cards/Rule/Rule.types.ts index 8040cf1..cfe37c4 100644 --- a/app/components/cards/Rule/Rule.types.ts +++ b/app/components/cards/Rule/Rule.types.ts @@ -66,6 +66,10 @@ export interface RuleProps { * `expanded` — Figma `22142:898446` compact `Card / Rule` only. */ recommended?: boolean; + /** + * 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; } export interface RuleViewProps { @@ -90,4 +94,5 @@ export interface RuleViewProps { bottomStatusLabel?: string; bottomLinks?: RuleBottomLink[]; recommended?: boolean; + templateGridFigmaShell?: boolean; } diff --git a/app/components/cards/Rule/Rule.view.tsx b/app/components/cards/Rule/Rule.view.tsx index ea91042..e2f4a8c 100644 --- a/app/components/cards/Rule/Rule.view.tsx +++ b/app/components/cards/Rule/Rule.view.tsx @@ -30,6 +30,7 @@ export function RuleView({ bottomStatusLabel, bottomLinks, recommended = false, + templateGridFigmaShell = false, }: RuleViewProps) { const t = useTranslation("ruleCard"); const ariaLabel = t("ariaLabel")?.replace("{title}", title) || title; @@ -84,7 +85,13 @@ export function RuleView({ // 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. const logoSize = 103; // `next/image` prop; actual box comes from `logoContainerClass` - const logoContainerClass = ` + const logoContainerClass = templateGridFigmaShell + ? ` + max-[639px]:size-[56px] + min-[640px]:max-[1023px]:size-[64px] + min-[1024px]:size-[88px] + ` + : ` max-[639px]:size-[56px] min-[640px]:max-[1023px]:size-[64px] min-[1024px]:max-[1439px]:size-[56px] @@ -93,22 +100,51 @@ export function RuleView({ // Title typography - use CSS responsive classes const showRecommendedTag = recommended && !expanded; - const titleClass = ` + const titleClass = templateGridFigmaShell + ? ` + max-[639px]:font-inter max-[639px]:font-bold max-[639px]:text-[20px] max-[639px]:leading-[28px] + min-[640px]:max-[1023px]:font-bricolage-grotesque min-[640px]:max-[1023px]:font-bold min-[640px]:max-[1023px]:text-[28px] min-[640px]:max-[1023px]:leading-[36px] + min-[1024px]:max-[1439px]:font-bricolage-grotesque min-[1024px]:max-[1439px]:font-extrabold min-[1024px]:max-[1439px]:text-[36px] min-[1024px]:max-[1439px]:leading-[44px] + min-[1440px]:font-bricolage-grotesque min-[1440px]:font-extrabold min-[1440px]:text-[36px] min-[1440px]:leading-[44px] + ` + : ` max-[639px]:font-inter max-[639px]:font-bold max-[639px]:text-[20px] max-[639px]:leading-[28px] min-[640px]:max-[1023px]:font-bricolage-grotesque min-[640px]:max-[1023px]:font-bold min-[640px]:max-[1023px]:text-[28px] min-[640px]:max-[1023px]:leading-[36px] min-[1024px]:max-[1439px]:font-bricolage-grotesque min-[1024px]:max-[1439px]:font-bold min-[1024px]:max-[1439px]:text-[24px] min-[1024px]:max-[1439px]:leading-[32px] min-[1440px]:font-bricolage-grotesque min-[1440px]:font-extrabold min-[1440px]:text-[36px] min-[1440px]:leading-[44px] `; - // Description typography const descriptionClass = isLarge ? "font-inter font-medium text-[18px] leading-[24px]" : isMedium - ? "font-inter font-medium text-[14px] leading-[16px]" + ? templateGridFigmaShell + ? "font-inter font-medium text-[14px] leading-[16px] min-[1024px]:max-[1439px]:text-[18px] min-[1024px]:max-[1439px]:leading-[24px]" + : "font-inter font-medium text-[14px] leading-[16px]" : isSmall ? "font-inter font-medium text-[14px] leading-[16px]" // S: 14px, medium, Inter : "font-inter font-medium text-[12px] leading-[14px]"; // XS: 12px, medium, Inter + const headerIconCellClass = templateGridFigmaShell + ? ` + flex shrink-0 items-center justify-center + pl-[4px] pr-[8px] py-[8px] + max-[639px]:w-[72px] + min-[640px]:max-[1023px]:w-[80px] + min-[1024px]:max-[1439px]:w-[130px] + min-[1440px]:w-[119px] + ` + : ` + flex shrink-0 items-center justify-center + pl-[4px] pr-[8px] py-[8px] + max-[639px]:w-[72px] + min-[640px]:max-[1023px]:w-[80px] + min-[1024px]:w-[119px] + `; + + const titleColumnMinHClass = templateGridFigmaShell + ? "min-h-[72px] min-[640px]:min-h-[80px] min-[1024px]:max-[1439px]:min-h-[136px] min-[1440px]:min-h-[136px]" + : "min-h-[72px] min-[640px]:min-h-[80px] min-[1024px]:min-h-[88px] min-[1440px]:min-h-[136px]"; + // Render logo/icon const renderLogo = () => { if (logoUrl) { @@ -236,15 +272,7 @@ export function RuleView({ " > {renderLogo() && ( -
+
{renderLogo()}
)} @@ -252,7 +280,7 @@ export function RuleView({
@@ -410,9 +438,17 @@ export function RuleView({ ) : ( /* Collapsed State: Description */ description && ( -
+

{description}

diff --git a/app/components/navigation/Footer.tsx b/app/components/navigation/Footer.tsx index 237470f..d246074 100644 --- a/app/components/navigation/Footer.tsx +++ b/app/components/navigation/Footer.tsx @@ -137,7 +137,7 @@ const Footer = memo(() => { md:gap-[var(--spacing-scale-032)]" > {t("navigation.useCases")} diff --git a/app/components/navigation/Top/Top.container.tsx b/app/components/navigation/Top/Top.container.tsx index 87aad64..d20f57a 100644 --- a/app/components/navigation/Top/Top.container.tsx +++ b/app/components/navigation/Top/Top.container.tsx @@ -17,14 +17,9 @@ type MenuClusterSize = "X Small" | "Small" | "Medium" | "Large" | "X Large"; /** Map responsive `NavSize` breakpoints to Figma menu item sizes (shared by nav links + login). */ const NAV_SIZE_TO_MENU_ITEM_SIZE: Record = { - default: "Small", xsmall: "X Small", - xsmallUseCases: "X Small", - home: "X Small", homeMd: "Medium", - homeUseCases: "Small", large: "Large", - largeUseCases: "Large", homeXlarge: "X Large", xlarge: "X Large", }; @@ -77,7 +72,7 @@ const TopContainer = memo( // Navigation items with translations const navigationItems = [ - { href: "#", text: t("navigation.useCases"), extraPadding: true }, + { href: "/use-cases", text: t("navigation.useCases"), extraPadding: true }, { href: "/learn", text: t("navigation.learn") }, { href: "/about", text: t("navigation.about") }, ]; @@ -134,7 +129,7 @@ const TopContainer = memo( // folderTop: inverse mode (black text) for smallest breakpoints (xsmall/home) // folderTop: default mode (yellow text) for 640px+ breakpoints (homeMd/large/homeXlarge/xlarge) // false folderTop: always default mode (yellow text on dark background) - const isSmallBreakpoint = size === "xsmall" || size === "home"; + const isSmallBreakpoint = size === "xsmall"; const mode = folderTop && isSmallBreakpoint ? "inverse" : "default"; const label = loggedIn ? t("buttons.profile") : t("buttons.logIn"); diff --git a/app/components/navigation/Top/Top.types.ts b/app/components/navigation/Top/Top.types.ts index c394f4f..dffc63a 100644 --- a/app/components/navigation/Top/Top.types.ts +++ b/app/components/navigation/Top/Top.types.ts @@ -9,15 +9,11 @@ export interface TopProps { logIn?: boolean; } +/** Breakpoint slot passed from {@link Top.view} into nav render helpers. */ export type NavSize = - | "default" | "xsmall" - | "xsmallUseCases" - | "home" | "homeMd" - | "homeUseCases" | "large" - | "largeUseCases" | "homeXlarge" | "xlarge"; diff --git a/app/components/sections/AskOrganizer/AskOrganizer.container.tsx b/app/components/sections/AskOrganizer/AskOrganizer.container.tsx index 9d3a8a3..30489ed 100644 --- a/app/components/sections/AskOrganizer/AskOrganizer.container.tsx +++ b/app/components/sections/AskOrganizer/AskOrganizer.container.tsx @@ -32,6 +32,7 @@ const VARIANT_STYLES: Record< }, }; +/** Figma **Section/AskOrganizer** [18116:15960](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=18116-15960&m=dev) (`lg` shell + type + button). */ const AskOrganizerContainer = memo( ({ title, diff --git a/app/components/sections/AskOrganizer/AskOrganizer.view.tsx b/app/components/sections/AskOrganizer/AskOrganizer.view.tsx index 09430d4..0578bea 100644 --- a/app/components/sections/AskOrganizer/AskOrganizer.view.tsx +++ b/app/components/sections/AskOrganizer/AskOrganizer.view.tsx @@ -28,6 +28,7 @@ function AskOrganizerView({ aria-labelledby={labelledBy} aria-label={labelledBy ? undefined : ariaLabel} tabIndex={-1} + data-figma-node="18116-15960" >
{/* Content Lockup */} @@ -41,13 +42,15 @@ function AskOrganizerView({ /> {/* Button */} -
+
+
+ ) : null} +
+
+ ); +} + +PageHeaderView.displayName = "PageHeaderView"; + +export default memo(PageHeaderView); diff --git a/app/components/type/PageHeader/index.tsx b/app/components/type/PageHeader/index.tsx new file mode 100644 index 0000000..43a318c --- /dev/null +++ b/app/components/type/PageHeader/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./PageHeader.container"; +export type { PageHeaderProps } from "./PageHeader.types"; diff --git a/app/components/type/SectionHeader/SectionHeader.tsx b/app/components/type/SectionHeader/SectionHeader.tsx index 948e63e..abd1eab 100644 --- a/app/components/type/SectionHeader/SectionHeader.tsx +++ b/app/components/type/SectionHeader/SectionHeader.tsx @@ -10,6 +10,11 @@ interface SectionHeaderProps { variant?: SectionHeaderVariantValue; /** When set with `variant="multi-line"`, large screens show three title lines (Figma SectionCardSteps). */ stackedDesktopLines?: readonly [string, string, string]; + /** + * With `variant="multi-line"`, keep **Rule stack** desktop type: title **32/40** at `lg`, **40/52** at `xl`; + * subtitle **18 / 1.3** at `lg`, **24/32** at `xl`, **left-aligned** in its column from `lg` (Figma **22085:860413**). + */ + ruleStackDesktopTypeScale?: boolean; } /** @@ -23,6 +28,7 @@ const SectionHeader = memo( titleLg, variant: variantProp = "default", stackedDesktopLines, + ruleStackDesktopTypeScale = false, }) => { const variant = variantProp; const useStackedDesktop = @@ -47,7 +53,9 @@ const SectionHeader = memo(

@@ -68,14 +76,18 @@ const SectionHeader = memo(

diff --git a/app/components/type/TripleStep/TripleStep.container.tsx b/app/components/type/TripleStep/TripleStep.container.tsx new file mode 100644 index 0000000..4a958dc --- /dev/null +++ b/app/components/type/TripleStep/TripleStep.container.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { memo, useId } from "react"; +import TripleStepView from "./TripleStep.view"; +import type { TripleStepProps } from "./TripleStep.types"; + +/** + * Figma: **Section / Triple Step** ([22084-859405](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22084-859405&m=dev)); type baseline ([22112-871527](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22112-871527&m=dev)); **md+** two-column + **`triple-step.svg`**. + */ +const TripleStepContainer = memo((props) => { + const reactId = useId(); + const headingId = `${reactId}-triple-step-heading`; + + return ; +}); + +TripleStepContainer.displayName = "TripleStep"; + +export default TripleStepContainer; diff --git a/app/components/type/TripleStep/TripleStep.types.ts b/app/components/type/TripleStep/TripleStep.types.ts new file mode 100644 index 0000000..4488875 --- /dev/null +++ b/app/components/type/TripleStep/TripleStep.types.ts @@ -0,0 +1,16 @@ +export interface TripleStepStep { + title: string; + body: string; +} + +export interface TripleStepProps { + heading: string; + steps: TripleStepStep[]; + ctaText: string; + ctaHref: string; + className?: string; +} + +export interface TripleStepViewProps extends TripleStepProps { + headingId: string; +} diff --git a/app/components/type/TripleStep/TripleStep.view.tsx b/app/components/type/TripleStep/TripleStep.view.tsx new file mode 100644 index 0000000..ecd9842 --- /dev/null +++ b/app/components/type/TripleStep/TripleStep.view.tsx @@ -0,0 +1,95 @@ +"use client"; + +import Image from "next/image"; +import { memo } from "react"; +import { getAssetPath } from "../../../../lib/assetUtils"; +import AssetIcon from "../../asset/icon"; +import Button from "../../buttons/Button"; +import type { TripleStepViewProps } from "./TripleStep.types"; + +const TRIPLE_STEP_NUMERIC_ICONS = [ + "numeric_1_circle", + "numeric_2_circle", + "numeric_3_circle", +] as const; + +function TripleStepView({ + heading, + steps, + ctaText, + ctaHref, + headingId, + className = "", +}: TripleStepViewProps) { + /** Decorative column art — `public/assets/shapes/triple-step.svg` (288×576 viewBox). */ + const shapeSrc = getAssetPath("assets/shapes/triple-step.svg"); + + return ( +

+
+
+

+ {heading} +

+ {steps.map((step, index) => ( +
+ +
+

+ {step.title} +

+

+ {step.body} +

+
+
+ ))} +
+ +
+
+
+ +
+
+
+ ); +} + +TripleStepView.displayName = "TripleStepView"; + +export default memo(TripleStepView); diff --git a/app/components/type/TripleStep/index.tsx b/app/components/type/TripleStep/index.tsx new file mode 100644 index 0000000..83e6b12 --- /dev/null +++ b/app/components/type/TripleStep/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./TripleStep.container"; +export type { TripleStepProps, TripleStepStep } from "./TripleStep.types"; diff --git a/app/components/type/TripleTextBlock/TripleTextBlock.container.tsx b/app/components/type/TripleTextBlock/TripleTextBlock.container.tsx index 17717c2..45c248e 100644 --- a/app/components/type/TripleTextBlock/TripleTextBlock.container.tsx +++ b/app/components/type/TripleTextBlock/TripleTextBlock.container.tsx @@ -5,7 +5,8 @@ import TripleTextBlockView from "./TripleTextBlock.view"; import type { TripleTextBlockProps } from "./TripleTextBlock.types"; /** - * Figma: "Type / TripleTextBlock" stacked 22137:890676; lg 22128:888715; xl 22135:889705. + * Figma: "Type / TripleTextBlock" — use cases **`lg` 22037-26994**, **`xl` 22085-860414**; + * **`md` 22085-862437**; stacked 22137:890676; lg 22128:888715; xl 22135:889705 (default). */ const TripleTextBlockContainer = memo((props) => { const headingId = useId(); diff --git a/app/components/type/TripleTextBlock/TripleTextBlock.types.ts b/app/components/type/TripleTextBlock/TripleTextBlock.types.ts index 1f085fb..aa4d279 100644 --- a/app/components/type/TripleTextBlock/TripleTextBlock.types.ts +++ b/app/components/type/TripleTextBlock/TripleTextBlock.types.ts @@ -1,6 +1,8 @@ export interface TripleTextBlockColumn { title: string; description: string; + /** Optional second paragraph under `description` (e.g. use cases baseline multi-paragraph lockup). */ + descriptionSecondary?: string; /** * lg+ three-column layout (Figma 22128:888715). When either `lgTitle` or `lgDescription` * is set, stacked breakpoints use `title`/`description` and lg uses these (missing side falls back). @@ -16,6 +18,12 @@ export interface TripleTextBlockProps { ctaText?: string; ctaHref?: string; className?: string; + /** + * `useCases`: Figma use cases TripleText **`lg`** ([22037-26994](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22037-26994&m=dev)); + * **`xl`** ([22085-860414](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-860414&m=dev)); + * `md` ([22085-862437](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-862437&m=dev)); lg 3-col **22128-888715**. + */ + layoutPreset?: "default" | "useCases"; } export interface TripleTextBlockViewProps extends TripleTextBlockProps { diff --git a/app/components/type/TripleTextBlock/TripleTextBlock.view.tsx b/app/components/type/TripleTextBlock/TripleTextBlock.view.tsx index 011b078..5e11a3b 100644 --- a/app/components/type/TripleTextBlock/TripleTextBlock.view.tsx +++ b/app/components/type/TripleTextBlock/TripleTextBlock.view.tsx @@ -12,11 +12,35 @@ function columnUsesLargeBreakpointCopy(column: TripleTextBlockColumn): boolean { return column.lgTitle !== undefined || column.lgDescription !== undefined; } +function TripleTextUseCasesColumn({ column }: { column: TripleTextBlockColumn }) { + return ( +
+
+

+ {column.title} +

+
+

{column.description}

+ {column.descriptionSecondary ? ( +

{column.descriptionSecondary}

+ ) : null} +
+
+
+ ); +} + function TripleTextBlockColumnLockup({ column, + layoutPreset, }: { column: TripleTextBlockColumn; + layoutPreset: "default" | "useCases"; }) { + if (layoutPreset === "useCases") { + return ; + } + const dual = columnUsesLargeBreakpointCopy(column); const lgSubtitle = column.lgTitle ?? column.title; const lgBody = column.lgDescription ?? column.description; @@ -55,7 +79,11 @@ function TripleTextBlockColumnLockup({ } /** - * Figma: "Type / TripleTextBlock" stacked **22137:890676**; lg 3-col **22128-888715**; xl typography + horizontal inset scale/160 **22135:889705** (Subtitle 32 Small/Display, Body X Large/Paragraph 24 / lh 32; section px scale/160, py scale/064). + * Section horizontal padding adds **+ Scale/096** below `xl` (outer frame inset); **use cases `xl`** uses **Scale/160** only ([22085:860414](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-860414&m=dev)). + * + * Figma: use cases **`lg`** [22037:26994](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22037-26994&m=dev); + * **`md`** [22085:862437](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-862437&m=dev); stacked **22137:890676**; + * lg 3-col **22128:888715**; xl **22135:889705** (default preset). */ function TripleTextBlockView({ title = "", @@ -64,39 +92,71 @@ function TripleTextBlockView({ ctaHref, headingId, className = "", + layoutPreset = "default", }: TripleTextBlockViewProps) { const sectionTitle = title.trim(); const hasSectionTitle = sectionTitle.length > 0; + const isUseCases = layoutPreset === "useCases"; return (
-
+
{hasSectionTitle ? (

{sectionTitle}

) : null} -
+
{columns.map((column, index) => (
- +
))}
{ctaText ? ( -
+
} + title="Static Title" + description="Static Description" + />, + ); + expect(screen.getByRole("article")).toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); }); diff --git a/tests/components/RelatedArticles.test.tsx b/tests/components/RelatedArticles.test.tsx index a0ecf58..5c0d470 100644 --- a/tests/components/RelatedArticles.test.tsx +++ b/tests/components/RelatedArticles.test.tsx @@ -1,8 +1,11 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; import { describe, it, expect, vi } from "vitest"; import RelatedArticles from "../../app/components/sections/RelatedArticles"; import type { BlogPost } from "../../lib/content"; +import { + renderWithProviders as render, + screen, +} from "../utils/test-utils"; vi.mock("next/link", () => ({ default: ({ @@ -63,7 +66,7 @@ const mockPosts: BlogPost[] = [ }, ]; -// Pure presentational; no provider context needed (mocked thumbnail + useIsMobile). +// MessagesProvider required — container uses useMessages(). describe("RelatedArticles", () => { it("renders without crashing", () => { render( @@ -86,4 +89,20 @@ describe("RelatedArticles", () => { expect(screen.queryByTestId("thumbnail-article-1")).not.toBeInTheDocument(); expect(screen.getByTestId("thumbnail-article-2")).toBeInTheDocument(); }); + + it("useCases variant shows localized stacked title", () => { + render( + , + ); + expect( + screen.getByRole("heading", { + level: 2, + name: /Tools to set your group up for success/, + }), + ).toBeInTheDocument(); + }); }); diff --git a/tests/components/cards/CaseStudy.test.tsx b/tests/components/cards/CaseStudy.test.tsx new file mode 100644 index 0000000..522fc12 --- /dev/null +++ b/tests/components/cards/CaseStudy.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import CaseStudy from "../../../app/components/cards/CaseStudy"; + +describe("CaseStudy", () => { + it("renders tile container", () => { + const { container } = render( + , + ); + expect(container.querySelector('[data-figma-node="21993-32352"]')).toBeTruthy(); + }); + + it("renders built-in raster when visual is omitted (neutral)", () => { + render( + , + ); + + expect( + screen.getByRole("img", { name: "Food Not Bombs logo" }), + ).toHaveAttribute("src"); + }); + + it("uses Mutual Aid vector on lavender surface", () => { + const { container } = render( + , + ); + expect(container.querySelector("img")?.getAttribute("src")).toContain( + "case-study-mutual-aid.svg", + ); + }); +}); diff --git a/tests/components/sections/Groups.test.tsx b/tests/components/sections/Groups.test.tsx new file mode 100644 index 0000000..7aa78f3 --- /dev/null +++ b/tests/components/sections/Groups.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import Groups from "../../../app/components/sections/Groups"; + +describe("Groups", () => { + it("renders a static icon tile grid", () => { + const { container } = render( + a, + title: "One", + description: "First description text.", + }, + { + icon: b, + title: "Two", + description: "Second description text.", + }, + { + icon: c, + title: "Three", + description: "Third description text.", + }, + { + icon: d, + title: "Four", + description: "Fourth description text.", + }, + ]} + />, + ); + + expect( + screen.getByRole("heading", { level: 2, name: "Who is this for?" }), + ).toBeInTheDocument(); + expect(screen.getAllByRole("article")).toHaveLength(4); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + expect( + container.querySelector('[data-figma-node="22085-860411"]'), + ).toBeTruthy(); + }); +}); diff --git a/tests/components/sections/UseCasesOrgs.test.tsx b/tests/components/sections/UseCasesOrgs.test.tsx new file mode 100644 index 0000000..dd7cb40 --- /dev/null +++ b/tests/components/sections/UseCasesOrgs.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import UseCasesOrgs from "../../../app/components/sections/UseCasesOrgs"; + +describe("UseCasesOrgs", () => { + it("renders children", () => { + const { container } = render( + +
Child A
+
Child B
+
, + ); + + expect(screen.getByText("Child A")).toBeInTheDocument(); + expect(screen.getByText("Child B")).toBeInTheDocument(); + expect( + container.querySelector('[data-figma-node="21993-33687"]'), + ).toBeTruthy(); + }); +}); diff --git a/tests/components/type/PageHeader.test.tsx b/tests/components/type/PageHeader.test.tsx new file mode 100644 index 0000000..4396c1e --- /dev/null +++ b/tests/components/type/PageHeader.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import PageHeader from "../../../app/components/type/PageHeader"; + +describe("PageHeader", () => { + it("renders title and description", () => { + render( + , + ); + + expect( + screen.getByRole("heading", { level: 1, name: "Test title" }), + ).toBeInTheDocument(); + expect(screen.getByText("Test description body.")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Go" })).toHaveAttribute( + "href", + "/create", + ); + }); + + it("omits CTA when ctaText is absent", () => { + render( + , + ); + + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); + + it("omits description when omitted and renders stacked centered title lines", () => { + render( + , + ); + + const heading = screen.getByRole("heading", { level: 1 }); + expect(heading).toHaveTextContent(/See how groups useCommunityRule/); + expect( + heading.querySelectorAll("span.block"), + ).toHaveLength(2); + expect(screen.queryByRole("paragraph")).not.toBeInTheDocument(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); + + it("renders use-cases lg single-line title segments when singleLineTitleFromLg", () => { + render( + , + ); + + const heading = screen.getByRole("heading", { level: 1 }); + expect(heading).toHaveTextContent(/See how groups use CommunityRule/); + expect( + heading.querySelectorAll("span.block.lg\\:inline"), + ).toHaveLength(2); + expect(heading.closest("section")).toHaveAttribute( + "data-figma-node", + "21004-24825", + ); + }); +}); diff --git a/tests/components/type/TripleStep.test.tsx b/tests/components/type/TripleStep.test.tsx new file mode 100644 index 0000000..9335b41 --- /dev/null +++ b/tests/components/type/TripleStep.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import TripleStep from "../../../app/components/type/TripleStep"; + +describe("TripleStep", () => { + it("renders heading, steps, and CTA", () => { + render( + , + ); + + expect( + screen.getByRole("heading", { level: 2, name: "Get organized" }), + ).toBeInTheDocument(); + expect( + document.querySelector('[data-figma-node="22084-859405"]'), + ).toBeTruthy(); + expect(screen.getByText("Step one")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Create Rule" })).toHaveAttribute( + "href", + "/create", + ); + }); +}); diff --git a/tests/components/type/TripleTextBlock.test.tsx b/tests/components/type/TripleTextBlock.test.tsx index 0d101f1..5560a48 100644 --- a/tests/components/type/TripleTextBlock.test.tsx +++ b/tests/components/type/TripleTextBlock.test.tsx @@ -48,4 +48,45 @@ describe("TripleTextBlock", () => { ); expect(screen.getByText("Only body.")).toBeInTheDocument(); }); + + it("useCases preset renders persistent section heading, column h3 titles, dual paragraphs, outline CTA", () => { + const { container } = render( + , + ); + + expect( + container.querySelector('[data-figma-node="22085-860414"]'), + ).toBeTruthy(); + + expect( + screen.getByRole("heading", { + level: 2, + name: "Why Horizontal groups need CommunityRule", + }), + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { + level: 3, + name: "Share Leadership", + }), + ).toBeInTheDocument(); + expect(screen.getByText("First paragraph.")).toBeInTheDocument(); + expect(screen.getByText("Second paragraph.")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Setup your community" })).toHaveAttribute( + "href", + "/create", + ); + }); }); diff --git a/tests/unit/QuoteBlock.test.jsx b/tests/unit/QuoteBlock.test.jsx index 911eb7a..555687a 100644 --- a/tests/unit/QuoteBlock.test.jsx +++ b/tests/unit/QuoteBlock.test.jsx @@ -223,7 +223,7 @@ describe("QuoteBlock Component", () => { ).not.toBeInTheDocument(); }); - test("statement variant renders dual paragraphs without attribution", () => { + test("statement variant uses one paragraph with responsive stack (Figma 21967-24638)", () => { render( { name: /first paragraph of the statement/i, }); expect(region).toBeInTheDocument(); + expect(region).toHaveAttribute("data-figma-node", "21967-24638"); expect( screen.getByText("Second paragraph of the statement."), ).toBeInTheDocument(); expect(screen.queryByRole("cite")).not.toBeInTheDocument(); + + const heading = region.querySelector("#about-test-quote-content"); + expect(heading?.querySelectorAll("span.block.lg\\:inline").length).toBe(2); }); test("statement variant logs when quoteSecondary is missing", () => { diff --git a/tests/unit/RuleStack.test.jsx b/tests/unit/RuleStack.test.jsx index 1f8c537..74fa516 100644 --- a/tests/unit/RuleStack.test.jsx +++ b/tests/unit/RuleStack.test.jsx @@ -74,6 +74,25 @@ describe("RuleStack Component", () => { expect(fetchMock.mock.calls.length).toBe(callsBefore); }); + test("uses translationNamespace for section heading copy", () => { + render( + , + ); + const heading = screen.getByRole("heading", { level: 2 }); + expect(heading.textContent).toMatch( + /Get Templates that help your community run smoothly/, + ); + }); + + test("defaults to home rule stack heading copy when namespace omitted", () => { + render(); + const heading = screen.getByRole("heading", { level: 2 }); + expect(heading.textContent).toMatch(/Popular templates/); + }); + test("renders four featured governance template cards on the home row", async () => { render(); await waitForRuleStackCards(); @@ -166,6 +185,7 @@ describe("RuleStack Component", () => { await waitForRuleStackCards(); const section = document.querySelector("section"); + expect(section).toHaveAttribute("data-figma-node", "22085-860413"); expect(section).toHaveClass("px-[20px]", "py-[32px]"); expect(section?.className).toMatch(/min-\[640px\]:px-\[32px\]/); expect(section?.className).toMatch(/min-\[640px\]:py-\[48px\]/); @@ -281,6 +301,12 @@ describe("RuleStack Component", () => { expect(circlesIcon?.className).toMatch( /min-\[640px\]:max-\[1023px\]:h-\[56px\]/, ); + expect(circlesIcon?.className).toMatch( + /min-\[1024px\]:max-\[1439px\]:w-\[90px\]/, + ); + expect(circlesIcon?.className).toMatch( + /min-\[1024px\]:max-\[1439px\]:h-\[90px\]/, + ); expect(circlesIcon?.className).toMatch(/min-\[1440px\]:w-\[90px\]/); expect(circlesIcon?.className).toMatch(/min-\[1440px\]:h-\[90px\]/); }); -- 2.43.0 From 40ce5064d6ca496368c9936bc242c58a75f79642 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Sun, 17 May 2026 22:40:06 -0600 Subject: [PATCH 3/8] Implement how it works page --- .../HowItWorksDecorativeShapes.tsx | 43 ++++++ app/(marketing)/how-it-works/page.tsx | 132 ++++++++++++++++++ app/(marketing)/page.tsx | 1 + .../ContentContainer.container.tsx | 13 +- .../ContentContainer.types.ts | 5 + .../ContentContainer.view.tsx | 3 +- .../CardSteps/CardSteps.container.tsx | 3 +- .../sections/CardSteps/CardSteps.types.ts | 2 + .../sections/CardSteps/CardSteps.view.tsx | 8 +- app/components/sections/ContentBanner.tsx | 84 ----------- .../ContentBanner/ContentBanner.container.tsx | 56 ++++++++ .../ContentBanner/ContentBanner.types.ts | 24 ++++ .../ContentBanner/ContentBanner.view.tsx | 128 +++++++++++++++++ .../sections/ContentBanner/index.tsx | 5 + .../RelatedArticles.container.tsx | 4 + .../RelatedArticles/RelatedArticles.types.ts | 9 ++ .../RelatedArticles/RelatedArticles.view.tsx | 12 +- .../ContentLockup/ContentLockup.container.tsx | 2 + .../type/ContentLockup/ContentLockup.view.tsx | 4 + lib/assetUtils.ts | 19 +++ lib/howItWorksSyntheticPost.ts | 31 ++++ messages/en/components/cardSteps.json | 3 +- messages/en/index.ts | 2 + messages/en/metadata.json | 10 ++ messages/en/pages/home.json | 2 +- messages/en/pages/howItWorks.json | 12 ++ .../assets/shapes/guide-banner-logo-arrow.svg | 3 + public/assets/shapes/how-shape-1.svg | 9 ++ public/assets/shapes/how-shape-2.svg | 9 ++ stories/sections/ContentBanner.stories.js | 40 +++++- tests/components/ContentBanner.test.tsx | 22 +++ tests/e2e/critical-journeys.spec.ts | 10 +- tests/e2e/edge-cases.spec.ts | 50 +++---- tests/pages/home.test.jsx | 4 + tests/pages/how-it-works.test.jsx | 66 +++++++++ 35 files changed, 707 insertions(+), 123 deletions(-) create mode 100644 app/(marketing)/how-it-works/_components/HowItWorksDecorativeShapes.tsx create mode 100644 app/(marketing)/how-it-works/page.tsx delete mode 100644 app/components/sections/ContentBanner.tsx create mode 100644 app/components/sections/ContentBanner/ContentBanner.container.tsx create mode 100644 app/components/sections/ContentBanner/ContentBanner.types.ts create mode 100644 app/components/sections/ContentBanner/ContentBanner.view.tsx create mode 100644 app/components/sections/ContentBanner/index.tsx create mode 100644 lib/howItWorksSyntheticPost.ts create mode 100644 messages/en/pages/howItWorks.json create mode 100644 public/assets/shapes/guide-banner-logo-arrow.svg create mode 100644 public/assets/shapes/how-shape-1.svg create mode 100644 public/assets/shapes/how-shape-2.svg create mode 100644 tests/pages/how-it-works.test.jsx diff --git a/app/(marketing)/how-it-works/_components/HowItWorksDecorativeShapes.tsx b/app/(marketing)/how-it-works/_components/HowItWorksDecorativeShapes.tsx new file mode 100644 index 0000000..be24187 --- /dev/null +++ b/app/(marketing)/how-it-works/_components/HowItWorksDecorativeShapes.tsx @@ -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 ( + <> +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ + ); +} diff --git a/app/(marketing)/how-it-works/page.tsx b/app/(marketing)/how-it-works/page.tsx new file mode 100644 index 0000000..b565907 --- /dev/null +++ b/app/(marketing)/how-it-works/page.tsx @@ -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: () => ( +
+ ), + ssr: true, + }, +); + +export async function generateMetadata(): Promise { + 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 ( + <> +