Marketing About, How It Works, and Use Cases pages #52

Merged
an.di merged 8 commits from adilallo/feature/AboutUseCasesPages into main 2026-05-21 05:09:15 +00:00
281 changed files with 10020 additions and 1081 deletions
+1
View File
@@ -51,6 +51,7 @@ npm-cache/
/build
# misc
/tmp/
*.pem
# IDE and editor files
+12 -2
View File
@@ -44,6 +44,7 @@ import {
} from "./utils/createFlowScreenRegistry";
import Button from "../../components/buttons/Button";
import { isValidCreateFlowSaveEmail } from "../../../lib/create/isValidCreateFlowSaveEmail";
import { buildCreateFlowDraftPayload } from "../../../lib/create/buildCreateFlowDraftPayload";
import {
fetchAuthSession,
requestMagicLink,
@@ -52,6 +53,7 @@ import { safeInternalPath } from "../../../lib/safeInternalPath";
import {
clearAnonymousCreateFlowStorage,
setTransferPendingFlag,
writeAnonymousCreateFlowState,
} from "./utils/anonymousDraftStorage";
import {
createFlowStateFromPublishedRule,
@@ -396,7 +398,15 @@ function CreateFlowLayoutContent({
const segment = stepAfterSave ?? "review";
const rawNext = `/create/${segment}?syncDraft=1`;
const nextPath = safeInternalPath(rawNext);
const result = await requestMagicLink(trimmed, nextPath);
const draftPayload = buildCreateFlowDraftPayload(state, currentStep);
writeAnonymousCreateFlowState({
...draftPayload,
communitySaveEmail: trimmed,
});
const result = await requestMagicLink(trimmed, nextPath, {
...draftPayload,
communitySaveEmail: trimmed,
});
if (result.ok === false) {
if (result.retryAfterMs != null && result.retryAfterMs > 0) {
const seconds = Math.ceil(result.retryAfterMs / 1000);
@@ -418,7 +428,7 @@ function CreateFlowLayoutContent({
} finally {
setCommunitySaveMagicLinkSubmitting(false);
}
}, [state.communitySaveEmail, tLogin, updateState]);
}, [state, currentStep, tLogin, updateState]);
const isCompletedStep = currentStep === "completed";
const isRightRailStep = currentStep === "decision-approaches";
+67 -62
View File
@@ -9,16 +9,55 @@ import {
} from "./utils/anonymousDraftStorage";
import { useCreateFlow } from "./context/CreateFlowContext";
import { parseCreateFlowScreenFromPathname } from "./utils/flowSteps";
import { saveDraftToServer } from "../../../lib/create/api";
import { fetchDraftFromServer, saveDraftToServer } from "../../../lib/create/api";
import { createFlowStateHasKeys } from "../../../lib/create/draftHydrationUtils";
import type { CreateFlowState } from "./types";
import messages from "../../../messages/en/index";
import Alert from "../../components/modals/Alert";
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
function buildPayloadWithStep(
base: CreateFlowState,
pathname: string | null,
): CreateFlowState {
const step =
parseCreateFlowScreenFromPathname(pathname ?? null) ?? undefined;
return {
...base,
...(step ? { currentStep: step } : {}),
};
}
/**
* Prefer the on-device anonymous mirror when present; otherwise use the draft
* stored on the magic-link token at request time (written during verify).
*/
async function resolvePostLoginDraftPayload(
local: CreateFlowState,
pathname: string | null,
): Promise<CreateFlowState | null> {
const localPayload = createFlowStateHasKeys(local)
? buildPayloadWithStep(local, pathname)
: null;
const serverDraft = await fetchDraftFromServer();
const serverPayload =
serverDraft != null && createFlowStateHasKeys(serverDraft)
? buildPayloadWithStep(serverDraft, pathname)
: null;
if (localPayload && serverPayload) {
return { ...serverPayload, ...localPayload };
}
return localPayload ?? serverPayload;
}
/**
* After magic-link verify, redirects to `/create/...?syncDraft=1` with session cookie.
* With backend sync: PUT draft once then hydrates context. Without sync: hydrates from
* `create-flow-anonymous` localStorage only (no server write).
* With backend sync: PUT draft once when the device mirror is non-empty, then hydrates
* context. Without sync: hydrates from localStorage and/or the server draft saved at
* verify. Never writes an empty payload over an existing server draft.
*/
export function PostLoginDraftTransfer({
sessionUser,
@@ -39,49 +78,6 @@ export function PostLoginDraftTransfer({
if (!wantsTransfer) return;
if (attemptedRef.current) return;
if (!SYNC_ENABLED) {
attemptedRef.current = true;
let cancelled = false;
void (async () => {
const local = readAnonymousCreateFlowState();
const pending = hasTransferPendingFlag();
if (Object.keys(local).length === 0 && !pending) {
const params = new URLSearchParams(searchParams.toString());
params.delete("syncDraft");
const q = params.toString();
if (pathname) {
router.replace(q ? `${pathname}?${q}` : pathname);
}
attemptedRef.current = false;
return;
}
const step =
parseCreateFlowScreenFromPathname(pathname ?? null) ?? undefined;
const payload = {
...local,
...(step ? { currentStep: step } : {}),
};
if (cancelled) return;
clearAnonymousCreateFlowStorage();
replaceState(payload);
if (cancelled) return;
if (pathname) {
const params = new URLSearchParams(searchParams.toString());
params.delete("syncDraft");
const q = params.toString();
router.replace(q ? `${pathname}?${q}` : pathname);
}
})();
return () => {
cancelled = true;
};
}
attemptedRef.current = true;
let cancelled = false;
@@ -90,7 +86,7 @@ export function PostLoginDraftTransfer({
const local = readAnonymousCreateFlowState();
const pending = hasTransferPendingFlag();
if (Object.keys(local).length === 0 && !pending) {
if (!createFlowStateHasKeys(local) && !pending) {
const params = new URLSearchParams(searchParams.toString());
params.delete("syncDraft");
const q = params.toString();
@@ -101,27 +97,36 @@ export function PostLoginDraftTransfer({
return;
}
const step =
parseCreateFlowScreenFromPathname(pathname ?? null) ?? undefined;
const payload = {
...local,
...(step ? { currentStep: step } : {}),
};
const saveResult = await saveDraftToServer(payload);
const payload = await resolvePostLoginDraftPayload(local, pathname);
if (cancelled) return;
if (saveResult.ok === false) {
setTransferError(
messages.create.topNav.postLoginSaveFailedWithReason.replace(
"{reason}",
saveResult.message,
),
);
if (payload == null || !createFlowStateHasKeys(payload)) {
const params = new URLSearchParams(searchParams.toString());
params.delete("syncDraft");
const q = params.toString();
if (pathname) {
router.replace(q ? `${pathname}?${q}` : pathname);
}
attemptedRef.current = false;
return;
}
if (SYNC_ENABLED && createFlowStateHasKeys(local)) {
const saveResult = await saveDraftToServer(payload);
if (cancelled) return;
if (saveResult.ok === false) {
setTransferError(
messages.create.topNav.postLoginSaveFailedWithReason.replace(
"{reason}",
saveResult.message,
),
);
attemptedRef.current = false;
return;
}
}
clearAnonymousCreateFlowStorage();
replaceState(payload);
@@ -65,7 +65,6 @@ export function SignedInDraftHydration({
if (finishedUserIdRef.current === userId) return;
if (syncDraftParam === "1" || hasTransferPendingFlag()) {
finishedUserIdRef.current = userId;
return;
}
@@ -0,0 +1,109 @@
"use client";
/**
* Edit published rule: community name with the same 48-char limit as
* {@link CreateFlowTextFieldScreen} `community-name` step.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import Create from "../../../components/modals/Create";
import TextInput from "../../../components/controls/TextInput";
import ContentLockup from "../../../components/type/ContentLockup";
import { useTranslation } from "../../../contexts/MessagesContext";
/** Matches `community-name` step (`CreateFlowTextFieldScreen` `maxLength={48}`). */
export const COMMUNITY_TITLE_FIELD_MAX_LENGTH = 48;
export interface FinalReviewTitleEditModalProps {
isOpen: boolean;
onClose: () => void;
initialValue: string;
onSave: (_value: string) => void;
}
export function FinalReviewTitleEditModal({
isOpen,
onClose,
initialValue,
onSave,
}: FinalReviewTitleEditModalProps) {
const tModal = useTranslation(
"create.reviewAndComplete.finalReview.titleEditModal",
);
const tField = useTranslation("create.community.communityName");
const tSave = useTranslation(
"create.reviewAndComplete.finalReview.chipEditModal",
);
const [draft, setDraft] = useState("");
const initialRef = useRef("");
const seededOpenRef = useRef(false);
useEffect(() => {
if (!isOpen) {
seededOpenRef.current = false;
return;
}
if (seededOpenRef.current) return;
seededOpenRef.current = true;
const seed = initialValue;
setDraft(seed);
initialRef.current = seed;
}, [isOpen, initialValue]);
const isDirty = useMemo(() => draft !== initialRef.current, [draft]);
const trimmedDraft = draft.trim();
const canSave = isDirty && trimmedDraft.length > 0;
const characterHint = tField("characterCountTemplate")
.replace("{current}", String(draft.length))
.replace("{max}", String(COMMUNITY_TITLE_FIELD_MAX_LENGTH));
const handleSave = () => {
if (!canSave) return;
const capped = trimmedDraft.slice(0, COMMUNITY_TITLE_FIELD_MAX_LENGTH);
onSave(capped);
onClose();
};
return (
<Create
isOpen={isOpen}
onClose={onClose}
backdropVariant="blurredYellow"
headerContent={
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup
title={tModal("title")}
description={tModal("description")}
variant="modal"
alignment="left"
/>
</div>
}
showBackButton={false}
showNextButton
nextButtonText={tSave("saveButton")}
nextButtonDisabled={!canSave}
onNext={handleSave}
ariaLabel={tModal("title")}
>
<div className="pb-2">
<TextInput
className="!transition-none"
type="text"
placeholder={tField("placeholder")}
value={draft}
onChange={(e) => {
setDraft(e.target.value);
}}
inputSize="medium"
formHeader={false}
textHint={characterHint}
maxLength={COMMUNITY_TITLE_FIELD_MAX_LENGTH}
/>
</div>
</Create>
);
}
@@ -18,6 +18,7 @@ import type {
import {
clearAnonymousCreateFlowStorage,
clearLegacyCreateFlowKeysOnce,
hasTransferPendingFlag,
readAnonymousCreateFlowState,
writeAnonymousCreateFlowState,
} from "../utils/anonymousDraftStorage";
@@ -94,6 +95,13 @@ export function CreateFlowProvider({
const wasOff = !prevPersistRef.current;
prevPersistRef.current = true;
if (!wasOff) return;
if (hasTransferPendingFlag()) return;
if (
typeof window !== "undefined" &&
new URLSearchParams(window.location.search).get("syncDraft") === "1"
) {
return;
}
const from = readAnonymousCreateFlowState();
if (Object.keys(from).length === 0) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- hydrate local draft when mirroring turns on
@@ -0,0 +1,20 @@
"use client";
import { useEffect, useState } from "react";
import { useMediaQuery } from "../../../hooks/useMediaQuery";
/** `--breakpoint-sm2` (440px); pairs with Tailwind `sm2:` on create-flow chrome. */
const CREATE_FLOW_MIN_WIDTH_SM2 = "(min-width: 440px)";
/** True at viewport ≥440px. */
export function useCreateFlowSm2Up(): boolean {
const [isMounted, setIsMounted] = useState(false);
const isSm2OrLarger = useMediaQuery(CREATE_FLOW_MIN_WIDTH_SM2);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- defer until mount for SSR/first-paint alignment
setIsMounted(true);
}, []);
return !isMounted || isSm2OrLarger;
}
@@ -163,10 +163,10 @@ export function CompletedScreen() {
<>
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[var(--color-teal-teal50,#c9fef9)] md:h-full">
<div
className={`mx-auto grid min-h-0 w-full grid-cols-1 gap-4 px-5 max-md:max-w-[639px] max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] md:overflow-hidden md:px-12 md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
className={`mx-auto grid min-h-0 w-full grid-cols-1 gap-4 px-5 max-md:max-w-[639px] max-md:overflow-y-auto max-md:overscroll-y-contain max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:grid-rows-1 md:items-stretch md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] md:overflow-hidden md:px-12 md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
>
<div
className={`flex flex-col justify-start overflow-hidden md:justify-center md:pb-8 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
className={`flex flex-col justify-start max-md:min-h-min max-md:overflow-visible min-h-0 overflow-hidden md:justify-center md:pb-8 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
<CreateFlowHeaderLockup
title={headerTitle}
@@ -177,7 +177,7 @@ export function CompletedScreen() {
/>
</div>
<div
className={`scrollbar-hide relative flex min-h-0 flex-col overflow-x-hidden md:overflow-y-auto ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
className={`scrollbar-hide relative flex min-h-0 flex-col self-stretch overflow-x-hidden md:max-h-full md:overflow-y-auto ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
<div
className="pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 bg-gradient-to-b from-[var(--color-teal-teal50,#c9fef9)]/55 from-0% via-[var(--color-teal-teal50,#c9fef9)]/20 via-50% to-transparent md:block"
@@ -25,6 +25,7 @@ import {
type FinalReviewChipEditTarget,
} from "../../components/FinalReviewChipEditModal";
import { FinalReviewCommunityContextEditModal } from "../../components/FinalReviewCommunityContextEditModal";
import { FinalReviewTitleEditModal } from "../../components/FinalReviewTitleEditModal";
import { useCreateFlowNavigation } from "../../hooks/useCreateFlowNavigation";
import { createFlowStepForFacetGroup } from "../../utils/facetGroupToCreateFlowStep";
import {
@@ -114,6 +115,7 @@ export function FinalReviewScreen({
useState<TemplateChipDetail | null>(null);
const [communityContextModalOpen, setCommunityContextModalOpen] =
useState(false);
const [titleModalOpen, setTitleModalOpen] = useState(false);
const handleSave = useCallback(
(patch: FinalReviewChipEditPatch) => {
@@ -225,6 +227,9 @@ export function FinalReviewScreen({
const rawCommunityContextForModal =
typeof state.communityContext === "string" ? state.communityContext : "";
const rawTitleForModal =
typeof state.title === "string" ? state.title : "";
const descriptionEmptyHint =
variant === "editPublished" ? t("communityContextEditModal.emptyHint") : undefined;
@@ -242,6 +247,16 @@ export function FinalReviewScreen({
<Rule
title={ruleCardTitle}
description={ruleCardDescription}
onTitleClick={
variant === "editPublished"
? () => setTitleModalOpen(true)
: undefined
}
titleEditAriaLabel={
variant === "editPublished"
? t("titleEditModal.ariaEditTitle")
: undefined
}
onDescriptionClick={
variant === "editPublished"
? () => setCommunityContextModalOpen(true)
@@ -278,15 +293,26 @@ export function FinalReviewScreen({
detail={activeReadOnlyDetail}
/>
{variant === "editPublished" ? (
<FinalReviewCommunityContextEditModal
isOpen={communityContextModalOpen}
onClose={() => setCommunityContextModalOpen(false)}
initialValue={rawCommunityContextForModal}
onSave={(value) => {
markCreateFlowInteraction();
updateState({ communityContext: value, summary: value });
}}
/>
<>
<FinalReviewTitleEditModal
isOpen={titleModalOpen}
onClose={() => setTitleModalOpen(false)}
initialValue={rawTitleForModal}
onSave={(value) => {
markCreateFlowInteraction();
updateState({ title: value });
}}
/>
<FinalReviewCommunityContextEditModal
isOpen={communityContextModalOpen}
onClose={() => setCommunityContextModalOpen(false)}
initialValue={rawCommunityContextForModal}
onSave={(value) => {
markCreateFlowInteraction();
updateState({ communityContext: value, summary: value });
}}
/>
</>
) : null}
</CreateFlowLockupCardStepShell>
);
@@ -10,14 +10,28 @@ const RuleStack = dynamic(() => import("../../components/sections/RuleStack"), {
ssr: true,
});
type MarketingRuleStackSectionProps = {
translationNamespace?: string;
twoColumnsFromMd?: boolean;
};
/**
* Server-loaded “Popular templates” row so the first paint has card data without a client fetch.
*/
export async function MarketingRuleStackSection() {
export async function MarketingRuleStackSection({
translationNamespace,
twoColumnsFromMd,
}: MarketingRuleStackSectionProps = {}) {
const rows = await listRuleTemplatesFromDb();
const initialGridEntries = gridEntriesForSlugOrderWithCatalogFallback(
rows,
GOVERNANCE_TEMPLATE_HOME_SLUGS,
);
return <RuleStack initialGridEntries={initialGridEntries} />;
return (
<RuleStack
initialGridEntries={initialGridEntries}
translationNamespace={translationNamespace}
twoColumnsFromMd={twoColumnsFromMd}
/>
);
}
+73
View File
@@ -0,0 +1,73 @@
import messages from "../../../messages/en/index";
import { getTranslation } from "../../../lib/i18n/getTranslation";
import AboutHeader from "../../components/type/AboutHeader";
import type { AboutHeaderSegment } from "../../components/type/AboutHeader";
import Stats from "../../components/sections/Stats";
import type { StatItem } from "../../components/sections/Stats";
import TripleTextBlock from "../../components/type/TripleTextBlock";
import type { TripleTextBlockColumn } from "../../components/type/TripleTextBlock";
import Book from "../../components/sections/Book";
import FaqAccordion from "../../components/sections/Accordion";
import type { FaqAccordionItem } from "../../components/sections/Accordion";
import QuoteBlock from "../../components/sections/QuoteBlock";
import AskOrganizer from "../../components/sections/AskOrganizer";
function asArray<T>(value: unknown): T[] {
return Array.isArray(value) ? value : [];
}
export default function AboutPage() {
const t = (key: string) => getTranslation(messages, key);
const page = messages.pages.about;
const headerSegments = asArray<AboutHeaderSegment>(page.aboutHeader.segments);
const statsItems = asArray<StatItem>(page.stats.items);
const statsAsOf =
typeof page.stats.asOf === "string"
? page.stats.asOf
: String(page.stats.asOf ?? "");
const faqItems = asArray<FaqAccordionItem>(page.faq.items);
const tripleColumns = asArray<TripleTextBlockColumn>(page.tripleTextBlock.columns);
const askOrganizerData = {
title: t("pages.home.askOrganizer.title"),
subtitle: t("pages.home.askOrganizer.subtitle"),
buttonText: t("pages.home.askOrganizer.buttonText"),
};
return (
<div className="min-h-screen bg-black">
<AboutHeader segments={headerSegments} />
<Stats
titlePrefix={page.stats.titlePrefix}
titleEmphasis={page.stats.titleEmphasis}
titleSuffix={page.stats.titleSuffix}
items={statsItems.map((item) => ({
...item,
asOf: statsAsOf,
}))}
/>
<TripleTextBlock columns={tripleColumns} />
<Book
title={page.book.title}
description={page.book.description}
buttonText={page.book.buttonText}
buttonHref={page.book.buttonHref}
imageAlt={page.book.imageAlt}
/>
<FaqAccordion title={page.faq.title} items={faqItems} />
<QuoteBlock
variant="statement"
id="about-statement-quote"
quote={page.quote.paragraph1}
quoteSecondary={page.quote.paragraph2}
/>
<section className="bg-[var(--color-surface-default-primary)]">
<AskOrganizer {...askOrganizerData} />
</section>
</div>
);
}
+18 -65
View File
@@ -1,10 +1,11 @@
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import dynamic from "next/dynamic";
import type { BlogPost } from "../../../../lib/content";
import {
getBlogPostBySlug,
getAllBlogPosts as getAllPosts,
type BlogPost,
getRelatedBlogPosts,
} from "../../../../lib/content";
import { logger } from "../../../../lib/logger";
import ContentBanner from "../../../components/sections/ContentBanner";
@@ -111,66 +112,12 @@ export default async function BlogPostPage({ params }: PageProps) {
// Get related articles with improved algorithm
const allPosts = getAllPosts();
// Create slug order for consistent background cycling
const slugOrder = allPosts.map((post) => post.slug);
// Simple related articles algorithm based on content similarity
const getRelatedArticles = (
currentPost: BlogPost,
allPosts: BlogPost[],
limit = 3,
): BlogPost[] => {
const otherPosts = allPosts.filter((p) => p.slug !== currentPost.slug);
// Score posts based on content similarity
const scoredPosts = otherPosts.map((post) => {
let score = 0;
// Check for similar keywords in title and description
const currentTitle = currentPost.frontmatter.title.toLowerCase();
const currentDesc = currentPost.frontmatter.description.toLowerCase();
const postTitle = post.frontmatter.title.toLowerCase();
const postDesc = post.frontmatter.description.toLowerCase();
// Common keywords that indicate similarity
const keywords = [
"community",
"conflict",
"decision",
"governance",
"security",
"trust",
"collaboration",
"organization",
];
keywords.forEach((keyword) => {
if (currentTitle.includes(keyword) && postTitle.includes(keyword))
score += 3;
if (currentDesc.includes(keyword) && postDesc.includes(keyword))
score += 2;
if (currentTitle.includes(keyword) && postDesc.includes(keyword))
score += 1;
if (currentDesc.includes(keyword) && postTitle.includes(keyword))
score += 1;
});
return { ...post, score };
});
// Sort by score and return top posts
return scoredPosts
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(({ score, ...post }) => {
// Score used for sorting, removed from final result
void score;
return post;
});
};
const relatedArticles = getRelatedArticles(post, allPosts);
const relatedArticles = getRelatedBlogPosts(
post.slug,
post.frontmatter.related,
3,
);
// Generate structured data for search engines
const structuredData = {
@@ -255,7 +202,7 @@ export default async function BlogPostPage({ params }: PageProps) {
/>
<div
className="min-h-screen relative overflow-hidden"
className="relative min-h-screen overflow-x-clip"
style={{ backgroundColor }}
>
{/* Content Banner */}
@@ -296,10 +243,16 @@ export default async function BlogPostPage({ params }: PageProps) {
/>
</div>
{/* Main Content */}
<article className="p-[var(--spacing-scale-024)] sm:py-[var(--spacing-scale-032)]">
{/* Article Content */}
<div className="post-body -mt-[var(--spacing-scale-048)] text-[var(--color-content-inverse-primary)] text-[16px] leading-[24px] sm:text-[18px] sm:leading-[130%] lg:text-[24px] lg:leading-[32px] xl:text-[32px] xl:leading-[40px] sm:mx-auto sm:max-w-[390px] md:max-w-[472px] lg:max-w-[700px] xl:max-w-[904px]">
{/* Main Content — Figma Content page Template (19003:23305) article body instances */}
<article
data-node-id="19031:10426"
className="
relative z-[2] flex w-full justify-center
p-[var(--spacing-scale-024)]
sm:px-0 sm:py-[var(--spacing-scale-032)]
"
>
<div className="post-body w-full text-[var(--color-content-inverse-primary)] text-[16px] leading-[24px] sm:text-[18px] sm:leading-[130%] lg:text-[24px] lg:leading-[32px] xl:text-[32px] xl:leading-[40px] sm:max-w-[390px] md:max-w-[472px] lg:max-w-[700px] xl:max-w-[904px]">
<div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
</div>
</article>
+4
View File
@@ -1,4 +1,8 @@
/* Blog post body styling with semantic spacing */
.post-body > :first-child {
margin-block-start: 0;
}
.post-body p {
/* Scales with font size - uses logical properties for better writing mode support */
margin-block: 1em;
@@ -0,0 +1,43 @@
/**
* Figma: "A Guide to CommunityRule" body ornaments (22078:791901)
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22078-791901
*
* - 19003:23575 — concentric circles, right (`how-shape-2.svg`)
* - 19003:23576 — loop mark, left (`how-shape-1.svg`)
*/
import {
getAssetPath,
howItWorksOrnamentLeftPath,
howItWorksOrnamentRightPath,
} from "../../../../lib/assetUtils";
export default function HowItWorksDecorativeShapes() {
return (
<>
<div
aria-hidden
className="pointer-events-none absolute z-0 hidden aspect-square md:block left-[84.86%] right-[-9.28%] top-[clamp(200px,20vw,255px)]"
data-node-id="19003:23575"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getAssetPath(howItWorksOrnamentRightPath())}
alt=""
className="pointer-events-none size-full max-w-none object-cover"
/>
</div>
<div
aria-hidden
className="pointer-events-none absolute z-0 hidden aspect-square md:block left-[-1.66%] right-[88.38%] top-[clamp(520px,55vw,811px)]"
data-node-id="19003:23576"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getAssetPath(howItWorksOrnamentLeftPath())}
alt=""
className="pointer-events-none size-full max-w-none object-cover"
/>
</div>
</>
);
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Figma: "How Community Rule works" (22078:806964)
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22078-806964
*/
import type { Metadata } from "next";
import dynamic from "next/dynamic";
import messages from "../../../messages/en/index";
import { getAllBlogPosts } from "../../../lib/content";
import {
buildHowItWorksSyntheticPost,
HOW_IT_WORKS_SENTINEL_SLUG,
} from "../../../lib/howItWorksSyntheticPost";
import ContentBanner from "../../components/sections/ContentBanner";
import HowItWorksDecorativeShapes from "./_components/HowItWorksDecorativeShapes";
import AskOrganizer from "../../components/sections/AskOrganizer";
import "../blog/blog.css";
const RelatedArticles = dynamic(
() => import("../../components/sections/RelatedArticles"),
{
loading: () => (
<section className="py-[var(--spacing-scale-032)] min-h-[400px]" />
),
ssr: true,
},
);
export async function generateMetadata(): Promise<Metadata> {
const meta = messages.metadata.howItWorks;
const page = messages.pages.howItWorks;
return {
title: meta.title,
description: meta.description,
keywords: meta.keywords,
openGraph: {
title: page.banner.title,
description: page.banner.description,
type: "website",
siteName: "CommunityRule",
},
};
}
export default function HowItWorksPage() {
const page = messages.pages.howItWorks;
const syntheticPost = buildHowItWorksSyntheticPost(page);
const allPosts = getAllBlogPosts();
const relatedPosts = allPosts.slice(0, 8);
const slugOrder = allPosts.map((post) => post.slug);
const askOrganizerData = {
title: messages.pages.home.askOrganizer.title,
subtitle: messages.pages.home.askOrganizer.subtitle,
buttonText: messages.pages.home.askOrganizer.buttonText,
};
const structuredData = {
"@context": "https://schema.org",
"@type": "WebPage",
name: page.banner.title,
description: page.banner.description,
url: "https://communityrule.com/how-it-works",
publisher: {
"@type": "Organization",
name: "CommunityRule",
url: "https://communityrule.com",
},
};
const breadcrumbData = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: "https://communityrule.com",
},
{
"@type": "ListItem",
position: 2,
name: page.banner.title,
item: "https://communityrule.com/how-it-works",
},
],
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(structuredData),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(breadcrumbData),
}}
/>
<div className="relative min-h-screen overflow-x-hidden bg-transparent">
<ContentBanner post={syntheticPost} variant="guide" />
<div className="relative w-full">
<HowItWorksDecorativeShapes />
<article className="relative z-10 p-[var(--spacing-scale-024)] sm:py-[var(--spacing-scale-032)]">
<div
className="post-body -mt-[var(--spacing-scale-048)] text-[var(--color-content-default-primary)] text-[16px] leading-[24px] sm:text-[18px] sm:leading-[130%] lg:text-[24px] lg:leading-[32px] xl:text-[32px] xl:leading-[40px] sm:mx-auto sm:max-w-[390px] md:max-w-[472px] lg:max-w-[700px] xl:max-w-[904px]"
dangerouslySetInnerHTML={{ __html: syntheticPost.htmlContent }}
/>
</article>
</div>
<RelatedArticles
relatedPosts={relatedPosts}
currentPostSlug={HOW_IT_WORKS_SENTINEL_SLUG}
slugOrder={slugOrder}
headingSurface="onLight"
heading={page.relatedArticles.title}
/>
<AskOrganizer {...askOrganizerData} />
</div>
</>
);
}
+10 -24
View File
@@ -6,10 +6,8 @@ import AskOrganizer from "../../components/sections/AskOrganizer";
import { getAllBlogPosts } from "../../../lib/content";
export default function LearnPage() {
// Get real blog posts from the content system
const allPosts = getAllBlogPosts();
// Use direct message access for server components
const t = (key: string) => getTranslation(messages, key);
const contentLockupData = {
@@ -31,36 +29,24 @@ export default function LearnPage() {
<div className="min-h-screen bg-[var(--color-surface-default-primary)]">
<ContentLockup {...contentLockupData} />
{/* Horizontal list (below smd) */}
<div className="smd:hidden sm:pt-[var(--spacing-scale-024)] sm:pb-[var(--spacing-scale-024)] sm:px-[var(--spacing-scale-020)] space-y-[var(--spacing-scale-002)] sm:space-y-[var(--spacing-scale-008)]">
{allPosts.slice(0, 3).map((post, index) => (
{allPosts.map((post) => (
<ContentThumbnailTemplate
key={`${post.slug}-${index}-${
post.frontmatter.thumbnail?.horizontal || "default"
}`}
key={`${post.slug}-horizontal`}
post={post}
variant="horizontal"
/>
))}
</div>
{/* smd and up: 2x3 grid of vertical thumbnails, repeat posts as needed */}
<div className="hidden smd:grid smd:grid-cols-2 xmd:grid-cols-3 lg:grid-cols-3 lg2:grid-cols-4 xl:grid-cols-5 smd:gap-[var(--spacing-scale-008)] md:gap-[var(--spacing-scale-016)] xmd:gap-[var(--spacing-scale-012)] lg:gap-[var(--spacing-scale-016)] lg2:gap-x-[var(--spacing-scale-016)] lg2:gap-y-[var(--spacing-scale-024)] xl:gap-x-[var(--spacing-scale-016)] xl:gap-y-[var(--spacing-scale-016)] smd:pt-[var(--spacing-scale-024)] smd:pb-[var(--spacing-scale-024)] smd:px-[var(--spacing-scale-020)] md:px-[var(--spacing-scale-032)] lg:pt-[var(--spacing-scale-032)] lg:pb-[var(--spacing-scale-064)] lg:px-[var(--spacing-scale-064)]">
{Array.from({ length: 16 }).map((_, i) => {
const post = allPosts[i % allPosts.length];
return (
<ContentThumbnailTemplate
key={`grid-${post.slug}-${i}-${
post.frontmatter.thumbnail?.vertical || "default"
}`}
post={post}
variant="vertical"
className={`${i >= 6 ? "hidden lg2:block" : ""} ${
i >= 10 ? "xl:hidden" : ""
}`}
/>
);
})}
<div className="hidden smd:grid smd:grid-cols-2 xmd:grid-cols-3 lg:grid-cols-3 lg2:grid-cols-4 xl:grid-cols-5 smd:gap-[var(--spacing-scale-008)] md:gap-[var(--spacing-scale-016)] xmd:gap-[var(--spacing-scale-012)] lg:gap-[var(--spacing-scale-016)] lg2:gap-x-[var(--spacing-scale-016)] lg2:gap-y-[var(--spacing-scale-024)] xl:gap-x-[var(--spacing-scale-016)] xl:gap-y-[var(--spacing-scale-016)] smd:pt-[var(--spacing-scale-024)] smd:pb-[var(--spacing-scale-024)] smd:px-[var(--spacing-scale-020)] md:px-[var(--spacing-scale-032)] lg:pt-[var(--spacing-scale-032)] lg:pb-[var(--spacing-scale-064)] lg:px-[var(--spacing-scale-064)] [&>*]:min-w-0">
{allPosts.map((post) => (
<ContentThumbnailTemplate
key={`${post.slug}-vertical`}
post={post}
variant="vertical"
/>
))}
</div>
<AskOrganizer {...askOrganizerData} />
+1
View File
@@ -76,6 +76,7 @@ export default function Page() {
iconColor: "orange",
},
],
seeHowItWorksHref: t("cardSteps.buttons.seeHowItWorksHref"),
};
const featureGridData = {
+150
View File
@@ -0,0 +1,150 @@
/**
* Figma: use case detail (22015:42619)
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22015-42619
*/
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import messages from "../../../../messages/en/index";
import {
buildUseCaseSyntheticPost,
getUseCaseDetailEntry,
isUseCaseDetailSlug,
USE_CASE_DETAIL_SLUGS,
useCaseContentKeyForSlug,
} from "../../../../lib/useCaseSyntheticPost";
import ContentBanner from "../../../components/sections/ContentBanner";
import AskOrganizer from "../../../components/sections/AskOrganizer";
import type { AskOrganizerVariant } from "../../../components/sections/AskOrganizer/AskOrganizer.types";
import "../use-cases.css";
type PageProps = {
params: Promise<{ slug: string }>;
};
export function generateStaticParams() {
return USE_CASE_DETAIL_SLUGS.map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
if (!isUseCaseDetailSlug(slug)) {
return {};
}
const contentKey = useCaseContentKeyForSlug(slug);
const meta = messages.metadata.useCasesDetail[contentKey];
return {
title: meta.title,
description: meta.description,
keywords: meta.keywords,
openGraph: {
title: meta.title,
description: meta.description,
type: "website",
siteName: "CommunityRule",
},
};
}
export default async function UseCaseDetailPage({ params }: PageProps) {
const { slug } = await params;
if (!isUseCaseDetailSlug(slug)) {
notFound();
}
const detail = messages.pages.useCasesDetail;
const entry = getUseCaseDetailEntry(slug, detail);
const syntheticPost = buildUseCaseSyntheticPost(slug, detail);
const { ruleCard, askOrganizer } = entry;
const askVariant = (askOrganizer.variant ?? "use-case-detail") as AskOrganizerVariant;
const structuredData = {
"@context": "https://schema.org",
"@type": "WebPage",
name: entry.banner.title,
description: entry.banner.description,
url: `https://communityrule.com/use-cases/${slug}`,
publisher: {
"@type": "Organization",
name: "CommunityRule",
url: "https://communityrule.com",
},
};
const breadcrumbData = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: "https://communityrule.com",
},
{
"@type": "ListItem",
position: 2,
name: "Use cases",
item: "https://communityrule.com/use-cases",
},
{
"@type": "ListItem",
position: 3,
name: entry.banner.title,
item: `https://communityrule.com/use-cases/${slug}`,
},
],
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(structuredData),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(breadcrumbData),
}}
/>
<div
className="min-h-screen"
style={{ background: entry.pageBackground }}
>
<ContentBanner
post={syntheticPost}
variant="useCase"
rulePreview={{
title: ruleCard.title,
description: ruleCard.description,
backgroundColor: ruleCard.backgroundColor,
iconPath: ruleCard.iconPath,
href: `/use-cases/${slug}/rule`,
}}
/>
<article
data-figma-node="22015:42622"
className="flex w-full items-center justify-center self-stretch px-[var(--spacing-scale-024)] py-[var(--spacing-scale-032)] sm:px-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-048)] lg:px-[var(--spacing-scale-064)] xl:px-[256px]"
>
<div
className="use-case-body"
dangerouslySetInnerHTML={{ __html: syntheticPost.htmlContent }}
/>
</article>
<AskOrganizer
title={askOrganizer.title}
subtitle={askOrganizer.subtitle}
buttonText={askOrganizer.buttonText}
variant={askVariant}
/>
</div>
</>
);
}
+207
View File
@@ -0,0 +1,207 @@
import type { Metadata } from "next";
import dynamic from "next/dynamic";
import Link from "next/link";
import { Suspense } from "react";
import messages from "../../../messages/en/index";
import { getAllBlogPosts } from "../../../lib/content";
import PageHeader from "../../components/type/PageHeader";
import CaseStudy from "../../components/cards/CaseStudy";
import UseCasesOrgs from "../../components/sections/UseCasesOrgs";
import QuoteBlock from "../../components/sections/QuoteBlock";
import Groups from "../../components/sections/Groups";
import type { GroupsItem } from "../../components/sections/Groups";
import TripleStep from "../../components/type/TripleStep";
import TripleTextBlock from "../../components/type/TripleTextBlock";
import type { TripleTextBlockColumn } from "../../components/type/TripleTextBlock";
import AskOrganizer from "../../components/sections/AskOrganizer";
import { MarketingRuleStackSection } from "../_components/MarketingRuleStackSection";
import { getAssetPath, vectorMarkPath } from "../../../lib/assetUtils";
const RelatedArticles = dynamic(
() => import("../../components/sections/RelatedArticles"),
{
loading: () => (
<section className="min-h-[400px] bg-black py-[var(--spacing-scale-032)]" />
),
ssr: true,
},
);
function asArray<T>(value: unknown): T[] {
return Array.isArray(value) ? value : [];
}
const CASE_STUDY_TILE_RADIUS_CLASS = "rounded-[23.093px]";
const CASE_STUDY_LINK_CLASS = [
CASE_STUDY_TILE_RADIUS_CLASS,
"block shrink-0 cursor-pointer outline-none transition-transform duration-200",
"hover:scale-[1.02] hover:opacity-95",
"focus-visible:ring-2 focus-visible:ring-[var(--color-border-default-brand-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]",
"active:scale-[0.98]",
].join(" ");
/** Matches `pages.useCases.groups.items` order ↔ `public/assets/vector/*.svg`. */
const USE_CASES_GROUP_VECTOR_SLUGS = [
"worker-coop",
"mutual-aid",
"open-source",
"dao",
] as const;
const USE_CASES_RELATED_SENTINEL_SLUG = "__use-cases-page__";
export async function generateMetadata(): Promise<Metadata> {
const title = messages.metadata.useCases.title;
const description = messages.metadata.useCases.description;
const keywords = messages.metadata.useCases.keywords;
return {
title,
description,
keywords,
openGraph: {
title,
description,
type: "website",
siteName: "CommunityRule",
},
};
}
export default function UseCasesPage() {
const page = messages.pages.useCases;
const tripleColumns = asArray<TripleTextBlockColumn>(page.tripleTextBlock.columns);
const groupItemsRaw = asArray<{ title: string; description: string }>(
page.groups.items,
);
const groupItems: GroupsItem[] = groupItemsRaw.map((item, index) => ({
...item,
icon: (
/* eslint-disable-next-line @next/next/no-img-element -- small vector marks from `public/assets/vector` */
<img
alt=""
aria-hidden
className="block size-9 shrink-0 object-contain"
height={36}
src={getAssetPath(
vectorMarkPath(
USE_CASES_GROUP_VECTOR_SLUGS[index] ?? USE_CASES_GROUP_VECTOR_SLUGS[0],
),
)}
width={36}
/>
),
}));
const askOrganizerData = {
title: page.askOrganizer.title,
subtitle: page.askOrganizer.subtitle,
buttonText: page.askOrganizer.buttonText,
};
const allPosts = getAllBlogPosts();
const relatedPosts = allPosts.slice(0, 8);
const slugOrder = allPosts.map((p) => p.slug);
const tripleStepSteps = asArray<{ title: string; body: string }>(
page.tripleStep.steps,
);
const caseStudyLinks = asArray<{ href: string; ariaLabel: string }>(
page.caseStudyTiles.links,
);
const caseStudySurfaces = ["lavender", "neutral", "rose"] as const;
const caseStudyAlts = [
page.caseStudyTiles.mutualAidColoradoAlt,
page.caseStudyTiles.foodNotBombsAlt,
page.caseStudyTiles.boulderCountyStreetMedicsAlt,
];
return (
<div className="min-h-screen bg-[var(--color-surface-default-primary)]">
<PageHeader
title={page.pageHeader.title}
headingAlign="center"
sectionMinimal
singleLineTitleFromLg
/>
<UseCasesOrgs>
{caseStudySurfaces.map((surface, index) => {
const link = caseStudyLinks[index];
const card = (
<CaseStudy surface={surface} imageAlt={caseStudyAlts[index]} />
);
if (!link?.href) {
return <div key={surface}>{card}</div>;
}
return (
<Link
key={surface}
href={link.href}
aria-label={link.ariaLabel}
className={CASE_STUDY_LINK_CLASS}
>
{card}
</Link>
);
})}
</UseCasesOrgs>
<QuoteBlock
variant="statement"
id="use-cases-statement-quote"
quote={page.quote.paragraph1}
quoteSecondary={page.quote.paragraph2}
/>
<Groups title={page.groups.title} items={groupItems} />
<TripleStep
heading={page.tripleStep.heading}
steps={tripleStepSteps}
ctaText={page.tripleStep.ctaText}
ctaHref={page.tripleStep.ctaHref}
/>
<div className="bg-[var(--color-surface-default-primary)]">
<Suspense
fallback={
<section className="min-h-[400px] py-[var(--spacing-scale-032)]" />
}
>
<MarketingRuleStackSection
translationNamespace="pages.useCases.ruleStack"
twoColumnsFromMd
/>
</Suspense>
</div>
<TripleTextBlock
layoutPreset="useCases"
title={page.tripleTextBlock.title}
ctaText={page.tripleTextBlock.ctaText}
ctaHref={page.tripleTextBlock.ctaHref}
columns={tripleColumns}
/>
<div className="bg-black">
<RelatedArticles
relatedPosts={relatedPosts}
currentPostSlug={USE_CASES_RELATED_SENTINEL_SLUG}
slugOrder={slugOrder}
variant="useCases"
/>
</div>
<section className="bg-[var(--color-surface-default-primary)]">
<AskOrganizer {...askOrganizerData} />
</section>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Figma: use case detail body text (22015:42622 / 22015:42623)
* X Large/Paragraph — Inter 24px / 32px, inverse primary on brand surface.
*/
.use-case-body {
width: 100%;
max-width: 700px;
font-family: var(--font-inter, Inter, sans-serif);
font-size: 18px;
font-weight: 400;
line-height: 130%;
color: var(--color-content-inverse-primary);
word-break: break-word;
}
@media (min-width: 768px) {
.use-case-body {
font-size: 24px;
line-height: 32px;
}
}
.use-case-body p {
margin-block: 0;
}
.use-case-body p + p {
margin-block-start: 1em;
}
@media (min-width: 768px) {
.use-case-body p + p {
margin-block-start: 32px;
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { ReactNode } from "react";
/** Full-viewport case-study surfaces (completed rule demos) — no marketing footer. */
export default function MarketingCaseStudyLayout({
children,
}: {
children: ReactNode;
}) {
return (
<main className="flex h-dvh min-h-0 flex-col overflow-hidden">
{children}
</main>
);
}
@@ -0,0 +1,136 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import CommunityRule from "../../../../../components/type/CommunityRule";
import type { CommunityRuleSection } from "../../../../../components/type/CommunityRule/CommunityRule.types";
import CreateFlowTopNav from "../../../../../components/navigation/CreateFlowTopNav";
import Share from "../../../../../components/modals/Share";
import Alert from "../../../../../components/modals/Alert";
import { CreateFlowHeaderLockup } from "../../../../../(app)/create/components/CreateFlowHeaderLockup";
import {
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "../../../../../(app)/create/components/createFlowLayoutTokens";
import { useCreateFlowMdUp } from "../../../../../(app)/create/hooks/useCreateFlowMdUp";
import { useTranslation } from "../../../../../contexts/MessagesContext";
import type { UseCaseDetailSlug } from "../../../../../../lib/useCaseSyntheticPost";
import type { UseCaseCompletedRuleFixture } from "../../../../../../lib/useCaseCompletedRule";
import {
useUseCaseCompletedRuleActions,
type UseCaseCompletedRuleActionBanner,
} from "./useUseCaseCompletedRuleActions";
export type UseCaseCompletedRuleViewProps = {
slug: UseCaseDetailSlug;
fixture: UseCaseCompletedRuleFixture;
sections: CommunityRuleSection[];
};
/** Figma: Completed CR — use case demos (21995:39476, 21995:40092, 22015:42413). */
export function UseCaseCompletedRuleView({
slug,
fixture,
sections,
}: UseCaseCompletedRuleViewProps) {
const router = useRouter();
const mdUp = useCreateFlowMdUp();
const tTopNav = useTranslation("pages.useCasesCompletedRule.topNav");
const [shareModalOpen, setShareModalOpen] = useState(false);
const [actionBanner, setActionBanner] =
useState<UseCaseCompletedRuleActionBanner | null>(null);
const { copyPageLink, mailtoPageLink, handleDuplicate } =
useUseCaseCompletedRuleActions({
slug,
fixture,
setActionBanner,
});
const pageBg = fixture.pageBackground;
return (
<>
{/*
Mobile: grid scrolls (title sticky at top of scrollport).
Desktop: viewport-tall columns; rule scrolls in the right column only.
*/}
<div
className="flex min-h-0 w-full flex-1 flex-col overflow-hidden md:h-full"
style={{ background: pageBg }}
>
{actionBanner ? (
<div className="pointer-events-none fixed inset-x-0 top-0 z-20 flex justify-center px-5 pt-3">
<div className="pointer-events-auto w-full max-w-[639px]">
<Alert
type="banner"
status={actionBanner.status}
title={actionBanner.title}
description={actionBanner.description}
hasLeadingIcon
hasBodyText={Boolean(actionBanner.description)}
onClose={() => setActionBanner(null)}
className="w-full"
/>
</div>
</div>
) : null}
<Share
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
onCopyLink={() => void copyPageLink()}
onEmailShare={mailtoPageLink}
onSignalShare={() => void copyPageLink()}
onSlackShare={() => void copyPageLink()}
onDiscordShare={() => void copyPageLink()}
/>
<CreateFlowTopNav
hasShare
hasDuplicate
duplicateLabel={tTopNav("duplicate")}
duplicateAriaLabel={tTopNav("duplicateAriaLabel")}
exitLabel={tTopNav("return")}
buttonPalette="inverse"
className="shrink-0 !bg-transparent"
onShare={() => setShareModalOpen(true)}
onDuplicate={() => void handleDuplicate()}
onExit={() => router.push(`/use-cases/${slug}`)}
/>
<div
className={`mx-auto grid w-full min-h-0 flex-1 grid-cols-1 gap-4 px-5 max-md:max-w-[639px] max-md:gap-6 max-md:overflow-y-auto max-md:overscroll-y-contain max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:flex-1 md:grid-cols-2 md:grid-rows-1 md:items-start md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] md:overflow-hidden md:px-12 md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
>
<div
className={`relative z-[1] flex flex-col justify-start max-md:sticky max-md:top-0 max-md:z-10 max-md:shrink-0 max-md:pb-4 md:sticky md:top-0 md:z-[1] md:flex md:h-[calc(100dvh-4rem)] md:max-h-[calc(100dvh-4rem)] md:flex-col md:justify-center md:self-start md:overflow-hidden md:pb-8 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
style={{ background: pageBg }}
>
<CreateFlowHeaderLockup
title={fixture.title}
description={fixture.summary}
justification="left"
palette="inverse"
/>
</div>
<div
className={`scrollbar-hide relative z-0 flex min-h-min flex-col overflow-x-hidden max-md:shrink-0 md:h-[calc(100dvh-4rem)] md:max-h-[calc(100dvh-4rem)] md:min-h-0 md:self-start md:overflow-y-auto ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
<div
className="pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 md:block"
style={{
backgroundImage: `linear-gradient(to bottom, color-mix(in srgb, ${pageBg} 55%, transparent), color-mix(in srgb, ${pageBg} 20%, transparent) 50%, transparent)`,
}}
aria-hidden
/>
<div className="w-full min-w-0 py-0 md:pb-8">
<CommunityRule
sections={sections}
useCardStyle={!mdUp}
cardAccentColor={pageBg}
className={mdUp ? "min-w-0" : "w-full min-w-0 p-4"}
/>
</div>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,122 @@
"use client";
import { useCallback, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useAuthModal } from "../../../../../contexts/AuthModalContext";
import { useTranslation } from "../../../../../contexts/MessagesContext";
import {
duplicateUseCaseTemplate,
fetchAuthSession,
} from "../../../../../../lib/create/api";
import type { UseCaseDetailSlug } from "../../../../../../lib/useCaseSyntheticPost";
import type { UseCaseCompletedRuleFixture } from "../../../../../../lib/useCaseCompletedRule";
export type UseCaseCompletedRuleActionBanner = {
key: string;
status: "positive" | "danger";
title: string;
description?: string;
};
export function useUseCaseCompletedRuleActions({
slug,
fixture,
setActionBanner,
}: {
slug: UseCaseDetailSlug;
fixture: UseCaseCompletedRuleFixture;
setActionBanner: (_: UseCaseCompletedRuleActionBanner | null) => void;
}) {
const router = useRouter();
const pathname = usePathname();
const { openLogin } = useAuthModal();
const t = useTranslation("pages.useCasesCompletedRule.topNav");
const [duplicateBusy, setDuplicateBusy] = useState(false);
const copyPageLink = useCallback(async () => {
if (typeof window === "undefined") return;
try {
await navigator.clipboard.writeText(window.location.href);
setActionBanner({
key: "shareCopied",
status: "positive",
title: t("shareLinkCopiedTitle"),
description: t("shareLinkCopiedDescription"),
});
} catch {
setActionBanner({
key: "shareCopyFailed",
status: "danger",
title: t("shareCopyFailedTitle"),
description: t("shareCopyFailedDescription"),
});
}
}, [setActionBanner, t]);
const mailtoPageLink = useCallback(() => {
if (typeof window === "undefined") return;
const url = window.location.href;
const subject = encodeURIComponent(fixture.title);
const body = encodeURIComponent(`${fixture.summary}\n\n${url}`);
window.location.href = `mailto:?subject=${subject}&body=${body}`;
}, [fixture.summary, fixture.title]);
const handleDuplicate = useCallback(async () => {
if (duplicateBusy) return;
setActionBanner(null);
const { user } = await fetchAuthSession();
if (!user) {
openLogin({
nextPath:
pathname && pathname.length > 0
? pathname
: `/use-cases/${slug}/rule`,
backdropVariant: "blurredYellow",
});
return;
}
setDuplicateBusy(true);
const res = await duplicateUseCaseTemplate(slug);
setDuplicateBusy(false);
if (res.ok === false) {
if (res.status === 401) {
openLogin({
nextPath:
pathname && pathname.length > 0
? pathname
: `/use-cases/${slug}/rule`,
backdropVariant: "blurredYellow",
});
return;
}
setActionBanner({
key: "duplicateFailed",
status: "danger",
title: t("duplicateFailedTitle"),
description:
res.status === 404 ? t("duplicateNotFoundDescription") : res.error,
});
return;
}
router.push("/profile");
}, [
duplicateBusy,
openLogin,
pathname,
router,
setActionBanner,
slug,
t,
]);
return {
copyPageLink,
mailtoPageLink,
handleDuplicate,
};
}
@@ -0,0 +1,66 @@
/**
* Figma: Completed CR — use case community rule demos
* (21995:39476, 21995:40092, 22015:42413)
*/
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import messages from "../../../../../messages/en/index";
import { resolveUseCaseCompletedRule } from "../../../../../lib/useCaseCompletedRule";
import {
USE_CASE_DETAIL_SLUGS,
useCaseContentKeyForSlug,
} from "../../../../../lib/useCaseSyntheticPost";
import { UseCaseCompletedRuleView } from "./_components/UseCaseCompletedRule.view";
type PageProps = {
params: Promise<{ slug: string }>;
};
export function generateStaticParams() {
return USE_CASE_DETAIL_SLUGS.map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const resolved = resolveUseCaseCompletedRule(
slug,
messages.pages.useCasesCompletedRules,
);
if (!resolved) {
return {};
}
const contentKey = useCaseContentKeyForSlug(resolved.slug);
const meta = messages.metadata.useCasesCompletedRule[contentKey];
return {
title: meta.title,
description: meta.description,
keywords: meta.keywords,
openGraph: {
title: meta.title,
description: meta.description,
type: "website",
siteName: "CommunityRule",
},
};
}
export default async function UseCaseCompletedRulePage({ params }: PageProps) {
const { slug } = await params;
const resolved = resolveUseCaseCompletedRule(
slug,
messages.pages.useCasesCompletedRules,
);
if (!resolved) {
notFound();
}
return (
<UseCaseCompletedRuleView
slug={resolved.slug}
fixture={resolved.fixture}
sections={resolved.sections}
/>
);
}
+15 -13
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import type { Prisma } from "@prisma/client";
import { prisma } from "../../../../../lib/server/db";
import {
getSessionPepper,
@@ -19,6 +20,8 @@ import {
import { logRouteError } from "../../../../../lib/server/requestId";
import { apiRoute } from "../../../../../lib/server/apiRoute";
import { safeInternalPath } from "../../../../../lib/safeInternalPath";
import { magicLinkRequestBodySchema } from "../../../../../lib/server/validation/createFlowSchemas";
import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp";
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000;
const EMAIL_MIN_INTERVAL_MS = 60 * 1000;
@@ -32,13 +35,6 @@ function normalizeEmail(raw: unknown): string | null {
return email;
}
function readNextPath(body: unknown): string | null {
if (!body || typeof body !== "object" || !("next" in body)) return null;
const n = (body as { next: unknown }).next;
if (typeof n !== "string") return null;
return safeInternalPath(n);
}
export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { requestId }) => {
if (!isDatabaseConfigured()) {
return dbUnavailable();
@@ -51,15 +47,21 @@ export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { request
return errorJson("invalid_json", "Invalid JSON", 400);
}
const email = normalizeEmail(
body && typeof body === "object" && "email" in body
? (body as { email: unknown }).email
: null,
);
const parsed = magicLinkRequestBodySchema.safeParse(body);
if (!parsed.success) {
return jsonFromZodError(parsed.error);
}
const email = normalizeEmail(parsed.data.email);
if (!email) {
return errorJson("validation_error", "Valid email required", 400);
}
const nextPath = parsed.data.next
? safeInternalPath(parsed.data.next)
: null;
const draftPayload = parsed.data.draft as Prisma.InputJsonValue | undefined;
const ip =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
request.headers.get("x-real-ip") ??
@@ -85,7 +87,6 @@ export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { request
const token = newSessionToken();
const tokenHash = hashSessionToken(token, pepper);
const expiresAt = new Date(Date.now() + MAGIC_LINK_TTL_MS);
const nextPath = readNextPath(body);
await prisma.magicLinkToken.deleteMany({ where: { email } });
await prisma.magicLinkToken.create({
@@ -94,6 +95,7 @@ export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { request
tokenHash,
expiresAt,
nextPath: nextPath ?? undefined,
...(draftPayload !== undefined ? { draftPayload } : {}),
},
});
+14
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import type { Prisma } from "@prisma/client";
import { prisma } from "../../../../../lib/server/db";
import {
getSessionPepper,
@@ -68,6 +69,19 @@ export async function GET(request: NextRequest) {
update: {},
});
if (row.draftPayload != null) {
await prisma.ruleDraft.upsert({
where: { userId: user.id },
create: {
userId: user.id,
payload: row.draftPayload as Prisma.InputJsonValue,
},
update: {
payload: row.draftPayload as Prisma.InputJsonValue,
},
});
}
const { token: sessionToken, expiresAt } = await createSessionForUser(
user.id,
);
@@ -0,0 +1,67 @@
import type { Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import messages from "../../../../../messages/en/index";
import { prisma } from "../../../../../lib/server/db";
import { isDatabaseConfigured } from "../../../../../lib/server/env";
import {
dbUnavailable,
notFound,
unauthorized,
} from "../../../../../lib/server/responses";
import { getSessionUser } from "../../../../../lib/server/session";
import { apiRoute } from "../../../../../lib/server/apiRoute";
import { resolveUseCaseCompletedRule } from "../../../../../lib/useCaseCompletedRule";
import { isUseCaseDetailSlug } from "../../../../../lib/useCaseSyntheticPost";
import { normalizePublishedDocumentForEdit } from "../../../../../lib/create/normalizePublishedDocumentForEdit";
import { useCaseTemplateDuplicateTitle } from "../../../../../lib/useCaseTemplateDuplicate";
type RouteContext = { params: Promise<{ slug: string }> };
export const POST = apiRoute<RouteContext>(
"useCases.bySlug.duplicate",
async (_request, context) => {
if (!isDatabaseConfigured()) {
return dbUnavailable();
}
const user = await getSessionUser();
if (!user) {
return unauthorized();
}
const { slug } = await context.params;
if (!isUseCaseDetailSlug(slug)) {
return notFound();
}
const resolved = resolveUseCaseCompletedRule(
slug,
messages.pages.useCasesCompletedRules,
);
if (!resolved) {
return notFound();
}
const { fixture } = resolved;
const newRule = await prisma.publishedRule.create({
data: {
userId: user.id,
title: useCaseTemplateDuplicateTitle(fixture.title),
summary: fixture.summary,
document: normalizePublishedDocumentForEdit(
fixture.document,
) as Prisma.InputJsonValue,
},
});
return NextResponse.json({
rule: {
id: newRule.id,
title: newRule.title,
summary: newRule.summary,
createdAt: newRule.createdAt,
updatedAt: newRule.updatedAt,
},
});
},
);
@@ -0,0 +1,16 @@
"use client";
import { memo } from "react";
import ShapesView from "./Shapes.view";
import type { ShapesProps } from "./Shapes.types";
/**
* Figma: "Shapes" (22851-36508) — **Card / Stat** decorative shapes (`assets/shapes/stat-shape-*.svg`).
*/
const ShapesContainer = memo<ShapesProps>((props) => {
return <ShapesView {...props} />;
});
ShapesContainer.displayName = "Shapes";
export default ShapesContainer;
@@ -0,0 +1,6 @@
export type StatShapeVariant = "yellow" | "purple" | "green" | "orange";
export interface ShapesProps {
variant?: StatShapeVariant;
className?: string;
}
@@ -0,0 +1,34 @@
"use client";
import { memo } from "react";
import { getAssetPath, statShapeAssetPath } from "../../../../lib/assetUtils";
import type { ShapesProps, StatShapeVariant } from "./Shapes.types";
/** Figma **Card / Stat** color variants → `stat-shape-{1..4}.svg`. */
const SHAPE_INDEX_BY_VARIANT: Record<StatShapeVariant, 1 | 2 | 3 | 4> = {
yellow: 1,
purple: 2,
green: 3,
orange: 4,
};
/**
* Figma: "Shapes" (22851-36508) — decorative stat card art (SVG under `assets/shapes/`).
*/
function ShapesView({ variant = "yellow", className = "" }: ShapesProps) {
const src = getAssetPath(statShapeAssetPath(SHAPE_INDEX_BY_VARIANT[variant]));
return (
/* eslint-disable-next-line @next/next/no-img-element -- dynamic path from getAssetPath */
<img
src={src}
alt=""
aria-hidden
className={`pointer-events-none object-contain ${className}`.trim()}
/>
);
}
ShapesView.displayName = "ShapesView";
export default memo(ShapesView);
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Shapes.container";
export type { ShapesProps, StatShapeVariant } from "./Shapes.types";
+9
View File
@@ -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 (**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,53 @@
"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: Figma-exported SVG composites (305×305 incl. rounded bg). */
const SURFACE_ART: Record<CaseStudyProps["surface"], string> = {
lavender: "/assets/case-study/case-study-mutual-aid.svg",
neutral: "/assets/case-study/case-study-food-not-bombs.svg",
rose: "/assets/case-study/case-study-boulder-county-street-medics.svg",
};
/** 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
className="pointer-events-none size-full select-none object-contain object-center"
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>
@@ -28,6 +28,8 @@ const RuleContainer = memo<RuleProps>(
onDescriptionClick,
descriptionEmptyHint,
descriptionEditAriaLabel,
onTitleClick,
titleEditAriaLabel,
icon,
backgroundColor = "bg-[var(--color-community-teal-100)]",
className = "",
@@ -43,6 +45,8 @@ const RuleContainer = memo<RuleProps>(
bottomStatusLabel,
bottomLinks,
recommended = false,
templateGridFigmaShell = false,
fluidWidth = false,
}) => {
const size = sizeProp ?? "L";
@@ -82,6 +86,8 @@ const RuleContainer = memo<RuleProps>(
onDescriptionClick={onDescriptionClick}
descriptionEmptyHint={descriptionEmptyHint}
descriptionEditAriaLabel={descriptionEditAriaLabel}
onTitleClick={onTitleClick}
titleEditAriaLabel={titleEditAriaLabel}
icon={icon}
backgroundColor={backgroundColor}
className={className}
@@ -98,6 +104,8 @@ const RuleContainer = memo<RuleProps>(
bottomStatusLabel={bottomStatusLabel}
bottomLinks={bottomLinks}
recommended={recommended}
templateGridFigmaShell={templateGridFigmaShell}
fluidWidth={fluidWidth}
/>
);
},
+17
View File
@@ -39,6 +39,13 @@ export interface RuleProps {
descriptionEditAriaLabel?: string;
/** Shown when {@link onDescriptionClick} is set and `description` is empty. */
descriptionEmptyHint?: string;
/**
* When set, the title in the card header is clickable — caller handles modal /
* navigation (e.g. edit published rule).
*/
onTitleClick?: () => void;
/** When {@link onTitleClick} is set, forwarded to the controls `aria-label`. */
titleEditAriaLabel?: string;
icon?: React.ReactNode;
backgroundColor?: string;
className?: string;
@@ -66,6 +73,12 @@ 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;
/** When true, expanded cards fill their container instead of a fixed Figma width. */
fluidWidth?: boolean;
}
export interface RuleViewProps {
@@ -74,6 +87,8 @@ export interface RuleViewProps {
onDescriptionClick?: () => void;
descriptionEmptyHint?: string;
descriptionEditAriaLabel?: string;
onTitleClick?: () => void;
titleEditAriaLabel?: string;
icon?: React.ReactNode;
backgroundColor: string;
className: string;
@@ -90,4 +105,6 @@ export interface RuleViewProps {
bottomStatusLabel?: string;
bottomLinks?: RuleBottomLink[];
recommended?: boolean;
templateGridFigmaShell?: boolean;
fluidWidth?: boolean;
}
+91 -31
View File
@@ -14,6 +14,8 @@ export function RuleView({
onDescriptionClick,
descriptionEmptyHint,
descriptionEditAriaLabel,
onTitleClick,
titleEditAriaLabel,
icon,
backgroundColor,
className,
@@ -30,6 +32,8 @@ export function RuleView({
bottomStatusLabel,
bottomLinks,
recommended = false,
templateGridFigmaShell = false,
fluidWidth = false,
}: RuleViewProps) {
const t = useTranslation("ruleCard");
const ariaLabel = t("ariaLabel")?.replace("{title}", title) || title;
@@ -73,18 +77,27 @@ 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.
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 +106,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 +278,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 +286,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)]
`}
>
@@ -275,11 +309,27 @@ export function RuleView({
{t("recommendedLabel")}
</Tag>
) : null}
<h3
className={`${titleClass} cursor-inherit text-[var(--color-content-invert-primary)] overflow-hidden text-ellipsis w-full`}
>
{title}
</h3>
{onTitleClick ? (
<InlineTextButton
type="button"
underline={false}
data-testid="rule-title-edit"
ariaLabel={titleEditAriaLabel}
className={`${titleClass} w-full min-w-0 cursor-pointer text-left text-[var(--color-content-invert-primary)] hover:!opacity-100 overflow-hidden text-ellipsis`}
onClick={(e) => {
e.stopPropagation();
onTitleClick();
}}
>
{title}
</InlineTextButton>
) : (
<h3
className={`${titleClass} cursor-inherit text-[var(--color-content-invert-primary)] overflow-hidden text-ellipsis w-full`}
>
{title}
</h3>
)}
</div>
</div>
)}
@@ -335,9 +385,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
@@ -410,9 +462,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>
@@ -0,0 +1,15 @@
"use client";
import { memo } from "react";
import StatView from "./Stat.view";
import type { StatProps } from "./Stat.types";
const StatContainer = memo<StatProps>(
({ shapeVariant: shapeVariantProp = "yellow", ...props }) => {
return <StatView {...props} shapeVariant={shapeVariantProp} />;
},
);
StatContainer.displayName = "Stat";
export default StatContainer;
+13
View File
@@ -0,0 +1,13 @@
import type { StatShapeVariant } from "../../asset/Shapes";
export interface StatProps {
value: string;
label: string;
asOf?: string;
shapeVariant?: StatShapeVariant;
className?: string;
}
export interface StatViewProps extends StatProps {
shapeVariant: StatShapeVariant;
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { memo } from "react";
import Shapes from "../../asset/Shapes";
import type { StatViewProps } from "./Stat.types";
/**
* Figma: "Card / Stat" (21598-18215). Full width of grid column at desktop.
*/
function StatView({
value,
label,
asOf,
shapeVariant,
className = "",
}: StatViewProps) {
return (
<article
className={`relative flex h-auto min-h-[182px] w-full flex-col items-start justify-between rounded-[var(--radius-measures-radius-xlarge,20px)] bg-[var(--color-surface-invert-primary,white)] px-[var(--spacing-scale-024)] py-[var(--spacing-scale-032)] sm:h-[170px] sm:min-h-0 sm:p-[var(--spacing-scale-024)] ${className}`.trim()}
>
<div className="relative flex w-full flex-col items-start">
<div className="relative flex items-center">
<Shapes
variant={shapeVariant}
className="absolute -left-[11px] -top-[21px] size-[80px] rotate-[15deg] opacity-90"
/>
<p className="relative font-bricolage-grotesque text-[40px] font-bold leading-[52px] text-[var(--color-content-invert-primary,black)]">
{value}
</p>
</div>
<p className="font-inter text-[14px] font-normal leading-5 text-[var(--color-content-invert-primary,black)]">
{label}
</p>
</div>
{asOf ? (
<p className="w-full font-inter text-[10px] font-normal leading-[14px] text-[var(--color-content-invert-tertiary,#2d2d2d)]">
{asOf}
</p>
) : null}
</article>
);
}
StatView.displayName = "StatView";
export default memo(StatView);
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Stat.container";
export type { StatProps } from "./Stat.types";
@@ -1,38 +1,54 @@
"use client";
/**
* Figma: "Components" / Container (19614-14838, 19003-23432).
* XS thumbnail copy: title 18/22, description 12/16, metadata 10/14.
*/
import { memo } from "react";
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
import {
contentBlogTagPath,
contentCatalogSlugForFallback,
CONTENT_CATALOG_SLUG_ORDER,
} from "../../../../lib/assetUtils";
import ContentContainerView from "./ContentContainer.view";
import type { ContentContainerProps } from "./ContentContainer.types";
const ContentContainerContainer = memo<ContentContainerProps>(
({ post, width = "200px", size: sizeProp = "responsive" }) => {
({
post,
width = "200px",
size: sizeProp = "responsive",
tone: toneProp = "inverse",
leadingImageSrc,
leadingImageAlt,
showLeadingImage: showLeadingImageProp = true,
}) => {
const size = sizeProp;
// Get the corresponding icon based on the same logic as background images
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)]";
const getIconImage = (slug: string): string => {
const icons = [
getAssetPath(ASSETS.ICON_1),
getAssetPath(ASSETS.ICON_2),
getAssetPath(ASSETS.ICON_3),
];
const resolvedSlug =
CONTENT_CATALOG_SLUG_ORDER.indexOf(
slug as (typeof CONTENT_CATALOG_SLUG_ORDER)[number],
) >= 0
? slug
: contentCatalogSlugForFallback(slug);
if (!slug) return icons[0];
// Use the same cycling logic as background images to ensure matching
const slugOrder = [
"building-community-trust",
"operational-security-mutual-aid",
"making-decisions-without-hierarchy",
"resolving-active-conflicts",
];
const index = slugOrder.indexOf(slug);
const finalIndex = index >= 0 ? index % icons.length : 0;
return icons[finalIndex];
return contentBlogTagPath(resolvedSlug);
};
const iconImage = getIconImage(post.slug);
const iconImage = leadingImageSrc ?? getIconImage(post.slug);
const iconAlt =
leadingImageAlt ?? `Icon for ${post.frontmatter.title}`;
// Format date
const formattedDate = new Date(post.frontmatter.date).toLocaleDateString(
"en-US",
{
@@ -41,10 +57,9 @@ const ContentContainerContainer = memo<ContentContainerProps>(
},
);
// Choose styling based on size prop
const containerClasses =
size === "xs"
? "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)]"
? "relative z-20 flex h-full flex-col gap-[var(--measures-spacing-012)]"
: "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)] sm:gap-[var(--measures-spacing-016)] md:gap-[18px] lg:gap-[var(--measures-spacing-024)]";
const contentGapClasses =
@@ -59,30 +74,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-[22px] 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)]";
? `overflow-hidden text-ellipsis whitespace-nowrap 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)]";
? `overflow-hidden text-ellipsis whitespace-nowrap 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,13 +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;
@@ -6,6 +6,8 @@ function ContentContainerView({
width,
size,
iconImage,
iconAlt,
showLeadingImage,
containerClasses,
contentGapClasses,
textGapClasses,
@@ -18,19 +20,20 @@ function ContentContainerView({
return (
<div
className={containerClasses}
style={size === "responsive" ? {} : { width }}
style={size === "responsive" || size === "xs" ? {} : { width }}
>
{/* 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={`Icon for ${post.frontmatter.title}`}
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}>
@@ -42,8 +45,7 @@ function ContentContainerView({
</div>
</div>
{/* Metadata Container - horizontal with 8px gap */}
<div className="flex items-center gap-[var(--measures-spacing-008)]">
<div className="flex min-w-0 items-end gap-[var(--measures-spacing-008)]">
{/* Author Name */}
<span className={authorClasses}>{post.frontmatter.author}</span>
@@ -1,19 +1,33 @@
"use client";
/**
* Figma: "Components" / Thumbnail (19428:22574).
* Vertical 260×390; horizontal 320×225.5; per-article backgrounds in public/content/blog/.
*/
import { memo } from "react";
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
import {
contentBlogHorizontalPath,
contentBlogVerticalPath,
contentCatalogSlugForFallback,
CONTENT_CATALOG_SLUG_ORDER,
} from "../../../../lib/assetUtils";
import ContentThumbnailTemplateView from "./ContentThumbnailTemplate.view";
import type { ContentThumbnailTemplateProps } from "./ContentThumbnailTemplate.types";
const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
({ post, className = "", variant: variantProp = "vertical" }) => {
({
post,
className = "",
variant: variantProp = "vertical",
sizing: sizingProp = "fluid",
}) => {
const variant = variantProp;
const sizing = sizingProp;
// Get article-specific background image from frontmatter
const getBackgroundImage = (
post: ContentThumbnailTemplateProps["post"],
variant: "vertical" | "horizontal",
): string => {
// Check if post has thumbnail images defined in frontmatter
if (post.frontmatter?.thumbnail) {
const imageName =
variant === "vertical"
@@ -21,18 +35,21 @@ const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
: post.frontmatter.thumbnail.horizontal;
if (imageName) {
// Return path to image in public/content/blog directory
return `/content/blog/${imageName}`;
}
}
// Fallback to default images if no thumbnail specified
const fallbackImages: Record<string, string> = {
vertical: getAssetPath(ASSETS.VERTICAL_1),
horizontal: getAssetPath(ASSETS.HORIZONTAL_1),
};
const slug = post.slug;
const resolvedSlug =
CONTENT_CATALOG_SLUG_ORDER.indexOf(
slug as (typeof CONTENT_CATALOG_SLUG_ORDER)[number],
) >= 0
? slug
: contentCatalogSlugForFallback(slug);
return fallbackImages[variant] || fallbackImages.vertical;
return variant === "vertical"
? contentBlogVerticalPath(resolvedSlug)
: contentBlogHorizontalPath(resolvedSlug);
};
const backgroundImage = getBackgroundImage(post, variant);
@@ -42,6 +59,7 @@ const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
post={post}
className={className}
variant={variant}
sizing={sizing}
backgroundImage={backgroundImage}
/>
);
@@ -2,6 +2,8 @@ import type { BlogPost } from "../../../../lib/content";
export type ContentThumbnailTemplateVariantValue = "vertical" | "horizontal";
export type ContentThumbnailTemplateSizingValue = "fluid" | "fixed";
export interface ContentThumbnailTemplateProps {
post: BlogPost;
className?: string;
@@ -9,6 +11,10 @@ export interface ContentThumbnailTemplateProps {
* Content thumbnail variant.
*/
variant?: ContentThumbnailTemplateVariantValue;
/**
* fluid — fill parent (Learn grid). fixed — Figma px dimensions (Related Articles).
*/
sizing?: ContentThumbnailTemplateSizingValue;
slugOrder?: string[];
}
@@ -16,5 +22,6 @@ export interface ContentThumbnailTemplateViewProps {
post: BlogPost;
className: string;
variant: "vertical" | "horizontal";
sizing: ContentThumbnailTemplateSizingValue;
backgroundImage: string;
}
@@ -7,55 +7,95 @@ function ContentThumbnailTemplateView({
post,
className,
variant,
sizing,
backgroundImage,
}: ContentThumbnailTemplateViewProps) {
if (variant === "vertical") {
if (sizing === "fixed") {
return (
<Link
href={`/blog/${post.slug}`}
className={`group block transition-transform duration-200 hover:scale-[1.02] ${className}`}
>
<div className="relative h-[390px] w-[260px] overflow-hidden">
<div className="absolute inset-0 z-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={backgroundImage}
alt=""
className="pointer-events-none size-full object-cover"
/>
</div>
<div className="absolute left-[18px] top-[18px] z-20 w-[200px]">
<ContentContainer post={post} width="200px" size="xs" />
</div>
</div>
</Link>
);
}
return (
<Link
href={`/blog/${post.slug}`}
className={`block transition-transform duration-200 hover:scale-[1.02] ${className}`}
className={`group block w-full transition-transform duration-200 hover:scale-[1.02] ${className}`}
>
<div className="relative w-full aspect-[2/3] overflow-hidden pt-[18px] pl-[18px] pr-[42px] pb-[212px]">
{/* Background SVG - fills container with maintained aspect */}
<div className="relative aspect-[260/390] w-full overflow-hidden">
<div className="absolute inset-0 z-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={backgroundImage}
alt={`Background for ${post.frontmatter.title}`}
className="w-full h-full object-cover"
alt=""
className="pointer-events-none size-full object-cover"
/>
{/* Gradient overlay for better text readability */}
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-black/60 z-10" />
</div>
{/* Content Section - positioned within the padding constraints */}
<ContentContainer post={post} width="200px" size="xs" />
<div className="absolute left-[6.923%] top-[4.615%] z-20 w-[76.923%]">
<ContentContainer post={post} size="xs" />
</div>
</div>
</Link>
);
}
if (sizing === "fixed") {
return (
<Link
href={`/blog/${post.slug}`}
className={`group block transition-transform duration-200 hover:scale-[1.02] ${className}`}
>
<div className="relative h-[225.5px] w-[320px] overflow-hidden">
<div className="absolute inset-0 z-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={backgroundImage}
alt=""
className="pointer-events-none size-full object-cover"
/>
</div>
<div className="absolute left-[14px] top-[13.75px] z-20 w-[230px]">
<ContentContainer post={post} width="230px" size="xs" />
</div>
</div>
</Link>
);
}
// Horizontal variant
return (
<Link
href={`/blog/${post.slug}`}
className={`block transition-transform duration-200 hover:scale-[1.02] ${className}`}
className={`group block w-full transition-transform duration-200 hover:scale-[1.02] ${className}`}
>
<div className="relative min-w-[320px] max-w-[800px] h-[225.5px] overflow-hidden pt-[13.75px] pr-[76px] pb-[73.75px] pl-[14px]">
{/* Background SVG - sized to fit the 320x225.5 container exactly */}
<div className="relative aspect-[320/225.5] w-full overflow-hidden">
<div className="absolute inset-0 z-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={backgroundImage}
alt={`Background for ${post.frontmatter.title}`}
className="w-full h-[225.5px] object-cover"
alt=""
className="pointer-events-none size-full object-cover"
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-transparent to-black/70 z-10" />
</div>
{/* Content - positioned within the padding constraints */}
<ContentContainer post={post} width="230px" size="xs" />
<div className="absolute left-[4.375%] top-[6.099%] z-20 w-[71.875%]">
<ContentContainer post={post} size="xs" />
</div>
</div>
</Link>
);
@@ -0,0 +1,50 @@
"use client";
import { memo, useCallback, useId, useState } from "react";
import AccordionView from "./Accordion.view";
import type { AccordionProps, AccordionSizeValue } from "./Accordion.types";
/**
* Figma: "Layout / Accordion" (21842-2813); Medium 22135-890258; optional `lgSize` / `xlSize` stacking (FAQ **s**→**m** `lg`; **l** `xl`, 22135-890328).
*/
const AccordionContainer = memo<AccordionProps>(
({
title,
subhead,
children,
size: sizeProp = "l",
lgSize,
xlSize,
defaultOpen = false,
className = "",
}) => {
const size: AccordionSizeValue = sizeProp;
const [isOpen, setIsOpen] = useState(defaultOpen);
const panelId = useId();
const buttonId = useId();
const onToggle = useCallback(() => {
setIsOpen((open) => !open);
}, []);
return (
<AccordionView
title={title}
subhead={subhead}
children={children}
size={size}
lgSize={lgSize}
xlSize={xlSize}
isOpen={isOpen}
panelId={panelId}
buttonId={buttonId}
onToggle={onToggle}
className={className}
/>
);
},
);
AccordionContainer.displayName = "Accordion";
export default AccordionContainer;
@@ -0,0 +1,34 @@
import type { ReactNode } from "react";
export type AccordionSizeValue = "s" | "m" | "l";
export interface AccordionProps {
title: string;
subhead?: string;
children?: ReactNode;
size?: AccordionSizeValue;
/**
* From `lg` up, use this sizes header / type / panel styles (e.g. FAQ: `s` + `lgSize="m"`).
*/
lgSize?: AccordionSizeValue;
/**
* From `xl` up, override with this size (e.g. FAQ: `xlSize="l"` at wide desktop — Figma **22135:890328**).
*/
xlSize?: AccordionSizeValue;
defaultOpen?: boolean;
className?: string;
}
export interface AccordionViewProps {
title: string;
subhead?: string;
children?: ReactNode;
size: AccordionSizeValue;
lgSize?: AccordionSizeValue;
xlSize?: AccordionSizeValue;
isOpen: boolean;
panelId: string;
buttonId: string;
onToggle: () => void;
className: string;
}
@@ -0,0 +1,164 @@
"use client";
import { memo } from "react";
import Icon from "../../asset/icon/Icon";
import Divider from "../../utility/Divider";
import type { AccordionSizeValue, AccordionViewProps } from "./Accordion.types";
const SIZE_CLASSES: Record<
AccordionSizeValue,
{ header: string; title: string; subhead: string }
> = {
s: {
header:
"gap-[var(--spacing-scale-016)] px-[var(--spacing-scale-016)] py-[var(--spacing-scale-020)] items-center",
title: "text-[14px] font-medium leading-[18px]",
subhead: "text-[12px] leading-[14px]",
},
/** Figma: Layout / Accordion — Medium (22135-890258; header gap/px/py + Large/Label 18/24). */
m: {
header:
"gap-[var(--spacing-scale-024)] px-[var(--spacing-scale-016)] py-[var(--spacing-scale-024)] items-center",
title: "text-[18px] font-medium leading-6",
subhead: "text-[14px] leading-[18px]",
},
l: {
header:
"gap-[var(--spacing-scale-048)] px-[var(--spacing-scale-016)] py-[var(--spacing-scale-032)] items-center",
/** Figma Large: X Large Label 24 Regular, lh 28 (21842-2869). */
title: "text-[24px] font-normal leading-7",
subhead: "text-[18px] leading-6",
},
};
const PANEL_CLASSES: Record<AccordionSizeValue, string> = {
s: "px-[var(--spacing-scale-016)] pb-[var(--spacing-scale-020)] font-inter text-[14px] font-normal leading-5 text-[var(--color-content-default-secondary,#d2d2d2)]",
m: "px-[var(--spacing-scale-016)] pb-[var(--spacing-scale-024)] font-inter text-[16px] font-normal leading-6 text-[var(--color-content-default-secondary,#d2d2d2)]",
l: "px-[var(--spacing-scale-016)] pb-[var(--spacing-scale-032)] font-inter text-[18px] font-normal leading-[26px] text-[var(--color-content-default-secondary,#d2d2d2)]",
};
function withLgClasses(base: string, lg: string): string {
const prefixed = lg
.trim()
.split(/\s+/)
.filter(Boolean)
.map((c) => `lg:${c}`)
.join(" ");
return `${base} ${prefixed}`.trim();
}
function withXlClasses(base: string, xl: string): string {
const prefixed = xl
.trim()
.split(/\s+/)
.filter(Boolean)
.map((c) => `xl:${c}`)
.join(" ");
return `${base} ${prefixed}`.trim();
}
function resolvedLayoutClasses(
size: AccordionSizeValue,
lgSize: AccordionSizeValue | undefined,
xlSize: AccordionSizeValue | undefined,
): { header: string; title: string; subhead: string; panel: string } {
const sm = SIZE_CLASSES[size];
let header = sm.header;
let title = sm.title;
let subhead = sm.subhead;
let panel = PANEL_CLASSES[size];
if (lgSize && lgSize !== size) {
const lg = SIZE_CLASSES[lgSize];
header = withLgClasses(header, lg.header);
title = withLgClasses(title, lg.title);
subhead = withLgClasses(subhead, lg.subhead);
panel = withLgClasses(panel, PANEL_CLASSES[lgSize]);
}
if (
xlSize === undefined ||
xlSize === size ||
xlSize === lgSize
) {
return { header, title, subhead, panel };
}
const xls = SIZE_CLASSES[xlSize];
header = withXlClasses(header, xls.header);
title = withXlClasses(title, xls.title);
subhead = withXlClasses(subhead, xls.subhead);
panel = withXlClasses(panel, PANEL_CLASSES[xlSize]);
return { header, title, subhead, panel };
}
/**
* Figma: "Layout / Accordion" (21842-2813); Medium 22135-890258; FAQ **s**→**m** `lg`, **l** `xl` (22135-890328) via `lgSize` / `xlSize`.
*/
function AccordionView({
title,
subhead,
children,
size,
lgSize,
xlSize,
isOpen,
panelId,
buttonId,
onToggle,
className,
}: AccordionViewProps) {
const sizeClass = resolvedLayoutClasses(size, lgSize, xlSize);
return (
<div className={`w-full ${className}`.trim()}>
<h3 className="m-0">
<button
id={buttonId}
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={onToggle}
className={`flex w-full ${sizeClass.header} text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-content-default-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#141414]`}
>
<span className="flex min-w-0 flex-1 flex-col gap-[var(--spacing-scale-004)]">
<span
className={`font-inter text-[var(--color-content-default-primary,white)] ${sizeClass.title}`}
>
{title}
</span>
{subhead ? (
<span
className={`font-inter font-medium text-[var(--color-content-default-tertiary,#b4b4b4)] ${sizeClass.subhead}`}
>
{subhead}
</span>
) : null}
</span>
<span
className={`flex size-6 shrink-0 items-center justify-center text-[var(--color-content-default-primary,white)] transition-transform ${isOpen ? "-rotate-90" : "rotate-90"}`}
aria-hidden
>
<Icon name="chevron_right" size={24} />
</span>
</button>
</h3>
{isOpen && children ? (
<div
id={panelId}
role="region"
aria-labelledby={buttonId}
className={sizeClass.panel}
>
{children}
</div>
) : null}
<Divider type="content" orientation="horizontal" />
</div>
);
}
AccordionView.displayName = "AccordionView";
export default memo(AccordionView);
@@ -0,0 +1,2 @@
export { default } from "./Accordion.container";
export type { AccordionProps, AccordionSizeValue } from "./Accordion.types";
@@ -81,6 +81,7 @@ export function AskOrganizerInquiryModalView({
<Create
isOpen={isOpen}
onClose={onClose}
backdropVariant="blurredYellow"
title={t("title")}
description={t("description")}
showBackButton={false}
+13 -2
View File
@@ -9,8 +9,12 @@ import TextInput from "../../controls/TextInput";
import ContentLockup from "../../type/ContentLockup";
import Alert from "../Alert";
import { requestMagicLink } from "../../../../lib/create/api";
import { buildCreateFlowDraftPayload } from "../../../../lib/create/buildCreateFlowDraftPayload";
import { safeInternalPath } from "../../../../lib/safeInternalPath";
import { setTransferPendingFlag } from "../../../(app)/create/utils/anonymousDraftStorage";
import {
readAnonymousCreateFlowState,
setTransferPendingFlag,
} from "../../../(app)/create/utils/anonymousDraftStorage";
/** Mail icon for login modal (inline SVG; same pattern as InfoMessageBox ExclamationIconInline). */
function MailIconInline() {
@@ -91,7 +95,14 @@ export default function LoginForm({
try {
const rawNext = magicLinkNextPath ?? nextParam;
const nextPath = safeInternalPath(rawNext);
const result = await requestMagicLink(trimmed, nextPath);
const shouldAttachDraft =
isSaveProgress || nextPath.includes("syncDraft=1");
const localDraft = readAnonymousCreateFlowState();
const draft =
shouldAttachDraft && Object.keys(localDraft).length > 0
? buildCreateFlowDraftPayload(localDraft)
: undefined;
const result = await requestMagicLink(trimmed, nextPath, draft);
if (result.ok === false) {
if (result.retryAfterMs != null && result.retryAfterMs > 0) {
const seconds = Math.ceil(result.retryAfterMs / 1000);
@@ -2,6 +2,7 @@
import { memo } from "react";
import { usePathname } from "next/navigation";
import { isChromelessNavigationPath } from "../../../lib/navigationChromelessPath";
import TopWithPathname from "./Top/TopWithPathname";
export type ConditionalNavigationClientProps = {
@@ -15,10 +16,8 @@ export type ConditionalNavigationClientProps = {
const ConditionalNavigationClient = memo(
({ initialSignedIn }: ConditionalNavigationClientProps) => {
const pathname = usePathname();
const isCreateFlow = pathname?.startsWith("/create");
const isLogin = pathname === "/login";
if (isCreateFlow || isLogin) {
if (isChromelessNavigationPath(pathname)) {
return null;
}
@@ -16,17 +16,23 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
hasShare = false,
hasExport = false,
hasEdit = false,
hasDuplicate = false,
hasManageStakeholders = false,
saveDraftOnExit = false,
onShare,
onSelectExportFormat,
onEdit,
onDuplicate,
onManageStakeholders,
onExit,
exitLabel,
duplicateLabel,
duplicateAriaLabel,
buttonPalette,
className = "",
}) => {
const router = useRouter();
const t = useTranslation("create.topNav");
const tPopover = useTranslation("modals.popoverExport");
const handleExit = (options?: { saveDraft?: boolean }) => {
@@ -43,19 +49,26 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
hasShare={hasShare}
hasExport={hasExport}
hasEdit={hasEdit}
hasDuplicate={hasDuplicate}
hasManageStakeholders={hasManageStakeholders}
saveDraftOnExit={saveDraftOnExit}
onShare={onShare}
onSelectExportFormat={onSelectExportFormat}
onEdit={onEdit}
onDuplicate={onDuplicate}
onManageStakeholders={onManageStakeholders}
onExit={handleExit}
exitLabel={exitLabel}
duplicateLabel={duplicateLabel}
duplicateAriaLabel={duplicateAriaLabel}
buttonPalette={buttonPalette}
className={className}
exportPopoverMenuAriaLabel={tPopover("menuAriaLabel")}
exportPopoverPdfLabel={tPopover("downloadPdf")}
exportPopoverCsvLabel={tPopover("downloadCsv")}
exportPopoverMarkdownLabel={tPopover("downloadMarkdown")}
moreOptionsAriaLabel={t("moreOptionsAriaLabel")}
actionsMenuAriaLabel={t("actionsMenuAriaLabel")}
/>
);
},
@@ -21,6 +21,11 @@ export interface CreateFlowTopNavProps {
* @default false
*/
hasEdit?: boolean;
/**
* Whether to show Duplicate instead of Edit (marketing completed demos).
* @default false
*/
hasDuplicate?: boolean;
/**
* Whether to show **Manage Stakeholders** (published-rule invite management).
* Used on `/create/edit-rule` only.
@@ -45,6 +50,17 @@ export interface CreateFlowTopNavProps {
* Callback when Edit button is clicked
*/
onEdit?: () => void;
/**
* Callback when Duplicate button is clicked
*/
onDuplicate?: () => void;
/**
* Override exit button label (e.g. "Return" on marketing demos).
*/
exitLabel?: string;
/** Label for Duplicate when {@link hasDuplicate} is true. */
duplicateLabel?: string;
duplicateAriaLabel?: string;
/**
* Callback when Manage Stakeholders is clicked
*/
@@ -71,4 +87,6 @@ export type CreateFlowTopNavViewProps = CreateFlowTopNavProps & {
exportPopoverPdfLabel: string;
exportPopoverCsvLabel: string;
exportPopoverMarkdownLabel: string;
moreOptionsAriaLabel: string;
actionsMenuAriaLabel: string;
};
@@ -1,39 +1,178 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { IconName } from "../../asset/icon";
import Logo from "../../asset/Logo";
import Button from "../../buttons/Button";
import ListItem from "../../layout/ListItem";
import Popover from "../../modals/Popover";
import { useCreateFlowSm2Up } from "../../../(app)/create/hooks/useCreateFlowSm2Up";
import { useTranslation } from "../../../contexts/MessagesContext";
import type { CreateFlowTopNavViewProps } from "./CreateFlowTopNav.types";
const outlineButtonClass =
"md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]";
const exitButtonFigmaClass =
"!rounded-[var(--radius-measures-radius-full,9999px)] !border-[1.25px] !px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!text-[12px] md:!leading-[14px]";
type ActionMenuItem = {
id: string;
label: string;
leadingIcon: IconName;
onClick: () => void;
};
function KebabIcon({ className = "" }: { className?: string }) {
return (
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={`shrink-0 md:h-[14px] md:w-[14px] ${className}`}
aria-hidden="true"
>
<circle cx="4" cy="8" r="1.5" fill="currentColor" />
<circle cx="8" cy="8" r="1.5" fill="currentColor" />
<circle cx="12" cy="8" r="1.5" fill="currentColor" />
</svg>
);
}
export function CreateFlowTopNavView({
hasShare = false,
hasExport = false,
hasEdit = false,
hasDuplicate = false,
hasManageStakeholders = false,
saveDraftOnExit = false,
onShare,
onSelectExportFormat,
onEdit,
onDuplicate,
onManageStakeholders,
onExit,
exitLabel,
duplicateLabel,
duplicateAriaLabel,
buttonPalette = "default",
className = "",
exportPopoverMenuAriaLabel,
exportPopoverPdfLabel,
exportPopoverCsvLabel,
exportPopoverMarkdownLabel,
moreOptionsAriaLabel,
actionsMenuAriaLabel,
}: CreateFlowTopNavViewProps) {
const t = useTranslation("create.topNav");
const exitButtonText = saveDraftOnExit ? t("saveAndExit") : t("exit");
const sm2Up = useCreateFlowSm2Up();
const exitButtonText =
exitLabel ?? (saveDraftOnExit ? t("saveAndExit") : t("exit"));
const [exportMenuOpen, setExportMenuOpen] = useState(false);
const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
const exportWrapRef = useRef<HTMLDivElement>(null);
const actionsWrapRef = useRef<HTMLDivElement>(null);
const exportMenuId = useId();
const actionsMenuId = useId();
const hasSecondaryActions =
hasShare ||
hasExport ||
hasEdit ||
hasDuplicate ||
hasManageStakeholders;
const useKebabMenu = hasSecondaryActions && !sm2Up;
const actionMenuItems = useMemo((): ActionMenuItem[] => {
const items: ActionMenuItem[] = [];
if (hasShare && onShare) {
items.push({
id: "share",
label: t("share"),
leadingIcon: "mail",
onClick: onShare,
});
}
if (hasExport && onSelectExportFormat) {
items.push(
{
id: "export-pdf",
label: exportPopoverPdfLabel,
leadingIcon: "picture_as_pdf",
onClick: () => onSelectExportFormat("pdf"),
},
{
id: "export-csv",
label: exportPopoverCsvLabel,
leadingIcon: "csv",
onClick: () => onSelectExportFormat("csv"),
},
{
id: "export-markdown",
label: exportPopoverMarkdownLabel,
leadingIcon: "markdown_copy",
onClick: () => onSelectExportFormat("markdown"),
},
);
}
if (hasDuplicate && onDuplicate) {
items.push({
id: "duplicate",
label: duplicateLabel ?? t("edit"),
leadingIcon: "content_copy",
onClick: onDuplicate,
});
} else if (hasEdit && onEdit) {
items.push({
id: "edit",
label: t("edit"),
leadingIcon: "edit",
onClick: onEdit,
});
}
if (hasManageStakeholders && onManageStakeholders) {
items.push({
id: "manage-stakeholders",
label: t("manageStakeholders"),
leadingIcon: "tags",
onClick: onManageStakeholders,
});
}
items.push({
id: "exit",
label: exitButtonText,
leadingIcon: "log_out",
onClick: () => void onExit?.({ saveDraft: saveDraftOnExit }),
});
return items;
}, [
duplicateLabel,
exitButtonText,
exportPopoverCsvLabel,
exportPopoverMarkdownLabel,
exportPopoverPdfLabel,
hasDuplicate,
hasEdit,
hasExport,
hasManageStakeholders,
hasShare,
onDuplicate,
onEdit,
onExit,
onManageStakeholders,
onSelectExportFormat,
onShare,
saveDraftOnExit,
t,
]);
useEffect(() => {
if (!exportMenuOpen) return;
@@ -49,6 +188,20 @@ export function CreateFlowTopNavView({
return () => document.removeEventListener("mousedown", onDoc);
}, [exportMenuOpen]);
useEffect(() => {
if (!actionsMenuOpen) return;
const onDoc = (e: MouseEvent) => {
if (
actionsWrapRef.current &&
!actionsWrapRef.current.contains(e.target as Node)
) {
setActionsMenuOpen(false);
}
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [actionsMenuOpen]);
useEffect(() => {
if (!exportMenuOpen) return;
const onKey = (e: KeyboardEvent) => {
@@ -58,6 +211,155 @@ export function CreateFlowTopNavView({
return () => window.removeEventListener("keydown", onKey);
}, [exportMenuOpen]);
useEffect(() => {
if (!actionsMenuOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setActionsMenuOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [actionsMenuOpen]);
const inlineActions = (
<>
{hasShare && (
<Button
buttonType="outline"
palette={buttonPalette}
size="xsmall"
onClick={onShare}
ariaLabel={t("shareAriaLabel")}
className={outlineButtonClass}
>
{t("share")}
</Button>
)}
{hasExport && onSelectExportFormat ? (
<div className="relative" ref={exportWrapRef}>
<Button
buttonType="outline"
palette={buttonPalette}
size="xsmall"
type="button"
ariaLabel={t("exportAriaLabel")}
aria-haspopup="menu"
aria-expanded={exportMenuOpen}
aria-controls={exportMenuId}
onClick={() => setExportMenuOpen((o) => !o)}
className={`justify-center gap-[var(--spacing-scale-002,2px)] !pl-[var(--spacing-scale-012,12px)] !pr-[var(--spacing-scale-006,6px)] md:!pr-[var(--spacing-scale-006,6px)] ${outlineButtonClass}`}
>
<span>{t("export")}</span>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0 md:h-[14px] md:w-[14px]"
aria-hidden="true"
>
<path d="M19 9l-7 7-7-7" />
</svg>
</Button>
{exportMenuOpen ? (
<div className="absolute right-0 top-[calc(100%+var(--spacing-measures-spacing-200,8px))] z-[300]">
<Popover
id={exportMenuId}
menuAriaLabel={exportPopoverMenuAriaLabel}
>
<ListItem
showDivider
leadingIcon="picture_as_pdf"
label={exportPopoverPdfLabel}
onClick={() => {
onSelectExportFormat("pdf");
setExportMenuOpen(false);
}}
/>
<ListItem
showDivider
leadingIcon="csv"
label={exportPopoverCsvLabel}
onClick={() => {
onSelectExportFormat("csv");
setExportMenuOpen(false);
}}
/>
<ListItem
showDivider={false}
leadingIcon="markdown_copy"
label={exportPopoverMarkdownLabel}
onClick={() => {
onSelectExportFormat("markdown");
setExportMenuOpen(false);
}}
/>
</Popover>
</div>
) : null}
</div>
) : null}
{hasDuplicate && (
<Button
buttonType="outline"
palette={buttonPalette}
size="xsmall"
onClick={onDuplicate}
ariaLabel={
duplicateAriaLabel ?? duplicateLabel ?? t("editAriaLabel")
}
className={outlineButtonClass}
>
{duplicateLabel ?? t("edit")}
</Button>
)}
{hasEdit && !hasDuplicate && (
<Button
buttonType="outline"
palette={buttonPalette}
size="xsmall"
onClick={onEdit}
ariaLabel={t("editAriaLabel")}
className={outlineButtonClass}
>
{t("edit")}
</Button>
)}
{hasManageStakeholders && onManageStakeholders ? (
<Button
buttonType="outline"
palette={buttonPalette}
size="xsmall"
type="button"
onClick={onManageStakeholders}
ariaLabel={t("manageStakeholdersAriaLabel")}
className={outlineButtonClass}
>
{t("manageStakeholders")}
</Button>
) : null}
<Button
buttonType="outline"
palette={buttonPalette}
size="xsmall"
type="button"
onClick={() => void onExit?.({ saveDraft: saveDraftOnExit })}
ariaLabel={exitButtonText}
className={`shrink-0 ${exitButtonFigmaClass} !text-[10px] !leading-[12px] !py-[6px] md:!py-[8px]`}
>
{exitButtonText}
</Button>
</>
);
return (
<header
className={`bg-black w-full ${className}`}
@@ -72,126 +374,56 @@ export function CreateFlowTopNavView({
<Logo size="createFlow" wordmark palette={buttonPalette} />
<div className="flex flex-wrap items-center justify-end gap-[var(--spacing-scale-012,12px)]">
{hasShare && (
<Button
buttonType="outline"
palette={buttonPalette}
size="xsmall"
onClick={onShare}
ariaLabel={t("shareAriaLabel")}
className="md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"
>
{t("share")}
</Button>
)}
{hasExport && onSelectExportFormat ? (
<div className="relative" ref={exportWrapRef}>
{useKebabMenu ? (
<div className="relative" ref={actionsWrapRef}>
<Button
buttonType="outline"
palette={buttonPalette}
size="xsmall"
type="button"
ariaLabel={t("exportAriaLabel")}
ariaLabel={moreOptionsAriaLabel}
aria-haspopup="menu"
aria-expanded={exportMenuOpen}
aria-controls={exportMenuId}
onClick={() => setExportMenuOpen((o) => !o)}
className="justify-center gap-[var(--spacing-scale-002,2px)] !pl-[var(--spacing-scale-012,12px)] !pr-[var(--spacing-scale-006,6px)] md:!pr-[var(--spacing-scale-006,6px)] !text-[10px] md:!text-[12px] !leading-[12px] md:!leading-[14px] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"
aria-expanded={actionsMenuOpen}
aria-controls={actionsMenuId}
onClick={() => setActionsMenuOpen((open) => !open)}
className={`!px-[var(--spacing-scale-010,10px)] ${outlineButtonClass}`}
>
<span>{t("export")}</span>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0 md:w-[14px] md:h-[14px]"
aria-hidden="true"
>
<path d="M19 9l-7 7-7-7" />
</svg>
<KebabIcon />
</Button>
{exportMenuOpen ? (
{actionsMenuOpen ? (
<div className="absolute right-0 top-[calc(100%+var(--spacing-measures-spacing-200,8px))] z-[300]">
<Popover
id={exportMenuId}
menuAriaLabel={exportPopoverMenuAriaLabel}
>
<ListItem
showDivider
leadingIcon="picture_as_pdf"
label={exportPopoverPdfLabel}
onClick={() => {
onSelectExportFormat("pdf");
setExportMenuOpen(false);
}}
/>
<ListItem
showDivider
leadingIcon="csv"
label={exportPopoverCsvLabel}
onClick={() => {
onSelectExportFormat("csv");
setExportMenuOpen(false);
}}
/>
<ListItem
showDivider={false}
leadingIcon="markdown_copy"
label={exportPopoverMarkdownLabel}
onClick={() => {
onSelectExportFormat("markdown");
setExportMenuOpen(false);
}}
/>
<Popover id={actionsMenuId} menuAriaLabel={actionsMenuAriaLabel}>
{actionMenuItems.map((item, index) => (
<ListItem
key={item.id}
showDivider={index < actionMenuItems.length - 1}
leadingIcon={item.leadingIcon}
label={item.label}
onClick={() => {
item.onClick();
setActionsMenuOpen(false);
}}
/>
))}
</Popover>
</div>
) : null}
</div>
) : null}
{hasEdit && (
<Button
buttonType="outline"
palette={buttonPalette}
size="xsmall"
onClick={onEdit}
ariaLabel={t("editAriaLabel")}
className="md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"
>
{t("edit")}
</Button>
)}
{hasManageStakeholders && onManageStakeholders ? (
) : hasSecondaryActions ? (
inlineActions
) : (
<Button
buttonType="outline"
palette={buttonPalette}
size="xsmall"
type="button"
onClick={onManageStakeholders}
ariaLabel={t("manageStakeholdersAriaLabel")}
className="md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"
onClick={() => void onExit?.({ saveDraft: saveDraftOnExit })}
ariaLabel={exitButtonText}
className={`shrink-0 ${exitButtonFigmaClass} !text-[10px] !leading-[12px] !py-[6px] md:!py-[8px]`}
>
{t("manageStakeholders")}
{exitButtonText}
</Button>
) : null}
<Button
buttonType="outline"
palette={buttonPalette}
size="xsmall"
type="button"
onClick={() => void onExit?.({ saveDraft: saveDraftOnExit })}
ariaLabel={exitButtonText}
className={`md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !py-[6px] md:!py-[8px] shrink-0 ${exitButtonFigmaClass}`}
>
{exitButtonText}
</Button>
)}
</div>
</nav>
</header>
+2 -2
View File
@@ -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")}
@@ -149,7 +149,7 @@ const Footer = memo(() => {
{t("navigation.learn")}
</Link>
<Link
href="#"
href="/about"
className={`w-full text-left ${primaryLinkClass} md:w-auto md:text-right`}
>
{t("navigation.about")}
@@ -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,9 +72,9 @@ 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: "#", text: t("navigation.about") },
{ href: "/about", text: t("navigation.about") },
];
const renderNavigationItems = (size: NavSize) => {
@@ -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");
+1 -5
View File
@@ -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";
@@ -0,0 +1,32 @@
"use client";
import { memo, useId } from "react";
import FaqAccordionView from "./Accordion.view";
import type { FaqAccordionProps, FaqAccordionViewProps } from "./Accordion.types";
import type { AccordionSizeValue } from "../../layout/Accordion";
/**
* Figma: "Sections / Accordion" (22130-889248). Rows: **s** / **m** at `lg` (22135-890258); **Large** (`l`) at `xl` (22135:890328).
*/
const FaqAccordionContainer = memo<FaqAccordionProps>(
({ size: sizeProp = "s", lgSize: lgSizeProp = "m", xlSize: xlSizeProp = "l", ...props }) => {
const headingId = useId();
const size: AccordionSizeValue = sizeProp;
const lgSize: AccordionSizeValue = lgSizeProp;
const xlSize: AccordionSizeValue = xlSizeProp;
const viewProps: FaqAccordionViewProps = {
...props,
size,
lgSize,
xlSize,
headingId,
};
return <FaqAccordionView {...viewProps} />;
},
);
FaqAccordionContainer.displayName = "FaqAccordion";
export default FaqAccordionContainer;
@@ -0,0 +1,25 @@
import type { AccordionSizeValue } from "../../layout/Accordion";
export interface FaqAccordionItem {
title: string;
answer: string;
subhead?: string;
}
export interface FaqAccordionProps {
title: string;
items: FaqAccordionItem[];
size?: AccordionSizeValue;
/** Layout accordion size from `lg` (default **m**, Figma 22135-890258). */
lgSize?: AccordionSizeValue;
/** Layout accordion size from `xl` (default **l**, Figma 22135:890328 Large). */
xlSize?: AccordionSizeValue;
className?: string;
}
export interface FaqAccordionViewProps extends FaqAccordionProps {
headingId: string;
size: AccordionSizeValue;
lgSize: AccordionSizeValue;
xlSize: AccordionSizeValue;
}
@@ -0,0 +1,53 @@
"use client";
import { memo } from "react";
import LayoutAccordion from "../../layout/Accordion";
import type { FaqAccordionViewProps } from "./Accordion.types";
/**
* Figma: "Sections / Accordion" (22130-889248; mobile FAQ 22132-889380). **xl** rows **Large** via `xlSize` (22135:890328).
* Section title: Large Heading (32px, lh 40) below `lg`; X Large Heading (36px, lh 44) at `lg`; XX Large Heading (40px, lh 52) at `xl` (Figma desktop frame 22135:890398).
*/
function FaqAccordionView({
title,
items,
size,
lgSize,
xlSize,
headingId,
className = "",
}: FaqAccordionViewProps) {
return (
<section
aria-labelledby={headingId}
className={`bg-[#141414] px-[var(--spacing-scale-004)] py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-160)] md:py-[var(--spacing-scale-096)] ${className}`.trim()}
>
<div className="mx-auto flex w-full max-w-[1440px] flex-col items-center gap-[var(--spacing-scale-096)] md:gap-[var(--spacing-scale-040)]">
<h2
id={headingId}
className="w-full px-[var(--spacing-scale-016)] text-center font-bricolage-grotesque text-[length:var(--text-large-heading)] font-bold leading-[length:var(--text-large-heading--line-height)] text-[var(--color-content-default-brand-primary,#fefcc9)] md:px-0 lg:text-[length:var(--text-x-large-heading)] lg:leading-[length:var(--text-x-large-heading--line-height)] xl:text-[length:var(--text-xx-large-heading)] xl:leading-[length:var(--text-xx-large-heading--line-height)] xl:tracking-[var(--text-xx-large-heading--letter-spacing)]"
>
{title}
</h2>
<div className="w-full md:px-0">
{items.map((item, index) => (
<LayoutAccordion
key={`${item.title}-${index}`}
title={item.title}
subhead={item.subhead}
size={size}
lgSize={lgSize}
xlSize={xlSize}
>
{item.answer}
</LayoutAccordion>
))}
</div>
</div>
</section>
);
}
FaqAccordionView.displayName = "FaqAccordionView";
export default memo(FaqAccordionView);
@@ -0,0 +1,2 @@
export { default } from "./Accordion.container";
export type { FaqAccordionProps, FaqAccordionItem } from "./Accordion.types";
@@ -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,8 +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** — baseline default [17487:12288](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=17487-12288&m=dev), inverse [19189:8140](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=19189-8140&m=dev); md+ [16306:14995](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=16306-14995&m=dev). Use-case detail instance: [22015:42624](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22015-42624&m=dev). */
const AskOrganizerContainer = memo<AskOrganizerProps>(
({
title,
@@ -56,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-032)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-096)] md:px-[var(--spacing-scale-064)]";
: resolvedVariant === "use-case-detail" || resolvedVariant === "inverse"
? "w-full py-[var(--spacing-scale-032)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-096)] md:px-[var(--spacing-scale-064)]"
: "py-[var(--spacing-scale-040)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-096)] 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,26 +35,31 @@ function AskOrganizerView({
aria-labelledby={labelledBy}
aria-label={labelledBy ? undefined : ariaLabel}
tabIndex={-1}
data-figma-node={isUseCaseDetail ? "22015-42624" : "18116-15960"}
>
<div className={`flex flex-col ${contentGap}`}>
<div
className={`mx-auto flex w-full min-w-0 max-w-[1280px] flex-col md:min-w-[358px] ${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}
/>
{/* Button */}
<div className={buttonContainerClass}>
<div
className={`${buttonContainerClass} flex-wrap gap-y-[var(--spacing-scale-016)]`}
>
<Button
{...(buttonHref ? { href: buttonHref } : {})}
size="large"
size="small"
buttonType="filled"
palette={variant === "inverse" ? "inverse" : "default"}
className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]"
palette={buttonPalette}
className="!px-[var(--spacing-scale-010)] md:!px-[var(--spacing-scale-016)] md:!py-[var(--spacing-scale-012)] md:!text-[16px] md:!leading-[20px]"
onClick={onContactClick}
ariaLabel={ariaLabel}
data-testid="ask-organizer-cta"
@@ -0,0 +1,18 @@
"use client";
import { memo, useId } from "react";
import BookView from "./Book.view";
import type { BookProps } from "./Book.types";
/**
* Figma: "Sections / Book" frame **22135:889706** (see Book.view.tsx).
*/
const BookContainer = memo<BookProps>((props) => {
const headingId = useId();
return <BookView {...props} headingId={headingId} />;
});
BookContainer.displayName = "Book";
export default BookContainer;
@@ -0,0 +1,13 @@
export interface BookProps {
title: string;
description: string;
buttonText: string;
buttonHref?: string;
imageSrc?: string;
imageAlt?: string;
className?: string;
}
export interface BookViewProps extends BookProps {
headingId: string;
}
@@ -0,0 +1,65 @@
"use client";
import { memo } from "react";
import { ASSETS, getAssetPath } from "../../../../lib/assetUtils";
import Button from "../../buttons/Button";
import ContentLockup from "../../type/ContentLockup";
import type { BookViewProps } from "./Book.types";
/**
* Figma: "Sections / Book" outer **22135:889706** (1440+: **Content Card Horizontal** 22135:890130): card `max-width` **1280px**, inner padding **scale/048**, gutter **scale/032** (`Content Lockup`: Small/Display 32 lh 1.1 Medium; body X Large / Paragraph **24 lh 32**). Section inset lg **scale/160** / **064** unchanged.
*/
function BookView({
title,
description,
buttonText,
buttonHref,
imageSrc,
imageAlt,
headingId,
className = "",
}: BookViewProps) {
const coverSrc = imageSrc ?? getAssetPath(ASSETS.COMMUNITYRULES_COVER);
return (
<section
aria-labelledby={headingId}
className={`px-[var(--spacing-scale-008)] py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-064)] lg:px-[var(--spacing-scale-160)] lg:py-[var(--spacing-scale-064)] ${className}`.trim()}
>
<div className="mx-auto flex w-full max-w-[1440px] flex-col items-center">
<div className="flex w-full flex-col items-center gap-[var(--spacing-scale-032)] rounded-[var(--radius-measures-radius-xlarge,20px)] bg-[#171717] p-[var(--spacing-scale-048)] shadow-[0_0_48px_rgba(0,0,0,0.1)] md:flex-row md:items-center lg:gap-[var(--spacing-scale-040)] lg:p-[var(--spacing-scale-064)] xl:mx-auto xl:max-w-[1280px] xl:gap-[var(--spacing-scale-032)] xl:p-[var(--spacing-scale-048)]">
<div className="relative aspect-[375/580] w-full shrink-0 overflow-hidden rounded-[4px] shadow-[0_0_24px_rgba(0,0,0,0.25)] md:aspect-auto md:h-[495px] md:w-[320px]">
{/* eslint-disable-next-line @next/next/no-img-element -- marketing cover art */}
<img
src={coverSrc}
alt={imageAlt ?? ""}
className="size-full object-cover"
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-[var(--spacing-scale-016)] lg:gap-[var(--spacing-scale-024)] xl:gap-[var(--spacing-scale-020)]">
<ContentLockup
variant="book"
alignment="left"
titleId={headingId}
title={title}
description={description}
/>
<Button
buttonType="filled"
palette="default"
size="small"
href={buttonHref}
className="self-start"
>
{buttonText}
</Button>
</div>
</div>
</div>
</section>
);
}
BookView.displayName = "BookView";
export default memo(BookView);
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Book.container";
export type { BookProps } from "./Book.types";
@@ -10,7 +10,7 @@ import type { CardStepsProps } from "./CardSteps.types";
* Composes **`cards/Step`** (Figma Card / Step), not **`progress/Stepper`**.
*/
const CardStepsContainer = memo<CardStepsProps>(
({ title, subtitle, steps, headingDesktopLines }) => {
({ title, subtitle, steps, headingDesktopLines, seeHowItWorksHref }) => {
const schemaData = useSchemaData({
type: "HowTo",
name: title,
@@ -29,6 +29,7 @@ const CardStepsContainer = memo<CardStepsProps>(
subtitle={subtitle}
steps={steps}
headingDesktopLines={headingDesktopLines}
seeHowItWorksHref={seeHowItWorksHref}
schemaJson={schemaJson}
/>
);
@@ -11,6 +11,8 @@ export interface CardStepsProps {
steps: CardStepsItem[];
/** Large-screen heading split: line 13 (e.g. How / CommunityRule / helps). */
headingDesktopLines?: readonly [string, string, string];
/** When set, the section CTA renders as a link. */
seeHowItWorksHref?: string;
}
export interface CardStepsViewProps extends CardStepsProps {
@@ -11,6 +11,7 @@ function CardStepsView({
subtitle,
steps,
headingDesktopLines,
seeHowItWorksHref,
schemaJson,
}: CardStepsViewProps) {
const t = useTranslation();
@@ -47,7 +48,12 @@ function CardStepsView({
</div>
<div className="text-center">
<Button buttonType="outline" palette="default" size="large">
<Button
buttonType="outline"
palette="default"
size="large"
href={seeHowItWorksHref}
>
{t("cardSteps.buttons.seeHowItWorks")}
</Button>
</div>
-84
View File
@@ -1,84 +0,0 @@
"use client";
import { memo } from "react";
import { getAssetPath } from "../../../lib/assetUtils";
import ContentContainer from "../content/ContentContainer";
import type { BlogPost } from "../../../lib/content";
interface ContentBannerProps {
post: BlogPost;
}
const ContentBanner = memo<ContentBannerProps>(({ post }) => {
// Get article-specific horizontal thumbnail (small) and banner (md+)
const getBackgroundImage = (post: BlogPost): string => {
if (post.frontmatter?.thumbnail?.horizontal) {
return `/content/blog/${post.frontmatter.thumbnail.horizontal}`;
}
// Fallback to default image
return getAssetPath("assets/Content_Banner.svg");
};
const getBannerImageMd = (post: BlogPost): string => {
// Use banner.horizontal when provided; fallback to horizontal thumbnail
if (post.frontmatter?.banner?.horizontal) {
return `/content/blog/${post.frontmatter.banner.horizontal}`;
}
// Fallback to horizontal thumbnail, then default banner
if (post.frontmatter?.thumbnail?.horizontal) {
return `/content/blog/${post.frontmatter.thumbnail.horizontal}`;
}
return getAssetPath("assets/Content_Banner_2.svg");
};
const backgroundImage = getBackgroundImage(post);
const bannerImageMd = getBannerImageMd(post);
return (
<div className="pt-[var(--measures-spacing-016)] md:pt-[var(--measures-spacing-008)] lg:pt-[50px] xl:pt-[112px] h-[275px] sm:h-[326px] md:h-[224px] lg:h-[358.4px] xl:h-[504px] relative w-full sm:overflow-hidden">
{/* Background SVG - Default to sm breakpoint */}
<div
className="absolute inset-0 w-full h-full bg-cover bg-no-repeat aspect-[320/225.5]"
style={{
backgroundImage: `url(${backgroundImage})`,
backgroundPosition: "center bottom",
}}
/>
{/* Background SVG - md breakpoint and above (article banner image) */}
<div
className="absolute inset-0 w-full h-full bg-cover bg-no-repeat aspect-[640/224] md:block hidden"
style={{
backgroundImage: `url(${bannerImageMd})`,
backgroundPosition: "center bottom",
}}
/>
{/* Content Container */}
<div
className="
relative z-10 h-full
flex flex-col
pl-[var(--measures-spacing-016)] md:pl-[var(--measures-spacing-024)] lg:pl-[var(--measures-spacing-064)]
pr-[96px] md:pr-[350px]
/* default: normal flow, top-aligned */
justify-start
/* only at md: take out of flow and center vertically */
md:absolute md:inset-x-0 md:top-1/2 md:-translate-y-1/2 md:w-full md:h-auto
/* after md (lg+): snap back to normal flow/top align */
lg:static lg:translate-y-0 lg:top-auto lg:h-full lg:justify-start
"
>
{/* ContentContainer with post data */}
<ContentContainer post={post} size="responsive" />
</div>
</div>
);
});
ContentBanner.displayName = "ContentBanner";
export default ContentBanner;
@@ -0,0 +1,80 @@
"use client";
import { memo } from "react";
import {
getAssetPath,
contentBlogHorizontalPath,
contentBlogSectionPath,
CONTENT_CATALOG_SLUG_ORDER,
} from "../../../../lib/assetUtils";
import type { BlogPost } from "../../../../lib/content";
import ContentBannerView from "./ContentBanner.view";
import type { ContentBannerProps } from "./ContentBanner.types";
/** Figma: Content page Template (19003:23305) — article ContentBanner per breakpoint. */
const ContentBannerContainer = memo<ContentBannerProps>(
({
post,
variant: variantProp = "article",
leadingImageSrc,
leadingImageAlt,
rulePreview,
contentTone,
}) => {
const variant = variantProp;
const resolveHorizontalImage = (blogPost: BlogPost): string => {
if (blogPost.frontmatter?.thumbnail?.horizontal) {
return `/content/blog/${blogPost.frontmatter.thumbnail.horizontal}`;
}
if (
CONTENT_CATALOG_SLUG_ORDER.includes(
blogPost.slug as (typeof CONTENT_CATALOG_SLUG_ORDER)[number],
)
) {
return contentBlogHorizontalPath(blogPost.slug);
}
return getAssetPath("assets/Content_Banner.svg");
};
const resolveSectionImage = (blogPost: BlogPost): string => {
if (blogPost.frontmatter?.banner?.horizontal) {
return `/content/blog/${blogPost.frontmatter.banner.horizontal}`;
}
if (
CONTENT_CATALOG_SLUG_ORDER.includes(
blogPost.slug as (typeof CONTENT_CATALOG_SLUG_ORDER)[number],
)
) {
return contentBlogSectionPath(blogPost.slug);
}
return resolveHorizontalImage(blogPost);
};
const backgroundImageHorizontal =
variant === "article" ? resolveHorizontalImage(post) : undefined;
const backgroundImageSection =
variant === "article" ? resolveSectionImage(post) : undefined;
return (
<ContentBannerView
variant={variant}
post={post}
leadingImageSrc={leadingImageSrc}
leadingImageAlt={leadingImageAlt}
backgroundImageHorizontal={backgroundImageHorizontal}
backgroundImageSection={backgroundImageSection}
rulePreview={rulePreview}
contentTone={contentTone}
/>
);
},
);
ContentBannerContainer.displayName = "ContentBanner";
export default ContentBannerContainer;
@@ -0,0 +1,44 @@
import type { BlogPost } from "../../../../lib/content";
import type { ContentContainerToneValue } from "../../content/ContentContainer/ContentContainer.types";
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;
/** When set, the rule preview links to the completed community rule screen. */
href?: 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 / 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 {
variant: ContentBannerVariant;
post: BlogPost;
leadingImageSrc?: string;
leadingImageAlt?: string;
/** Article variant: horizontal thumbnail below lg (`320×225.5`). */
backgroundImageHorizontal?: string;
/** Article variant: section banner at md+ (`1920×672`, Figma Section orientation). */
backgroundImageSection?: string;
rulePreview?: ContentBannerRulePreview;
contentTone?: ContentContainerToneValue;
}
@@ -0,0 +1,271 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { memo } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import ContentContainer from "../../content/ContentContainer";
import Rule from "../../cards/Rule";
import {
getAssetPath,
guideBannerLogoArrowPath,
} from "../../../../lib/assetUtils";
import type { ContentBannerViewProps } from "./ContentBanner.types";
/**
* Figma: ContentBanner on content page template (22078:791901) — left column
* title + description; logo mark (22078:806960) in right column.
*/
function ContentBannerGuideView({
post,
}: Pick<ContentBannerViewProps, "post">) {
const { title, description } = post.frontmatter;
return (
<section
className="relative w-full overflow-clip px-[var(--spacing-scale-020)] py-[var(--spacing-scale-024)] sm:px-[var(--spacing-scale-032)] sm:py-[var(--spacing-scale-032)] lg:px-[var(--spacing-scale-048)] lg:py-[var(--spacing-scale-040)]"
aria-labelledby="content-banner-title"
>
<div
className="mx-auto flex w-full max-w-[1024px] flex-col items-start gap-[var(--spacing-scale-024)] md:flex-row md:items-center md:gap-[var(--spacing-scale-032)]"
data-node-id="19189:9358"
>
<div
className="flex w-full max-w-[365px] shrink-0 flex-col items-start justify-center gap-[var(--spacing-scale-024)]"
data-node-id="19189:9171"
>
<div className="flex w-full flex-col items-start gap-[var(--measures-spacing-016)]">
<div className="flex w-full flex-col items-start gap-[var(--measures-spacing-004)] text-left text-[var(--color-content-default-primary)]">
<h1
id="content-banner-title"
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>
<div
className="flex w-full shrink-0 items-center justify-center md:flex-1"
data-node-id="22078:806960"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getAssetPath(guideBannerLogoArrowPath())}
alt=""
aria-hidden
className="h-[clamp(120px,20vw,171px)] w-[clamp(120px,20vw,172px)] max-w-none shrink-0 object-contain"
/>
</div>
</div>
</section>
);
}
/**
* Figma: Content page Template (19003:23305) — ContentBanner article instances.
* Horizontal thumbnail below md; Section SVG (1920×672) at md+.
*/
function ContentBannerArticleView({
post,
leadingImageSrc,
leadingImageAlt,
backgroundImageHorizontal,
backgroundImageSection,
}: ContentBannerViewProps) {
if (!backgroundImageHorizontal || !backgroundImageSection) {
return null;
}
return (
<div
data-node-id="19189:9053"
className="
relative z-[1] w-full overflow-visible
min-h-[275px]
pt-[var(--spacing-scale-016)] px-[var(--spacing-scale-016)]
pb-[var(--spacing-scale-064)]
sm:min-h-[326px] sm:pb-[var(--spacing-scale-048)]
md:min-h-[224px] md:px-[var(--spacing-scale-024)] md:pb-0
md:pt-[var(--spacing-scale-008)]
lg:min-h-[358.4px] lg:px-[var(--spacing-scale-048)] lg:py-[var(--spacing-scale-040)]
xl:min-h-[504px] xl:px-[var(--spacing-scale-064)] xl:py-[var(--spacing-scale-076)]
"
>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 -bottom-[var(--spacing-scale-024)] sm:-bottom-[var(--spacing-scale-032)] md:hidden"
data-name="ContentBannerBackgroundHorizontal"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={backgroundImageHorizontal}
alt=""
className="absolute inset-0 size-full max-w-none object-cover object-bottom"
/>
</div>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 -bottom-[var(--spacing-scale-032)] hidden md:block"
data-name="ContentBannerBackgroundSection"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={backgroundImageSection}
alt=""
className="absolute inset-0 size-full max-w-none object-cover object-center"
/>
</div>
<div
data-node-id="19189:9010"
className="
relative z-10
max-w-[calc(100%-96px)]
sm:max-w-[calc(100%-151px)]
md:max-w-[280px]
lg:max-w-[365px]
xl:max-w-[623px]
"
>
<ContentContainer
post={post}
size="responsive"
leadingImageSrc={leadingImageSrc}
leadingImageAlt={leadingImageAlt}
/>
</div>
</div>
);
}
/**
* Figma: use case detail ContentBanner (22015:42621) — copy left, Rule preview right.
*/
function ContentBannerUseCaseView({
post,
rulePreview,
contentTone = "inverse",
leadingImageSrc,
leadingImageAlt,
}: Pick<
ContentBannerViewProps,
| "post"
| "rulePreview"
| "contentTone"
| "leadingImageSrc"
| "leadingImageAlt"
>) {
const t = useTranslation("pages.useCasesCompletedRule");
if (!rulePreview) {
return null;
}
const { title } = post.frontmatter;
return (
<section
className="relative w-full overflow-clip"
aria-label={title}
>
<div
data-figma-node="22015:42621"
className="mx-auto grid w-full max-w-[1024px] grid-cols-1 items-center gap-[var(--space-800)] px-[var(--space-1200)] py-[var(--space-1000)] lg:grid-cols-2 lg:items-center"
>
<div
data-node-id="19189:9171"
className="flex w-full min-w-0 shrink-0 flex-col lg:max-w-[365px]"
>
<ContentContainer
post={post}
size="responsive"
tone={contentTone}
showLeadingImage={false}
leadingImageSrc={leadingImageSrc}
leadingImageAlt={leadingImageAlt}
/>
</div>
<div className="flex min-w-0 w-full">
{rulePreview.href ? (
<Link
href={rulePreview.href}
className="block w-full rounded-[24px] outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-default-brand-primary)] focus-visible:ring-offset-2"
aria-label={t("ruleCardLinkAriaLabel").replace(
"{title}",
rulePreview.title,
)}
>
<Rule
title={rulePreview.title}
description={rulePreview.description}
expanded
fluidWidth
size="L"
templateGridFigmaShell
backgroundColor={rulePreview.backgroundColor}
className="w-full cursor-pointer rounded-[24px] transition-opacity hover:opacity-95"
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"
/>
}
/>
</Link>
) : (
<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} />;
}
ContentBannerView.displayName = "ContentBannerView";
export default memo(ContentBannerView);
@@ -0,0 +1,5 @@
export { default } from "./ContentBanner.container";
export type {
ContentBannerProps,
ContentBannerVariant,
} from "./ContentBanner.types";
@@ -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);
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Groups.container";
export type { GroupsProps, GroupsItem } from "./Groups.types";
@@ -5,11 +5,13 @@ 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 (22137890679; **`lg`** single paragraph **2196724638** — About + use cases). */
const QuoteBlockContainer = memo<QuoteBlockProps>(
({
variant: variantProp = "standard",
className = "",
quote = "The rules of decision-making must be open and available to everyone, and this can happen only if they are formalized.",
quoteSecondary,
author = "Jo Freeman",
source = "The Tyranny of Structurelessness",
avatarSrc = "/assets/Quote_Avatar.svg",
@@ -17,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);
@@ -69,12 +70,29 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
"text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]",
showDecor: true,
},
statement: {
container:
"flex w-full flex-col items-center px-[var(--spacing-scale-032)] py-[var(--spacing-scale-048)] md:px-[var(--spacing-scale-096)] md:py-[var(--space-1200)]",
card: "",
gap: "",
avatarGap: "",
avatar: "",
quote: "",
author: "",
source: "",
showDecor: false,
statementLayout: true,
},
};
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 || `quote-${author.toLowerCase().replace(/\s+/g, "-")}`;
const baseId =
id ||
(variantProp === "statement"
? "statement-quote"
: `quote-${author.toLowerCase().replace(/\s+/g, "-")}`);
const quoteId = `${baseId}-content`;
const authorId = `${baseId}-author`;
@@ -105,7 +123,22 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
};
// Validate required props
if (!quote || !author) {
if (variantProp === "statement") {
if (!quote?.trim() || !quoteSecondary?.trim()) {
logger.error(
"QuoteBlock: statement variant requires non-empty quote and quoteSecondary",
);
if (onError) {
onError({
type: "missing_props",
message:
"QuoteBlock statement variant requires quote and quoteSecondary",
quote: !!(quote?.trim() && quoteSecondary?.trim()),
});
}
return null;
}
} else if (!quote || !author) {
logger.error("QuoteBlock: Missing required props (quote or author)");
if (onError) {
onError({
@@ -125,6 +158,7 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
<QuoteBlockView
className={className}
quote={quote}
quoteSecondary={quoteSecondary}
author={author}
source={source}
quoteId={quoteId}
@@ -1,12 +1,16 @@
export type QuoteBlockVariantValue = "compact" | "standard" | "extended";
export type QuoteBlockVariantValue =
| "compact"
| "standard"
| "extended"
| "statement";
export interface QuoteBlockProps {
/**
* Quote block variant.
*/
/** 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`** (Section/Quote); merged into one `<p>` from **`lg`**. */
quoteSecondary?: string;
author?: string;
source?: string;
avatarSrc?: string;
@@ -32,11 +36,16 @@ export interface VariantConfig {
author: string;
source: string;
showDecor: boolean;
/**
* When true, render Figma **Section/Quote** layout (yellow surface; stacked copy below **`lg`**, single paragraph from **`lg`**; no attribution).
*/
statementLayout?: boolean;
}
export interface QuoteBlockViewProps {
className: string;
quote: string;
quoteSecondary?: string;
author: string;
source?: string;
quoteId: string;
@@ -4,11 +4,13 @@ import { memo } from "react";
import Image from "next/image";
import { useTranslation } from "../../../contexts/MessagesContext";
import QuoteDecor from "./QuoteDecor";
import QuoteStatementDecor from "./QuoteStatementDecor";
import type { QuoteBlockViewProps } from "./QuoteBlock.types";
function QuoteBlockView({
className,
quote,
quoteSecondary,
author,
source,
quoteId,
@@ -23,6 +25,40 @@ function QuoteBlockView({
const t = useTranslation("quoteBlock");
const avatarAlt = t("avatarAlt").replace("{author}", author);
if (config.statementLayout) {
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"
>
<div
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 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>
</section>
);
}
return (
<section
className={`${config.container} ${className}`}
@@ -0,0 +1,41 @@
"use client";
import { memo } from "react";
import { getAssetPath, quoteStatementShapePath } from "../../../../lib/assetUtils";
/** 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%)";
const GRAIN_MULTIPLY_FILTER =
'url(\'data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg"><defs><filter id="grain" filterUnits="objectBoundingBox" x="0" y="0" width="1" height="1" colorInterpolationFilters="sRGB"><feTurbulence type="fractalNoise" baseFrequency="0.4" numOctaves="3" seed="7" stitchTiles="stitch" result="noise"/><feColorMatrix in="noise" result="softNoise" type="matrix" values="0.8 0 0 0 0.3 0 0.6 0 0 0.2 0 0 1.0 0 0.4 0 0 0 0.25 0"/><feComposite in="softNoise" in2="SourceAlpha" operator="in" result="maskedNoise"/><feBlend in="SourceGraphic" in2="maskedNoise" mode="multiply"/></filter></defs></svg>#grain\')';
const QuoteStatementDecor = memo<{ className?: string }>(({ className = "" }) => {
const src = getAssetPath(quoteStatementShapePath());
const bg = `url("${src}")`;
return (
<div
className={`pointer-events-none absolute inset-0 z-0 overflow-hidden opacity-[0.55] select-none ${className}`.trim()}
aria-hidden
style={{
backgroundImage: bg,
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
backgroundPosition: "center center",
WebkitMaskImage: EDGE_MASK,
maskImage: EDGE_MASK,
WebkitMaskSize: "100% 100%",
maskSize: "100% 100%",
WebkitMaskRepeat: "no-repeat",
maskRepeat: "no-repeat",
filter: GRAIN_MULTIPLY_FILTER,
WebkitFilter: GRAIN_MULTIPLY_FILTER,
}}
/>
);
});
QuoteStatementDecor.displayName = "QuoteStatementDecor";
export default QuoteStatementDecor;
+4 -1
View File
@@ -1,2 +1,5 @@
export { default } from "./QuoteBlock.container";
export type { QuoteBlockProps } from "./QuoteBlock.types";
export type {
QuoteBlockProps,
QuoteBlockVariantValue,
} from "./QuoteBlock.types";
@@ -2,11 +2,20 @@
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",
headingSurface = "onDark",
heading,
}) => {
const messages = useMessages();
// Memoize filtered posts to prevent unnecessary re-computations
const filteredPosts = useMemo(
() => relatedPosts.filter((post) => post.slug !== currentPostSlug),
@@ -95,6 +104,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 +117,10 @@ const RelatedArticlesContainer = memo<RelatedArticlesProps>(
transformStyle={transformStyle}
getProgressStyle={getProgressStyle}
onMouseDown={handleMouseDown}
variant={variant}
headingSurface={headingSurface}
heading={heading}
useCasesHeadingLines={useCasesHeadingLines}
/>
);
},
@@ -1,9 +1,24 @@
import type { BlogPost } from "../../../../lib/content";
export type RelatedArticlesVariant = "default" | "useCases";
/** Heading contrast when the section sits on a dark vs light page background. */
export type RelatedArticlesHeadingSurface = "onDark" | "onLight";
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;
/** Default `onDark` (blog). Use `onLight` on transparent / light marketing pages. */
headingSurface?: RelatedArticlesHeadingSurface;
/** Overrides the default “Related Articles” heading. */
heading?: string;
}
export interface RelatedArticlesViewProps {
@@ -13,4 +28,9 @@ export interface RelatedArticlesViewProps {
transformStyle: React.CSSProperties;
getProgressStyle: (_index: number) => React.CSSProperties;
onMouseDown?: (_e: React.MouseEvent<HTMLDivElement>) => void;
variant?: RelatedArticlesVariant;
headingSurface?: RelatedArticlesHeadingSurface;
heading?: string;
/** Stacked title lines (`pages.useCases.relatedArticles.title`) when `variant="useCases"`. */
useCasesHeadingLines?: readonly string[];
}

Some files were not shown because too many files have changed in this diff Show More