Merge pull request 'Repo cleanup pass: assets, FeatureGrid, templates, create-flow UX, and API tests' (#53) from adilallo/Cleanup into main

Reviewed-on: #53
This commit was merged in pull request #53.
This commit is contained in:
2026-05-22 20:14:14 +00:00
291 changed files with 4802 additions and 2658 deletions
+1
View File
@@ -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/(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/(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/(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 | | `app/api/` | API routes | n/a | n/a |
Route folders **must not** sit loose at the top level of `app/`. If a new Route folders **must not** sit loose at the top level of `app/`. If a new
+1
View File
@@ -15,6 +15,7 @@ SMTP_FROM="Community Rule <noreply@localhost>"
ORGANIZER_INQUIRY_TO= ORGANIZER_INQUIRY_TO=
# Set to `true` to sync the create-flow draft with `/api/drafts/me` when the user is signed in. # 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= NEXT_PUBLIC_ENABLE_BACKEND_SYNC=
# Web vitals API (CR-80): `external` = structured logs only, no writes under `.next` (default in production). # Web vitals API (CR-80): `external` = structured logs only, no writes under `.next` (default in production).
+2 -1
View File
@@ -68,7 +68,8 @@ Run these (in order) before declaring a change done:
```bash ```bash
rm -rf .next # only if you moved/renamed routes or layouts rm -rf .next # only if you moved/renamed routes or layouts
npx tsc --noEmit # type check 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 npx next build # production build + route manifest
``` ```
+16 -4
View File
@@ -46,6 +46,18 @@ deployment-pipeline work.
| GET | `/api/templates` | List curated templates. Optional repeatable `facet.<group>=<value>` 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/templates` | List curated templates. Optional repeatable `facet.<group>=<value>` 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. | | 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. | | 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 ### Magic-link sign-in
@@ -58,10 +70,10 @@ deployment-pipeline work.
### Optional draft sync ### Optional draft sync
`NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` enables Postgres draft persistence Postgres draft persistence via `PUT /api/drafts/me` is **on by default** for
via `PUT /api/drafts/me` for signed-in users and post-sign-in upload of signed-in users and post-sign-in transfer of anonymous drafts. Set
anonymous drafts. Without it, anonymous progress stays in `localStorage` `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=false` to disable server sync (anonymous
and signed-in progress stays in memory until **Save & Exit**. progress stays in `localStorage` only).
### Create flow ### Create flow
@@ -1,5 +1,9 @@
"use client"; "use client";
/**
* Figma: "WebVitalsDashboard" (see registry)
*/
import { memo, useEffect, useState } from "react"; import { memo, useEffect, useState } from "react";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { logger } from "../../../../../lib/logger"; import { logger } from "../../../../../lib/logger";
@@ -4,6 +4,7 @@ import {
Suspense, Suspense,
useCallback, useCallback,
useEffect, useEffect,
useRef,
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
@@ -80,6 +81,7 @@ import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
import { SignedInDraftHydration } from "./SignedInDraftHydration"; import { SignedInDraftHydration } from "./SignedInDraftHydration";
import { CreateFlowPendingAvatarFlush } from "./components/CreateFlowPendingAvatarFlush"; import { CreateFlowPendingAvatarFlush } from "./components/CreateFlowPendingAvatarFlush";
import Alert from "../../components/modals/Alert"; import Alert from "../../components/modals/Alert";
import Create from "../../components/modals/Create";
import Share from "../../components/modals/Share"; import Share from "../../components/modals/Share";
import { import {
CreateFlowDraftSaveBannerProvider, CreateFlowDraftSaveBannerProvider,
@@ -190,6 +192,26 @@ function CreateFlowLayoutContent({
description?: string; description?: string;
} | null>(null); } | null>(null);
const [shareModalOpen, setShareModalOpen] = useState(false); const [shareModalOpen, setShareModalOpen] = useState(false);
const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false);
const leaveConfirmResolverRef = useRef<((proceed: boolean) => void) | null>(
null,
);
const confirmLeave = useCallback(
() =>
new Promise<boolean>((resolve) => {
leaveConfirmResolverRef.current = resolve;
setLeaveConfirmOpen(true);
}),
[],
);
const closeLeaveConfirm = useCallback((proceed: boolean) => {
setLeaveConfirmOpen(false);
const resolve = leaveConfirmResolverRef.current;
leaveConfirmResolverRef.current = null;
resolve?.(proceed);
}, []);
const { const {
copyPublishedRuleLink, copyPublishedRuleLink,
@@ -256,6 +278,7 @@ function CreateFlowLayoutContent({
router, router,
user: sessionUser ?? null, user: sessionUser ?? null,
setDraftSaveBannerMessage, setDraftSaveBannerMessage,
confirmLeave,
}); });
const handleExit = async (opts?: { saveDraft?: boolean }) => { const handleExit = async (opts?: { saveDraft?: boolean }) => {
@@ -601,6 +624,28 @@ function CreateFlowLayoutContent({
onSlackShare={() => void sharePublishedRuleViaSlack()} onSlackShare={() => void sharePublishedRuleViaSlack()}
onDiscordShare={() => void sharePublishedRuleViaDiscord()} onDiscordShare={() => void sharePublishedRuleViaDiscord()}
/> />
<Create
isOpen={leaveConfirmOpen}
onClose={() => closeLeaveConfirm(false)}
title={messages.create.topNav.leaveConfirmTitle}
description={messages.create.topNav.leaveConfirmDescription}
showBackButton={false}
showNextButton
nextButtonText={messages.create.topNav.leaveConfirmProceed}
onNext={() => closeLeaveConfirm(true)}
footerContent={
<Button
buttonType="ghost"
palette="default"
size="xsmall"
onClick={() => closeLeaveConfirm(false)}
>
{messages.create.topNav.leaveConfirmCancel}
</Button>
}
backdropVariant="blurredYellow"
ariaLabel={messages.create.topNav.leaveConfirmTitle}
/>
<CreateFlowTopNav <CreateFlowTopNav
hasShare={isCompletedStep} hasShare={isCompletedStep}
hasExport={isCompletedStep} hasExport={isCompletedStep}
+13 -7
View File
@@ -2,18 +2,24 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useTranslation } from "../../contexts/MessagesContext";
function CreateFlowLayoutLoading() {
const t = useTranslation("controlsChrome");
return (
<div
className="flex h-screen min-h-0 flex-col overflow-hidden bg-black"
aria-busy="true"
aria-label={t("loadingCreateFlow")}
/>
);
}
const CreateFlowLayoutClient = dynamic( const CreateFlowLayoutClient = dynamic(
() => import("./CreateFlowLayoutClient"), () => import("./CreateFlowLayoutClient"),
{ {
ssr: false, ssr: false,
loading: () => ( loading: () => <CreateFlowLayoutLoading />,
<div
className="flex h-screen min-h-0 flex-col overflow-hidden bg-black"
aria-busy="true"
aria-label="Loading create flow"
/>
),
}, },
); );
+2 -2
View File
@@ -15,7 +15,7 @@ import type { CreateFlowState } from "./types";
import messages from "../../../messages/en/index"; import messages from "../../../messages/en/index";
import Alert from "../../components/modals/Alert"; 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( function buildPayloadWithStep(
base: CreateFlowState, base: CreateFlowState,
@@ -111,7 +111,7 @@ export function PostLoginDraftTransfer({
return; return;
} }
if (SYNC_ENABLED && createFlowStateHasKeys(local)) { if (isBackendSyncEnabled() && createFlowStateHasKeys(local)) {
const saveResult = await saveDraftToServer(payload); const saveResult = await saveDraftToServer(payload);
if (cancelled) return; if (cancelled) return;
+2 -2
View File
@@ -17,7 +17,7 @@ import {
parseCreateFlowScreenFromPathname, parseCreateFlowScreenFromPathname,
} from "./utils/flowSteps"; } 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 * 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<string | null>(null); const finishedUserIdRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
if (!SYNC_ENABLED) return; if (!isBackendSyncEnabled()) return;
if (!sessionResolved) return; if (!sessionResolved) return;
if (sessionUser == null || sessionUser === undefined) { if (sessionUser == null || sessionUser === undefined) {
finishedUserIdRef.current = null; finishedUserIdRef.current = 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<CustomMethodCardFieldBlock, { kind: "upload" }>;
blocks: CustomMethodCardFieldBlock[];
patch: (_next: CustomMethodCardFieldBlock[]) => void;
uploadFileInputAriaLabel: string;
uploadHint: string;
clearPendingUploadAriaLabel: string;
clearPendingUploadTooltip: string;
uploadPreviewImageAlt: string;
noFileChosen: string;
}) {
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const tUpload = useTranslation("create.upload");
const [busy, setBusy] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(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 (
<div className="flex flex-col gap-2">
<InputLabel
label={block.blockTitle}
helpIcon
size="s"
palette="default"
/>
{!hasAsset ? (
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m)] text-[var(--color-content-default-secondary)]">
{displayName}
</p>
) : null}
<input
ref={uploadInputRef}
type="file"
className="sr-only"
tabIndex={-1}
accept="image/jpeg,image/png,image/webp,image/gif,application/pdf"
aria-label={uploadFileInputAriaLabel}
onChange={(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();
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 ? (
<div className="relative inline-block max-w-full">
<button
type="button"
onClick={clearUpload}
className="absolute right-[8px] top-[8px] z-[1] flex h-[32px] w-[32px] cursor-pointer items-center justify-center rounded-full bg-[var(--color-surface-default-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]"
aria-label={clearPendingUploadAriaLabel}
title={clearPendingUploadTooltip}
>
{/* eslint-disable-next-line @next/next/no-img-element -- matches ModalHeader close control */}
<img
src={getAssetPath("assets/Icon_Close.svg")}
alt=""
className="h-[16px] w-[16px]"
style={{
filter: "brightness(0) invert(1)",
}}
/>
</button>
{/* eslint-disable-next-line @next/next/no-img-element -- same-origin upload URL */}
<img
src={assetUrlTrimmed}
alt={uploadPreviewImageAlt}
className="max-h-[160px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
/>
</div>
) : (
<Upload
active={!busy}
hintText={busy ? tUpload("uploading") : uploadHint}
onClick={() => {
if (!busy) uploadInputRef.current?.click();
}}
/>
)}
{errorMessage ? (
<p
className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-s)] text-[var(--color-content-default-secondary)]"
role="alert"
>
{errorMessage}
</p>
) : null}
</div>
);
}
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 (
<div className="flex flex-col gap-6">
{blocks.map((block) => {
if (block.kind === "text") {
return (
<ModalTextAreaField
key={block.id}
label={block.blockTitle}
rows={6}
value={block.placeholderText}
onChange={(v) =>
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 (
<div key={block.id} className="flex flex-col gap-2">
<InputLabel
label={block.blockTitle}
helpIcon
size="s"
palette="default"
/>
{block.options.length > 0 ? (
<div className="flex flex-wrap items-center gap-2">
{block.options.map((opt, idx) => (
<Chip
key={`${block.id}-${idx}`}
label={opt}
state="selected"
palette="default"
size="s"
disabled
ariaLabel={opt}
/>
))}
</div>
) : (
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m)] text-[var(--color-content-default-secondary)]">
{emptyValue}
</p>
)}
</div>
);
}
return (
<ApplicableScopeField
key={block.id}
label={block.blockTitle}
addLabel={fm.badges.addOptionLabel}
scopes={block.options}
selectedScopes={block.options}
onToggleScope={(scope) =>
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 (
<div key={block.id}>
{readOnly ? (
<div className="flex flex-col gap-2">
<InputLabel
label={block.blockTitle}
helpIcon
size="s"
palette="default"
/>
{block.assetUrl?.trim() ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={block.assetUrl.trim()}
alt={
block.fileName?.trim() ||
block.blockTitle ||
noFileChosen
}
className="max-h-[160px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
/>
) : (
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m)] text-[var(--color-content-default-secondary)]">
{noFileChosen}
</p>
)}
</div>
) : (
<CustomMethodCardUploadBlockRow
block={block}
blocks={blocks}
patch={patch}
uploadFileInputAriaLabel={fm.upload.uploadFileInputAriaLabel}
uploadHint={fm.upload.uploadHint}
clearPendingUploadAriaLabel={
fm.upload.clearPendingUploadAriaLabel
}
clearPendingUploadTooltip={
fm.upload.clearPendingUploadTooltip
}
uploadPreviewImageAlt={fm.upload.uploadPreviewImageAlt}
noFileChosen={noFileChosen}
/>
)}
</div>
);
}
return (
<IncrementerBlock
key={block.id}
label={block.blockTitle}
value={block.defaultPercent}
min={1}
max={100}
step={1}
disabled={readOnly}
onChange={(v) =>
patch(
mapBlockById(blocks, block.id, (b) =>
b.kind === "proportion" ? { ...b, defaultPercent: v } : b,
),
)
}
formatValue={(v) => `${v}%`}
decrementAriaLabel={fm.proportion.decrementAriaLabel}
incrementAriaLabel={fm.proportion.incrementAriaLabel}
/>
);
})}
</div>
);
}
const CustomMethodCardFieldBlocksSummary = memo(
CustomMethodCardFieldBlocksSummaryComponent,
);
CustomMethodCardFieldBlocksSummary.displayName =
"CustomMethodCardFieldBlocksSummary";
export default CustomMethodCardFieldBlocksSummary;
@@ -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<NonNullable<typeof onBlocksChange>>[0]) => {
onBlocksChange?.(next);
},
[onBlocksChange],
);
return (
<CustomMethodCardFieldBlocksSummaryView
blocks={blocks}
readOnly={readOnly}
emptyValue={em.readout.emptyValue}
noFileChosen={em.readout.noFileChosen}
fieldModalsCopy={{
badges: { addOptionLabel: fm.badges.addOptionLabel },
upload: {
uploadFileInputAriaLabel: fm.upload.uploadFileInputAriaLabel,
uploadHint: fm.upload.uploadHint,
clearPendingUploadAriaLabel: fm.upload.clearPendingUploadAriaLabel,
clearPendingUploadTooltip: fm.upload.clearPendingUploadTooltip,
uploadPreviewImageAlt: fm.upload.uploadPreviewImageAlt,
},
proportion: {
decrementAriaLabel: fm.proportion.decrementAriaLabel,
incrementAriaLabel: fm.proportion.incrementAriaLabel,
},
}}
onPatch={onPatch}
/>
);
}
const CustomMethodCardFieldBlocksSummary = memo(
CustomMethodCardFieldBlocksSummaryContainerComponent,
);
CustomMethodCardFieldBlocksSummary.displayName =
"CustomMethodCardFieldBlocksSummary";
export default CustomMethodCardFieldBlocksSummary;
@@ -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<CustomMethodCardFieldBlock, { kind: "upload" }>;
blocks: CustomMethodCardFieldBlock[];
onPatch: (_next: CustomMethodCardFieldBlock[]) => void;
uploadFileInputAriaLabel: string;
uploadHint: string;
clearPendingUploadAriaLabel: string;
clearPendingUploadTooltip: string;
uploadPreviewImageAlt: string;
noFileChosen: string;
};
export type CustomMethodCardUploadBlockRowViewProps =
CustomMethodCardUploadBlockRowProps & {
uploadInputRef: RefObject<HTMLInputElement | null>;
busy: boolean;
uploadingHint: string;
errorMessage: string | null;
onClearUpload: () => void;
onFileInputChange: ChangeEventHandler<HTMLInputElement>;
onUploadClick: () => void;
};
@@ -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 (
<div className="flex flex-col gap-6">
{blocks.map((block) => {
if (block.kind === "text") {
return (
<ModalTextAreaField
key={block.id}
label={block.blockTitle}
rows={6}
value={block.placeholderText}
onChange={(v) =>
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 (
<div key={block.id} className="flex flex-col gap-2">
<InputLabel
label={block.blockTitle}
helpIcon
size="s"
palette="default"
/>
{block.options.length > 0 ? (
<div className="flex flex-wrap items-center gap-2">
{block.options.map((opt, idx) => (
<Chip
key={`${block.id}-${idx}`}
label={opt}
state="selected"
palette="default"
size="s"
disabled
ariaLabel={opt}
/>
))}
</div>
) : (
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m)] text-[var(--color-content-default-secondary)]">
{emptyValue}
</p>
)}
</div>
);
}
return (
<ApplicableScopeField
key={block.id}
label={block.blockTitle}
addLabel={fm.badges.addOptionLabel}
scopes={block.options}
selectedScopes={block.options}
onToggleScope={(scope) =>
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 (
<div key={block.id}>
{readOnly ? (
<div className="flex flex-col gap-2">
<InputLabel
label={block.blockTitle}
helpIcon
size="s"
palette="default"
/>
{block.assetUrl?.trim() ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={block.assetUrl.trim()}
alt={
block.fileName?.trim() ||
block.blockTitle ||
noFileChosen
}
className="max-h-[160px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
/>
) : (
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m)] text-[var(--color-content-default-secondary)]">
{noFileChosen}
</p>
)}
</div>
) : (
<CustomMethodCardUploadBlockRow
block={block}
blocks={blocks}
onPatch={onPatch}
uploadFileInputAriaLabel={fm.upload.uploadFileInputAriaLabel}
uploadHint={fm.upload.uploadHint}
clearPendingUploadAriaLabel={
fm.upload.clearPendingUploadAriaLabel
}
clearPendingUploadTooltip={
fm.upload.clearPendingUploadTooltip
}
uploadPreviewImageAlt={fm.upload.uploadPreviewImageAlt}
noFileChosen={noFileChosen}
/>
)}
</div>
);
}
return (
<IncrementerBlock
key={block.id}
label={block.blockTitle}
value={block.defaultPercent}
min={1}
max={100}
step={1}
disabled={readOnly}
onChange={(v) =>
onPatch(
mapBlockById(blocks, block.id, (b) =>
b.kind === "proportion" ? { ...b, defaultPercent: v } : b,
),
)
}
formatValue={(v) => `${v}%`}
decrementAriaLabel={fm.proportion.decrementAriaLabel}
incrementAriaLabel={fm.proportion.incrementAriaLabel}
/>
);
})}
</div>
);
}
export const CustomMethodCardFieldBlocksSummaryView = memo(
CustomMethodCardFieldBlocksSummaryViewComponent,
);
CustomMethodCardFieldBlocksSummaryView.displayName =
"CustomMethodCardFieldBlocksSummaryView";
@@ -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<HTMLInputElement | null>(null);
const tUpload = useTranslation("create.upload");
const [busy, setBusy] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(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<HTMLInputElement>
>(
(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 (
<CustomMethodCardUploadBlockRowView
block={block}
blocks={blocks}
onPatch={onPatch}
uploadFileInputAriaLabel={uploadFileInputAriaLabel}
uploadHint={uploadHint}
clearPendingUploadAriaLabel={clearPendingUploadAriaLabel}
clearPendingUploadTooltip={clearPendingUploadTooltip}
uploadPreviewImageAlt={uploadPreviewImageAlt}
noFileChosen={noFileChosen}
uploadInputRef={uploadInputRef}
busy={busy}
uploadingHint={tUpload("uploading")}
errorMessage={errorMessage}
onClearUpload={clearUpload}
onFileInputChange={handleFileInputChange}
onUploadClick={handleUploadClick}
/>
);
}
export const CustomMethodCardUploadBlockRow = memo(
CustomMethodCardUploadBlockRowContainerComponent,
);
CustomMethodCardUploadBlockRow.displayName = "CustomMethodCardUploadBlockRow";
@@ -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 (
<div className="flex flex-col gap-2">
<InputLabel
label={block.blockTitle}
helpIcon
size="s"
palette="default"
/>
{!hasAsset ? (
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m)] text-[var(--color-content-default-secondary)]">
{displayName}
</p>
) : null}
<input
ref={uploadInputRef}
type="file"
className="sr-only"
tabIndex={-1}
accept="image/jpeg,image/png,image/webp,image/gif,application/pdf"
aria-label={uploadFileInputAriaLabel}
onChange={onFileInputChange}
/>
{hasAsset ? (
<div className="relative inline-block max-w-full">
<button
type="button"
onClick={onClearUpload}
className="absolute right-[8px] top-[8px] z-[1] flex h-[32px] w-[32px] cursor-pointer items-center justify-center rounded-full bg-[var(--color-surface-default-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]"
aria-label={clearPendingUploadAriaLabel}
title={clearPendingUploadTooltip}
>
{/* eslint-disable-next-line @next/next/no-img-element -- matches ModalHeader close control */}
<img
src={getAssetPath(ASSETS.ICON_CLOSE)}
alt=""
className="h-[16px] w-[16px]"
style={{
filter: "brightness(0) invert(1)",
}}
/>
</button>
{/* eslint-disable-next-line @next/next/no-img-element -- same-origin upload URL */}
<img
src={assetUrlTrimmed}
alt={uploadPreviewImageAlt}
className="max-h-[160px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
/>
</div>
) : (
<Upload
active={!busy}
hintText={busy ? uploadingHint : uploadHint}
onClick={onUploadClick}
/>
)}
{errorMessage ? (
<p
className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-s)] text-[var(--color-content-default-secondary)]"
role="alert"
>
{errorMessage}
</p>
) : null}
</div>
);
}
export const CustomMethodCardUploadBlockRowView = memo(
CustomMethodCardUploadBlockRowViewComponent,
);
CustomMethodCardUploadBlockRowView.displayName =
"CustomMethodCardUploadBlockRowView";
@@ -0,0 +1,2 @@
export { default } from "./CustomMethodCardFieldBlocksSummary.container";
export type { CustomMethodCardFieldBlocksSummaryProps } from "./CustomMethodCardFieldBlocksSummary.types";
@@ -6,7 +6,7 @@ import InputWithCounter from "../../../../components/controls/InputWithCounter";
import TextArea from "../../../../components/controls/TextArea"; import TextArea from "../../../../components/controls/TextArea";
import AddCustomField from "../../../../components/controls/AddCustomField"; import AddCustomField from "../../../../components/controls/AddCustomField";
import { CustomMethodCardWizardFieldBodiesView } from "./CustomMethodCardWizardFieldBodies.view"; import { CustomMethodCardWizardFieldBodiesView } from "./CustomMethodCardWizardFieldBodies.view";
import { CustomMethodCardWizardBlocksListView } from "./CustomMethodCardWizardBlocksList.view"; import { CustomMethodCardWizardBlocksList } from "./CustomMethodCardWizardBlocksList.container";
import type { CustomMethodCardWizardViewProps } from "./CustomMethodCardWizard.types"; import type { CustomMethodCardWizardViewProps } from "./CustomMethodCardWizard.types";
function CustomMethodCardWizardViewComponent({ function CustomMethodCardWizardViewComponent({
@@ -90,7 +90,7 @@ function CustomMethodCardWizardViewComponent({
{!fieldTypeModal && wizardStep === 3 ? ( {!fieldTypeModal && wizardStep === 3 ? (
<div className="flex w-full flex-col gap-4"> <div className="flex w-full flex-col gap-4">
{draftFieldBlocks.length > 0 ? ( {draftFieldBlocks.length > 0 ? (
<CustomMethodCardWizardBlocksListView <CustomMethodCardWizardBlocksList
blocks={draftFieldBlocks} blocks={draftFieldBlocks}
fieldTypeLabels={copy.fieldTypeLabels} fieldTypeLabels={copy.fieldTypeLabels}
dragHandleAriaLabel={copy.step3BlocksList.dragHandleAriaLabel} dragHandleAriaLabel={copy.step3BlocksList.dragHandleAriaLabel}
@@ -0,0 +1,77 @@
"use client";
import { memo, useCallback, useState, type DragEvent } from "react";
import { reorderCustomMethodCardFieldBlocks } from "../../../../../lib/create/reorderCustomMethodCardFieldBlocks";
import { CustomMethodCardWizardBlocksListView } from "./CustomMethodCardWizardBlocksList.view";
import type { CustomMethodCardWizardBlocksListProps } from "./CustomMethodCardWizardBlocksList.types";
function CustomMethodCardWizardBlocksListContainerComponent({
blocks,
fieldTypeLabels,
dragHandleAriaLabel,
listLabel,
onBlocksReorder,
}: CustomMethodCardWizardBlocksListProps) {
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
const [overIndex, setOverIndex] = useState<number | null>(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 (
<CustomMethodCardWizardBlocksListView
blocks={blocks}
fieldTypeLabels={fieldTypeLabels}
dragHandleAriaLabel={dragHandleAriaLabel}
listLabel={listLabel}
onBlocksReorder={onBlocksReorder}
draggingIndex={draggingIndex}
overIndex={overIndex}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnd={clearDragUi}
/>
);
}
export const CustomMethodCardWizardBlocksList = memo(
CustomMethodCardWizardBlocksListContainerComponent,
);
CustomMethodCardWizardBlocksList.displayName =
"CustomMethodCardWizardBlocksList";
@@ -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<AddCustomFieldType, string>;
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;
}
@@ -1,11 +1,10 @@
"use client"; "use client";
import { memo, useCallback, useState, type DragEvent } from "react"; import { memo } from "react";
import Icon from "../../../../components/asset/icon"; import Icon from "../../../../components/asset/icon";
import { ADD_CUSTOM_FIELD_TYPE_ICONS } from "../../../../components/controls/AddCustomField/AddCustomField.types"; import { ADD_CUSTOM_FIELD_TYPE_ICONS } from "../../../../components/controls/AddCustomField/AddCustomField.types";
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types"; import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import type { CustomMethodCardWizardBlocksListViewProps } from "./CustomMethodCardWizardBlocksList.types";
import { reorderCustomMethodCardFieldBlocks } from "../../../../../lib/create/reorderCustomMethodCardFieldBlocks";
function DragHandleGlyph({ className }: { className?: string }) { function DragHandleGlyph({ className }: { className?: string }) {
return ( return (
@@ -28,62 +27,18 @@ function DragHandleGlyph({ className }: { className?: string }) {
); );
} }
export interface CustomMethodCardWizardBlocksListViewProps {
blocks: CustomMethodCardFieldBlock[];
fieldTypeLabels: Record<AddCustomFieldType, string>;
dragHandleAriaLabel: string;
listLabel: string;
onBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void;
}
function CustomMethodCardWizardBlocksListViewComponent({ function CustomMethodCardWizardBlocksListViewComponent({
blocks, blocks,
fieldTypeLabels, fieldTypeLabels,
dragHandleAriaLabel, dragHandleAriaLabel,
listLabel, listLabel,
onBlocksReorder, draggingIndex,
overIndex,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
}: CustomMethodCardWizardBlocksListViewProps) { }: CustomMethodCardWizardBlocksListViewProps) {
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
const [overIndex, setOverIndex] = useState<number | null>(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 ( return (
<ul className="flex list-none flex-col gap-2 p-0" aria-label={listLabel}> <ul className="flex list-none flex-col gap-2 p-0" aria-label={listLabel}>
{blocks.map((block, index) => { {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)]" ? "ring-2 ring-[var(--color-border-invert-primary)] ring-offset-2 ring-offset-[var(--color-surface-default-primary)]"
: "" : ""
} ${draggingIndex === index ? "opacity-60" : ""}`} } ${draggingIndex === index ? "opacity-60" : ""}`}
onDragOver={handleDragOver(index)} onDragOver={onDragOver(index)}
onDrop={handleDrop(index)} onDrop={onDrop(index)}
> >
<button <button
type="button" type="button"
draggable draggable
onDragStart={handleDragStart(index)} onDragStart={onDragStart(index)}
onDragEnd={clearDragUi} onDragEnd={onDragEnd}
className="flex shrink-0 cursor-grab touch-manipulation items-center justify-center rounded-[var(--measures-radius-200,8px)] border-0 bg-transparent px-1 text-[var(--color-content-default-secondary)] active:cursor-grabbing focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)]" className="flex shrink-0 cursor-grab touch-manipulation items-center justify-center rounded-[var(--measures-radius-200,8px)] border-0 bg-transparent px-1 text-[var(--color-content-default-secondary)] active:cursor-grabbing focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)]"
aria-label={dragHandleAriaLabel} aria-label={dragHandleAriaLabel}
> >
@@ -1,7 +1,7 @@
"use client"; "use client";
import { memo } from "react"; import { memo } from "react";
import { getAssetPath } from "../../../../../lib/assetUtils"; import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
import InputWithCounter from "../../../../components/controls/InputWithCounter"; import InputWithCounter from "../../../../components/controls/InputWithCounter";
import TextArea from "../../../../components/controls/TextArea"; import TextArea from "../../../../components/controls/TextArea";
import TextInput from "../../../../components/controls/TextInput"; import TextInput from "../../../../components/controls/TextInput";
@@ -140,7 +140,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
> >
{/* eslint-disable-next-line @next/next/no-img-element -- matches ModalHeader close control */} {/* eslint-disable-next-line @next/next/no-img-element -- matches ModalHeader close control */}
<img <img
src={getAssetPath("assets/Icon_Close.svg")} src={getAssetPath(ASSETS.ICON_CLOSE)}
alt="" alt=""
className="h-[16px] w-[16px]" className="h-[16px] w-[16px]"
style={{ style={{
@@ -28,6 +28,7 @@ import {
import CustomMethodCardModalBody from "./CustomMethodCardModalBody"; import CustomMethodCardModalBody from "./CustomMethodCardModalBody";
import MethodCardCustomizeModalHeader from "./MethodCardCustomizeModalHeader"; import MethodCardCustomizeModalHeader from "./MethodCardCustomizeModalHeader";
import { buildCustomRuleModalKebabMenu } from "./customRuleModalKebabMenu"; import { buildCustomRuleModalKebabMenu } from "./customRuleModalKebabMenu";
import { useDiscardCustomizeConfirm } from "../hooks/useDiscardCustomizeConfirm";
import { import {
communicationPresetFor, communicationPresetFor,
conflictManagementPresetFor, conflictManagementPresetFor,
@@ -52,7 +53,6 @@ import {
} from "../../../../lib/create/coreValueChipFacet"; } from "../../../../lib/create/coreValueChipFacet";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
confirmDiscardMethodCardCustomizeSession,
isMethodCardCustomizeSessionDirty, isMethodCardCustomizeSessionDirty,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
@@ -171,6 +171,8 @@ export function FinalReviewChipEditModal({
const tModal = useTranslation( const tModal = useTranslation(
"create.reviewAndComplete.finalReview.chipEditModal", "create.reviewAndComplete.finalReview.chipEditModal",
); );
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
useDiscardCustomizeConfirm();
const [draft, setDraft] = useState<Draft | null>(null); const [draft, setDraft] = useState<Draft | null>(null);
const [modalEditUnlocked, setModalEditUnlocked] = useState(false); const [modalEditUnlocked, setModalEditUnlocked] = useState(false);
@@ -342,32 +344,30 @@ export function FinalReviewChipEditModal({
onClose(); onClose();
}, [onClose]); }, [onClose]);
const handleModalClose = useCallback(() => { const handleModalClose = useCallback(async () => {
if ( if (
target && target &&
target.groupKey === "coreValues" && target.groupKey === "coreValues" &&
!confirmDiscardMethodCardCustomizeSession( !(await confirmDiscard(
modalEditUnlocked, modalEditUnlocked,
coreCustomizeSnapshotRef.current, coreCustomizeSnapshotRef.current,
draft?.groupKey === "coreValues" ? draft.value : null, draft?.groupKey === "coreValues" ? draft.value : null,
null, null,
customizeHeaderDraft, customizeHeaderDraft,
modalKebabMenu.discardUnsavedCustomizeChanges, ))
)
) { ) {
return; return;
} }
if ( if (
target && target &&
isMethodFacetGroup(target.groupKey) && isMethodFacetGroup(target.groupKey) &&
!confirmDiscardMethodCardCustomizeSession( !(await confirmDiscard(
modalEditUnlocked, modalEditUnlocked,
customizeSnapshotRef.current, customizeSnapshotRef.current,
methodDetailDraftForCustomizeSession(draft), methodDetailDraftForCustomizeSession(draft),
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeHeaderDraft,
modalKebabMenu.discardUnsavedCustomizeChanges, ))
)
) { ) {
return; return;
} }
@@ -380,17 +380,17 @@ export function FinalReviewChipEditModal({
} }
finalizeModalClose(); finalizeModalClose();
}, [ }, [
confirmDiscard,
customizeHeaderDraft, customizeHeaderDraft,
draft, draft,
draftFieldBlocks, draftFieldBlocks,
finalizeModalClose, finalizeModalClose,
modalEditUnlocked, modalEditUnlocked,
modalKebabMenu.discardUnsavedCustomizeChanges,
replaceState, replaceState,
target, target,
]); ]);
const handleCancelCustomize = useCallback(() => { const handleCancelCustomize = useCallback(async () => {
if (!modalEditUnlocked || !target) { if (!modalEditUnlocked || !target) {
return; return;
} }
@@ -404,13 +404,12 @@ export function FinalReviewChipEditModal({
} }
if ( if (
draft?.groupKey === "coreValues" && draft?.groupKey === "coreValues" &&
isMethodCardCustomizeSessionDirty( !(await confirmDirtyCustomizeCancel(
snap, snap,
draft.value, draft.value,
null, null,
customizeHeaderDraft, customizeHeaderDraft,
) && ))
!window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges)
) { ) {
return; return;
} }
@@ -435,13 +434,12 @@ export function FinalReviewChipEditModal({
return; return;
} }
if ( if (
isMethodCardCustomizeSessionDirty( !(await confirmDirtyCustomizeCancel(
snap, snap,
methodDetailDraftForCustomizeSession(draft), methodDetailDraftForCustomizeSession(draft),
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeHeaderDraft,
) && ))
!window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges)
) { ) {
return; return;
} }
@@ -451,11 +449,11 @@ export function FinalReviewChipEditModal({
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setCustomizeHeaderDraft(null); setCustomizeHeaderDraft(null);
}, [ }, [
confirmDirtyCustomizeCancel,
customizeHeaderDraft, customizeHeaderDraft,
draft, draft,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked, modalEditUnlocked,
modalKebabMenu.discardUnsavedCustomizeChanges,
target, target,
]); ]);
@@ -565,7 +563,7 @@ export function FinalReviewChipEditModal({
tCm, tCm,
]); ]);
const handleRemoveSelectedFromModal = useCallback(() => { const handleRemoveSelectedFromModal = useCallback(async () => {
if (!target || !isMethodFacetGroup(target.groupKey)) { if (!target || !isMethodFacetGroup(target.groupKey)) {
return; return;
} }
@@ -575,14 +573,13 @@ export function FinalReviewChipEditModal({
} }
onInteract?.(); onInteract?.();
if ( if (
!confirmDiscardMethodCardCustomizeSession( !(await confirmDiscard(
modalEditUnlocked, modalEditUnlocked,
customizeSnapshotRef.current, customizeSnapshotRef.current,
methodDetailDraftForCustomizeSession(draft), methodDetailDraftForCustomizeSession(draft),
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeHeaderDraft,
modalKebabMenu.discardUnsavedCustomizeChanges, ))
)
) { ) {
return; return;
} }
@@ -597,32 +594,31 @@ export function FinalReviewChipEditModal({
})); }));
finalizeModalClose(); finalizeModalClose();
}, [ }, [
confirmDiscard,
customizeHeaderDraft, customizeHeaderDraft,
draft, draft,
draftFieldBlocks, draftFieldBlocks,
finalizeModalClose, finalizeModalClose,
modalEditUnlocked, modalEditUnlocked,
modalKebabMenu.discardUnsavedCustomizeChanges,
onInteract, onInteract,
replaceState, replaceState,
selectionIdsForTarget, selectionIdsForTarget,
target, target,
]); ]);
const handleRemoveCoreValueFromModal = useCallback(() => { const handleRemoveCoreValueFromModal = useCallback(async () => {
if (!target || target.groupKey !== "coreValues") { if (!target || target.groupKey !== "coreValues") {
return; return;
} }
onInteract?.(); onInteract?.();
if ( if (
!confirmDiscardMethodCardCustomizeSession( !(await confirmDiscard(
modalEditUnlocked, modalEditUnlocked,
coreCustomizeSnapshotRef.current, coreCustomizeSnapshotRef.current,
draft?.groupKey === "coreValues" ? draft.value : null, draft?.groupKey === "coreValues" ? draft.value : null,
null, null,
customizeHeaderDraft, customizeHeaderDraft,
modalKebabMenu.discardUnsavedCustomizeChanges, ))
)
) { ) {
return; return;
} }
@@ -634,17 +630,17 @@ export function FinalReviewChipEditModal({
})); }));
finalizeModalClose(); finalizeModalClose();
}, [ }, [
confirmDiscard,
customizeHeaderDraft, customizeHeaderDraft,
draft, draft,
finalizeModalClose, finalizeModalClose,
modalEditUnlocked, modalEditUnlocked,
modalKebabMenu.discardUnsavedCustomizeChanges,
onInteract, onInteract,
replaceState, replaceState,
target, target,
]); ]);
const handleDuplicateCoreValue = useCallback(() => { const handleDuplicateCoreValue = useCallback(async () => {
if ( if (
!target || !target ||
target.groupKey !== "coreValues" || target.groupKey !== "coreValues" ||
@@ -659,14 +655,13 @@ export function FinalReviewChipEditModal({
return; return;
} }
if ( if (
!confirmDiscardMethodCardCustomizeSession( !(await confirmDiscard(
modalEditUnlocked, modalEditUnlocked,
coreCustomizeSnapshotRef.current, coreCustomizeSnapshotRef.current,
draft.value, draft.value,
null, null,
customizeHeaderDraft, customizeHeaderDraft,
modalKebabMenu.discardUnsavedCustomizeChanges, ))
)
) { ) {
return; return;
} }
@@ -711,10 +706,10 @@ export function FinalReviewChipEditModal({
chipLabel: outcome.newLabel, chipLabel: outcome.newLabel,
}); });
}, [ }, [
confirmDiscard,
customizeHeaderDraft, customizeHeaderDraft,
draft, draft,
modalEditUnlocked, modalEditUnlocked,
modalKebabMenu.discardUnsavedCustomizeChanges,
modalKebabMenu.duplicateTitleSuffix, modalKebabMenu.duplicateTitleSuffix,
onEditTargetChange, onEditTargetChange,
onInteract, onInteract,
@@ -1015,6 +1010,7 @@ export function FinalReviewChipEditModal({
: showMethodModalPrimary; : showMethodModalPrimary;
return ( return (
<>
<Create <Create
isOpen={isOpen} isOpen={isOpen}
onClose={handleModalClose} onClose={handleModalClose}
@@ -1184,6 +1180,8 @@ export function FinalReviewChipEditModal({
))} ))}
</div> </div>
</Create> </Create>
{confirmDialog}
</>
); );
} }
+17 -8
View File
@@ -5,10 +5,9 @@ import type { CreateFlowState, CreateFlowStep } from "../types";
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload"; import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
import { saveDraftToServer, updatePublishedRule } from "../../../../lib/create/api"; import { saveDraftToServer, updatePublishedRule } from "../../../../lib/create/api";
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule"; import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
import { isBackendSyncEnabled } from "../../../../lib/create/backendSyncEnabled";
import messages from "../../../../messages/en/index"; import messages from "../../../../messages/en/index";
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
export type CreateFlowExitClearState = () => void; export type CreateFlowExitClearState = () => void;
type AppRouterLike = { push: (_href: string) => void }; type AppRouterLike = { push: (_href: string) => void };
@@ -23,6 +22,7 @@ export function useCreateFlowExit({
router, router,
user, user,
setDraftSaveBannerMessage, setDraftSaveBannerMessage,
confirmLeave,
}: { }: {
state: CreateFlowState; state: CreateFlowState;
currentStep: CreateFlowStep | null; currentStep: CreateFlowStep | null;
@@ -31,6 +31,8 @@ export function useCreateFlowExit({
user: { id: string; email: string } | null; user: { id: string; email: string } | null;
/** When save fails, surface the server message in the create shell banner (no leave confirm). */ /** When save fails, surface the server message in the create shell banner (no leave confirm). */
setDraftSaveBannerMessage?: (_message: string | null) => void; setDraftSaveBannerMessage?: (_message: string | null) => void;
/** When exit would discard unsaved work, return true to proceed. Defaults to denying leave. */
confirmLeave?: () => Promise<boolean>;
}): (_options?: { saveDraft?: boolean }) => Promise<void> { }): (_options?: { saveDraft?: boolean }) => Promise<void> {
return useCallback( return useCallback(
async (options?: { saveDraft?: boolean }) => { async (options?: { saveDraft?: boolean }) => {
@@ -38,14 +40,13 @@ export function useCreateFlowExit({
const saveDraft = options?.saveDraft ?? false; const saveDraft = options?.saveDraft ?? false;
if (!saveDraft && typeof window !== "undefined") { if (!saveDraft) {
const confirmed = window.confirm( const confirmFn = confirmLeave ?? (async () => false);
messages.create.topNav.leaveConfirmLoss, const confirmed = await confirmFn();
);
if (!confirmed) return; if (!confirmed) return;
} }
if (saveDraft && SYNC_ENABLED) { if (saveDraft && isBackendSyncEnabled()) {
const editingId = const editingId =
typeof state.editingPublishedRuleId === "string" typeof state.editingPublishedRuleId === "string"
? state.editingPublishedRuleId.trim() ? state.editingPublishedRuleId.trim()
@@ -97,6 +98,14 @@ export function useCreateFlowExit({
clearState(); clearState();
router.push("/"); router.push("/");
}, },
[state, currentStep, clearState, router, user, setDraftSaveBannerMessage], [
state,
currentStep,
clearState,
router,
user,
setDraftSaveBannerMessage,
confirmLeave,
],
); );
} }
@@ -0,0 +1,78 @@
"use client";
import { useCallback } from "react";
import messages from "../../../../messages/en/index";
import { useAsyncConfirm } from "../../../hooks/useAsyncConfirm";
import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks";
import {
confirmDiscardMethodCardCustomizeSession,
isMethodCardCustomizeSessionDirty,
type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft,
} from "../../../../lib/create/methodCardCustomizeSession";
const copy = messages.create.customRule.modalKebabMenu;
const confirmOptions = {
title: copy.discardUnsavedCustomizeChangesTitle,
description: copy.discardUnsavedCustomizeChangesDescription,
proceedText: copy.discardUnsavedCustomizeChangesProceed,
cancelText: copy.discardUnsavedCustomizeChangesCancel,
};
/**
* Create-flow confirm for exiting customize mode with unsaved edits.
*
* @returns Async helpers plus `confirmDialog` to render once in the screen JSX.
*/
export function useDiscardCustomizeConfirm() {
const { requestConfirm, confirmDialog } = useAsyncConfirm();
const runConfirm = useCallback(
() => requestConfirm(confirmOptions),
[requestConfirm],
);
const confirmDiscard = useCallback(
async <TDraft,>(
modalEditUnlocked: boolean,
snapshot: MethodCardCustomizeSnapshot<TDraft> | null,
pendingDraft: TDraft | null,
draftFieldBlocks: CustomMethodCardFieldBlock[] | null,
headerDraft: MethodCardHeaderDraft | null,
) =>
confirmDiscardMethodCardCustomizeSession(
modalEditUnlocked,
snapshot,
pendingDraft,
draftFieldBlocks,
headerDraft,
runConfirm,
),
[runConfirm],
);
const confirmDirtyCustomizeCancel = useCallback(
async <TDraft,>(
snapshot: MethodCardCustomizeSnapshot<TDraft>,
pendingDraft: TDraft | null,
draftFieldBlocks: CustomMethodCardFieldBlock[] | null,
headerDraft: MethodCardHeaderDraft | null,
) => {
if (
!isMethodCardCustomizeSessionDirty(
snapshot,
pendingDraft,
draftFieldBlocks,
headerDraft,
)
) {
return true;
}
return runConfirm();
},
[runConfirm],
);
return { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog };
}
@@ -19,6 +19,7 @@ import { useState, useCallback, useMemo, useRef } from "react";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../../components/cards/CardStack"; import CardStack from "../../../../components/cards/CardStack";
@@ -53,8 +54,6 @@ import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalK
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch"; import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
confirmDiscardMethodCardCustomizeSession,
isMethodCardCustomizeSessionDirty,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession"; } from "../../../../../lib/create/methodCardCustomizeSession";
@@ -65,6 +64,8 @@ export function CommunicationMethodsScreen() {
const comm = m.create.customRule.communication; const comm = m.create.customRule.communication;
const modalKebabMenu = m.create.customRule.modalKebabMenu; const modalKebabMenu = m.create.customRule.modalKebabMenu;
const mdUp = useCreateFlowMdUp(); const mdUp = useCreateFlowMdUp();
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
useDiscardCustomizeConfirm();
const { state, updateState, replaceState, markCreateFlowInteraction } = const { state, updateState, replaceState, markCreateFlowInteraction } =
useCreateFlow(); useCreateFlow();
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null); const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
@@ -201,16 +202,15 @@ export function CommunicationMethodsScreen() {
], ],
); );
const handleCreateModalClose = useCallback(() => { const handleCreateModalClose = useCallback(async () => {
if ( if (
!confirmDiscardMethodCardCustomizeSession( !(await confirmDiscard(
modalEditUnlocked, modalEditUnlocked,
customizeSnapshotRef.current, customizeSnapshotRef.current,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeHeaderDraft,
modalKebabMenu.discardUnsavedCustomizeChanges, ))
)
) { ) {
return; return;
} }
@@ -241,15 +241,15 @@ export function CommunicationMethodsScreen() {
setDraftFieldBlocks(null); setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null); setCustomizeHeaderDraft(null);
}, [ }, [
confirmDiscard,
customizeHeaderDraft, customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked, modalEditUnlocked,
modalKebabMenu.discardUnsavedCustomizeChanges,
pendingDraft, pendingDraft,
replaceState, replaceState,
]); ]);
const handleCancelCustomize = useCallback(() => { const handleCancelCustomize = useCallback(async () => {
if (!modalEditUnlocked) { if (!modalEditUnlocked) {
return; return;
} }
@@ -262,13 +262,12 @@ export function CommunicationMethodsScreen() {
return; return;
} }
if ( if (
isMethodCardCustomizeSessionDirty( !(await confirmDirtyCustomizeCancel(
snap, snap,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeHeaderDraft,
) && ))
!window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges)
) { ) {
return; return;
} }
@@ -278,27 +277,26 @@ export function CommunicationMethodsScreen() {
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setCustomizeHeaderDraft(null); setCustomizeHeaderDraft(null);
}, [ }, [
confirmDirtyCustomizeCancel,
customizeHeaderDraft, customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked, modalEditUnlocked,
modalKebabMenu.discardUnsavedCustomizeChanges,
pendingDraft, pendingDraft,
]); ]);
const handleRemoveSelectedFromModal = useCallback(() => { const handleRemoveSelectedFromModal = useCallback(async () => {
if (!pendingCardId || !selectedIds.includes(pendingCardId)) { if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
return; return;
} }
markCreateFlowInteraction(); markCreateFlowInteraction();
if ( if (
!confirmDiscardMethodCardCustomizeSession( !(await confirmDiscard(
modalEditUnlocked, modalEditUnlocked,
customizeSnapshotRef.current, customizeSnapshotRef.current,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeHeaderDraft,
modalKebabMenu.discardUnsavedCustomizeChanges, ))
)
) { ) {
return; return;
} }
@@ -310,14 +308,14 @@ export function CommunicationMethodsScreen() {
pendingCardId, pendingCardId,
), ),
); );
handleCreateModalClose(); await handleCreateModalClose();
}, [ }, [
confirmDiscard,
customizeHeaderDraft, customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
handleCreateModalClose, handleCreateModalClose,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked, modalEditUnlocked,
modalKebabMenu.discardUnsavedCustomizeChanges,
pendingDraft, pendingDraft,
pendingCardId, pendingCardId,
selectedIds, selectedIds,
@@ -829,6 +827,7 @@ export function CommunicationMethodsScreen() {
uploadCreateFlowFile(file, "customMethodAttachment") uploadCreateFlowFile(file, "customMethodAttachment")
} }
/> />
{confirmDialog}
</> </>
); );
} }
@@ -16,6 +16,7 @@ import { useState, useCallback, useMemo, useRef } from "react";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../../components/cards/CardStack"; import CardStack from "../../../../components/cards/CardStack";
@@ -50,8 +51,6 @@ import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalK
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch"; import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
confirmDiscardMethodCardCustomizeSession,
isMethodCardCustomizeSessionDirty,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession"; } from "../../../../../lib/create/methodCardCustomizeSession";
@@ -62,6 +61,8 @@ export function ConflictManagementScreen() {
const cm = m.create.customRule.conflictManagement; const cm = m.create.customRule.conflictManagement;
const modalKebabMenu = m.create.customRule.modalKebabMenu; const modalKebabMenu = m.create.customRule.modalKebabMenu;
const mdUp = useCreateFlowMdUp(); const mdUp = useCreateFlowMdUp();
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
useDiscardCustomizeConfirm();
const { state, updateState, replaceState, markCreateFlowInteraction } = const { state, updateState, replaceState, markCreateFlowInteraction } =
useCreateFlow(); useCreateFlow();
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null); const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
@@ -202,16 +203,15 @@ export function ConflictManagementScreen() {
], ],
); );
const handleCreateModalClose = useCallback(() => { const handleCreateModalClose = useCallback(async () => {
if ( if (
!confirmDiscardMethodCardCustomizeSession( !(await confirmDiscard(
modalEditUnlocked, modalEditUnlocked,
customizeSnapshotRef.current, customizeSnapshotRef.current,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeHeaderDraft,
modalKebabMenu.discardUnsavedCustomizeChanges, ))
)
) { ) {
return; return;
} }
@@ -242,15 +242,15 @@ export function ConflictManagementScreen() {
setDraftFieldBlocks(null); setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null); setCustomizeHeaderDraft(null);
}, [ }, [
confirmDiscard,
customizeHeaderDraft, customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked, modalEditUnlocked,
modalKebabMenu.discardUnsavedCustomizeChanges,
pendingDraft, pendingDraft,
replaceState, replaceState,
]); ]);
const handleCancelCustomize = useCallback(() => { const handleCancelCustomize = useCallback(async () => {
if (!modalEditUnlocked) { if (!modalEditUnlocked) {
return; return;
} }
@@ -263,13 +263,12 @@ export function ConflictManagementScreen() {
return; return;
} }
if ( if (
isMethodCardCustomizeSessionDirty( !(await confirmDirtyCustomizeCancel(
snap, snap,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeHeaderDraft,
) && ))
!window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges)
) { ) {
return; return;
} }
@@ -279,27 +278,26 @@ export function ConflictManagementScreen() {
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setCustomizeHeaderDraft(null); setCustomizeHeaderDraft(null);
}, [ }, [
confirmDirtyCustomizeCancel,
customizeHeaderDraft, customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked, modalEditUnlocked,
modalKebabMenu.discardUnsavedCustomizeChanges,
pendingDraft, pendingDraft,
]); ]);
const handleRemoveSelectedFromModal = useCallback(() => { const handleRemoveSelectedFromModal = useCallback(async () => {
if (!pendingCardId || !selectedIds.includes(pendingCardId)) { if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
return; return;
} }
markCreateFlowInteraction(); markCreateFlowInteraction();
if ( if (
!confirmDiscardMethodCardCustomizeSession( !(await confirmDiscard(
modalEditUnlocked, modalEditUnlocked,
customizeSnapshotRef.current, customizeSnapshotRef.current,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeHeaderDraft,
modalKebabMenu.discardUnsavedCustomizeChanges, ))
)
) { ) {
return; return;
} }
@@ -311,14 +309,14 @@ export function ConflictManagementScreen() {
pendingCardId, pendingCardId,
), ),
); );
handleCreateModalClose(); await handleCreateModalClose();
}, [ }, [
confirmDiscard,
customizeHeaderDraft, customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
handleCreateModalClose, handleCreateModalClose,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked, modalEditUnlocked,
modalKebabMenu.discardUnsavedCustomizeChanges,
pendingDraft, pendingDraft,
pendingCardId, pendingCardId,
selectedIds, selectedIds,
@@ -828,6 +826,7 @@ export function ConflictManagementScreen() {
uploadCreateFlowFile(file, "customMethodAttachment") uploadCreateFlowFile(file, "customMethodAttachment")
} }
/> />
{confirmDialog}
</> </>
); );
} }
@@ -17,6 +17,7 @@ import { useState, useCallback, useMemo, useRef } from "react";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../../components/cards/CardStack"; import CardStack from "../../../../components/cards/CardStack";
@@ -51,8 +52,6 @@ import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalK
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch"; import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
confirmDiscardMethodCardCustomizeSession,
isMethodCardCustomizeSessionDirty,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession"; } from "../../../../../lib/create/methodCardCustomizeSession";
@@ -63,6 +62,8 @@ export function MembershipMethodsScreen() {
const mem = m.create.customRule.membership; const mem = m.create.customRule.membership;
const modalKebabMenu = m.create.customRule.modalKebabMenu; const modalKebabMenu = m.create.customRule.modalKebabMenu;
const mdUp = useCreateFlowMdUp(); const mdUp = useCreateFlowMdUp();
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
useDiscardCustomizeConfirm();
const { state, updateState, replaceState, markCreateFlowInteraction } = const { state, updateState, replaceState, markCreateFlowInteraction } =
useCreateFlow(); useCreateFlow();
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null); const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
@@ -199,16 +200,15 @@ export function MembershipMethodsScreen() {
], ],
); );
const handleCreateModalClose = useCallback(() => { const handleCreateModalClose = useCallback(async () => {
if ( if (
!confirmDiscardMethodCardCustomizeSession( !(await confirmDiscard(
modalEditUnlocked, modalEditUnlocked,
customizeSnapshotRef.current, customizeSnapshotRef.current,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeHeaderDraft,
modalKebabMenu.discardUnsavedCustomizeChanges, ))
)
) { ) {
return; return;
} }
@@ -239,15 +239,15 @@ export function MembershipMethodsScreen() {
setDraftFieldBlocks(null); setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null); setCustomizeHeaderDraft(null);
}, [ }, [
confirmDiscard,
customizeHeaderDraft, customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked, modalEditUnlocked,
modalKebabMenu.discardUnsavedCustomizeChanges,
pendingDraft, pendingDraft,
replaceState, replaceState,
]); ]);
const handleCancelCustomize = useCallback(() => { const handleCancelCustomize = useCallback(async () => {
if (!modalEditUnlocked) { if (!modalEditUnlocked) {
return; return;
} }
@@ -260,13 +260,12 @@ export function MembershipMethodsScreen() {
return; return;
} }
if ( if (
isMethodCardCustomizeSessionDirty( !(await confirmDirtyCustomizeCancel(
snap, snap,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeHeaderDraft,
) && ))
!window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges)
) { ) {
return; return;
} }
@@ -276,27 +275,26 @@ export function MembershipMethodsScreen() {
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setCustomizeHeaderDraft(null); setCustomizeHeaderDraft(null);
}, [ }, [
confirmDirtyCustomizeCancel,
customizeHeaderDraft, customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked, modalEditUnlocked,
modalKebabMenu.discardUnsavedCustomizeChanges,
pendingDraft, pendingDraft,
]); ]);
const handleRemoveSelectedFromModal = useCallback(() => { const handleRemoveSelectedFromModal = useCallback(async () => {
if (!pendingCardId || !selectedIds.includes(pendingCardId)) { if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
return; return;
} }
markCreateFlowInteraction(); markCreateFlowInteraction();
if ( if (
!confirmDiscardMethodCardCustomizeSession( !(await confirmDiscard(
modalEditUnlocked, modalEditUnlocked,
customizeSnapshotRef.current, customizeSnapshotRef.current,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeHeaderDraft,
modalKebabMenu.discardUnsavedCustomizeChanges, ))
)
) { ) {
return; return;
} }
@@ -304,14 +302,14 @@ export function MembershipMethodsScreen() {
updateState( updateState(
removeMethodCardFromFacetSelection(state, "membership", pendingCardId), removeMethodCardFromFacetSelection(state, "membership", pendingCardId),
); );
handleCreateModalClose(); await handleCreateModalClose();
}, [ }, [
confirmDiscard,
customizeHeaderDraft, customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
handleCreateModalClose, handleCreateModalClose,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked, modalEditUnlocked,
modalKebabMenu.discardUnsavedCustomizeChanges,
pendingDraft, pendingDraft,
pendingCardId, pendingCardId,
selectedIds, selectedIds,
@@ -821,6 +819,7 @@ export function MembershipMethodsScreen() {
uploadCreateFlowFile(file, "customMethodAttachment") uploadCreateFlowFile(file, "customMethodAttachment")
} }
/> />
{confirmDialog}
</> </>
); );
} }
@@ -26,6 +26,7 @@ import type { InfoMessageBoxItem } from "../../../../components/controls/InfoMes
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell"; import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import { DecisionApproachEditFields } from "../../components/methodEditFields"; import { DecisionApproachEditFields } from "../../components/methodEditFields";
@@ -52,8 +53,6 @@ import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalK
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch"; import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
confirmDiscardMethodCardCustomizeSession,
isMethodCardCustomizeSessionDirty,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession"; } from "../../../../../lib/create/methodCardCustomizeSession";
@@ -64,6 +63,8 @@ export function DecisionApproachesScreen() {
const da = m.create.customRule.decisionApproaches; const da = m.create.customRule.decisionApproaches;
const modalKebabMenu = m.create.customRule.modalKebabMenu; const modalKebabMenu = m.create.customRule.modalKebabMenu;
const mdUp = useCreateFlowMdUp(); const mdUp = useCreateFlowMdUp();
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
useDiscardCustomizeConfirm();
const { state, updateState, replaceState, markCreateFlowInteraction } = const { state, updateState, replaceState, markCreateFlowInteraction } =
useCreateFlow(); useCreateFlow();
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null); const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
@@ -216,16 +217,15 @@ export function DecisionApproachesScreen() {
], ],
); );
const handleCreateModalClose = useCallback(() => { const handleCreateModalClose = useCallback(async () => {
if ( if (
!confirmDiscardMethodCardCustomizeSession( !(await confirmDiscard(
modalEditUnlocked, modalEditUnlocked,
customizeSnapshotRef.current, customizeSnapshotRef.current,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeHeaderDraft,
modalKebabMenu.discardUnsavedCustomizeChanges, ))
)
) { ) {
return; return;
} }
@@ -256,15 +256,15 @@ export function DecisionApproachesScreen() {
setDraftFieldBlocks(null); setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null); setCustomizeHeaderDraft(null);
}, [ }, [
confirmDiscard,
customizeHeaderDraft, customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked, modalEditUnlocked,
modalKebabMenu.discardUnsavedCustomizeChanges,
pendingDraft, pendingDraft,
replaceState, replaceState,
]); ]);
const handleCancelCustomize = useCallback(() => { const handleCancelCustomize = useCallback(async () => {
if (!modalEditUnlocked) { if (!modalEditUnlocked) {
return; return;
} }
@@ -277,13 +277,12 @@ export function DecisionApproachesScreen() {
return; return;
} }
if ( if (
isMethodCardCustomizeSessionDirty( !(await confirmDirtyCustomizeCancel(
snap, snap,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeHeaderDraft,
) && ))
!window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges)
) { ) {
return; return;
} }
@@ -293,27 +292,26 @@ export function DecisionApproachesScreen() {
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setCustomizeHeaderDraft(null); setCustomizeHeaderDraft(null);
}, [ }, [
confirmDirtyCustomizeCancel,
customizeHeaderDraft, customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked, modalEditUnlocked,
modalKebabMenu.discardUnsavedCustomizeChanges,
pendingDraft, pendingDraft,
]); ]);
const handleRemoveSelectedFromModal = useCallback(() => { const handleRemoveSelectedFromModal = useCallback(async () => {
if (!pendingCardId || !selectedIds.includes(pendingCardId)) { if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
return; return;
} }
markCreateFlowInteraction(); markCreateFlowInteraction();
if ( if (
!confirmDiscardMethodCardCustomizeSession( !(await confirmDiscard(
modalEditUnlocked, modalEditUnlocked,
customizeSnapshotRef.current, customizeSnapshotRef.current,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeHeaderDraft,
modalKebabMenu.discardUnsavedCustomizeChanges, ))
)
) { ) {
return; return;
} }
@@ -325,14 +323,14 @@ export function DecisionApproachesScreen() {
pendingCardId, pendingCardId,
), ),
); );
handleCreateModalClose(); await handleCreateModalClose();
}, [ }, [
confirmDiscard,
customizeHeaderDraft, customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
handleCreateModalClose, handleCreateModalClose,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked, modalEditUnlocked,
modalKebabMenu.discardUnsavedCustomizeChanges,
pendingDraft, pendingDraft,
pendingCardId, pendingCardId,
selectedIds, selectedIds,
@@ -867,6 +865,7 @@ export function DecisionApproachesScreen() {
uploadCreateFlowFile(file, "customMethodAttachment") uploadCreateFlowFile(file, "customMethodAttachment")
} }
/> />
{confirmDialog}
</> </>
); );
} }
@@ -8,6 +8,7 @@ import ContentLockup from "../../../../components/type/ContentLockup";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { buildCoreValueChipOptionsFromDraft } from "../../../../../lib/create/coreValueChipOptionsFromDraft"; import { buildCoreValueChipOptionsFromDraft } from "../../../../../lib/create/coreValueChipOptionsFromDraft";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import type { import type {
CommunityStructureChipSnapshotRow, CommunityStructureChipSnapshotRow,
CoreValueDetailEntry, CoreValueDetailEntry,
@@ -19,7 +20,6 @@ import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomize
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu"; import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
confirmDiscardMethodCardCustomizeSession,
isMethodCardCustomizeSessionDirty, isMethodCardCustomizeSessionDirty,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
@@ -101,6 +101,8 @@ export function CoreValuesSelectScreen() {
[cv.values], [cv.values],
); );
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
useDiscardCustomizeConfirm();
const { markCreateFlowInteraction, updateState, replaceState, state } = const { markCreateFlowInteraction, updateState, replaceState, state } =
useCreateFlow(); useCreateFlow();
@@ -239,7 +241,7 @@ export function CoreValuesSelectScreen() {
setModalEditUnlocked(true); setModalEditUnlocked(true);
}, [activeModalChipId, coreValueOptions, draft, markCreateFlowInteraction]); }, [activeModalChipId, coreValueOptions, draft, markCreateFlowInteraction]);
const handleCancelCustomize = useCallback(() => { const handleCancelCustomize = useCallback(async () => {
if (!modalEditUnlocked) return; if (!modalEditUnlocked) return;
const snap = coreCustomizeSnapshotRef.current; const snap = coreCustomizeSnapshotRef.current;
if (!snap) { if (!snap) {
@@ -247,18 +249,22 @@ export function CoreValuesSelectScreen() {
return; return;
} }
if ( if (
isMethodCardCustomizeSessionDirty(snap, draft, null, customizeHeaderDraft) && !(await confirmDirtyCustomizeCancel(
!window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges) snap,
draft,
null,
customizeHeaderDraft,
))
) { ) {
return; return;
} }
setDraft(structuredClone(snap.pendingDraft)); setDraft(structuredClone(snap.pendingDraft));
resetCustomizeSession(); resetCustomizeSession();
}, [ }, [
confirmDirtyCustomizeCancel,
customizeHeaderDraft, customizeHeaderDraft,
draft, draft,
modalEditUnlocked, modalEditUnlocked,
modalKebabMenu.discardUnsavedCustomizeChanges,
resetCustomizeSession, resetCustomizeSession,
]); ]);
@@ -271,17 +277,16 @@ export function CoreValuesSelectScreen() {
); );
}, [activeModalChipId, customizeHeaderDraft, coreValueOptions]); }, [activeModalChipId, customizeHeaderDraft, coreValueOptions]);
const handleDuplicateCoreChip = useCallback(() => { const handleDuplicateCoreChip = useCallback(async () => {
if (!activeModalChipId || !modalSession) return; if (!activeModalChipId || !modalSession) return;
if ( if (
!confirmDiscardMethodCardCustomizeSession( !(await confirmDiscard(
modalEditUnlocked, modalEditUnlocked,
coreCustomizeSnapshotRef.current, coreCustomizeSnapshotRef.current,
draft, draft,
null, null,
customizeHeaderDraft, customizeHeaderDraft,
modalKebabMenu.discardUnsavedCustomizeChanges, ))
)
) { ) {
return; return;
} }
@@ -317,11 +322,11 @@ export function CoreValuesSelectScreen() {
); );
}, [ }, [
activeModalChipId, activeModalChipId,
confirmDiscard,
customizeHeaderDraft, customizeHeaderDraft,
draft, draft,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked, modalEditUnlocked,
modalKebabMenu.discardUnsavedCustomizeChanges,
modalKebabMenu.duplicateTitleSuffix, modalKebabMenu.duplicateTitleSuffix,
modalSession, modalSession,
openModal, openModal,
@@ -329,16 +334,15 @@ export function CoreValuesSelectScreen() {
resetCustomizeSession, resetCustomizeSession,
]); ]);
const handleRemoveFromKebab = useCallback(() => { const handleRemoveFromKebab = useCallback(async () => {
if ( if (
!confirmDiscardMethodCardCustomizeSession( !(await confirmDiscard(
modalEditUnlocked, modalEditUnlocked,
coreCustomizeSnapshotRef.current, coreCustomizeSnapshotRef.current,
draft, draft,
null, null,
customizeHeaderDraft, customizeHeaderDraft,
modalKebabMenu.discardUnsavedCustomizeChanges, ))
)
) { ) {
return; return;
} }
@@ -382,30 +386,27 @@ export function CoreValuesSelectScreen() {
finalizeModalDismiss(); finalizeModalDismiss();
}, [ }, [
activeModalChipId, activeModalChipId,
confirmDiscard,
coreValueOptions, coreValueOptions,
customizeHeaderDraft, customizeHeaderDraft,
draft, draft,
finalizeModalDismiss, finalizeModalDismiss,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked, modalEditUnlocked,
modalKebabMenu.discardUnsavedCustomizeChanges,
modalSession, modalSession,
persistCoreValues, persistCoreValues,
replaceState, replaceState,
modalSession,
persistCoreValues,
]); ]);
const handleModalDismiss = useCallback(() => { const handleModalDismiss = useCallback(async () => {
if ( if (
!confirmDiscardMethodCardCustomizeSession( !(await confirmDiscard(
modalEditUnlocked, modalEditUnlocked,
coreCustomizeSnapshotRef.current, coreCustomizeSnapshotRef.current,
draft, draft,
null, null,
customizeHeaderDraft, customizeHeaderDraft,
modalKebabMenu.discardUnsavedCustomizeChanges, ))
)
) { ) {
return; return;
} }
@@ -435,12 +436,12 @@ export function CoreValuesSelectScreen() {
finalizeModalDismiss(); finalizeModalDismiss();
}, [ }, [
activeModalChipId, activeModalChipId,
confirmDiscard,
coreValueOptions, coreValueOptions,
customizeHeaderDraft, customizeHeaderDraft,
draft, draft,
finalizeModalDismiss, finalizeModalDismiss,
modalEditUnlocked, modalEditUnlocked,
modalKebabMenu.discardUnsavedCustomizeChanges,
modalSession, modalSession,
persistCoreValues, persistCoreValues,
replaceState, replaceState,
@@ -645,6 +646,7 @@ export function CoreValuesSelectScreen() {
const detailModal = cv.detailModal; const detailModal = cv.detailModal;
return ( return (
<>
<CreateFlowTwoColumnSelectShell <CreateFlowTwoColumnSelectShell
lgVerticalAlign="start" lgVerticalAlign="start"
header={ header={
@@ -724,5 +726,7 @@ export function CoreValuesSelectScreen() {
</Create> </Create>
)} )}
</CreateFlowTwoColumnSelectShell> </CreateFlowTwoColumnSelectShell>
{confirmDialog}
</>
); );
} }
@@ -14,7 +14,7 @@ import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell"; import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens"; import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
import { fetchAuthSession } from "../../../../../lib/create/api"; import { fetchAuthSession } from "../../../../../lib/create/api";
import { getAssetPath } from "../../../../../lib/assetUtils"; import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
import { import {
UploadToServerError, UploadToServerError,
uploadCreateFlowFile, uploadCreateFlowFile,
@@ -177,7 +177,7 @@ export function CommunityUploadScreen() {
> >
{/* eslint-disable-next-line @next/next/no-img-element -- matches ModalHeader close control */} {/* eslint-disable-next-line @next/next/no-img-element -- matches ModalHeader close control */}
<img <img
src={getAssetPath("assets/Icon_Close.svg")} src={getAssetPath(ASSETS.ICON_CLOSE)}
alt="" alt=""
className="h-[16px] w-[16px]" className="h-[16px] w-[16px]"
style={{ style={{
@@ -13,10 +13,11 @@ export const CREATE_FLOW_TRANSFER_PENDING_KEY =
"create-flow-transfer-pending" as const; "create-flow-transfer-pending" as const;
/** /**
* When signed-in + sync, {@link SignedInDraftHydration} resolves server vs this key via `window.confirm` * When signed-in + sync, local draft wins if non-empty; server draft applies when local is empty.
* if both are non-empty; see `messages/en/create/draftHydration.json`. * See `messages/en/create/draftHydration.json`.
*/ */
// TODO(legacy): Remove after production soak — one-time migration from pre-anonymous keys.
const LEGACY_LIVE_KEY = "create-flow-state"; const LEGACY_LIVE_KEY = "create-flow-state";
const LEGACY_DRAFT_KEY = "create-flow-draft"; const LEGACY_DRAFT_KEY = "create-flow-draft";
@@ -2,8 +2,7 @@ import { deleteServerDraft } from "../../../../lib/create/api";
import { clearAnonymousCreateFlowStorage } from "./anonymousDraftStorage"; import { clearAnonymousCreateFlowStorage } from "./anonymousDraftStorage";
import { clearCoreValueDetailsLocalStorage } from "./coreValueDetailsLocalStorage"; import { clearCoreValueDetailsLocalStorage } from "./coreValueDetailsLocalStorage";
const SYNC_ENABLED = import { isBackendSyncEnabled } from "../../../../lib/create/backendSyncEnabled";
process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
/** /**
* Call **before** navigating into `/create` from marketing or profile new rule * Call **before** navigating into `/create` from marketing or profile new rule
@@ -17,7 +16,7 @@ const SYNC_ENABLED =
export async function prepareFreshCreateFlowEntry(): Promise<void> { export async function prepareFreshCreateFlowEntry(): Promise<void> {
clearAnonymousCreateFlowStorage(); clearAnonymousCreateFlowStorage();
clearCoreValueDetailsLocalStorage(); clearCoreValueDetailsLocalStorage();
if (SYNC_ENABLED) { if (isBackendSyncEnabled()) {
await deleteServerDraft(); await deleteServerDraft();
} }
} }
+4 -1
View File
@@ -1,7 +1,10 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { notFound } from "next/navigation";
// Development-only previews (e.g. `/components-preview`) — no public chrome. // Development-only previews (e.g. `/components-preview`) — no public chrome.
// Routes here are gated by NODE_ENV checks at the page level.
export default function DevLayout({ children }: { children: ReactNode }) { export default function DevLayout({ children }: { children: ReactNode }) {
if (process.env.NODE_ENV === "production") {
notFound();
}
return <main className="flex-1">{children}</main>; return <main className="flex-1">{children}</main>;
} }
+2 -1
View File
@@ -1,4 +1,5 @@
import messages from "../../../messages/en/index"; import messages from "../../../messages/en/index";
import { getAssetPath, governanceBookletPath } from "../../../lib/assetUtils";
import { getTranslation } from "../../../lib/i18n/getTranslation"; import { getTranslation } from "../../../lib/i18n/getTranslation";
import AboutHeader from "../../components/type/AboutHeader"; import AboutHeader from "../../components/type/AboutHeader";
import type { AboutHeaderSegment } from "../../components/type/AboutHeader"; import type { AboutHeaderSegment } from "../../components/type/AboutHeader";
@@ -55,7 +56,7 @@ export default function AboutPage() {
title={page.book.title} title={page.book.title}
description={page.book.description} description={page.book.description}
buttonText={page.book.buttonText} buttonText={page.book.buttonText}
buttonHref={page.book.buttonHref} buttonHref={getAssetPath(governanceBookletPath())}
imageAlt={page.book.imageAlt} imageAlt={page.book.imageAlt}
/> />
<FaqAccordion title={page.faq.title} items={faqItems} /> <FaqAccordion title={page.faq.title} items={faqItems} />
+1 -1
View File
@@ -135,7 +135,7 @@ export default async function BlogPostPage({ params }: PageProps) {
url: "https://communityrule.com", url: "https://communityrule.com",
logo: { logo: {
"@type": "ImageObject", "@type": "ImageObject",
url: "https://communityrule.com/assets/logo/Logo.svg", url: "https://communityrule.com/assets/logos/community-rule.svg",
}, },
}, },
datePublished: post.frontmatter.date, datePublished: post.frontmatter.date,
+2 -1
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import messages from "../../../../messages/en/index";
import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules"; import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules";
import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../lib/create/publishedDocumentToDisplaySections"; import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../lib/create/publishedDocumentToDisplaySections";
import CommunityRule from "../../../components/type/CommunityRule"; import CommunityRule from "../../../components/type/CommunityRule";
@@ -16,7 +17,7 @@ export async function generateMetadata({
const rule = await getPublicPublishedRuleById(id); const rule = await getPublicPublishedRuleById(id);
if (!rule) { if (!rule) {
return { return {
title: "Rule Not Found", title: messages.pages.ruleDetail.notFoundTitle,
description: "The requested CommunityRule could not be found.", description: "The requested CommunityRule could not be found.",
}; };
} }
@@ -0,0 +1,57 @@
"use client";
import { memo, useState } from "react";
import { useRouter } from "next/navigation";
import { useCreateFlowMdUp } from "../../../../../(app)/create/hooks/useCreateFlowMdUp";
import { useTranslation } from "../../../../../contexts/MessagesContext";
import { UseCaseCompletedRuleView } from "./UseCaseCompletedRule.view";
import {
useUseCaseCompletedRuleActions,
type UseCaseCompletedRuleActionBanner,
} from "./useUseCaseCompletedRuleActions";
import type { UseCaseCompletedRuleProps } from "./UseCaseCompletedRule.types";
/** Figma: Completed CR — use case demos (21995:39476, 21995:40092, 22015:42413). */
function UseCaseCompletedRuleContainerComponent({
slug,
fixture,
sections,
}: UseCaseCompletedRuleProps) {
const router = useRouter();
const mdUp = useCreateFlowMdUp();
const tTopNav = useTranslation("pages.useCasesCompletedRule.topNav");
const [shareModalOpen, setShareModalOpen] = useState(false);
const [actionBanner, setActionBanner] =
useState<UseCaseCompletedRuleActionBanner | null>(null);
const { copyPageLink, mailtoPageLink, handleDuplicate } =
useUseCaseCompletedRuleActions({
slug,
fixture,
setActionBanner,
});
return (
<UseCaseCompletedRuleView
slug={slug}
fixture={fixture}
sections={sections}
mdUp={mdUp}
duplicateLabel={tTopNav("duplicate")}
duplicateAriaLabel={tTopNav("duplicateAriaLabel")}
exitLabel={tTopNav("return")}
shareModalOpen={shareModalOpen}
onShareOpen={() => setShareModalOpen(true)}
onShareClose={() => setShareModalOpen(false)}
onCopyLink={() => void copyPageLink()}
onEmailShare={mailtoPageLink}
onDuplicate={() => void handleDuplicate()}
onExit={() => router.push(`/use-cases/${slug}`)}
actionBanner={actionBanner}
onActionBannerClose={() => setActionBanner(null)}
/>
);
}
export const UseCaseCompletedRule = memo(UseCaseCompletedRuleContainerComponent);
UseCaseCompletedRule.displayName = "UseCaseCompletedRule";
@@ -0,0 +1,26 @@
import type { CommunityRuleSection } from "../../../../../components/type/CommunityRule/CommunityRule.types";
import type { UseCaseDetailSlug } from "../../../../../../lib/useCaseSyntheticPost";
import type { UseCaseCompletedRuleFixture } from "../../../../../../lib/useCaseCompletedRule";
import type { UseCaseCompletedRuleActionBanner } from "./useUseCaseCompletedRuleActions";
export type UseCaseCompletedRuleProps = {
slug: UseCaseDetailSlug;
fixture: UseCaseCompletedRuleFixture;
sections: CommunityRuleSection[];
};
export type UseCaseCompletedRuleViewProps = UseCaseCompletedRuleProps & {
mdUp: boolean;
duplicateLabel: string;
duplicateAriaLabel: string;
exitLabel: string;
shareModalOpen: boolean;
onShareOpen: () => void;
onShareClose: () => void;
onCopyLink: () => void;
onEmailShare: () => void;
onDuplicate: () => void;
onExit: () => void;
actionBanner: UseCaseCompletedRuleActionBanner | null;
onActionBannerClose: () => void;
};
@@ -1,9 +1,6 @@
"use client"; "use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import CommunityRule from "../../../../../components/type/CommunityRule"; import CommunityRule from "../../../../../components/type/CommunityRule";
import type { CommunityRuleSection } from "../../../../../components/type/CommunityRule/CommunityRule.types";
import CreateFlowTopNav from "../../../../../components/navigation/CreateFlowTopNav"; import CreateFlowTopNav from "../../../../../components/navigation/CreateFlowTopNav";
import Share from "../../../../../components/modals/Share"; import Share from "../../../../../components/modals/Share";
import Alert from "../../../../../components/modals/Alert"; import Alert from "../../../../../components/modals/Alert";
@@ -12,41 +9,25 @@ import {
CREATE_FLOW_MD_UP_GRID_CELL_CLASS, CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS, CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "../../../../../(app)/create/components/createFlowLayoutTokens"; } from "../../../../../(app)/create/components/createFlowLayoutTokens";
import { useCreateFlowMdUp } from "../../../../../(app)/create/hooks/useCreateFlowMdUp"; import type { UseCaseCompletedRuleViewProps } from "./UseCaseCompletedRule.types";
import { useTranslation } from "../../../../../contexts/MessagesContext";
import type { UseCaseDetailSlug } from "../../../../../../lib/useCaseSyntheticPost";
import type { UseCaseCompletedRuleFixture } from "../../../../../../lib/useCaseCompletedRule";
import {
useUseCaseCompletedRuleActions,
type UseCaseCompletedRuleActionBanner,
} from "./useUseCaseCompletedRuleActions";
export type UseCaseCompletedRuleViewProps = {
slug: UseCaseDetailSlug;
fixture: UseCaseCompletedRuleFixture;
sections: CommunityRuleSection[];
};
/** Figma: Completed CR — use case demos (21995:39476, 21995:40092, 22015:42413). */
export function UseCaseCompletedRuleView({ export function UseCaseCompletedRuleView({
slug,
fixture, fixture,
sections, sections,
mdUp,
duplicateLabel,
duplicateAriaLabel,
exitLabel,
shareModalOpen,
onShareOpen,
onShareClose,
onCopyLink,
onEmailShare,
onDuplicate,
onExit,
actionBanner,
onActionBannerClose,
}: UseCaseCompletedRuleViewProps) { }: UseCaseCompletedRuleViewProps) {
const router = useRouter();
const mdUp = useCreateFlowMdUp();
const tTopNav = useTranslation("pages.useCasesCompletedRule.topNav");
const [shareModalOpen, setShareModalOpen] = useState(false);
const [actionBanner, setActionBanner] =
useState<UseCaseCompletedRuleActionBanner | null>(null);
const { copyPageLink, mailtoPageLink, handleDuplicate } =
useUseCaseCompletedRuleActions({
slug,
fixture,
setActionBanner,
});
const pageBg = fixture.pageBackground; const pageBg = fixture.pageBackground;
return ( return (
@@ -69,7 +50,7 @@ export function UseCaseCompletedRuleView({
description={actionBanner.description} description={actionBanner.description}
hasLeadingIcon hasLeadingIcon
hasBodyText={Boolean(actionBanner.description)} hasBodyText={Boolean(actionBanner.description)}
onClose={() => setActionBanner(null)} onClose={onActionBannerClose}
className="w-full" className="w-full"
/> />
</div> </div>
@@ -77,24 +58,24 @@ export function UseCaseCompletedRuleView({
) : null} ) : null}
<Share <Share
isOpen={shareModalOpen} isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)} onClose={onShareClose}
onCopyLink={() => void copyPageLink()} onCopyLink={onCopyLink}
onEmailShare={mailtoPageLink} onEmailShare={onEmailShare}
onSignalShare={() => void copyPageLink()} onSignalShare={onCopyLink}
onSlackShare={() => void copyPageLink()} onSlackShare={onCopyLink}
onDiscordShare={() => void copyPageLink()} onDiscordShare={onCopyLink}
/> />
<CreateFlowTopNav <CreateFlowTopNav
hasShare hasShare
hasDuplicate hasDuplicate
duplicateLabel={tTopNav("duplicate")} duplicateLabel={duplicateLabel}
duplicateAriaLabel={tTopNav("duplicateAriaLabel")} duplicateAriaLabel={duplicateAriaLabel}
exitLabel={tTopNav("return")} exitLabel={exitLabel}
buttonPalette="inverse" buttonPalette="inverse"
className="shrink-0 !bg-transparent" className="shrink-0 !bg-transparent"
onShare={() => setShareModalOpen(true)} onShare={onShareOpen}
onDuplicate={() => void handleDuplicate()} onDuplicate={onDuplicate}
onExit={() => router.push(`/use-cases/${slug}`)} onExit={onExit}
/> />
<div <div
className={`mx-auto grid w-full min-h-0 flex-1 grid-cols-1 gap-4 px-5 max-md:max-w-[639px] max-md:gap-6 max-md:overflow-y-auto max-md:overscroll-y-contain max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:flex-1 md:grid-cols-2 md:grid-rows-1 md:items-start md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] md:overflow-hidden md:px-12 md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`} className={`mx-auto grid w-full min-h-0 flex-1 grid-cols-1 gap-4 px-5 max-md:max-w-[639px] max-md:gap-6 max-md:overflow-y-auto max-md:overscroll-y-contain max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:flex-1 md:grid-cols-2 md:grid-rows-1 md:items-start md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] md:overflow-hidden md:px-12 md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
@@ -10,7 +10,7 @@ import {
USE_CASE_DETAIL_SLUGS, USE_CASE_DETAIL_SLUGS,
useCaseContentKeyForSlug, useCaseContentKeyForSlug,
} from "../../../../../lib/useCaseSyntheticPost"; } from "../../../../../lib/useCaseSyntheticPost";
import { UseCaseCompletedRuleView } from "./_components/UseCaseCompletedRule.view"; import { UseCaseCompletedRule } from "./_components/UseCaseCompletedRule.container";
type PageProps = { type PageProps = {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
@@ -57,7 +57,7 @@ export default async function UseCaseCompletedRulePage({ params }: PageProps) {
} }
return ( return (
<UseCaseCompletedRuleView <UseCaseCompletedRule
slug={resolved.slug} slug={resolved.slug}
fixture={resolved.fixture} fixture={resolved.fixture}
sections={resolved.sections} sections={resolved.sections}
+33 -33
View File
@@ -1,7 +1,8 @@
import { NextResponse, type NextRequest } from "next/server"; import { NextResponse, type NextRequest } from "next/server";
import { isDatabaseConfigured } from "../../../../lib/server/env"; import { isDatabaseConfigured } from "../../../../lib/server/env";
import { listMethodRecommendations } from "../../../../lib/server/methodRecommendations"; import { listMethodRecommendations } from "../../../../lib/server/methodRecommendations";
import { dbUnavailable } from "../../../../lib/server/responses"; import { apiRoute } from "../../../../lib/server/apiRoute";
import { dbUnavailable, errorJson } from "../../../../lib/server/responses";
import { import {
SECTION_IDS, SECTION_IDS,
type SectionId, type SectionId,
@@ -19,38 +20,37 @@ const SECTION_SET = new Set<string>(SECTION_IDS);
* *
* See `docs/guides/template-recommendation-matrix.md` §9.2 / §10. * See `docs/guides/template-recommendation-matrix.md` §9.2 / §10.
*/ */
export async function GET(request: NextRequest) { export const GET = apiRoute(
if (!isDatabaseConfigured()) { "createFlow.methods.get",
return dbUnavailable(); async (request: NextRequest) => {
} if (!isDatabaseConfigured()) {
return dbUnavailable();
}
const sectionParam = request.nextUrl.searchParams.get("section"); const sectionParam = request.nextUrl.searchParams.get("section");
if (!sectionParam || !SECTION_SET.has(sectionParam)) { if (!sectionParam || !SECTION_SET.has(sectionParam)) {
return NextResponse.json( return errorJson(
{ "validation_error",
error: { `Unknown section. Expected one of: ${SECTION_IDS.join(", ")}`,
code: "validation_error", 400,
message: `Unknown section. Expected one of: ${SECTION_IDS.join(", ")}`, );
}, }
}, const section = sectionParam as SectionId;
{ status: 400 },
const facets = parseRequestedFacetsFromSearchParams(
request.nextUrl.searchParams,
); );
} const result = await listMethodRecommendations({ section, facets });
const section = sectionParam as SectionId; 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( const methods = result.rankedSlugs.map((slug) => ({
request.nextUrl.searchParams, slug,
); matches: result.matchesBySlug[slug] ?? { score: 0, matchedFacets: [] },
const result = await listMethodRecommendations({ section, facets }); }));
if (!result) { return NextResponse.json({ section, methods });
// 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 });
}
+3 -2
View File
@@ -1,8 +1,9 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "../../../lib/server/db"; import { prisma } from "../../../lib/server/db";
import { isDatabaseConfigured } from "../../../lib/server/env"; 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()) { if (!isDatabaseConfigured()) {
return NextResponse.json({ return NextResponse.json({
ok: true, ok: true,
@@ -16,4 +17,4 @@ export async function GET() {
} catch { } catch {
return NextResponse.json({ ok: false, database: "error" }, { status: 503 }); return NextResponse.json({ ok: false, database: "error" }, { status: 503 });
} }
} });
+3 -2
View File
@@ -3,6 +3,7 @@ import { isDatabaseConfigured } from "../../../lib/server/env";
import { listRankedRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates"; import { listRankedRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
import { dbUnavailable } from "../../../lib/server/responses"; import { dbUnavailable } from "../../../lib/server/responses";
import { parseRequestedFacetsFromSearchParams } from "../../../lib/server/validation/methodFacetsSchemas"; import { parseRequestedFacetsFromSearchParams } from "../../../lib/server/validation/methodFacetsSchemas";
import { apiRoute } from "../../../lib/server/apiRoute";
/** /**
* GET /api/templates * GET /api/templates
@@ -15,7 +16,7 @@ import { parseRequestedFacetsFromSearchParams } from "../../../lib/server/valida
* *
* See `docs/guides/template-recommendation-matrix.md` §9.1. * 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()) { if (!isDatabaseConfigured()) {
return dbUnavailable(); return dbUnavailable();
} }
@@ -29,4 +30,4 @@ export async function GET(request: NextRequest) {
return NextResponse.json( return NextResponse.json(
hasScores ? { templates, scores } : { templates }, hasScores ? { templates, scores } : { templates },
); );
} });
+47 -62
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { logger } from "../../../lib/logger"; import { logger } from "../../../lib/logger";
import { apiRoute } from "../../../lib/server/apiRoute";
import { getWebVitalsStorageMode } from "../../../lib/server/webVitals/mode"; import { getWebVitalsStorageMode } from "../../../lib/server/webVitals/mode";
import { import {
appendLocalWebVital, appendLocalWebVital,
@@ -29,70 +30,54 @@ function logExternalIngest(body: WebVitalData): void {
logger.info(line); logger.info(line);
} }
export async function POST(request: NextRequest) { export const POST = apiRoute("webVitals.post", async (request: NextRequest) => {
try { const limited = await readLimitedJson(request);
const limited = await readLimitedJson(request); if (limited.ok === false) {
if (limited.ok === false) { return limited.response;
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 async function GET() { const parsed = webVitalIngestSchema.safeParse(limited.value);
try { if (!parsed.success) return jsonFromZodError(parsed.error);
const mode = getWebVitalsStorageMode();
if (mode === "external") { const body = parsed.data;
return NextResponse.json({
metrics: {},
storage: "external" as const,
});
}
const metrics = readLocalAggregatedMetrics(); const vitalsData: WebVitalData = {
return NextResponse.json({ metrics, storage: "local" as const }); metric: body.metric,
} catch (error) { data: {
logger.error("Error fetching web vitals:", error); value: body.data.value,
return NextResponse.json( rating: body.data.rating,
{ error: "Internal server error" }, },
{ status: 500 }, 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 });
});
+9 -4
View File
@@ -1,5 +1,8 @@
"use client";
import { memo } from "react"; import { memo } from "react";
import Link from "next/link"; import Link from "next/link";
import { useTranslation } from "../../../contexts/MessagesContext";
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils"; import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
interface LogoProps { interface LogoProps {
@@ -31,6 +34,8 @@ interface SizeConfig {
const Logo = memo<LogoProps>( const Logo = memo<LogoProps>(
({ size = "default", palette = "default", wordmark = true }) => { ({ size = "default", palette = "default", wordmark = true }) => {
const t = useTranslation("controlsChrome");
// Size configurations // Size configurations
const sizes: Record<string, SizeConfig> = { const sizes: Record<string, SizeConfig> = {
default: { default: {
@@ -97,7 +102,7 @@ const Logo = memo<LogoProps>(
: "hidden"; : "hidden";
return ( return (
<Link href="/" className="block" aria-label="CommunityRule Logo"> <Link href="/" className="block" aria-label={t("logoAlt")}>
<div <div
className={`flex items-center ${config.containerHeight} ${ className={`flex items-center ${config.containerHeight} ${
wordmark ? config.gap : "" wordmark ? config.gap : ""
@@ -106,16 +111,16 @@ const Logo = memo<LogoProps>(
{/* Logo Text - responsive visibility for topNav sizes */} {/* Logo Text - responsive visibility for topNav sizes */}
<div <div
className={`font-bricolage-grotesque ${textColorClass} ${config.textSize} ${config.lineHeight} font-normal tracking-[0px] transition-colors duration-200 ${wordmarkVisibilityClass}`} className={`font-bricolage-grotesque ${textColorClass} ${config.textSize} ${config.lineHeight} font-normal tracking-[0px] transition-colors duration-200 ${wordmarkVisibilityClass}`}
aria-label="CommunityRule" aria-label={t("logoText")}
> >
CommunityRule {t("logoText")}
</div> </div>
{/* Vector Icon */} {/* Vector Icon */}
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={getAssetPath(ASSETS.LOGO)} src={getAssetPath(ASSETS.LOGO)}
alt="CommunityRule Logo Icon" alt={t("logoAlt")}
width={27.05} width={27.05}
height={27.05} height={27.05}
className={`flex-shrink-0 ${config.iconSize} transition-all duration-200 ${ className={`flex-shrink-0 ${config.iconSize} transition-all duration-200 ${
@@ -1,11 +1,11 @@
"use client"; "use client";
import { memo, useCallback, useState } from "react"; import { memo, useCallback, useState } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import { CardStackView } from "./CardStack.view"; import { CardStackView } from "./CardStack.view";
import type { CardStackProps } from "./CardStack.types"; import type { CardStackProps } from "./CardStack.types";
const DEFAULT_TOGGLE_LABEL = "See all communication approaches"; const DEFAULT_TOGGLE_LABEL = "See all communication approaches";
const DEFAULT_SHOW_LESS_LABEL = "Show less";
/** /**
* Figma: "Utility / CardStack"; canonical code under `cards/`. * Figma: "Utility / CardStack"; canonical code under `cards/`.
@@ -22,7 +22,7 @@ const CardStackContainer = memo<CardStackProps>(
onToggleExpand: controlledOnToggleExpand, onToggleExpand: controlledOnToggleExpand,
hasMore = true, hasMore = true,
toggleLabel = DEFAULT_TOGGLE_LABEL, toggleLabel = DEFAULT_TOGGLE_LABEL,
showLessLabel = DEFAULT_SHOW_LESS_LABEL, showLessLabel,
title = "", title = "",
description = "", description = "",
layout = "default", layout = "default",
@@ -37,6 +37,7 @@ const CardStackContainer = memo<CardStackProps>(
addCardAriaLabel = "", addCardAriaLabel = "",
onAddCard, onAddCard,
}) => { }) => {
const t = useTranslation("controlsChrome");
const [internalExpanded, setInternalExpanded] = useState(false); const [internalExpanded, setInternalExpanded] = useState(false);
const [internalSelectedIds, setInternalSelectedIds] = useState<string[]>( const [internalSelectedIds, setInternalSelectedIds] = useState<string[]>(
[], [],
@@ -84,7 +85,7 @@ const CardStackContainer = memo<CardStackProps>(
onToggleExpand={handleToggleExpand} onToggleExpand={handleToggleExpand}
hasMore={hasMore} hasMore={hasMore}
toggleLabel={toggleLabel} toggleLabel={toggleLabel}
showLessLabel={showLessLabel} showLessLabel={showLessLabel ?? t("cardStackShowLess")}
title={title} title={title}
description={description} description={description}
layout={layout} layout={layout}
@@ -7,10 +7,10 @@ export type CaseStudySurfaceValue = (typeof CASE_STUDY_SURFACE_OPTIONS)[number];
export interface CaseStudyProps { export interface CaseStudyProps {
surface: CaseStudySurfaceValue; 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; imageAlt?: string;
/** Overrides built-in raster with custom slot content when provided. */ /** Overrides built-in artwork with custom slot content when provided. */
visual?: ReactNode; visual?: ReactNode;
className?: string; className?: string;
} }
@@ -2,6 +2,7 @@
import Image from "next/image"; import Image from "next/image";
import { memo } from "react"; import { memo } from "react";
import { caseStudyVisualPath, getAssetPath } from "../../../../lib/assetUtils";
import type { CaseStudyProps } from "./CaseStudy.types"; import type { CaseStudyProps } from "./CaseStudy.types";
const SURFACE_CLASS: Record<CaseStudyProps["surface"], string> = { const SURFACE_CLASS: Record<CaseStudyProps["surface"], string> = {
@@ -12,9 +13,9 @@ const SURFACE_CLASS: Record<CaseStudyProps["surface"], string> = {
/** Default art per tile: Figma-exported SVG composites (305×305 incl. rounded bg). */ /** Default art per tile: Figma-exported SVG composites (305×305 incl. rounded bg). */
const SURFACE_ART: Record<CaseStudyProps["surface"], string> = { const SURFACE_ART: Record<CaseStudyProps["surface"], string> = {
lavender: "/assets/case-study/case-study-mutual-aid.svg", lavender: getAssetPath(caseStudyVisualPath("lavender")),
neutral: "/assets/case-study/case-study-food-not-bombs.svg", neutral: getAssetPath(caseStudyVisualPath("neutral")),
rose: "/assets/case-study/case-study-boulder-county-street-medics.svg", rose: getAssetPath(caseStudyVisualPath("rose")),
}; };
/** Figma: ~23px corner (“Card / CaseStudy” shells). */ /** Figma: ~23px corner (“Card / CaseStudy” shells). */
@@ -1,5 +1,9 @@
"use client"; "use client";
/**
* Figma: "Card / Icon" (see registry)
*/
import { memo, useId } from "react"; import { memo, useId } from "react";
import { IconView } from "./Icon.view"; import { IconView } from "./Icon.view";
import type { IconProps } from "./Icon.types"; import type { IconProps } from "./Icon.types";
+17 -2
View File
@@ -1,6 +1,11 @@
"use client"; "use client";
/**
* Figma: "Card / Mini" (see registry)
*/
import { memo, useMemo } from "react"; import { memo, useMemo } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import MiniView from "./Mini.view"; import MiniView from "./Mini.view";
import type { MiniProps } from "./Mini.types"; import type { MiniProps } from "./Mini.types";
@@ -16,15 +21,21 @@ const MiniContainer = memo<MiniProps>(
onClick, onClick,
href, href,
ariaLabel, ariaLabel,
featureGridShell = false,
panelWidth,
panelHeight,
panelImageClassName,
}) => { }) => {
const t = useTranslation("controlsChrome");
// Compute aria-label // Compute aria-label
const computedAriaLabel = useMemo( const computedAriaLabel = useMemo(
() => () =>
ariaLabel || ariaLabel ||
(labelLine1 && labelLine2 (labelLine1 && labelLine2
? `${labelLine1} ${labelLine2}` ? `${labelLine1} ${labelLine2}`
: label || "Feature card"), : label || t("miniFeatureFallback")),
[ariaLabel, labelLine1, labelLine2, label], [ariaLabel, labelLine1, labelLine2, label, t],
); );
// Determine wrapper element and props // Determine wrapper element and props
@@ -85,6 +96,10 @@ const MiniContainer = memo<MiniProps>(
computedAriaLabel={computedAriaLabel} computedAriaLabel={computedAriaLabel}
wrapperElement={wrapperElement} wrapperElement={wrapperElement}
wrapperProps={wrapperProps} wrapperProps={wrapperProps}
featureGridShell={featureGridShell}
panelWidth={panelWidth}
panelHeight={panelHeight}
panelImageClassName={panelImageClassName}
> >
{children} {children}
</MiniView> </MiniView>
+9
View File
@@ -9,6 +9,11 @@ export interface MiniProps {
onClick?: () => void; onClick?: () => void;
href?: string; href?: string;
ariaLabel?: string; ariaLabel?: string;
/** Figma Feature-Grid mini tile shell (18847:22410). */
featureGridShell?: boolean;
panelWidth?: number;
panelHeight?: number;
panelImageClassName?: string;
} }
export interface MiniViewProps { export interface MiniViewProps {
@@ -25,4 +30,8 @@ export interface MiniViewProps {
| React.AnchorHTMLAttributes<HTMLAnchorElement> | React.AnchorHTMLAttributes<HTMLAnchorElement>
| React.ButtonHTMLAttributes<HTMLButtonElement> | React.ButtonHTMLAttributes<HTMLButtonElement>
| React.HTMLAttributes<HTMLDivElement>; | React.HTMLAttributes<HTMLDivElement>;
featureGridShell?: boolean;
panelWidth?: number;
panelHeight?: number;
panelImageClassName?: string;
} }
+36 -15
View File
@@ -2,6 +2,7 @@
import { memo } from "react"; import { memo } from "react";
import Image from "next/image"; import Image from "next/image";
import { SVG_GRAIN_MULTIPLY_FILTER } from "../../../../lib/svgGrainFilter";
import type { MiniViewProps } from "./Mini.types"; import type { MiniViewProps } from "./Mini.types";
function MiniView({ function MiniView({
@@ -15,39 +16,59 @@ function MiniView({
computedAriaLabel, computedAriaLabel,
wrapperElement, wrapperElement,
wrapperProps, wrapperProps,
featureGridShell = false,
panelWidth,
panelHeight,
panelImageClassName,
}: MiniViewProps) { }: 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 = ( const cardContentElement = (
<div className={`h-[186px] flex flex-col gap-[7px] ${className}`}> <div className={outerClass}>
{/* Top part - Inner panel */} <div className={panelClass}>
<div
className={`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`}
>
{/* Content for the inner panel */}
{panelContent && ( {panelContent && (
<div className="flex items-center justify-center w-full h-full"> <div className="flex h-full w-full items-center justify-center">
<Image <Image
src={panelContent} src={panelContent}
alt={computedAriaLabel} alt={computedAriaLabel}
className="max-w-[58px] max-h-[58px] w-auto h-auto object-contain" className={imageClass}
width={58} width={imageWidth}
height={58} height={imageHeight}
sizes="(max-width: 768px) 50vw, 25vw" sizes="(max-width: 768px) 50vw, 25vw"
loading="lazy" loading="lazy"
placeholder="blur" style={
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=" featureGridShell
? {
filter: SVG_GRAIN_MULTIPLY_FILTER,
WebkitFilter: SVG_GRAIN_MULTIPLY_FILTER,
}
: undefined
}
/> />
</div> </div>
)} )}
{children} {children}
</div> </div>
{/* Bottom part - Text container */} <div className="text-center font-inter text-[12px] font-medium leading-[14px] text-[var(--color-content-default-primary)]">
<div className="font-inter font-medium text-[12px] leading-[14px] text-center text-[var(--color-content-default-primary)]">
{labelLine1 && labelLine2 ? ( {labelLine1 && labelLine2 ? (
<> <>
<div>{labelLine1}</div> <div>{labelLine1}</div>
<div>{labelLine2}</div> <div>{labelLine2}</div>
<div>&nbsp;</div>
</> </>
) : ( ) : (
label label
@@ -1,6 +1,7 @@
"use client"; "use client";
import { memo } from "react"; import { memo } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import { RuleView } from "./Rule.view"; import { RuleView } from "./Rule.view";
import type { RuleProps } from "./Rule.types"; import type { RuleProps } from "./Rule.types";
@@ -49,6 +50,9 @@ const RuleContainer = memo<RuleProps>(
fluidWidth = false, fluidWidth = false,
}) => { }) => {
const size = sizeProp ?? "L"; const size = sizeProp ?? "L";
const t = useTranslation("ruleCard");
const cardAriaLabel = t("ariaLabel")?.replace("{title}", title) || title;
const recommendedLabel = t("recommendedLabel");
const handleClick = () => { const handleClick = () => {
if (hasBottomLinks) return; if (hasBottomLinks) return;
@@ -106,6 +110,8 @@ const RuleContainer = memo<RuleProps>(
recommended={recommended} recommended={recommended}
templateGridFigmaShell={templateGridFigmaShell} templateGridFigmaShell={templateGridFigmaShell}
fluidWidth={fluidWidth} fluidWidth={fluidWidth}
cardAriaLabel={cardAriaLabel}
recommendedLabel={recommendedLabel}
/> />
); );
}, },
+3
View File
@@ -107,4 +107,7 @@ export interface RuleViewProps {
recommended?: boolean; recommended?: boolean;
templateGridFigmaShell?: boolean; templateGridFigmaShell?: boolean;
fluidWidth?: boolean; fluidWidth?: boolean;
/** Interactive card aria-label; supplied by the container from `ruleCard` messages. */
cardAriaLabel: string;
recommendedLabel: string;
} }
+4 -4
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import { useTranslation } from "../../../contexts/MessagesContext";
import MultiSelect from "../../controls/MultiSelect"; import MultiSelect from "../../controls/MultiSelect";
import InlineTextButton from "../../buttons/InlineTextButton"; import InlineTextButton from "../../buttons/InlineTextButton";
import NavigationLink from "../../navigation/Link"; import NavigationLink from "../../navigation/Link";
@@ -34,9 +33,10 @@ export function RuleView({
recommended = false, recommended = false,
templateGridFigmaShell = false, templateGridFigmaShell = false,
fluidWidth = false, fluidWidth = false,
cardAriaLabel,
recommendedLabel,
}: RuleViewProps) { }: RuleViewProps) {
const t = useTranslation("ruleCard"); const ariaLabel = cardAriaLabel;
const ariaLabel = t("ariaLabel")?.replace("{title}", title) || title;
const interactiveCard = !hasBottomLinks; const interactiveCard = !hasBottomLinks;
// Size-based styling // Size-based styling
@@ -306,7 +306,7 @@ export function RuleView({
> >
{showRecommendedTag ? ( {showRecommendedTag ? (
<Tag variant="templateRecommended"> <Tag variant="templateRecommended">
{t("recommendedLabel")} {recommendedLabel}
</Tag> </Tag>
) : null} ) : null}
{onTitleClick ? ( {onTitleClick ? (
@@ -1,5 +1,9 @@
"use client"; "use client";
/**
* Figma: "Card / Stat" (21598-18215)
*/
import { memo } from "react"; import { memo } from "react";
import StatView from "./Stat.view"; import StatView from "./Stat.view";
import type { StatProps } from "./Stat.types"; import type { StatProps } from "./Stat.types";
@@ -20,7 +20,7 @@ function ContentContainerView({
return ( return (
<div <div
className={containerClasses} className={containerClasses}
style={size === "responsive" || size === "xs" ? {} : { width }} style={size === "xs" ? {} : { width }}
> >
{/* Content Container - gap between icon and text */} {/* Content Container - gap between icon and text */}
<div className={contentGapClasses}> <div className={contentGapClasses}>
@@ -1,6 +1,7 @@
"use client"; "use client";
import { memo, useState, useEffect, useRef } from "react"; import { memo, useState, useEffect, useRef } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import ChipView from "./Chip.view"; import ChipView from "./Chip.view";
import type { ChipProps } from "./Chip.types"; import type { ChipProps } from "./Chip.types";
@@ -22,6 +23,7 @@ const ChipContainer = memo<ChipProps>(
onClose, onClose,
ariaLabel, ariaLabel,
}) => { }) => {
const t = useTranslation("controlsChrome");
const state = stateProp; const state = stateProp;
const palette = paletteProp; const palette = paletteProp;
const size = sizeProp; const size = sizeProp;
@@ -92,6 +94,9 @@ const ChipContainer = memo<ChipProps>(
onInputKeyDown={isCustom ? handleKeyDown : undefined} onInputKeyDown={isCustom ? handleKeyDown : undefined}
inputRef={isCustom ? inputRef : undefined} inputRef={isCustom ? inputRef : undefined}
ariaLabel={ariaLabel} ariaLabel={ariaLabel}
confirmAriaLabel={t("chipConfirm")}
typeToAddPlaceholder={t("chipTypeToAdd")}
closeAriaLabel={t("chipClose")}
/> />
); );
}, },
@@ -68,4 +68,7 @@ export interface ChipViewProps {
onInputKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void; onInputKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
inputRef?: React.RefObject<HTMLInputElement>; inputRef?: React.RefObject<HTMLInputElement>;
ariaLabel?: string; ariaLabel?: string;
confirmAriaLabel: string;
typeToAddPlaceholder: string;
closeAriaLabel: string;
} }
+6 -3
View File
@@ -19,6 +19,9 @@ function ChipView({
onInputKeyDown, onInputKeyDown,
inputRef, inputRef,
ariaLabel, ariaLabel,
confirmAriaLabel,
typeToAddPlaceholder,
closeAriaLabel,
}: ChipViewProps) { }: ChipViewProps) {
// The container is the source of truth for `disabled`. This allows // The container is the source of truth for `disabled`. This allows
// `state="disabled"` to be used purely as a visual (for toggle-group chips // `state="disabled"` to be used purely as a visual (for toggle-group chips
@@ -167,7 +170,7 @@ function ChipView({
<button <button
type="button" type="button"
className="flex items-center justify-center p-[var(--measures-spacing-150,6px)] rounded-full hover:bg-[var(--color-surface-default-semi-opaque,rgba(0,0,0,0.1))] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="flex items-center justify-center p-[var(--measures-spacing-150,6px)] rounded-full hover:bg-[var(--color-surface-default-semi-opaque,rgba(0,0,0,0.1))] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Confirm" aria-label={confirmAriaLabel}
disabled={!inputValue || !inputValue.trim()} disabled={!inputValue || !inputValue.trim()}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
@@ -204,7 +207,7 @@ function ChipView({
value={inputValue ?? ""} value={inputValue ?? ""}
onChange={(e) => onInputChange?.(e.target.value)} onChange={(e) => onInputChange?.(e.target.value)}
onKeyDown={onInputKeyDown} onKeyDown={onInputKeyDown}
placeholder="Type to add" placeholder={typeToAddPlaceholder}
className="bg-transparent border-none outline-none flex-1 min-w-0 font-inter font-normal text-[color:var(--color-content-default-tertiary,#b4b4b4)] placeholder:text-[color:var(--color-content-default-tertiary,#b4b4b4)]" className="bg-transparent border-none outline-none flex-1 min-w-0 font-inter font-normal text-[color:var(--color-content-default-tertiary,#b4b4b4)] placeholder:text-[color:var(--color-content-default-tertiary,#b4b4b4)]"
style={{ style={{
fontSize: isSmall fontSize: isSmall
@@ -222,7 +225,7 @@ function ChipView({
<button <button
type="button" type="button"
className="flex items-center justify-center p-[var(--measures-spacing-150,6px)] rounded-full hover:bg-[var(--color-surface-default-semi-opaque,rgba(0,0,0,0.1))] transition-colors" className="flex items-center justify-center p-[var(--measures-spacing-150,6px)] rounded-full hover:bg-[var(--color-surface-default-semi-opaque,rgba(0,0,0,0.1))] transition-colors"
aria-label="Close" aria-label={closeAriaLabel}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
onClose(event); onClose(event);
@@ -1,6 +1,7 @@
"use client"; "use client";
import { memo } from "react"; import { memo } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import MultiSelectView from "./MultiSelect.view"; import MultiSelectView from "./MultiSelect.view";
import type { MultiSelectProps } from "./MultiSelect.types"; import type { MultiSelectProps } from "./MultiSelect.types";
@@ -18,12 +19,13 @@ const MultiSelectContainer = memo<MultiSelectProps>(
onChipClick, onChipClick,
onAddClick, onAddClick,
addButton: addButtonProp = true, addButton: addButtonProp = true,
addButtonText = "Add organization type", addButtonText,
formHeader = true, formHeader = true,
onCustomChipConfirm, onCustomChipConfirm,
onCustomChipClose, onCustomChipClose,
className = "", className = "",
}) => { }) => {
const t = useTranslation("controlsChrome");
const size = sizeProp; const size = sizeProp;
const palette = paletteProp; const palette = paletteProp;
@@ -38,6 +40,9 @@ const MultiSelectContainer = memo<MultiSelectProps>(
onAddClick={onAddClick} onAddClick={onAddClick}
addButton={addButtonProp} addButton={addButtonProp}
addButtonText={addButtonText} addButtonText={addButtonText}
addButtonAriaLabel={
addButtonText || t("multiSelectAddFallback")
}
formHeader={formHeader} formHeader={formHeader}
onCustomChipConfirm={onCustomChipConfirm} onCustomChipConfirm={onCustomChipConfirm}
onCustomChipClose={onCustomChipClose} onCustomChipClose={onCustomChipClose}
@@ -74,7 +74,8 @@ export interface MultiSelectViewProps {
onChipClick?: (chipId: string) => void; onChipClick?: (chipId: string) => void;
onAddClick?: () => void; onAddClick?: () => void;
addButton: boolean; addButton: boolean;
addButtonText: string; addButtonText?: string;
addButtonAriaLabel: string;
formHeader: boolean; formHeader: boolean;
onCustomChipConfirm?: (chipId: string, value: string) => void; onCustomChipConfirm?: (chipId: string, value: string) => void;
onCustomChipClose?: (chipId: string) => void; onCustomChipClose?: (chipId: string) => void;
@@ -15,6 +15,7 @@ function MultiSelectView({
onAddClick, onAddClick,
addButton, addButton,
addButtonText, addButtonText,
addButtonAriaLabel,
formHeader = true, formHeader = true,
onCustomChipConfirm, onCustomChipConfirm,
onCustomChipClose, onCustomChipClose,
@@ -81,7 +82,7 @@ function MultiSelectView({
{addButton && ( {addButton && (
<button <button
type="button" type="button"
aria-label={addButtonText || "Add option"} aria-label={addButtonAriaLabel}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onAddClick?.(); onAddClick?.();
@@ -5,10 +5,11 @@ import { forwardRef, memo } from "react";
interface SelectDropdownProps extends React.HTMLAttributes<HTMLDivElement> { interface SelectDropdownProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string; className?: string;
children?: React.ReactNode; children?: React.ReactNode;
ariaLabel: string;
} }
const SelectDropdown = forwardRef<HTMLDivElement, SelectDropdownProps>( const SelectDropdown = forwardRef<HTMLDivElement, SelectDropdownProps>(
({ className = "", children, ...props }, ref) => { ({ className = "", children, ariaLabel, ...props }, ref) => {
const menuClasses = ` const menuClasses = `
bg-black bg-black
border border-[var(--color-border-default-tertiary)] border border-[var(--color-border-default-tertiary)]
@@ -27,7 +28,7 @@ const SelectDropdown = forwardRef<HTMLDivElement, SelectDropdownProps>(
ref={ref} ref={ref}
className={menuClasses} className={menuClasses}
role="listbox" role="listbox"
aria-label="Select an option" aria-label={ariaLabel}
style={{ backgroundColor: "#000000" }} style={{ backgroundColor: "#000000" }}
{...props} {...props}
> >
@@ -14,6 +14,7 @@ import React, {
useEffect, useEffect,
} from "react"; } from "react";
import { useClickOutside } from "../../../hooks"; import { useClickOutside } from "../../../hooks";
import { useTranslation } from "../../../contexts/MessagesContext";
import { SelectInputView } from "./SelectInput.view"; import { SelectInputView } from "./SelectInput.view";
import type { SelectInputProps } from "./SelectInput.types"; import type { SelectInputProps } from "./SelectInput.types";
@@ -38,7 +39,7 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
textHint = false, textHint = false,
disabled = false, disabled = false,
error = false, error = false,
placeholder = "Choose an option", placeholder,
className = "", className = "",
children, children,
value, value,
@@ -48,6 +49,9 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
}, },
ref, ref,
) => { ) => {
const t = useTranslation("controlsChrome");
const resolvedPlaceholder = placeholder ?? t("selectPlaceholder");
// Determine if label should be shown // Determine if label should be shown
const shouldShowLabel = const shouldShowLabel =
showLabel !== undefined ? showLabel : labelText !== undefined; showLabel !== undefined ? showLabel : labelText !== undefined;
@@ -181,13 +185,13 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
// Get display text for selected value // Get display text for selected value
const getDisplayText = (): string => { const getDisplayText = (): string => {
if (!selectedValue) return placeholder; if (!selectedValue) return resolvedPlaceholder;
if (options && Array.isArray(options)) { if (options && Array.isArray(options)) {
const selectedOption = options.find( const selectedOption = options.find(
(option) => option.value === selectedValue, (option) => option.value === selectedValue,
); );
return selectedOption ? selectedOption.label : placeholder; return selectedOption ? selectedOption.label : resolvedPlaceholder;
} }
const selectedOption = Children.toArray(children).find( const selectedOption = Children.toArray(children).find(
@@ -207,13 +211,13 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
); );
return selectedOption return selectedOption
? String(selectedOption.props.children) ? String(selectedOption.props.children)
: placeholder; : resolvedPlaceholder;
}; };
return ( return (
<SelectInputView <SelectInputView
label={shouldShowLabel ? labelText : undefined} label={shouldShowLabel ? labelText : undefined}
placeholder={placeholder} placeholder={resolvedPlaceholder}
state={actualState} state={actualState}
disabled={disabled} disabled={disabled}
error={error} error={error}
@@ -241,6 +245,8 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
textData={textData} textData={textData}
iconRight={iconRight} iconRight={iconRight}
textHint={textHint} textHint={textHint}
selectAriaLabel={t("selectAriaLabel")}
hintDefault={t("hintDefault")}
{...props} {...props}
/> />
); );
@@ -40,6 +40,8 @@ export interface SelectInputViewProps {
textData?: boolean; textData?: boolean;
iconRight?: boolean; iconRight?: boolean;
textHint?: boolean; textHint?: boolean;
selectAriaLabel: string;
hintDefault: string;
} }
export function SelectInputView({ export function SelectInputView({
@@ -72,6 +74,8 @@ export function SelectInputView({
textData = true, textData = true,
iconRight = true, iconRight = true,
textHint = false, textHint = false,
selectAriaLabel,
hintDefault,
}: SelectInputViewProps) { }: SelectInputViewProps) {
// Styles based on Figma design // Styles based on Figma design
const containerClasses = "flex flex-col gap-[8px]"; const containerClasses = "flex flex-col gap-[8px]";
@@ -222,7 +226,7 @@ export function SelectInputView({
ref={menuRef} ref={menuRef}
className="absolute top-full left-0 right-0 z-50 mt-1" className="absolute top-full left-0 right-0 z-50 mt-1"
> >
<SelectDropdown> <SelectDropdown ariaLabel={selectAriaLabel}>
{options && Array.isArray(options) {options && Array.isArray(options)
? options.map((option) => ( ? options.map((option) => (
<SelectOption <SelectOption
@@ -268,7 +272,7 @@ export function SelectInputView({
{textHint && ( {textHint && (
<div className="flex items-start relative shrink-0 w-full"> <div className="flex items-start relative shrink-0 w-full">
<p className="flex-[1_0_0] font-inter font-normal leading-[16px] min-h-px min-w-px relative text-[color:var(--color-content-default-tertiary,#b4b4b4)] text-[length:var(--sizing-300,12px)]"> <p className="flex-[1_0_0] font-inter font-normal leading-[16px] min-h-px min-w-px relative text-[color:var(--color-content-default-tertiary,#b4b4b4)] text-[length:var(--sizing-300,12px)]">
Hint text here {hintDefault}
</p> </p>
</div> </div>
)} )}
@@ -1,6 +1,7 @@
"use client"; "use client";
import { memo, useCallback, useId, forwardRef } from "react"; import { memo, useCallback, useId, forwardRef } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import { SwitchView } from "./Switch.view"; import { SwitchView } from "./Switch.view";
import type { SwitchProps } from "./Switch.types"; import type { SwitchProps } from "./Switch.types";
@@ -10,6 +11,7 @@ import type { SwitchProps } from "./Switch.types";
*/ */
const SwitchContainer = memo( const SwitchContainer = memo(
forwardRef<HTMLButtonElement, SwitchProps>((props, ref) => { forwardRef<HTMLButtonElement, SwitchProps>((props, ref) => {
const t = useTranslation("controlsChrome");
const { const {
propSwitch = false, propSwitch = false,
onChange, onChange,
@@ -154,6 +156,7 @@ const SwitchContainer = memo(
trackClasses={trackClasses} trackClasses={trackClasses}
thumbClasses={thumbClasses} thumbClasses={thumbClasses}
labelClasses={labelClasses} labelClasses={labelClasses}
switchAriaLabel={text ?? t("toggleSwitch")}
onClick={handleClick} onClick={handleClick}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onFocus={handleFocus} onFocus={handleFocus}
@@ -37,6 +37,7 @@ export interface SwitchViewProps {
trackClasses: string; trackClasses: string;
thumbClasses: string; thumbClasses: string;
labelClasses: string; labelClasses: string;
switchAriaLabel: string;
onClick: (_e: React.MouseEvent<HTMLButtonElement>) => void; onClick: (_e: React.MouseEvent<HTMLButtonElement>) => void;
onKeyDown: (_e: React.KeyboardEvent<HTMLButtonElement>) => void; onKeyDown: (_e: React.KeyboardEvent<HTMLButtonElement>) => void;
onFocus: (_e: React.FocusEvent<HTMLButtonElement>) => void; onFocus: (_e: React.FocusEvent<HTMLButtonElement>) => void;
@@ -11,6 +11,7 @@ export const SwitchView = forwardRef<HTMLButtonElement, SwitchViewProps>(
trackClasses, trackClasses,
thumbClasses, thumbClasses,
labelClasses, labelClasses,
switchAriaLabel,
onClick, onClick,
onKeyDown, onKeyDown,
onFocus, onFocus,
@@ -27,7 +28,7 @@ export const SwitchView = forwardRef<HTMLButtonElement, SwitchViewProps>(
type="button" type="button"
role="switch" role="switch"
aria-checked={propSwitch} aria-checked={propSwitch}
aria-label={text || "Toggle switch"} aria-label={switchAriaLabel}
onClick={onClick} onClick={onClick}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onFocus={onFocus} onFocus={onFocus}
@@ -2,6 +2,7 @@
import { memo, forwardRef } from "react"; import { memo, forwardRef } from "react";
import { useComponentId, useFormField } from "../../../hooks"; import { useComponentId, useFormField } from "../../../hooks";
import { useTranslation } from "../../../contexts/MessagesContext";
import { TextAreaView } from "./TextArea.view"; import { TextAreaView } from "./TextArea.view";
import type { TextAreaProps } from "./TextArea.types"; import type { TextAreaProps } from "./TextArea.types";
@@ -35,6 +36,7 @@ const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
}, },
ref, ref,
) => { ) => {
const t = useTranslation("controlsChrome");
const size = sizeProp; const size = sizeProp;
const labelVariant = labelVariantProp; const labelVariant = labelVariantProp;
const state = stateProp; const state = stateProp;
@@ -200,6 +202,8 @@ const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
formHeader={formHeader} formHeader={formHeader}
showHelpIcon={showHelpIcon} showHelpIcon={showHelpIcon}
appearance={appearance} appearance={appearance}
helpIconAlt={t("helpIconAlt")}
hintDefault={t("hintDefault")}
{...props} {...props}
/> />
); );
@@ -79,4 +79,6 @@ export interface TextAreaViewProps {
formHeader?: boolean; formHeader?: boolean;
showHelpIcon?: boolean; showHelpIcon?: boolean;
appearance?: "default" | "embedded"; appearance?: "default" | "embedded";
helpIconAlt: string;
hintDefault: string;
} }
@@ -25,6 +25,8 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
formHeader = true, formHeader = true,
showHelpIcon = false, showHelpIcon = false,
appearance: _appearance, appearance: _appearance,
helpIconAlt,
hintDefault,
// Component-only props: do not pass to DOM // Component-only props: do not pass to DOM
size: _size, size: _size,
labelVariant: _labelVariant, labelVariant: _labelVariant,
@@ -51,7 +53,7 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */} {/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
<img <img
src={getAssetPath(ASSETS.ICON_HELP)} src={getAssetPath(ASSETS.ICON_HELP)}
alt="Help" alt={helpIconAlt}
className="block max-w-none size-full" className="block max-w-none size-full"
/> />
</div> </div>
@@ -81,7 +83,7 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
{textHint ? ( {textHint ? (
<div className="flex items-start relative shrink-0 w-full"> <div className="flex items-start relative shrink-0 w-full">
<p className="flex-[1_0_0] font-inter font-normal leading-[16px] min-h-px min-w-px relative text-[color:var(--color-content-default-tertiary,#b4b4b4)] text-[length:var(--sizing-300,12px)]"> <p className="flex-[1_0_0] font-inter font-normal leading-[16px] min-h-px min-w-px relative text-[color:var(--color-content-default-tertiary,#b4b4b4)] text-[length:var(--sizing-300,12px)]">
{typeof textHint === "string" ? textHint : "Hint text here"} {typeof textHint === "string" ? textHint : hintDefault}
</p> </p>
</div> </div>
) : null} ) : null}
@@ -2,6 +2,7 @@
import { memo, forwardRef, useState, useRef } from "react"; import { memo, forwardRef, useState, useRef } from "react";
import { useComponentId, useFormField } from "../../../hooks"; import { useComponentId, useFormField } from "../../../hooks";
import { useTranslation } from "../../../contexts/MessagesContext";
import { TextInputView } from "./TextInput.view"; import { TextInputView } from "./TextInput.view";
import type { TextInputProps } from "./TextInput.types"; import type { TextInputProps } from "./TextInput.types";
@@ -34,6 +35,7 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
}, },
ref, ref,
) => { ) => {
const t = useTranslation("controlsChrome");
const externalState = externalStateProp; const externalState = externalStateProp;
const inputSize = inputSizeProp; const inputSize = inputSizeProp;
@@ -244,6 +246,8 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
textHint={textHint} textHint={textHint}
formHeader={formHeader} formHeader={formHeader}
maxLength={maxLength} maxLength={maxLength}
helpIconAlt={t("helpIconAlt")}
hintDefault={t("hintDefault")}
{...props} {...props}
/> />
); );
@@ -65,4 +65,6 @@ export interface TextInputViewProps {
textHint?: boolean | string; textHint?: boolean | string;
formHeader?: boolean; formHeader?: boolean;
maxLength?: number; maxLength?: number;
helpIconAlt: string;
hintDefault: string;
} }
@@ -29,6 +29,8 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
textHint = false, textHint = false,
formHeader = true, formHeader = true,
maxLength, maxLength,
helpIconAlt,
hintDefault,
}, },
ref, ref,
) => { ) => {
@@ -49,7 +51,7 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */} {/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
<img <img
src={getAssetPath(ASSETS.ICON_HELP)} src={getAssetPath(ASSETS.ICON_HELP)}
alt="Help" alt={helpIconAlt}
className="block max-w-none size-full" className="block max-w-none size-full"
/> />
</div> </div>
@@ -83,7 +85,7 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
{textHint && ( {textHint && (
<div className="flex items-start relative shrink-0 w-full"> <div className="flex items-start relative shrink-0 w-full">
<p className="flex-[1_0_0] font-inter font-normal leading-[16px] min-h-px min-w-px relative text-[color:var(--color-content-default-tertiary,#b4b4b4)] text-[length:var(--sizing-300,12px)]"> <p className="flex-[1_0_0] font-inter font-normal leading-[16px] min-h-px min-w-px relative text-[color:var(--color-content-default-tertiary,#b4b4b4)] text-[length:var(--sizing-300,12px)]">
{typeof textHint === "string" ? textHint : "Hint text here"} {typeof textHint === "string" ? textHint : hintDefault}
</p> </p>
</div> </div>
)} )}
@@ -1,6 +1,7 @@
"use client"; "use client";
import { memo, useCallback, useId, forwardRef } from "react"; import { memo, useCallback, useId, forwardRef } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import { ToggleGroupView } from "./ToggleGroup.view"; import { ToggleGroupView } from "./ToggleGroup.view";
import type { ToggleGroupProps } from "./ToggleGroup.types"; import type { ToggleGroupProps } from "./ToggleGroup.types";
@@ -10,6 +11,7 @@ import type { ToggleGroupProps } from "./ToggleGroup.types";
*/ */
const ToggleGroupContainer = memo( const ToggleGroupContainer = memo(
forwardRef<HTMLButtonElement, ToggleGroupProps>((props, _ref) => { forwardRef<HTMLButtonElement, ToggleGroupProps>((props, _ref) => {
const t = useTranslation("controlsChrome");
const { const {
children, children,
className = "", className = "",
@@ -131,6 +133,7 @@ const ToggleGroupContainer = memo(
state={state} state={state}
showText={showText} showText={showText}
ariaLabel={ariaLabel} ariaLabel={ariaLabel}
defaultToggleOptionAriaLabel={t("toggleOption")}
toggleClasses={toggleClasses} toggleClasses={toggleClasses}
onClick={handleClick} onClick={handleClick}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@@ -35,6 +35,7 @@ export interface ToggleGroupViewProps {
state: "default" | "hover" | "focus" | "selected"; state: "default" | "hover" | "focus" | "selected";
showText: boolean; showText: boolean;
ariaLabel?: string; ariaLabel?: string;
defaultToggleOptionAriaLabel: string;
toggleClasses: string; toggleClasses: string;
onClick: (_e: React.MouseEvent<HTMLButtonElement>) => void; onClick: (_e: React.MouseEvent<HTMLButtonElement>) => void;
onKeyDown: (_e: React.KeyboardEvent<HTMLButtonElement>) => void; onKeyDown: (_e: React.KeyboardEvent<HTMLButtonElement>) => void;
@@ -8,6 +8,7 @@ export function ToggleGroupView({
state: _state, state: _state,
showText, showText,
ariaLabel, ariaLabel,
defaultToggleOptionAriaLabel,
toggleClasses, toggleClasses,
onClick, onClick,
onKeyDown, onKeyDown,
@@ -20,7 +21,7 @@ export function ToggleGroupView({
id={groupId} id={groupId}
type="button" type="button"
role="button" role="button"
aria-label={ariaLabel || (showText ? undefined : "Toggle option")} aria-label={ariaLabel || (showText ? undefined : defaultToggleOptionAriaLabel)}
onClick={onClick} onClick={onClick}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onFocus={onFocus} onFocus={onFocus}
@@ -1,6 +1,7 @@
"use client"; "use client";
import { memo } from "react"; import { memo } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import UploadView from "./Upload.view"; import UploadView from "./Upload.view";
import type { UploadProps } from "./Upload.types"; import type { UploadProps } from "./Upload.types";
@@ -13,16 +14,20 @@ const UploadContainer = memo<UploadProps>(
active = true, active = true,
label, label,
showHelpIcon = true, showHelpIcon = true,
hintText = "Add image from your device", hintText,
onClick, onClick,
className = "", className = "",
}) => { }) => {
const t = useTranslation("controlsChrome");
return ( return (
<UploadView <UploadView
active={active} active={active}
label={label} label={label}
showHelpIcon={showHelpIcon} showHelpIcon={showHelpIcon}
hintText={hintText} hintText={hintText ?? t("uploadHintDefault")}
uploadButtonLabel={t("uploadButton")}
uploadAriaLabel={t("uploadAriaLabel")}
onClick={onClick} onClick={onClick}
className={className} className={className}
/> />
@@ -35,6 +35,8 @@ export interface UploadViewProps {
label?: string; label?: string;
showHelpIcon: boolean; showHelpIcon: boolean;
hintText: string; hintText: string;
uploadButtonLabel: string;
uploadAriaLabel: string;
onClick?: () => void; onClick?: () => void;
className: string; className: string;
} }
@@ -9,6 +9,8 @@ function UploadView({
label, label,
showHelpIcon = true, showHelpIcon = true,
hintText, hintText,
uploadButtonLabel,
uploadAriaLabel,
onClick, onClick,
className = "", className = "",
}: UploadViewProps) { }: UploadViewProps) {
@@ -56,7 +58,7 @@ function UploadView({
type="button" type="button"
onClick={onClick} 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`} 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 */} {/* Upload icon */}
<div className={`relative shrink-0 size-[20px] ${iconColor}`}> <div className={`relative shrink-0 size-[20px] ${iconColor}`}>
@@ -98,7 +100,7 @@ function UploadView({
<div <div
className={`flex flex-col font-inter font-medium justify-center leading-[0] relative shrink-0 text-[length:var(--sizing-400,16px)] whitespace-nowrap ${buttonTextColor}`} className={`flex flex-col font-inter font-medium justify-center leading-[0] relative shrink-0 text-[length:var(--sizing-400,16px)] whitespace-nowrap ${buttonTextColor}`}
> >
<p className="leading-[20px]">Upload</p> <p className="leading-[20px]">{uploadButtonLabel}</p>
</div> </div>
</button> </button>
@@ -1,18 +0,0 @@
"use client";
import { memo } from "react";
import LanguageSwitcherView from "./LanguageSwitcher.view";
import type { LanguageSwitcherProps } from "./LanguageSwitcher.types";
const LanguageSwitcherContainer = memo<LanguageSwitcherProps>(
({ className }) => {
// Future: Add language switching logic here
// For now, this is just a UI component
return <LanguageSwitcherView className={className} />;
},
);
LanguageSwitcherContainer.displayName = "LanguageSwitcher";
export default LanguageSwitcherContainer;
@@ -1,9 +0,0 @@
export interface LanguageSwitcherProps {
className?: string;
}
export interface Language {
code: string;
name: string;
nativeName: string;
}
@@ -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 (
<div className={className}>
<label htmlFor="language-select" className="sr-only">
{t("label")}
</label>
<select
id="language-select"
className="bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] font-inter text-sm leading-5 font-normal border border-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-small)] px-[var(--spacing-scale-012)] py-[var(--spacing-scale-008)] focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 cursor-pointer"
aria-label={t("ariaLabel")}
disabled
>
{AVAILABLE_LANGUAGES.map((language) => (
<option key={language.code} value={language.code}>
{language.nativeName}
</option>
))}
</select>
<p className="text-[var(--color-content-default-secondary)] font-inter text-xs leading-4 font-normal mt-[var(--spacing-scale-008)]">
{t("comingSoonMessage")}
</p>
</div>
);
}
export default memo(LanguageSwitcherView);
@@ -1,2 +0,0 @@
export { default } from "./LanguageSwitcher.container";
export type { LanguageSwitcherProps, Language } from "./LanguageSwitcher.types";
@@ -6,6 +6,7 @@
*/ */
import { memo } from "react"; import { memo } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import { AlertView } from "./Alert.view"; import { AlertView } from "./Alert.view";
import type { AlertProps } from "./Alert.types"; import type { AlertProps } from "./Alert.types";
@@ -74,6 +75,7 @@ const AlertContainer = memo<AlertProps>(
onClose, onClose,
className = "", className = "",
}) => { }) => {
const t = useTranslation("controlsChrome");
const status = statusProp; const status = statusProp;
const type = typeProp; const type = typeProp;
const size = sizeProp; const size = sizeProp;
@@ -175,6 +177,7 @@ const AlertContainer = memo<AlertProps>(
iconColor={statusStyles.iconColor} iconColor={statusStyles.iconColor}
closeButtonIconColor={statusStyles.closeButtonIconColor} closeButtonIconColor={statusStyles.closeButtonIconColor}
onClose={onClose} onClose={onClose}
closeAlertAriaLabel={t("closeAlert")}
/> />
); );
}, },
@@ -57,4 +57,5 @@ export interface AlertViewProps {
iconColor: string; iconColor: string;
closeButtonIconColor: string; closeButtonIconColor: string;
onClose?: () => void; onClose?: () => void;
closeAlertAriaLabel: string;
} }
+2 -1
View File
@@ -17,6 +17,7 @@ export function AlertView({
iconColor, iconColor,
closeButtonIconColor, closeButtonIconColor,
onClose, onClose,
closeAlertAriaLabel,
}: AlertViewProps) { }: AlertViewProps) {
const getIcon = () => { const getIcon = () => {
// Use the Icon_Alert.svg with dynamic fill color // Use the Icon_Alert.svg with dynamic fill color
@@ -61,7 +62,7 @@ export function AlertView({
palette="default" palette="default"
size="large" size="large"
onClick={onClose} 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)]" className="shrink-0 [&_svg_path]:transition-colors [&_svg_path]:duration-200 hover:[&_svg_path]:fill-[var(--color-content-default-primary)]"
> >
<svg <svg
@@ -5,7 +5,7 @@
* File: agv0VBLiBlcnSAaiAORgPR, node 22078-587823 * File: agv0VBLiBlcnSAaiAORgPR, node 22078-587823
*/ */
import { memo, useCallback, useEffect, useState, type FormEvent } from "react"; import { memo, useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
import { AskOrganizerInquiryModalView } from "./AskOrganizerInquiryModal.view"; import { AskOrganizerInquiryModalView } from "./AskOrganizerInquiryModal.view";
import type { AskOrganizerInquiryModalProps } from "./AskOrganizerInquiryModal.types"; import type { AskOrganizerInquiryModalProps } from "./AskOrganizerInquiryModal.types";
import { ORGANIZER_INQUIRY_HONEYPOT_FIELD } from "../../../../lib/organizerInquiryConstants"; import { ORGANIZER_INQUIRY_HONEYPOT_FIELD } from "../../../../lib/organizerInquiryConstants";
@@ -14,6 +14,23 @@ import { useTranslation } from "../../../contexts/MessagesContext";
const AskOrganizerInquiryModalContainer = memo<AskOrganizerInquiryModalProps>( const AskOrganizerInquiryModalContainer = memo<AskOrganizerInquiryModalProps>(
({ isOpen, onClose }) => { ({ isOpen, onClose }) => {
const t = useTranslation("modals.askOrganizerInquiry"); 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 [email, setEmail] = useState("");
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [honeypot, setHoneypot] = useState(""); const [honeypot, setHoneypot] = useState("");
@@ -102,6 +119,7 @@ const AskOrganizerInquiryModalContainer = memo<AskOrganizerInquiryModalProps>(
<AskOrganizerInquiryModalView <AskOrganizerInquiryModalView
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
copy={copy}
email={email} email={email}
message={message} message={message}
honeypot={honeypot} honeypot={honeypot}
@@ -2,3 +2,35 @@ export interface AskOrganizerInquiryModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => 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<HTMLFormElement>) => void;
}
@@ -1,31 +1,14 @@
"use client"; "use client";
import type { FormEvent } from "react";
import Create from "../Create"; import Create from "../Create";
import TextInput from "../../controls/TextInput"; import TextInput from "../../controls/TextInput";
import TextArea from "../../controls/TextArea"; import TextArea from "../../controls/TextArea";
import Button from "../../buttons/Button"; import Button from "../../buttons/Button";
import { useTranslation } from "../../../contexts/MessagesContext";
import { import {
ASK_ORGANIZER_INQUIRY_FORM_ID, ASK_ORGANIZER_INQUIRY_FORM_ID,
ORGANIZER_INQUIRY_HONEYPOT_FIELD, ORGANIZER_INQUIRY_HONEYPOT_FIELD,
} from "../../../../lib/organizerInquiryConstants"; } from "../../../../lib/organizerInquiryConstants";
import type { AskOrganizerInquiryModalProps } from "./AskOrganizerInquiryModal.types"; import type { AskOrganizerInquiryModalViewProps } 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<HTMLFormElement>) => void;
};
/** /**
* Figma: Community Rule System Modal / Ask an Organizer (22078-587823) * Figma: Community Rule System Modal / Ask an Organizer (22078-587823)
@@ -33,6 +16,7 @@ export type AskOrganizerInquiryModalViewProps = AskOrganizerInquiryModalProps &
export function AskOrganizerInquiryModalView({ export function AskOrganizerInquiryModalView({
isOpen, isOpen,
onClose, onClose,
copy,
email, email,
message, message,
honeypot, honeypot,
@@ -46,8 +30,6 @@ export function AskOrganizerInquiryModalView({
onHoneypotChange, onHoneypotChange,
onSubmit, onSubmit,
}: AskOrganizerInquiryModalViewProps) { }: AskOrganizerInquiryModalViewProps) {
const t = useTranslation("modals.askOrganizerInquiry");
const footer = success ? ( const footer = success ? (
<div className="w-full px-1"> <div className="w-full px-1">
<Button <Button
@@ -58,7 +40,7 @@ export function AskOrganizerInquiryModalView({
className="w-full !justify-center" className="w-full !justify-center"
onClick={onClose} onClick={onClose}
> >
{t("closeAfterSuccess")} {copy.closeAfterSuccess}
</Button> </Button>
</div> </div>
) : ( ) : (
@@ -72,7 +54,7 @@ export function AskOrganizerInquiryModalView({
className="w-full !justify-center" className="w-full !justify-center"
disabled={submitting} disabled={submitting}
> >
{t("submitButton")} {copy.submitButton}
</Button> </Button>
</div> </div>
); );
@@ -82,22 +64,22 @@ export function AskOrganizerInquiryModalView({
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
backdropVariant="blurredYellow" backdropVariant="blurredYellow"
title={t("title")} title={copy.title}
description={t("description")} description={copy.description}
showBackButton={false} showBackButton={false}
showNextButton={false} showNextButton={false}
stepper={false} stepper={false}
ariaLabel={t("ariaDialog")} ariaLabel={copy.ariaDialog}
footerContent={footer} footerContent={footer}
footerClassName="!h-auto min-h-[112px] shrink-0 flex flex-col justify-end pb-8 pt-3 px-4" footerClassName="!h-auto min-h-[112px] shrink-0 flex flex-col justify-end pb-8 pt-3 px-4"
> >
{success ? ( {success ? (
<div className="flex flex-col gap-3 py-2"> <div className="flex flex-col gap-3 py-2">
<p className="font-inter text-[18px] font-semibold leading-[24px] text-[var(--color-content-default-primary)]"> <p className="font-inter text-[18px] font-semibold leading-[24px] text-[var(--color-content-default-primary)]">
{t("successTitle")} {copy.successTitle}
</p> </p>
<p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-secondary)]"> <p className="font-inter text-[14px] leading-[20px] text-[var(--color-content-default-secondary)]">
{t("successDescription")} {copy.successDescription}
</p> </p>
</div> </div>
) : ( ) : (
@@ -120,8 +102,8 @@ export function AskOrganizerInquiryModalView({
type="email" type="email"
name="email" name="email"
autoComplete="email" autoComplete="email"
label={t("emailLabel")} label={copy.emailLabel}
placeholder={t("emailPlaceholder")} placeholder={copy.emailPlaceholder}
value={email} value={email}
onChange={(e) => onEmailChange(e.target.value)} onChange={(e) => onEmailChange(e.target.value)}
error={emailError} error={emailError}
@@ -131,8 +113,8 @@ export function AskOrganizerInquiryModalView({
<TextArea <TextArea
name="message" name="message"
label={t("questionLabel")} label={copy.questionLabel}
placeholder={t("questionPlaceholder")} placeholder={copy.questionPlaceholder}
value={message} value={message}
onChange={(e) => onMessageChange(e.target.value)} onChange={(e) => onMessageChange(e.target.value)}
error={questionError} error={questionError}
@@ -146,7 +128,7 @@ export function AskOrganizerInquiryModalView({
className="pointer-events-none absolute left-0 top-0 h-px w-px overflow-hidden opacity-0" className="pointer-events-none absolute left-0 top-0 h-px w-px overflow-hidden opacity-0"
> >
<label htmlFor={`${ASK_ORGANIZER_INQUIRY_FORM_ID}-${ORGANIZER_INQUIRY_HONEYPOT_FIELD}`}> <label htmlFor={`${ASK_ORGANIZER_INQUIRY_FORM_ID}-${ORGANIZER_INQUIRY_HONEYPOT_FIELD}`}>
{t("honeypotLabel")} {copy.honeypotLabel}
</label> </label>
<input <input
id={`${ASK_ORGANIZER_INQUIRY_FORM_ID}-${ORGANIZER_INQUIRY_HONEYPOT_FIELD}`} id={`${ASK_ORGANIZER_INQUIRY_FORM_ID}-${ORGANIZER_INQUIRY_HONEYPOT_FIELD}`}
@@ -1,5 +1,9 @@
"use client"; "use client";
/**
* Figma: "Modal / Create" (20874-172292)
*/
import { memo, useRef } from "react"; import { memo, useRef } from "react";
import { CreateView } from "./Create.view"; import { CreateView } from "./Create.view";
import type { CreateProps } from "./Create.types"; import type { CreateProps } from "./Create.types";
@@ -1,5 +1,9 @@
"use client"; "use client";
/**
* Figma: "Dialog" (see registry)
*/
import { memo, useId, useRef } from "react"; import { memo, useId, useRef } from "react";
import { useCreateModalA11y } from "../Create/useCreateModalA11y"; import { useCreateModalA11y } from "../Create/useCreateModalA11y";
import { DialogView } from "./Dialog.view"; import { DialogView } from "./Dialog.view";
@@ -1,5 +1,9 @@
"use client"; "use client";
/**
* Figma: "Modal / Login" (see registry)
*/
import { memo, useEffect, useLayoutEffect, useRef, useState } from "react"; import { memo, useEffect, useLayoutEffect, useRef, useState } from "react";
import { LoginView } from "./Login.view"; import { LoginView } from "./Login.view";
import type { LoginProps } from "./Login.types"; import type { LoginProps } from "./Login.types";
+2 -2
View File
@@ -262,14 +262,14 @@ export default function LoginForm({
<p className="text-center font-inter text-[14px] leading-[20px] text-[var(--color-content-default-tertiary)]"> <p className="text-center font-inter text-[14px] leading-[20px] text-[var(--color-content-default-tertiary)]">
{t("legalPrefix")} {t("legalPrefix")}
<Link <Link
href="#" href={tFooter("legal.termsOfServiceHref")}
className="text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2" className="text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2"
> >
{tFooter("legal.termsOfService")} {tFooter("legal.termsOfService")}
</Link> </Link>
{t("legalAnd")} {t("legalAnd")}
<Link <Link
href="#" href={tFooter("legal.privacyPolicyHref")}
className="text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2" className="text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2"
> >
{tFooter("legal.privacyPolicy")} {tFooter("legal.privacyPolicy")}
@@ -1,6 +1,7 @@
"use client"; "use client";
import { memo } from "react"; import { memo } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import { ModalFooterView } from "./ModalFooter.view"; import { ModalFooterView } from "./ModalFooter.view";
import type { ModalFooterProps } from "./ModalFooter.types"; import type { ModalFooterProps } from "./ModalFooter.types";
@@ -10,7 +11,17 @@ import type { ModalFooterProps } from "./ModalFooter.types";
* primary/secondary actions. * primary/secondary actions.
*/ */
const ModalFooterContainer = memo<ModalFooterProps>((props) => { const ModalFooterContainer = memo<ModalFooterProps>((props) => {
return <ModalFooterView {...props} />; const t = useTranslation("common");
const resolvedBackText = props.backButtonText ?? t("buttons.back");
const resolvedNextText = props.nextButtonText ?? t("buttons.next");
return (
<ModalFooterView
{...props}
backButtonText={resolvedBackText}
nextButtonText={resolvedNextText}
/>
);
}); });
ModalFooterContainer.displayName = "ModalFooter"; ModalFooterContainer.displayName = "ModalFooter";
@@ -1,6 +1,5 @@
"use client"; "use client";
import { useTranslation } from "../../../contexts/MessagesContext";
import Button from "../../buttons/Button"; import Button from "../../buttons/Button";
import Stepper from "../../progress/Stepper"; import Stepper from "../../progress/Stepper";
import type { ModalFooterProps } from "./ModalFooter.types"; import type { ModalFooterProps } from "./ModalFooter.types";
@@ -19,14 +18,6 @@ export function ModalFooterView({
footerContent, footerContent,
className = "", className = "",
}: ModalFooterProps) { }: ModalFooterProps) {
const t = useTranslation("common");
// Use localized defaults if text not provided
const defaultBackText = backButtonText || t("buttons.back");
const defaultNextText = nextButtonText || t("buttons.next");
// Determine if stepper should be shown
// Defaults to true if currentStep and totalSteps are provided, unless explicitly set to false
const shouldShowStepper = const shouldShowStepper =
stepperProp !== undefined stepperProp !== undefined
? stepperProp ? stepperProp
@@ -36,7 +27,6 @@ export function ModalFooterView({
<div <div
className={`h-[64px] bg-[var(--color-surface-default-primary)] rounded-bl-[var(--radius-300,12px)] rounded-br-[var(--radius-300,12px)] shrink-0 relative ${className}`} className={`h-[64px] bg-[var(--color-surface-default-primary)] rounded-bl-[var(--radius-300,12px)] rounded-br-[var(--radius-300,12px)] shrink-0 relative ${className}`}
> >
{/* Back Button - Absolutely positioned bottom left */}
{showBackButton && ( {showBackButton && (
<div className="absolute left-[16px] top-[12px]"> <div className="absolute left-[16px] top-[12px]">
<Button <Button
@@ -45,19 +35,17 @@ export function ModalFooterView({
size="medium" size="medium"
onClick={onBack} onClick={onBack}
> >
{defaultBackText} {backButtonText}
</Button> </Button>
</div> </div>
)} )}
{/* Stepper (Centered) */}
{shouldShowStepper && currentStep && totalSteps && ( {shouldShowStepper && currentStep && totalSteps && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"> <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<Stepper active={currentStep} totalSteps={totalSteps} /> <Stepper active={currentStep} totalSteps={totalSteps} />
</div> </div>
)} )}
{/* Next Button - Absolutely positioned bottom right */}
{showNextButton && ( {showNextButton && (
<div className="absolute right-[16px] top-[12px]"> <div className="absolute right-[16px] top-[12px]">
<Button <Button
@@ -67,12 +55,11 @@ export function ModalFooterView({
onClick={onNext} onClick={onNext}
disabled={nextButtonDisabled} disabled={nextButtonDisabled}
> >
{defaultNextText} {nextButtonText}
</Button> </Button>
</div> </div>
)} )}
{/* Custom Footer Content */}
{footerContent} {footerContent}
</div> </div>
); );
@@ -1,6 +1,7 @@
"use client"; "use client";
import { memo, useEffect, useId, useRef, useState } from "react"; import { memo, useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import { ModalHeaderView } from "./ModalHeader.view"; import { ModalHeaderView } from "./ModalHeader.view";
import type { ModalHeaderProps } from "./ModalHeader.types"; import type { ModalHeaderProps } from "./ModalHeader.types";
@@ -10,7 +11,14 @@ import type { ModalHeaderProps } from "./ModalHeader.types";
* (right) icon buttons. * (right) icon buttons.
*/ */
const ModalHeaderContainer = memo<ModalHeaderProps>((props) => { const ModalHeaderContainer = memo<ModalHeaderProps>((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 hasMenu = menuItems.length > 0;
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const menuId = useId(); const menuId = useId();
@@ -44,7 +52,11 @@ const ModalHeaderContainer = memo<ModalHeaderProps>((props) => {
return ( return (
<div ref={menuWrapRef}> <div ref={menuWrapRef}>
<ModalHeaderView <ModalHeaderView
{...props} {...rest}
menuItems={menuItems}
closeButtonAriaLabel={closeButtonAriaLabel}
moreOptionsAriaLabel={moreOptionsAriaLabel}
menuAriaLabel={menuAriaLabel}
menuId={menuId} menuId={menuId}
menuOpen={menuOpen} menuOpen={menuOpen}
onToggleMenu={hasMenu ? () => setMenuOpen((open) => !open) : undefined} onToggleMenu={hasMenu ? () => setMenuOpen((open) => !open) : undefined}
@@ -1,6 +1,6 @@
import ListItem from "../../layout/ListItem"; import ListItem from "../../layout/ListItem";
import Popover from "../Popover"; import Popover from "../Popover";
import { getAssetPath } from "../../../../lib/assetUtils"; import { ASSETS, getAssetPath } from "../../../../lib/assetUtils";
import type { ModalHeaderProps } from "./ModalHeader.types"; import type { ModalHeaderProps } from "./ModalHeader.types";
const iconButtonClass = const iconButtonClass =
@@ -11,9 +11,9 @@ export function ModalHeaderView({
onMoreOptions, onMoreOptions,
showCloseButton = true, showCloseButton = true,
showMoreOptionsButton = true, showMoreOptionsButton = true,
closeButtonAriaLabel = "Close dialog", closeButtonAriaLabel,
moreOptionsAriaLabel = "More options", moreOptionsAriaLabel,
menuAriaLabel = "More options menu", menuAriaLabel,
menuItems = [], menuItems = [],
menuId, menuId,
menuOpen = false, menuOpen = false,
@@ -37,7 +37,7 @@ export function ModalHeaderView({
> >
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */} {/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
<img <img
src={getAssetPath("assets/Icon_Close.svg")} src={getAssetPath(ASSETS.ICON_CLOSE)}
alt="" alt=""
className="w-[16px] h-[16px]" className="w-[16px] h-[16px]"
style={{ style={{
+14 -14
View File
@@ -2,6 +2,11 @@
import Image from "next/image"; import Image from "next/image";
import { memo } from "react"; import { memo } from "react";
import {
getAssetPath,
shareIconPath,
type ShareIconName,
} from "../../../../lib/assetUtils";
import ContentLockup from "../../type/ContentLockup"; import ContentLockup from "../../type/ContentLockup";
import Button from "../../buttons/Button"; import Button from "../../buttons/Button";
import ModalHeader from "../ModalHeader"; import ModalHeader from "../ModalHeader";
@@ -9,21 +14,16 @@ import ModalFooter from "../ModalFooter";
import { CreateModalFrameView } from "../Create/CreateModalFrame.view"; import { CreateModalFrameView } from "../Create/CreateModalFrame.view";
import type { ShareChannelTileProps, ShareViewProps } from "./Share.types"; import type { ShareChannelTileProps, ShareViewProps } from "./Share.types";
/** Decorative glyphs in `public/assets/Share/` — sizes match prior inline SVGs within the 60×60 circles. */ /** Decorative glyphs in `public/assets/share/` — sizes match prior inline SVGs within the 60×60 circles. */
function ShareAssetIcon(props: { function ShareAssetIcon(props: {
src: name: ShareIconName;
| "/assets/Share/Discord.svg"
| "/assets/Share/Link.svg"
| "/assets/Share/Mail.svg"
| "/assets/Share/Signal.svg"
| "/assets/Share/Slack.svg";
width: number; width: number;
height: number; height: number;
}) { }) {
const { src, width, height } = props; const { name, width, height } = props;
return ( return (
<Image <Image
src={src} src={getAssetPath(shareIconPath(name))}
alt="" alt=""
width={width} width={width}
height={height} height={height}
@@ -111,31 +111,31 @@ export const ShareView = memo(function ShareView({
label={copyLinkLabel} label={copyLinkLabel}
onClick={onCopyLink} onClick={onCopyLink}
circleClassName="border-[#444444] bg-[#333333]" circleClassName="border-[#444444] bg-[#333333]"
icon={<ShareAssetIcon src="/assets/Share/Link.svg" width={24} height={24} />} icon={<ShareAssetIcon name="link" width={24} height={24} />}
/> />
<ShareChannelTile <ShareChannelTile
label={signalLabel} label={signalLabel}
onClick={onSignalShare} onClick={onSignalShare}
circleClassName="border-[#3a76f0] bg-[#3a76f0]" circleClassName="border-[#3a76f0] bg-[#3a76f0]"
icon={<ShareAssetIcon src="/assets/Share/Signal.svg" width={26} height={26} />} icon={<ShareAssetIcon name="signal" width={26} height={26} />}
/> />
<ShareChannelTile <ShareChannelTile
label={slackLabel} label={slackLabel}
onClick={onSlackShare} onClick={onSlackShare}
circleClassName="border-[#4a154b] bg-[#4a154b]" circleClassName="border-[#4a154b] bg-[#4a154b]"
icon={<ShareAssetIcon src="/assets/Share/Slack.svg" width={26} height={26} />} icon={<ShareAssetIcon name="slack" width={26} height={26} />}
/> />
<ShareChannelTile <ShareChannelTile
label={discordLabel} label={discordLabel}
onClick={onDiscordShare} onClick={onDiscordShare}
circleClassName="border-[#5865f2] bg-[#5865f2]" circleClassName="border-[#5865f2] bg-[#5865f2]"
icon={<ShareAssetIcon src="/assets/Share/Discord.svg" width={30} height={30} />} icon={<ShareAssetIcon name="discord" width={30} height={30} />}
/> />
<ShareChannelTile <ShareChannelTile
label={emailLabel} label={emailLabel}
onClick={onEmailShare} onClick={onEmailShare}
circleClassName="border-[var(--color-surface-default-brand-kiwi)] bg-[var(--color-surface-default-brand-kiwi)]" circleClassName="border-[var(--color-surface-default-brand-kiwi)] bg-[var(--color-surface-default-brand-kiwi)]"
icon={<ShareAssetIcon src="/assets/Share/Mail.svg" width={24} height={24} />} icon={<ShareAssetIcon name="mail" width={24} height={24} />}
/> />
</div> </div>
</div> </div>

Some files were not shown because too many files have changed in this diff Show More