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 ? ( +
+ + { + onSelectExportFormat("pdf"); + setExportMenuOpen(false); + }} + /> + { + onSelectExportFormat("csv"); + setExportMenuOpen(false); + }} + /> + { + onSelectExportFormat("markdown"); + setExportMenuOpen(false); + }} + /> + +
+ ) : null} +
+ ) : null} + + {hasDuplicate && ( + + )} + + {hasEdit && !hasDuplicate && ( + + )} + + {hasManageStakeholders && onManageStakeholders ? ( + + ) : null} + + + + ); + return (
- {hasShare && ( - - )} - - {hasExport && onSelectExportFormat ? ( -
+ {useKebabMenu ? ( +
- {exportMenuOpen ? ( + {actionsMenuOpen ? (
- - { - onSelectExportFormat("pdf"); - setExportMenuOpen(false); - }} - /> - { - onSelectExportFormat("csv"); - setExportMenuOpen(false); - }} - /> - { - onSelectExportFormat("markdown"); - setExportMenuOpen(false); - }} - /> + + {actionMenuItems.map((item, index) => ( + { + item.onClick(); + setActionsMenuOpen(false); + }} + /> + ))}
) : 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 */}