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..766871d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,8 @@ 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) +npm run knip # unused files / exports (local; no remote CI) +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 bb1c53e..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 { 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/CaseStudy/CaseStudy.types.ts b/app/components/cards/CaseStudy/CaseStudy.types.ts index 4a014b6..82a21ff 100644 --- a/app/components/cards/CaseStudy/CaseStudy.types.ts +++ b/app/components/cards/CaseStudy/CaseStudy.types.ts @@ -7,10 +7,10 @@ export type CaseStudySurfaceValue = (typeof CASE_STUDY_SURFACE_OPTIONS)[number]; export interface CaseStudyProps { surface: CaseStudySurfaceValue; /** - * Alt text for built-in raster art (`public/assets/use-cases/`) when **`visual`** is omitted. + * Alt text for built-in SVG art (`public/assets/case-study/`) when **`visual`** is omitted. */ imageAlt?: string; - /** Overrides built-in raster with custom slot content when provided. */ + /** Overrides built-in artwork with custom slot content when provided. */ visual?: ReactNode; className?: string; } diff --git a/app/components/cards/CaseStudy/CaseStudy.view.tsx b/app/components/cards/CaseStudy/CaseStudy.view.tsx index 929a27f..dd5dece 100644 --- a/app/components/cards/CaseStudy/CaseStudy.view.tsx +++ b/app/components/cards/CaseStudy/CaseStudy.view.tsx @@ -2,6 +2,7 @@ import Image from "next/image"; import { memo } from "react"; +import { caseStudyVisualPath, getAssetPath } from "../../../../lib/assetUtils"; import type { CaseStudyProps } from "./CaseStudy.types"; const SURFACE_CLASS: Record = { @@ -12,9 +13,9 @@ const SURFACE_CLASS: Record = { /** 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/case-study/case-study-food-not-bombs.svg", - rose: "/assets/case-study/case-study-boulder-county-street-medics.svg", + lavender: getAssetPath(caseStudyVisualPath("lavender")), + neutral: getAssetPath(caseStudyVisualPath("neutral")), + rose: getAssetPath(caseStudyVisualPath("rose")), }; /** Figma: ~23px corner (“Card / CaseStudy” shells). */ 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..7397e63 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"; @@ -16,15 +21,21 @@ const MiniContainer = memo( onClick, href, ariaLabel, + featureGridShell = false, + panelWidth, + panelHeight, + panelImageClassName, }) => { + 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 @@ -85,6 +96,10 @@ const MiniContainer = memo( computedAriaLabel={computedAriaLabel} wrapperElement={wrapperElement} wrapperProps={wrapperProps} + featureGridShell={featureGridShell} + panelWidth={panelWidth} + panelHeight={panelHeight} + panelImageClassName={panelImageClassName} > {children} diff --git a/app/components/cards/Mini/Mini.types.ts b/app/components/cards/Mini/Mini.types.ts index ca0f41c..9f3f479 100644 --- a/app/components/cards/Mini/Mini.types.ts +++ b/app/components/cards/Mini/Mini.types.ts @@ -9,6 +9,11 @@ export interface MiniProps { onClick?: () => void; href?: string; ariaLabel?: string; + /** Figma Feature-Grid mini tile shell (18847:22410). */ + featureGridShell?: boolean; + panelWidth?: number; + panelHeight?: number; + panelImageClassName?: string; } export interface MiniViewProps { @@ -25,4 +30,8 @@ export interface MiniViewProps { | React.AnchorHTMLAttributes | React.ButtonHTMLAttributes | React.HTMLAttributes; + featureGridShell?: boolean; + panelWidth?: number; + panelHeight?: number; + panelImageClassName?: string; } diff --git a/app/components/cards/Mini/Mini.view.tsx b/app/components/cards/Mini/Mini.view.tsx index fe83111..fdd9434 100644 --- a/app/components/cards/Mini/Mini.view.tsx +++ b/app/components/cards/Mini/Mini.view.tsx @@ -2,6 +2,7 @@ import { memo } from "react"; import Image from "next/image"; +import { SVG_GRAIN_MULTIPLY_FILTER } from "../../../../lib/svgGrainFilter"; import type { MiniViewProps } from "./Mini.types"; function MiniView({ @@ -15,39 +16,59 @@ function MiniView({ computedAriaLabel, wrapperElement, wrapperProps, + featureGridShell = false, + panelWidth, + panelHeight, + panelImageClassName, }: MiniViewProps) { + const defaultPanelSize = featureGridShell ? 48 : 58; + const imageWidth = panelWidth ?? defaultPanelSize; + const imageHeight = panelHeight ?? defaultPanelSize; + + const outerClass = featureGridShell + ? `flex min-h-[159px] flex-col gap-[7px] ${className}` + : `h-[186px] flex flex-col gap-[7px] ${className}`; + + const panelClass = featureGridShell + ? `h-[138px] shrink-0 rounded-[var(--measures-radius-400,16px)] px-[24px] py-[32px] ${backgroundColor} flex items-center justify-center` + : `flex-1 rounded-[var(--radius-measures-radius-xlarge)] border border-[1px] py-[var(--spacing-scale-032)] px-[var(--spacing-scale-024)] ${backgroundColor} flex items-center justify-center transition-all duration-200 hover:scale-[1.02] hover:shadow-lg`; + + const imageClass = featureGridShell + ? `max-h-[48px] max-w-[56px] w-auto h-auto object-contain${panelImageClassName ? ` ${panelImageClassName}` : ""}` + : "max-w-[58px] max-h-[58px] w-auto h-auto object-contain"; + const cardContentElement = ( -
- {/* Top part - Inner panel */} -
- {/* Content for the inner panel */} +
+
{panelContent && ( -
+
{computedAriaLabel}
)} {children}
- {/* Bottom part - Text container */} -
+
{labelLine1 && labelLine2 ? ( <>
{labelLine1}
{labelLine2}
-
 
) : ( label diff --git a/app/components/cards/Rule/Rule.container.tsx b/app/components/cards/Rule/Rule.container.tsx index f367808..fd3503b 100644 --- a/app/components/cards/Rule/Rule.container.tsx +++ b/app/components/cards/Rule/Rule.container.tsx @@ -1,6 +1,7 @@ "use client"; import { memo } from "react"; +import { useTranslation } from "../../../contexts/MessagesContext"; import { RuleView } from "./Rule.view"; import type { RuleProps } from "./Rule.types"; @@ -49,6 +50,9 @@ const RuleContainer = memo( fluidWidth = false, }) => { const size = sizeProp ?? "L"; + const t = useTranslation("ruleCard"); + const cardAriaLabel = t("ariaLabel")?.replace("{title}", title) || title; + const recommendedLabel = t("recommendedLabel"); const handleClick = () => { if (hasBottomLinks) return; @@ -106,6 +110,8 @@ const RuleContainer = memo( recommended={recommended} templateGridFigmaShell={templateGridFigmaShell} fluidWidth={fluidWidth} + cardAriaLabel={cardAriaLabel} + recommendedLabel={recommendedLabel} /> ); }, diff --git a/app/components/cards/Rule/Rule.types.ts b/app/components/cards/Rule/Rule.types.ts index 27fecc2..efb1586 100644 --- a/app/components/cards/Rule/Rule.types.ts +++ b/app/components/cards/Rule/Rule.types.ts @@ -107,4 +107,7 @@ export interface RuleViewProps { recommended?: boolean; templateGridFigmaShell?: boolean; fluidWidth?: boolean; + /** Interactive card aria-label; supplied by the container from `ruleCard` messages. */ + cardAriaLabel: string; + recommendedLabel: string; } diff --git a/app/components/cards/Rule/Rule.view.tsx b/app/components/cards/Rule/Rule.view.tsx index 1982a06..a8ecfe9 100644 --- a/app/components/cards/Rule/Rule.view.tsx +++ b/app/components/cards/Rule/Rule.view.tsx @@ -1,7 +1,6 @@ "use client"; import Image from "next/image"; -import { useTranslation } from "../../../contexts/MessagesContext"; import MultiSelect from "../../controls/MultiSelect"; import InlineTextButton from "../../buttons/InlineTextButton"; import NavigationLink from "../../navigation/Link"; @@ -34,9 +33,10 @@ export function RuleView({ recommended = false, templateGridFigmaShell = false, fluidWidth = false, + cardAriaLabel, + recommendedLabel, }: RuleViewProps) { - const t = useTranslation("ruleCard"); - const ariaLabel = t("ariaLabel")?.replace("{title}", title) || title; + const ariaLabel = cardAriaLabel; const interactiveCard = !hasBottomLinks; // Size-based styling @@ -306,7 +306,7 @@ export function RuleView({ > {showRecommendedTag ? ( - {t("recommendedLabel")} + {recommendedLabel} ) : null} {onTitleClick ? ( 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 deleted file mode 100644 index 51832c4..0000000 --- a/app/components/localization/LanguageSwitcher/LanguageSwitcher.container.tsx +++ /dev/null @@ -1,18 +0,0 @@ -"use client"; - -import { memo } from "react"; -import LanguageSwitcherView from "./LanguageSwitcher.view"; -import type { LanguageSwitcherProps } from "./LanguageSwitcher.types"; - -const LanguageSwitcherContainer = memo( - ({ className }) => { - // Future: Add language switching logic here - // For now, this is just a UI component - - return ; - }, -); - -LanguageSwitcherContainer.displayName = "LanguageSwitcher"; - -export default LanguageSwitcherContainer; diff --git a/app/components/localization/LanguageSwitcher/LanguageSwitcher.types.ts b/app/components/localization/LanguageSwitcher/LanguageSwitcher.types.ts deleted file mode 100644 index 5b6976b..0000000 --- a/app/components/localization/LanguageSwitcher/LanguageSwitcher.types.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface LanguageSwitcherProps { - className?: string; -} - -export interface Language { - code: string; - name: string; - nativeName: string; -} diff --git a/app/components/localization/LanguageSwitcher/LanguageSwitcher.view.tsx b/app/components/localization/LanguageSwitcher/LanguageSwitcher.view.tsx deleted file mode 100644 index a5f138b..0000000 --- a/app/components/localization/LanguageSwitcher/LanguageSwitcher.view.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import { memo } from "react"; -import { useTranslation } from "../../../contexts/MessagesContext"; -import type { LanguageSwitcherProps, Language } from "./LanguageSwitcher.types"; - -function LanguageSwitcherView({ className = "" }: LanguageSwitcherProps) { - const t = useTranslation("languageSwitcher"); - - const AVAILABLE_LANGUAGES: Language[] = [ - { - code: "en", - name: t("languages.english.name"), - nativeName: t("languages.english.nativeName"), - }, - ]; - - return ( -
- - -

- {t("comingSoonMessage")} -

-
- ); -} - -export default memo(LanguageSwitcherView); diff --git a/app/components/localization/LanguageSwitcher/index.tsx b/app/components/localization/LanguageSwitcher/index.tsx deleted file mode 100644 index 4ea8270..0000000 --- a/app/components/localization/LanguageSwitcher/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from "./LanguageSwitcher.container"; -export type { LanguageSwitcherProps, Language } 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)]" > ( ({ isOpen, onClose }) => { const t = useTranslation("modals.askOrganizerInquiry"); + const copy = useMemo( + () => ({ + title: t("title"), + description: t("description"), + emailLabel: t("emailLabel"), + emailPlaceholder: t("emailPlaceholder"), + questionLabel: t("questionLabel"), + questionPlaceholder: t("questionPlaceholder"), + submitButton: t("submitButton"), + closeAfterSuccess: t("closeAfterSuccess"), + successTitle: t("successTitle"), + successDescription: t("successDescription"), + ariaDialog: t("ariaDialog"), + honeypotLabel: t("honeypotLabel"), + }), + [t], + ); const [email, setEmail] = useState(""); const [message, setMessage] = useState(""); const [honeypot, setHoneypot] = useState(""); @@ -102,6 +119,7 @@ const AskOrganizerInquiryModalContainer = memo( void; } + +export interface AskOrganizerInquiryModalCopy { + title: string; + description: string; + emailLabel: string; + emailPlaceholder: string; + questionLabel: string; + questionPlaceholder: string; + submitButton: string; + closeAfterSuccess: string; + successTitle: string; + successDescription: string; + ariaDialog: string; + honeypotLabel: string; +} + +export interface AskOrganizerInquiryModalViewProps + extends AskOrganizerInquiryModalProps { + copy: AskOrganizerInquiryModalCopy; + email: string; + message: string; + honeypot: string; + submitting: boolean; + success: boolean; + formError: string | null; + emailError: boolean; + questionError: boolean; + onEmailChange: (_v: string) => void; + onMessageChange: (_v: string) => void; + onHoneypotChange: (_v: string) => void; + onSubmit: (_e: import("react").FormEvent) => void; +} diff --git a/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.view.tsx b/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.view.tsx index 906c3c3..f01f9ac 100644 --- a/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.view.tsx +++ b/app/components/modals/AskOrganizerInquiry/AskOrganizerInquiryModal.view.tsx @@ -1,31 +1,14 @@ "use client"; -import type { FormEvent } from "react"; import Create from "../Create"; import TextInput from "../../controls/TextInput"; import TextArea from "../../controls/TextArea"; import Button from "../../buttons/Button"; -import { useTranslation } from "../../../contexts/MessagesContext"; import { ASK_ORGANIZER_INQUIRY_FORM_ID, ORGANIZER_INQUIRY_HONEYPOT_FIELD, } from "../../../../lib/organizerInquiryConstants"; -import type { AskOrganizerInquiryModalProps } from "./AskOrganizerInquiryModal.types"; - -export type AskOrganizerInquiryModalViewProps = AskOrganizerInquiryModalProps & { - email: string; - message: string; - honeypot: string; - submitting: boolean; - success: boolean; - formError: string | null; - emailError: boolean; - questionError: boolean; - onEmailChange: (_v: string) => void; - onMessageChange: (_v: string) => void; - onHoneypotChange: (_v: string) => void; - onSubmit: (_e: FormEvent) => void; -}; +import type { AskOrganizerInquiryModalViewProps } from "./AskOrganizerInquiryModal.types"; /** * Figma: Community Rule System — Modal / Ask an Organizer (22078-587823) @@ -33,6 +16,7 @@ export type AskOrganizerInquiryModalViewProps = AskOrganizerInquiryModalProps & export function AskOrganizerInquiryModalView({ isOpen, onClose, + copy, email, message, honeypot, @@ -46,8 +30,6 @@ export function AskOrganizerInquiryModalView({ onHoneypotChange, onSubmit, }: AskOrganizerInquiryModalViewProps) { - const t = useTranslation("modals.askOrganizerInquiry"); - const footer = success ? (
) : ( @@ -72,7 +54,7 @@ export function AskOrganizerInquiryModalView({ className="w-full !justify-center" disabled={submitting} > - {t("submitButton")} + {copy.submitButton}
); @@ -82,22 +64,22 @@ export function AskOrganizerInquiryModalView({ isOpen={isOpen} onClose={onClose} backdropVariant="blurredYellow" - title={t("title")} - description={t("description")} + title={copy.title} + description={copy.description} showBackButton={false} showNextButton={false} stepper={false} - ariaLabel={t("ariaDialog")} + ariaLabel={copy.ariaDialog} footerContent={footer} footerClassName="!h-auto min-h-[112px] shrink-0 flex flex-col justify-end pb-8 pt-3 px-4" > {success ? (

- {t("successTitle")} + {copy.successTitle}

- {t("successDescription")} + {copy.successDescription}

) : ( @@ -120,8 +102,8 @@ export function AskOrganizerInquiryModalView({ type="email" name="email" autoComplete="email" - label={t("emailLabel")} - placeholder={t("emailPlaceholder")} + label={copy.emailLabel} + placeholder={copy.emailPlaceholder} value={email} onChange={(e) => onEmailChange(e.target.value)} error={emailError} @@ -131,8 +113,8 @@ export function AskOrganizerInquiryModalView({