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
@@ -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";