From 2f2b5d0dc26c491b53e4efb0c9177fbd2792acce Mon Sep 17 00:00:00 2001
From: adilallo <39313955+adilallo@users.noreply.github.com>
Date: Tue, 19 May 2026 22:16:08 -0600
Subject: [PATCH] Refine use cases rule examples
---
.gitignore | 1 +
.../components/FinalReviewTitleEditModal.tsx | 109 +++
app/(app)/create/hooks/useCreateFlowSm2Up.ts | 20 +
.../screens/completed/CompletedScreen.tsx | 6 +-
.../screens/review/FinalReviewScreen.tsx | 44 +-
app/(marketing)/use-cases/[slug]/page.tsx | 1 +
app/(marketing-case-study)/layout.tsx | 14 +
.../_components/UseCaseCompletedRule.view.tsx | 136 ++++
.../useUseCaseCompletedRuleActions.ts | 122 ++++
.../use-cases/[slug]/rule/page.tsx | 66 ++
app/api/use-cases/[slug]/duplicate/route.ts | 67 ++
.../cards/CaseStudy/CaseStudy.view.tsx | 14 +-
app/components/cards/Rule/Rule.container.tsx | 4 +
app/components/cards/Rule/Rule.types.ts | 9 +
app/components/cards/Rule/Rule.view.tsx | 28 +-
.../ConditionalNavigationClient.tsx | 5 +-
.../CreateFlowTopNav.container.tsx | 13 +
.../CreateFlowTopNav.types.ts | 18 +
.../CreateFlowTopNav.view.tsx | 436 +++++++++---
.../AskOrganizer/AskOrganizer.container.tsx | 8 +-
.../AskOrganizer/AskOrganizer.view.tsx | 6 +-
.../ContentBanner/ContentBanner.types.ts | 2 +
.../ContentBanner/ContentBanner.view.tsx | 124 ++--
.../sections/RuleStack/RuleStack.view.tsx | 1 +
.../type/CommunityRule/CommunityRule.types.ts | 7 +-
.../type/CommunityRule/CommunityRule.view.tsx | 17 +-
.../ContentLockup/ContentLockup.container.tsx | 8 +-
.../type/HeaderLockup/HeaderLockup.view.tsx | 8 +-
.../type/SectionHeader/SectionHeader.tsx | 45 +-
.../TripleTextBlock.container.tsx | 2 +-
.../TripleTextBlock/TripleTextBlock.view.tsx | 34 +-
lib/create/api.ts | 38 ++
lib/create/buildPublishPayload.ts | 10 +-
lib/create/documentEntryGuards.ts | 10 +-
lib/create/finalReviewChipPresets.ts | 21 +
.../normalizePublishedDocumentForEdit.ts | 331 +++++++++
.../publishedDocumentToCreateFlowState.ts | 5 +-
lib/navigationChromelessPath.ts | 15 +
lib/useCaseCompletedRule.ts | 52 ++
lib/useCaseTemplateDuplicate.ts | 7 +
.../create/reviewAndComplete/finalReview.json | 5 +
messages/en/create/topNav.json | 2 +
messages/en/index.ts | 4 +
messages/en/metadata.json | 17 +
messages/en/pages/useCasesCompletedRule.json | 15 +
messages/en/pages/useCasesCompletedRules.json | 644 ++++++++++++++++++
messages/en/pages/useCasesDetail.json | 8 +-
...ase-study-boulder-county-street-medics.svg | 35 +
.../case-study/case-study-food-not-bombs.svg | 101 +++
stories/type/CommunityRule.stories.js | 8 +-
tests/components/AskOrganizer.test.tsx | 30 +-
tests/components/CommunityRule.test.tsx | 18 +-
tests/components/ContentBanner.test.tsx | 39 +-
tests/components/CreateFlowTopNav.test.tsx | 131 +++-
tests/components/FinalReviewPage.test.tsx | 35 +-
tests/components/SectionHeader.test.tsx | 21 +
.../components/type/TripleTextBlock.test.tsx | 10 +-
tests/pages/use-cases-completed-rule.test.jsx | 153 +++++
tests/pages/use-cases-detail.test.jsx | 6 +
tests/unit/Rule.test.jsx | 16 +
tests/unit/buildPublishPayload.test.ts | 34 +
.../unit/lib/navigationChromelessPath.test.ts | 23 +
.../normalizePublishedDocumentForEdit.test.ts | 48 ++
tests/unit/useCaseTemplateDuplicate.test.ts | 16 +
tests/unit/useCasesDuplicateRoute.test.ts | 98 +++
65 files changed, 3129 insertions(+), 252 deletions(-)
create mode 100644 app/(app)/create/components/FinalReviewTitleEditModal.tsx
create mode 100644 app/(app)/create/hooks/useCreateFlowSm2Up.ts
create mode 100644 app/(marketing-case-study)/layout.tsx
create mode 100644 app/(marketing-case-study)/use-cases/[slug]/rule/_components/UseCaseCompletedRule.view.tsx
create mode 100644 app/(marketing-case-study)/use-cases/[slug]/rule/_components/useUseCaseCompletedRuleActions.ts
create mode 100644 app/(marketing-case-study)/use-cases/[slug]/rule/page.tsx
create mode 100644 app/api/use-cases/[slug]/duplicate/route.ts
create mode 100644 lib/create/normalizePublishedDocumentForEdit.ts
create mode 100644 lib/navigationChromelessPath.ts
create mode 100644 lib/useCaseCompletedRule.ts
create mode 100644 lib/useCaseTemplateDuplicate.ts
create mode 100644 messages/en/pages/useCasesCompletedRule.json
create mode 100644 messages/en/pages/useCasesCompletedRules.json
create mode 100644 public/assets/case-study/case-study-boulder-county-street-medics.svg
create mode 100644 public/assets/case-study/case-study-food-not-bombs.svg
create mode 100644 tests/pages/use-cases-completed-rule.test.jsx
create mode 100644 tests/unit/lib/navigationChromelessPath.test.ts
create mode 100644 tests/unit/normalizePublishedDocumentForEdit.test.ts
create mode 100644 tests/unit/useCaseTemplateDuplicate.test.ts
create mode 100644 tests/unit/useCasesDuplicateRoute.test.ts
diff --git a/.gitignore b/.gitignore
index 5c074bb..fc752b1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,6 +51,7 @@ npm-cache/
/build
# misc
+/tmp/
*.pem
# IDE and editor files
diff --git a/app/(app)/create/components/FinalReviewTitleEditModal.tsx b/app/(app)/create/components/FinalReviewTitleEditModal.tsx
new file mode 100644
index 0000000..d12c830
--- /dev/null
+++ b/app/(app)/create/components/FinalReviewTitleEditModal.tsx
@@ -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 (
+
+
+
+ }
+ showBackButton={false}
+ showNextButton
+ nextButtonText={tSave("saveButton")}
+ nextButtonDisabled={!canSave}
+ onNext={handleSave}
+ ariaLabel={tModal("title")}
+ >
+
+ {
+ setDraft(e.target.value);
+ }}
+ inputSize="medium"
+ formHeader={false}
+ textHint={characterHint}
+ maxLength={COMMUNITY_TITLE_FIELD_MAX_LENGTH}
+ />
+
+
+ );
+}
diff --git a/app/(app)/create/hooks/useCreateFlowSm2Up.ts b/app/(app)/create/hooks/useCreateFlowSm2Up.ts
new file mode 100644
index 0000000..ce5da76
--- /dev/null
+++ b/app/(app)/create/hooks/useCreateFlowSm2Up.ts
@@ -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;
+}
diff --git a/app/(app)/create/screens/completed/CompletedScreen.tsx b/app/(app)/create/screens/completed/CompletedScreen.tsx
index 74bc05f..cb96331 100644
--- a/app/(app)/create/screens/completed/CompletedScreen.tsx
+++ b/app/(app)/create/screens/completed/CompletedScreen.tsx
@@ -163,10 +163,10 @@ export function CompletedScreen() {
<>
(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({
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" ? (
- setCommunityContextModalOpen(false)}
- initialValue={rawCommunityContextForModal}
- onSave={(value) => {
- markCreateFlowInteraction();
- updateState({ communityContext: value, summary: value });
- }}
- />
+ <>
+ setTitleModalOpen(false)}
+ initialValue={rawTitleForModal}
+ onSave={(value) => {
+ markCreateFlowInteraction();
+ updateState({ title: value });
+ }}
+ />
+ setCommunityContextModalOpen(false)}
+ initialValue={rawCommunityContextForModal}
+ onSave={(value) => {
+ markCreateFlowInteraction();
+ updateState({ communityContext: value, summary: value });
+ }}
+ />
+ >
) : null}
);
diff --git a/app/(marketing)/use-cases/[slug]/page.tsx b/app/(marketing)/use-cases/[slug]/page.tsx
index dd94ce6..301dc52 100644
--- a/app/(marketing)/use-cases/[slug]/page.tsx
+++ b/app/(marketing)/use-cases/[slug]/page.tsx
@@ -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`,
}}
/>
+ {children}
+
+ );
+}
diff --git a/app/(marketing-case-study)/use-cases/[slug]/rule/_components/UseCaseCompletedRule.view.tsx b/app/(marketing-case-study)/use-cases/[slug]/rule/_components/UseCaseCompletedRule.view.tsx
new file mode 100644
index 0000000..393dcfa
--- /dev/null
+++ b/app/(marketing-case-study)/use-cases/[slug]/rule/_components/UseCaseCompletedRule.view.tsx
@@ -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(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.
+ */}
+
+ {actionBanner ? (
+
+
+
setActionBanner(null)}
+ className="w-full"
+ />
+
+
+ ) : null}
+
setShareModalOpen(false)}
+ onCopyLink={() => void copyPageLink()}
+ onEmailShare={mailtoPageLink}
+ onSignalShare={() => void copyPageLink()}
+ onSlackShare={() => void copyPageLink()}
+ onDiscordShare={() => void copyPageLink()}
+ />
+ setShareModalOpen(true)}
+ onDuplicate={() => void handleDuplicate()}
+ onExit={() => router.push(`/use-cases/${slug}`)}
+ />
+
+
+ >
+ );
+}
diff --git a/app/(marketing-case-study)/use-cases/[slug]/rule/_components/useUseCaseCompletedRuleActions.ts b/app/(marketing-case-study)/use-cases/[slug]/rule/_components/useUseCaseCompletedRuleActions.ts
new file mode 100644
index 0000000..0a208e0
--- /dev/null
+++ b/app/(marketing-case-study)/use-cases/[slug]/rule/_components/useUseCaseCompletedRuleActions.ts
@@ -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,
+ };
+}
diff --git a/app/(marketing-case-study)/use-cases/[slug]/rule/page.tsx b/app/(marketing-case-study)/use-cases/[slug]/rule/page.tsx
new file mode 100644
index 0000000..68adcf2
--- /dev/null
+++ b/app/(marketing-case-study)/use-cases/[slug]/rule/page.tsx
@@ -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 {
+ 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 (
+
+ );
+}
diff --git a/app/api/use-cases/[slug]/duplicate/route.ts b/app/api/use-cases/[slug]/duplicate/route.ts
new file mode 100644
index 0000000..d53cedd
--- /dev/null
+++ b/app/api/use-cases/[slug]/duplicate/route.ts
@@ -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(
+ "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,
+ },
+ });
+ },
+);
diff --git a/app/components/cards/CaseStudy/CaseStudy.view.tsx b/app/components/cards/CaseStudy/CaseStudy.view.tsx
index b05663c..929a27f 100644
--- a/app/components/cards/CaseStudy/CaseStudy.view.tsx
+++ b/app/components/cards/CaseStudy/CaseStudy.view.tsx
@@ -10,11 +10,11 @@ const SURFACE_CLASS: Record = {
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 = {
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}
/>
)}
diff --git a/app/components/cards/Rule/Rule.container.tsx b/app/components/cards/Rule/Rule.container.tsx
index 8cc6b8e..f367808 100644
--- a/app/components/cards/Rule/Rule.container.tsx
+++ b/app/components/cards/Rule/Rule.container.tsx
@@ -28,6 +28,8 @@ const RuleContainer = memo(
onDescriptionClick,
descriptionEmptyHint,
descriptionEditAriaLabel,
+ onTitleClick,
+ titleEditAriaLabel,
icon,
backgroundColor = "bg-[var(--color-community-teal-100)]",
className = "",
@@ -84,6 +86,8 @@ const RuleContainer = memo(
onDescriptionClick={onDescriptionClick}
descriptionEmptyHint={descriptionEmptyHint}
descriptionEditAriaLabel={descriptionEditAriaLabel}
+ onTitleClick={onTitleClick}
+ titleEditAriaLabel={titleEditAriaLabel}
icon={icon}
backgroundColor={backgroundColor}
className={className}
diff --git a/app/components/cards/Rule/Rule.types.ts b/app/components/cards/Rule/Rule.types.ts
index 4c052e5..27fecc2 100644
--- a/app/components/cards/Rule/Rule.types.ts
+++ b/app/components/cards/Rule/Rule.types.ts
@@ -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;
diff --git a/app/components/cards/Rule/Rule.view.tsx b/app/components/cards/Rule/Rule.view.tsx
index e199d6f..1982a06 100644
--- a/app/components/cards/Rule/Rule.view.tsx
+++ b/app/components/cards/Rule/Rule.view.tsx
@@ -14,6 +14,8 @@ export function RuleView({
onDescriptionClick,
descriptionEmptyHint,
descriptionEditAriaLabel,
+ onTitleClick,
+ titleEditAriaLabel,
icon,
backgroundColor,
className,
@@ -307,11 +309,27 @@ export function RuleView({
{t("recommendedLabel")}
) : null}
-
- {title}
-
+ {onTitleClick ? (
+ {
+ e.stopPropagation();
+ onTitleClick();
+ }}
+ >
+ {title}
+
+ ) : (
+
+ {title}
+
+ )}
)}
diff --git a/app/components/navigation/ConditionalNavigationClient.tsx b/app/components/navigation/ConditionalNavigationClient.tsx
index c1505c6..e020cdb 100644
--- a/app/components/navigation/ConditionalNavigationClient.tsx
+++ b/app/components/navigation/ConditionalNavigationClient.tsx
@@ -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;
}
diff --git a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.container.tsx b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.container.tsx
index 74d4f16..6f19796 100644
--- a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.container.tsx
+++ b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.container.tsx
@@ -16,17 +16,23 @@ const CreateFlowTopNavContainer = memo
(
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(
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")}
/>
);
},
diff --git a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.types.ts b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.types.ts
index 0bf91b0..aa15f25 100644
--- a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.types.ts
+++ b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.types.ts
@@ -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;
};
diff --git a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.view.tsx b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.view.tsx
index a78071f..aaffb45 100644
--- a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.view.tsx
+++ b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.view.tsx
@@ -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 (
+
+ );
+}
+
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(null);
+ const actionsWrapRef = useRef(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 && (
+
+ )}
+
+ {hasExport && onSelectExportFormat ? (
+
+
+ {exportMenuOpen ? (
+
+
+
+ ) : null}
+
+ ) : null}
+
+ {hasDuplicate && (
+
+ )}
+
+ {hasEdit && !hasDuplicate && (
+
+ )}
+
+ {hasManageStakeholders && onManageStakeholders ? (
+
+ ) : null}
+
+
+ >
+ );
+
return (
- {hasShare && (
-
- )}
-
- {hasExport && onSelectExportFormat ? (
-
+ {useKebabMenu ? (
+
- {exportMenuOpen ? (
+ {actionsMenuOpen ? (
-
) : null}
- ) : null}
-
- {hasEdit && (
-
- )}
-
- {hasManageStakeholders && onManageStakeholders ? (
+ ) : hasSecondaryActions ? (
+ inlineActions
+ ) : (
- ) : null}
-
-
+ )}
diff --git a/app/components/sections/AskOrganizer/AskOrganizer.container.tsx b/app/components/sections/AskOrganizer/AskOrganizer.container.tsx
index 0275357..7253973 100644
--- a/app/components/sections/AskOrganizer/AskOrganizer.container.tsx
+++ b/app/components/sections/AskOrganizer/AskOrganizer.container.tsx
@@ -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
(
({
title,
@@ -61,9 +61,9 @@ const AskOrganizerContainer = memo(
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"
diff --git a/app/components/sections/AskOrganizer/AskOrganizer.view.tsx b/app/components/sections/AskOrganizer/AskOrganizer.view.tsx
index 2f1e4cb..e86d526 100644
--- a/app/components/sections/AskOrganizer/AskOrganizer.view.tsx
+++ b/app/components/sections/AskOrganizer/AskOrganizer.view.tsx
@@ -38,7 +38,7 @@ function AskOrganizerView({
data-figma-node={isUseCaseDetail ? "22015-42624" : "18116-15960"}
>
{/* Content Lockup */}