Create use cases pages

This commit is contained in:
adilallo
2026-05-18 16:50:44 -06:00
parent 40ce5064d6
commit 7c46cbd87b
28 changed files with 836 additions and 58 deletions
@@ -44,6 +44,7 @@ const RuleContainer = memo<RuleProps>(
bottomLinks,
recommended = false,
templateGridFigmaShell = false,
fluidWidth = false,
}) => {
const size = sizeProp ?? "L";
@@ -100,6 +101,7 @@ const RuleContainer = memo<RuleProps>(
bottomLinks={bottomLinks}
recommended={recommended}
templateGridFigmaShell={templateGridFigmaShell}
fluidWidth={fluidWidth}
/>
);
},
+3
View File
@@ -70,6 +70,8 @@ export interface RuleProps {
* 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;
/** When true, expanded cards fill their container instead of a fixed Figma width. */
fluidWidth?: boolean;
}
export interface RuleViewProps {
@@ -95,4 +97,5 @@ export interface RuleViewProps {
bottomLinks?: RuleBottomLink[];
recommended?: boolean;
templateGridFigmaShell?: boolean;
fluidWidth?: boolean;
}
+16 -10
View File
@@ -31,6 +31,7 @@ export function RuleView({
bottomLinks,
recommended = false,
templateGridFigmaShell = false,
fluidWidth = false,
}: RuleViewProps) {
const t = useTranslation("ruleCard");
const ariaLabel = t("ariaLabel")?.replace("{title}", title) || title;
@@ -74,13 +75,16 @@ export function RuleView({
: isMedium
? "gap-[12px]"
: "gap-[18px]"; // XS and S: 18px gap
const cardWidth = expanded
? isLarge
? "w-[568px]"
: isMedium
? "w-[398px]"
: "" // XS and S: no fixed width
: "";
const cardWidth =
fluidWidth && expanded
? ""
: expanded
? isLarge
? "w-[568px]"
: isMedium
? "w-[398px]"
: ""
: "";
// 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.
@@ -363,9 +367,11 @@ export function RuleView({
(onDescriptionClick &&
typeof descriptionEmptyHint === "string")) && (
<div
className={`relative w-full shrink-0 border-b border-solid border-[var(--color-content-invert-primary)] pb-[16px] ${
expanded && (isLarge || isMedium) ? "px-0" : "px-[12px]"
}`}
className={`relative w-full shrink-0 ${
categories && categories.length > 0
? "border-b border-solid border-[var(--color-content-invert-primary)] pb-[16px]"
: ""
} ${expanded && (isLarge || isMedium) ? "px-0" : "px-[12px]"}`}
>
{onDescriptionClick ? (
<InlineTextButton
@@ -10,10 +10,21 @@ const ContentContainerContainer = memo<ContentContainerProps>(
post,
width = "200px",
size: sizeProp = "responsive",
tone: toneProp = "inverse",
leadingImageSrc,
leadingImageAlt,
showLeadingImage: showLeadingImageProp = true,
}) => {
const size = sizeProp;
const tone = toneProp;
const showLeadingImage = showLeadingImageProp;
const onLight = tone === "onLight";
const titleColor = onLight
? "text-[var(--color-content-default-primary)] group-hover:text-[var(--color-content-default-brand-primary)]"
: "text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200";
const bodyColor = onLight
? "text-[var(--color-content-default-secondary)]"
: "text-[var(--color-content-inverse-brand-royal)]";
// Get the corresponding icon based on the same logic as background images
const getIconImage = (slug: string): string => {
const icons = [
@@ -67,31 +78,33 @@ const ContentContainerContainer = memo<ContentContainerProps>(
const titleClasses =
size === "xs"
? "font-bricolage font-medium text-[18px] leading-[120%] text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200 transition-colors"
: "font-bricolage font-medium text-[18px] leading-[120%] sm:text-[24px] sm:leading-[24px] md:text-[32px] md:leading-[110%] lg:text-[44px] lg:leading-[110%] xl:text-[64px] xl:leading-[110%] text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200 transition-colors";
? `font-bricolage font-medium text-[18px] leading-[120%] transition-colors ${titleColor}`
: `font-bricolage font-medium text-[18px] leading-[120%] sm:text-[24px] sm:leading-[24px] md:text-[32px] md:leading-[110%] lg:text-[44px] lg:leading-[110%] xl:text-[64px] xl:leading-[110%] transition-colors ${titleColor}`;
const descriptionClasses =
size === "xs"
? "font-inter font-normal text-[12px] leading-[16px] text-[var(--color-content-inverse-brand-royal)] max-w-md"
: "font-inter font-normal text-[12px] leading-[16px] sm:text-[14px] sm:leading-[20px] md:text-[14px] md:leading-[20px] lg:text-[18px] lg:leading-[130%] xl:text-[24px] xl:leading-[32px] text-[var(--color-content-inverse-brand-royal)]";
? `font-inter font-normal text-[12px] leading-[16px] max-w-md ${bodyColor}`
: `font-inter font-normal text-[12px] leading-[16px] sm:text-[14px] sm:leading-[20px] md:text-[14px] md:leading-[20px] lg:text-[18px] lg:leading-[130%] xl:text-[24px] xl:leading-[32px] ${bodyColor}`;
const authorClasses =
size === "xs"
? "font-inter font-normal text-[10px] leading-[14px] text-[var(--color-content-inverse-brand-royal)]"
: "font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] text-[var(--color-content-inverse-brand-royal)]";
? `font-inter font-normal text-[10px] leading-[14px] ${bodyColor}`
: `font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] ${bodyColor}`;
const dateClasses =
size === "xs"
? "font-inter font-normal text-[10px] leading-[14px] text-[var(--color-content-inverse-brand-royal)]"
: "font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] text-[var(--color-content-inverse-brand-royal)]";
? `font-inter font-normal text-[10px] leading-[14px] ${bodyColor}`
: `font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] ${bodyColor}`;
return (
<ContentContainerView
post={post}
width={width}
size={size}
tone={tone}
iconImage={iconImage}
iconAlt={iconAlt}
showLeadingImage={showLeadingImage}
containerClasses={containerClasses}
contentGapClasses={contentGapClasses}
textGapClasses={textGapClasses}
@@ -2,6 +2,9 @@ import type { BlogPost } from "../../../../lib/content";
export type ContentContainerSizeValue = "xs" | "responsive";
/** `inverse` — blog hero on imagery; `onLight` — marketing pages on default surface. */
export type ContentContainerToneValue = "inverse" | "onLight";
export interface ContentContainerProps {
post: BlogPost;
width?: string;
@@ -9,18 +12,26 @@ export interface ContentContainerProps {
* Content container size.
*/
size?: ContentContainerSizeValue;
/**
* Text color tokens. Default `inverse` (royal on dark/imagery).
*/
tone?: ContentContainerToneValue;
/** When set, replaces the default slug-based thumbnail icon. */
leadingImageSrc?: string;
/** Alt text for `leadingImageSrc`; defaults to post title. */
leadingImageAlt?: string;
/** When false, omits the icon row above the title. Default true. */
showLeadingImage?: boolean;
}
export interface ContentContainerViewProps {
post: BlogPost;
width: string;
size: "xs" | "responsive";
tone: ContentContainerToneValue;
iconImage: string;
iconAlt: string;
showLeadingImage: boolean;
containerClasses: string;
contentGapClasses: string;
textGapClasses: string;
@@ -7,6 +7,7 @@ function ContentContainerView({
size,
iconImage,
iconAlt,
showLeadingImage,
containerClasses,
contentGapClasses,
textGapClasses,
@@ -23,15 +24,16 @@ function ContentContainerView({
>
{/* Content Container - gap between icon and text */}
<div className={contentGapClasses}>
{/* Icon */}
<div className="w-[60px] h-[30px] flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={iconImage}
alt={iconAlt}
className="w-[60px] h-[30px] object-contain"
/>
</div>
{showLeadingImage ? (
<div className="flex h-[30px] w-[60px] items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={iconImage}
alt={iconAlt}
className="h-[30px] w-[60px] object-contain"
/>
</div>
) : null}
{/* Text Container */}
<div className={textGapClasses}>
@@ -11,7 +11,7 @@ import type {
} from "./AskOrganizer.types";
const VARIANT_STYLES: Record<
"centered" | "left-aligned" | "compact" | "inverse",
AskOrganizerVariant,
{ container: string; buttonContainer: string }
> = {
centered: {
@@ -30,9 +30,13 @@ const VARIANT_STYLES: Record<
container: "text-center",
buttonContainer: "flex justify-center",
},
"use-case-detail": {
container: "w-full text-center",
buttonContainer: "flex w-full justify-center",
},
};
/** Figma **Section/AskOrganizer** [18116:15960](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=18116-15960&m=dev) (`lg` shell + type + button). */
/** Figma **Section/AskOrganizer** [18116:15960](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=18116-15960&m=dev) (`lg` shell + type + button). Use-case detail: [22015:42624](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22015-42624&m=dev). */
const AskOrganizerContainer = memo<AskOrganizerProps>(
({
title,
@@ -57,12 +61,16 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
const sectionPadding =
resolvedVariant === "compact"
? "py-[var(--spacing-scale-016)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-032)]"
: "py-[var(--spacing-scale-096)] px-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-064)]";
: resolvedVariant === "use-case-detail"
? "w-full py-[var(--spacing-scale-096)] px-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-064)]"
: "py-[var(--spacing-scale-096)] px-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-064)]";
const contentGap =
resolvedVariant === "compact"
? "gap-[var(--spacing-scale-020)]"
: "gap-[var(--spacing-scale-040)]";
: resolvedVariant === "use-case-detail"
? "gap-[var(--spacing-scale-040)]"
: "gap-[var(--spacing-scale-040)]";
const labelledBy = title ? "ask-organizer-headline" : undefined;
@@ -4,7 +4,8 @@ export type AskOrganizerVariant =
| "centered"
| "left-aligned"
| "compact"
| "inverse";
| "inverse"
| "use-case-detail";
export interface AskOrganizerProps {
title?: string;
@@ -21,6 +21,13 @@ function AskOrganizerView({
}: AskOrganizerViewProps) {
const t = useTranslation();
const ariaLabel = t("askOrganizer.ariaLabel");
const isUseCaseDetail = variant === "use-case-detail";
const lockupVariant =
variant === "inverse" || isUseCaseDetail ? "ask-inverse" : "ask";
const lockupAlignment =
variant === "left-aligned" ? "left" : "center";
const buttonPalette =
variant === "inverse" || isUseCaseDetail ? "inverse" : "default";
return (
<section
@@ -28,16 +35,18 @@ function AskOrganizerView({
aria-labelledby={labelledBy}
aria-label={labelledBy ? undefined : ariaLabel}
tabIndex={-1}
data-figma-node="18116-15960"
data-figma-node={isUseCaseDetail ? "22015-42624" : "18116-15960"}
>
<div className={`flex flex-col ${contentGap}`}>
<div
className={`mx-auto flex w-full min-w-[358px] max-w-[1280px] flex-col ${contentGap} ${isUseCaseDetail ? "items-center" : ""}`}
>
{/* Content Lockup */}
<ContentLockup
title={title}
subtitle={subtitle}
description={description}
variant={variant === "inverse" ? "ask-inverse" : "ask"}
alignment={variant === "left-aligned" ? "left" : "center"}
variant={lockupVariant}
alignment={lockupAlignment}
titleId={labelledBy}
/>
@@ -49,7 +58,7 @@ function AskOrganizerView({
{...(buttonHref ? { href: buttonHref } : {})}
size="large"
buttonType="filled"
palette={variant === "inverse" ? "inverse" : "default"}
palette={buttonPalette}
className="!px-[var(--spacing-scale-016)] !py-[var(--spacing-scale-012)]"
onClick={onContactClick}
ariaLabel={ariaLabel}
@@ -13,6 +13,8 @@ const ContentBannerContainer = memo<ContentBannerProps>(
variant: variantProp = "article",
leadingImageSrc,
leadingImageAlt,
rulePreview,
contentTone,
}) => {
const variant = variantProp;
@@ -46,6 +48,8 @@ const ContentBannerContainer = memo<ContentBannerProps>(
leadingImageAlt={leadingImageAlt}
backgroundImageSm={backgroundImageSm}
backgroundImageMd={backgroundImageMd}
rulePreview={rulePreview}
contentTone={contentTone}
/>
);
},
@@ -1,17 +1,31 @@
import type { BlogPost } from "../../../../lib/content";
import type { ContentContainerToneValue } from "../../content/ContentContainer/ContentContainer.types";
export type ContentBannerVariant = "article" | "guide";
export type ContentBannerVariant = "article" | "guide" | "useCase";
/** Rule column for `useCase` variant (Figma 22015:42621). */
export interface ContentBannerRulePreview {
title: string;
description: string;
backgroundColor: string;
iconPath: string;
}
export interface ContentBannerProps {
post: BlogPost;
/**
* `article` — blog post hero with thumbnail/banner imagery and metadata.
* `guide` — static guide pages (Figma ContentBanner on content page template).
* `useCase` — use case detail: ContentContainer + Rule preview.
*/
variant?: ContentBannerVariant;
/** Article variant only: replaces slug-based thumbnail icon in ContentContainer. */
/** Article / useCase: replaces slug-based thumbnail icon in ContentContainer. */
leadingImageSrc?: string;
leadingImageAlt?: string;
/** `useCase` only: expanded Rule preview in the right column. */
rulePreview?: ContentBannerRulePreview;
/** `useCase` only: ContentContainer text tokens (default `onLight`). */
contentTone?: ContentContainerToneValue;
}
export interface ContentBannerViewProps {
@@ -21,4 +35,6 @@ export interface ContentBannerViewProps {
leadingImageAlt?: string;
backgroundImageSm?: string;
backgroundImageMd?: string;
rulePreview?: ContentBannerRulePreview;
contentTone?: ContentContainerToneValue;
}
@@ -1,7 +1,9 @@
"use client";
import Image from "next/image";
import { memo } from "react";
import ContentContainer from "../../content/ContentContainer";
import Rule from "../../cards/Rule";
import {
getAssetPath,
guideBannerLogoArrowPath,
@@ -115,11 +117,91 @@ function ContentBannerArticleView({
);
}
/**
* Figma: use case detail ContentBanner (22015:42621) — copy left, Rule preview right.
*/
function ContentBannerUseCaseView({
post,
rulePreview,
}: Pick<ContentBannerViewProps, "post" | "rulePreview">) {
if (!rulePreview) {
return null;
}
const { title, description, author, date } = post.frontmatter;
const formattedDate = new Date(date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
});
return (
<section
className="relative w-full overflow-clip"
aria-label={title}
>
<div
data-figma-node="22015:42621"
className="mx-auto flex w-full max-w-[1024px] flex-col items-center gap-[var(--space-800)] px-[var(--space-1200)] py-[var(--space-1000)] md:flex-row md:items-center"
>
<div
data-node-id="19189:9171"
className="flex w-full max-w-[365px] shrink-0 flex-col gap-[var(--spacing-scale-024)]"
>
<div className="flex w-full flex-col gap-[var(--measures-spacing-016)]">
<div className="flex w-full flex-col gap-[var(--measures-spacing-004)] text-[var(--color-content-inverse-brand-royal)]">
<h1 className="w-full font-bricolage font-medium text-[32px] leading-[110%] sm:text-[40px] lg:text-[44px]">
{title}
</h1>
{description ? (
<p className="w-full font-inter font-normal text-[16px] leading-[130%] sm:text-[18px]">
{description}
</p>
) : null}
</div>
</div>
<div className="flex w-full items-end gap-[var(--measures-spacing-008)] font-inter text-[14px] leading-[20px] text-[var(--color-content-inverse-brand-royal)]">
<span>{author}</span>
<span>{formattedDate}</span>
</div>
</div>
<div className="flex min-w-0 w-full flex-1">
<Rule
title={rulePreview.title}
description={rulePreview.description}
expanded
fluidWidth
size="L"
templateGridFigmaShell
backgroundColor={rulePreview.backgroundColor}
className="pointer-events-none w-full select-none rounded-[24px]"
icon={
<Image
src={getAssetPath(rulePreview.iconPath)}
alt=""
width={103}
height={103}
draggable={false}
unoptimized={rulePreview.iconPath.endsWith(".svg")}
className="aspect-square size-full max-h-[103px] max-w-[103px] object-contain mix-blend-luminosity"
/>
}
/>
</div>
</div>
</section>
);
}
function ContentBannerView(props: ContentBannerViewProps) {
if (props.variant === "guide") {
return <ContentBannerGuideView post={props.post} />;
}
if (props.variant === "useCase") {
return <ContentBannerUseCaseView {...props} />;
}
return <ContentBannerArticleView {...props} />;
}
@@ -118,14 +118,14 @@ const ContentLockupContainer = memo<ContentLockupProps>(
"w-[16px] h-[16px] md:w-[20px] md:h-[20px] lg:w-[24px] lg:h-[24px]",
},
"ask-inverse": {
container: "flex flex-col gap-[var(--spacing-scale-008)] relative z-10",
container: "flex flex-col gap-[var(--spacing-scale-008)] relative z-10 w-full",
textContainer: "flex flex-col gap-[var(--spacing-scale-008)]",
titleGroup: "flex flex-col gap-[var(--spacing-scale-008)]",
titleGroup: "flex flex-col gap-[var(--spacing-scale-008)] w-full",
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
title:
"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)]",
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-invert-secondary)]",
shape:
"w-[16px] h-[16px] md:w-[20px] md:h-[20px] lg:w-[24px] lg:h-[24px]",
},