Implement use cases page
This commit is contained in:
@@ -13,6 +13,9 @@ import ImageGlyphIcon from "./image.svg";
|
||||
import LogOutIcon from "./log_out.svg";
|
||||
import MailIcon from "./mail.svg";
|
||||
import MarkdownCopyIcon from "./markdown_copy.svg";
|
||||
import Numeric1CircleIcon from "./numeric-1-circle.svg";
|
||||
import Numeric2CircleIcon from "./numeric-2-circle.svg";
|
||||
import Numeric3CircleIcon from "./numeric-3-circle.svg";
|
||||
import NumberIcon from "./number.svg";
|
||||
import PictureAsPdfIcon from "./picture_as_pdf.svg";
|
||||
import TagsIcon from "./tags.svg";
|
||||
@@ -31,6 +34,9 @@ export const ICON_NAME_OPTIONS = [
|
||||
"log_out",
|
||||
"mail",
|
||||
"markdown_copy",
|
||||
"numeric_1_circle",
|
||||
"numeric_2_circle",
|
||||
"numeric_3_circle",
|
||||
"number",
|
||||
"picture_as_pdf",
|
||||
"tags",
|
||||
@@ -57,6 +63,9 @@ const iconMap: Record<IconName, SvgComponent> = {
|
||||
log_out: LogOutIcon,
|
||||
mail: MailIcon,
|
||||
markdown_copy: MarkdownCopyIcon,
|
||||
numeric_1_circle: Numeric1CircleIcon,
|
||||
numeric_2_circle: Numeric2CircleIcon,
|
||||
numeric_3_circle: Numeric3CircleIcon,
|
||||
number: NumberIcon,
|
||||
picture_as_pdf: PictureAsPdfIcon,
|
||||
tags: TagsIcon,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 7V9H12V17H14V7H10ZM12 2C13.3132 2 14.6136 2.25866 15.8268 2.7612C17.0401 3.26375 18.1425 4.00035 19.0711 4.92893C19.9997 5.85752 20.7362 6.95991 21.2388 8.17317C21.7413 9.38642 22 10.6868 22 12C22 14.6522 20.9464 17.1957 19.0711 19.0711C17.1957 20.9464 14.6522 22 12 22C10.6868 22 9.38642 21.7413 8.17317 21.2388C6.95991 20.7362 5.85752 19.9997 4.92893 19.0711C3.05357 17.1957 2 14.6522 2 12C2 9.34784 3.05357 6.8043 4.92893 4.92893C6.8043 3.05357 9.34784 2 12 2Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 596 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 7V9H13V11H11C10.4696 11 9.96086 11.2107 9.58579 11.5858C9.21071 11.9609 9 12.4696 9 13V17H11H15V15H11V13H13C13.5304 13 14.0391 12.7893 14.4142 12.4142C14.7893 12.0391 15 11.5304 15 11V9C15 8.46957 14.7893 7.96086 14.4142 7.58579C14.0391 7.21071 13.5304 7 13 7H9ZM12 2C13.3132 2 14.6136 2.25866 15.8268 2.7612C17.0401 3.26375 18.1425 4.00035 19.0711 4.92893C19.9997 5.85752 20.7362 6.95991 21.2388 8.17317C21.7413 9.38642 22 10.6868 22 12C22 14.6522 20.9464 17.1957 19.0711 19.0711C17.1957 20.9464 14.6522 22 12 22C10.6868 22 9.38642 21.7413 8.17317 21.2388C6.95991 20.7362 5.85752 19.9997 4.92893 19.0711C3.05357 17.1957 2 14.6522 2 12C2 9.34784 3.05357 6.8043 4.92893 4.92893C6.8043 3.05357 9.34784 2 12 2Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 839 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 15V13.5C15 13.1022 14.842 12.7206 14.5607 12.4393C14.2794 12.158 13.8978 12 13.5 12C13.8978 12 14.2794 11.842 14.5607 11.5607C14.842 11.2794 15 10.8978 15 10.5V9C15 7.89 14.1 7 13 7H9V9H13V11H11V13H13V15H9V17H13C13.5304 17 14.0391 16.7893 14.4142 16.4142C14.7893 16.0391 15 15.5304 15 15ZM12 2C13.3132 2 14.6136 2.25866 15.8268 2.7612C17.0401 3.26375 18.1425 4.00035 19.0711 4.92893C19.9997 5.85752 20.7362 6.95991 21.2388 8.17317C21.7413 9.38642 22 10.6868 22 12C22 14.6522 20.9464 17.1957 19.0711 19.0711C17.1957 20.9464 14.6522 22 12 22C10.6868 22 9.38642 21.7413 8.17317 21.2388C6.95991 20.7362 5.85752 19.9997 4.92893 19.0711C3.05357 17.1957 2 14.6522 2 12C2 9.34784 3.05357 6.8043 4.92893 4.92893C6.8043 3.05357 9.34784 2 12 2Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 866 B |
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import CaseStudyView from "./CaseStudy.view";
|
||||
import type { CaseStudyProps } from "./CaseStudy.types";
|
||||
|
||||
/**
|
||||
* Figma: Section org lockup ([22112-871524](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22112-871524)): **Card / CaseStudy** — MAC vector (`assets/case-study/`), FNB/BCSM rasters (**21993‑32352** / **32353**).
|
||||
*/
|
||||
const CaseStudyContainer = memo<CaseStudyProps>((props) => {
|
||||
return <CaseStudyView {...props} />;
|
||||
});
|
||||
|
||||
CaseStudyContainer.displayName = "CaseStudy";
|
||||
|
||||
export default CaseStudyContainer;
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const CASE_STUDY_SURFACE_OPTIONS = ["lavender", "neutral", "rose"] as const;
|
||||
|
||||
export type CaseStudySurfaceValue = (typeof CASE_STUDY_SURFACE_OPTIONS)[number];
|
||||
|
||||
export interface CaseStudyProps {
|
||||
surface: CaseStudySurfaceValue;
|
||||
/**
|
||||
* Alt text for built-in raster art (`public/assets/use-cases/`) when **`visual`** is omitted.
|
||||
*/
|
||||
imageAlt?: string;
|
||||
/** Overrides built-in raster with custom slot content when provided. */
|
||||
visual?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { memo } from "react";
|
||||
import type { CaseStudyProps } from "./CaseStudy.types";
|
||||
|
||||
const SURFACE_CLASS: Record<CaseStudyProps["surface"], string> = {
|
||||
lavender: "bg-[var(--color-surface-invert-brand-lavender)]",
|
||||
neutral: "bg-[var(--color-surface-invert-secondary)]",
|
||||
rose: "bg-[var(--color-surface-invert-brand-red)]",
|
||||
};
|
||||
|
||||
/** Default art per tile: PNG composites (FNB/BCSM) or vector Mutual Aid logo. */
|
||||
const SURFACE_ART: Record<CaseStudyProps["surface"], string> = {
|
||||
lavender: "/assets/case-study/case-study-mutual-aid.svg",
|
||||
neutral: "/assets/use-cases/case-study-food-not-bombs.png",
|
||||
rose: "/assets/use-cases/case-study-boulder-county-street-medics.png",
|
||||
};
|
||||
|
||||
/** Figma: ~23px corner (“Card / CaseStudy” shells). */
|
||||
const CASE_TILE_RADIUS_CLASS = "rounded-[23.093px]";
|
||||
|
||||
function CaseStudyView({
|
||||
surface,
|
||||
imageAlt = "",
|
||||
visual,
|
||||
className = "",
|
||||
}: CaseStudyProps) {
|
||||
return (
|
||||
<div
|
||||
data-figma-node="21993-32352"
|
||||
className={`relative flex h-[305px] w-[305px] shrink-0 overflow-hidden ${CASE_TILE_RADIUS_CLASS} ${SURFACE_CLASS[surface]} ${className}`.trim()}
|
||||
>
|
||||
{visual ? (
|
||||
<div className="flex size-full items-center justify-center p-2">{visual}</div>
|
||||
) : (
|
||||
<Image
|
||||
src={SURFACE_ART[surface]}
|
||||
alt={imageAlt}
|
||||
width={305}
|
||||
height={305}
|
||||
unoptimized={
|
||||
SURFACE_ART[surface].endsWith(".svg") ? true : undefined
|
||||
}
|
||||
className={`pointer-events-none select-none ${
|
||||
surface === "lavender" ? "object-contain object-center" : "object-cover"
|
||||
}`}
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CaseStudyView.displayName = "CaseStudyView";
|
||||
|
||||
export default memo(CaseStudyView);
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default } from "./CaseStudy.container";
|
||||
export type { CaseStudyProps, CaseStudySurfaceValue } from "./CaseStudy.types";
|
||||
export { CASE_STUDY_SURFACE_OPTIONS } from "./CaseStudy.types";
|
||||
@@ -1,16 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { memo, useId } from "react";
|
||||
import { IconView } from "./Icon.view";
|
||||
import type { IconProps } from "./Icon.types";
|
||||
|
||||
const IconContainer = memo<IconProps>(
|
||||
({ icon, title, description, className = "", onClick }) => {
|
||||
({ icon, title, description, className = "", onClick, interactive: interactiveProp = true }) => {
|
||||
const layoutTitleId = useId();
|
||||
|
||||
const handleClick = () => {
|
||||
if (!interactiveProp) return;
|
||||
if (onClick) onClick();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!interactiveProp) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleClick();
|
||||
@@ -23,6 +27,8 @@ const IconContainer = memo<IconProps>(
|
||||
title={title}
|
||||
description={description}
|
||||
className={className}
|
||||
interactive={interactiveProp}
|
||||
layoutTitleId={layoutTitleId}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,11 @@ export interface IconProps {
|
||||
description: string;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
/**
|
||||
* When false, renders a static tile (no button semantics or focus ring).
|
||||
* @default true
|
||||
*/
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
export interface IconViewProps {
|
||||
@@ -11,6 +16,9 @@ export interface IconViewProps {
|
||||
title: string;
|
||||
description: string;
|
||||
className: string;
|
||||
interactive: boolean;
|
||||
/** Stable id for `aria-labelledby` when `interactive` is false. */
|
||||
layoutTitleId: string;
|
||||
onClick: () => void;
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
@@ -7,30 +7,41 @@ export function IconView({
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
interactive,
|
||||
layoutTitleId,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
}: IconViewProps) {
|
||||
const interactionClass = interactive
|
||||
? "cursor-pointer transition-all duration-200 hover:scale-[1.02] hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-brand-primary)] focus:ring-offset-2"
|
||||
: "cursor-default";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border border-[var(--color-border-default-primary)] flex flex-col h-[350px] items-start justify-between p-[var(--measures-spacing-020)] relative w-[288px] bg-transparent cursor-pointer transition-all duration-200 hover:scale-[1.02] hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-brand-primary)] focus:ring-offset-2 ${className}`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`${title}: ${description}`}
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
data-figma-node="22084-859659"
|
||||
className={`relative flex h-[350px] w-full min-w-[240px] max-w-[480px] flex-col items-start justify-between border border-solid border-[var(--color-border-default-secondary)] bg-transparent p-[var(--measures-spacing-020)] ${interactionClass} ${className}`}
|
||||
tabIndex={interactive ? 0 : undefined}
|
||||
role={interactive ? "button" : "article"}
|
||||
aria-label={interactive ? `${title}: ${description}` : undefined}
|
||||
aria-labelledby={interactive ? undefined : layoutTitleId}
|
||||
onClick={interactive ? onClick : undefined}
|
||||
onKeyDown={interactive ? onKeyDown : undefined}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="shrink-0 w-[36px] h-[36px] flex items-center justify-center">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
{/* Title - Centered with auto space above and below */}
|
||||
<h3 className="font-inter font-normal text-[32px] leading-[36px] text-[var(--color-content-default-primary)] w-full">
|
||||
{/* Title — Figma XX Large / Label (32 / 36) */}
|
||||
<h3
|
||||
id={interactive ? undefined : layoutTitleId}
|
||||
className="w-full text-left font-inter text-[32px] font-normal leading-[36px] text-[var(--color-content-default-primary)]"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="font-inter font-medium text-[10px] leading-[14px] uppercase text-[var(--color-content-default-primary)] w-full">
|
||||
{/* Body: X Small / Paragraph (12/16) per Figma; 14/20 on md–<lg only (Section 22084-859062) */}
|
||||
<p className="w-full text-left font-inter font-normal text-[length:var(--text-x-small-paragraph)] leading-[length:var(--text-x-small-paragraph--line-height)] text-[var(--color-content-default-primary)] md:text-[length:var(--text-small-paragraph)] md:leading-[length:var(--text-small-paragraph--line-height)] lg:text-[length:var(--text-x-small-paragraph)] lg:leading-[length:var(--text-x-small-paragraph--line-height)]">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -43,6 +43,7 @@ const RuleContainer = memo<RuleProps>(
|
||||
bottomStatusLabel,
|
||||
bottomLinks,
|
||||
recommended = false,
|
||||
templateGridFigmaShell = false,
|
||||
}) => {
|
||||
const size = sizeProp ?? "L";
|
||||
|
||||
@@ -98,6 +99,7 @@ const RuleContainer = memo<RuleProps>(
|
||||
bottomStatusLabel={bottomStatusLabel}
|
||||
bottomLinks={bottomLinks}
|
||||
recommended={recommended}
|
||||
templateGridFigmaShell={templateGridFigmaShell}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -66,6 +66,10 @@ export interface RuleProps {
|
||||
* `expanded` — Figma `22142:898446` compact `Card / Rule` only.
|
||||
*/
|
||||
recommended?: boolean;
|
||||
/**
|
||||
* Marketing **GovernanceTemplateGrid** / RuleStack shell (Figma [22085:860413](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-860413&m=dev); card shell **18375:22616**).
|
||||
*/
|
||||
templateGridFigmaShell?: boolean;
|
||||
}
|
||||
|
||||
export interface RuleViewProps {
|
||||
@@ -90,4 +94,5 @@ export interface RuleViewProps {
|
||||
bottomStatusLabel?: string;
|
||||
bottomLinks?: RuleBottomLink[];
|
||||
recommended?: boolean;
|
||||
templateGridFigmaShell?: boolean;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export function RuleView({
|
||||
bottomStatusLabel,
|
||||
bottomLinks,
|
||||
recommended = false,
|
||||
templateGridFigmaShell = false,
|
||||
}: RuleViewProps) {
|
||||
const t = useTranslation("ruleCard");
|
||||
const ariaLabel = t("ariaLabel")?.replace("{title}", title) || title;
|
||||
@@ -84,7 +85,13 @@ export function RuleView({
|
||||
// Logo/Icon dimensions (inner circle) after Figma header `pl-1 pr-2 py-2` in icon cell
|
||||
// (Card / Rule — e.g. `22143:900771` / `19706:12110`); outer column width holds padding + this.
|
||||
const logoSize = 103; // `next/image` prop; actual box comes from `logoContainerClass`
|
||||
const logoContainerClass = `
|
||||
const logoContainerClass = templateGridFigmaShell
|
||||
? `
|
||||
max-[639px]:size-[56px]
|
||||
min-[640px]:max-[1023px]:size-[64px]
|
||||
min-[1024px]:size-[88px]
|
||||
`
|
||||
: `
|
||||
max-[639px]:size-[56px]
|
||||
min-[640px]:max-[1023px]:size-[64px]
|
||||
min-[1024px]:max-[1439px]:size-[56px]
|
||||
@@ -93,22 +100,51 @@ export function RuleView({
|
||||
|
||||
// Title typography - use CSS responsive classes
|
||||
const showRecommendedTag = recommended && !expanded;
|
||||
const titleClass = `
|
||||
const titleClass = templateGridFigmaShell
|
||||
? `
|
||||
max-[639px]:font-inter max-[639px]:font-bold max-[639px]:text-[20px] max-[639px]:leading-[28px]
|
||||
min-[640px]:max-[1023px]:font-bricolage-grotesque min-[640px]:max-[1023px]:font-bold min-[640px]:max-[1023px]:text-[28px] min-[640px]:max-[1023px]:leading-[36px]
|
||||
min-[1024px]:max-[1439px]:font-bricolage-grotesque min-[1024px]:max-[1439px]:font-extrabold min-[1024px]:max-[1439px]:text-[36px] min-[1024px]:max-[1439px]:leading-[44px]
|
||||
min-[1440px]:font-bricolage-grotesque min-[1440px]:font-extrabold min-[1440px]:text-[36px] min-[1440px]:leading-[44px]
|
||||
`
|
||||
: `
|
||||
max-[639px]:font-inter max-[639px]:font-bold max-[639px]:text-[20px] max-[639px]:leading-[28px]
|
||||
min-[640px]:max-[1023px]:font-bricolage-grotesque min-[640px]:max-[1023px]:font-bold min-[640px]:max-[1023px]:text-[28px] min-[640px]:max-[1023px]:leading-[36px]
|
||||
min-[1024px]:max-[1439px]:font-bricolage-grotesque min-[1024px]:max-[1439px]:font-bold min-[1024px]:max-[1439px]:text-[24px] min-[1024px]:max-[1439px]:leading-[32px]
|
||||
min-[1440px]:font-bricolage-grotesque min-[1440px]:font-extrabold min-[1440px]:text-[36px] min-[1440px]:leading-[44px]
|
||||
`;
|
||||
|
||||
// Description typography
|
||||
const descriptionClass = isLarge
|
||||
? "font-inter font-medium text-[18px] leading-[24px]"
|
||||
: isMedium
|
||||
? "font-inter font-medium text-[14px] leading-[16px]"
|
||||
? templateGridFigmaShell
|
||||
? "font-inter font-medium text-[14px] leading-[16px] min-[1024px]:max-[1439px]:text-[18px] min-[1024px]:max-[1439px]:leading-[24px]"
|
||||
: "font-inter font-medium text-[14px] leading-[16px]"
|
||||
: isSmall
|
||||
? "font-inter font-medium text-[14px] leading-[16px]" // S: 14px, medium, Inter
|
||||
: "font-inter font-medium text-[12px] leading-[14px]"; // XS: 12px, medium, Inter
|
||||
|
||||
const headerIconCellClass = templateGridFigmaShell
|
||||
? `
|
||||
flex shrink-0 items-center justify-center
|
||||
pl-[4px] pr-[8px] py-[8px]
|
||||
max-[639px]:w-[72px]
|
||||
min-[640px]:max-[1023px]:w-[80px]
|
||||
min-[1024px]:max-[1439px]:w-[130px]
|
||||
min-[1440px]:w-[119px]
|
||||
`
|
||||
: `
|
||||
flex shrink-0 items-center justify-center
|
||||
pl-[4px] pr-[8px] py-[8px]
|
||||
max-[639px]:w-[72px]
|
||||
min-[640px]:max-[1023px]:w-[80px]
|
||||
min-[1024px]:w-[119px]
|
||||
`;
|
||||
|
||||
const titleColumnMinHClass = templateGridFigmaShell
|
||||
? "min-h-[72px] min-[640px]:min-h-[80px] min-[1024px]:max-[1439px]:min-h-[136px] min-[1440px]:min-h-[136px]"
|
||||
: "min-h-[72px] min-[640px]:min-h-[80px] min-[1024px]:min-h-[88px] min-[1440px]:min-h-[136px]";
|
||||
|
||||
// Render logo/icon
|
||||
const renderLogo = () => {
|
||||
if (logoUrl) {
|
||||
@@ -236,15 +272,7 @@ export function RuleView({
|
||||
"
|
||||
>
|
||||
{renderLogo() && (
|
||||
<div
|
||||
className="
|
||||
flex shrink-0 items-center justify-center
|
||||
pl-[4px] pr-[8px] py-[8px]
|
||||
max-[639px]:w-[72px]
|
||||
min-[640px]:max-[1023px]:w-[80px]
|
||||
min-[1024px]:w-[119px]
|
||||
"
|
||||
>
|
||||
<div className={headerIconCellClass}>
|
||||
{renderLogo()}
|
||||
</div>
|
||||
)}
|
||||
@@ -252,7 +280,7 @@ export function RuleView({
|
||||
<div
|
||||
className={`
|
||||
flex min-w-0 flex-1 flex-col justify-center
|
||||
min-h-[72px] min-[640px]:min-h-[80px] min-[1024px]:min-h-[88px] min-[1440px]:min-h-[136px]
|
||||
${titleColumnMinHClass}
|
||||
border-l border-solid border-[var(--color-content-invert-primary)]
|
||||
`}
|
||||
>
|
||||
@@ -410,9 +438,17 @@ export function RuleView({
|
||||
) : (
|
||||
/* Collapsed State: Description */
|
||||
description && (
|
||||
<div className="flex items-center justify-center relative shrink-0 w-full">
|
||||
<div
|
||||
className={
|
||||
templateGridFigmaShell
|
||||
? "relative flex w-full shrink-0 items-center justify-start"
|
||||
: "relative flex w-full shrink-0 items-center justify-center"
|
||||
}
|
||||
>
|
||||
<p
|
||||
className={`${descriptionClass} cursor-inherit text-[var(--color-content-invert-primary)] flex-1`}
|
||||
className={`${descriptionClass} cursor-inherit text-[var(--color-content-invert-primary)] ${
|
||||
templateGridFigmaShell ? "w-full text-left" : "flex-1"
|
||||
}`}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
|
||||
@@ -137,7 +137,7 @@ const Footer = memo(() => {
|
||||
md:gap-[var(--spacing-scale-032)]"
|
||||
>
|
||||
<Link
|
||||
href="#"
|
||||
href="/use-cases"
|
||||
className={`w-full text-left ${primaryLinkClass} md:w-auto md:text-right`}
|
||||
>
|
||||
{t("navigation.useCases")}
|
||||
|
||||
@@ -17,14 +17,9 @@ type MenuClusterSize = "X Small" | "Small" | "Medium" | "Large" | "X Large";
|
||||
|
||||
/** Map responsive `NavSize` breakpoints to Figma menu item sizes (shared by nav links + login). */
|
||||
const NAV_SIZE_TO_MENU_ITEM_SIZE: Record<NavSize, MenuClusterSize> = {
|
||||
default: "Small",
|
||||
xsmall: "X Small",
|
||||
xsmallUseCases: "X Small",
|
||||
home: "X Small",
|
||||
homeMd: "Medium",
|
||||
homeUseCases: "Small",
|
||||
large: "Large",
|
||||
largeUseCases: "Large",
|
||||
homeXlarge: "X Large",
|
||||
xlarge: "X Large",
|
||||
};
|
||||
@@ -77,7 +72,7 @@ const TopContainer = memo<TopProps>(
|
||||
|
||||
// Navigation items with translations
|
||||
const navigationItems = [
|
||||
{ href: "#", text: t("navigation.useCases"), extraPadding: true },
|
||||
{ href: "/use-cases", text: t("navigation.useCases"), extraPadding: true },
|
||||
{ href: "/learn", text: t("navigation.learn") },
|
||||
{ href: "/about", text: t("navigation.about") },
|
||||
];
|
||||
@@ -134,7 +129,7 @@ const TopContainer = memo<TopProps>(
|
||||
// folderTop: inverse mode (black text) for smallest breakpoints (xsmall/home)
|
||||
// folderTop: default mode (yellow text) for 640px+ breakpoints (homeMd/large/homeXlarge/xlarge)
|
||||
// false folderTop: always default mode (yellow text on dark background)
|
||||
const isSmallBreakpoint = size === "xsmall" || size === "home";
|
||||
const isSmallBreakpoint = size === "xsmall";
|
||||
const mode = folderTop && isSmallBreakpoint ? "inverse" : "default";
|
||||
|
||||
const label = loggedIn ? t("buttons.profile") : t("buttons.logIn");
|
||||
|
||||
@@ -9,15 +9,11 @@ export interface TopProps {
|
||||
logIn?: boolean;
|
||||
}
|
||||
|
||||
/** Breakpoint slot passed from {@link Top.view} into nav render helpers. */
|
||||
export type NavSize =
|
||||
| "default"
|
||||
| "xsmall"
|
||||
| "xsmallUseCases"
|
||||
| "home"
|
||||
| "homeMd"
|
||||
| "homeUseCases"
|
||||
| "large"
|
||||
| "largeUseCases"
|
||||
| "homeXlarge"
|
||||
| "xlarge";
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ const VARIANT_STYLES: Record<
|
||||
},
|
||||
};
|
||||
|
||||
/** Figma **Section/AskOrganizer** [18116:15960](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=18116-15960&m=dev) (`lg` shell + type + button). */
|
||||
const AskOrganizerContainer = memo<AskOrganizerProps>(
|
||||
({
|
||||
title,
|
||||
|
||||
@@ -28,6 +28,7 @@ function AskOrganizerView({
|
||||
aria-labelledby={labelledBy}
|
||||
aria-label={labelledBy ? undefined : ariaLabel}
|
||||
tabIndex={-1}
|
||||
data-figma-node="18116-15960"
|
||||
>
|
||||
<div className={`flex flex-col ${contentGap}`}>
|
||||
{/* Content Lockup */}
|
||||
@@ -41,13 +42,15 @@ function AskOrganizerView({
|
||||
/>
|
||||
|
||||
{/* Button */}
|
||||
<div className={buttonContainerClass}>
|
||||
<div
|
||||
className={`${buttonContainerClass} flex-wrap gap-y-[var(--spacing-scale-016)]`}
|
||||
>
|
||||
<Button
|
||||
{...(buttonHref ? { href: buttonHref } : {})}
|
||||
size="large"
|
||||
buttonType="filled"
|
||||
palette={variant === "inverse" ? "inverse" : "default"}
|
||||
className="md:!px-[var(--spacing-scale-020)] md:!py-[var(--spacing-scale-012)] md:!text-[24px] md:!leading-[28px]"
|
||||
className="!px-[var(--spacing-scale-016)] !py-[var(--spacing-scale-012)]"
|
||||
onClick={onContactClick}
|
||||
ariaLabel={ariaLabel}
|
||||
data-testid="ask-organizer-cta"
|
||||
|
||||
@@ -10,11 +10,17 @@ import type { GovernanceTemplateCatalogEntry } from "../../../../lib/templates/g
|
||||
export interface GovernanceTemplateGridProps {
|
||||
entries: GovernanceTemplateCatalogEntry[];
|
||||
onTemplateClick: (_slug: string) => void;
|
||||
/**
|
||||
* When true, use project **`md`** (640px) for a 2-column grid (e.g. `/use-cases`).
|
||||
* Default keeps the template shell break at **768px**.
|
||||
*/
|
||||
twoColumnsFromMd?: boolean;
|
||||
}
|
||||
|
||||
export function GovernanceTemplateGrid({
|
||||
entries,
|
||||
onTemplateClick,
|
||||
twoColumnsFromMd = false,
|
||||
}: GovernanceTemplateGridProps) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
@@ -44,13 +50,21 @@ export function GovernanceTemplateGrid({
|
||||
: "M"
|
||||
: "M";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
const gridLayoutClasses = twoColumnsFromMd
|
||||
? `
|
||||
flex flex-col gap-[18px]
|
||||
md:grid md:grid-cols-2 md:gap-[18px]
|
||||
lg:gap-[24px]
|
||||
`
|
||||
: `
|
||||
flex flex-col gap-[18px]
|
||||
min-[768px]:grid min-[768px]:grid-cols-2 min-[768px]:gap-[18px]
|
||||
min-[1024px]:gap-[24px]
|
||||
`}
|
||||
`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={gridLayoutClasses}
|
||||
>
|
||||
{entries.map((card) => (
|
||||
<Rule
|
||||
@@ -58,19 +72,20 @@ export function GovernanceTemplateGrid({
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
recommended={card.recommended === true}
|
||||
templateGridFigmaShell
|
||||
size={cardSize}
|
||||
className={`
|
||||
select-none
|
||||
cursor-pointer
|
||||
max-[639px]:rounded-[var(--measures-radius-200,8px)]
|
||||
min-[640px]:max-[1023px]:rounded-[var(--measures-radius-300,12px)]
|
||||
min-[1024px]:rounded-[var(--radius-measures-radius-small)]
|
||||
min-[1024px]:rounded-[var(--radius-measures-radius-large)]
|
||||
max-[639px]:pb-[24px] max-[639px]:pt-[12px] max-[639px]:px-[12px]
|
||||
min-[640px]:max-[1023px]:p-[24px]
|
||||
min-[1024px]:max-[1439px]:p-[16px]
|
||||
min-[1024px]:max-[1439px]:p-[24px]
|
||||
min-[1440px]:p-[24px]
|
||||
max-[1023px]:gap-[18px]
|
||||
min-[1024px]:max-[1439px]:gap-[12px]
|
||||
min-[1024px]:max-[1439px]:gap-[10px]
|
||||
min-[1440px]:gap-[10px]
|
||||
`}
|
||||
icon={
|
||||
@@ -84,7 +99,7 @@ export function GovernanceTemplateGrid({
|
||||
cursor-inherit
|
||||
max-[639px]:w-[40px] max-[639px]:h-[40px]
|
||||
min-[640px]:max-[1023px]:w-[56px] min-[640px]:max-[1023px]:h-[56px]
|
||||
min-[1024px]:max-[1439px]:w-[56px] min-[1024px]:max-[1439px]:h-[56px]
|
||||
min-[1024px]:max-[1439px]:w-[90px] min-[1024px]:max-[1439px]:h-[90px]
|
||||
min-[1440px]:w-[90px] min-[1440px]:h-[90px]
|
||||
"
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
/**
|
||||
* Placeholder grid matching GovernanceTemplateGrid layout (loading state).
|
||||
*/
|
||||
export function GovernanceTemplateGridSkeleton({ count }: { count: number }) {
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
export function GovernanceTemplateGridSkeleton({
|
||||
count,
|
||||
twoColumnsFromMd = false,
|
||||
}: {
|
||||
count: number;
|
||||
twoColumnsFromMd?: boolean;
|
||||
}) {
|
||||
const gridLayoutClasses = twoColumnsFromMd
|
||||
? `
|
||||
flex flex-col gap-[18px]
|
||||
md:grid md:grid-cols-2 md:gap-[18px]
|
||||
lg:gap-[24px]
|
||||
`
|
||||
: `
|
||||
flex flex-col gap-[18px]
|
||||
min-[768px]:grid min-[768px]:grid-cols-2 min-[768px]:gap-[18px]
|
||||
min-[1024px]:gap-[24px]
|
||||
"
|
||||
`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={gridLayoutClasses}
|
||||
aria-busy
|
||||
aria-label="Loading templates"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useId } from "react";
|
||||
import GroupsView from "./Groups.view";
|
||||
import type { GroupsProps } from "./Groups.types";
|
||||
|
||||
/**
|
||||
* Figma: **Section** instance [**22085-860411**](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-860411&m=dev) (`xl`: **Scale/160** horizontal padding);
|
||||
* Card group ref [**22084-859062**](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22084-859062&m=dev); legacy **22084-859470**.
|
||||
*/
|
||||
const GroupsContainer = memo<GroupsProps>(({ title, items, className = "" }) => {
|
||||
const reactId = useId();
|
||||
const headingId = `${reactId}-groups-heading`;
|
||||
|
||||
return (
|
||||
<GroupsView
|
||||
title={title}
|
||||
items={items}
|
||||
headingId={headingId}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
GroupsContainer.displayName = "Groups";
|
||||
|
||||
export default GroupsContainer;
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface GroupsItem {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface GroupsProps {
|
||||
title: string;
|
||||
items: GroupsItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface GroupsViewProps extends GroupsProps {
|
||||
headingId: string;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import Icon from "../../cards/Icon";
|
||||
import type { GroupsViewProps } from "./Groups.types";
|
||||
|
||||
function GroupsView({ title, items, headingId, className = "" }: GroupsViewProps) {
|
||||
return (
|
||||
<section
|
||||
data-figma-node="22085-860411"
|
||||
aria-labelledby={headingId}
|
||||
className={`bg-transparent px-0 py-[var(--spacing-scale-064)] lg:px-[var(--spacing-scale-064)] xl:px-[var(--spacing-scale-160)] ${className}`.trim()}
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-[560px] flex-col items-center gap-[var(--spacing-scale-032)] md:max-w-[1280px] md:gap-[var(--spacing-scale-048)]">
|
||||
<h2
|
||||
id={headingId}
|
||||
className="w-full shrink-0 text-center font-bricolage-grotesque text-[28px] font-bold leading-9 text-[var(--color-content-default-primary)] md:text-[32px] md:leading-10 lg:text-[40px] lg:leading-[52px]"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<div className="flex w-full shrink-0 flex-col bg-[var(--color-surface-default-primary)] max-md:[&>*+*]:-mt-px md:grid md:grid-cols-2 md:gap-px md:bg-[var(--color-border-default-primary)] md:[&>*+*]:mt-0 lg:grid-cols-4">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={`${item.title}-${index}`}
|
||||
className="flex min-h-[350px] min-w-0 shrink-0 justify-center bg-[var(--color-surface-default-primary)] md:justify-stretch"
|
||||
>
|
||||
<Icon
|
||||
icon={item.icon}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
interactive={false}
|
||||
className="w-full min-w-0 max-w-none shrink-0"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
GroupsView.displayName = "GroupsView";
|
||||
|
||||
export default memo(GroupsView);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./Groups.container";
|
||||
export type { GroupsProps, GroupsItem } from "./Groups.types";
|
||||
@@ -5,7 +5,7 @@ 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). */
|
||||
/** Figma: portrait variants standard | compact | extended; **`statement`** = Section/Quote (22137‑890679; **`lg`** single paragraph **21967‑24638** — About + use cases). */
|
||||
const QuoteBlockContainer = memo<QuoteBlockProps>(
|
||||
({
|
||||
variant: variantProp = "standard",
|
||||
@@ -19,7 +19,6 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
|
||||
fallbackAvatarSrc = "/assets/Quote_Avatar.svg",
|
||||
onError,
|
||||
}) => {
|
||||
const variant = variantProp;
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
|
||||
@@ -86,12 +85,12 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
|
||||
},
|
||||
};
|
||||
|
||||
const config = variants[variant] || variants.standard;
|
||||
const config = variants[variantProp] || variants.standard;
|
||||
|
||||
// Use provided ID or generate a stable one based on content
|
||||
const baseId =
|
||||
id ||
|
||||
(variant === "statement"
|
||||
(variantProp === "statement"
|
||||
? "statement-quote"
|
||||
: `quote-${author.toLowerCase().replace(/\s+/g, "-")}`);
|
||||
const quoteId = `${baseId}-content`;
|
||||
@@ -124,7 +123,7 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
|
||||
};
|
||||
|
||||
// Validate required props
|
||||
if (variant === "statement") {
|
||||
if (variantProp === "statement") {
|
||||
if (!quote?.trim() || !quoteSecondary?.trim()) {
|
||||
logger.error(
|
||||
"QuoteBlock: statement variant requires non-empty quote and quoteSecondary",
|
||||
@@ -134,7 +133,7 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
|
||||
type: "missing_props",
|
||||
message:
|
||||
"QuoteBlock statement variant requires quote and quoteSecondary",
|
||||
quote: !!quote?.trim() && !!quoteSecondary?.trim(),
|
||||
quote: !!(quote?.trim() && quoteSecondary?.trim()),
|
||||
});
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -5,13 +5,11 @@ export type QuoteBlockVariantValue =
|
||||
| "statement";
|
||||
|
||||
export interface QuoteBlockProps {
|
||||
/** Default `standard` (home portrait quote). `statement` is About-only dual-paragraph layout; isolated branch in QuoteBlock.view. */
|
||||
/** Default `standard` (home portrait quote). **`statement`** = yellow Section / Quote (**About** + **`/use-cases`** — two paragraphs below **`lg`**, one paragraph from **`lg`**; [21967-24638](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=21967-24638&m=dev)). */
|
||||
variant?: QuoteBlockVariantValue;
|
||||
className?: string;
|
||||
quote?: string;
|
||||
/**
|
||||
* Second paragraph for **`statement`** variant (Figma Section/Quote 22137:890679).
|
||||
*/
|
||||
/** Second paragraph for **`statement`** (Section/Quote); merged into one `<p>` from **`lg`**. */
|
||||
quoteSecondary?: string;
|
||||
author?: string;
|
||||
source?: string;
|
||||
@@ -39,7 +37,7 @@ export interface VariantConfig {
|
||||
source: string;
|
||||
showDecor: boolean;
|
||||
/**
|
||||
* When true, render Figma **Section/Quote** layout (yellow surface, dual paragraphs, no attribution).
|
||||
* When true, render Figma **Section/Quote** layout (yellow surface; stacked copy below **`lg`**, single paragraph from **`lg`**; no attribution).
|
||||
*/
|
||||
statementLayout?: boolean;
|
||||
}
|
||||
|
||||
@@ -26,15 +26,12 @@ function QuoteBlockView({
|
||||
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
|
||||
data-figma-node="21967-24638"
|
||||
className={`${config.container} ${className}`.trim()}
|
||||
aria-labelledby={quoteId}
|
||||
role="region"
|
||||
@@ -43,12 +40,18 @@ function QuoteBlockView({
|
||||
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}
|
||||
<div className="relative z-10 flex w-full min-w-0 shrink-0 flex-col items-center text-center">
|
||||
<p
|
||||
id={quoteId}
|
||||
className={`${statementTextClass} mb-0 flex w-full min-w-0 flex-col gap-9 text-center md:gap-[length:var(--text-xx-large-heading--line-height)] lg:block lg:gap-0`}
|
||||
>
|
||||
<span className="block lg:inline">{quote}</span>
|
||||
{quoteSecondary ? (
|
||||
<>
|
||||
<span className="hidden lg:inline">{" "}</span>
|
||||
<span className="block lg:inline">{quoteSecondary}</span>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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). */
|
||||
/** Figma: Section / Quote — **`shape-qoute.svg`** (22137:890679). */
|
||||
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%)";
|
||||
|
||||
|
||||
@@ -2,11 +2,18 @@
|
||||
|
||||
import { useState, useEffect, memo, useMemo, useCallback } from "react";
|
||||
import { useIsMobile } from "../../../hooks";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import { RelatedArticlesView } from "./RelatedArticles.view";
|
||||
import type { RelatedArticlesProps } from "./RelatedArticles.types";
|
||||
|
||||
const RelatedArticlesContainer = memo<RelatedArticlesProps>(
|
||||
({ relatedPosts, currentPostSlug, slugOrder = [] }) => {
|
||||
({
|
||||
relatedPosts,
|
||||
currentPostSlug,
|
||||
slugOrder = [],
|
||||
variant = "default",
|
||||
}) => {
|
||||
const messages = useMessages();
|
||||
// Memoize filtered posts to prevent unnecessary re-computations
|
||||
const filteredPosts = useMemo(
|
||||
() => relatedPosts.filter((post) => post.slug !== currentPostSlug),
|
||||
@@ -95,6 +102,11 @@ const RelatedArticlesContainer = memo<RelatedArticlesProps>(
|
||||
return () => clearInterval(progressInterval);
|
||||
}, [currentIndex, filteredPosts.length, isMobile]);
|
||||
|
||||
const useCasesHeadingLines =
|
||||
variant === "useCases"
|
||||
? messages.pages.useCases.relatedArticles.title
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<RelatedArticlesView
|
||||
filteredPosts={filteredPosts}
|
||||
@@ -103,6 +115,8 @@ const RelatedArticlesContainer = memo<RelatedArticlesProps>(
|
||||
transformStyle={transformStyle}
|
||||
getProgressStyle={getProgressStyle}
|
||||
onMouseDown={handleMouseDown}
|
||||
variant={variant}
|
||||
useCasesHeadingLines={useCasesHeadingLines}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { BlogPost } from "../../../../lib/content";
|
||||
|
||||
export type RelatedArticlesVariant = "default" | "useCases";
|
||||
|
||||
export interface RelatedArticlesProps {
|
||||
relatedPosts: BlogPost[];
|
||||
currentPostSlug: string;
|
||||
slugOrder?: string[];
|
||||
/**
|
||||
* **`useCases`**: Figma related section — baseline [**22112-872308**](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22112-872308&m=dev),
|
||||
* **`md`** [**22085-863216**](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-863216&m=dev),
|
||||
* **`lg`** [**20711-14231**](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=20711-14231&m=dev) (shell + card row gutter / padding).
|
||||
*/
|
||||
variant?: RelatedArticlesVariant;
|
||||
}
|
||||
|
||||
export interface RelatedArticlesViewProps {
|
||||
@@ -13,4 +21,7 @@ export interface RelatedArticlesViewProps {
|
||||
transformStyle: React.CSSProperties;
|
||||
getProgressStyle: (_index: number) => React.CSSProperties;
|
||||
onMouseDown?: (_e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
variant?: RelatedArticlesVariant;
|
||||
/** Stacked title lines (`pages.useCases.relatedArticles.title`) when `variant="useCases"`. */
|
||||
useCasesHeadingLines?: readonly string[];
|
||||
}
|
||||
|
||||
@@ -8,25 +8,66 @@ export function RelatedArticlesView({
|
||||
transformStyle,
|
||||
getProgressStyle,
|
||||
onMouseDown,
|
||||
variant = "default",
|
||||
useCasesHeadingLines,
|
||||
}: RelatedArticlesViewProps) {
|
||||
if (filteredPosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isUseCases = variant === "useCases";
|
||||
|
||||
return (
|
||||
<section
|
||||
className="py-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)]"
|
||||
className={
|
||||
isUseCases
|
||||
? "px-[var(--spacing-scale-020)] py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-024)] md:py-[var(--spacing-scale-048)] lg:px-[var(--spacing-scale-120)] lg:py-[var(--spacing-scale-064)]"
|
||||
: "py-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)]"
|
||||
}
|
||||
data-testid="related-articles"
|
||||
{...(isUseCases ? { "data-figma-node": "20711-14231" } : {})}
|
||||
>
|
||||
<div className="flex flex-col gap-[var(--spacing-scale-032)] lg:gap-[51px]">
|
||||
<h2 className="text-[32px] lg:text-[44px] leading-[110%] font-medium text-[var(--color-content-inverse-primary)] text-center">
|
||||
Related Articles
|
||||
</h2>
|
||||
<div
|
||||
className={
|
||||
isUseCases
|
||||
? "mx-auto flex w-full max-w-[1440px] flex-col items-center gap-[var(--spacing-scale-024)] md:gap-[var(--spacing-scale-032)]"
|
||||
: "flex flex-col gap-[var(--spacing-scale-032)] lg:gap-[51px]"
|
||||
}
|
||||
>
|
||||
{isUseCases && useCasesHeadingLines?.length ? (
|
||||
<h2 className="mx-auto w-full min-w-0 max-w-[693px] text-center font-bricolage-grotesque text-[28px] font-bold leading-9 text-[var(--color-content-default-primary)] md:text-[32px] md:leading-[40px] lg:text-[40px] lg:leading-[52px]">
|
||||
{/* Baseline 22112-872308: stacked lines; md+ single line; lg 20711-14231: 40/52, max 693px */}
|
||||
<span className="flex flex-col md:hidden">
|
||||
{useCasesHeadingLines.map((line, index) => (
|
||||
<span key={`${index}-${line}`} className="block">
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="hidden md:block">
|
||||
{useCasesHeadingLines.join(" ")}
|
||||
</span>
|
||||
</h2>
|
||||
) : (
|
||||
<h2 className="text-center text-[32px] font-medium leading-[110%] text-[var(--color-content-inverse-primary)] lg:text-[44px]">
|
||||
Related Articles
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{/* Horizontal Articles Row - Carousel on mobile, Scrollable slider on desktop */}
|
||||
<div className="flex justify-center overflow-hidden">
|
||||
<div
|
||||
className={
|
||||
isUseCases
|
||||
? "flex w-full max-w-[1440px] justify-center overflow-hidden"
|
||||
: "flex justify-center overflow-hidden"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`flex gap-0 transition-transform duration-500 ease-in-out ${
|
||||
isUseCases
|
||||
? "lg:gap-[var(--spacing-scale-012)] lg:pl-[var(--spacing-scale-024)]"
|
||||
: ""
|
||||
} ${
|
||||
!isMobile
|
||||
? "overflow-x-auto scrollbar-hide cursor-grab active:cursor-grabbing"
|
||||
: ""
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
export { default } from "./RelatedArticles.container";
|
||||
export type { RelatedArticlesProps } from "./RelatedArticles.types";
|
||||
export type {
|
||||
RelatedArticlesProps,
|
||||
RelatedArticlesVariant,
|
||||
} from "./RelatedArticles.types";
|
||||
|
||||
@@ -28,7 +28,7 @@ declare global {
|
||||
}
|
||||
|
||||
const RuleStackContainer = memo<RuleStackProps>(
|
||||
({ className = "", initialGridEntries }) => {
|
||||
({ className = "", initialGridEntries, translationNamespace, twoColumnsFromMd }) => {
|
||||
const router = useRouter();
|
||||
const [gridEntries, setGridEntries] = useState<TemplateGridCardEntry[] | null>(
|
||||
() => initialGridEntries ?? null,
|
||||
@@ -103,6 +103,8 @@ const RuleStackContainer = memo<RuleStackProps>(
|
||||
className={className}
|
||||
onTemplateClick={handleTemplateClick}
|
||||
gridEntries={gridEntries}
|
||||
translationNamespace={translationNamespace ?? "pages.home.ruleStack"}
|
||||
twoColumnsFromMd={twoColumnsFromMd}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -7,6 +7,15 @@ export interface RuleStackProps {
|
||||
* the client skips the `/api/templates` request.
|
||||
*/
|
||||
initialGridEntries?: TemplateGridCardEntry[];
|
||||
/**
|
||||
* Prefix for `title`, `subtitle`, `button.seeAllTemplates` keys (default
|
||||
* matches home: `pages.home.ruleStack`).
|
||||
*/
|
||||
translationNamespace?: string;
|
||||
/**
|
||||
* Use **`md`** (640px) for two template columns — `/use-cases` Rule Stack.
|
||||
*/
|
||||
twoColumnsFromMd?: boolean;
|
||||
}
|
||||
|
||||
export interface RuleStackViewProps {
|
||||
@@ -14,4 +23,6 @@ export interface RuleStackViewProps {
|
||||
onTemplateClick: (_slug: string) => void;
|
||||
/** `null` while loading curated templates from the API. */
|
||||
gridEntries: TemplateGridCardEntry[] | null;
|
||||
translationNamespace: string;
|
||||
twoColumnsFromMd?: boolean;
|
||||
}
|
||||
|
||||
@@ -7,16 +7,20 @@ import { GovernanceTemplateGrid } from "../GovernanceTemplateGrid";
|
||||
import { GovernanceTemplateGridSkeleton } from "../GovernanceTemplateGrid/GovernanceTemplateGridSkeleton";
|
||||
import type { RuleStackViewProps } from "./RuleStack.types";
|
||||
|
||||
/** Figma **Section / RuleStack** [22085:860413](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-860413&m=dev). */
|
||||
export function RuleStackView({
|
||||
className,
|
||||
onTemplateClick,
|
||||
gridEntries,
|
||||
translationNamespace,
|
||||
twoColumnsFromMd = false,
|
||||
}: RuleStackViewProps) {
|
||||
const t = useTranslation("pages.home.ruleStack");
|
||||
const t = useTranslation(translationNamespace);
|
||||
const buttonText = t("button.seeAllTemplates");
|
||||
|
||||
return (
|
||||
<section
|
||||
data-figma-node="22085-860413"
|
||||
className={`
|
||||
w-full bg-transparent flex flex-col
|
||||
px-[20px] py-[32px]
|
||||
@@ -34,14 +38,19 @@ export function RuleStackView({
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
variant="multi-line"
|
||||
ruleStackDesktopTypeScale
|
||||
/>
|
||||
|
||||
{gridEntries === null ? (
|
||||
<GovernanceTemplateGridSkeleton count={4} />
|
||||
<GovernanceTemplateGridSkeleton
|
||||
count={4}
|
||||
twoColumnsFromMd={twoColumnsFromMd}
|
||||
/>
|
||||
) : (
|
||||
<GovernanceTemplateGrid
|
||||
entries={gridEntries}
|
||||
onTemplateClick={onTemplateClick}
|
||||
twoColumnsFromMd={twoColumnsFromMd}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import UseCasesOrgsView from "./UseCasesOrgs.view";
|
||||
import type { UseCasesOrgsProps } from "./UseCasesOrgs.types";
|
||||
|
||||
/**
|
||||
* Figma: **Orgs** instance ([21993-33687](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=21993-33687&m=dev)) —
|
||||
* **305×305** `CaseStudy` tiles, **8px** gap, **24px** horizontal / **48px** bottom inset.
|
||||
*/
|
||||
const UseCasesOrgsContainer = memo<UseCasesOrgsProps>((props) => {
|
||||
return <UseCasesOrgsView {...props} />;
|
||||
});
|
||||
|
||||
UseCasesOrgsContainer.displayName = "UseCasesOrgs";
|
||||
|
||||
export default UseCasesOrgsContainer;
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface UseCasesOrgsProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface UseCasesOrgsViewProps extends UseCasesOrgsProps {}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import type { UseCasesOrgsViewProps } from "./UseCasesOrgs.types";
|
||||
|
||||
function UseCasesOrgsView({ children, className = "" }: UseCasesOrgsViewProps) {
|
||||
return (
|
||||
<section
|
||||
data-figma-node="21993-33687"
|
||||
className={`bg-[var(--color-surface-default-primary)] px-[var(--spacing-scale-024)] pb-[var(--spacing-scale-048)] ${className}`.trim()}
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-[1440px] flex-wrap content-center items-center justify-center gap-[var(--spacing-scale-008)] lg:flex-nowrap">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
UseCasesOrgsView.displayName = "UseCasesOrgsView";
|
||||
|
||||
export default memo(UseCasesOrgsView);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./UseCasesOrgs.container";
|
||||
export type { UseCasesOrgsProps } from "./UseCasesOrgs.types";
|
||||
@@ -110,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-[52px] md:leading-[110%] text-[var(--color-content-default-brand-primary)]",
|
||||
"font-bricolage-grotesque font-medium text-[36px] leading-[110%] tracking-[0] lg:text-[44px] lg:leading-[1.1] 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:
|
||||
@@ -122,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-[52px] md:leading-[110%] text-[var(--color-content-inverse-primary)]",
|
||||
"font-bricolage-grotesque font-medium text-[36px] leading-[110%] tracking-[0] lg:text-[44px] lg:leading-[1.1] 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:
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useId } from "react";
|
||||
import PageHeaderView from "./PageHeader.view";
|
||||
import type { PageHeaderProps } from "./PageHeader.types";
|
||||
|
||||
/**
|
||||
* Figma: "Type / PageHeader" (21004-15902).
|
||||
* Minimal headline-only: Section/PageHeader (22112-871523); md density **22085-862431** when `sectionMinimal` is set;
|
||||
* Use cases **`lg`** single-line title **21004-24825** when `singleLineTitleFromLg` is set;
|
||||
* **`xl`** headline scale **22085-860408** when `sectionMinimal` (X Large/Display / `--sizing-1600`).
|
||||
*/
|
||||
const PageHeaderContainer = memo<PageHeaderProps>(
|
||||
({
|
||||
title,
|
||||
description,
|
||||
ctaText,
|
||||
ctaHref,
|
||||
headingAlign = "start",
|
||||
sectionMinimal = false,
|
||||
singleLineTitleFromLg = false,
|
||||
titleId: titleIdProp,
|
||||
className = "",
|
||||
}) => {
|
||||
const reactId = useId();
|
||||
const titleId = titleIdProp ?? `${reactId}-page-header-title`;
|
||||
|
||||
return (
|
||||
<PageHeaderView
|
||||
title={title}
|
||||
description={description}
|
||||
ctaText={ctaText}
|
||||
ctaHref={ctaHref}
|
||||
headingAlign={headingAlign}
|
||||
sectionMinimal={sectionMinimal}
|
||||
singleLineTitleFromLg={singleLineTitleFromLg}
|
||||
titleId={titleId}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PageHeaderContainer.displayName = "PageHeader";
|
||||
|
||||
export default PageHeaderContainer;
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface PageHeaderProps {
|
||||
/** Single line or stacked lines inside one `<h1>` (matches Figma line breaks when centered). */
|
||||
title: string | readonly string[];
|
||||
description?: string;
|
||||
ctaText?: string;
|
||||
ctaHref?: string;
|
||||
/** `center` stacks and centers the headline (Section/PageHeader minimal / use cases). */
|
||||
headingAlign?: "start" | "center";
|
||||
/**
|
||||
* Section/PageHeader minimal density ([22085-862431](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-862431&m=dev)):
|
||||
* md+ **52px** display type and **56px** vertical padding (with **64px** horizontal).
|
||||
*/
|
||||
sectionMinimal?: boolean;
|
||||
/**
|
||||
* When `title` is multiple lines, use one centered line from **`lg`** ([21004-24825](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=21004-24825&m=dev)).
|
||||
*/
|
||||
singleLineTitleFromLg?: boolean;
|
||||
titleId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export type PageHeaderViewProps = Omit<PageHeaderProps, "titleId"> & {
|
||||
titleId: string;
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, memo } from "react";
|
||||
import Button from "../../buttons/Button";
|
||||
import type { PageHeaderViewProps } from "./PageHeader.types";
|
||||
|
||||
function PageHeaderView({
|
||||
title,
|
||||
description,
|
||||
ctaText,
|
||||
ctaHref,
|
||||
headingAlign = "start",
|
||||
sectionMinimal = false,
|
||||
singleLineTitleFromLg = false,
|
||||
titleId,
|
||||
className = "",
|
||||
}: PageHeaderViewProps) {
|
||||
const hasCta = Boolean(ctaText?.trim() && ctaHref?.trim());
|
||||
const hasDescription = Boolean(description?.trim());
|
||||
const isCenter = headingAlign === "center";
|
||||
const titleLines = typeof title === "string" ? [title] : title;
|
||||
const collapseTitleAtLg =
|
||||
singleLineTitleFromLg && titleLines.length > 1;
|
||||
|
||||
const lockupAlign = isCenter
|
||||
? "items-center text-center"
|
||||
: "items-start text-[var(--color-content-default-primary)]";
|
||||
const h1Align = isCenter ? "text-center" : "";
|
||||
|
||||
const sectionPadding = sectionMinimal
|
||||
? "py-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-056)]"
|
||||
: "py-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-032)]";
|
||||
|
||||
const titleTypeClasses = sectionMinimal
|
||||
? "font-bricolage-grotesque text-[32px] font-medium leading-[1.1] text-[var(--color-content-default-primary)] md:text-[52px] md:leading-[1.1] lg:text-[52px] lg:leading-[1.1] xl:text-[length:var(--sizing-1600)] xl:leading-[1.1]"
|
||||
: "font-bricolage-grotesque text-[32px] font-medium leading-[1.1] text-[var(--color-content-default-primary)] md:text-[44px] md:leading-[110%] lg:text-[52px]";
|
||||
|
||||
const sectionFigmaNode =
|
||||
sectionMinimal && collapseTitleAtLg
|
||||
? "21004-24825"
|
||||
: sectionMinimal
|
||||
? "22085-862431"
|
||||
: "21004-22394";
|
||||
|
||||
return (
|
||||
<section
|
||||
data-figma-node={sectionFigmaNode}
|
||||
className={`bg-[var(--color-surface-default-primary)] px-[var(--spacing-scale-020)] md:px-[var(--spacing-scale-064)] ${sectionPadding} ${className}`.trim()}
|
||||
>
|
||||
<div
|
||||
className={`mx-auto flex w-full max-w-[1440px] flex-col gap-[var(--spacing-scale-024)] ${isCenter ? "items-center" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`flex w-full flex-col gap-[var(--spacing-scale-020)] ${lockupAlign}`}
|
||||
>
|
||||
<h1
|
||||
id={titleId}
|
||||
className={`${titleTypeClasses} ${h1Align}${collapseTitleAtLg ? " lg:whitespace-nowrap" : ""}`.trim()}
|
||||
>
|
||||
{titleLines.length === 1 ? (
|
||||
titleLines[0]
|
||||
) : collapseTitleAtLg ? (
|
||||
titleLines.map((line, index) => (
|
||||
<Fragment key={`${index}-${line}`}>
|
||||
{index > 0 ? (
|
||||
<span className="hidden lg:inline">{" "}</span>
|
||||
) : null}
|
||||
<span className="block lg:inline">{line}</span>
|
||||
</Fragment>
|
||||
))
|
||||
) : (
|
||||
titleLines.map((line, index) => (
|
||||
<span key={`${index}-${line}`} className="block">
|
||||
{line}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</h1>
|
||||
{hasDescription ? (
|
||||
<p className="font-inter text-[18px] font-normal leading-[28px] text-[var(--color-content-default-primary)] lg:text-[24px] lg:leading-[28px]">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{hasCta ? (
|
||||
<div
|
||||
className={`flex ${isCenter ? "justify-center" : "justify-start"}`}
|
||||
>
|
||||
<Button
|
||||
href={ctaHref}
|
||||
buttonType="filled"
|
||||
palette="inverse"
|
||||
size="large"
|
||||
>
|
||||
{ctaText}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
PageHeaderView.displayName = "PageHeaderView";
|
||||
|
||||
export default memo(PageHeaderView);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./PageHeader.container";
|
||||
export type { PageHeaderProps } from "./PageHeader.types";
|
||||
@@ -10,6 +10,11 @@ interface SectionHeaderProps {
|
||||
variant?: SectionHeaderVariantValue;
|
||||
/** When set with `variant="multi-line"`, large screens show three title lines (Figma SectionCardSteps). */
|
||||
stackedDesktopLines?: readonly [string, string, string];
|
||||
/**
|
||||
* With `variant="multi-line"`, keep **Rule stack** desktop type: title **32/40** at `lg`, **40/52** at `xl`;
|
||||
* subtitle **18 / 1.3** at `lg`, **24/32** at `xl`, **left-aligned** in its column from `lg` (Figma **22085:860413**).
|
||||
*/
|
||||
ruleStackDesktopTypeScale?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,6 +28,7 @@ const SectionHeader = memo<SectionHeaderProps>(
|
||||
titleLg,
|
||||
variant: variantProp = "default",
|
||||
stackedDesktopLines,
|
||||
ruleStackDesktopTypeScale = false,
|
||||
}) => {
|
||||
const variant = variantProp;
|
||||
const useStackedDesktop =
|
||||
@@ -47,7 +53,9 @@ const SectionHeader = memo<SectionHeaderProps>(
|
||||
<h2
|
||||
className={
|
||||
variant === "multi-line"
|
||||
? "font-bricolage-grotesque font-bold text-[28px] leading-[36px] md:text-[32px] md:leading-[40px] lg:w-[410px] lg:text-left xl:text-[40px] xl:leading-[52px] text-[var(--color-content-default-primary)]"
|
||||
? ruleStackDesktopTypeScale
|
||||
? "font-bricolage-grotesque font-bold text-[28px] leading-[36px] md:text-[32px] md:leading-[40px] lg:w-full lg:max-w-none lg:text-left lg:text-[32px] lg:leading-[40px] xl:text-[40px] xl:leading-[52px] text-[var(--color-content-default-primary)]"
|
||||
: "font-bricolage-grotesque font-bold text-[28px] leading-[36px] md:text-[32px] md:leading-[40px] lg:w-[410px] lg:text-left xl:text-[40px] xl:leading-[52px] text-[var(--color-content-default-primary)]"
|
||||
: "font-bricolage-grotesque font-bold text-[28px] leading-[36px] sm:text-[32px] sm:leading-[40px] lg:text-[32px] lg:leading-[40px] lg:w-[369px] lg:pr-[var(--spacing-scale-096)] xl:text-[40px] xl:leading-[52px] xl:w-[452px] xl:pr-[var(--spacing-scale-096)] text-[var(--color-content-default-primary)]"
|
||||
}
|
||||
>
|
||||
@@ -68,14 +76,18 @@ const SectionHeader = memo<SectionHeaderProps>(
|
||||
<div
|
||||
className={
|
||||
variant === "multi-line"
|
||||
? "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center lg:justify-end lg:ml-[var(--spacing-scale-016)] xl:ml-0 xl:w-[50%] xl:h-[156px] xl:flex xl:items-center xl:justify-end"
|
||||
? ruleStackDesktopTypeScale
|
||||
? "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center lg:justify-start lg:ml-[var(--spacing-scale-016)] xl:ml-0 xl:w-[50%] xl:h-[156px] xl:flex xl:items-center xl:justify-start"
|
||||
: "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center lg:justify-end lg:ml-[var(--spacing-scale-016)] xl:ml-0 xl:w-[50%] xl:h-[156px] xl:flex xl:items-center xl:justify-end"
|
||||
: "lg:w-[928px] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center lg:justify-end xl:h-[156px] xl:flex xl:items-center xl:justify-end"
|
||||
}
|
||||
>
|
||||
<p
|
||||
className={
|
||||
variant === "multi-line"
|
||||
? "font-inter font-normal text-[14px] leading-[20px] md:text-[18px] md:leading-[130%] xl:text-[24px] xl:leading-[32px] text-[var(--color-content-default-tertiary)] lg:text-right"
|
||||
? ruleStackDesktopTypeScale
|
||||
? "font-inter font-normal text-[14px] leading-[20px] md:text-[18px] md:leading-[130%] lg:text-left lg:text-[18px] lg:leading-[130%] text-[var(--color-content-default-tertiary)] xl:text-[24px] xl:leading-[32px]"
|
||||
: "font-inter font-normal text-[14px] leading-[20px] md:text-[18px] md:leading-[130%] xl:text-[24px] xl:leading-[32px] text-[var(--color-content-default-tertiary)] lg:text-right"
|
||||
: "font-inter font-normal text-[18px] leading-[130%] sm:text-[18px] sm:leading-[32px] lg:text-[24px] lg:leading-[32px] xl:text-[32px] xl:leading-[40px] xl:text-right text-[#484848] sm:text-[var(--color-content-default-tertiary)] lg:text-[var(--color-content-default-tertiary)] xl:text-[var(--color-content-default-tertiary)] tracking-[0px]"
|
||||
}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useId } from "react";
|
||||
import TripleStepView from "./TripleStep.view";
|
||||
import type { TripleStepProps } from "./TripleStep.types";
|
||||
|
||||
/**
|
||||
* Figma: **Section / Triple Step** ([22084-859405](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22084-859405&m=dev)); type baseline ([22112-871527](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22112-871527&m=dev)); **md+** two-column + **`triple-step.svg`**.
|
||||
*/
|
||||
const TripleStepContainer = memo<TripleStepProps>((props) => {
|
||||
const reactId = useId();
|
||||
const headingId = `${reactId}-triple-step-heading`;
|
||||
|
||||
return <TripleStepView {...props} headingId={headingId} />;
|
||||
});
|
||||
|
||||
TripleStepContainer.displayName = "TripleStep";
|
||||
|
||||
export default TripleStepContainer;
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface TripleStepStep {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface TripleStepProps {
|
||||
heading: string;
|
||||
steps: TripleStepStep[];
|
||||
ctaText: string;
|
||||
ctaHref: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface TripleStepViewProps extends TripleStepProps {
|
||||
headingId: string;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { memo } from "react";
|
||||
import { getAssetPath } from "../../../../lib/assetUtils";
|
||||
import AssetIcon from "../../asset/icon";
|
||||
import Button from "../../buttons/Button";
|
||||
import type { TripleStepViewProps } from "./TripleStep.types";
|
||||
|
||||
const TRIPLE_STEP_NUMERIC_ICONS = [
|
||||
"numeric_1_circle",
|
||||
"numeric_2_circle",
|
||||
"numeric_3_circle",
|
||||
] as const;
|
||||
|
||||
function TripleStepView({
|
||||
heading,
|
||||
steps,
|
||||
ctaText,
|
||||
ctaHref,
|
||||
headingId,
|
||||
className = "",
|
||||
}: TripleStepViewProps) {
|
||||
/** Decorative column art — `public/assets/shapes/triple-step.svg` (288×576 viewBox). */
|
||||
const shapeSrc = getAssetPath("assets/shapes/triple-step.svg");
|
||||
|
||||
return (
|
||||
<section
|
||||
data-figma-node="22084-859405"
|
||||
aria-labelledby={headingId}
|
||||
className={`bg-transparent p-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-048)] lg:px-[var(--spacing-scale-064)] lg:py-[var(--spacing-scale-048)] ${className}`.trim()}
|
||||
>
|
||||
<div className="mx-auto grid w-full min-w-0 max-w-[560px] grid-cols-1 gap-[var(--spacing-scale-032)] md:max-w-[1400px] md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] md:items-stretch md:gap-[var(--spacing-scale-060)] lg:items-center">
|
||||
<div className="flex w-full min-w-0 flex-col gap-[var(--spacing-scale-040)] break-words md:self-start lg:self-center">
|
||||
<h2
|
||||
id={headingId}
|
||||
className="font-bricolage-grotesque text-[length:var(--text-medium-heading)] font-bold leading-[length:var(--text-medium-heading--line-height)] text-[var(--color-content-default-primary)] md:text-[length:var(--text-large-heading)] md:leading-[length:var(--text-large-heading--line-height)]"
|
||||
>
|
||||
{heading}
|
||||
</h2>
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={step.title}
|
||||
className="flex flex-col items-start gap-[var(--spacing-scale-016)]"
|
||||
>
|
||||
<AssetIcon
|
||||
name={
|
||||
TRIPLE_STEP_NUMERIC_ICONS[Math.min(index, 2)]
|
||||
}
|
||||
size={32}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-1 text-[var(--color-content-default-primary)]">
|
||||
<p className="font-bricolage-grotesque text-[18px] font-medium leading-[22px]">
|
||||
{step.title}
|
||||
</p>
|
||||
<p className="font-inter text-[length:var(--text-small-paragraph)] font-normal leading-[length:var(--text-small-paragraph--line-height)]">
|
||||
{step.body}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-start">
|
||||
<Button
|
||||
href={ctaHref}
|
||||
buttonType="outline"
|
||||
palette="default"
|
||||
size="medium"
|
||||
className="max-md:!px-[var(--spacing-scale-012)] max-md:!py-[var(--spacing-scale-010)] max-md:!text-[14px] max-md:!leading-[18px] md:!p-[var(--spacing-scale-012)] md:!text-[16px] md:!leading-5 md:!gap-[var(--spacing-scale-006)]"
|
||||
>
|
||||
{ctaText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="hidden min-h-0 min-w-0 w-full md:flex md:items-center md:justify-center"
|
||||
aria-hidden
|
||||
>
|
||||
<Image
|
||||
src={shapeSrc}
|
||||
alt=""
|
||||
width={288}
|
||||
height={576}
|
||||
unoptimized
|
||||
className="pointer-events-none h-auto w-full max-w-full min-w-0 select-none object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
TripleStepView.displayName = "TripleStepView";
|
||||
|
||||
export default memo(TripleStepView);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./TripleStep.container";
|
||||
export type { TripleStepProps, TripleStepStep } from "./TripleStep.types";
|
||||
@@ -5,7 +5,8 @@ import TripleTextBlockView from "./TripleTextBlock.view";
|
||||
import type { TripleTextBlockProps } from "./TripleTextBlock.types";
|
||||
|
||||
/**
|
||||
* Figma: "Type / TripleTextBlock" stacked 22137:890676; lg 22128:888715; xl 22135:889705.
|
||||
* Figma: "Type / TripleTextBlock" — use cases **`lg` 22037-26994**, **`xl` 22085-860414**;
|
||||
* **`md` 22085-862437**; stacked 22137:890676; lg 22128:888715; xl 22135:889705 (default).
|
||||
*/
|
||||
const TripleTextBlockContainer = memo<TripleTextBlockProps>((props) => {
|
||||
const headingId = useId();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface TripleTextBlockColumn {
|
||||
title: string;
|
||||
description: string;
|
||||
/** Optional second paragraph under `description` (e.g. use cases baseline multi-paragraph lockup). */
|
||||
descriptionSecondary?: string;
|
||||
/**
|
||||
* lg+ three-column layout (Figma 22128:888715). When either `lgTitle` or `lgDescription`
|
||||
* is set, stacked breakpoints use `title`/`description` and lg uses these (missing side falls back).
|
||||
@@ -16,6 +18,12 @@ export interface TripleTextBlockProps {
|
||||
ctaText?: string;
|
||||
ctaHref?: string;
|
||||
className?: string;
|
||||
/**
|
||||
* `useCases`: Figma use cases TripleText **`lg`** ([22037-26994](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22037-26994&m=dev));
|
||||
* **`xl`** ([22085-860414](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-860414&m=dev));
|
||||
* `md` ([22085-862437](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-862437&m=dev)); lg 3-col **22128-888715**.
|
||||
*/
|
||||
layoutPreset?: "default" | "useCases";
|
||||
}
|
||||
|
||||
export interface TripleTextBlockViewProps extends TripleTextBlockProps {
|
||||
|
||||
@@ -12,11 +12,35 @@ function columnUsesLargeBreakpointCopy(column: TripleTextBlockColumn): boolean {
|
||||
return column.lgTitle !== undefined || column.lgDescription !== undefined;
|
||||
}
|
||||
|
||||
function TripleTextUseCasesColumn({ column }: { column: TripleTextBlockColumn }) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-[var(--spacing-scale-020)] lg:gap-0 xl:gap-[var(--spacing-scale-020)]">
|
||||
<div className="flex flex-col gap-[var(--spacing-scale-008)] lg:gap-[var(--spacing-scale-004)] xl:gap-[var(--spacing-scale-008)]">
|
||||
<h3 className="text-left font-bricolage-grotesque text-[24px] font-medium leading-8 text-[var(--color-content-default-primary,white)] md:text-[32px] md:leading-[1.1] lg:text-[18px] lg:leading-[var(--spacing-scale-022)] xl:text-[32px] xl:leading-[1.1]">
|
||||
{column.title}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-[var(--spacing-scale-024)] font-inter text-[16px] font-normal leading-6 text-[var(--color-content-default-secondary)] md:text-[24px] md:leading-8 lg:gap-[var(--spacing-scale-020)] lg:text-[14px] lg:leading-5 xl:gap-[var(--spacing-scale-032)] xl:text-[24px] xl:leading-8">
|
||||
<p>{column.description}</p>
|
||||
{column.descriptionSecondary ? (
|
||||
<p>{column.descriptionSecondary}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TripleTextBlockColumnLockup({
|
||||
column,
|
||||
layoutPreset,
|
||||
}: {
|
||||
column: TripleTextBlockColumn;
|
||||
layoutPreset: "default" | "useCases";
|
||||
}) {
|
||||
if (layoutPreset === "useCases") {
|
||||
return <TripleTextUseCasesColumn column={column} />;
|
||||
}
|
||||
|
||||
const dual = columnUsesLargeBreakpointCopy(column);
|
||||
const lgSubtitle = column.lgTitle ?? column.title;
|
||||
const lgBody = column.lgDescription ?? column.description;
|
||||
@@ -55,7 +79,11 @@ function TripleTextBlockColumnLockup({
|
||||
}
|
||||
|
||||
/**
|
||||
* Figma: "Type / TripleTextBlock" stacked **22137:890676**; lg 3-col **22128-888715**; xl typography + horizontal inset scale/160 **22135:889705** (Subtitle 32 Small/Display, Body X Large/Paragraph 24 / lh 32; section px scale/160, py scale/064).
|
||||
* Section horizontal padding adds **+ Scale/096** below `xl` (outer frame inset); **use cases `xl`** uses **Scale/160** only ([22085:860414](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-860414&m=dev)).
|
||||
*
|
||||
* Figma: use cases **`lg`** [22037:26994](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22037-26994&m=dev);
|
||||
* **`md`** [22085:862437](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-862437&m=dev); stacked **22137:890676**;
|
||||
* lg 3-col **22128:888715**; xl **22135:889705** (default preset).
|
||||
*/
|
||||
function TripleTextBlockView({
|
||||
title = "",
|
||||
@@ -64,39 +92,71 @@ function TripleTextBlockView({
|
||||
ctaHref,
|
||||
headingId,
|
||||
className = "",
|
||||
layoutPreset = "default",
|
||||
}: TripleTextBlockViewProps) {
|
||||
const sectionTitle = title.trim();
|
||||
const hasSectionTitle = sectionTitle.length > 0;
|
||||
const isUseCases = layoutPreset === "useCases";
|
||||
|
||||
return (
|
||||
<section
|
||||
{...(isUseCases ? { "data-figma-node": "22085-860414" } : {})}
|
||||
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()}
|
||||
className={`bg-black px-[calc(var(--spacing-scale-032)+var(--spacing-scale-096))] py-[var(--spacing-scale-064)] md:px-[calc(var(--spacing-scale-096)+var(--spacing-scale-096))] md:py-[var(--spacing-scale-064)] lg:px-[calc(var(--spacing-scale-096)+var(--spacing-scale-096))] lg:py-[var(--spacing-scale-064)] ${
|
||||
isUseCases
|
||||
? "xl:px-[var(--spacing-scale-160)]"
|
||||
: "xl:px-[calc(var(--spacing-scale-160)+var(--spacing-scale-096))]"
|
||||
} xl:py-[var(--spacing-scale-064)] ${className}`.trim()}
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-[1440px] flex-col items-start gap-[var(--spacing-scale-032)]">
|
||||
<div
|
||||
className={
|
||||
isUseCases
|
||||
? "mx-auto flex w-full max-w-[1440px] flex-col items-start gap-[var(--spacing-scale-032)] md:gap-[var(--spacing-scale-048)] lg:items-center lg:gap-[var(--spacing-scale-064)]"
|
||||
: "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)]"
|
||||
className={
|
||||
isUseCases
|
||||
? "w-full text-left font-bricolage-grotesque text-[28px] font-bold leading-9 text-[var(--color-content-default-primary,white)] md:text-[32px] md:leading-[40px] lg:mx-auto lg:max-w-[693px] lg:text-center lg:text-[36px] lg:font-extrabold lg:leading-[44px] xl:text-[40px] xl:leading-[52px] xl:font-bold"
|
||||
: "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)]">
|
||||
<div
|
||||
className={
|
||||
isUseCases
|
||||
? "flex w-full flex-col gap-[var(--spacing-scale-048)] lg:flex-row lg:items-start lg:gap-[var(--spacing-scale-032)]"
|
||||
: "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} />
|
||||
<TripleTextBlockColumnLockup
|
||||
column={column}
|
||||
layoutPreset={layoutPreset}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{ctaText ? (
|
||||
<div className="flex w-full justify-start">
|
||||
<div
|
||||
className={
|
||||
isUseCases
|
||||
? "flex w-full justify-start lg:justify-center"
|
||||
: "flex w-full justify-start"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="inverse"
|
||||
buttonType={isUseCases ? "outline" : "filled"}
|
||||
palette={isUseCases ? "default" : "inverse"}
|
||||
size="large"
|
||||
href={ctaHref}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user