Merge pull request 'Marketing About, How It Works, and Use Cases pages' (#52) from adilallo/feature/AboutUseCasesPages into main
Reviewed-on: #52
This commit was merged in pull request #52.
This commit is contained in:
@@ -51,6 +51,7 @@ npm-cache/
|
||||
/build
|
||||
|
||||
# misc
|
||||
/tmp/
|
||||
*.pem
|
||||
|
||||
# IDE and editor files
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -76,6 +76,7 @@ export default function Page() {
|
||||
iconColor: "orange",
|
||||
},
|
||||
],
|
||||
seeHowItWorksHref: t("cardSteps.buttons.seeHowItWorksHref"),
|
||||
};
|
||||
|
||||
const featureGridData = {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+136
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+122
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./Shapes.container";
|
||||
export type { ShapesProps, StatShapeVariant } from "./Shapes.types";
|
||||
@@ -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 (**21993‑32352** / **32353**).
|
||||
*/
|
||||
const CaseStudyContainer = memo<CaseStudyProps>((props) => {
|
||||
return <CaseStudyView {...props} />;
|
||||
});
|
||||
|
||||
CaseStudyContainer.displayName = "CaseStudy";
|
||||
|
||||
export default CaseStudyContainer;
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const CASE_STUDY_SURFACE_OPTIONS = ["lavender", "neutral", "rose"] as const;
|
||||
|
||||
export type CaseStudySurfaceValue = (typeof CASE_STUDY_SURFACE_OPTIONS)[number];
|
||||
|
||||
export interface CaseStudyProps {
|
||||
surface: CaseStudySurfaceValue;
|
||||
/**
|
||||
* Alt text for built-in raster art (`public/assets/use-cases/`) when **`visual`** is omitted.
|
||||
*/
|
||||
imageAlt?: string;
|
||||
/** Overrides built-in raster with custom slot content when provided. */
|
||||
visual?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default } from "./CaseStudy.container";
|
||||
export type { CaseStudyProps, CaseStudySurfaceValue } from "./CaseStudy.types";
|
||||
export { CASE_STUDY_SURFACE_OPTIONS } from "./CaseStudy.types";
|
||||
@@ -1,16 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { memo, useId } from "react";
|
||||
import { IconView } from "./Icon.view";
|
||||
import type { IconProps } from "./Icon.types";
|
||||
|
||||
const IconContainer = memo<IconProps>(
|
||||
({ icon, title, description, className = "", onClick }) => {
|
||||
({ icon, title, description, className = "", onClick, interactive: interactiveProp = true }) => {
|
||||
const layoutTitleId = useId();
|
||||
|
||||
const handleClick = () => {
|
||||
if (!interactiveProp) return;
|
||||
if (onClick) onClick();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!interactiveProp) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleClick();
|
||||
@@ -23,6 +27,8 @@ const IconContainer = memo<IconProps>(
|
||||
title={title}
|
||||
description={description}
|
||||
className={className}
|
||||
interactive={interactiveProp}
|
||||
layoutTitleId={layoutTitleId}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,11 @@ export interface IconProps {
|
||||
description: string;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
/**
|
||||
* When false, renders a static tile (no button semantics or focus ring).
|
||||
* @default true
|
||||
*/
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
export interface IconViewProps {
|
||||
@@ -11,6 +16,9 @@ export interface IconViewProps {
|
||||
title: string;
|
||||
description: string;
|
||||
className: string;
|
||||
interactive: boolean;
|
||||
/** Stable id for `aria-labelledby` when `interactive` is false. */
|
||||
layoutTitleId: string;
|
||||
onClick: () => void;
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
@@ -7,30 +7,41 @@ export function IconView({
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
interactive,
|
||||
layoutTitleId,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
}: IconViewProps) {
|
||||
const interactionClass = interactive
|
||||
? "cursor-pointer transition-all duration-200 hover:scale-[1.02] hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-brand-primary)] focus:ring-offset-2"
|
||||
: "cursor-default";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border border-[var(--color-border-default-primary)] flex flex-col h-[350px] items-start justify-between p-[var(--measures-spacing-020)] relative w-[288px] bg-transparent cursor-pointer transition-all duration-200 hover:scale-[1.02] hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-brand-primary)] focus:ring-offset-2 ${className}`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`${title}: ${description}`}
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
data-figma-node="22084-859659"
|
||||
className={`relative flex h-[350px] w-full min-w-[240px] max-w-[480px] flex-col items-start justify-between border border-solid border-[var(--color-border-default-secondary)] bg-transparent p-[var(--measures-spacing-020)] ${interactionClass} ${className}`}
|
||||
tabIndex={interactive ? 0 : undefined}
|
||||
role={interactive ? "button" : "article"}
|
||||
aria-label={interactive ? `${title}: ${description}` : undefined}
|
||||
aria-labelledby={interactive ? undefined : layoutTitleId}
|
||||
onClick={interactive ? onClick : undefined}
|
||||
onKeyDown={interactive ? onKeyDown : undefined}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="shrink-0 w-[36px] h-[36px] flex items-center justify-center">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
{/* Title - Centered with auto space above and below */}
|
||||
<h3 className="font-inter font-normal text-[32px] leading-[36px] text-[var(--color-content-default-primary)] w-full">
|
||||
{/* Title — Figma XX Large / Label (32 / 36) */}
|
||||
<h3
|
||||
id={interactive ? undefined : layoutTitleId}
|
||||
className="w-full text-left font-inter text-[32px] font-normal leading-[36px] text-[var(--color-content-default-primary)]"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="font-inter font-medium text-[10px] leading-[14px] uppercase text-[var(--color-content-default-primary)] w-full">
|
||||
{/* Body: X Small / Paragraph (12/16) per Figma; 14/20 on md–<lg only (Section 22084-859062) */}
|
||||
<p className="w-full text-left font-inter font-normal text-[length:var(--text-x-small-paragraph)] leading-[length:var(--text-x-small-paragraph--line-height)] text-[var(--color-content-default-primary)] md:text-[length:var(--text-small-paragraph)] md:leading-[length:var(--text-small-paragraph--line-height)] lg:text-[length:var(--text-x-small-paragraph)] lg:leading-[length:var(--text-x-small-paragraph--line-height)]">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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 control’s `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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
|
||||
|
||||
+28
-10
@@ -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 size’s 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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
@@ -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 1–3 (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>
|
||||
|
||||
@@ -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);
|
||||
@@ -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 (22137‑890679; **`lg`** single paragraph **21967‑24638** — 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;
|
||||
@@ -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
Reference in New Issue
Block a user