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\]/); });