Implement use cases page
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user