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