Implement about page

This commit is contained in:
adilallo
2026-05-13 23:08:36 -06:00
parent d2dfa099a2
commit b6b9b63608
69 changed files with 1834 additions and 28 deletions
+73
View File
@@ -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<T>(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<AboutHeaderSegment>(page.aboutHeader.segments);
const statsItems = asArray<StatItem>(page.stats.items);
const statsAsOf =
typeof page.stats.asOf === "string"
? page.stats.asOf
: String(page.stats.asOf ?? "");
const faqItems = asArray<FaqAccordionItem>(page.faq.items);
const tripleColumns = asArray<TripleTextBlockColumn>(page.tripleTextBlock.columns);
const askOrganizerData = {
title: t("pages.home.askOrganizer.title"),
subtitle: t("pages.home.askOrganizer.subtitle"),
buttonText: t("pages.home.askOrganizer.buttonText"),
};
return (
<div className="min-h-screen bg-black">
<AboutHeader segments={headerSegments} />
<Stats
titlePrefix={page.stats.titlePrefix}
titleEmphasis={page.stats.titleEmphasis}
titleSuffix={page.stats.titleSuffix}
items={statsItems.map((item) => ({
...item,
asOf: statsAsOf,
}))}
/>
<TripleTextBlock columns={tripleColumns} />
<Book
title={page.book.title}
description={page.book.description}
buttonText={page.book.buttonText}
buttonHref={page.book.buttonHref}
imageAlt={page.book.imageAlt}
/>
<FaqAccordion title={page.faq.title} items={faqItems} />
<QuoteBlock
variant="statement"
id="about-statement-quote"
quote={page.quote.paragraph1}
quoteSecondary={page.quote.paragraph2}
/>
<section className="bg-[var(--color-surface-default-primary)]">
<AskOrganizer {...askOrganizerData} />
</section>
</div>
);
}
@@ -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<ShapesProps>((props) => {
return <ShapesView {...props} />;
});
ShapesContainer.displayName = "Shapes";
export default ShapesContainer;
@@ -0,0 +1,6 @@
export type StatShapeVariant = "yellow" | "purple" | "green" | "orange";
export interface ShapesProps {
variant?: StatShapeVariant;
className?: string;
}
@@ -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<StatShapeVariant, 1 | 2 | 3 | 4> = {
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 */
<img
src={src}
alt=""
aria-hidden
className={`pointer-events-none object-contain ${className}`.trim()}
/>
);
}
ShapesView.displayName = "ShapesView";
export default memo(ShapesView);
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Shapes.container";
export type { ShapesProps, StatShapeVariant } from "./Shapes.types";
@@ -0,0 +1,15 @@
"use client";
import { memo } from "react";
import StatView from "./Stat.view";
import type { StatProps } from "./Stat.types";
const StatContainer = memo<StatProps>(
({ shapeVariant: shapeVariantProp = "yellow", ...props }) => {
return <StatView {...props} shapeVariant={shapeVariantProp} />;
},
);
StatContainer.displayName = "Stat";
export default StatContainer;
+13
View File
@@ -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;
}
+46
View File
@@ -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 (
<article
className={`relative flex h-auto min-h-[182px] w-full flex-col items-start justify-between rounded-[var(--radius-measures-radius-xlarge,20px)] bg-[var(--color-surface-invert-primary,white)] px-[var(--spacing-scale-024)] py-[var(--spacing-scale-032)] sm:h-[170px] sm:min-h-0 sm:p-[var(--spacing-scale-024)] ${className}`.trim()}
>
<div className="relative flex w-full flex-col items-start">
<div className="relative flex items-center">
<Shapes
variant={shapeVariant}
className="absolute -left-[11px] -top-[21px] size-[80px] rotate-[15deg] opacity-90"
/>
<p className="relative font-bricolage-grotesque text-[40px] font-bold leading-[52px] text-[var(--color-content-invert-primary,black)]">
{value}
</p>
</div>
<p className="font-inter text-[14px] font-normal leading-5 text-[var(--color-content-invert-primary,black)]">
{label}
</p>
</div>
{asOf ? (
<p className="w-full font-inter text-[10px] font-normal leading-[14px] text-[var(--color-content-invert-tertiary,#2d2d2d)]">
{asOf}
</p>
) : null}
</article>
);
}
StatView.displayName = "StatView";
export default memo(StatView);
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Stat.container";
export type { StatProps } from "./Stat.types";
@@ -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<AccordionProps>(
({
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 (
<AccordionView
title={title}
subhead={subhead}
children={children}
size={size}
lgSize={lgSize}
xlSize={xlSize}
isOpen={isOpen}
panelId={panelId}
buttonId={buttonId}
onToggle={onToggle}
className={className}
/>
);
},
);
AccordionContainer.displayName = "Accordion";
export default AccordionContainer;
@@ -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 sizes 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;
}
@@ -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<AccordionSizeValue, string> = {
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 (
<div className={`w-full ${className}`.trim()}>
<h3 className="m-0">
<button
id={buttonId}
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={onToggle}
className={`flex w-full ${sizeClass.header} text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-content-default-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#141414]`}
>
<span className="flex min-w-0 flex-1 flex-col gap-[var(--spacing-scale-004)]">
<span
className={`font-inter text-[var(--color-content-default-primary,white)] ${sizeClass.title}`}
>
{title}
</span>
{subhead ? (
<span
className={`font-inter font-medium text-[var(--color-content-default-tertiary,#b4b4b4)] ${sizeClass.subhead}`}
>
{subhead}
</span>
) : null}
</span>
<span
className={`flex size-6 shrink-0 items-center justify-center text-[var(--color-content-default-primary,white)] transition-transform ${isOpen ? "-rotate-90" : "rotate-90"}`}
aria-hidden
>
<Icon name="chevron_right" size={24} />
</span>
</button>
</h3>
{isOpen && children ? (
<div
id={panelId}
role="region"
aria-labelledby={buttonId}
className={sizeClass.panel}
>
{children}
</div>
) : null}
<Divider type="content" orientation="horizontal" />
</div>
);
}
AccordionView.displayName = "AccordionView";
export default memo(AccordionView);
@@ -0,0 +1,2 @@
export { default } from "./Accordion.container";
export type { AccordionProps, AccordionSizeValue } from "./Accordion.types";
@@ -81,6 +81,7 @@ export function AskOrganizerInquiryModalView({
<Create
isOpen={isOpen}
onClose={onClose}
backdropVariant="blurredYellow"
title={t("title")}
description={t("description")}
showBackButton={false}
+1 -1
View File
@@ -149,7 +149,7 @@ const Footer = memo(() => {
{t("navigation.learn")}
</Link>
<Link
href="#"
href="/about"
className={`w-full text-left ${primaryLinkClass} md:w-auto md:text-right`}
>
{t("navigation.about")}
@@ -79,7 +79,7 @@ const TopContainer = memo<TopProps>(
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) => {
@@ -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<FaqAccordionProps>(
({ 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 <FaqAccordionView {...viewProps} />;
},
);
FaqAccordionContainer.displayName = "FaqAccordion";
export default FaqAccordionContainer;
@@ -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;
}
@@ -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 (
<section
aria-labelledby={headingId}
className={`bg-[#141414] px-[var(--spacing-scale-004)] py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-160)] md:py-[var(--spacing-scale-096)] ${className}`.trim()}
>
<div className="mx-auto flex w-full max-w-[1440px] flex-col items-center gap-[var(--spacing-scale-096)] md:gap-[var(--spacing-scale-040)]">
<h2
id={headingId}
className="w-full px-[var(--spacing-scale-016)] text-center font-bricolage-grotesque text-[length:var(--text-large-heading)] font-bold leading-[length:var(--text-large-heading--line-height)] text-[var(--color-content-default-brand-primary,#fefcc9)] md:px-0 lg:text-[length:var(--text-x-large-heading)] lg:leading-[length:var(--text-x-large-heading--line-height)] xl:text-[length:var(--text-xx-large-heading)] xl:leading-[length:var(--text-xx-large-heading--line-height)] xl:tracking-[var(--text-xx-large-heading--letter-spacing)]"
>
{title}
</h2>
<div className="w-full md:px-0">
{items.map((item, index) => (
<LayoutAccordion
key={`${item.title}-${index}`}
title={item.title}
subhead={item.subhead}
size={size}
lgSize={lgSize}
xlSize={xlSize}
>
{item.answer}
</LayoutAccordion>
))}
</div>
</div>
</section>
);
}
FaqAccordionView.displayName = "FaqAccordionView";
export default memo(FaqAccordionView);
@@ -0,0 +1,2 @@
export { default } from "./Accordion.container";
export type { FaqAccordionProps, FaqAccordionItem } from "./Accordion.types";
@@ -56,7 +56,7 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
const sectionPadding =
resolvedVariant === "compact"
? "py-[var(--spacing-scale-016)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-032)]"
: "py-[var(--spacing-scale-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"
@@ -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"
@@ -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<BookProps>((props) => {
const headingId = useId();
return <BookView {...props} headingId={headingId} />;
});
BookContainer.displayName = "Book";
export default BookContainer;
@@ -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;
}
@@ -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 (
<section
aria-labelledby={headingId}
className={`px-[var(--spacing-scale-008)] py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-064)] lg:px-[var(--spacing-scale-160)] lg:py-[var(--spacing-scale-064)] ${className}`.trim()}
>
<div className="mx-auto flex w-full max-w-[1440px] flex-col items-center">
<div className="flex w-full flex-col items-center gap-[var(--spacing-scale-032)] rounded-[var(--radius-measures-radius-xlarge,20px)] bg-[#171717] p-[var(--spacing-scale-048)] shadow-[0_0_48px_rgba(0,0,0,0.1)] md:flex-row md:items-center lg:gap-[var(--spacing-scale-040)] lg:p-[var(--spacing-scale-064)] xl:mx-auto xl:max-w-[1280px] xl:gap-[var(--spacing-scale-032)] xl:p-[var(--spacing-scale-048)]">
<div className="relative aspect-[375/580] w-full shrink-0 overflow-hidden rounded-[4px] shadow-[0_0_24px_rgba(0,0,0,0.25)] md:aspect-auto md:h-[495px] md:w-[320px]">
{/* eslint-disable-next-line @next/next/no-img-element -- marketing cover art */}
<img
src={coverSrc}
alt={imageAlt ?? ""}
className="size-full object-cover"
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-[var(--spacing-scale-016)] lg:gap-[var(--spacing-scale-024)] xl:gap-[var(--spacing-scale-020)]">
<ContentLockup
variant="book"
alignment="left"
titleId={headingId}
title={title}
description={description}
/>
<Button
buttonType="filled"
palette="default"
size="small"
href={buttonHref}
className="self-start"
>
{buttonText}
</Button>
</div>
</div>
</div>
</section>
);
}
BookView.displayName = "BookView";
export default memo(BookView);
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Book.container";
export type { BookProps } from "./Book.types";
@@ -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<QuoteBlockProps>(
({
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<QuoteBlockProps>(
"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<QuoteBlockProps>(
};
// 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<QuoteBlockProps>(
<QuoteBlockView
className={className}
quote={quote}
quoteSecondary={quoteSecondary}
author={author}
source={source}
quoteId={quoteId}
@@ -1,12 +1,18 @@
export type QuoteBlockVariantValue = "compact" | "standard" | "extended";
export type QuoteBlockVariantValue =
| "compact"
| "standard"
| "extended"
| "statement";
export interface QuoteBlockProps {
/**
* Quote block variant.
*/
/** Default `standard` (home portrait quote). `statement` is About-only dual-paragraph layout; isolated branch in QuoteBlock.view. */
variant?: QuoteBlockVariantValue;
className?: string;
quote?: string;
/**
* Second paragraph for **`statement`** variant (Figma Section/Quote 22137:890679).
*/
quoteSecondary?: string;
author?: string;
source?: string;
avatarSrc?: string;
@@ -32,11 +38,16 @@ export interface VariantConfig {
author: string;
source: string;
showDecor: boolean;
/**
* When true, render Figma **Section/Quote** layout (yellow surface, dual paragraphs, no attribution).
*/
statementLayout?: boolean;
}
export interface QuoteBlockViewProps {
className: string;
quote: string;
quoteSecondary?: string;
author: string;
source?: string;
quoteId: string;
@@ -4,11 +4,13 @@ import { memo } from "react";
import Image from "next/image";
import { useTranslation } from "../../../contexts/MessagesContext";
import QuoteDecor from "./QuoteDecor";
import QuoteStatementDecor from "./QuoteStatementDecor";
import type { QuoteBlockViewProps } from "./QuoteBlock.types";
function QuoteBlockView({
className,
quote,
quoteSecondary,
author,
source,
quoteId,
@@ -23,6 +25,37 @@ function QuoteBlockView({
const t = useTranslation("quoteBlock");
const avatarAlt = t("avatarAlt").replace("{author}", author);
if (config.statementLayout) {
if (!quoteSecondary?.trim()) {
return null;
}
const statementTextClass =
"font-bricolage-grotesque text-[28px] font-bold leading-9 tracking-[var(--text-xx-large-heading--letter-spacing)] text-[var(--color-surface-default-tertiary)] md:text-[length:var(--text-xx-large-heading)] md:leading-[length:var(--text-xx-large-heading--line-height)]";
return (
<section
className={`${config.container} ${className}`.trim()}
aria-labelledby={quoteId}
role="region"
>
<div
className="relative box-border flex w-full max-w-[1440px] shrink-0 flex-col items-center justify-center gap-[var(--space-800)] overflow-hidden rounded-[var(--spacing-scale-020)] bg-[var(--color-surface-invert-brand-primary,#fefcc9)] px-[var(--spacing-scale-032)] py-[var(--spacing-scale-048)] md:px-[var(--space-1800)] md:py-[var(--space-2400)]"
>
<QuoteStatementDecor />
<div className="relative z-10 flex w-full min-w-0 shrink-0 flex-col items-center gap-9 text-center md:gap-[length:var(--text-xx-large-heading--line-height)]">
<p id={quoteId} className={`${statementTextClass} mb-0 w-full min-w-0`}>
{quote}
</p>
<p className={`${statementTextClass} mb-0 w-full min-w-0`}>
{quoteSecondary}
</p>
</div>
</div>
</section>
);
}
return (
<section
className={`${config.container} ${className}`}
@@ -0,0 +1,41 @@
"use client";
import { memo } from "react";
import { getAssetPath, quoteStatementShapePath } from "../../../../lib/assetUtils";
/** Figma: Section / Quote — Shapes (22137:890679). Radial asset + horizontal gradient mask (side lobes only); grain matches QuoteBlock/HeroDecor. Background `cover` so wide banners still fill lateral mask stripes (square sized by panel height misses them when centered). */
const EDGE_MASK =
"linear-gradient(to right, #fff 0%, #fff 14%, rgba(255,255,255,0) 30%, rgba(255,255,255,0) 70%, #fff 86%, #fff 100%)";
const GRAIN_MULTIPLY_FILTER =
'url(\'data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg"><defs><filter id="grain" filterUnits="objectBoundingBox" x="0" y="0" width="1" height="1" colorInterpolationFilters="sRGB"><feTurbulence type="fractalNoise" baseFrequency="0.4" numOctaves="3" seed="7" stitchTiles="stitch" result="noise"/><feColorMatrix in="noise" result="softNoise" type="matrix" values="0.8 0 0 0 0.3 0 0.6 0 0 0.2 0 0 1.0 0 0.4 0 0 0 0.25 0"/><feComposite in="softNoise" in2="SourceAlpha" operator="in" result="maskedNoise"/><feBlend in="SourceGraphic" in2="maskedNoise" mode="multiply"/></filter></defs></svg>#grain\')';
const QuoteStatementDecor = memo<{ className?: string }>(({ className = "" }) => {
const src = getAssetPath(quoteStatementShapePath());
const bg = `url("${src}")`;
return (
<div
className={`pointer-events-none absolute inset-0 z-0 overflow-hidden opacity-[0.55] select-none ${className}`.trim()}
aria-hidden
style={{
backgroundImage: bg,
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
backgroundPosition: "center center",
WebkitMaskImage: EDGE_MASK,
maskImage: EDGE_MASK,
WebkitMaskSize: "100% 100%",
maskSize: "100% 100%",
WebkitMaskRepeat: "no-repeat",
maskRepeat: "no-repeat",
filter: GRAIN_MULTIPLY_FILTER,
WebkitFilter: GRAIN_MULTIPLY_FILTER,
}}
/>
);
});
QuoteStatementDecor.displayName = "QuoteStatementDecor";
export default QuoteStatementDecor;
+4 -1
View File
@@ -1,2 +1,5 @@
export { default } from "./QuoteBlock.container";
export type { QuoteBlockProps } from "./QuoteBlock.types";
export type {
QuoteBlockProps,
QuoteBlockVariantValue,
} from "./QuoteBlock.types";
@@ -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<StatsProps>((props) => {
const headingId = useId();
return <StatsView {...props} headingId={headingId} />;
});
StatsContainer.displayName = "Stats";
export default StatsContainer;
@@ -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;
}
@@ -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 (
<section
aria-labelledby={headingId}
className={`bg-black px-[var(--spacing-scale-032)] py-[var(--spacing-scale-048)] md:px-[var(--spacing-scale-064)] md:py-[var(--spacing-scale-064)] lg:px-[var(--spacing-scale-160)] ${className}`.trim()}
>
<div className="mx-auto flex w-full max-w-[1440px] flex-col items-start gap-[var(--spacing-scale-010)] sm:gap-[var(--spacing-scale-032)]">
<h2
id={headingId}
className="max-w-[116px] font-inter text-[24px] font-normal leading-[32px] md:text-[length:var(--spacing-scale-016)] md:leading-[length:var(--spacing-scale-020)] md:translate-y-24 lg:translate-y-0"
>
<span className="-mb-1 block whitespace-nowrap md:-mb-0 md:inline md:whitespace-normal md:leading-[inherit]">
{titlePrefix ? (
<span className="text-[#636363]">
{titlePrefix}{" "}
</span>
) : null}
{titleEmphasis ? (
<span className="font-normal text-[#e0e0e0]">
{titleEmphasis}
</span>
) : null}
</span>
{titleSuffix ? (
<>
<span className="hidden md:inline md:leading-[inherit]">{" "}</span>
<span className="block whitespace-nowrap md:inline md:whitespace-normal md:leading-[inherit]">
<span className="text-[#636363]">
{suffixLead}
{suffixTail ? "\u00a0" : null}
</span>
{suffixTail ? (
<span className="text-[#e0e0e0]">{suffixTail}</span>
) : null}
</span>
</>
) : null}
</h2>
<ul className="grid w-full flex-1 grid-cols-1 gap-[var(--spacing-scale-014)] md:grid-cols-2 md:gap-[var(--spacing-scale-016)] lg:grid-cols-4">
{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 (
<li key={`${item.value}-${index}`} className={staggerClass}>
<Stat {...item} className={heightClass} />
</li>
);
})}
</ul>
</div>
</section>
);
}
StatsView.displayName = "StatsView";
export default memo(StatsView);
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Stats.container";
export type { StatsProps, StatItem } from "./Stats.types";
@@ -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<AboutHeaderProps>((props) => {
const titleId = useId();
return <AboutHeaderView {...props} titleId={titleId} />;
});
AboutHeaderContainer.displayName = "AboutHeader";
export default AboutHeaderContainer;
@@ -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;
}
@@ -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 (
<section
className={`bg-black px-[var(--spacing-scale-020)] py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-064)] md:py-[var(--spacing-scale-032)] ${className}`.trim()}
>
<ContentLockup
variant="about"
alignment="left"
titleId={titleId}
titleContent={segments.map((segment, index) => {
if (segment.type === "word") {
return (
<span key={`${segment.text}-${index}`} className="whitespace-nowrap">
{segment.text}
</span>
);
}
return (
<span
key={`${segment.icon}-${index}`}
className={
segment.icon === "arrow"
? "inline-flex h-[0.92em] min-h-[24px] max-h-[52px] shrink-0 items-center self-center md:max-h-[64px]"
: "inline-flex size-[29px] shrink-0 items-center justify-center md:size-[80px]"
}
>
{/* eslint-disable-next-line @next/next/no-img-element -- decorative inline vector */}
<img
src={getAssetPath(assetRelativeForInlineIcon(segment.icon))}
alt=""
className={
segment.icon === "arrow"
? "h-full w-auto max-w-[4.75rem] object-contain md:max-w-[6rem]"
: "size-full object-contain"
}
role="presentation"
/>
</span>
);
})}
/>
</section>
);
}
AboutHeaderView.displayName = "AboutHeaderView";
export default memo(AboutHeaderView);
@@ -0,0 +1,2 @@
export { default } from "./AboutHeader.container";
export type { AboutHeaderProps, AboutHeaderSegment } from "./AboutHeader.types";
@@ -16,6 +16,7 @@ const ContentLockupContainer = memo<ContentLockupProps>(
linkHref,
alignment: alignmentProp = "center",
titleId,
titleContent,
}) => {
const variant = variantProp;
const alignment = alignmentProp;
@@ -48,10 +49,44 @@ const ContentLockupContainer = memo<ContentLockupProps>(
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<ContentLockupProps>(
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<ContentLockupProps>(
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<ContentLockupProps>(
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<ContentLockupProps>(
linkHref={linkHref}
alignment={alignment}
titleId={titleId}
titleContent={titleContent}
styles={styles}
/>
);
@@ -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;
}
@@ -16,6 +16,7 @@ function ContentLockupView({
linkHref,
alignment,
titleId,
titleContent,
styles,
}: ContentLockupViewProps) {
return (
@@ -57,7 +58,14 @@ function ContentLockupView({
<div className={styles.titleGroup}>
{/* Title container */}
<div className={styles.titleContainer}>
{title ? (
{titleContent ? (
<h1
id={titleId}
className={`${styles.title} flex flex-wrap items-center gap-[var(--spacing-scale-008)] md:gap-[var(--spacing-scale-010)]`}
>
{titleContent}
</h1>
) : title ? (
<h1 id={titleId} className={styles.title}>
{title}
</h1>
@@ -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<TripleTextBlockProps>((props) => {
const headingId = useId();
return <TripleTextBlockView {...props} headingId={headingId} />;
});
TripleTextBlockContainer.displayName = "TripleTextBlock";
export default TripleTextBlockContainer;
@@ -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;
}
@@ -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 (
<ContentLockup
variant="about"
alignment="left"
subtitle={column.title}
description={column.description}
/>
);
}
return (
<>
<div className="lg:hidden">
<ContentLockup
variant="about"
alignment="left"
subtitle={column.title}
description={column.description}
/>
</div>
<div className="hidden lg:block">
<ContentLockup
variant="about"
alignment="left"
subtitle={lgSubtitle}
description={lgBody}
/>
</div>
</>
);
}
/**
* 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 (
<section
aria-labelledby={hasSectionTitle ? headingId : undefined}
className={`bg-black px-[var(--spacing-scale-032)] py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-096)] md:py-[var(--spacing-scale-064)] xl:px-[var(--spacing-scale-160)] ${className}`.trim()}
>
<div className="mx-auto flex w-full max-w-[1440px] flex-col items-start gap-[var(--spacing-scale-032)]">
{hasSectionTitle ? (
<h2
id={headingId}
className="w-full text-left font-bricolage-grotesque text-[32px] font-medium leading-[1.1] text-[var(--color-content-default-primary,white)]"
>
{sectionTitle}
</h2>
) : null}
<div className="flex w-full flex-col gap-[var(--spacing-scale-032)] lg:flex-row lg:items-start lg:gap-[var(--spacing-scale-032)]">
{columns.map((column, index) => (
<div
key={`${column.title}-${column.lgTitle ?? ""}-${index}`}
className="w-full min-w-0 lg:flex-1"
>
<TripleTextBlockColumnLockup column={column} />
</div>
))}
</div>
{ctaText ? (
<div className="flex w-full justify-start">
<Button
buttonType="filled"
palette="inverse"
size="large"
href={ctaHref}
>
{ctaText}
</Button>
</div>
) : null}
</div>
</section>
);
}
TripleTextBlockView.displayName = "TripleTextBlockView";
export default memo(TripleTextBlockView);
@@ -0,0 +1,5 @@
export { default } from "./TripleTextBlock.container";
export type {
TripleTextBlockProps,
TripleTextBlockColumn,
} from "./TripleTextBlock.types";
+13 -6
View File
@@ -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:** Figmas 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/<slug>.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.*
+18
View File
@@ -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",
+1
View File
@@ -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];
+2
View File
@@ -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,
+108
View File
@@ -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."
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 53 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg viewBox="0 0 1034 1034" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M951.501 516.639C967.326 555.506 974.393 598.69 971.709 643.453C966.784 725.587 929.434 806.314 867.874 867.874C806.314 929.434 725.587 966.784 643.453 971.709C598.689 974.393 555.506 967.326 516.639 951.501L951.501 516.639Z" fill="#F6F06F"/>
<path d="M516.639 81.7771C477.772 65.9523 434.589 58.885 389.825 61.569C307.691 66.4938 226.964 103.845 165.404 165.404C103.844 226.964 66.4938 307.691 61.5691 389.825C58.8851 434.589 65.9523 477.772 81.7771 516.639L516.639 81.7771Z" fill="#F6F06F"/>
<path d="M315.933 14.8753C288.815 3.70934 258.726 -1.3766 227.574 0.319692C170.415 3.43212 114.36 29.1235 71.7418 71.7419C29.1234 114.36 3.43208 170.415 0.319627 227.574C-1.37667 258.726 3.70932 288.815 14.8753 315.934L315.933 14.8753Z" fill="#F6F06F"/>
<path d="M717.345 1018.4C744.463 1029.57 774.552 1034.65 805.704 1032.96C862.863 1029.85 918.918 1004.15 961.536 961.536C1004.15 918.918 1029.85 862.863 1032.96 805.704C1034.65 774.552 1029.57 744.463 1018.4 717.345L717.345 1018.4Z" fill="#F6F06F"/>
<path d="M1018.4 315.933C1029.57 288.815 1034.65 258.726 1032.96 227.574C1029.85 170.415 1004.15 114.36 961.536 71.7419C918.918 29.1234 862.863 3.43221 805.704 0.319667C774.552 -1.37662 744.463 3.70932 717.345 14.8753L1018.4 315.933Z" fill="#F6F06F"/>
<path d="M14.8752 717.344C3.70926 744.463 -1.37673 774.552 0.319568 805.704C3.43205 862.863 29.1234 918.918 71.7418 961.536C114.36 1004.15 170.415 1029.85 227.574 1032.96C258.726 1034.65 288.815 1029.57 315.934 1018.4L14.8752 717.344Z" fill="#F6F06F"/>
<path d="M516.639 951.501C477.772 967.326 434.589 974.393 389.825 971.709C307.691 966.784 226.964 929.434 165.404 867.874C103.844 806.314 66.4938 725.587 61.569 643.453C58.8851 598.689 65.9523 555.506 81.7771 516.639L516.639 951.501Z" fill="#F6F06F"/>
<path d="M951.501 516.639C967.326 477.772 974.393 434.589 971.709 389.825C966.784 307.691 929.434 226.964 867.874 165.404C806.314 103.845 725.587 66.4939 643.453 61.5691C598.689 58.8852 555.506 65.9523 516.639 81.7771L951.501 516.639Z" fill="#F6F06F"/>
<path d="M734.07 299.208C854.154 419.292 854.154 613.986 734.07 734.07C613.986 854.154 419.292 854.154 299.208 734.07C179.124 613.986 179.124 419.292 299.208 299.208C419.292 179.124 613.986 179.124 734.07 299.208Z" fill="#F6F06F"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+15
View File
@@ -0,0 +1,15 @@
<svg width="98" height="98" viewBox="0 0 98 98" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_22851_36717)">
<mask id="mask0_22851_36717" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="98" height="98">
<path d="M97.6332 20.6323L20.6323 0L3.90632e-06 77.0009L77.0009 97.6332L97.6332 20.6323Z" fill="white"/>
</mask>
<g mask="url(#mask0_22851_36717)">
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M20.4272 41.2097C-11.7101 63.8094 5.5692 93.7379 41.2097 77.206C63.8036 109.342 93.7439 92.0418 77.206 56.4235C109.343 33.8239 92.0583 3.89382 56.4235 20.4272C33.8239 -11.7101 3.89533 5.56922 20.4272 41.2097ZM44.9996 63.0617C52.8672 65.1699 60.9536 60.5011 63.0617 52.6336C65.1698 44.7662 60.5011 36.6795 52.6336 34.5714C44.7662 32.4634 36.6795 37.1322 34.5714 44.9996C32.4633 52.8672 37.1322 60.9537 44.9996 63.0617Z" fill="#F6F06F"/>
</g>
</g>
<defs>
<clipPath id="clip0_22851_36717">
<rect width="79.7172" height="79.7172" fill="white" transform="translate(20.6323) rotate(15)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg width="98" height="98" viewBox="0 0 98 98" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_22851_36731)">
<mask id="mask0_22851_36731" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="98" height="98">
<path d="M97.6332 20.6323L20.6323 0L3.90632e-06 77.0009L77.0009 97.6332L97.6332 20.6323Z" fill="white"/>
</mask>
<g mask="url(#mask0_22851_36731)">
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M65.3591 17.8794C66.173 14.8419 64.3704 11.7196 61.3327 10.9056L56.9327 9.72666C53.8951 8.91274 50.7729 10.7154 49.9589 13.753L49.6555 14.8854C48.3426 19.7854 41.7607 20.6519 39.2243 16.2587L38.6381 15.2434C37.0657 12.52 33.5832 11.5869 30.8598 13.1592L26.9148 15.4369C24.1914 17.0092 23.2583 20.4917 24.8306 23.2151L25.4168 24.2304C27.9533 28.6237 23.9119 33.8905 19.0119 32.5775L17.8795 32.2741C14.8419 31.4602 11.7196 33.2628 10.9057 36.3004L9.7267 40.7003C8.91275 43.738 10.7154 46.8603 13.753 47.6743L14.8854 47.9777C19.7854 49.2906 20.6519 55.8726 16.2587 58.4089L15.2434 58.9951C12.52 60.5675 11.5869 64.0499 13.1592 66.7734L15.4369 70.7183C17.0093 73.4417 20.4917 74.3749 23.2152 72.8024L24.2305 72.2162C28.6236 69.6799 33.8905 73.7214 32.5775 78.6213L32.2741 79.7536C31.4602 82.7913 33.2628 85.9136 36.3004 86.7275L40.7003 87.9065C43.738 88.7204 46.8603 86.9178 47.6743 83.8801L47.9777 82.7478C49.2906 77.8478 55.8726 76.9813 58.4089 81.3743L58.9952 82.3897C60.5676 85.1131 64.0499 86.0462 66.7734 84.4738L70.7183 82.1963C73.4417 80.6239 74.3748 77.1415 72.8024 74.418L72.2166 73.4027C69.6799 69.0096 73.7214 63.7427 78.6213 65.0557L79.7536 65.3591C82.7913 66.173 85.9136 64.3703 86.7276 61.3327L87.9065 56.9327C88.7204 53.8951 86.9178 50.7728 83.8801 49.9589L82.7478 49.6555C77.8478 48.3426 76.9812 41.7607 81.3743 39.2242L82.3897 38.638C85.1131 37.0656 86.0463 33.5832 84.4739 30.8597L82.1963 26.9148C80.6239 24.1913 77.1415 23.2582 74.418 24.8306L73.4027 25.4167C69.0096 27.9532 63.7427 23.9118 65.0557 19.0118L65.3591 17.8794Z" fill="url(#paint0_linear_22851_36731)"/>
</g>
</g>
<defs>
<linearGradient id="paint0_linear_22851_36731" x1="23.3402" y1="11.4544" x2="71.0306" y2="87.5742" gradientUnits="userSpaceOnUse">
<stop stop-color="#E9B8FF"/>
<stop offset="1" stop-color="#F9ECFF"/>
</linearGradient>
<clipPath id="clip0_22851_36731">
<rect width="79.7172" height="79.7172" fill="white" transform="translate(20.6323) rotate(15)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+15
View File
@@ -0,0 +1,15 @@
<svg width="98" height="98" viewBox="0 0 98 98" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_22851_36745)">
<mask id="mask0_22851_36745" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="98" height="98">
<path d="M97.6332 20.6323L20.6323 0L3.90632e-06 77.0009L77.0009 97.6332L97.6332 20.6323Z" fill="white"/>
</mask>
<g mask="url(#mask0_22851_36745)">
<path d="M48.9495 90.1168C38.4982 87.3164 41.8589 74.7742 31.4078 71.9738C19.3787 68.7506 4.29297 60.9793 7.51708 48.9468C10.3174 38.496 22.8561 41.8557 25.6563 31.4052C28.8781 19.3813 36.6504 4.29202 48.6838 7.51637C59.1351 10.3168 55.7743 22.8592 66.2253 25.6595C78.2501 28.8816 93.3402 36.6539 90.1161 48.6866C87.3158 59.1374 74.7727 55.7765 71.9725 66.227C68.7402 78.2718 60.9785 93.34 48.9495 90.1168Z" fill="#CEE8AB"/>
</g>
</g>
<defs>
<clipPath id="clip0_22851_36745">
<rect width="79.7172" height="79.7172" fill="white" transform="translate(20.6323) rotate(15)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1003 B

+19
View File
@@ -0,0 +1,19 @@
<svg width="98" height="98" viewBox="0 0 98 98" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_22851_36759)">
<mask id="mask0_22851_36759" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="98" height="98">
<path d="M97.6332 20.6323L20.6323 0L3.90632e-06 77.0009L77.0009 97.6332L97.6332 20.6323Z" fill="white"/>
</mask>
<g mask="url(#mask0_22851_36759)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M61.4702 12.5931C61.6981 11.7425 61.1933 10.8683 60.3428 10.6404L57.9227 9.99193C57.0722 9.76404 56.198 10.2688 55.9701 11.1193L51.9803 26.0093C51.5267 27.702 49.0714 27.5303 48.8578 25.791L46.9792 10.4906C46.8719 9.61663 46.0764 8.99512 45.2024 9.10244L42.7157 9.40776C41.8417 9.51508 41.2202 10.3106 41.3275 11.1845L43.3304 27.4962C43.542 29.2201 41.2356 29.9918 40.3671 28.4877L32.1501 14.2553C31.7098 13.4927 30.7347 13.2314 29.9722 13.6717L27.8024 14.9244C27.0399 15.3646 26.7786 16.3397 27.2189 17.1023L34.5978 29.8829C35.4786 31.4086 33.5991 33.0253 32.2223 31.9261L20.6889 22.7188C20.0008 22.1694 18.9976 22.282 18.4482 22.9701L16.8851 24.9281C16.3358 25.6162 16.4483 26.6194 17.1364 27.1688L30.4264 37.7784C31.7765 38.8562 30.6878 41.0115 29.019 40.5644L12.5931 36.163C11.7425 35.9351 10.8683 36.4399 10.6404 37.2904L9.99193 39.7105C9.76404 40.561 10.2688 41.4353 11.1193 41.6632L27.5452 46.0645C29.2139 46.5116 29.0792 48.9225 27.3711 49.1807L10.5569 51.7242C9.68627 51.8557 9.08724 52.6682 9.21897 53.5387L9.59365 56.016C9.72526 56.8868 10.5378 57.4858 11.4085 57.3539L26.0003 55.1471C27.7422 54.8835 28.5616 57.2233 27.0359 58.1044L14.2553 65.4829C13.4927 65.9235 13.2315 66.8984 13.6717 67.6609L14.9244 69.8307C15.3647 70.5933 16.3398 70.8545 17.1023 70.4143L31.3347 62.1972C32.8389 61.3288 34.4504 63.1504 33.4052 64.5375L23.5148 77.6624C22.985 78.3655 23.1255 79.3651 23.8286 79.8952L25.8295 81.4031C26.5328 81.9328 27.5324 81.7925 28.0624 81.089L37.3395 68.7781C38.3942 67.3784 40.6063 68.4576 40.1528 70.1501L36.163 85.0401C35.9352 85.8906 36.4399 86.7649 37.2904 86.9928L39.7105 87.6413C40.561 87.8691 41.4353 87.3644 41.6632 86.5139L45.6529 71.6238C46.1064 69.9314 48.562 70.1028 48.7755 71.8424L50.6539 87.1424C50.7613 88.0167 51.5567 88.6379 52.4309 88.5309L54.9176 88.2254C55.7915 88.1179 56.4132 87.3226 56.3057 86.4487L54.303 70.137C54.0911 68.4131 56.3974 67.6413 57.2662 69.1455L65.4829 83.3779C65.9235 84.1405 66.8984 84.4017 67.6609 83.9614L69.8308 82.7087C70.5933 82.2684 70.8545 81.2936 70.4144 80.5307L63.0353 67.7504C62.1547 66.2245 64.0342 64.6079 65.4108 65.7072L76.9441 74.9142C77.6324 75.4638 78.6356 75.3513 79.1848 74.6629L80.7481 72.7049C81.2973 72.0169 81.1848 71.0137 80.4967 70.4645L67.2068 59.8547C65.8569 58.7771 66.9453 56.6217 68.6143 57.0689L85.0401 61.4702C85.8906 61.6981 86.7649 61.1933 86.9928 60.3428L87.6413 57.9227C87.8691 57.0722 87.3644 56.1979 86.5139 55.9701L70.0881 51.5688C68.4191 51.1216 68.5538 48.7106 70.2621 48.4523L87.0763 45.9091C87.9468 45.7774 88.5458 44.9648 88.4143 44.0943L88.0395 41.617C87.9079 40.7464 87.0954 40.1474 86.2246 40.279L71.6329 42.4861C69.8911 42.7496 69.0717 40.4099 70.5973 39.529L83.3779 32.15C84.1404 31.7098 84.4017 30.7347 83.9615 29.9721L82.7087 27.8024C82.2684 27.0398 81.2936 26.7786 80.5307 27.2188L66.2986 35.4359C64.7942 36.3043 63.1827 34.4827 64.2282 33.0957L74.1184 19.9708C74.6484 19.2676 74.5078 18.2679 73.8047 17.738L71.8038 16.2302C71.1003 15.7003 70.1007 15.8408 69.5707 16.544L60.2938 28.8552C59.2392 30.2548 57.0269 29.1757 57.4804 27.4831L61.4702 12.5931Z" fill="url(#paint0_linear_22851_36759)"/>
</g>
</g>
<defs>
<linearGradient id="paint0_linear_22851_36759" x1="29.2599" y1="10.152" x2="59.9963" y2="82.5543" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFD9A0"/>
<stop offset="1" stop-color="#FFF5F1"/>
</linearGradient>
<clipPath id="clip0_22851_36759">
<rect width="79.7172" height="79.7172" fill="white" transform="translate(20.6323) rotate(15)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

+15
View File
@@ -0,0 +1,15 @@
<svg width="58" height="58" viewBox="0 0 58 58" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_about_vector)">
<mask id="mask0_about_vector" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="58" height="58">
<path d="M58 0L0 0L0 58H58V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_about_vector)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M29 58C45.0161 58 58 45.0161 58 29C58 12.9837 45.0161 0 29 0C12.9837 0 0 12.9837 0 29C0 45.0161 12.9837 58 29 58ZM29 41.6875C36.007 41.6875 41.6875 36.007 41.6875 29C41.6875 21.9929 36.007 16.3125 29 16.3125C21.9929 16.3125 16.3125 21.9929 16.3125 29C16.3125 36.007 21.9929 41.6875 29 41.6875Z" fill="#F7D0DB"/>
</g>
</g>
<defs>
<clipPath id="clip0_about_vector">
<rect width="58" height="58" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 832 B

+33
View File
@@ -0,0 +1,33 @@
import type { Meta, StoryObj } from "@storybook/react";
import Accordion from "../../app/components/layout/Accordion";
const meta: Meta<typeof Accordion> = {
title: "Components/Layout/Accordion",
component: Accordion,
parameters: {
layout: "padded",
backgrounds: { default: "dark" },
},
};
export default meta;
type Story = StoryObj<typeof Accordion>;
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",
},
};
+24 -4
View File
@@ -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",
+37
View File
@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from "@storybook/react";
import Stats from "../../app/components/sections/Stats";
const meta: Meta<typeof Stats> = {
title: "Components/Sections/Stats",
component: Stats,
parameters: {
layout: "fullscreen",
backgrounds: { default: "dark" },
},
};
export default meta;
type Story = StoryObj<typeof Stats>;
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",
},
],
},
};
+37
View File
@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from "@storybook/react";
import AboutHeader from "../../app/components/type/AboutHeader";
const meta: Meta<typeof AboutHeader> = {
title: "Components/Type/AboutHeader",
component: AboutHeader,
parameters: {
layout: "fullscreen",
backgrounds: { default: "dark" },
},
};
export default meta;
type Story = StoryObj<typeof AboutHeader>;
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" },
],
},
};
+19
View File
@@ -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(
<Stat
value="420M+"
label="open source projects"
asOf="as of June 30, 2024"
/>,
);
expect(screen.getByText("420M+")).toBeInTheDocument();
expect(screen.getByText("open source projects")).toBeInTheDocument();
expect(screen.getByText("as of June 30, 2024")).toBeInTheDocument();
});
});
@@ -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(
<Accordion title="Question" defaultOpen={false}>
Answer copy
</Accordion>,
);
expect(screen.queryByText("Answer copy")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Question" }));
expect(screen.getByText("Answer copy")).toBeInTheDocument();
});
});
+28
View File
@@ -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(
<Stats
titlePrefix="From"
titleEmphasis="projects"
titleSuffix="to communities"
items={[
{
value: "27%",
label: "year over year growth",
asOf: "as of June 30, 2024",
shapeVariant: "purple",
},
]}
/>,
);
expect(
screen.getByRole("heading", { name: /From projects to communities/i }),
).toBeInTheDocument();
expect(screen.getByText("27%")).toBeInTheDocument();
});
});
@@ -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(
<AboutHeader
segments={[
{ type: "word", text: "CommunityRule" },
{ type: "word", text: "helps" },
]}
/>,
);
expect(
screen.getByRole("heading", { name: /CommunityRule helps/i }),
).toBeInTheDocument();
});
});
@@ -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(
<TripleTextBlock
columns={[
{
title: "Stacked headline",
description: "Long stacked body.",
lgTitle: "Wide headline",
lgDescription: "Short wide body.",
},
]}
/>,
);
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(
<TripleTextBlock
columns={[
{
title: "Only headline",
description: "Only body.",
},
]}
/>,
);
expect(screen.getAllByRole("heading", { name: "Only headline" })).toHaveLength(
1,
);
expect(screen.getByText("Only body.")).toBeInTheDocument();
});
});
+37
View File
@@ -222,4 +222,41 @@ describe("QuoteBlock Component", () => {
screen.queryByText("The Tyranny of Structurelessness"),
).not.toBeInTheDocument();
});
test("statement variant renders dual paragraphs without attribution", () => {
render(
<QuoteBlock
variant="statement"
id="about-test-quote"
quote="First paragraph of the statement."
quoteSecondary="Second paragraph of the statement."
/>,
);
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(
<QuoteBlock
variant="statement"
quote="Only one paragraph"
/>,
);
expect(consoleSpy).toHaveBeenCalledWith(
"QuoteBlock: statement variant requires non-empty quote and quoteSecondary",
);
consoleSpy.mockRestore();
});
});
+7 -2
View File
@@ -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";