Implement about page
This commit is contained in:
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 size’s header / type / panel styles (e.g. FAQ: `s` + `lgSize="m"`).
|
||||
*/
|
||||
lgSize?: AccordionSizeValue;
|
||||
/**
|
||||
* From `xl` up, override with this size (e.g. FAQ: `xlSize="l"` at wide desktop — Figma **22135:890328**).
|
||||
*/
|
||||
xlSize?: AccordionSizeValue;
|
||||
defaultOpen?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface AccordionViewProps {
|
||||
title: string;
|
||||
subhead?: string;
|
||||
children?: ReactNode;
|
||||
size: AccordionSizeValue;
|
||||
lgSize?: AccordionSizeValue;
|
||||
xlSize?: AccordionSizeValue;
|
||||
isOpen: boolean;
|
||||
panelId: string;
|
||||
buttonId: string;
|
||||
onToggle: () => void;
|
||||
className: string;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user