Full cleanup pass
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/(admin)/` | `/monitor`, future ops dashboards | Operators | `Top` (via root) — no footer |
|
||||
| `app/(dev)/` | `/components-preview`, future dev previews | Local dev (NODE_ENV gated) | `Top` (via root) — no footer |
|
||||
| `app/(marketing-case-study)/` | `/use-cases/[slug]/rule` | Public case-study demos | Chromeless (no global `Top`; see `navigationChromelessPath.ts`) |
|
||||
| `app/api/` | API routes | n/a | n/a |
|
||||
|
||||
Route folders **must not** sit loose at the top level of `app/`. If a new
|
||||
|
||||
@@ -15,6 +15,7 @@ SMTP_FROM="Community Rule <noreply@localhost>"
|
||||
ORGANIZER_INQUIRY_TO=
|
||||
|
||||
# Set to `true` to sync the create-flow draft with `/api/drafts/me` when the user is signed in.
|
||||
# Server draft sync (default on). Set to `false` to disable PUT/GET /api/drafts/me.
|
||||
NEXT_PUBLIC_ENABLE_BACKEND_SYNC=
|
||||
|
||||
# Web vitals API (CR-80): `external` = structured logs only, no writes under `.next` (default in production).
|
||||
|
||||
@@ -68,7 +68,7 @@ Run these (in order) before declaring a change done:
|
||||
```bash
|
||||
rm -rf .next # only if you moved/renamed routes or layouts
|
||||
npx tsc --noEmit # type check
|
||||
npx vitest run # unit + component (101 files / ~700 tests)
|
||||
npx vitest run # unit + component (~185 test files)
|
||||
npx next build # production build + route manifest
|
||||
```
|
||||
|
||||
|
||||
+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/create-flow/methods` | Facet-aware scores for custom-rule card steps: required `section` (`communication` \| `membership` \| `decisionApproaches` \| `conflictManagement`) and optional `facet.*` params (same facet groups as `/api/templates`). Returns `methods` with match metadata for re-ordering in the wizard. |
|
||||
| POST / GET | `/api/web-vitals` | Ingest or read web vitals. **Production default:** `external` — structured logs only (no writes under `.next`; safe for read-only FS). **Development default:** `local` — aggregates under `.next/web-vitals`. Override with `WEB_VITALS_STORAGE`. See [docs/guides/backend-roadmap.md](docs/guides/backend-roadmap.md) §7. |
|
||||
| GET | `/api/rules/me` | Authenticated list of own published rules. |
|
||||
| GET / PATCH / DELETE | `/api/rules/[id]` | Public read; owner update/delete. |
|
||||
| POST | `/api/rules/[id]/duplicate` | Owner clone of a published rule. |
|
||||
| GET / POST | `/api/rules/[id]/stakeholders` | List or invite rule stakeholders. |
|
||||
| DELETE | `/api/rules/[id]/stakeholders/[stakeholderId]` | Remove a stakeholder. |
|
||||
| POST | `/api/rules/[id]/stakeholders/[stakeholderId]/resend` | Resend stakeholder invite email. |
|
||||
| GET | `/api/invites/rule-stakeholder/verify` | Verify stakeholder invite token; redirect. |
|
||||
| DELETE | `/api/user/me` | Delete authenticated user account. |
|
||||
| POST | `/api/user/email-change/request` | Request email change (magic link to new address). |
|
||||
| GET | `/api/user/email-change/verify` | Verify email-change token; update `User.email`. |
|
||||
| POST | `/api/organizer-inquiry` | Submit ask-organizer inquiry form. |
|
||||
| POST | `/api/use-cases/[slug]/duplicate` | Duplicate a use-case demo rule. |
|
||||
|
||||
### Magic-link sign-in
|
||||
|
||||
@@ -58,10 +70,10 @@ deployment-pipeline work.
|
||||
|
||||
### Optional draft sync
|
||||
|
||||
`NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` enables Postgres draft persistence
|
||||
via `PUT /api/drafts/me` for signed-in users and post-sign-in upload of
|
||||
anonymous drafts. Without it, anonymous progress stays in `localStorage`
|
||||
and signed-in progress stays in memory until **Save & Exit**.
|
||||
Postgres draft persistence via `PUT /api/drafts/me` is **on by default** for
|
||||
signed-in users and post-sign-in transfer of anonymous drafts. Set
|
||||
`NEXT_PUBLIC_ENABLE_BACKEND_SYNC=false` to disable server sync (anonymous
|
||||
progress stays in `localStorage` only).
|
||||
|
||||
### Create flow
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "WebVitalsDashboard" (see registry)
|
||||
*/
|
||||
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { logger } from "../../../../../lib/logger";
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
@@ -80,6 +81,7 @@ import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
|
||||
import { SignedInDraftHydration } from "./SignedInDraftHydration";
|
||||
import { CreateFlowPendingAvatarFlush } from "./components/CreateFlowPendingAvatarFlush";
|
||||
import Alert from "../../components/modals/Alert";
|
||||
import Create from "../../components/modals/Create";
|
||||
import Share from "../../components/modals/Share";
|
||||
import {
|
||||
CreateFlowDraftSaveBannerProvider,
|
||||
@@ -190,6 +192,26 @@ function CreateFlowLayoutContent({
|
||||
description?: string;
|
||||
} | null>(null);
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false);
|
||||
const leaveConfirmResolverRef = useRef<((proceed: boolean) => void) | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const confirmLeave = useCallback(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
leaveConfirmResolverRef.current = resolve;
|
||||
setLeaveConfirmOpen(true);
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const closeLeaveConfirm = useCallback((proceed: boolean) => {
|
||||
setLeaveConfirmOpen(false);
|
||||
const resolve = leaveConfirmResolverRef.current;
|
||||
leaveConfirmResolverRef.current = null;
|
||||
resolve?.(proceed);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
copyPublishedRuleLink,
|
||||
@@ -256,6 +278,7 @@ function CreateFlowLayoutContent({
|
||||
router,
|
||||
user: sessionUser ?? null,
|
||||
setDraftSaveBannerMessage,
|
||||
confirmLeave,
|
||||
});
|
||||
|
||||
const handleExit = async (opts?: { saveDraft?: boolean }) => {
|
||||
@@ -601,6 +624,28 @@ function CreateFlowLayoutContent({
|
||||
onSlackShare={() => void sharePublishedRuleViaSlack()}
|
||||
onDiscordShare={() => void sharePublishedRuleViaDiscord()}
|
||||
/>
|
||||
<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
|
||||
hasShare={isCompletedStep}
|
||||
hasExport={isCompletedStep}
|
||||
|
||||
@@ -2,18 +2,24 @@
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
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(
|
||||
() => import("./CreateFlowLayoutClient"),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div
|
||||
className="flex h-screen min-h-0 flex-col overflow-hidden bg-black"
|
||||
aria-busy="true"
|
||||
aria-label="Loading create flow"
|
||||
/>
|
||||
),
|
||||
loading: () => <CreateFlowLayoutLoading />,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { CreateFlowState } from "./types";
|
||||
import messages from "../../../messages/en/index";
|
||||
import Alert from "../../components/modals/Alert";
|
||||
|
||||
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
||||
import { isBackendSyncEnabled } from "../../../lib/create/backendSyncEnabled";
|
||||
|
||||
function buildPayloadWithStep(
|
||||
base: CreateFlowState,
|
||||
@@ -111,7 +111,7 @@ export function PostLoginDraftTransfer({
|
||||
return;
|
||||
}
|
||||
|
||||
if (SYNC_ENABLED && createFlowStateHasKeys(local)) {
|
||||
if (isBackendSyncEnabled() && createFlowStateHasKeys(local)) {
|
||||
const saveResult = await saveDraftToServer(payload);
|
||||
if (cancelled) return;
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
parseCreateFlowScreenFromPathname,
|
||||
} from "./utils/flowSteps";
|
||||
|
||||
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
||||
import { isBackendSyncEnabled } from "../../../lib/create/backendSyncEnabled";
|
||||
|
||||
/**
|
||||
* When sync is on and the user is signed in, restore the server-side draft only
|
||||
@@ -54,7 +54,7 @@ export function SignedInDraftHydration({
|
||||
const finishedUserIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!SYNC_ENABLED) return;
|
||||
if (!isBackendSyncEnabled()) return;
|
||||
if (!sessionResolved) return;
|
||||
if (sessionUser == null || sessionUser === undefined) {
|
||||
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 { ASSETS, getAssetPath } from "../../../../lib/assetUtils";
|
||||
import ApplicableScopeField from "./ApplicableScopeField";
|
||||
import InputLabel from "../../../components/type/InputLabel";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks";
|
||||
import ModalTextAreaField from "./ModalTextAreaField";
|
||||
import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer";
|
||||
|
||||
const TEXT_VALUE_MAX = 8000;
|
||||
|
||||
export interface CustomMethodCardFieldBlocksSummaryProps {
|
||||
blocks: CustomMethodCardFieldBlock[];
|
||||
/** When set, fields update the draft via immutable block-array replacements. */
|
||||
onBlocksChange?: (_next: CustomMethodCardFieldBlock[]) => void;
|
||||
}
|
||||
|
||||
function mapBlockById(
|
||||
blocks: CustomMethodCardFieldBlock[],
|
||||
blockId: string,
|
||||
mapFn: (_b: CustomMethodCardFieldBlock) => CustomMethodCardFieldBlock,
|
||||
): CustomMethodCardFieldBlock[] {
|
||||
return blocks.map((b) => (b.id === blockId ? mapFn(b) : b));
|
||||
}
|
||||
|
||||
function CustomMethodCardUploadBlockRow({
|
||||
block,
|
||||
blocks,
|
||||
patch,
|
||||
uploadFileInputAriaLabel,
|
||||
uploadHint,
|
||||
clearPendingUploadAriaLabel,
|
||||
clearPendingUploadTooltip,
|
||||
uploadPreviewImageAlt,
|
||||
noFileChosen,
|
||||
}: {
|
||||
block: Extract<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)}
|
||||
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 AddCustomField from "../../../../components/controls/AddCustomField";
|
||||
import { CustomMethodCardWizardFieldBodiesView } from "./CustomMethodCardWizardFieldBodies.view";
|
||||
import { CustomMethodCardWizardBlocksListView } from "./CustomMethodCardWizardBlocksList.view";
|
||||
import { CustomMethodCardWizardBlocksList } from "./CustomMethodCardWizardBlocksList.container";
|
||||
import type { CustomMethodCardWizardViewProps } from "./CustomMethodCardWizard.types";
|
||||
|
||||
function CustomMethodCardWizardViewComponent({
|
||||
@@ -90,7 +90,7 @@ function CustomMethodCardWizardViewComponent({
|
||||
{!fieldTypeModal && wizardStep === 3 ? (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{draftFieldBlocks.length > 0 ? (
|
||||
<CustomMethodCardWizardBlocksListView
|
||||
<CustomMethodCardWizardBlocksList
|
||||
blocks={draftFieldBlocks}
|
||||
fieldTypeLabels={copy.fieldTypeLabels}
|
||||
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";
|
||||
|
||||
import { memo, useCallback, useState, type DragEvent } from "react";
|
||||
import { memo } from "react";
|
||||
import Icon from "../../../../components/asset/icon";
|
||||
import { ADD_CUSTOM_FIELD_TYPE_ICONS } from "../../../../components/controls/AddCustomField/AddCustomField.types";
|
||||
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||
import { reorderCustomMethodCardFieldBlocks } from "../../../../../lib/create/reorderCustomMethodCardFieldBlocks";
|
||||
import type { CustomMethodCardWizardBlocksListViewProps } from "./CustomMethodCardWizardBlocksList.types";
|
||||
|
||||
function DragHandleGlyph({ className }: { className?: string }) {
|
||||
return (
|
||||
@@ -28,62 +27,18 @@ function DragHandleGlyph({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export interface CustomMethodCardWizardBlocksListViewProps {
|
||||
blocks: CustomMethodCardFieldBlock[];
|
||||
fieldTypeLabels: Record<AddCustomFieldType, string>;
|
||||
dragHandleAriaLabel: string;
|
||||
listLabel: string;
|
||||
onBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void;
|
||||
}
|
||||
|
||||
function CustomMethodCardWizardBlocksListViewComponent({
|
||||
blocks,
|
||||
fieldTypeLabels,
|
||||
dragHandleAriaLabel,
|
||||
listLabel,
|
||||
onBlocksReorder,
|
||||
draggingIndex,
|
||||
overIndex,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
}: CustomMethodCardWizardBlocksListViewProps) {
|
||||
const [draggingIndex, setDraggingIndex] = useState<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 (
|
||||
<ul className="flex list-none flex-col gap-2 p-0" aria-label={listLabel}>
|
||||
{blocks.map((block, index) => {
|
||||
@@ -98,14 +53,14 @@ function CustomMethodCardWizardBlocksListViewComponent({
|
||||
? "ring-2 ring-[var(--color-border-invert-primary)] ring-offset-2 ring-offset-[var(--color-surface-default-primary)]"
|
||||
: ""
|
||||
} ${draggingIndex === index ? "opacity-60" : ""}`}
|
||||
onDragOver={handleDragOver(index)}
|
||||
onDrop={handleDrop(index)}
|
||||
onDragOver={onDragOver(index)}
|
||||
onDrop={onDrop(index)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
draggable
|
||||
onDragStart={handleDragStart(index)}
|
||||
onDragEnd={clearDragUi}
|
||||
onDragStart={onDragStart(index)}
|
||||
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)]"
|
||||
aria-label={dragHandleAriaLabel}
|
||||
>
|
||||
|
||||
@@ -5,10 +5,9 @@ import type { CreateFlowState, CreateFlowStep } from "../types";
|
||||
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
|
||||
import { saveDraftToServer, updatePublishedRule } from "../../../../lib/create/api";
|
||||
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
|
||||
import { isBackendSyncEnabled } from "../../../../lib/create/backendSyncEnabled";
|
||||
import messages from "../../../../messages/en/index";
|
||||
|
||||
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
||||
|
||||
export type CreateFlowExitClearState = () => void;
|
||||
|
||||
type AppRouterLike = { push: (_href: string) => void };
|
||||
@@ -23,6 +22,7 @@ export function useCreateFlowExit({
|
||||
router,
|
||||
user,
|
||||
setDraftSaveBannerMessage,
|
||||
confirmLeave,
|
||||
}: {
|
||||
state: CreateFlowState;
|
||||
currentStep: CreateFlowStep | null;
|
||||
@@ -31,6 +31,8 @@ export function useCreateFlowExit({
|
||||
user: { id: string; email: string } | null;
|
||||
/** When save fails, surface the server message in the create shell banner (no leave confirm). */
|
||||
setDraftSaveBannerMessage?: (_message: string | null) => void;
|
||||
/** When exit would discard unsaved work, return true to proceed. Defaults to `window.confirm`. */
|
||||
confirmLeave?: () => Promise<boolean>;
|
||||
}): (_options?: { saveDraft?: boolean }) => Promise<void> {
|
||||
return useCallback(
|
||||
async (options?: { saveDraft?: boolean }) => {
|
||||
@@ -38,14 +40,18 @@ export function useCreateFlowExit({
|
||||
|
||||
const saveDraft = options?.saveDraft ?? false;
|
||||
|
||||
if (!saveDraft && typeof window !== "undefined") {
|
||||
const confirmed = window.confirm(
|
||||
messages.create.topNav.leaveConfirmLoss,
|
||||
);
|
||||
if (!saveDraft) {
|
||||
const confirmFn =
|
||||
confirmLeave ??
|
||||
(async () => {
|
||||
if (typeof window === "undefined") return true;
|
||||
return window.confirm(messages.create.topNav.leaveConfirmLoss);
|
||||
});
|
||||
const confirmed = await confirmFn();
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
if (saveDraft && SYNC_ENABLED) {
|
||||
if (saveDraft && isBackendSyncEnabled()) {
|
||||
const editingId =
|
||||
typeof state.editingPublishedRuleId === "string"
|
||||
? state.editingPublishedRuleId.trim()
|
||||
@@ -97,6 +103,14 @@ export function useCreateFlowExit({
|
||||
clearState();
|
||||
router.push("/");
|
||||
},
|
||||
[state, currentStep, clearState, router, user, setDraftSaveBannerMessage],
|
||||
[
|
||||
state,
|
||||
currentStep,
|
||||
clearState,
|
||||
router,
|
||||
user,
|
||||
setDraftSaveBannerMessage,
|
||||
confirmLeave,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,11 @@ export const CREATE_FLOW_TRANSFER_PENDING_KEY =
|
||||
"create-flow-transfer-pending" as const;
|
||||
|
||||
/**
|
||||
* When signed-in + sync, {@link SignedInDraftHydration} resolves server vs this key via `window.confirm`
|
||||
* if both are non-empty; see `messages/en/create/draftHydration.json`.
|
||||
* When signed-in + sync, local draft wins if non-empty; server draft applies when local is empty.
|
||||
* 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_DRAFT_KEY = "create-flow-draft";
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@ import { deleteServerDraft } from "../../../../lib/create/api";
|
||||
import { clearAnonymousCreateFlowStorage } from "./anonymousDraftStorage";
|
||||
import { clearCoreValueDetailsLocalStorage } from "./coreValueDetailsLocalStorage";
|
||||
|
||||
const SYNC_ENABLED =
|
||||
process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
||||
import { isBackendSyncEnabled } from "../../../../lib/create/backendSyncEnabled";
|
||||
|
||||
/**
|
||||
* Call **before** navigating into `/create` from marketing or profile “new rule”
|
||||
@@ -17,7 +16,7 @@ const SYNC_ENABLED =
|
||||
export async function prepareFreshCreateFlowEntry(): Promise<void> {
|
||||
clearAnonymousCreateFlowStorage();
|
||||
clearCoreValueDetailsLocalStorage();
|
||||
if (SYNC_ENABLED) {
|
||||
if (isBackendSyncEnabled()) {
|
||||
await deleteServerDraft();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
// 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 }) {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
notFound();
|
||||
}
|
||||
return <main className="flex-1">{children}</main>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import messages from "../../../../messages/en/index";
|
||||
import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules";
|
||||
import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../lib/create/publishedDocumentToDisplaySections";
|
||||
import CommunityRule from "../../../components/type/CommunityRule";
|
||||
@@ -16,7 +17,7 @@ export async function generateMetadata({
|
||||
const rule = await getPublicPublishedRuleById(id);
|
||||
if (!rule) {
|
||||
return {
|
||||
title: "Rule Not Found",
|
||||
title: messages.pages.ruleDetail.notFoundTitle,
|
||||
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";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import CommunityRule from "../../../../../components/type/CommunityRule";
|
||||
import type { CommunityRuleSection } from "../../../../../components/type/CommunityRule/CommunityRule.types";
|
||||
import CreateFlowTopNav from "../../../../../components/navigation/CreateFlowTopNav";
|
||||
import Share from "../../../../../components/modals/Share";
|
||||
import Alert from "../../../../../components/modals/Alert";
|
||||
@@ -12,41 +9,25 @@ import {
|
||||
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
|
||||
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
||||
} from "../../../../../(app)/create/components/createFlowLayoutTokens";
|
||||
import { useCreateFlowMdUp } from "../../../../../(app)/create/hooks/useCreateFlowMdUp";
|
||||
import { useTranslation } from "../../../../../contexts/MessagesContext";
|
||||
import type { UseCaseDetailSlug } from "../../../../../../lib/useCaseSyntheticPost";
|
||||
import type { UseCaseCompletedRuleFixture } from "../../../../../../lib/useCaseCompletedRule";
|
||||
import {
|
||||
useUseCaseCompletedRuleActions,
|
||||
type UseCaseCompletedRuleActionBanner,
|
||||
} from "./useUseCaseCompletedRuleActions";
|
||||
import type { UseCaseCompletedRuleViewProps } from "./UseCaseCompletedRule.types";
|
||||
|
||||
export type UseCaseCompletedRuleViewProps = {
|
||||
slug: UseCaseDetailSlug;
|
||||
fixture: UseCaseCompletedRuleFixture;
|
||||
sections: CommunityRuleSection[];
|
||||
};
|
||||
|
||||
/** Figma: Completed CR — use case demos (21995:39476, 21995:40092, 22015:42413). */
|
||||
export function UseCaseCompletedRuleView({
|
||||
slug,
|
||||
fixture,
|
||||
sections,
|
||||
mdUp,
|
||||
duplicateLabel,
|
||||
duplicateAriaLabel,
|
||||
exitLabel,
|
||||
shareModalOpen,
|
||||
onShareOpen,
|
||||
onShareClose,
|
||||
onCopyLink,
|
||||
onEmailShare,
|
||||
onDuplicate,
|
||||
onExit,
|
||||
actionBanner,
|
||||
onActionBannerClose,
|
||||
}: 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;
|
||||
|
||||
return (
|
||||
@@ -69,7 +50,7 @@ export function UseCaseCompletedRuleView({
|
||||
description={actionBanner.description}
|
||||
hasLeadingIcon
|
||||
hasBodyText={Boolean(actionBanner.description)}
|
||||
onClose={() => setActionBanner(null)}
|
||||
onClose={onActionBannerClose}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -77,24 +58,24 @@ export function UseCaseCompletedRuleView({
|
||||
) : null}
|
||||
<Share
|
||||
isOpen={shareModalOpen}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
onCopyLink={() => void copyPageLink()}
|
||||
onEmailShare={mailtoPageLink}
|
||||
onSignalShare={() => void copyPageLink()}
|
||||
onSlackShare={() => void copyPageLink()}
|
||||
onDiscordShare={() => void copyPageLink()}
|
||||
onClose={onShareClose}
|
||||
onCopyLink={onCopyLink}
|
||||
onEmailShare={onEmailShare}
|
||||
onSignalShare={onCopyLink}
|
||||
onSlackShare={onCopyLink}
|
||||
onDiscordShare={onCopyLink}
|
||||
/>
|
||||
<CreateFlowTopNav
|
||||
hasShare
|
||||
hasDuplicate
|
||||
duplicateLabel={tTopNav("duplicate")}
|
||||
duplicateAriaLabel={tTopNav("duplicateAriaLabel")}
|
||||
exitLabel={tTopNav("return")}
|
||||
duplicateLabel={duplicateLabel}
|
||||
duplicateAriaLabel={duplicateAriaLabel}
|
||||
exitLabel={exitLabel}
|
||||
buttonPalette="inverse"
|
||||
className="shrink-0 !bg-transparent"
|
||||
onShare={() => setShareModalOpen(true)}
|
||||
onDuplicate={() => void handleDuplicate()}
|
||||
onExit={() => router.push(`/use-cases/${slug}`)}
|
||||
onShare={onShareOpen}
|
||||
onDuplicate={onDuplicate}
|
||||
onExit={onExit}
|
||||
/>
|
||||
<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}`}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
USE_CASE_DETAIL_SLUGS,
|
||||
useCaseContentKeyForSlug,
|
||||
} from "../../../../../lib/useCaseSyntheticPost";
|
||||
import { UseCaseCompletedRuleView } from "./_components/UseCaseCompletedRule.view";
|
||||
import { UseCaseCompletedRule } from "./_components/UseCaseCompletedRule.container";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ slug: string }>;
|
||||
@@ -57,7 +57,7 @@ export default async function UseCaseCompletedRulePage({ params }: PageProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<UseCaseCompletedRuleView
|
||||
<UseCaseCompletedRule
|
||||
slug={resolved.slug}
|
||||
fixture={resolved.fixture}
|
||||
sections={resolved.sections}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
||||
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 {
|
||||
SECTION_IDS,
|
||||
type SectionId,
|
||||
@@ -19,38 +20,37 @@ const SECTION_SET = new Set<string>(SECTION_IDS);
|
||||
*
|
||||
* See `docs/guides/template-recommendation-matrix.md` §9.2 / §10.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
export const GET = apiRoute(
|
||||
"createFlow.methods.get",
|
||||
async (request: NextRequest) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const sectionParam = request.nextUrl.searchParams.get("section");
|
||||
if (!sectionParam || !SECTION_SET.has(sectionParam)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: "validation_error",
|
||||
message: `Unknown section. Expected one of: ${SECTION_IDS.join(", ")}`,
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
const sectionParam = request.nextUrl.searchParams.get("section");
|
||||
if (!sectionParam || !SECTION_SET.has(sectionParam)) {
|
||||
return errorJson(
|
||||
"validation_error",
|
||||
`Unknown section. Expected one of: ${SECTION_IDS.join(", ")}`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
const section = sectionParam as SectionId;
|
||||
|
||||
const facets = parseRequestedFacetsFromSearchParams(
|
||||
request.nextUrl.searchParams,
|
||||
);
|
||||
}
|
||||
const section = sectionParam as SectionId;
|
||||
const result = await listMethodRecommendations({ section, facets });
|
||||
if (!result) {
|
||||
// DB query failed; return empty so the wizard falls back to its messages
|
||||
// deck in authoring order (§10).
|
||||
return NextResponse.json({ section, methods: [] });
|
||||
}
|
||||
|
||||
const facets = parseRequestedFacetsFromSearchParams(
|
||||
request.nextUrl.searchParams,
|
||||
);
|
||||
const result = await listMethodRecommendations({ section, facets });
|
||||
if (!result) {
|
||||
// DB query failed; return empty so the wizard falls back to its messages
|
||||
// deck in authoring order (§10).
|
||||
return NextResponse.json({ section, methods: [] });
|
||||
}
|
||||
|
||||
const methods = result.rankedSlugs.map((slug) => ({
|
||||
slug,
|
||||
matches: result.matchesBySlug[slug] ?? { score: 0, matchedFacets: [] },
|
||||
}));
|
||||
return NextResponse.json({ section, methods });
|
||||
}
|
||||
const methods = result.rankedSlugs.map((slug) => ({
|
||||
slug,
|
||||
matches: result.matchesBySlug[slug] ?? { score: 0, matchedFacets: [] },
|
||||
}));
|
||||
return NextResponse.json({ section, methods });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/server/db";
|
||||
import { isDatabaseConfigured } from "../../../lib/server/env";
|
||||
import { apiRoute } from "../../../lib/server/apiRoute";
|
||||
|
||||
export async function GET() {
|
||||
export const GET = apiRoute("health.get", async () => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
@@ -16,4 +17,4 @@ export async function GET() {
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, database: "error" }, { status: 503 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { isDatabaseConfigured } from "../../../lib/server/env";
|
||||
import { listRankedRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
|
||||
import { dbUnavailable } from "../../../lib/server/responses";
|
||||
import { parseRequestedFacetsFromSearchParams } from "../../../lib/server/validation/methodFacetsSchemas";
|
||||
import { apiRoute } from "../../../lib/server/apiRoute";
|
||||
|
||||
/**
|
||||
* GET /api/templates
|
||||
@@ -15,7 +16,7 @@ import { parseRequestedFacetsFromSearchParams } from "../../../lib/server/valida
|
||||
*
|
||||
* See `docs/guides/template-recommendation-matrix.md` §9.1.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
export const GET = apiRoute("templates.get", async (request: NextRequest) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
@@ -29,4 +30,4 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
hasScores ? { templates, scores } : { templates },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
+47
-62
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { apiRoute } from "../../../lib/server/apiRoute";
|
||||
import { getWebVitalsStorageMode } from "../../../lib/server/webVitals/mode";
|
||||
import {
|
||||
appendLocalWebVital,
|
||||
@@ -29,70 +30,54 @@ function logExternalIngest(body: WebVitalData): void {
|
||||
logger.info(line);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const limited = await readLimitedJson(request);
|
||||
if (limited.ok === false) {
|
||||
return limited.response;
|
||||
}
|
||||
|
||||
const parsed = webVitalIngestSchema.safeParse(limited.value);
|
||||
if (!parsed.success) return jsonFromZodError(parsed.error);
|
||||
|
||||
const body = parsed.data;
|
||||
|
||||
const vitalsData: WebVitalData = {
|
||||
metric: body.metric,
|
||||
data: {
|
||||
value: body.data.value,
|
||||
rating: body.data.rating,
|
||||
},
|
||||
url: body.url,
|
||||
userAgent: body.userAgent,
|
||||
timestamp: normalizeTimestamp(body.timestamp),
|
||||
receivedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mode = getWebVitalsStorageMode();
|
||||
|
||||
if (mode === "external") {
|
||||
logExternalIngest(vitalsData);
|
||||
return NextResponse.json({ success: true, storage: "external" });
|
||||
}
|
||||
|
||||
appendLocalWebVital(vitalsData);
|
||||
logger.info(
|
||||
`Web Vital received: ${body.metric} = ${body.data.value}ms (${body.data.rating})`,
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, storage: "local" });
|
||||
} catch (error) {
|
||||
logger.error("Error processing web vital:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
export const POST = apiRoute("webVitals.post", async (request: NextRequest) => {
|
||||
const limited = await readLimitedJson(request);
|
||||
if (limited.ok === false) {
|
||||
return limited.response;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const mode = getWebVitalsStorageMode();
|
||||
const parsed = webVitalIngestSchema.safeParse(limited.value);
|
||||
if (!parsed.success) return jsonFromZodError(parsed.error);
|
||||
|
||||
if (mode === "external") {
|
||||
return NextResponse.json({
|
||||
metrics: {},
|
||||
storage: "external" as const,
|
||||
});
|
||||
}
|
||||
const body = parsed.data;
|
||||
|
||||
const metrics = readLocalAggregatedMetrics();
|
||||
return NextResponse.json({ metrics, storage: "local" as const });
|
||||
} catch (error) {
|
||||
logger.error("Error fetching web vitals:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
const vitalsData: WebVitalData = {
|
||||
metric: body.metric,
|
||||
data: {
|
||||
value: body.data.value,
|
||||
rating: body.data.rating,
|
||||
},
|
||||
url: body.url,
|
||||
userAgent: body.userAgent,
|
||||
timestamp: normalizeTimestamp(body.timestamp),
|
||||
receivedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mode = getWebVitalsStorageMode();
|
||||
|
||||
if (mode === "external") {
|
||||
logExternalIngest(vitalsData);
|
||||
return NextResponse.json({ success: true, storage: "external" });
|
||||
}
|
||||
}
|
||||
|
||||
appendLocalWebVital(vitalsData);
|
||||
logger.info(
|
||||
`Web Vital received: ${body.metric} = ${body.data.value}ms (${body.data.rating})`,
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, storage: "local" });
|
||||
});
|
||||
|
||||
export const GET = apiRoute("webVitals.get", async () => {
|
||||
const mode = getWebVitalsStorageMode();
|
||||
|
||||
if (mode === "external") {
|
||||
return NextResponse.json({
|
||||
metrics: {},
|
||||
storage: "external" as const,
|
||||
});
|
||||
}
|
||||
|
||||
const metrics = readLocalAggregatedMetrics();
|
||||
return NextResponse.json({ metrics, storage: "local" as const });
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
|
||||
|
||||
interface LogoProps {
|
||||
@@ -31,6 +34,8 @@ interface SizeConfig {
|
||||
|
||||
const Logo = memo<LogoProps>(
|
||||
({ size = "default", palette = "default", wordmark = true }) => {
|
||||
const t = useTranslation("controlsChrome");
|
||||
|
||||
// Size configurations
|
||||
const sizes: Record<string, SizeConfig> = {
|
||||
default: {
|
||||
@@ -97,7 +102,7 @@ const Logo = memo<LogoProps>(
|
||||
: "hidden";
|
||||
|
||||
return (
|
||||
<Link href="/" className="block" aria-label="CommunityRule Logo">
|
||||
<Link href="/" className="block" aria-label={t("logoAlt")}>
|
||||
<div
|
||||
className={`flex items-center ${config.containerHeight} ${
|
||||
wordmark ? config.gap : ""
|
||||
@@ -106,16 +111,16 @@ const Logo = memo<LogoProps>(
|
||||
{/* Logo Text - responsive visibility for topNav sizes */}
|
||||
<div
|
||||
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>
|
||||
|
||||
{/* Vector Icon */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getAssetPath(ASSETS.LOGO)}
|
||||
alt="CommunityRule Logo Icon"
|
||||
alt={t("logoAlt")}
|
||||
width={27.05}
|
||||
height={27.05}
|
||||
className={`flex-shrink-0 ${config.iconSize} transition-all duration-200 ${
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { CardStackView } from "./CardStack.view";
|
||||
import type { CardStackProps } from "./CardStack.types";
|
||||
|
||||
const DEFAULT_TOGGLE_LABEL = "See all communication approaches";
|
||||
const DEFAULT_SHOW_LESS_LABEL = "Show less";
|
||||
|
||||
/**
|
||||
* Figma: "Utility / CardStack"; canonical code under `cards/`.
|
||||
@@ -22,7 +22,7 @@ const CardStackContainer = memo<CardStackProps>(
|
||||
onToggleExpand: controlledOnToggleExpand,
|
||||
hasMore = true,
|
||||
toggleLabel = DEFAULT_TOGGLE_LABEL,
|
||||
showLessLabel = DEFAULT_SHOW_LESS_LABEL,
|
||||
showLessLabel,
|
||||
title = "",
|
||||
description = "",
|
||||
layout = "default",
|
||||
@@ -37,6 +37,7 @@ const CardStackContainer = memo<CardStackProps>(
|
||||
addCardAriaLabel = "",
|
||||
onAddCard,
|
||||
}) => {
|
||||
const t = useTranslation("controlsChrome");
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
const [internalSelectedIds, setInternalSelectedIds] = useState<string[]>(
|
||||
[],
|
||||
@@ -84,7 +85,7 @@ const CardStackContainer = memo<CardStackProps>(
|
||||
onToggleExpand={handleToggleExpand}
|
||||
hasMore={hasMore}
|
||||
toggleLabel={toggleLabel}
|
||||
showLessLabel={showLessLabel}
|
||||
showLessLabel={showLessLabel ?? t("cardStackShowLess")}
|
||||
title={title}
|
||||
description={description}
|
||||
layout={layout}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Card / Icon" (see registry)
|
||||
*/
|
||||
|
||||
import { memo, useId } from "react";
|
||||
import { IconView } from "./Icon.view";
|
||||
import type { IconProps } from "./Icon.types";
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Card / Mini" (see registry)
|
||||
*/
|
||||
|
||||
import { memo, useMemo } from "react";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import MiniView from "./Mini.view";
|
||||
import type { MiniProps } from "./Mini.types";
|
||||
|
||||
@@ -17,14 +22,16 @@ const MiniContainer = memo<MiniProps>(
|
||||
href,
|
||||
ariaLabel,
|
||||
}) => {
|
||||
const t = useTranslation("controlsChrome");
|
||||
|
||||
// Compute aria-label
|
||||
const computedAriaLabel = useMemo(
|
||||
() =>
|
||||
ariaLabel ||
|
||||
(labelLine1 && labelLine2
|
||||
? `${labelLine1} ${labelLine2}`
|
||||
: label || "Feature card"),
|
||||
[ariaLabel, labelLine1, labelLine2, label],
|
||||
: label || t("miniFeatureFallback")),
|
||||
[ariaLabel, labelLine1, labelLine2, label, t],
|
||||
);
|
||||
|
||||
// Determine wrapper element and props
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Card / Stat" (21598-18215)
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import StatView from "./Stat.view";
|
||||
import type { StatProps } from "./Stat.types";
|
||||
|
||||
@@ -20,7 +20,7 @@ function ContentContainerView({
|
||||
return (
|
||||
<div
|
||||
className={containerClasses}
|
||||
style={size === "responsive" || size === "xs" ? {} : { width }}
|
||||
style={size === "xs" ? {} : { width }}
|
||||
>
|
||||
{/* Content Container - gap between icon and text */}
|
||||
<div className={contentGapClasses}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import ChipView from "./Chip.view";
|
||||
import type { ChipProps } from "./Chip.types";
|
||||
|
||||
@@ -22,6 +23,7 @@ const ChipContainer = memo<ChipProps>(
|
||||
onClose,
|
||||
ariaLabel,
|
||||
}) => {
|
||||
const t = useTranslation("controlsChrome");
|
||||
const state = stateProp;
|
||||
const palette = paletteProp;
|
||||
const size = sizeProp;
|
||||
@@ -92,6 +94,9 @@ const ChipContainer = memo<ChipProps>(
|
||||
onInputKeyDown={isCustom ? handleKeyDown : undefined}
|
||||
inputRef={isCustom ? inputRef : undefined}
|
||||
ariaLabel={ariaLabel}
|
||||
confirmAriaLabel={t("chipConfirm")}
|
||||
typeToAddPlaceholder={t("chipTypeToAdd")}
|
||||
closeAriaLabel={t("chipClose")}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -68,4 +68,7 @@ export interface ChipViewProps {
|
||||
onInputKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
inputRef?: React.RefObject<HTMLInputElement>;
|
||||
ariaLabel?: string;
|
||||
confirmAriaLabel: string;
|
||||
typeToAddPlaceholder: string;
|
||||
closeAriaLabel: string;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ function ChipView({
|
||||
onInputKeyDown,
|
||||
inputRef,
|
||||
ariaLabel,
|
||||
confirmAriaLabel,
|
||||
typeToAddPlaceholder,
|
||||
closeAriaLabel,
|
||||
}: ChipViewProps) {
|
||||
// The container is the source of truth for `disabled`. This allows
|
||||
// `state="disabled"` to be used purely as a visual (for toggle-group chips
|
||||
@@ -167,7 +170,7 @@ function ChipView({
|
||||
<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"
|
||||
aria-label="Confirm"
|
||||
aria-label={confirmAriaLabel}
|
||||
disabled={!inputValue || !inputValue.trim()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
@@ -204,7 +207,7 @@ function ChipView({
|
||||
value={inputValue ?? ""}
|
||||
onChange={(e) => onInputChange?.(e.target.value)}
|
||||
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)]"
|
||||
style={{
|
||||
fontSize: isSmall
|
||||
@@ -222,7 +225,7 @@ function ChipView({
|
||||
<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"
|
||||
aria-label="Close"
|
||||
aria-label={closeAriaLabel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClose(event);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import MultiSelectView from "./MultiSelect.view";
|
||||
import type { MultiSelectProps } from "./MultiSelect.types";
|
||||
|
||||
@@ -18,12 +19,13 @@ const MultiSelectContainer = memo<MultiSelectProps>(
|
||||
onChipClick,
|
||||
onAddClick,
|
||||
addButton: addButtonProp = true,
|
||||
addButtonText = "Add organization type",
|
||||
addButtonText,
|
||||
formHeader = true,
|
||||
onCustomChipConfirm,
|
||||
onCustomChipClose,
|
||||
className = "",
|
||||
}) => {
|
||||
const t = useTranslation("controlsChrome");
|
||||
const size = sizeProp;
|
||||
const palette = paletteProp;
|
||||
|
||||
@@ -38,6 +40,9 @@ const MultiSelectContainer = memo<MultiSelectProps>(
|
||||
onAddClick={onAddClick}
|
||||
addButton={addButtonProp}
|
||||
addButtonText={addButtonText}
|
||||
addButtonAriaLabel={
|
||||
addButtonText || t("multiSelectAddFallback")
|
||||
}
|
||||
formHeader={formHeader}
|
||||
onCustomChipConfirm={onCustomChipConfirm}
|
||||
onCustomChipClose={onCustomChipClose}
|
||||
|
||||
@@ -74,7 +74,8 @@ export interface MultiSelectViewProps {
|
||||
onChipClick?: (chipId: string) => void;
|
||||
onAddClick?: () => void;
|
||||
addButton: boolean;
|
||||
addButtonText: string;
|
||||
addButtonText?: string;
|
||||
addButtonAriaLabel: string;
|
||||
formHeader: boolean;
|
||||
onCustomChipConfirm?: (chipId: string, value: string) => void;
|
||||
onCustomChipClose?: (chipId: string) => void;
|
||||
|
||||
@@ -15,6 +15,7 @@ function MultiSelectView({
|
||||
onAddClick,
|
||||
addButton,
|
||||
addButtonText,
|
||||
addButtonAriaLabel,
|
||||
formHeader = true,
|
||||
onCustomChipConfirm,
|
||||
onCustomChipClose,
|
||||
@@ -81,7 +82,7 @@ function MultiSelectView({
|
||||
{addButton && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={addButtonText || "Add option"}
|
||||
aria-label={addButtonAriaLabel}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddClick?.();
|
||||
|
||||
@@ -5,10 +5,11 @@ import { forwardRef, memo } from "react";
|
||||
interface SelectDropdownProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
const SelectDropdown = forwardRef<HTMLDivElement, SelectDropdownProps>(
|
||||
({ className = "", children, ...props }, ref) => {
|
||||
({ className = "", children, ariaLabel, ...props }, ref) => {
|
||||
const menuClasses = `
|
||||
bg-black
|
||||
border border-[var(--color-border-default-tertiary)]
|
||||
@@ -27,7 +28,7 @@ const SelectDropdown = forwardRef<HTMLDivElement, SelectDropdownProps>(
|
||||
ref={ref}
|
||||
className={menuClasses}
|
||||
role="listbox"
|
||||
aria-label="Select an option"
|
||||
aria-label={ariaLabel}
|
||||
style={{ backgroundColor: "#000000" }}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -14,6 +14,7 @@ import React, {
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { useClickOutside } from "../../../hooks";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { SelectInputView } from "./SelectInput.view";
|
||||
import type { SelectInputProps } from "./SelectInput.types";
|
||||
|
||||
@@ -38,7 +39,7 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
|
||||
textHint = false,
|
||||
disabled = false,
|
||||
error = false,
|
||||
placeholder = "Choose an option",
|
||||
placeholder,
|
||||
className = "",
|
||||
children,
|
||||
value,
|
||||
@@ -48,6 +49,9 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const t = useTranslation("controlsChrome");
|
||||
const resolvedPlaceholder = placeholder ?? t("selectPlaceholder");
|
||||
|
||||
// Determine if label should be shown
|
||||
const shouldShowLabel =
|
||||
showLabel !== undefined ? showLabel : labelText !== undefined;
|
||||
@@ -181,13 +185,13 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
|
||||
|
||||
// Get display text for selected value
|
||||
const getDisplayText = (): string => {
|
||||
if (!selectedValue) return placeholder;
|
||||
if (!selectedValue) return resolvedPlaceholder;
|
||||
|
||||
if (options && Array.isArray(options)) {
|
||||
const selectedOption = options.find(
|
||||
(option) => option.value === selectedValue,
|
||||
);
|
||||
return selectedOption ? selectedOption.label : placeholder;
|
||||
return selectedOption ? selectedOption.label : resolvedPlaceholder;
|
||||
}
|
||||
|
||||
const selectedOption = Children.toArray(children).find(
|
||||
@@ -207,13 +211,13 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
|
||||
);
|
||||
return selectedOption
|
||||
? String(selectedOption.props.children)
|
||||
: placeholder;
|
||||
: resolvedPlaceholder;
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectInputView
|
||||
label={shouldShowLabel ? labelText : undefined}
|
||||
placeholder={placeholder}
|
||||
placeholder={resolvedPlaceholder}
|
||||
state={actualState}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
@@ -241,6 +245,8 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
|
||||
textData={textData}
|
||||
iconRight={iconRight}
|
||||
textHint={textHint}
|
||||
selectAriaLabel={t("selectAriaLabel")}
|
||||
hintDefault={t("hintDefault")}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface SelectInputViewProps {
|
||||
textData?: boolean;
|
||||
iconRight?: boolean;
|
||||
textHint?: boolean;
|
||||
selectAriaLabel: string;
|
||||
hintDefault: string;
|
||||
}
|
||||
|
||||
export function SelectInputView({
|
||||
@@ -72,6 +74,8 @@ export function SelectInputView({
|
||||
textData = true,
|
||||
iconRight = true,
|
||||
textHint = false,
|
||||
selectAriaLabel,
|
||||
hintDefault,
|
||||
}: SelectInputViewProps) {
|
||||
// Styles based on Figma design
|
||||
const containerClasses = "flex flex-col gap-[8px]";
|
||||
@@ -222,7 +226,7 @@ export function SelectInputView({
|
||||
ref={menuRef}
|
||||
className="absolute top-full left-0 right-0 z-50 mt-1"
|
||||
>
|
||||
<SelectDropdown>
|
||||
<SelectDropdown ariaLabel={selectAriaLabel}>
|
||||
{options && Array.isArray(options)
|
||||
? options.map((option) => (
|
||||
<SelectOption
|
||||
@@ -268,7 +272,7 @@ export function SelectInputView({
|
||||
{textHint && (
|
||||
<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)]">
|
||||
Hint text here
|
||||
{hintDefault}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useId, forwardRef } from "react";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { SwitchView } from "./Switch.view";
|
||||
import type { SwitchProps } from "./Switch.types";
|
||||
|
||||
@@ -10,6 +11,7 @@ import type { SwitchProps } from "./Switch.types";
|
||||
*/
|
||||
const SwitchContainer = memo(
|
||||
forwardRef<HTMLButtonElement, SwitchProps>((props, ref) => {
|
||||
const t = useTranslation("controlsChrome");
|
||||
const {
|
||||
propSwitch = false,
|
||||
onChange,
|
||||
@@ -154,6 +156,7 @@ const SwitchContainer = memo(
|
||||
trackClasses={trackClasses}
|
||||
thumbClasses={thumbClasses}
|
||||
labelClasses={labelClasses}
|
||||
switchAriaLabel={text ?? t("toggleSwitch")}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface SwitchViewProps {
|
||||
trackClasses: string;
|
||||
thumbClasses: string;
|
||||
labelClasses: string;
|
||||
switchAriaLabel: string;
|
||||
onClick: (_e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onKeyDown: (_e: React.KeyboardEvent<HTMLButtonElement>) => void;
|
||||
onFocus: (_e: React.FocusEvent<HTMLButtonElement>) => void;
|
||||
|
||||
@@ -11,6 +11,7 @@ export const SwitchView = forwardRef<HTMLButtonElement, SwitchViewProps>(
|
||||
trackClasses,
|
||||
thumbClasses,
|
||||
labelClasses,
|
||||
switchAriaLabel,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
onFocus,
|
||||
@@ -27,7 +28,7 @@ export const SwitchView = forwardRef<HTMLButtonElement, SwitchViewProps>(
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={propSwitch}
|
||||
aria-label={text || "Toggle switch"}
|
||||
aria-label={switchAriaLabel}
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={onFocus}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { memo, forwardRef } from "react";
|
||||
import { useComponentId, useFormField } from "../../../hooks";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { TextAreaView } from "./TextArea.view";
|
||||
import type { TextAreaProps } from "./TextArea.types";
|
||||
|
||||
@@ -35,6 +36,7 @@ const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const t = useTranslation("controlsChrome");
|
||||
const size = sizeProp;
|
||||
const labelVariant = labelVariantProp;
|
||||
const state = stateProp;
|
||||
@@ -200,6 +202,8 @@ const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||
formHeader={formHeader}
|
||||
showHelpIcon={showHelpIcon}
|
||||
appearance={appearance}
|
||||
helpIconAlt={t("helpIconAlt")}
|
||||
hintDefault={t("hintDefault")}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -79,4 +79,6 @@ export interface TextAreaViewProps {
|
||||
formHeader?: boolean;
|
||||
showHelpIcon?: boolean;
|
||||
appearance?: "default" | "embedded";
|
||||
helpIconAlt: string;
|
||||
hintDefault: string;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
|
||||
formHeader = true,
|
||||
showHelpIcon = false,
|
||||
appearance: _appearance,
|
||||
helpIconAlt,
|
||||
hintDefault,
|
||||
// Component-only props: do not pass to DOM
|
||||
size: _size,
|
||||
labelVariant: _labelVariant,
|
||||
@@ -51,7 +53,7 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
|
||||
<img
|
||||
src={getAssetPath(ASSETS.ICON_HELP)}
|
||||
alt="Help"
|
||||
alt={helpIconAlt}
|
||||
className="block max-w-none size-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -81,7 +83,7 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
|
||||
{textHint ? (
|
||||
<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)]">
|
||||
{typeof textHint === "string" ? textHint : "Hint text here"}
|
||||
{typeof textHint === "string" ? textHint : hintDefault}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { memo, forwardRef, useState, useRef } from "react";
|
||||
import { useComponentId, useFormField } from "../../../hooks";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { TextInputView } from "./TextInput.view";
|
||||
import type { TextInputProps } from "./TextInput.types";
|
||||
|
||||
@@ -34,6 +35,7 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const t = useTranslation("controlsChrome");
|
||||
const externalState = externalStateProp;
|
||||
const inputSize = inputSizeProp;
|
||||
|
||||
@@ -244,6 +246,8 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
|
||||
textHint={textHint}
|
||||
formHeader={formHeader}
|
||||
maxLength={maxLength}
|
||||
helpIconAlt={t("helpIconAlt")}
|
||||
hintDefault={t("hintDefault")}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -65,4 +65,6 @@ export interface TextInputViewProps {
|
||||
textHint?: boolean | string;
|
||||
formHeader?: boolean;
|
||||
maxLength?: number;
|
||||
helpIconAlt: string;
|
||||
hintDefault: string;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
|
||||
textHint = false,
|
||||
formHeader = true,
|
||||
maxLength,
|
||||
helpIconAlt,
|
||||
hintDefault,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -49,7 +51,7 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
|
||||
<img
|
||||
src={getAssetPath(ASSETS.ICON_HELP)}
|
||||
alt="Help"
|
||||
alt={helpIconAlt}
|
||||
className="block max-w-none size-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -83,7 +85,7 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
|
||||
{textHint && (
|
||||
<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)]">
|
||||
{typeof textHint === "string" ? textHint : "Hint text here"}
|
||||
{typeof textHint === "string" ? textHint : hintDefault}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useId, forwardRef } from "react";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { ToggleGroupView } from "./ToggleGroup.view";
|
||||
import type { ToggleGroupProps } from "./ToggleGroup.types";
|
||||
|
||||
@@ -10,6 +11,7 @@ import type { ToggleGroupProps } from "./ToggleGroup.types";
|
||||
*/
|
||||
const ToggleGroupContainer = memo(
|
||||
forwardRef<HTMLButtonElement, ToggleGroupProps>((props, _ref) => {
|
||||
const t = useTranslation("controlsChrome");
|
||||
const {
|
||||
children,
|
||||
className = "",
|
||||
@@ -131,6 +133,7 @@ const ToggleGroupContainer = memo(
|
||||
state={state}
|
||||
showText={showText}
|
||||
ariaLabel={ariaLabel}
|
||||
defaultToggleOptionAriaLabel={t("toggleOption")}
|
||||
toggleClasses={toggleClasses}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface ToggleGroupViewProps {
|
||||
state: "default" | "hover" | "focus" | "selected";
|
||||
showText: boolean;
|
||||
ariaLabel?: string;
|
||||
defaultToggleOptionAriaLabel: string;
|
||||
toggleClasses: string;
|
||||
onClick: (_e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onKeyDown: (_e: React.KeyboardEvent<HTMLButtonElement>) => void;
|
||||
|
||||
@@ -8,6 +8,7 @@ export function ToggleGroupView({
|
||||
state: _state,
|
||||
showText,
|
||||
ariaLabel,
|
||||
defaultToggleOptionAriaLabel,
|
||||
toggleClasses,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
@@ -20,7 +21,7 @@ export function ToggleGroupView({
|
||||
id={groupId}
|
||||
type="button"
|
||||
role="button"
|
||||
aria-label={ariaLabel || (showText ? undefined : "Toggle option")}
|
||||
aria-label={ariaLabel || (showText ? undefined : defaultToggleOptionAriaLabel)}
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={onFocus}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import UploadView from "./Upload.view";
|
||||
import type { UploadProps } from "./Upload.types";
|
||||
|
||||
@@ -13,16 +14,20 @@ const UploadContainer = memo<UploadProps>(
|
||||
active = true,
|
||||
label,
|
||||
showHelpIcon = true,
|
||||
hintText = "Add image from your device",
|
||||
hintText,
|
||||
onClick,
|
||||
className = "",
|
||||
}) => {
|
||||
const t = useTranslation("controlsChrome");
|
||||
|
||||
return (
|
||||
<UploadView
|
||||
active={active}
|
||||
label={label}
|
||||
showHelpIcon={showHelpIcon}
|
||||
hintText={hintText}
|
||||
hintText={hintText ?? t("uploadHintDefault")}
|
||||
uploadButtonLabel={t("uploadButton")}
|
||||
uploadAriaLabel={t("uploadAriaLabel")}
|
||||
onClick={onClick}
|
||||
className={className}
|
||||
/>
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface UploadViewProps {
|
||||
label?: string;
|
||||
showHelpIcon: boolean;
|
||||
hintText: string;
|
||||
uploadButtonLabel: string;
|
||||
uploadAriaLabel: string;
|
||||
onClick?: () => void;
|
||||
className: string;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ function UploadView({
|
||||
label,
|
||||
showHelpIcon = true,
|
||||
hintText,
|
||||
uploadButtonLabel,
|
||||
uploadAriaLabel,
|
||||
onClick,
|
||||
className = "",
|
||||
}: UploadViewProps) {
|
||||
@@ -56,7 +58,7 @@ function UploadView({
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`${buttonBgClass} flex gap-[var(--measures-spacing-150,6px)] items-center justify-center overflow-clip px-[var(--space-400,16px)] py-[var(--measures-spacing-300,12px)] rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity`}
|
||||
aria-label="Upload"
|
||||
aria-label={uploadAriaLabel}
|
||||
>
|
||||
{/* Upload icon */}
|
||||
<div className={`relative shrink-0 size-[20px] ${iconColor}`}>
|
||||
@@ -98,7 +100,7 @@ function UploadView({
|
||||
<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}`}
|
||||
>
|
||||
<p className="leading-[20px]">Upload</p>
|
||||
<p className="leading-[20px]">{uploadButtonLabel}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "localization/LanguageSwitcher" (see registry)
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import LanguageSwitcherView from "./LanguageSwitcher.view";
|
||||
import type { LanguageSwitcherProps } from "./LanguageSwitcher.types";
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { AlertView } from "./Alert.view";
|
||||
import type { AlertProps } from "./Alert.types";
|
||||
|
||||
@@ -74,6 +75,7 @@ const AlertContainer = memo<AlertProps>(
|
||||
onClose,
|
||||
className = "",
|
||||
}) => {
|
||||
const t = useTranslation("controlsChrome");
|
||||
const status = statusProp;
|
||||
const type = typeProp;
|
||||
const size = sizeProp;
|
||||
@@ -175,6 +177,7 @@ const AlertContainer = memo<AlertProps>(
|
||||
iconColor={statusStyles.iconColor}
|
||||
closeButtonIconColor={statusStyles.closeButtonIconColor}
|
||||
onClose={onClose}
|
||||
closeAlertAriaLabel={t("closeAlert")}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -57,4 +57,5 @@ export interface AlertViewProps {
|
||||
iconColor: string;
|
||||
closeButtonIconColor: string;
|
||||
onClose?: () => void;
|
||||
closeAlertAriaLabel: string;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export function AlertView({
|
||||
iconColor,
|
||||
closeButtonIconColor,
|
||||
onClose,
|
||||
closeAlertAriaLabel,
|
||||
}: AlertViewProps) {
|
||||
const getIcon = () => {
|
||||
// Use the Icon_Alert.svg with dynamic fill color
|
||||
@@ -61,7 +62,7 @@ export function AlertView({
|
||||
palette="default"
|
||||
size="large"
|
||||
onClick={onClose}
|
||||
ariaLabel="Close alert"
|
||||
ariaLabel={closeAlertAriaLabel}
|
||||
className="shrink-0 [&_svg_path]:transition-colors [&_svg_path]:duration-200 hover:[&_svg_path]:fill-[var(--color-content-default-primary)]"
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Modal / Create" (20874-172292)
|
||||
*/
|
||||
|
||||
import { memo, useRef } from "react";
|
||||
import { CreateView } from "./Create.view";
|
||||
import type { CreateProps } from "./Create.types";
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Dialog" (see registry)
|
||||
*/
|
||||
|
||||
import { memo, useId, useRef } from "react";
|
||||
import { useCreateModalA11y } from "../Create/useCreateModalA11y";
|
||||
import { DialogView } from "./Dialog.view";
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Modal / Login" (see registry)
|
||||
*/
|
||||
|
||||
import { memo, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { LoginView } from "./Login.view";
|
||||
import type { LoginProps } from "./Login.types";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useEffect, useId, useRef, useState } from "react";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { ModalHeaderView } from "./ModalHeader.view";
|
||||
import type { ModalHeaderProps } from "./ModalHeader.types";
|
||||
|
||||
@@ -10,7 +11,14 @@ import type { ModalHeaderProps } from "./ModalHeader.types";
|
||||
* (right) icon buttons.
|
||||
*/
|
||||
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 [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuId = useId();
|
||||
@@ -44,7 +52,11 @@ const ModalHeaderContainer = memo<ModalHeaderProps>((props) => {
|
||||
return (
|
||||
<div ref={menuWrapRef}>
|
||||
<ModalHeaderView
|
||||
{...props}
|
||||
{...rest}
|
||||
menuItems={menuItems}
|
||||
closeButtonAriaLabel={closeButtonAriaLabel}
|
||||
moreOptionsAriaLabel={moreOptionsAriaLabel}
|
||||
menuAriaLabel={menuAriaLabel}
|
||||
menuId={menuId}
|
||||
menuOpen={menuOpen}
|
||||
onToggleMenu={hasMenu ? () => setMenuOpen((open) => !open) : undefined}
|
||||
|
||||
@@ -11,9 +11,9 @@ export function ModalHeaderView({
|
||||
onMoreOptions,
|
||||
showCloseButton = true,
|
||||
showMoreOptionsButton = true,
|
||||
closeButtonAriaLabel = "Close dialog",
|
||||
moreOptionsAriaLabel = "More options",
|
||||
menuAriaLabel = "More options menu",
|
||||
closeButtonAriaLabel,
|
||||
moreOptionsAriaLabel,
|
||||
menuAriaLabel,
|
||||
menuItems = [],
|
||||
menuId,
|
||||
menuOpen = false,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Modal / Tooltip" (see registry)
|
||||
*/
|
||||
|
||||
import { memo, useState } from "react";
|
||||
import { TooltipView } from "./Tooltip.view";
|
||||
import type { TooltipProps } from "./Tooltip.types";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { CreateFlowFooterView } from "./CreateFlowFooter.view";
|
||||
import type { CreateFlowFooterProps } from "./CreateFlowFooter.types";
|
||||
|
||||
@@ -16,7 +17,10 @@ const CreateFlowFooterContainer = memo<CreateFlowFooterProps>(
|
||||
proportionBarVariant,
|
||||
onBackClick,
|
||||
className = "",
|
||||
footerAriaLabel,
|
||||
}) => {
|
||||
const t = useTranslation("controlsChrome");
|
||||
|
||||
return (
|
||||
<CreateFlowFooterView
|
||||
secondButton={secondButton}
|
||||
@@ -25,6 +29,7 @@ const CreateFlowFooterContainer = memo<CreateFlowFooterProps>(
|
||||
proportionBarVariant={proportionBarVariant}
|
||||
onBackClick={onBackClick}
|
||||
className={className}
|
||||
footerAriaLabel={footerAriaLabel ?? t("createFlowFooterAriaLabel")}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -36,4 +36,8 @@ export interface CreateFlowFooterProps {
|
||||
* Additional CSS classes
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Accessible name for the footer landmark.
|
||||
*/
|
||||
footerAriaLabel?: string;
|
||||
}
|
||||
|
||||
@@ -9,13 +9,14 @@ export function CreateFlowFooterView({
|
||||
proportionBarVariant: proportionBarVariantProp,
|
||||
onBackClick,
|
||||
className = "",
|
||||
footerAriaLabel,
|
||||
}: CreateFlowFooterProps) {
|
||||
const proportionBarVariant = proportionBarVariantProp ?? "default";
|
||||
return (
|
||||
<footer
|
||||
className={`bg-black w-full ${className}`}
|
||||
role="contentinfo"
|
||||
aria-label="Create Flow Footer"
|
||||
aria-label={footerAriaLabel}
|
||||
>
|
||||
{/* Progress Bar - Top */}
|
||||
{progressBar && (
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCreateFlowSm2Up } from "../../../(app)/create/hooks/useCreateFlowSm2Up";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { CreateFlowTopNavView } from "./CreateFlowTopNav.view";
|
||||
import type { CreateFlowTopNavProps } from "./CreateFlowTopNav.types";
|
||||
import type {
|
||||
CreateFlowTopNavActionMenuItem,
|
||||
CreateFlowTopNavProps,
|
||||
} from "./CreateFlowTopNav.types";
|
||||
|
||||
/**
|
||||
* Figma: Utility / CreateFlowTopNav — wizard header (create-flow chrome).
|
||||
@@ -34,15 +38,168 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
|
||||
const router = useRouter();
|
||||
const t = useTranslation("create.topNav");
|
||||
const tPopover = useTranslation("modals.popoverExport");
|
||||
const sm2Up = useCreateFlowSm2Up();
|
||||
const exitButtonText =
|
||||
exitLabel ?? (saveDraftOnExit ? t("saveAndExit") : t("exit"));
|
||||
const [exportMenuOpen, setExportMenuOpen] = useState(false);
|
||||
const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
|
||||
const exportWrapRef = useRef<HTMLDivElement>(null);
|
||||
const actionsWrapRef = useRef<HTMLDivElement>(null);
|
||||
const exportMenuId = useId();
|
||||
const actionsMenuId = useId();
|
||||
|
||||
const handleExit = (options?: { saveDraft?: boolean }) => {
|
||||
if (onExit) {
|
||||
onExit(options);
|
||||
} else {
|
||||
// Default behavior: navigate to home
|
||||
router.push("/");
|
||||
const handleExit = useCallback(
|
||||
(options?: { saveDraft?: boolean }) => {
|
||||
if (onExit) {
|
||||
onExit(options);
|
||||
} else {
|
||||
// Default behavior: navigate to home
|
||||
router.push("/");
|
||||
}
|
||||
},
|
||||
[onExit, router],
|
||||
);
|
||||
|
||||
const hasSecondaryActions =
|
||||
hasShare ||
|
||||
hasExport ||
|
||||
hasEdit ||
|
||||
hasDuplicate ||
|
||||
hasManageStakeholders;
|
||||
const useKebabMenu = hasSecondaryActions && !sm2Up;
|
||||
|
||||
const actionMenuItems = useMemo((): CreateFlowTopNavActionMenuItem[] => {
|
||||
const items: CreateFlowTopNavActionMenuItem[] = [];
|
||||
|
||||
if (hasShare && onShare) {
|
||||
items.push({
|
||||
id: "share",
|
||||
label: t("share"),
|
||||
leadingIcon: "mail",
|
||||
onClick: onShare,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (hasExport && onSelectExportFormat) {
|
||||
items.push(
|
||||
{
|
||||
id: "export-pdf",
|
||||
label: tPopover("downloadPdf"),
|
||||
leadingIcon: "picture_as_pdf",
|
||||
onClick: () => onSelectExportFormat("pdf"),
|
||||
},
|
||||
{
|
||||
id: "export-csv",
|
||||
label: tPopover("downloadCsv"),
|
||||
leadingIcon: "csv",
|
||||
onClick: () => onSelectExportFormat("csv"),
|
||||
},
|
||||
{
|
||||
id: "export-markdown",
|
||||
label: tPopover("downloadMarkdown"),
|
||||
leadingIcon: "markdown_copy",
|
||||
onClick: () => onSelectExportFormat("markdown"),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (hasDuplicate && onDuplicate) {
|
||||
items.push({
|
||||
id: "duplicate",
|
||||
label: duplicateLabel ?? t("edit"),
|
||||
leadingIcon: "content_copy",
|
||||
onClick: onDuplicate,
|
||||
});
|
||||
} else if (hasEdit && onEdit) {
|
||||
items.push({
|
||||
id: "edit",
|
||||
label: t("edit"),
|
||||
leadingIcon: "edit",
|
||||
onClick: onEdit,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasManageStakeholders && onManageStakeholders) {
|
||||
items.push({
|
||||
id: "manage-stakeholders",
|
||||
label: t("manageStakeholders"),
|
||||
leadingIcon: "tags",
|
||||
onClick: onManageStakeholders,
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: "exit",
|
||||
label: exitButtonText,
|
||||
leadingIcon: "log_out",
|
||||
onClick: () => void handleExit({ saveDraft: saveDraftOnExit }),
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [
|
||||
duplicateLabel,
|
||||
exitButtonText,
|
||||
handleExit,
|
||||
hasDuplicate,
|
||||
hasEdit,
|
||||
hasExport,
|
||||
hasManageStakeholders,
|
||||
hasShare,
|
||||
onDuplicate,
|
||||
onEdit,
|
||||
onManageStakeholders,
|
||||
onSelectExportFormat,
|
||||
onShare,
|
||||
saveDraftOnExit,
|
||||
t,
|
||||
tPopover,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!exportMenuOpen) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (
|
||||
exportWrapRef.current &&
|
||||
!exportWrapRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setExportMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [exportMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionsMenuOpen) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (
|
||||
actionsWrapRef.current &&
|
||||
!actionsWrapRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setActionsMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [actionsMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!exportMenuOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setExportMenuOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [exportMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionsMenuOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setActionsMenuOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [actionsMenuOpen]);
|
||||
|
||||
return (
|
||||
<CreateFlowTopNavView
|
||||
@@ -63,6 +220,17 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
|
||||
duplicateAriaLabel={duplicateAriaLabel}
|
||||
buttonPalette={buttonPalette}
|
||||
className={className}
|
||||
exitButtonText={exitButtonText}
|
||||
useKebabMenu={useKebabMenu}
|
||||
exportMenuOpen={exportMenuOpen}
|
||||
setExportMenuOpen={setExportMenuOpen}
|
||||
actionsMenuOpen={actionsMenuOpen}
|
||||
setActionsMenuOpen={setActionsMenuOpen}
|
||||
exportWrapRef={exportWrapRef}
|
||||
actionsWrapRef={actionsWrapRef}
|
||||
exportMenuId={exportMenuId}
|
||||
actionsMenuId={actionsMenuId}
|
||||
actionMenuItems={actionMenuItems}
|
||||
exportPopoverMenuAriaLabel={tPopover("menuAriaLabel")}
|
||||
exportPopoverPdfLabel={tPopover("downloadPdf")}
|
||||
exportPopoverCsvLabel={tPopover("downloadCsv")}
|
||||
|
||||
@@ -5,6 +5,16 @@
|
||||
* Includes logo and action buttons (Share, Export, Edit, Exit).
|
||||
*/
|
||||
|
||||
import type { Dispatch, RefObject, SetStateAction } from "react";
|
||||
import type { IconName } from "../../asset/icon";
|
||||
|
||||
export type CreateFlowTopNavActionMenuItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
leadingIcon: IconName;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export interface CreateFlowTopNavProps {
|
||||
/**
|
||||
* Whether to show the Share button
|
||||
@@ -81,8 +91,19 @@ export interface CreateFlowTopNavProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Resolved copy for the export popover; supplied by the container. */
|
||||
/** Resolved copy and menu state; supplied by the container. */
|
||||
export type CreateFlowTopNavViewProps = CreateFlowTopNavProps & {
|
||||
exitButtonText: string;
|
||||
useKebabMenu: boolean;
|
||||
exportMenuOpen: boolean;
|
||||
setExportMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
actionsMenuOpen: boolean;
|
||||
setActionsMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
exportWrapRef: RefObject<HTMLDivElement | null>;
|
||||
actionsWrapRef: RefObject<HTMLDivElement | null>;
|
||||
exportMenuId: string;
|
||||
actionsMenuId: string;
|
||||
actionMenuItems: CreateFlowTopNavActionMenuItem[];
|
||||
exportPopoverMenuAriaLabel: string;
|
||||
exportPopoverPdfLabel: string;
|
||||
exportPopoverCsvLabel: string;
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import type { IconName } from "../../asset/icon";
|
||||
import Logo from "../../asset/Logo";
|
||||
import Button from "../../buttons/Button";
|
||||
import ListItem from "../../layout/ListItem";
|
||||
import Popover from "../../modals/Popover";
|
||||
import { useCreateFlowSm2Up } from "../../../(app)/create/hooks/useCreateFlowSm2Up";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import type { CreateFlowTopNavViewProps } from "./CreateFlowTopNav.types";
|
||||
|
||||
@@ -16,13 +13,6 @@ const outlineButtonClass =
|
||||
const exitButtonFigmaClass =
|
||||
"!rounded-[var(--radius-measures-radius-full,9999px)] !border-[1.25px] !px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!text-[12px] md:!leading-[14px]";
|
||||
|
||||
type ActionMenuItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
leadingIcon: IconName;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
function KebabIcon({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
@@ -54,11 +44,21 @@ export function CreateFlowTopNavView({
|
||||
onDuplicate,
|
||||
onManageStakeholders,
|
||||
onExit,
|
||||
exitLabel,
|
||||
duplicateLabel,
|
||||
duplicateAriaLabel,
|
||||
buttonPalette = "default",
|
||||
className = "",
|
||||
exitButtonText,
|
||||
useKebabMenu,
|
||||
exportMenuOpen,
|
||||
setExportMenuOpen,
|
||||
actionsMenuOpen,
|
||||
setActionsMenuOpen,
|
||||
exportWrapRef,
|
||||
actionsWrapRef,
|
||||
exportMenuId,
|
||||
actionsMenuId,
|
||||
actionMenuItems,
|
||||
exportPopoverMenuAriaLabel,
|
||||
exportPopoverPdfLabel,
|
||||
exportPopoverCsvLabel,
|
||||
@@ -67,15 +67,6 @@ export function CreateFlowTopNavView({
|
||||
actionsMenuAriaLabel,
|
||||
}: CreateFlowTopNavViewProps) {
|
||||
const t = useTranslation("create.topNav");
|
||||
const sm2Up = useCreateFlowSm2Up();
|
||||
const exitButtonText =
|
||||
exitLabel ?? (saveDraftOnExit ? t("saveAndExit") : t("exit"));
|
||||
const [exportMenuOpen, setExportMenuOpen] = useState(false);
|
||||
const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
|
||||
const exportWrapRef = useRef<HTMLDivElement>(null);
|
||||
const actionsWrapRef = useRef<HTMLDivElement>(null);
|
||||
const exportMenuId = useId();
|
||||
const actionsMenuId = useId();
|
||||
|
||||
const hasSecondaryActions =
|
||||
hasShare ||
|
||||
@@ -83,142 +74,6 @@ export function CreateFlowTopNavView({
|
||||
hasEdit ||
|
||||
hasDuplicate ||
|
||||
hasManageStakeholders;
|
||||
const useKebabMenu = hasSecondaryActions && !sm2Up;
|
||||
|
||||
const actionMenuItems = useMemo((): ActionMenuItem[] => {
|
||||
const items: ActionMenuItem[] = [];
|
||||
|
||||
if (hasShare && onShare) {
|
||||
items.push({
|
||||
id: "share",
|
||||
label: t("share"),
|
||||
leadingIcon: "mail",
|
||||
onClick: onShare,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasExport && onSelectExportFormat) {
|
||||
items.push(
|
||||
{
|
||||
id: "export-pdf",
|
||||
label: exportPopoverPdfLabel,
|
||||
leadingIcon: "picture_as_pdf",
|
||||
onClick: () => onSelectExportFormat("pdf"),
|
||||
},
|
||||
{
|
||||
id: "export-csv",
|
||||
label: exportPopoverCsvLabel,
|
||||
leadingIcon: "csv",
|
||||
onClick: () => onSelectExportFormat("csv"),
|
||||
},
|
||||
{
|
||||
id: "export-markdown",
|
||||
label: exportPopoverMarkdownLabel,
|
||||
leadingIcon: "markdown_copy",
|
||||
onClick: () => onSelectExportFormat("markdown"),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (hasDuplicate && onDuplicate) {
|
||||
items.push({
|
||||
id: "duplicate",
|
||||
label: duplicateLabel ?? t("edit"),
|
||||
leadingIcon: "content_copy",
|
||||
onClick: onDuplicate,
|
||||
});
|
||||
} else if (hasEdit && onEdit) {
|
||||
items.push({
|
||||
id: "edit",
|
||||
label: t("edit"),
|
||||
leadingIcon: "edit",
|
||||
onClick: onEdit,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasManageStakeholders && onManageStakeholders) {
|
||||
items.push({
|
||||
id: "manage-stakeholders",
|
||||
label: t("manageStakeholders"),
|
||||
leadingIcon: "tags",
|
||||
onClick: onManageStakeholders,
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: "exit",
|
||||
label: exitButtonText,
|
||||
leadingIcon: "log_out",
|
||||
onClick: () => void onExit?.({ saveDraft: saveDraftOnExit }),
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [
|
||||
duplicateLabel,
|
||||
exitButtonText,
|
||||
exportPopoverCsvLabel,
|
||||
exportPopoverMarkdownLabel,
|
||||
exportPopoverPdfLabel,
|
||||
hasDuplicate,
|
||||
hasEdit,
|
||||
hasExport,
|
||||
hasManageStakeholders,
|
||||
hasShare,
|
||||
onDuplicate,
|
||||
onEdit,
|
||||
onExit,
|
||||
onManageStakeholders,
|
||||
onSelectExportFormat,
|
||||
onShare,
|
||||
saveDraftOnExit,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!exportMenuOpen) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (
|
||||
exportWrapRef.current &&
|
||||
!exportWrapRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setExportMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [exportMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionsMenuOpen) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (
|
||||
actionsWrapRef.current &&
|
||||
!actionsWrapRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setActionsMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [actionsMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!exportMenuOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setExportMenuOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [exportMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionsMenuOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setActionsMenuOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [actionsMenuOpen]);
|
||||
|
||||
const inlineActions = (
|
||||
<>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getAssetPath, ASSETS } from "../../../lib/assetUtils";
|
||||
*/
|
||||
const Footer = memo(() => {
|
||||
const t = useTranslation("footer");
|
||||
const tChrome = useTranslation("controlsChrome");
|
||||
|
||||
const linkFocusClass =
|
||||
"hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity";
|
||||
@@ -129,7 +130,7 @@ const Footer = memo(() => {
|
||||
</div>
|
||||
|
||||
<nav
|
||||
aria-label="Footer"
|
||||
aria-label={tChrome("footerAriaLabel")}
|
||||
className="order-1 flex w-full max-w-full flex-col
|
||||
items-start
|
||||
gap-[var(--spacing-scale-032)]
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Utility / Menu Item" (see registry)
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import MenuItemView from "./MenuItem.view";
|
||||
import type { MenuItemProps } from "./MenuItem.types";
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Navigation / NavigationItem" (see registry)
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import NavigationItemView from "./NavigationItem.view";
|
||||
import type { NavigationItemProps } from "./NavigationItem.types";
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Navigation / Top" (22078-808559)
|
||||
*/
|
||||
|
||||
import { memo, useCallback } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useAuthModal } from "../../../contexts/AuthModalContext";
|
||||
@@ -24,11 +28,17 @@ const NAV_SIZE_TO_MENU_ITEM_SIZE: Record<NavSize, MenuClusterSize> = {
|
||||
xlarge: "X Large",
|
||||
};
|
||||
|
||||
export const avatarImages = [
|
||||
{ src: getAssetPath(ASSETS.AVATAR_3), alt: "Avatar 3" },
|
||||
{ src: getAssetPath(ASSETS.AVATAR_2), alt: "Avatar 2" },
|
||||
{ src: getAssetPath(ASSETS.AVATAR_1), alt: "Avatar 1" },
|
||||
];
|
||||
export const avatarImageSources = [
|
||||
getAssetPath(ASSETS.AVATAR_3),
|
||||
getAssetPath(ASSETS.AVATAR_2),
|
||||
getAssetPath(ASSETS.AVATAR_1),
|
||||
] as const;
|
||||
|
||||
/** @deprecated Use `avatarImageSources` — alts are resolved in `TopContainer` via `topNav` messages. */
|
||||
export const avatarImages = avatarImageSources.map((src, index) => ({
|
||||
src,
|
||||
alt: `Avatar ${3 - index}`,
|
||||
}));
|
||||
|
||||
const TopContainer = memo<TopProps>(
|
||||
({ folderTop = false, loggedIn = false, profile = false, logIn = true }) => {
|
||||
@@ -36,6 +46,7 @@ const TopContainer = memo<TopProps>(
|
||||
const router = useRouter();
|
||||
const { openLogin } = useAuthModal();
|
||||
const t = useTranslation("header");
|
||||
const tTopNav = useTranslation("topNav");
|
||||
|
||||
/**
|
||||
* `Top` is hidden on `/create` routes by ConditionalNavigationClient, so
|
||||
@@ -58,7 +69,7 @@ const TopContainer = memo<TopProps>(
|
||||
name: "CommunityRule",
|
||||
url: "https://communityrule.com",
|
||||
...(folderTop && {
|
||||
description: "Build operating manuals for successful communities",
|
||||
description: tTopNav("schemaDescription"),
|
||||
}),
|
||||
potentialAction: {
|
||||
"@type": "SearchAction",
|
||||
@@ -110,11 +121,11 @@ const TopContainer = memo<TopProps>(
|
||||
) => {
|
||||
return (
|
||||
<AvatarContainer size={containerSize}>
|
||||
{avatarImages.map((avatar, index) => (
|
||||
{avatarImageSources.map((src, index) => (
|
||||
<Avatar
|
||||
key={index}
|
||||
src={avatar.src}
|
||||
alt={avatar.alt}
|
||||
src={src}
|
||||
alt={tTopNav(`avatarAlts.${3 - index}`)}
|
||||
size={avatarSize}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Progress / Bar" (17861-33241)
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { ProportionBarView } from "./ProportionBar.view";
|
||||
import type { ProportionBarProps } from "./ProportionBar.types";
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Progress / Stepper" (see registry)
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { StepperView } from "./Stepper.view";
|
||||
import type { StepperProps } from "./Stepper.types";
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Sections / FeatureGrid" (see registry)
|
||||
*/
|
||||
|
||||
import { memo, useMemo } from "react";
|
||||
import { getAssetPath, featurePanelPath } from "../../../../lib/assetUtils";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
|
||||
/**
|
||||
* Placeholder grid matching GovernanceTemplateGrid layout (loading state).
|
||||
*/
|
||||
@@ -8,6 +12,7 @@ export function GovernanceTemplateGridSkeleton({
|
||||
count: number;
|
||||
twoColumnsFromMd?: boolean;
|
||||
}) {
|
||||
const t = useTranslation("controlsChrome");
|
||||
const gridLayoutClasses = twoColumnsFromMd
|
||||
? `
|
||||
flex flex-col gap-[18px]
|
||||
@@ -24,7 +29,7 @@ export function GovernanceTemplateGridSkeleton({
|
||||
<div
|
||||
className={gridLayoutClasses}
|
||||
aria-busy
|
||||
aria-label="Loading templates"
|
||||
aria-label={t("governanceTemplateGridLoading")}
|
||||
>
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Sections / Hero" (see registry)
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import ContentLockup from "../../type/ContentLockup";
|
||||
|
||||
@@ -1,55 +1,64 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Sections / LogoWall" (see registry)
|
||||
*/
|
||||
|
||||
import { memo, useState, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { getAssetPath, partnerLogoPath } from "../../../../lib/assetUtils";
|
||||
import LogoWallView from "./LogoWall.view";
|
||||
import type { LogoWallProps } from "./LogoWall.types";
|
||||
|
||||
const defaultLogos = [
|
||||
{
|
||||
src: getAssetPath(partnerLogoPath("food-not-bombs")),
|
||||
alt: "Food Not Bombs",
|
||||
size: "h-11 lg:h-14 xl:h-[70px]",
|
||||
order: "order-1 sm:order-4", // Mobile: row 1 col 1, SM: row 2 col 1 (bottom left)
|
||||
},
|
||||
{
|
||||
src: getAssetPath(partnerLogoPath("start-coop")),
|
||||
alt: "Start COOP",
|
||||
size: "h-[42px] lg:h-[53px] xl:h-[66px]",
|
||||
order: "order-2 sm:order-2", // Mobile: row 1 col 2, SM: row 1 col 2 (top middle)
|
||||
},
|
||||
{
|
||||
src: getAssetPath(partnerLogoPath("metagov")),
|
||||
alt: "Metagov",
|
||||
size: "h-6 lg:h-8 xl:h-[41px]",
|
||||
order: "order-3 sm:order-1", // Mobile: row 2 col 1, SM: row 1 col 1 (top left)
|
||||
},
|
||||
{
|
||||
src: getAssetPath(partnerLogoPath("open-civics")),
|
||||
alt: "Open Civics",
|
||||
size: "h-8 lg:h-10 xl:h-[50px]",
|
||||
order: "order-4 sm:order-5 md:order-6", // Mobile: row 2 col 2, SM: row 2 col 2, MD: swapped with Mutual Aid CO
|
||||
},
|
||||
{
|
||||
src: getAssetPath(partnerLogoPath("mutual-aid-co")),
|
||||
alt: "Mutual Aid CO",
|
||||
size: "h-11 lg:h-14 xl:h-[70px]",
|
||||
order: "order-5 sm:order-6 md:order-5", // Mobile: row 3 col 1, SM: row 2 col 3, MD: swapped with OpenCivics
|
||||
},
|
||||
{
|
||||
src: getAssetPath(partnerLogoPath("cu-boulder")),
|
||||
alt: "CU Boulder",
|
||||
size: "h-10 lg:h-12 xl:h-[60px]",
|
||||
order: "order-6 sm:order-3", // Mobile: row 3 col 2, SM: row 1 col 3 (top right)
|
||||
},
|
||||
];
|
||||
|
||||
const LogoWallContainer = memo<LogoWallProps>(({ logos, className = "" }) => {
|
||||
const t = useTranslation("logoWall");
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const defaultLogos = useMemo(
|
||||
() => [
|
||||
{
|
||||
src: getAssetPath(partnerLogoPath("food-not-bombs")),
|
||||
alt: t("partners.foodNotBombs"),
|
||||
size: "h-11 lg:h-14 xl:h-[70px]",
|
||||
order: "order-1 sm:order-4",
|
||||
},
|
||||
{
|
||||
src: getAssetPath(partnerLogoPath("start-coop")),
|
||||
alt: t("partners.startCoop"),
|
||||
size: "h-[42px] lg:h-[53px] xl:h-[66px]",
|
||||
order: "order-2 sm:order-2",
|
||||
},
|
||||
{
|
||||
src: getAssetPath(partnerLogoPath("metagov")),
|
||||
alt: t("partners.metagov"),
|
||||
size: "h-6 lg:h-8 xl:h-[41px]",
|
||||
order: "order-3 sm:order-1",
|
||||
},
|
||||
{
|
||||
src: getAssetPath(partnerLogoPath("open-civics")),
|
||||
alt: t("partners.openCivics"),
|
||||
size: "h-8 lg:h-10 xl:h-[50px]",
|
||||
order: "order-4 sm:order-5 md:order-6",
|
||||
},
|
||||
{
|
||||
src: getAssetPath(partnerLogoPath("mutual-aid-co")),
|
||||
alt: t("partners.mutualAidCo"),
|
||||
size: "h-11 lg:h-14 xl:h-[70px]",
|
||||
order: "order-5 sm:order-6 md:order-5",
|
||||
},
|
||||
{
|
||||
src: getAssetPath(partnerLogoPath("cu-boulder")),
|
||||
alt: t("partners.cuBoulder"),
|
||||
size: "h-10 lg:h-12 xl:h-[60px]",
|
||||
order: "order-6 sm:order-3",
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const displayLogos = useMemo(
|
||||
() => (logos && logos.length > 0 ? logos : defaultLogos),
|
||||
[logos],
|
||||
[logos, defaultLogos],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { memo } from "react";
|
||||
import { getAssetPath, quoteStatementShapePath } from "../../../../lib/assetUtils";
|
||||
|
||||
/** Figma: Section / Quote — **`shape-qoute.svg`** (22137:890679). */
|
||||
/** Figma: Section / Quote — **`shape-quote.svg`** (22137:890679). */
|
||||
const EDGE_MASK =
|
||||
"linear-gradient(to right, #fff 0%, #fff 14%, rgba(255,255,255,0) 30%, rgba(255,255,255,0) 70%, #fff 86%, #fff 100%)";
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Sections / RelatedArticles" (22112-872308)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, memo, useMemo, useCallback } from "react";
|
||||
import { useIsMobile } from "../../../hooks";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Sections / RuleStack" (22085-860413)
|
||||
*/
|
||||
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { logger } from "../../../../lib/logger";
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Sections / SectionNumber" (see registry)
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { getAssetPath, sectionNumberPath } from "../../../lib/assetUtils";
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Type / ContentLockup" (see registry)
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import ContentLockupView from "./ContentLockup.view";
|
||||
import type { ContentLockupProps, VariantStyle } from "./ContentLockup.types";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { memo } from "react";
|
||||
import Button from "../../buttons/Button";
|
||||
import { getAssetPath } from "../../../../lib/assetUtils";
|
||||
import { contentLockupShapePath, getAssetPath } from "../../../../lib/assetUtils";
|
||||
import type { ContentLockupViewProps } from "./ContentLockup.types";
|
||||
|
||||
function ContentLockupView({
|
||||
@@ -75,7 +75,7 @@ function ContentLockupView({
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- decorative shape SVG */}
|
||||
<img
|
||||
src={getAssetPath("assets/shapes/shapes-1.svg")}
|
||||
src={getAssetPath(contentLockupShapePath())}
|
||||
alt=""
|
||||
className={styles.shape}
|
||||
role="presentation"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Type / HeaderLockup" (see registry)
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import HeaderLockupView from "./HeaderLockup.view";
|
||||
import type { HeaderLockupProps } from "./HeaderLockup.types";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import InputLabelView from "./InputLabel.view";
|
||||
import type { InputLabelProps } from "./InputLabel.types";
|
||||
|
||||
@@ -19,6 +20,7 @@ const InputLabelContainer = memo<InputLabelProps>(
|
||||
palette: paletteProp = "default",
|
||||
className = "",
|
||||
}) => {
|
||||
const t = useTranslation("controlsChrome");
|
||||
const size = sizeProp;
|
||||
const palette = paletteProp;
|
||||
|
||||
@@ -31,6 +33,8 @@ const InputLabelContainer = memo<InputLabelProps>(
|
||||
size={size}
|
||||
palette={palette}
|
||||
className={className}
|
||||
helpIconAlt={t("helpIconAlt")}
|
||||
helperTextDefault={t("inputLabelOptional")}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -39,4 +39,6 @@ export interface InputLabelViewProps {
|
||||
size: "s" | "m";
|
||||
palette: "default" | "inverse";
|
||||
className: string;
|
||||
helpIconAlt: string;
|
||||
helperTextDefault: string;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ function InputLabelView({
|
||||
size,
|
||||
palette,
|
||||
className = "",
|
||||
helpIconAlt,
|
||||
helperTextDefault,
|
||||
}: InputLabelViewProps) {
|
||||
const isSmall = size === "s";
|
||||
const isInverse = palette === "inverse";
|
||||
@@ -79,7 +81,7 @@ function InputLabelView({
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- icon from asset path */}
|
||||
<img
|
||||
src={getAssetPath(ASSETS.ICON_HELP)}
|
||||
alt="Help"
|
||||
alt={helpIconAlt}
|
||||
className="block max-w-none size-full"
|
||||
style={
|
||||
helpIconFilter
|
||||
@@ -96,7 +98,7 @@ function InputLabelView({
|
||||
<p
|
||||
className={`flex-[1_0_0] font-inter font-normal ${helperTextSize} min-h-px min-w-px relative ${helperTextColor} text-right`}
|
||||
>
|
||||
{typeof helperText === "string" ? helperText : "Optional text"}
|
||||
{typeof helperText === "string" ? helperText : helperTextDefault}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: "Type / Numbered List" (see registry)
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import NumberedListView from "./NumberedList.view";
|
||||
import type { NumberedListProps } from "./NumberedList.types";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user