diff --git a/.cursor/rules/routes.mdc b/.cursor/rules/routes.mdc index 8ad772d..009f3ea 100644 --- a/.cursor/rules/routes.mdc +++ b/.cursor/rules/routes.mdc @@ -18,6 +18,7 @@ the file tree without affecting URLs. | `app/(app)/` | `/create/*`, `/login`, `/profile`, future signed-in surfaces | Authenticated product | `Top` (via root) — no footer except **`/profile`** (see `profile/layout.tsx`) | | `app/(admin)/` | `/monitor`, future ops dashboards | Operators | `Top` (via root) — no footer | | `app/(dev)/` | `/components-preview`, future dev previews | Local dev (NODE_ENV gated) | `Top` (via root) — no footer | +| `app/(marketing-case-study)/` | `/use-cases/[slug]/rule` | Public case-study demos | Chromeless (no global `Top`; see `navigationChromelessPath.ts`) | | `app/api/` | API routes | n/a | n/a | Route folders **must not** sit loose at the top level of `app/`. If a new diff --git a/.env.example b/.env.example index 513cd81..fc4265b 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,7 @@ SMTP_FROM="Community Rule " ORGANIZER_INQUIRY_TO= # Set to `true` to sync the create-flow draft with `/api/drafts/me` when the user is signed in. +# Server draft sync (default on). Set to `false` to disable PUT/GET /api/drafts/me. NEXT_PUBLIC_ENABLE_BACKEND_SYNC= # Web vitals API (CR-80): `external` = structured logs only, no writes under `.next` (default in production). diff --git a/AGENTS.md b/AGENTS.md index 7b68453..4583413 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,7 @@ Run these (in order) before declaring a change done: ```bash rm -rf .next # only if you moved/renamed routes or layouts npx tsc --noEmit # type check -npx vitest run # unit + component (101 files / ~700 tests) +npx vitest run # unit + component (~185 test files) npx next build # production build + route manifest ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 77ee7b2..1a7986b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,6 +46,18 @@ deployment-pipeline work. | GET | `/api/templates` | List curated templates. Optional repeatable `facet.=` query params re-rank results (and may include `scores` in the JSON). See [docs/guides/template-recommendation-matrix.md](docs/guides/template-recommendation-matrix.md) §9.1. | | GET | `/api/create-flow/methods` | Facet-aware scores for custom-rule card steps: required `section` (`communication` \| `membership` \| `decisionApproaches` \| `conflictManagement`) and optional `facet.*` params (same facet groups as `/api/templates`). Returns `methods` with match metadata for re-ordering in the wizard. | | POST / GET | `/api/web-vitals` | Ingest or read web vitals. **Production default:** `external` — structured logs only (no writes under `.next`; safe for read-only FS). **Development default:** `local` — aggregates under `.next/web-vitals`. Override with `WEB_VITALS_STORAGE`. See [docs/guides/backend-roadmap.md](docs/guides/backend-roadmap.md) §7. | +| GET | `/api/rules/me` | Authenticated list of own published rules. | +| GET / PATCH / DELETE | `/api/rules/[id]` | Public read; owner update/delete. | +| POST | `/api/rules/[id]/duplicate` | Owner clone of a published rule. | +| GET / POST | `/api/rules/[id]/stakeholders` | List or invite rule stakeholders. | +| DELETE | `/api/rules/[id]/stakeholders/[stakeholderId]` | Remove a stakeholder. | +| POST | `/api/rules/[id]/stakeholders/[stakeholderId]/resend` | Resend stakeholder invite email. | +| GET | `/api/invites/rule-stakeholder/verify` | Verify stakeholder invite token; redirect. | +| DELETE | `/api/user/me` | Delete authenticated user account. | +| POST | `/api/user/email-change/request` | Request email change (magic link to new address). | +| GET | `/api/user/email-change/verify` | Verify email-change token; update `User.email`. | +| POST | `/api/organizer-inquiry` | Submit ask-organizer inquiry form. | +| POST | `/api/use-cases/[slug]/duplicate` | Duplicate a use-case demo rule. | ### Magic-link sign-in @@ -58,10 +70,10 @@ deployment-pipeline work. ### Optional draft sync -`NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` enables Postgres draft persistence -via `PUT /api/drafts/me` for signed-in users and post-sign-in upload of -anonymous drafts. Without it, anonymous progress stays in `localStorage` -and signed-in progress stays in memory until **Save & Exit**. +Postgres draft persistence via `PUT /api/drafts/me` is **on by default** for +signed-in users and post-sign-in transfer of anonymous drafts. Set +`NEXT_PUBLIC_ENABLE_BACKEND_SYNC=false` to disable server sync (anonymous +progress stays in `localStorage` only). ### Create flow diff --git a/app/(admin)/monitor/_components/WebVitalsDashboard/WebVitalsDashboard.container.tsx b/app/(admin)/monitor/_components/WebVitalsDashboard/WebVitalsDashboard.container.tsx index f582d89..d67fa02 100644 --- a/app/(admin)/monitor/_components/WebVitalsDashboard/WebVitalsDashboard.container.tsx +++ b/app/(admin)/monitor/_components/WebVitalsDashboard/WebVitalsDashboard.container.tsx @@ -1,5 +1,9 @@ "use client"; +/** + * Figma: "WebVitalsDashboard" (see registry) + */ + import { memo, useEffect, useState } from "react"; import { useMessages } from "../../../../contexts/MessagesContext"; import { logger } from "../../../../../lib/logger"; diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index 587556f..627d4ac 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -4,6 +4,7 @@ import { Suspense, useCallback, useEffect, + useRef, useState, type ReactNode, } from "react"; @@ -80,6 +81,7 @@ import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer"; import { SignedInDraftHydration } from "./SignedInDraftHydration"; import { CreateFlowPendingAvatarFlush } from "./components/CreateFlowPendingAvatarFlush"; import Alert from "../../components/modals/Alert"; +import Create from "../../components/modals/Create"; import Share from "../../components/modals/Share"; import { CreateFlowDraftSaveBannerProvider, @@ -190,6 +192,26 @@ function CreateFlowLayoutContent({ description?: string; } | null>(null); const [shareModalOpen, setShareModalOpen] = useState(false); + const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false); + const leaveConfirmResolverRef = useRef<((proceed: boolean) => void) | null>( + null, + ); + + const confirmLeave = useCallback( + () => + new Promise((resolve) => { + leaveConfirmResolverRef.current = resolve; + setLeaveConfirmOpen(true); + }), + [], + ); + + const closeLeaveConfirm = useCallback((proceed: boolean) => { + setLeaveConfirmOpen(false); + const resolve = leaveConfirmResolverRef.current; + leaveConfirmResolverRef.current = null; + resolve?.(proceed); + }, []); const { copyPublishedRuleLink, @@ -256,6 +278,7 @@ function CreateFlowLayoutContent({ router, user: sessionUser ?? null, setDraftSaveBannerMessage, + confirmLeave, }); const handleExit = async (opts?: { saveDraft?: boolean }) => { @@ -601,6 +624,28 @@ function CreateFlowLayoutContent({ onSlackShare={() => void sharePublishedRuleViaSlack()} onDiscordShare={() => void sharePublishedRuleViaDiscord()} /> + closeLeaveConfirm(false)} + title={messages.create.topNav.leaveConfirmTitle} + description={messages.create.topNav.leaveConfirmDescription} + showBackButton={false} + showNextButton + nextButtonText={messages.create.topNav.leaveConfirmProceed} + onNext={() => closeLeaveConfirm(true)} + footerContent={ + + } + backdropVariant="blurredYellow" + ariaLabel={messages.create.topNav.leaveConfirmTitle} + /> + ); +} const CreateFlowLayoutClient = dynamic( () => import("./CreateFlowLayoutClient"), { ssr: false, - loading: () => ( -
- ), + loading: () => , }, ); diff --git a/app/(app)/create/PostLoginDraftTransfer.tsx b/app/(app)/create/PostLoginDraftTransfer.tsx index 6c43f7a..2d80360 100644 --- a/app/(app)/create/PostLoginDraftTransfer.tsx +++ b/app/(app)/create/PostLoginDraftTransfer.tsx @@ -15,7 +15,7 @@ import type { CreateFlowState } from "./types"; import messages from "../../../messages/en/index"; import Alert from "../../components/modals/Alert"; -const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true"; +import { isBackendSyncEnabled } from "../../../lib/create/backendSyncEnabled"; function buildPayloadWithStep( base: CreateFlowState, @@ -111,7 +111,7 @@ export function PostLoginDraftTransfer({ return; } - if (SYNC_ENABLED && createFlowStateHasKeys(local)) { + if (isBackendSyncEnabled() && createFlowStateHasKeys(local)) { const saveResult = await saveDraftToServer(payload); if (cancelled) return; diff --git a/app/(app)/create/SignedInDraftHydration.tsx b/app/(app)/create/SignedInDraftHydration.tsx index bed4ce3..d1554a0 100644 --- a/app/(app)/create/SignedInDraftHydration.tsx +++ b/app/(app)/create/SignedInDraftHydration.tsx @@ -17,7 +17,7 @@ import { parseCreateFlowScreenFromPathname, } from "./utils/flowSteps"; -const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true"; +import { isBackendSyncEnabled } from "../../../lib/create/backendSyncEnabled"; /** * When sync is on and the user is signed in, restore the server-side draft only @@ -54,7 +54,7 @@ export function SignedInDraftHydration({ const finishedUserIdRef = useRef(null); useEffect(() => { - if (!SYNC_ENABLED) return; + if (!isBackendSyncEnabled()) return; if (!sessionResolved) return; if (sessionUser == null || sessionUser === undefined) { finishedUserIdRef.current = null; diff --git a/app/(app)/create/components/CustomMethodCardFieldBlocksSummary.tsx b/app/(app)/create/components/CustomMethodCardFieldBlocksSummary.tsx deleted file mode 100644 index 3585f54..0000000 --- a/app/(app)/create/components/CustomMethodCardFieldBlocksSummary.tsx +++ /dev/null @@ -1,364 +0,0 @@ -"use client"; - -/** - * Controlled field blocks for wizard-authored method cards in Create modals - * (facet screens + final-review chip edit). When `onBlocksChange` is omitted, - * blocks render read-only (disabled controls). - * - * Layout matches preset method editors ({@link CommunicationMethodEditFields}, - * {@link DecisionApproachEditFields}): {@link ModalTextAreaField}, - * {@link ApplicableScopeField} chip rows, {@link IncrementerBlock}. - */ - -import { memo, useCallback, useRef, useState } from "react"; -import { useMessages, useTranslation } from "../../../contexts/MessagesContext"; -import Chip from "../../../components/controls/Chip"; -import IncrementerBlock from "../../../components/controls/IncrementerBlock"; -import Upload from "../../../components/controls/Upload"; -import { ASSETS, getAssetPath } from "../../../../lib/assetUtils"; -import ApplicableScopeField from "./ApplicableScopeField"; -import InputLabel from "../../../components/type/InputLabel"; -import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks"; -import ModalTextAreaField from "./ModalTextAreaField"; -import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer"; - -const TEXT_VALUE_MAX = 8000; - -export interface CustomMethodCardFieldBlocksSummaryProps { - blocks: CustomMethodCardFieldBlock[]; - /** When set, fields update the draft via immutable block-array replacements. */ - onBlocksChange?: (_next: CustomMethodCardFieldBlock[]) => void; -} - -function mapBlockById( - blocks: CustomMethodCardFieldBlock[], - blockId: string, - mapFn: (_b: CustomMethodCardFieldBlock) => CustomMethodCardFieldBlock, -): CustomMethodCardFieldBlock[] { - return blocks.map((b) => (b.id === blockId ? mapFn(b) : b)); -} - -function CustomMethodCardUploadBlockRow({ - block, - blocks, - patch, - uploadFileInputAriaLabel, - uploadHint, - clearPendingUploadAriaLabel, - clearPendingUploadTooltip, - uploadPreviewImageAlt, - noFileChosen, -}: { - block: Extract; - blocks: CustomMethodCardFieldBlock[]; - patch: (_next: CustomMethodCardFieldBlock[]) => void; - uploadFileInputAriaLabel: string; - uploadHint: string; - clearPendingUploadAriaLabel: string; - clearPendingUploadTooltip: string; - uploadPreviewImageAlt: string; - noFileChosen: string; -}) { - const uploadInputRef = useRef(null); - const tUpload = useTranslation("create.upload"); - const [busy, setBusy] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); - const displayName = block.fileName?.trim() ? block.fileName : noFileChosen; - const assetUrlTrimmed = block.assetUrl?.trim() ?? ""; - const hasAsset = assetUrlTrimmed.length > 0; - - const clearUpload = () => - patch( - mapBlockById(blocks, block.id, (b) => - b.kind === "upload" - ? { ...b, fileName: undefined, assetUrl: undefined } - : b, - ), - ); - - return ( -
- - {!hasAsset ? ( -

- {displayName} -

- ) : null} - { - const file = e.target.files?.[0]; - e.target.value = ""; - if (!file) return; - setErrorMessage(null); - setBusy(true); - void (async () => { - try { - const { url } = await uploadCreateFlowFile( - file, - "customMethodAttachment", - ); - const name = file.name?.trim(); - patch( - mapBlockById(blocks, block.id, (b) => - b.kind === "upload" - ? { - ...b, - ...(name ? { fileName: name } : {}), - assetUrl: url, - } - : b, - ), - ); - } catch { - setErrorMessage(tUpload("errors.generic")); - } finally { - setBusy(false); - } - })(); - }} - /> - {hasAsset ? ( -
- - {/* eslint-disable-next-line @next/next/no-img-element -- same-origin upload URL */} - {uploadPreviewImageAlt} -
- ) : ( - { - if (!busy) uploadInputRef.current?.click(); - }} - /> - )} - {errorMessage ? ( -

- {errorMessage} -

- ) : null} -
- ); -} - -function CustomMethodCardFieldBlocksSummaryComponent({ - blocks, - onBlocksChange, -}: CustomMethodCardFieldBlocksSummaryProps) { - const m = useMessages(); - const wiz = m.create.customRule.customMethodCardWizard; - const fm = wiz.fieldModals; - const em = wiz.editModal; - const emptyValue = em.readout.emptyValue; - const noFileChosen = em.readout.noFileChosen; - const readOnly = !onBlocksChange; - - const patch = useCallback( - (next: CustomMethodCardFieldBlock[]) => { - onBlocksChange?.(next); - }, - [onBlocksChange], - ); - - return ( -
- {blocks.map((block) => { - if (block.kind === "text") { - return ( - - patch( - mapBlockById(blocks, block.id, (b) => - b.kind === "text" - ? { ...b, placeholderText: v.slice(0, TEXT_VALUE_MAX) } - : b, - ), - ) - } - disabled={readOnly} - /> - ); - } - - if (block.kind === "badges") { - if (readOnly) { - return ( -
- - {block.options.length > 0 ? ( -
- {block.options.map((opt, idx) => ( - - ))} -
- ) : ( -

- {emptyValue} -

- )} -
- ); - } - return ( - - patch( - mapBlockById(blocks, block.id, (b) => - b.kind === "badges" - ? { ...b, options: b.options.filter((o) => o !== scope) } - : b, - ), - ) - } - onAddScope={(scope) => - patch( - mapBlockById(blocks, block.id, (b) => { - if (b.kind !== "badges") return b; - if (b.options.includes(scope) || b.options.length >= 50) - return b; - return { ...b, options: [...b.options, scope] }; - }), - ) - } - /> - ); - } - - if (block.kind === "upload") { - return ( -
- {readOnly ? ( -
- - {block.assetUrl?.trim() ? ( - // eslint-disable-next-line @next/next/no-img-element - { - ) : ( -

- {noFileChosen} -

- )} -
- ) : ( - - )} -
- ); - } - - return ( - - patch( - mapBlockById(blocks, block.id, (b) => - b.kind === "proportion" ? { ...b, defaultPercent: v } : b, - ), - ) - } - formatValue={(v) => `${v}%`} - decrementAriaLabel={fm.proportion.decrementAriaLabel} - incrementAriaLabel={fm.proportion.incrementAriaLabel} - /> - ); - })} -
- ); -} - -const CustomMethodCardFieldBlocksSummary = memo( - CustomMethodCardFieldBlocksSummaryComponent, -); -CustomMethodCardFieldBlocksSummary.displayName = - "CustomMethodCardFieldBlocksSummary"; - -export default CustomMethodCardFieldBlocksSummary; diff --git a/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/CustomMethodCardFieldBlocksSummary.container.tsx b/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/CustomMethodCardFieldBlocksSummary.container.tsx new file mode 100644 index 0000000..d5041ed --- /dev/null +++ b/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/CustomMethodCardFieldBlocksSummary.container.tsx @@ -0,0 +1,66 @@ +"use client"; + +/** + * Controlled field blocks for wizard-authored method cards in Create modals + * (facet screens + final-review chip edit). When `onBlocksChange` is omitted, + * blocks render read-only (disabled controls). + * + * Layout matches preset method editors ({@link CommunicationMethodEditFields}, + * {@link DecisionApproachEditFields}): {@link ModalTextAreaField}, + * {@link ApplicableScopeField} chip rows, {@link IncrementerBlock}. + */ + +import { memo, useCallback } from "react"; +import { useMessages } from "../../../../contexts/MessagesContext"; +import { CustomMethodCardFieldBlocksSummaryView } from "./CustomMethodCardFieldBlocksSummary.view"; +import type { CustomMethodCardFieldBlocksSummaryProps } from "./CustomMethodCardFieldBlocksSummary.types"; + +function CustomMethodCardFieldBlocksSummaryContainerComponent({ + blocks, + onBlocksChange, +}: CustomMethodCardFieldBlocksSummaryProps) { + const m = useMessages(); + const wiz = m.create.customRule.customMethodCardWizard; + const fm = wiz.fieldModals; + const em = wiz.editModal; + const readOnly = !onBlocksChange; + + const onPatch = useCallback( + (next: Parameters>[0]) => { + onBlocksChange?.(next); + }, + [onBlocksChange], + ); + + return ( + + ); +} + +const CustomMethodCardFieldBlocksSummary = memo( + CustomMethodCardFieldBlocksSummaryContainerComponent, +); +CustomMethodCardFieldBlocksSummary.displayName = + "CustomMethodCardFieldBlocksSummary"; + +export default CustomMethodCardFieldBlocksSummary; diff --git a/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/CustomMethodCardFieldBlocksSummary.types.ts b/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/CustomMethodCardFieldBlocksSummary.types.ts new file mode 100644 index 0000000..2d8879b --- /dev/null +++ b/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/CustomMethodCardFieldBlocksSummary.types.ts @@ -0,0 +1,55 @@ +import type { ChangeEventHandler, RefObject } from "react"; +import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; + +export interface CustomMethodCardFieldBlocksSummaryProps { + blocks: CustomMethodCardFieldBlock[]; + /** When set, fields update the draft via immutable block-array replacements. */ + onBlocksChange?: (_next: CustomMethodCardFieldBlock[]) => void; +} + +export type CustomMethodCardFieldBlocksSummaryFieldModalsCopy = { + badges: { addOptionLabel: string }; + upload: { + uploadFileInputAriaLabel: string; + uploadHint: string; + clearPendingUploadAriaLabel: string; + clearPendingUploadTooltip: string; + uploadPreviewImageAlt: string; + }; + proportion: { + decrementAriaLabel: string; + incrementAriaLabel: string; + }; +}; + +export interface CustomMethodCardFieldBlocksSummaryViewProps { + blocks: CustomMethodCardFieldBlock[]; + readOnly: boolean; + emptyValue: string; + noFileChosen: string; + fieldModalsCopy: CustomMethodCardFieldBlocksSummaryFieldModalsCopy; + onPatch: (_next: CustomMethodCardFieldBlock[]) => void; +} + +export type CustomMethodCardUploadBlockRowProps = { + block: Extract; + blocks: CustomMethodCardFieldBlock[]; + onPatch: (_next: CustomMethodCardFieldBlock[]) => void; + uploadFileInputAriaLabel: string; + uploadHint: string; + clearPendingUploadAriaLabel: string; + clearPendingUploadTooltip: string; + uploadPreviewImageAlt: string; + noFileChosen: string; +}; + +export type CustomMethodCardUploadBlockRowViewProps = + CustomMethodCardUploadBlockRowProps & { + uploadInputRef: RefObject; + busy: boolean; + uploadingHint: string; + errorMessage: string | null; + onClearUpload: () => void; + onFileInputChange: ChangeEventHandler; + onUploadClick: () => void; + }; diff --git a/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/CustomMethodCardFieldBlocksSummary.view.tsx b/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/CustomMethodCardFieldBlocksSummary.view.tsx new file mode 100644 index 0000000..bd9e7be --- /dev/null +++ b/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/CustomMethodCardFieldBlocksSummary.view.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { memo } from "react"; +import Chip from "../../../../components/controls/Chip"; +import IncrementerBlock from "../../../../components/controls/IncrementerBlock"; +import InputLabel from "../../../../components/type/InputLabel"; +import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; +import ApplicableScopeField from "../ApplicableScopeField"; +import ModalTextAreaField from "../ModalTextAreaField"; +import { CustomMethodCardUploadBlockRow } from "./CustomMethodCardUploadBlockRow.container"; +import type { CustomMethodCardFieldBlocksSummaryViewProps } from "./CustomMethodCardFieldBlocksSummary.types"; + +const TEXT_VALUE_MAX = 8000; + +function mapBlockById( + blocks: CustomMethodCardFieldBlock[], + blockId: string, + mapFn: (_b: CustomMethodCardFieldBlock) => CustomMethodCardFieldBlock, +): CustomMethodCardFieldBlock[] { + return blocks.map((b) => (b.id === blockId ? mapFn(b) : b)); +} + +function CustomMethodCardFieldBlocksSummaryViewComponent({ + blocks, + readOnly, + emptyValue, + noFileChosen, + fieldModalsCopy, + onPatch, +}: CustomMethodCardFieldBlocksSummaryViewProps) { + const fm = fieldModalsCopy; + + return ( +
+ {blocks.map((block) => { + if (block.kind === "text") { + return ( + + onPatch( + mapBlockById(blocks, block.id, (b) => + b.kind === "text" + ? { ...b, placeholderText: v.slice(0, TEXT_VALUE_MAX) } + : b, + ), + ) + } + disabled={readOnly} + /> + ); + } + + if (block.kind === "badges") { + if (readOnly) { + return ( +
+ + {block.options.length > 0 ? ( +
+ {block.options.map((opt, idx) => ( + + ))} +
+ ) : ( +

+ {emptyValue} +

+ )} +
+ ); + } + return ( + + onPatch( + mapBlockById(blocks, block.id, (b) => + b.kind === "badges" + ? { ...b, options: b.options.filter((o) => o !== scope) } + : b, + ), + ) + } + onAddScope={(scope) => + onPatch( + mapBlockById(blocks, block.id, (b) => { + if (b.kind !== "badges") return b; + if (b.options.includes(scope) || b.options.length >= 50) + return b; + return { ...b, options: [...b.options, scope] }; + }), + ) + } + /> + ); + } + + if (block.kind === "upload") { + return ( +
+ {readOnly ? ( +
+ + {block.assetUrl?.trim() ? ( + // eslint-disable-next-line @next/next/no-img-element + { + ) : ( +

+ {noFileChosen} +

+ )} +
+ ) : ( + + )} +
+ ); + } + + return ( + + onPatch( + mapBlockById(blocks, block.id, (b) => + b.kind === "proportion" ? { ...b, defaultPercent: v } : b, + ), + ) + } + formatValue={(v) => `${v}%`} + decrementAriaLabel={fm.proportion.decrementAriaLabel} + incrementAriaLabel={fm.proportion.incrementAriaLabel} + /> + ); + })} +
+ ); +} + +export const CustomMethodCardFieldBlocksSummaryView = memo( + CustomMethodCardFieldBlocksSummaryViewComponent, +); +CustomMethodCardFieldBlocksSummaryView.displayName = + "CustomMethodCardFieldBlocksSummaryView"; diff --git a/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/CustomMethodCardUploadBlockRow.container.tsx b/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/CustomMethodCardUploadBlockRow.container.tsx new file mode 100644 index 0000000..3f18af1 --- /dev/null +++ b/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/CustomMethodCardUploadBlockRow.container.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { memo, useCallback, useRef, useState } from "react"; +import { useTranslation } from "../../../../contexts/MessagesContext"; +import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; +import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; +import { CustomMethodCardUploadBlockRowView } from "./CustomMethodCardUploadBlockRow.view"; +import type { CustomMethodCardUploadBlockRowProps } from "./CustomMethodCardFieldBlocksSummary.types"; + +function mapBlockById( + blocks: CustomMethodCardFieldBlock[], + blockId: string, + mapFn: (_b: CustomMethodCardFieldBlock) => CustomMethodCardFieldBlock, +): CustomMethodCardFieldBlock[] { + return blocks.map((b) => (b.id === blockId ? mapFn(b) : b)); +} + +function CustomMethodCardUploadBlockRowContainerComponent({ + block, + blocks, + onPatch, + uploadFileInputAriaLabel, + uploadHint, + clearPendingUploadAriaLabel, + clearPendingUploadTooltip, + uploadPreviewImageAlt, + noFileChosen, +}: CustomMethodCardUploadBlockRowProps) { + const uploadInputRef = useRef(null); + const tUpload = useTranslation("create.upload"); + const [busy, setBusy] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const clearUpload = useCallback(() => { + onPatch( + mapBlockById(blocks, block.id, (b) => + b.kind === "upload" + ? { ...b, fileName: undefined, assetUrl: undefined } + : b, + ), + ); + }, [block.id, blocks, onPatch]); + + const handleFileInputChange = useCallback< + React.ChangeEventHandler + >( + (e) => { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + setErrorMessage(null); + setBusy(true); + void (async () => { + try { + const { url } = await uploadCreateFlowFile( + file, + "customMethodAttachment", + ); + const name = file.name?.trim(); + onPatch( + mapBlockById(blocks, block.id, (b) => + b.kind === "upload" + ? { + ...b, + ...(name ? { fileName: name } : {}), + assetUrl: url, + } + : b, + ), + ); + } catch { + setErrorMessage(tUpload("errors.generic")); + } finally { + setBusy(false); + } + })(); + }, + [block.id, blocks, onPatch, tUpload], + ); + + const handleUploadClick = useCallback(() => { + if (!busy) uploadInputRef.current?.click(); + }, [busy]); + + return ( + + ); +} + +export const CustomMethodCardUploadBlockRow = memo( + CustomMethodCardUploadBlockRowContainerComponent, +); +CustomMethodCardUploadBlockRow.displayName = "CustomMethodCardUploadBlockRow"; diff --git a/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/CustomMethodCardUploadBlockRow.view.tsx b/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/CustomMethodCardUploadBlockRow.view.tsx new file mode 100644 index 0000000..a7ea575 --- /dev/null +++ b/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/CustomMethodCardUploadBlockRow.view.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { memo } from "react"; +import Upload from "../../../../components/controls/Upload"; +import InputLabel from "../../../../components/type/InputLabel"; +import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils"; +import type { CustomMethodCardUploadBlockRowViewProps } from "./CustomMethodCardFieldBlocksSummary.types"; + +function CustomMethodCardUploadBlockRowViewComponent({ + block, + uploadFileInputAriaLabel, + uploadHint, + clearPendingUploadAriaLabel, + clearPendingUploadTooltip, + uploadPreviewImageAlt, + noFileChosen, + uploadInputRef, + busy, + uploadingHint, + errorMessage, + onClearUpload, + onFileInputChange, + onUploadClick, +}: CustomMethodCardUploadBlockRowViewProps) { + const displayName = block.fileName?.trim() ? block.fileName : noFileChosen; + const assetUrlTrimmed = block.assetUrl?.trim() ?? ""; + const hasAsset = assetUrlTrimmed.length > 0; + + return ( +
+ + {!hasAsset ? ( +

+ {displayName} +

+ ) : null} + + {hasAsset ? ( +
+ + {/* eslint-disable-next-line @next/next/no-img-element -- same-origin upload URL */} + {uploadPreviewImageAlt} +
+ ) : ( + + )} + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} +
+ ); +} + +export const CustomMethodCardUploadBlockRowView = memo( + CustomMethodCardUploadBlockRowViewComponent, +); +CustomMethodCardUploadBlockRowView.displayName = + "CustomMethodCardUploadBlockRowView"; diff --git a/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/index.tsx b/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/index.tsx new file mode 100644 index 0000000..eabfb7e --- /dev/null +++ b/app/(app)/create/components/CustomMethodCardFieldBlocksSummary/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./CustomMethodCardFieldBlocksSummary.container"; +export type { CustomMethodCardFieldBlocksSummaryProps } from "./CustomMethodCardFieldBlocksSummary.types"; diff --git a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.view.tsx b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.view.tsx index 237a3b7..e4616ce 100644 --- a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.view.tsx +++ b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.view.tsx @@ -6,7 +6,7 @@ import InputWithCounter from "../../../../components/controls/InputWithCounter"; import TextArea from "../../../../components/controls/TextArea"; import AddCustomField from "../../../../components/controls/AddCustomField"; import { CustomMethodCardWizardFieldBodiesView } from "./CustomMethodCardWizardFieldBodies.view"; -import { CustomMethodCardWizardBlocksListView } from "./CustomMethodCardWizardBlocksList.view"; +import { CustomMethodCardWizardBlocksList } from "./CustomMethodCardWizardBlocksList.container"; import type { CustomMethodCardWizardViewProps } from "./CustomMethodCardWizard.types"; function CustomMethodCardWizardViewComponent({ @@ -90,7 +90,7 @@ function CustomMethodCardWizardViewComponent({ {!fieldTypeModal && wizardStep === 3 ? (
{draftFieldBlocks.length > 0 ? ( - (null); + const [overIndex, setOverIndex] = useState(null); + + const clearDragUi = useCallback(() => { + setDraggingIndex(null); + setOverIndex(null); + }, []); + + const handleDragStart = useCallback( + (index: number) => (e: DragEvent) => { + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(index)); + setDraggingIndex(index); + }, + [], + ); + + const handleDragOver = useCallback((index: number) => { + return (e: DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setOverIndex(index); + }; + }, []); + + const handleDrop = useCallback( + (index: number) => (e: DragEvent) => { + e.preventDefault(); + const from = Number.parseInt(e.dataTransfer.getData("text/plain"), 10); + if (Number.isNaN(from)) { + clearDragUi(); + return; + } + onBlocksReorder( + reorderCustomMethodCardFieldBlocks(blocks, from, index), + ); + clearDragUi(); + }, + [blocks, clearDragUi, onBlocksReorder], + ); + + return ( + + ); +} + +export const CustomMethodCardWizardBlocksList = memo( + CustomMethodCardWizardBlocksListContainerComponent, +); +CustomMethodCardWizardBlocksList.displayName = + "CustomMethodCardWizardBlocksList"; diff --git a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardBlocksList.types.ts b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardBlocksList.types.ts new file mode 100644 index 0000000..8714348 --- /dev/null +++ b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardBlocksList.types.ts @@ -0,0 +1,21 @@ +import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types"; +import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; +import type { DragEvent } from "react"; + +export interface CustomMethodCardWizardBlocksListProps { + blocks: CustomMethodCardFieldBlock[]; + fieldTypeLabels: Record; + dragHandleAriaLabel: string; + listLabel: string; + onBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void; +} + +export interface CustomMethodCardWizardBlocksListViewProps + extends CustomMethodCardWizardBlocksListProps { + draggingIndex: number | null; + overIndex: number | null; + onDragStart: (_index: number) => (_e: DragEvent) => void; + onDragOver: (_index: number) => (_e: DragEvent) => void; + onDrop: (_index: number) => (_e: DragEvent) => void; + onDragEnd: () => void; +} diff --git a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardBlocksList.view.tsx b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardBlocksList.view.tsx index f27eebb..7b3f279 100644 --- a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardBlocksList.view.tsx +++ b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardBlocksList.view.tsx @@ -1,11 +1,10 @@ "use client"; -import { memo, useCallback, useState, type DragEvent } from "react"; +import { memo } from "react"; import Icon from "../../../../components/asset/icon"; import { ADD_CUSTOM_FIELD_TYPE_ICONS } from "../../../../components/controls/AddCustomField/AddCustomField.types"; import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types"; -import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; -import { reorderCustomMethodCardFieldBlocks } from "../../../../../lib/create/reorderCustomMethodCardFieldBlocks"; +import type { CustomMethodCardWizardBlocksListViewProps } from "./CustomMethodCardWizardBlocksList.types"; function DragHandleGlyph({ className }: { className?: string }) { return ( @@ -28,62 +27,18 @@ function DragHandleGlyph({ className }: { className?: string }) { ); } -export interface CustomMethodCardWizardBlocksListViewProps { - blocks: CustomMethodCardFieldBlock[]; - fieldTypeLabels: Record; - dragHandleAriaLabel: string; - listLabel: string; - onBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void; -} - function CustomMethodCardWizardBlocksListViewComponent({ blocks, fieldTypeLabels, dragHandleAriaLabel, listLabel, - onBlocksReorder, + draggingIndex, + overIndex, + onDragStart, + onDragOver, + onDrop, + onDragEnd, }: CustomMethodCardWizardBlocksListViewProps) { - const [draggingIndex, setDraggingIndex] = useState(null); - const [overIndex, setOverIndex] = useState(null); - - const clearDragUi = useCallback(() => { - setDraggingIndex(null); - setOverIndex(null); - }, []); - - const handleDragStart = useCallback( - (index: number) => (e: DragEvent) => { - e.dataTransfer.effectAllowed = "move"; - e.dataTransfer.setData("text/plain", String(index)); - setDraggingIndex(index); - }, - [], - ); - - const handleDragOver = useCallback((index: number) => { - return (e: DragEvent) => { - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - setOverIndex(index); - }; - }, []); - - const handleDrop = useCallback( - (index: number) => (e: DragEvent) => { - e.preventDefault(); - const from = Number.parseInt(e.dataTransfer.getData("text/plain"), 10); - if (Number.isNaN(from)) { - clearDragUi(); - return; - } - onBlocksReorder( - reorderCustomMethodCardFieldBlocks(blocks, from, index), - ); - clearDragUi(); - }, - [blocks, clearDragUi, onBlocksReorder], - ); - return (
    {blocks.map((block, index) => { @@ -98,14 +53,14 @@ function CustomMethodCardWizardBlocksListViewComponent({ ? "ring-2 ring-[var(--color-border-invert-primary)] ring-offset-2 ring-offset-[var(--color-surface-default-primary)]" : "" } ${draggingIndex === index ? "opacity-60" : ""}`} - onDragOver={handleDragOver(index)} - onDrop={handleDrop(index)} + onDragOver={onDragOver(index)} + onDrop={onDrop(index)} >
@@ -77,24 +58,24 @@ export function UseCaseCompletedRuleView({ ) : null} setShareModalOpen(false)} - onCopyLink={() => void copyPageLink()} - onEmailShare={mailtoPageLink} - onSignalShare={() => void copyPageLink()} - onSlackShare={() => void copyPageLink()} - onDiscordShare={() => void copyPageLink()} + onClose={onShareClose} + onCopyLink={onCopyLink} + onEmailShare={onEmailShare} + onSignalShare={onCopyLink} + onSlackShare={onCopyLink} + onDiscordShare={onCopyLink} /> setShareModalOpen(true)} - onDuplicate={() => void handleDuplicate()} - onExit={() => router.push(`/use-cases/${slug}`)} + onShare={onShareOpen} + onDuplicate={onDuplicate} + onExit={onExit} />
; @@ -57,7 +57,7 @@ export default async function UseCaseCompletedRulePage({ params }: PageProps) { } return ( - (SECTION_IDS); * * See `docs/guides/template-recommendation-matrix.md` §9.2 / §10. */ -export async function GET(request: NextRequest) { - if (!isDatabaseConfigured()) { - return dbUnavailable(); - } +export const GET = apiRoute( + "createFlow.methods.get", + async (request: NextRequest) => { + if (!isDatabaseConfigured()) { + return dbUnavailable(); + } - const sectionParam = request.nextUrl.searchParams.get("section"); - if (!sectionParam || !SECTION_SET.has(sectionParam)) { - return NextResponse.json( - { - error: { - code: "validation_error", - message: `Unknown section. Expected one of: ${SECTION_IDS.join(", ")}`, - }, - }, - { status: 400 }, + const sectionParam = request.nextUrl.searchParams.get("section"); + if (!sectionParam || !SECTION_SET.has(sectionParam)) { + return errorJson( + "validation_error", + `Unknown section. Expected one of: ${SECTION_IDS.join(", ")}`, + 400, + ); + } + const section = sectionParam as SectionId; + + const facets = parseRequestedFacetsFromSearchParams( + request.nextUrl.searchParams, ); - } - const section = sectionParam as SectionId; + const result = await listMethodRecommendations({ section, facets }); + if (!result) { + // DB query failed; return empty so the wizard falls back to its messages + // deck in authoring order (§10). + return NextResponse.json({ section, methods: [] }); + } - const facets = parseRequestedFacetsFromSearchParams( - request.nextUrl.searchParams, - ); - const result = await listMethodRecommendations({ section, facets }); - if (!result) { - // DB query failed; return empty so the wizard falls back to its messages - // deck in authoring order (§10). - return NextResponse.json({ section, methods: [] }); - } - - const methods = result.rankedSlugs.map((slug) => ({ - slug, - matches: result.matchesBySlug[slug] ?? { score: 0, matchedFacets: [] }, - })); - return NextResponse.json({ section, methods }); -} + const methods = result.rankedSlugs.map((slug) => ({ + slug, + matches: result.matchesBySlug[slug] ?? { score: 0, matchedFacets: [] }, + })); + return NextResponse.json({ section, methods }); + }, +); diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 60c4582..719d225 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,8 +1,9 @@ import { NextResponse } from "next/server"; import { prisma } from "../../../lib/server/db"; import { isDatabaseConfigured } from "../../../lib/server/env"; +import { apiRoute } from "../../../lib/server/apiRoute"; -export async function GET() { +export const GET = apiRoute("health.get", async () => { if (!isDatabaseConfigured()) { return NextResponse.json({ ok: true, @@ -16,4 +17,4 @@ export async function GET() { } catch { return NextResponse.json({ ok: false, database: "error" }, { status: 503 }); } -} +}); diff --git a/app/api/templates/route.ts b/app/api/templates/route.ts index 403d588..1128612 100644 --- a/app/api/templates/route.ts +++ b/app/api/templates/route.ts @@ -3,6 +3,7 @@ import { isDatabaseConfigured } from "../../../lib/server/env"; import { listRankedRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates"; import { dbUnavailable } from "../../../lib/server/responses"; import { parseRequestedFacetsFromSearchParams } from "../../../lib/server/validation/methodFacetsSchemas"; +import { apiRoute } from "../../../lib/server/apiRoute"; /** * GET /api/templates @@ -15,7 +16,7 @@ import { parseRequestedFacetsFromSearchParams } from "../../../lib/server/valida * * See `docs/guides/template-recommendation-matrix.md` §9.1. */ -export async function GET(request: NextRequest) { +export const GET = apiRoute("templates.get", async (request: NextRequest) => { if (!isDatabaseConfigured()) { return dbUnavailable(); } @@ -29,4 +30,4 @@ export async function GET(request: NextRequest) { return NextResponse.json( hasScores ? { templates, scores } : { templates }, ); -} +}); diff --git a/app/api/web-vitals/route.ts b/app/api/web-vitals/route.ts index ea2b1df..8bf77f8 100644 --- a/app/api/web-vitals/route.ts +++ b/app/api/web-vitals/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { logger } from "../../../lib/logger"; +import { apiRoute } from "../../../lib/server/apiRoute"; import { getWebVitalsStorageMode } from "../../../lib/server/webVitals/mode"; import { appendLocalWebVital, @@ -29,70 +30,54 @@ function logExternalIngest(body: WebVitalData): void { logger.info(line); } -export async function POST(request: NextRequest) { - try { - const limited = await readLimitedJson(request); - if (limited.ok === false) { - return limited.response; - } - - const parsed = webVitalIngestSchema.safeParse(limited.value); - if (!parsed.success) return jsonFromZodError(parsed.error); - - const body = parsed.data; - - const vitalsData: WebVitalData = { - metric: body.metric, - data: { - value: body.data.value, - rating: body.data.rating, - }, - url: body.url, - userAgent: body.userAgent, - timestamp: normalizeTimestamp(body.timestamp), - receivedAt: new Date().toISOString(), - }; - - const mode = getWebVitalsStorageMode(); - - if (mode === "external") { - logExternalIngest(vitalsData); - return NextResponse.json({ success: true, storage: "external" }); - } - - appendLocalWebVital(vitalsData); - logger.info( - `Web Vital received: ${body.metric} = ${body.data.value}ms (${body.data.rating})`, - ); - - return NextResponse.json({ success: true, storage: "local" }); - } catch (error) { - logger.error("Error processing web vital:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 }, - ); +export const POST = apiRoute("webVitals.post", async (request: NextRequest) => { + const limited = await readLimitedJson(request); + if (limited.ok === false) { + return limited.response; } -} -export async function GET() { - try { - const mode = getWebVitalsStorageMode(); + const parsed = webVitalIngestSchema.safeParse(limited.value); + if (!parsed.success) return jsonFromZodError(parsed.error); - if (mode === "external") { - return NextResponse.json({ - metrics: {}, - storage: "external" as const, - }); - } + const body = parsed.data; - const metrics = readLocalAggregatedMetrics(); - return NextResponse.json({ metrics, storage: "local" as const }); - } catch (error) { - logger.error("Error fetching web vitals:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 }, - ); + const vitalsData: WebVitalData = { + metric: body.metric, + data: { + value: body.data.value, + rating: body.data.rating, + }, + url: body.url, + userAgent: body.userAgent, + timestamp: normalizeTimestamp(body.timestamp), + receivedAt: new Date().toISOString(), + }; + + const mode = getWebVitalsStorageMode(); + + if (mode === "external") { + logExternalIngest(vitalsData); + return NextResponse.json({ success: true, storage: "external" }); } -} + + appendLocalWebVital(vitalsData); + logger.info( + `Web Vital received: ${body.metric} = ${body.data.value}ms (${body.data.rating})`, + ); + + return NextResponse.json({ success: true, storage: "local" }); +}); + +export const GET = apiRoute("webVitals.get", async () => { + const mode = getWebVitalsStorageMode(); + + if (mode === "external") { + return NextResponse.json({ + metrics: {}, + storage: "external" as const, + }); + } + + const metrics = readLocalAggregatedMetrics(); + return NextResponse.json({ metrics, storage: "local" as const }); +}); diff --git a/app/components/asset/logo/Logo.tsx b/app/components/asset/logo/Logo.tsx index 471d6b6..4fbe5e7 100644 --- a/app/components/asset/logo/Logo.tsx +++ b/app/components/asset/logo/Logo.tsx @@ -1,5 +1,8 @@ +"use client"; + import { memo } from "react"; import Link from "next/link"; +import { useTranslation } from "../../../contexts/MessagesContext"; import { getAssetPath, ASSETS } from "../../../../lib/assetUtils"; interface LogoProps { @@ -31,6 +34,8 @@ interface SizeConfig { const Logo = memo( ({ size = "default", palette = "default", wordmark = true }) => { + const t = useTranslation("controlsChrome"); + // Size configurations const sizes: Record = { default: { @@ -97,7 +102,7 @@ const Logo = memo( : "hidden"; return ( - +
( {/* Logo Text - responsive visibility for topNav sizes */}
- CommunityRule + {t("logoText")}
{/* Vector Icon */} {/* eslint-disable-next-line @next/next/no-img-element */} CommunityRule Logo Icon( onToggleExpand: controlledOnToggleExpand, hasMore = true, toggleLabel = DEFAULT_TOGGLE_LABEL, - showLessLabel = DEFAULT_SHOW_LESS_LABEL, + showLessLabel, title = "", description = "", layout = "default", @@ -37,6 +37,7 @@ const CardStackContainer = memo( addCardAriaLabel = "", onAddCard, }) => { + const t = useTranslation("controlsChrome"); const [internalExpanded, setInternalExpanded] = useState(false); const [internalSelectedIds, setInternalSelectedIds] = useState( [], @@ -84,7 +85,7 @@ const CardStackContainer = memo( onToggleExpand={handleToggleExpand} hasMore={hasMore} toggleLabel={toggleLabel} - showLessLabel={showLessLabel} + showLessLabel={showLessLabel ?? t("cardStackShowLess")} title={title} description={description} layout={layout} diff --git a/app/components/cards/Icon/Icon.container.tsx b/app/components/cards/Icon/Icon.container.tsx index 0ae8718..9126218 100644 --- a/app/components/cards/Icon/Icon.container.tsx +++ b/app/components/cards/Icon/Icon.container.tsx @@ -1,5 +1,9 @@ "use client"; +/** + * Figma: "Card / Icon" (see registry) + */ + import { memo, useId } from "react"; import { IconView } from "./Icon.view"; import type { IconProps } from "./Icon.types"; diff --git a/app/components/cards/Mini/Mini.container.tsx b/app/components/cards/Mini/Mini.container.tsx index 84b421e..7087b44 100644 --- a/app/components/cards/Mini/Mini.container.tsx +++ b/app/components/cards/Mini/Mini.container.tsx @@ -1,6 +1,11 @@ "use client"; +/** + * Figma: "Card / Mini" (see registry) + */ + import { memo, useMemo } from "react"; +import { useTranslation } from "../../../contexts/MessagesContext"; import MiniView from "./Mini.view"; import type { MiniProps } from "./Mini.types"; @@ -17,14 +22,16 @@ const MiniContainer = memo( href, ariaLabel, }) => { + const t = useTranslation("controlsChrome"); + // Compute aria-label const computedAriaLabel = useMemo( () => ariaLabel || (labelLine1 && labelLine2 ? `${labelLine1} ${labelLine2}` - : label || "Feature card"), - [ariaLabel, labelLine1, labelLine2, label], + : label || t("miniFeatureFallback")), + [ariaLabel, labelLine1, labelLine2, label, t], ); // Determine wrapper element and props diff --git a/app/components/cards/Stat/Stat.container.tsx b/app/components/cards/Stat/Stat.container.tsx index 67e5dda..12584bf 100644 --- a/app/components/cards/Stat/Stat.container.tsx +++ b/app/components/cards/Stat/Stat.container.tsx @@ -1,5 +1,9 @@ "use client"; +/** + * Figma: "Card / Stat" (21598-18215) + */ + import { memo } from "react"; import StatView from "./Stat.view"; import type { StatProps } from "./Stat.types"; diff --git a/app/components/content/ContentContainer/ContentContainer.view.tsx b/app/components/content/ContentContainer/ContentContainer.view.tsx index f837400..94a9c98 100644 --- a/app/components/content/ContentContainer/ContentContainer.view.tsx +++ b/app/components/content/ContentContainer/ContentContainer.view.tsx @@ -20,7 +20,7 @@ function ContentContainerView({ return (
{/* Content Container - gap between icon and text */}
diff --git a/app/components/controls/Chip/Chip.container.tsx b/app/components/controls/Chip/Chip.container.tsx index cfdbfb6..c126e61 100644 --- a/app/components/controls/Chip/Chip.container.tsx +++ b/app/components/controls/Chip/Chip.container.tsx @@ -1,6 +1,7 @@ "use client"; import { memo, useState, useEffect, useRef } from "react"; +import { useTranslation } from "../../../contexts/MessagesContext"; import ChipView from "./Chip.view"; import type { ChipProps } from "./Chip.types"; @@ -22,6 +23,7 @@ const ChipContainer = memo( onClose, ariaLabel, }) => { + const t = useTranslation("controlsChrome"); const state = stateProp; const palette = paletteProp; const size = sizeProp; @@ -92,6 +94,9 @@ const ChipContainer = memo( onInputKeyDown={isCustom ? handleKeyDown : undefined} inputRef={isCustom ? inputRef : undefined} ariaLabel={ariaLabel} + confirmAriaLabel={t("chipConfirm")} + typeToAddPlaceholder={t("chipTypeToAdd")} + closeAriaLabel={t("chipClose")} /> ); }, diff --git a/app/components/controls/Chip/Chip.types.ts b/app/components/controls/Chip/Chip.types.ts index d0ddc6c..2a082a6 100644 --- a/app/components/controls/Chip/Chip.types.ts +++ b/app/components/controls/Chip/Chip.types.ts @@ -68,4 +68,7 @@ export interface ChipViewProps { onInputKeyDown?: (event: React.KeyboardEvent) => void; inputRef?: React.RefObject; ariaLabel?: string; + confirmAriaLabel: string; + typeToAddPlaceholder: string; + closeAriaLabel: string; } diff --git a/app/components/controls/Chip/Chip.view.tsx b/app/components/controls/Chip/Chip.view.tsx index 6b11c84..7620104 100644 --- a/app/components/controls/Chip/Chip.view.tsx +++ b/app/components/controls/Chip/Chip.view.tsx @@ -19,6 +19,9 @@ function ChipView({ onInputKeyDown, inputRef, ariaLabel, + confirmAriaLabel, + typeToAddPlaceholder, + closeAriaLabel, }: ChipViewProps) { // The container is the source of truth for `disabled`. This allows // `state="disabled"` to be used purely as a visual (for toggle-group chips @@ -167,7 +170,7 @@ function ChipView({
)} diff --git a/app/components/controls/Switch/Switch.container.tsx b/app/components/controls/Switch/Switch.container.tsx index 232989a..cbe3b2c 100644 --- a/app/components/controls/Switch/Switch.container.tsx +++ b/app/components/controls/Switch/Switch.container.tsx @@ -1,6 +1,7 @@ "use client"; import { memo, useCallback, useId, forwardRef } from "react"; +import { useTranslation } from "../../../contexts/MessagesContext"; import { SwitchView } from "./Switch.view"; import type { SwitchProps } from "./Switch.types"; @@ -10,6 +11,7 @@ import type { SwitchProps } from "./Switch.types"; */ const SwitchContainer = memo( forwardRef((props, ref) => { + const t = useTranslation("controlsChrome"); const { propSwitch = false, onChange, @@ -154,6 +156,7 @@ const SwitchContainer = memo( trackClasses={trackClasses} thumbClasses={thumbClasses} labelClasses={labelClasses} + switchAriaLabel={text ?? t("toggleSwitch")} onClick={handleClick} onKeyDown={handleKeyDown} onFocus={handleFocus} diff --git a/app/components/controls/Switch/Switch.types.ts b/app/components/controls/Switch/Switch.types.ts index 167defd..d5a6de1 100644 --- a/app/components/controls/Switch/Switch.types.ts +++ b/app/components/controls/Switch/Switch.types.ts @@ -37,6 +37,7 @@ export interface SwitchViewProps { trackClasses: string; thumbClasses: string; labelClasses: string; + switchAriaLabel: string; onClick: (_e: React.MouseEvent) => void; onKeyDown: (_e: React.KeyboardEvent) => void; onFocus: (_e: React.FocusEvent) => void; diff --git a/app/components/controls/Switch/Switch.view.tsx b/app/components/controls/Switch/Switch.view.tsx index 414b000..1bd0b25 100644 --- a/app/components/controls/Switch/Switch.view.tsx +++ b/app/components/controls/Switch/Switch.view.tsx @@ -11,6 +11,7 @@ export const SwitchView = forwardRef( trackClasses, thumbClasses, labelClasses, + switchAriaLabel, onClick, onKeyDown, onFocus, @@ -27,7 +28,7 @@ export const SwitchView = forwardRef( type="button" role="switch" aria-checked={propSwitch} - aria-label={text || "Toggle switch"} + aria-label={switchAriaLabel} onClick={onClick} onKeyDown={onKeyDown} onFocus={onFocus} diff --git a/app/components/controls/TextArea/TextArea.container.tsx b/app/components/controls/TextArea/TextArea.container.tsx index 3287594..e48aeff 100644 --- a/app/components/controls/TextArea/TextArea.container.tsx +++ b/app/components/controls/TextArea/TextArea.container.tsx @@ -2,6 +2,7 @@ import { memo, forwardRef } from "react"; import { useComponentId, useFormField } from "../../../hooks"; +import { useTranslation } from "../../../contexts/MessagesContext"; import { TextAreaView } from "./TextArea.view"; import type { TextAreaProps } from "./TextArea.types"; @@ -35,6 +36,7 @@ const TextAreaContainer = forwardRef( }, ref, ) => { + const t = useTranslation("controlsChrome"); const size = sizeProp; const labelVariant = labelVariantProp; const state = stateProp; @@ -200,6 +202,8 @@ const TextAreaContainer = forwardRef( formHeader={formHeader} showHelpIcon={showHelpIcon} appearance={appearance} + helpIconAlt={t("helpIconAlt")} + hintDefault={t("hintDefault")} {...props} /> ); diff --git a/app/components/controls/TextArea/TextArea.types.ts b/app/components/controls/TextArea/TextArea.types.ts index b6981a5..5b8d98a 100644 --- a/app/components/controls/TextArea/TextArea.types.ts +++ b/app/components/controls/TextArea/TextArea.types.ts @@ -79,4 +79,6 @@ export interface TextAreaViewProps { formHeader?: boolean; showHelpIcon?: boolean; appearance?: "default" | "embedded"; + helpIconAlt: string; + hintDefault: string; } diff --git a/app/components/controls/TextArea/TextArea.view.tsx b/app/components/controls/TextArea/TextArea.view.tsx index 8b6e788..8b8a538 100644 --- a/app/components/controls/TextArea/TextArea.view.tsx +++ b/app/components/controls/TextArea/TextArea.view.tsx @@ -25,6 +25,8 @@ export const TextAreaView = forwardRef( formHeader = true, showHelpIcon = false, appearance: _appearance, + helpIconAlt, + hintDefault, // Component-only props: do not pass to DOM size: _size, labelVariant: _labelVariant, @@ -51,7 +53,7 @@ export const TextAreaView = forwardRef( {/* eslint-disable-next-line @next/next/no-img-element -- icon asset */} Help
@@ -81,7 +83,7 @@ export const TextAreaView = forwardRef( {textHint ? (

- {typeof textHint === "string" ? textHint : "Hint text here"} + {typeof textHint === "string" ? textHint : hintDefault}

) : null} diff --git a/app/components/controls/TextInput/TextInput.container.tsx b/app/components/controls/TextInput/TextInput.container.tsx index 8f312fb..4800c2f 100644 --- a/app/components/controls/TextInput/TextInput.container.tsx +++ b/app/components/controls/TextInput/TextInput.container.tsx @@ -2,6 +2,7 @@ import { memo, forwardRef, useState, useRef } from "react"; import { useComponentId, useFormField } from "../../../hooks"; +import { useTranslation } from "../../../contexts/MessagesContext"; import { TextInputView } from "./TextInput.view"; import type { TextInputProps } from "./TextInput.types"; @@ -34,6 +35,7 @@ const TextInputContainer = forwardRef( }, ref, ) => { + const t = useTranslation("controlsChrome"); const externalState = externalStateProp; const inputSize = inputSizeProp; @@ -244,6 +246,8 @@ const TextInputContainer = forwardRef( textHint={textHint} formHeader={formHeader} maxLength={maxLength} + helpIconAlt={t("helpIconAlt")} + hintDefault={t("hintDefault")} {...props} /> ); diff --git a/app/components/controls/TextInput/TextInput.types.ts b/app/components/controls/TextInput/TextInput.types.ts index de1e5ba..3e4fca7 100644 --- a/app/components/controls/TextInput/TextInput.types.ts +++ b/app/components/controls/TextInput/TextInput.types.ts @@ -65,4 +65,6 @@ export interface TextInputViewProps { textHint?: boolean | string; formHeader?: boolean; maxLength?: number; + helpIconAlt: string; + hintDefault: string; } diff --git a/app/components/controls/TextInput/TextInput.view.tsx b/app/components/controls/TextInput/TextInput.view.tsx index 7e114c9..70ee44b 100644 --- a/app/components/controls/TextInput/TextInput.view.tsx +++ b/app/components/controls/TextInput/TextInput.view.tsx @@ -29,6 +29,8 @@ export const TextInputView = forwardRef( textHint = false, formHeader = true, maxLength, + helpIconAlt, + hintDefault, }, ref, ) => { @@ -49,7 +51,7 @@ export const TextInputView = forwardRef( {/* eslint-disable-next-line @next/next/no-img-element -- icon asset */} Help
@@ -83,7 +85,7 @@ export const TextInputView = forwardRef( {textHint && (

- {typeof textHint === "string" ? textHint : "Hint text here"} + {typeof textHint === "string" ? textHint : hintDefault}

)} diff --git a/app/components/controls/ToggleGroup/ToggleGroup.container.tsx b/app/components/controls/ToggleGroup/ToggleGroup.container.tsx index 3efee12..885f3c1 100644 --- a/app/components/controls/ToggleGroup/ToggleGroup.container.tsx +++ b/app/components/controls/ToggleGroup/ToggleGroup.container.tsx @@ -1,6 +1,7 @@ "use client"; import { memo, useCallback, useId, forwardRef } from "react"; +import { useTranslation } from "../../../contexts/MessagesContext"; import { ToggleGroupView } from "./ToggleGroup.view"; import type { ToggleGroupProps } from "./ToggleGroup.types"; @@ -10,6 +11,7 @@ import type { ToggleGroupProps } from "./ToggleGroup.types"; */ const ToggleGroupContainer = memo( forwardRef((props, _ref) => { + const t = useTranslation("controlsChrome"); const { children, className = "", @@ -131,6 +133,7 @@ const ToggleGroupContainer = memo( state={state} showText={showText} ariaLabel={ariaLabel} + defaultToggleOptionAriaLabel={t("toggleOption")} toggleClasses={toggleClasses} onClick={handleClick} onKeyDown={handleKeyDown} diff --git a/app/components/controls/ToggleGroup/ToggleGroup.types.ts b/app/components/controls/ToggleGroup/ToggleGroup.types.ts index 0d6c424..4d75fb0 100644 --- a/app/components/controls/ToggleGroup/ToggleGroup.types.ts +++ b/app/components/controls/ToggleGroup/ToggleGroup.types.ts @@ -35,6 +35,7 @@ export interface ToggleGroupViewProps { state: "default" | "hover" | "focus" | "selected"; showText: boolean; ariaLabel?: string; + defaultToggleOptionAriaLabel: string; toggleClasses: string; onClick: (_e: React.MouseEvent) => void; onKeyDown: (_e: React.KeyboardEvent) => void; diff --git a/app/components/controls/ToggleGroup/ToggleGroup.view.tsx b/app/components/controls/ToggleGroup/ToggleGroup.view.tsx index 5daabec..a8f3711 100644 --- a/app/components/controls/ToggleGroup/ToggleGroup.view.tsx +++ b/app/components/controls/ToggleGroup/ToggleGroup.view.tsx @@ -8,6 +8,7 @@ export function ToggleGroupView({ state: _state, showText, ariaLabel, + defaultToggleOptionAriaLabel, toggleClasses, onClick, onKeyDown, @@ -20,7 +21,7 @@ export function ToggleGroupView({ id={groupId} type="button" role="button" - aria-label={ariaLabel || (showText ? undefined : "Toggle option")} + aria-label={ariaLabel || (showText ? undefined : defaultToggleOptionAriaLabel)} onClick={onClick} onKeyDown={onKeyDown} onFocus={onFocus} diff --git a/app/components/controls/Upload/Upload.container.tsx b/app/components/controls/Upload/Upload.container.tsx index 622621b..dbb0f11 100644 --- a/app/components/controls/Upload/Upload.container.tsx +++ b/app/components/controls/Upload/Upload.container.tsx @@ -1,6 +1,7 @@ "use client"; import { memo } from "react"; +import { useTranslation } from "../../../contexts/MessagesContext"; import UploadView from "./Upload.view"; import type { UploadProps } from "./Upload.types"; @@ -13,16 +14,20 @@ const UploadContainer = memo( active = true, label, showHelpIcon = true, - hintText = "Add image from your device", + hintText, onClick, className = "", }) => { + const t = useTranslation("controlsChrome"); + return ( diff --git a/app/components/controls/Upload/Upload.types.ts b/app/components/controls/Upload/Upload.types.ts index 9940390..7d9ad55 100644 --- a/app/components/controls/Upload/Upload.types.ts +++ b/app/components/controls/Upload/Upload.types.ts @@ -35,6 +35,8 @@ export interface UploadViewProps { label?: string; showHelpIcon: boolean; hintText: string; + uploadButtonLabel: string; + uploadAriaLabel: string; onClick?: () => void; className: string; } diff --git a/app/components/controls/Upload/Upload.view.tsx b/app/components/controls/Upload/Upload.view.tsx index c61dbf4..db8d8db 100644 --- a/app/components/controls/Upload/Upload.view.tsx +++ b/app/components/controls/Upload/Upload.view.tsx @@ -9,6 +9,8 @@ function UploadView({ label, showHelpIcon = true, hintText, + uploadButtonLabel, + uploadAriaLabel, onClick, className = "", }: UploadViewProps) { @@ -56,7 +58,7 @@ function UploadView({ type="button" onClick={onClick} className={`${buttonBgClass} flex gap-[var(--measures-spacing-150,6px)] items-center justify-center overflow-clip px-[var(--space-400,16px)] py-[var(--measures-spacing-300,12px)] rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity`} - aria-label="Upload" + aria-label={uploadAriaLabel} > {/* Upload icon */}
@@ -98,7 +100,7 @@ function UploadView({
-

Upload

+

{uploadButtonLabel}

diff --git a/app/components/localization/LanguageSwitcher/LanguageSwitcher.container.tsx b/app/components/localization/LanguageSwitcher/LanguageSwitcher.container.tsx index 51832c4..5904192 100644 --- a/app/components/localization/LanguageSwitcher/LanguageSwitcher.container.tsx +++ b/app/components/localization/LanguageSwitcher/LanguageSwitcher.container.tsx @@ -1,5 +1,9 @@ "use client"; +/** + * Figma: "localization/LanguageSwitcher" (see registry) + */ + import { memo } from "react"; import LanguageSwitcherView from "./LanguageSwitcher.view"; import type { LanguageSwitcherProps } from "./LanguageSwitcher.types"; diff --git a/app/components/modals/Alert/Alert.container.tsx b/app/components/modals/Alert/Alert.container.tsx index ce01a55..cc78e0f 100644 --- a/app/components/modals/Alert/Alert.container.tsx +++ b/app/components/modals/Alert/Alert.container.tsx @@ -6,6 +6,7 @@ */ import { memo } from "react"; +import { useTranslation } from "../../../contexts/MessagesContext"; import { AlertView } from "./Alert.view"; import type { AlertProps } from "./Alert.types"; @@ -74,6 +75,7 @@ const AlertContainer = memo( onClose, className = "", }) => { + const t = useTranslation("controlsChrome"); const status = statusProp; const type = typeProp; const size = sizeProp; @@ -175,6 +177,7 @@ const AlertContainer = memo( iconColor={statusStyles.iconColor} closeButtonIconColor={statusStyles.closeButtonIconColor} onClose={onClose} + closeAlertAriaLabel={t("closeAlert")} /> ); }, diff --git a/app/components/modals/Alert/Alert.types.ts b/app/components/modals/Alert/Alert.types.ts index 3278b1b..fe6d331 100644 --- a/app/components/modals/Alert/Alert.types.ts +++ b/app/components/modals/Alert/Alert.types.ts @@ -57,4 +57,5 @@ export interface AlertViewProps { iconColor: string; closeButtonIconColor: string; onClose?: () => void; + closeAlertAriaLabel: string; } diff --git a/app/components/modals/Alert/Alert.view.tsx b/app/components/modals/Alert/Alert.view.tsx index 08a67b6..41d0c63 100644 --- a/app/components/modals/Alert/Alert.view.tsx +++ b/app/components/modals/Alert/Alert.view.tsx @@ -17,6 +17,7 @@ export function AlertView({ iconColor, closeButtonIconColor, onClose, + closeAlertAriaLabel, }: AlertViewProps) { const getIcon = () => { // Use the Icon_Alert.svg with dynamic fill color @@ -61,7 +62,7 @@ export function AlertView({ palette="default" size="large" onClick={onClose} - ariaLabel="Close alert" + ariaLabel={closeAlertAriaLabel} className="shrink-0 [&_svg_path]:transition-colors [&_svg_path]:duration-200 hover:[&_svg_path]:fill-[var(--color-content-default-primary)]" > ((props) => { - const { menuItems = [] } = props; + const t = useTranslation("controlsChrome"); + const { + closeButtonAriaLabel = t("closeDialog"), + moreOptionsAriaLabel = t("moreOptions"), + menuAriaLabel = t("moreOptionsMenu"), + menuItems = [], + ...rest + } = props; const hasMenu = menuItems.length > 0; const [menuOpen, setMenuOpen] = useState(false); const menuId = useId(); @@ -44,7 +52,11 @@ const ModalHeaderContainer = memo((props) => { return (
setMenuOpen((open) => !open) : undefined} diff --git a/app/components/modals/ModalHeader/ModalHeader.view.tsx b/app/components/modals/ModalHeader/ModalHeader.view.tsx index 29aedd4..edf9764 100644 --- a/app/components/modals/ModalHeader/ModalHeader.view.tsx +++ b/app/components/modals/ModalHeader/ModalHeader.view.tsx @@ -11,9 +11,9 @@ export function ModalHeaderView({ onMoreOptions, showCloseButton = true, showMoreOptionsButton = true, - closeButtonAriaLabel = "Close dialog", - moreOptionsAriaLabel = "More options", - menuAriaLabel = "More options menu", + closeButtonAriaLabel, + moreOptionsAriaLabel, + menuAriaLabel, menuItems = [], menuId, menuOpen = false, diff --git a/app/components/modals/Tooltip/Tooltip.container.tsx b/app/components/modals/Tooltip/Tooltip.container.tsx index 1781f41..9b17351 100644 --- a/app/components/modals/Tooltip/Tooltip.container.tsx +++ b/app/components/modals/Tooltip/Tooltip.container.tsx @@ -1,5 +1,9 @@ "use client"; +/** + * Figma: "Modal / Tooltip" (see registry) + */ + import { memo, useState } from "react"; import { TooltipView } from "./Tooltip.view"; import type { TooltipProps } from "./Tooltip.types"; diff --git a/app/components/navigation/CreateFlowFooter/CreateFlowFooter.container.tsx b/app/components/navigation/CreateFlowFooter/CreateFlowFooter.container.tsx index 333fdf0..1ad6429 100644 --- a/app/components/navigation/CreateFlowFooter/CreateFlowFooter.container.tsx +++ b/app/components/navigation/CreateFlowFooter/CreateFlowFooter.container.tsx @@ -1,6 +1,7 @@ "use client"; import { memo } from "react"; +import { useTranslation } from "../../../contexts/MessagesContext"; import { CreateFlowFooterView } from "./CreateFlowFooter.view"; import type { CreateFlowFooterProps } from "./CreateFlowFooter.types"; @@ -16,7 +17,10 @@ const CreateFlowFooterContainer = memo( proportionBarVariant, onBackClick, className = "", + footerAriaLabel, }) => { + const t = useTranslation("controlsChrome"); + return ( ( proportionBarVariant={proportionBarVariant} onBackClick={onBackClick} className={className} + footerAriaLabel={footerAriaLabel ?? t("createFlowFooterAriaLabel")} /> ); }, diff --git a/app/components/navigation/CreateFlowFooter/CreateFlowFooter.types.ts b/app/components/navigation/CreateFlowFooter/CreateFlowFooter.types.ts index ad169d1..c97b0ba 100644 --- a/app/components/navigation/CreateFlowFooter/CreateFlowFooter.types.ts +++ b/app/components/navigation/CreateFlowFooter/CreateFlowFooter.types.ts @@ -36,4 +36,8 @@ export interface CreateFlowFooterProps { * Additional CSS classes */ className?: string; + /** + * Accessible name for the footer landmark. + */ + footerAriaLabel?: string; } diff --git a/app/components/navigation/CreateFlowFooter/CreateFlowFooter.view.tsx b/app/components/navigation/CreateFlowFooter/CreateFlowFooter.view.tsx index 47265bb..3f6f81a 100644 --- a/app/components/navigation/CreateFlowFooter/CreateFlowFooter.view.tsx +++ b/app/components/navigation/CreateFlowFooter/CreateFlowFooter.view.tsx @@ -9,13 +9,14 @@ export function CreateFlowFooterView({ proportionBarVariant: proportionBarVariantProp, onBackClick, className = "", + footerAriaLabel, }: CreateFlowFooterProps) { const proportionBarVariant = proportionBarVariantProp ?? "default"; return (
{/* Progress Bar - Top */} {progressBar && ( diff --git a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.container.tsx b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.container.tsx index 6f19796..1ef8d54 100644 --- a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.container.tsx +++ b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.container.tsx @@ -1,10 +1,14 @@ "use client"; -import { memo } from "react"; +import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; +import { useCreateFlowSm2Up } from "../../../(app)/create/hooks/useCreateFlowSm2Up"; import { useTranslation } from "../../../contexts/MessagesContext"; import { CreateFlowTopNavView } from "./CreateFlowTopNav.view"; -import type { CreateFlowTopNavProps } from "./CreateFlowTopNav.types"; +import type { + CreateFlowTopNavActionMenuItem, + CreateFlowTopNavProps, +} from "./CreateFlowTopNav.types"; /** * Figma: Utility / CreateFlowTopNav — wizard header (create-flow chrome). @@ -34,15 +38,168 @@ const CreateFlowTopNavContainer = memo( const router = useRouter(); const t = useTranslation("create.topNav"); const tPopover = useTranslation("modals.popoverExport"); + 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 handleExit = (options?: { saveDraft?: boolean }) => { - if (onExit) { - onExit(options); - } else { - // Default behavior: navigate to home - router.push("/"); + const handleExit = useCallback( + (options?: { saveDraft?: boolean }) => { + if (onExit) { + onExit(options); + } else { + // Default behavior: navigate to home + router.push("/"); + } + }, + [onExit, router], + ); + + const hasSecondaryActions = + hasShare || + hasExport || + hasEdit || + hasDuplicate || + hasManageStakeholders; + const useKebabMenu = hasSecondaryActions && !sm2Up; + + const actionMenuItems = useMemo((): CreateFlowTopNavActionMenuItem[] => { + const items: CreateFlowTopNavActionMenuItem[] = []; + + if (hasShare && onShare) { + items.push({ + id: "share", + label: t("share"), + leadingIcon: "mail", + onClick: onShare, + }); } - }; + + if (hasExport && onSelectExportFormat) { + items.push( + { + id: "export-pdf", + label: tPopover("downloadPdf"), + leadingIcon: "picture_as_pdf", + onClick: () => onSelectExportFormat("pdf"), + }, + { + id: "export-csv", + label: tPopover("downloadCsv"), + leadingIcon: "csv", + onClick: () => onSelectExportFormat("csv"), + }, + { + id: "export-markdown", + label: tPopover("downloadMarkdown"), + 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 handleExit({ saveDraft: saveDraftOnExit }), + }); + + return items; + }, [ + duplicateLabel, + exitButtonText, + handleExit, + hasDuplicate, + hasEdit, + hasExport, + hasManageStakeholders, + hasShare, + onDuplicate, + onEdit, + onManageStakeholders, + onSelectExportFormat, + onShare, + saveDraftOnExit, + t, + tPopover, + ]); + + useEffect(() => { + if (!exportMenuOpen) return; + const onDoc = (e: MouseEvent) => { + if ( + exportWrapRef.current && + !exportWrapRef.current.contains(e.target as Node) + ) { + setExportMenuOpen(false); + } + }; + document.addEventListener("mousedown", onDoc); + 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) => { + if (e.key === "Escape") setExportMenuOpen(false); + }; + window.addEventListener("keydown", onKey); + 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]); return ( ( duplicateAriaLabel={duplicateAriaLabel} buttonPalette={buttonPalette} className={className} + exitButtonText={exitButtonText} + useKebabMenu={useKebabMenu} + exportMenuOpen={exportMenuOpen} + setExportMenuOpen={setExportMenuOpen} + actionsMenuOpen={actionsMenuOpen} + setActionsMenuOpen={setActionsMenuOpen} + exportWrapRef={exportWrapRef} + actionsWrapRef={actionsWrapRef} + exportMenuId={exportMenuId} + actionsMenuId={actionsMenuId} + actionMenuItems={actionMenuItems} exportPopoverMenuAriaLabel={tPopover("menuAriaLabel")} exportPopoverPdfLabel={tPopover("downloadPdf")} exportPopoverCsvLabel={tPopover("downloadCsv")} diff --git a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.types.ts b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.types.ts index aa15f25..ba0de11 100644 --- a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.types.ts +++ b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.types.ts @@ -5,6 +5,16 @@ * Includes logo and action buttons (Share, Export, Edit, Exit). */ +import type { Dispatch, RefObject, SetStateAction } from "react"; +import type { IconName } from "../../asset/icon"; + +export type CreateFlowTopNavActionMenuItem = { + id: string; + label: string; + leadingIcon: IconName; + onClick: () => void; +}; + export interface CreateFlowTopNavProps { /** * Whether to show the Share button @@ -81,8 +91,19 @@ export interface CreateFlowTopNavProps { className?: string; } -/** Resolved copy for the export popover; supplied by the container. */ +/** Resolved copy and menu state; supplied by the container. */ export type CreateFlowTopNavViewProps = CreateFlowTopNavProps & { + exitButtonText: string; + useKebabMenu: boolean; + exportMenuOpen: boolean; + setExportMenuOpen: Dispatch>; + actionsMenuOpen: boolean; + setActionsMenuOpen: Dispatch>; + exportWrapRef: RefObject; + actionsWrapRef: RefObject; + exportMenuId: string; + actionsMenuId: string; + actionMenuItems: CreateFlowTopNavActionMenuItem[]; exportPopoverMenuAriaLabel: string; exportPopoverPdfLabel: string; exportPopoverCsvLabel: string; diff --git a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.view.tsx b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.view.tsx index aaffb45..072a614 100644 --- a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.view.tsx +++ b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.view.tsx @@ -1,12 +1,9 @@ "use client"; -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"; @@ -16,13 +13,6 @@ const outlineButtonClass = 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 ( (null); - const actionsWrapRef = useRef(null); - const exportMenuId = useId(); - const actionsMenuId = useId(); const hasSecondaryActions = hasShare || @@ -83,142 +74,6 @@ export function CreateFlowTopNavView({ 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; - const onDoc = (e: MouseEvent) => { - if ( - exportWrapRef.current && - !exportWrapRef.current.contains(e.target as Node) - ) { - setExportMenuOpen(false); - } - }; - document.addEventListener("mousedown", onDoc); - 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) => { - if (e.key === "Escape") setExportMenuOpen(false); - }; - window.addEventListener("keydown", onKey); - 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 = ( <> diff --git a/app/components/navigation/Footer.tsx b/app/components/navigation/Footer.tsx index d246074..e981b9a 100644 --- a/app/components/navigation/Footer.tsx +++ b/app/components/navigation/Footer.tsx @@ -14,6 +14,7 @@ import { getAssetPath, ASSETS } from "../../../lib/assetUtils"; */ const Footer = memo(() => { const t = useTranslation("footer"); + const tChrome = useTranslation("controlsChrome"); const linkFocusClass = "hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity"; @@ -129,7 +130,7 @@ const Footer = memo(() => {