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:
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
+66
@@ -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;
|
||||||
+55
@@ -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;
|
||||||
|
};
|
||||||
+198
@@ -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";
|
||||||
+110
@@ -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";
|
||||||
+100
@@ -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}
|
||||||
|
|||||||
+77
@@ -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";
|
||||||
+21
@@ -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;
|
||||||
|
}
|
||||||
+12
-57
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
+2
-2
@@ -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}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+57
@@ -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";
|
||||||
+26
@@ -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;
|
||||||
|
};
|
||||||
+27
-46
@@ -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}
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,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
@@ -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 });
|
||||||
|
});
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> </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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user