Implement use cases page

This commit is contained in:
adilallo
2026-05-17 21:41:54 -06:00
parent b6b9b63608
commit 450da4d8ab
78 changed files with 1870 additions and 118 deletions
@@ -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 (**2199332352** / **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);
+3
View File
@@ -0,0 +1,3 @@
export { default } from "./CaseStudy.container";
export type { CaseStudyProps, CaseStudySurfaceValue } from "./CaseStudy.types";
export { CASE_STUDY_SURFACE_OPTIONS } from "./CaseStudy.types";
+8 -2
View File
@@ -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}
/>
+8
View File
@@ -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;
}
+22 -11
View File
@@ -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}
/>
);
},
+5
View File
@@ -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;
}
+52 -16
View File
@@ -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>