Refine use cases rule examples
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -125,6 +125,7 @@ export default async function UseCaseDetailPage({ params }: PageProps) {
|
||||
description: ruleCard.description,
|
||||
backgroundColor: ruleCard.backgroundColor,
|
||||
iconPath: ruleCard.iconPath,
|
||||
href: `/use-cases/${slug}/rule`,
|
||||
}}
|
||||
/>
|
||||
<article
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -10,11 +10,11 @@ const SURFACE_CLASS: Record<CaseStudyProps["surface"], string> = {
|
||||
rose: "bg-[var(--color-surface-invert-brand-red)]",
|
||||
};
|
||||
|
||||
/** Default art per tile: PNG composites (FNB/BCSM) or vector Mutual Aid logo. */
|
||||
/** 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/use-cases/case-study-food-not-bombs.png",
|
||||
rose: "/assets/use-cases/case-study-boulder-county-street-medics.png",
|
||||
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). */
|
||||
@@ -39,12 +39,8 @@ function CaseStudyView({
|
||||
alt={imageAlt}
|
||||
width={305}
|
||||
height={305}
|
||||
unoptimized={
|
||||
SURFACE_ART[surface].endsWith(".svg") ? true : undefined
|
||||
}
|
||||
className={`pointer-events-none select-none ${
|
||||
surface === "lavender" ? "object-contain object-center" : "object-cover"
|
||||
}`}
|
||||
unoptimized
|
||||
className="pointer-events-none size-full select-none object-contain object-center"
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -28,6 +28,8 @@ const RuleContainer = memo<RuleProps>(
|
||||
onDescriptionClick,
|
||||
descriptionEmptyHint,
|
||||
descriptionEditAriaLabel,
|
||||
onTitleClick,
|
||||
titleEditAriaLabel,
|
||||
icon,
|
||||
backgroundColor = "bg-[var(--color-community-teal-100)]",
|
||||
className = "",
|
||||
@@ -84,6 +86,8 @@ const RuleContainer = memo<RuleProps>(
|
||||
onDescriptionClick={onDescriptionClick}
|
||||
descriptionEmptyHint={descriptionEmptyHint}
|
||||
descriptionEditAriaLabel={descriptionEditAriaLabel}
|
||||
onTitleClick={onTitleClick}
|
||||
titleEditAriaLabel={titleEditAriaLabel}
|
||||
icon={icon}
|
||||
backgroundColor={backgroundColor}
|
||||
className={className}
|
||||
|
||||
@@ -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;
|
||||
@@ -80,6 +87,8 @@ export interface RuleViewProps {
|
||||
onDescriptionClick?: () => void;
|
||||
descriptionEmptyHint?: string;
|
||||
descriptionEditAriaLabel?: string;
|
||||
onTitleClick?: () => void;
|
||||
titleEditAriaLabel?: string;
|
||||
icon?: React.ReactNode;
|
||||
backgroundColor: string;
|
||||
className: string;
|
||||
|
||||
@@ -14,6 +14,8 @@ export function RuleView({
|
||||
onDescriptionClick,
|
||||
descriptionEmptyHint,
|
||||
descriptionEditAriaLabel,
|
||||
onTitleClick,
|
||||
titleEditAriaLabel,
|
||||
icon,
|
||||
backgroundColor,
|
||||
className,
|
||||
@@ -307,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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -36,7 +36,7 @@ const VARIANT_STYLES: Record<
|
||||
},
|
||||
};
|
||||
|
||||
/** Figma **Section/AskOrganizer** [18116:15960](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=18116-15960&m=dev) (`lg` shell + type + button). Use-case detail: [22015:42624](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22015-42624&m=dev). */
|
||||
/** 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,
|
||||
@@ -61,9 +61,9 @@ 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)]"
|
||||
: resolvedVariant === "use-case-detail"
|
||||
? "w-full py-[var(--spacing-scale-096)] px-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-064)]"
|
||||
: "py-[var(--spacing-scale-096)] px-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-064)]";
|
||||
: 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"
|
||||
|
||||
@@ -38,7 +38,7 @@ function AskOrganizerView({
|
||||
data-figma-node={isUseCaseDetail ? "22015-42624" : "18116-15960"}
|
||||
>
|
||||
<div
|
||||
className={`mx-auto flex w-full min-w-[358px] max-w-[1280px] flex-col ${contentGap} ${isUseCaseDetail ? "items-center" : ""}`}
|
||||
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
|
||||
@@ -56,10 +56,10 @@ function AskOrganizerView({
|
||||
>
|
||||
<Button
|
||||
{...(buttonHref ? { href: buttonHref } : {})}
|
||||
size="large"
|
||||
size="small"
|
||||
buttonType="filled"
|
||||
palette={buttonPalette}
|
||||
className="!px-[var(--spacing-scale-016)] !py-[var(--spacing-scale-012)]"
|
||||
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"
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface ContentBannerRulePreview {
|
||||
description: string;
|
||||
backgroundColor: string;
|
||||
iconPath: string;
|
||||
/** When set, the rule preview links to the completed community rule screen. */
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export interface ContentBannerProps {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"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 {
|
||||
@@ -123,16 +125,23 @@ function ContentBannerArticleView({
|
||||
function ContentBannerUseCaseView({
|
||||
post,
|
||||
rulePreview,
|
||||
}: Pick<ContentBannerViewProps, "post" | "rulePreview">) {
|
||||
contentTone = "inverse",
|
||||
leadingImageSrc,
|
||||
leadingImageAlt,
|
||||
}: Pick<
|
||||
ContentBannerViewProps,
|
||||
| "post"
|
||||
| "rulePreview"
|
||||
| "contentTone"
|
||||
| "leadingImageSrc"
|
||||
| "leadingImageAlt"
|
||||
>) {
|
||||
const t = useTranslation("pages.useCasesCompletedRule");
|
||||
if (!rulePreview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { title, description, author, date } = post.frontmatter;
|
||||
const formattedDate = new Date(date).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
});
|
||||
const { title } = post.frontmatter;
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -141,52 +150,77 @@ function ContentBannerUseCaseView({
|
||||
>
|
||||
<div
|
||||
data-figma-node="22015:42621"
|
||||
className="mx-auto flex w-full max-w-[1024px] flex-col items-center gap-[var(--space-800)] px-[var(--space-1200)] py-[var(--space-1000)] md:flex-row md:items-center"
|
||||
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 max-w-[365px] shrink-0 flex-col gap-[var(--spacing-scale-024)]"
|
||||
className="flex w-full min-w-0 shrink-0 flex-col lg:max-w-[365px]"
|
||||
>
|
||||
<div className="flex w-full flex-col gap-[var(--measures-spacing-016)]">
|
||||
<div className="flex w-full flex-col gap-[var(--measures-spacing-004)] text-[var(--color-content-inverse-brand-royal)]">
|
||||
<h1 className="w-full font-bricolage font-medium text-[32px] leading-[110%] sm:text-[40px] lg:text-[44px]">
|
||||
{title}
|
||||
</h1>
|
||||
{description ? (
|
||||
<p className="w-full font-inter font-normal text-[16px] leading-[130%] sm:text-[18px]">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-end gap-[var(--measures-spacing-008)] font-inter text-[14px] leading-[20px] text-[var(--color-content-inverse-brand-royal)]">
|
||||
<span>{author}</span>
|
||||
<span>{formattedDate}</span>
|
||||
</div>
|
||||
<ContentContainer
|
||||
post={post}
|
||||
size="responsive"
|
||||
tone={contentTone}
|
||||
showLeadingImage={false}
|
||||
leadingImageSrc={leadingImageSrc}
|
||||
leadingImageAlt={leadingImageAlt}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 w-full flex-1">
|
||||
<Rule
|
||||
title={rulePreview.title}
|
||||
description={rulePreview.description}
|
||||
expanded
|
||||
fluidWidth
|
||||
size="L"
|
||||
templateGridFigmaShell
|
||||
backgroundColor={rulePreview.backgroundColor}
|
||||
className="pointer-events-none w-full select-none rounded-[24px]"
|
||||
icon={
|
||||
<Image
|
||||
src={getAssetPath(rulePreview.iconPath)}
|
||||
alt=""
|
||||
width={103}
|
||||
height={103}
|
||||
draggable={false}
|
||||
unoptimized={rulePreview.iconPath.endsWith(".svg")}
|
||||
className="aspect-square size-full max-h-[103px] max-w-[103px] object-contain mix-blend-luminosity"
|
||||
<div 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>
|
||||
|
||||
@@ -39,6 +39,7 @@ export function RuleStackView({
|
||||
subtitle={t("subtitle")}
|
||||
variant="multi-line"
|
||||
ruleStackDesktopTypeScale
|
||||
twoColumnsFromMd={twoColumnsFromMd}
|
||||
/>
|
||||
|
||||
{gridEntries === null ? (
|
||||
|
||||
@@ -28,6 +28,11 @@ export interface CommunityRuleSection {
|
||||
export interface CommunityRuleProps {
|
||||
sections: CommunityRuleSection[];
|
||||
className?: string;
|
||||
/** When true, wrap in white background with left teal bar (small breakpoint). */
|
||||
/** When true, wrap in white background with left accent bar (small breakpoint). */
|
||||
useCardStyle?: boolean;
|
||||
/**
|
||||
* Accent bar color when {@link useCardStyle} is true; should match the page
|
||||
* surface behind the card (defaults to create-flow teal).
|
||||
*/
|
||||
cardAccentColor?: string;
|
||||
}
|
||||
|
||||
@@ -17,17 +17,22 @@ function CommunityRuleView({
|
||||
sections,
|
||||
className = "",
|
||||
useCardStyle = false,
|
||||
cardAccentColor,
|
||||
}: CommunityRuleProps) {
|
||||
const accent = cardAccentColor ?? TEAL_BG;
|
||||
const rootClass = useCardStyle
|
||||
? `rounded-[12px] bg-white pl-3 border-l-4 ${className}`
|
||||
? `rounded-[12px] bg-white pl-4 ${className}`
|
||||
: className;
|
||||
const rootStyle = useCardStyle ? { borderLeftColor: TEAL_BG } : undefined;
|
||||
const rootStyle = useCardStyle
|
||||
? {
|
||||
gap: SECTION_GAP,
|
||||
// Inset bar (not border) — Safari mishandles `border-left` + CSS variable accent colors.
|
||||
boxShadow: `inset 4px 0 0 0 ${accent}`,
|
||||
}
|
||||
: { gap: SECTION_GAP };
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col min-w-0 ${rootClass}`}
|
||||
style={{ gap: SECTION_GAP, ...rootStyle }}
|
||||
>
|
||||
<div className={`flex flex-col min-w-0 ${rootClass}`} style={rootStyle}>
|
||||
{sections.map((ruleSection, sectionIndex) => (
|
||||
<Section
|
||||
key={sectionIndex}
|
||||
|
||||
@@ -111,9 +111,9 @@ const ContentLockupContainer = memo<ContentLockupProps>(
|
||||
titleGroup: "flex flex-col gap-[var(--spacing-scale-008)]",
|
||||
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
|
||||
title:
|
||||
"font-bricolage-grotesque font-medium text-[36px] leading-[110%] tracking-[0] lg:text-[44px] lg:leading-[1.1] text-[var(--color-content-default-brand-primary)]",
|
||||
"font-bricolage-grotesque font-medium text-[32px] leading-[1.1] tracking-[0] md:text-[44px] md:leading-[1.1] text-[var(--color-content-default-brand-primary)]",
|
||||
subtitle:
|
||||
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-default-primary)]",
|
||||
"font-inter font-normal text-[18px] leading-[1.3] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-default-primary)]",
|
||||
shape:
|
||||
"w-[16px] h-[16px] md:w-[20px] md:h-[20px] lg:w-[24px] lg:h-[24px]",
|
||||
},
|
||||
@@ -123,9 +123,9 @@ const ContentLockupContainer = memo<ContentLockupProps>(
|
||||
titleGroup: "flex flex-col gap-[var(--spacing-scale-008)] w-full",
|
||||
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
|
||||
title:
|
||||
"font-bricolage-grotesque font-medium text-[36px] leading-[110%] tracking-[0] lg:text-[44px] lg:leading-[1.1] text-[var(--color-content-inverse-primary)]",
|
||||
"font-bricolage-grotesque font-medium text-[32px] leading-[1.1] tracking-[0] md:text-[44px] md:leading-[1.1] text-[var(--color-content-inverse-primary)]",
|
||||
subtitle:
|
||||
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-invert-secondary)]",
|
||||
"font-inter font-normal text-[18px] leading-[1.3] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-invert-secondary)]",
|
||||
shape:
|
||||
"w-[16px] h-[16px] md:w-[20px] md:h-[20px] lg:w-[24px] lg:h-[24px]",
|
||||
},
|
||||
|
||||
@@ -29,10 +29,10 @@ function HeaderLockupView({
|
||||
}`}
|
||||
>
|
||||
{/* Title */}
|
||||
<div className="flex items-center relative shrink-0 w-full">
|
||||
<div className="flex w-full shrink-0 items-center">
|
||||
<h1
|
||||
id={titleId}
|
||||
className={`flex-[1_0_0] min-h-px min-w-px overflow-hidden relative ${titleColorClass} text-ellipsis whitespace-pre-wrap ${
|
||||
className={`relative w-full min-w-0 ${titleColorClass} whitespace-pre-wrap ${
|
||||
isLeft ? "text-left" : "text-center"
|
||||
} ${
|
||||
isL
|
||||
@@ -49,7 +49,7 @@ function HeaderLockupView({
|
||||
!(typeof description === "string" && description.length === 0) &&
|
||||
(typeof description === "string" ? (
|
||||
<p
|
||||
className={`font-inter font-normal max-w-[640px] overflow-hidden relative shrink-0 ${descriptionColorClass} text-ellipsis w-full whitespace-pre-wrap ${
|
||||
className={`font-inter font-normal max-w-[640px] overflow-visible relative shrink-0 ${descriptionColorClass} w-full whitespace-pre-wrap ${
|
||||
isLeft ? "" : "text-center"
|
||||
} ${
|
||||
isL ? "text-[18px] leading-[1.3]" : "text-[14px] leading-[20px]"
|
||||
@@ -59,7 +59,7 @@ function HeaderLockupView({
|
||||
</p>
|
||||
) : (
|
||||
<div
|
||||
className={`font-inter font-normal max-w-[640px] overflow-hidden relative shrink-0 ${descriptionColorClass} text-ellipsis w-full whitespace-pre-wrap ${
|
||||
className={`font-inter font-normal max-w-[640px] overflow-visible relative shrink-0 ${descriptionColorClass} w-full whitespace-pre-wrap ${
|
||||
isLeft ? "" : "text-center"
|
||||
} ${
|
||||
isL ? "text-[18px] leading-[1.3]" : "text-[14px] leading-[20px]"
|
||||
|
||||
@@ -15,6 +15,11 @@ interface SectionHeaderProps {
|
||||
* subtitle **18 / 1.3** at `lg`, **24/32** at `xl`, **left-aligned** in its column from `lg` (Figma **22085:860413**).
|
||||
*/
|
||||
ruleStackDesktopTypeScale?: boolean;
|
||||
/**
|
||||
* When true with `ruleStackDesktopTypeScale`, title and subtitle split into two columns from project **`md`** (640px),
|
||||
* e.g. `/use-cases` Rule stack. Default keeps the split at **`lg`** (1024px).
|
||||
*/
|
||||
twoColumnsFromMd?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,12 +34,18 @@ const SectionHeader = memo<SectionHeaderProps>(
|
||||
variant: variantProp = "default",
|
||||
stackedDesktopLines,
|
||||
ruleStackDesktopTypeScale = false,
|
||||
twoColumnsFromMd = false,
|
||||
}) => {
|
||||
const variant = variantProp;
|
||||
const useStackedDesktop =
|
||||
variant === "multi-line" && stackedDesktopLines != null;
|
||||
const rowAlignClasses =
|
||||
variant === "multi-line"
|
||||
const splitFromMd =
|
||||
twoColumnsFromMd &&
|
||||
ruleStackDesktopTypeScale &&
|
||||
variant === "multi-line";
|
||||
const rowAlignClasses = splitFromMd
|
||||
? "md:flex-row md:justify-between md:items-center xl:gap-[var(--spacing-scale-024)]"
|
||||
: variant === "multi-line"
|
||||
? "lg:flex-row lg:justify-between lg:items-center xl:gap-[var(--spacing-scale-024)]"
|
||||
: "lg:flex-row lg:justify-between lg:items-start xl:gap-[var(--spacing-scale-024)]";
|
||||
|
||||
@@ -42,11 +53,13 @@ const SectionHeader = memo<SectionHeaderProps>(
|
||||
<div
|
||||
className={`flex flex-col gap-[var(--spacing-scale-004)] w-full ${rowAlignClasses}`}
|
||||
>
|
||||
{/* Title — left column at lg+ */}
|
||||
{/* Title — left column at md+ (use cases) or lg+ */}
|
||||
<div
|
||||
className={
|
||||
variant === "multi-line"
|
||||
? "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center xl:w-[50%] xl:h-[156px] xl:flex xl:items-center"
|
||||
? splitFromMd
|
||||
? "md:w-[50%] md:h-[var(--spacing-scale-120)] md:flex md:items-center xl:w-[50%] xl:h-[156px] xl:flex xl:items-center"
|
||||
: "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center xl:w-[50%] xl:h-[156px] xl:flex xl:items-center"
|
||||
: "lg:w-[369px] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center xl:w-[452px] xl:h-[156px] xl:flex xl:items-center"
|
||||
}
|
||||
>
|
||||
@@ -54,30 +67,38 @@ const SectionHeader = memo<SectionHeaderProps>(
|
||||
className={
|
||||
variant === "multi-line"
|
||||
? ruleStackDesktopTypeScale
|
||||
? "font-bricolage-grotesque font-bold text-[28px] leading-[36px] md:text-[32px] md:leading-[40px] lg:w-full lg:max-w-none lg:text-left lg:text-[32px] lg:leading-[40px] xl:text-[40px] xl:leading-[52px] text-[var(--color-content-default-primary)]"
|
||||
? splitFromMd
|
||||
? "font-bricolage-grotesque font-bold text-[28px] leading-[36px] text-[var(--color-content-default-primary)] md:w-full md:max-w-none md:text-left md:text-[32px] md:leading-[40px] xl:text-[40px] xl:leading-[52px]"
|
||||
: "font-bricolage-grotesque font-bold text-[28px] leading-[36px] md:text-[32px] md:leading-[40px] lg:w-full lg:max-w-none lg:text-left lg:text-[32px] lg:leading-[40px] xl:text-[40px] xl:leading-[52px] text-[var(--color-content-default-primary)]"
|
||||
: "font-bricolage-grotesque font-bold text-[28px] leading-[36px] md:text-[32px] md:leading-[40px] lg:w-[410px] lg:text-left xl:text-[40px] xl:leading-[52px] text-[var(--color-content-default-primary)]"
|
||||
: "font-bricolage-grotesque font-bold text-[28px] leading-[36px] sm:text-[32px] sm:leading-[40px] lg:text-[32px] lg:leading-[40px] lg:w-[369px] lg:pr-[var(--spacing-scale-096)] xl:text-[40px] xl:leading-[52px] xl:w-[452px] xl:pr-[var(--spacing-scale-096)] text-[var(--color-content-default-primary)]"
|
||||
}
|
||||
>
|
||||
<span className="block lg:hidden">{title}</span>
|
||||
<span className={splitFromMd ? "block md:hidden" : "block lg:hidden"}>
|
||||
{title}
|
||||
</span>
|
||||
{useStackedDesktop ? (
|
||||
<span className="hidden lg:block">
|
||||
<span className={splitFromMd ? "hidden md:block" : "hidden lg:block"}>
|
||||
<span className="block">{stackedDesktopLines[0]}</span>
|
||||
<span className="block">{stackedDesktopLines[1]}</span>
|
||||
<span className="block">{stackedDesktopLines[2]}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="hidden lg:block">{titleLg || title}</span>
|
||||
<span className={splitFromMd ? "hidden md:block" : "hidden lg:block"}>
|
||||
{titleLg || title}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Subtitle — right column at lg+ (Figma X Large / Large / stacked small) */}
|
||||
{/* Subtitle — right column at md+ (use cases) or lg+ */}
|
||||
<div
|
||||
className={
|
||||
variant === "multi-line"
|
||||
? ruleStackDesktopTypeScale
|
||||
? "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center lg:justify-start lg:ml-[var(--spacing-scale-016)] xl:ml-0 xl:w-[50%] xl:h-[156px] xl:flex xl:items-center xl:justify-start"
|
||||
? splitFromMd
|
||||
? "md:w-[50%] md:h-[var(--spacing-scale-120)] md:flex md:items-center md:justify-start md:ml-[var(--spacing-scale-016)] xl:ml-0 xl:w-[50%] xl:h-[156px] xl:flex xl:items-center xl:justify-start"
|
||||
: "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center lg:justify-start lg:ml-[var(--spacing-scale-016)] xl:ml-0 xl:w-[50%] xl:h-[156px] xl:flex xl:items-center xl:justify-start"
|
||||
: "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center lg:justify-end lg:ml-[var(--spacing-scale-016)] xl:ml-0 xl:w-[50%] xl:h-[156px] xl:flex xl:items-center xl:justify-end"
|
||||
: "lg:w-[928px] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center lg:justify-end xl:h-[156px] xl:flex xl:items-center xl:justify-end"
|
||||
}
|
||||
@@ -86,7 +107,9 @@ const SectionHeader = memo<SectionHeaderProps>(
|
||||
className={
|
||||
variant === "multi-line"
|
||||
? ruleStackDesktopTypeScale
|
||||
? "font-inter font-normal text-[14px] leading-[20px] md:text-[18px] md:leading-[130%] lg:text-left lg:text-[18px] lg:leading-[130%] text-[var(--color-content-default-tertiary)] xl:text-[24px] xl:leading-[32px]"
|
||||
? splitFromMd
|
||||
? "font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-default-tertiary)] md:text-left md:text-[18px] md:leading-[130%] xl:text-[24px] xl:leading-[32px]"
|
||||
: "font-inter font-normal text-[14px] leading-[20px] md:text-[18px] md:leading-[130%] lg:text-left lg:text-[18px] lg:leading-[130%] text-[var(--color-content-default-tertiary)] xl:text-[24px] xl:leading-[32px]"
|
||||
: "font-inter font-normal text-[14px] leading-[20px] md:text-[18px] md:leading-[130%] xl:text-[24px] xl:leading-[32px] text-[var(--color-content-default-tertiary)] lg:text-right"
|
||||
: "font-inter font-normal text-[18px] leading-[130%] sm:text-[18px] sm:leading-[32px] lg:text-[24px] lg:leading-[32px] xl:text-[32px] xl:leading-[40px] xl:text-right text-[#484848] sm:text-[var(--color-content-default-tertiary)] lg:text-[var(--color-content-default-tertiary)] xl:text-[var(--color-content-default-tertiary)] tracking-[0px]"
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { TripleTextBlockProps } from "./TripleTextBlock.types";
|
||||
|
||||
/**
|
||||
* Figma: "Type / TripleTextBlock" — use cases **`lg` 22037-26994**, **`xl` 22085-860414**;
|
||||
* **`md` 22085-862437**; stacked 22137:890676; lg 22128:888715; xl 22135:889705 (default).
|
||||
* baseline **22112-871529**; **`md` 22085-862437**; stacked 22137:890676; lg 22128:888715; xl 22135:889705 (default).
|
||||
*/
|
||||
const TripleTextBlockContainer = memo<TripleTextBlockProps>((props) => {
|
||||
const headingId = useId();
|
||||
|
||||
@@ -14,19 +14,19 @@ function columnUsesLargeBreakpointCopy(column: TripleTextBlockColumn): boolean {
|
||||
|
||||
function TripleTextUseCasesColumn({ column }: { column: TripleTextBlockColumn }) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-[var(--spacing-scale-020)] lg:gap-0 xl:gap-[var(--spacing-scale-020)]">
|
||||
<div className="flex flex-col gap-[var(--spacing-scale-008)] lg:gap-[var(--spacing-scale-004)] xl:gap-[var(--spacing-scale-008)]">
|
||||
<h3 className="text-left font-bricolage-grotesque text-[24px] font-medium leading-8 text-[var(--color-content-default-primary,white)] md:text-[32px] md:leading-[1.1] lg:text-[18px] lg:leading-[var(--spacing-scale-022)] xl:text-[32px] xl:leading-[1.1]">
|
||||
{column.title}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-[var(--spacing-scale-024)] font-inter text-[16px] font-normal leading-6 text-[var(--color-content-default-secondary)] md:text-[24px] md:leading-8 lg:gap-[var(--spacing-scale-020)] lg:text-[14px] lg:leading-5 xl:gap-[var(--spacing-scale-032)] xl:text-[24px] xl:leading-8">
|
||||
<p>{column.description}</p>
|
||||
{column.descriptionSecondary ? (
|
||||
<p>{column.descriptionSecondary}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<article className="flex w-full flex-col gap-[var(--spacing-scale-006)] md:gap-[var(--spacing-scale-008)] lg:gap-[var(--spacing-scale-004)] xl:gap-[var(--spacing-scale-008)]">
|
||||
<h3 className="text-left font-bricolage-grotesque text-[24px] font-medium leading-8 text-[var(--color-content-default-primary,white)] md:text-[32px] md:leading-[1.1] lg:text-[18px] lg:leading-[var(--spacing-scale-022)] xl:text-[32px] xl:leading-[1.1]">
|
||||
{column.title}
|
||||
</h3>
|
||||
<div className="flex flex-col font-inter text-[16px] font-normal leading-6 text-[var(--color-content-default-secondary)] md:text-[24px] md:leading-8 lg:text-[14px] lg:leading-5 xl:text-[24px] xl:leading-8">
|
||||
<p>{column.description}</p>
|
||||
{column.descriptionSecondary ? (
|
||||
<p className="mt-[var(--spacing-scale-024)] md:mt-[var(--spacing-scale-032)] lg:mt-0 lg:pt-[var(--spacing-scale-020)] xl:mt-[var(--spacing-scale-032)]">
|
||||
{column.descriptionSecondary}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ function TripleTextBlockColumnLockup({
|
||||
* Section horizontal padding adds **+ Scale/096** below `xl` (outer frame inset); **use cases `xl`** uses **Scale/160** only ([22085:860414](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-860414&m=dev)).
|
||||
*
|
||||
* Figma: use cases **`lg`** [22037:26994](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22037-26994&m=dev);
|
||||
* **`md`** [22085:862437](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-862437&m=dev); stacked **22137:890676**;
|
||||
* baseline **22112:871529** / **22085:860366**; **`md`** [22085:862437](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-862437&m=dev); stacked **22137:890676**;
|
||||
* lg 3-col **22128:888715**; xl **22135:889705** (default preset).
|
||||
*/
|
||||
function TripleTextBlockView({
|
||||
@@ -102,11 +102,11 @@ function TripleTextBlockView({
|
||||
<section
|
||||
{...(isUseCases ? { "data-figma-node": "22085-860414" } : {})}
|
||||
aria-labelledby={hasSectionTitle ? headingId : undefined}
|
||||
className={`bg-black px-[calc(var(--spacing-scale-032)+var(--spacing-scale-096))] py-[var(--spacing-scale-064)] md:px-[calc(var(--spacing-scale-096)+var(--spacing-scale-096))] md:py-[var(--spacing-scale-064)] lg:px-[calc(var(--spacing-scale-096)+var(--spacing-scale-096))] lg:py-[var(--spacing-scale-064)] ${
|
||||
className={`bg-black py-[var(--spacing-scale-064)] xl:py-[var(--spacing-scale-064)] ${
|
||||
isUseCases
|
||||
? "xl:px-[var(--spacing-scale-160)]"
|
||||
: "xl:px-[calc(var(--spacing-scale-160)+var(--spacing-scale-096))]"
|
||||
} xl:py-[var(--spacing-scale-064)] ${className}`.trim()}
|
||||
? "px-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-096)] lg:px-[calc(var(--spacing-scale-096)+var(--spacing-scale-096))] xl:px-[var(--spacing-scale-160)]"
|
||||
: "px-[calc(var(--spacing-scale-032)+var(--spacing-scale-096))] md:px-[calc(var(--spacing-scale-096)+var(--spacing-scale-096))] lg:px-[calc(var(--spacing-scale-096)+var(--spacing-scale-096))] xl:px-[calc(var(--spacing-scale-160)+var(--spacing-scale-096))]"
|
||||
} ${className}`.trim()}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
|
||||
Reference in New Issue
Block a user