Compare commits
410 Commits
cfe54737b8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f0e193746c | |||
| 6d0335a86a | |||
| b84d80c3a9 | |||
| bb26d95b32 | |||
| e64ba640c8 | |||
| 200b9f8c9e | |||
| c13a3f2a04 | |||
| 4af244fa09 | |||
| 28eb5007fc | |||
| bb4ef2ac4b | |||
| 22ccc02b70 | |||
| 98eed6cf22 | |||
| ca338cb017 | |||
| a3119d3f90 | |||
| a14cae744d | |||
| a10dac8675 | |||
| d9b9c8e4e1 | |||
| 528ee0e2d9 | |||
| 753e1e320d | |||
| 062a9e4068 | |||
| d084ea3b33 | |||
| 2fd20d5b2a | |||
| 2ca8d9adcf | |||
| 8137593aa0 | |||
| c663e051da | |||
| 9e11063a11 | |||
| cef7c98205 | |||
| 5863a256f6 | |||
| 3dbb6b61d2 | |||
| 753220f97b | |||
| b7c804bac8 | |||
| 23f528caec | |||
| 99f535f821 | |||
| 28de8ef3bc | |||
| f3b73527fc | |||
| ea346abad8 | |||
| 1688ac85c9 | |||
| 7ee6282c1a | |||
| 2f2b5d0dc2 | |||
| 7c46cbd87b | |||
| 40ce5064d6 | |||
| 450da4d8ab | |||
| b6b9b63608 | |||
| d2dfa099a2 | |||
| 874de7a112 | |||
| 625a8c3161 | |||
| b5930331c0 | |||
| 9f2141a62d | |||
| 534c6c7c0e | |||
| 4595e2648a | |||
| 89fd5f3ade | |||
| 9de1e85817 | |||
| 026a1e6d71 | |||
| 26bcd61ea3 | |||
| dee2dd800e | |||
| 58d0e33500 | |||
| b7446873cd | |||
| a37a72c71d | |||
| a31a36c926 | |||
| 7fde82a94c | |||
| a4f0c4bf27 | |||
| c4c74ecdb4 | |||
| fc845d8308 | |||
| 3a9727bceb | |||
| ac1157a172 | |||
| 815de2fdfd | |||
| 048dceced9 | |||
| 0973b65743 | |||
| e6127f1a3f | |||
| 5c8512ccd9 | |||
| 252848eba9 | |||
| 9962f44ff1 | |||
| 0ce05372bf | |||
| 68517796a9 | |||
| 7dd2562bae | |||
| ce204bff03 | |||
| 701db2aa1a | |||
| 56da6d21ea | |||
| 208ddfb8ca | |||
| 5457d3554b | |||
| 4d066dad0e | |||
| c7f22a0990 | |||
| 0e7a57052b | |||
| 2d58887a15 | |||
| aaa3e4d654 | |||
| b01a49bc18 | |||
| 2438c6f707 | |||
| 707d08642c | |||
| a22d53e860 | |||
| c08cd62872 | |||
| d3bb8cdd0f | |||
| 45bbbb8a35 | |||
| e9dab04b34 | |||
| f866d11ff8 | |||
| 4854c49c4a | |||
| 36dcb79870 | |||
| eedb70f9f3 | |||
| beae150f02 | |||
| b15f0d6226 | |||
| 92149d9fb0 | |||
| d597a2348f | |||
| f8255bc2c7 | |||
| a0de78c020 | |||
| a39b4aa04b | |||
| cae4df261e | |||
| 60d4ae6dfd | |||
| a5c6b8971f | |||
| ec5afd1464 | |||
| cee81eda16 | |||
| 8f932e95cd | |||
| a4f0b449b6 | |||
| b6b833e80f | |||
| 759f5f1555 | |||
| 4b14510dde | |||
| 7218947df3 | |||
| 331ed40234 | |||
| be88135a03 | |||
| c4b600e944 | |||
| c8e930552b | |||
| fe54390849 | |||
| 1f6d38f71d | |||
| 427dc44476 | |||
| 5d6530e914 | |||
| 3a3e54d455 | |||
| 3e3d2881f5 | |||
| d811b87b12 | |||
| 0799636c78 | |||
| f5bfb25f5e | |||
| b2ed1d438c | |||
| f60df15c2b | |||
| 4bb6fe0a89 | |||
| 2e1538770c | |||
| c43f74f345 | |||
| 8d9b9d6ff3 | |||
| 37555b2725 | |||
| e6c1002dbb | |||
| 343b96a9bb | |||
| 12b1f59886 | |||
| a1d7505b9f | |||
| 9ef6d69db4 | |||
| 51990ca149 | |||
| aa7364769e | |||
| 8c7c074d59 | |||
| af0888798f | |||
| 1ca11a2229 | |||
| 85ff3b8f01 | |||
| 162fdf94db | |||
| d5c7262794 | |||
| 0aaa694fab | |||
| e3478e6105 | |||
| aef04c525a | |||
| 6f178e934f | |||
| db3c0274f6 | |||
| 69074b23f3 | |||
| 794b978aab | |||
| b012c73e65 | |||
| 7e2348048a | |||
| fc5933e6ba | |||
| 4c147780ac | |||
| 0dedebfaf8 | |||
| 3e935ecd9e | |||
| 8ba11070d3 | |||
| 769fc8e7c6 | |||
| 3db151b40c | |||
| b0b9699ced | |||
| 967fc11ae8 | |||
| af7e2d3e51 | |||
| ee9784271f | |||
| 97e2680c57 | |||
| 0ebad759f9 | |||
| 3f35e581b7 | |||
| 87a1e1d2a8 | |||
| 05e403e3c6 | |||
| 0e7985287f | |||
| 255f16477c | |||
| d8fa525514 | |||
| 91635cbf4c | |||
| 139780d867 | |||
| ecaef5d797 | |||
| 7f2caeaad7 | |||
| 6ea840505a | |||
| 8f0db08d0f | |||
| 5beb3193cb | |||
| 7f7c643e9b | |||
| 000cbc02bc | |||
| 9a42035b0c | |||
| 1ec9e9d639 | |||
| 4ab37afaf3 | |||
| e227e0c658 | |||
| 59d766cda2 | |||
| 3b8f2e791f | |||
| a8eb9e192b | |||
| b98b9dded3 | |||
| 6223ac5475 | |||
| 9d4a5f4238 | |||
| 462488ddce | |||
| 9149883ae0 | |||
| 293bbfc474 | |||
| 291625e3e7 | |||
| abe4bff09e | |||
| a94df9be37 | |||
| 6127f355bf | |||
| b723fc4a85 | |||
| 3fb5a389c7 | |||
| ebd025fe27 | |||
| 1280844706 | |||
| 14ec2dd2a0 | |||
| 2f37031411 | |||
| 1714e7f930 | |||
| adac7d0545 | |||
| ca42982dea | |||
| 16166f5cc5 | |||
| 5b77ed1c12 | |||
| 44c0a53c0b | |||
| 89a2e7b1b7 | |||
| 1f249f92ff | |||
| ba3a15a7d2 | |||
| f7e0b5f517 | |||
| 539f6c62e3 | |||
| b5735bb2ad | |||
| 7b9101824a | |||
| 11f32d7051 | |||
| a30bf6be4c | |||
| 9cb89162ab | |||
| 2652015e80 | |||
| d8fab7604f | |||
| 5ffad5551c | |||
| e6e5499646 | |||
| 63489ee38f | |||
| e6324a1eb7 | |||
| 27700cacb0 | |||
| 6de3a07811 | |||
| 7ea724a8d9 | |||
| e7a31789e3 | |||
| 29496dbaac | |||
| 01468ab5c8 | |||
| 29a3bd3824 | |||
| 6b8d646f8a | |||
| 2e027f5bb2 | |||
| f2cdb6fec9 | |||
| 94a7922b30 | |||
| f65b9c4e6b | |||
| ab806fbc16 | |||
| 1bb4627ab2 | |||
| bef13261b3 | |||
| 0dec7c41ee | |||
| 86d7cff5d4 | |||
| f513aecacc | |||
| d7bc40acd5 | |||
| 22d869afa2 | |||
| 4dbc5cda1a | |||
| d707ed8b58 | |||
| 9e8b767128 | |||
| 5442114c85 | |||
| 9c3800c394 | |||
| ae5fd62dc3 | |||
| c7e3048c09 | |||
| 92a3337aeb | |||
| f6a0673082 | |||
| 1ad6bc85b4 | |||
| fa5a190416 | |||
| c4a631a5d8 | |||
| 9de194bfc0 | |||
| 460237fc66 | |||
| 929729a67f | |||
| b71f0a7dea | |||
| 9c72afdc52 | |||
| 2bc5fcdf45 | |||
| 04783d3f62 | |||
| 0b9e918fd0 | |||
| 2835fac38b | |||
| a58e5c91a2 | |||
| 6bd751957c | |||
| c991e22f09 | |||
| 25c03705a8 | |||
| 8fe7eb4798 | |||
| 104208c7df | |||
| 2ed878af81 | |||
| e3861f6857 | |||
| 74b09eaf09 | |||
| f48fdff716 | |||
| e11f333915 | |||
| b15f913a14 | |||
| febf04b059 | |||
| e47e955c7d | |||
| fd006d1605 | |||
| 91e60d5b30 | |||
| dfca76a320 | |||
| 6d86ede87d | |||
| 27692750a6 | |||
| af4a08b934 | |||
| cc1d2ec7de | |||
| 8d97e647e7 | |||
| d530e43664 | |||
| eadfe561b8 | |||
| 6580f428c6 | |||
| b309971c6c | |||
| fa257984d6 | |||
| e4f3beb662 | |||
| 4b3a28f6b8 | |||
| ef5467b6c7 | |||
| a878fdf72f | |||
| 0e4188a390 | |||
| 2550eaa9b9 | |||
| 9ca35b8c02 | |||
| c6c4425846 | |||
| bfa04ad096 | |||
| c030187bc8 | |||
| abd4a7f0f8 | |||
| df418328c6 | |||
| 337a35d367 | |||
| eb9a108d86 | |||
| 29c4e6e4bd | |||
| 699de76e7f | |||
| e6f29a2d97 | |||
| a867fc45d6 | |||
| 102a10457a | |||
| 56a42db2da | |||
| 26de297ac5 | |||
| ec6bdebc44 | |||
| 54cddb5041 | |||
| 9150b8a502 | |||
| a276978743 | |||
| 62a7046612 | |||
| 842bbe44f1 | |||
| 500d2d0965 | |||
| 8daea70cb8 | |||
| ea023d5ec6 | |||
| a3a62fab91 | |||
| 6319d549df | |||
| c8f63ca39a | |||
| 0f9bc0d74e | |||
| f1214167e6 | |||
| 3820076435 | |||
| 8a31671bbc | |||
| ec2db8be22 | |||
| 6123ced665 | |||
| d500cf2c91 | |||
| d22f10af0d | |||
| b85b4248c0 | |||
| 5e83655f49 | |||
| 93182e6c2d | |||
| fc096129dd | |||
| d1400fe52c | |||
| 3cca30ae28 | |||
| 3a4b2d44c2 | |||
| b54ddb16ba | |||
| 3d6d4ed251 | |||
| 0ee9725f3f | |||
| 3e42fe86a4 | |||
| bf706de68e | |||
| b265aace57 | |||
| ce53de9003 | |||
| 25998e946f | |||
| 3f1327b116 | |||
| dfb64a590d | |||
| 8a6ac5e346 | |||
| d84abf5aa7 | |||
| 50efb6a22a | |||
| a34cc1d788 | |||
| b6446899b1 | |||
| 3bb4139aeb | |||
| 704bc01e23 | |||
| 1e16c8b6ff | |||
| 5a7295ff5d | |||
| 24c8fc525e | |||
| 8ab65d8c3c | |||
| 2fcc360e7d | |||
| 0fc5bf30f6 | |||
| 6b1bed3395 | |||
| 1e299af234 | |||
| d7495d8f31 | |||
| fae2b57c4f | |||
| a2381fe148 | |||
| 4a8f99a907 | |||
| 4ba5075a9f | |||
| fd14667da2 | |||
| 1253779c00 | |||
| e071449ce9 | |||
| e6bde95e6f | |||
| 1cda0e7ad3 | |||
| edf8637d7d | |||
| 394173161c | |||
| eb407e03ee | |||
| 494fd9cca1 | |||
| 12deae75e8 | |||
| 1e795e1340 | |||
| c20f704ccf | |||
| de04405de7 | |||
| 41a1fa84d7 | |||
| bb483c6139 | |||
| 8efe237018 | |||
| 92f67dc678 | |||
| 7b660e41eb | |||
| 89f1ee328f | |||
| 8969ead3bf | |||
| afdf7ce595 | |||
| e44d7c1b92 | |||
| 8e0b4246b2 | |||
| 0e08e838e8 | |||
| 9133ff8697 | |||
| 908fc79303 | |||
| b76d62deb5 | |||
| bead0c7373 | |||
| 8965dd5878 | |||
| 62b1fd3d4a | |||
| c8f72e6ec6 | |||
| 25c262e6eb | |||
| 73798b588a | |||
| 9ecba9a6f1 |
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
description: Unified Alert (toast/banner) for app notifications — Figma + drift prevention
|
||||||
|
globs: app/**/*.tsx, stories/modals/Alert.stories.js, tests/components/Alert.test.tsx
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Alerts and notifications
|
||||||
|
|
||||||
|
## Source of truth
|
||||||
|
|
||||||
|
- **Figma:** [Community Rule System — Modal / Alert](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=6351-14646) (node **6351-14646**).
|
||||||
|
- **Code:** `app/components/modals/Alert` — default export `Alert` from `Alert.container.tsx` (Figma docstring on the container).
|
||||||
|
|
||||||
|
## When to use `Alert`
|
||||||
|
|
||||||
|
Use **`Alert`** for **app-level, section-level, and shell-level** success, warning, error, and neutral status messages that should read as a designed system surface (not body copy alone).
|
||||||
|
|
||||||
|
Do **not** recreate the same job with ad-hoc UI: bordered `<p>`, free-standing `role="alert"` blocks, or raw `text-[var(--color-border-default-utility-negative)]` paragraphs for product messaging.
|
||||||
|
|
||||||
|
## Props (lowercase in code; match Figma intent)
|
||||||
|
|
||||||
|
| Concern | Prop | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Layout | `type` | `toast` — bottom accent bar, top rounded corners; `banner` — full rounded block, inline or stacked. |
|
||||||
|
| Intent | `status` | `default` \| `positive` \| `warning` \| `danger`. |
|
||||||
|
| Density | `size` | `s` \| `m` (Figma S/M). Typography and padding are implemented inside `Alert.container.tsx` — do not fork spacing per call site. |
|
||||||
|
| Copy | `title`, `description?` | Required title; optional description when `hasBodyText` is true. |
|
||||||
|
| Icon | `hasLeadingIcon?` | Default `true`. |
|
||||||
|
| Body | `hasBodyText?` | Default `true`; set `false` for title-only. |
|
||||||
|
| Dismiss | `onClose?`, `hasTrailingIcon?` | Close control shows only when `onClose` is provided **and** `hasTrailingIcon` is not `false`. Omit `onClose` for non-dismissible messages. |
|
||||||
|
|
||||||
|
Valid enum slices for Storybook / guards: `ALERT_*_OPTIONS` in `lib/propNormalization.ts`.
|
||||||
|
|
||||||
|
## Choosing toast vs banner
|
||||||
|
|
||||||
|
- **`toast`** — transient edge / bottom emphasis (e.g. completed flow), strong bottom border accent.
|
||||||
|
- **`banner`** — rounded block; for **page / shell / modal** messaging, mount inside a **`fixed`** (or equivalent) overlay wrapper with `pointer-events-none` on the outer layer and `pointer-events-auto` on the alert so layout chrome does not reflow when the message appears (see `CreateFlowLayoutClient` `topBanners`, profile overlays, `LoginForm`, `PostLoginDraftTransfer`).
|
||||||
|
|
||||||
|
## Exemptions (do not force `Alert`)
|
||||||
|
|
||||||
|
1. **Single-field validation** under a control — keep `TextInput` / `TextArea` `error` and helper text (e.g. invalid email on the login form) unless design explicitly moves that line into `Alert`.
|
||||||
|
2. **Marketing layout** — `HeroBanner`, `ContentBanner` are not system alerts.
|
||||||
|
3. **Landmarks** — `role="banner"` on headers/nav is not the `Alert` “banner” type.
|
||||||
|
4. **A11y-only live regions** — e.g. tooltip / incrementer `aria-live` for widget state, not product notifications.
|
||||||
|
|
||||||
|
## Copy
|
||||||
|
|
||||||
|
All user-visible strings go through **`messages/`** and `useTranslation` / message modules per `localization.mdc`.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
description: App Router API handler conventions (Next.js + Prisma + Zod)
|
||||||
|
globs: app/api/**/*.ts,lib/server/**/*.ts
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# API route anatomy
|
||||||
|
|
||||||
|
Every DB-touching handler in `app/api/**/route.ts` follows the same skeleton.
|
||||||
|
Keep new routes within this shape so auth, config, and validation stay uniform.
|
||||||
|
|
||||||
|
1. **Config guard (first line of the handler).**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (!isDatabaseConfigured()) return dbUnavailable();
|
||||||
|
```
|
||||||
|
|
||||||
|
From `lib/server/env` + `lib/server/responses`. Returns a consistent 503
|
||||||
|
when `CLOUDRON_POSTGRESQL_URL` is missing (local dev, preview builds).
|
||||||
|
|
||||||
|
2. **Auth (when the route requires a user).**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From `lib/server/session`. Never read session cookies or tokens directly.
|
||||||
|
|
||||||
|
3. **Body parsing + validation (POST/PUT/PATCH).**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const parsed = await readLimitedJson(request);
|
||||||
|
const result = mySchema.safeParse(parsed);
|
||||||
|
if (!result.success) return jsonFromZodError(result.error);
|
||||||
|
```
|
||||||
|
|
||||||
|
Helpers live in `lib/server/validation/{requestBody,zodHttp}.ts`. All
|
||||||
|
payload schemas belong in `lib/server/validation/*.ts` (today:
|
||||||
|
`createFlowSchemas.ts`) — colocate new schemas there rather than inline in
|
||||||
|
the route.
|
||||||
|
|
||||||
|
4. **Prisma access** via `import { prisma } from "lib/server/db"`. Do not
|
||||||
|
instantiate `PrismaClient` directly.
|
||||||
|
|
||||||
|
5. **Responses** via `NextResponse.json(...)`. Shared shapes
|
||||||
|
(`dbUnavailable`, `unauthorized`, `notFound`, `rateLimited`,
|
||||||
|
`serverMisconfigured`, `internalError`) and the generic `errorJson(code,
|
||||||
|
message, status, opts?)` live in `lib/server/responses.ts`. Add new
|
||||||
|
shared responses there when a pattern repeats in two routes.
|
||||||
|
|
||||||
|
6. **Errors + observability.** All 4xx/5xx bodies use the canonical shape
|
||||||
|
`{ error: { code, message }, details? }` with codes from the
|
||||||
|
`ApiErrorCode` union in `lib/server/responses.ts`. Wrap handlers with
|
||||||
|
`apiRoute("scope.name", async (req, ctx, { requestId }) => { ... })`
|
||||||
|
from `lib/server/apiRoute.ts` so an `x-request-id` is generated /
|
||||||
|
forwarded onto every response and uncaught throws return a canonical
|
||||||
|
500 with the id logged via `lib/logger`.
|
||||||
|
|
||||||
|
# Server-only isolation
|
||||||
|
|
||||||
|
`lib/server/*` is the server boundary. Anything that:
|
||||||
|
|
||||||
|
- imports `@prisma/client`,
|
||||||
|
- reads secrets from `env`,
|
||||||
|
- sends email, hashes tokens, or touches sessions
|
||||||
|
|
||||||
|
…lives under `lib/server/`. Never import `lib/server/*` from client
|
||||||
|
components, `app/components/**`, or any file marked `"use client"`. Shared
|
||||||
|
logic safe for both sides goes in `lib/*`.
|
||||||
|
|
||||||
|
# Deferred — follow existing code, don't invent
|
||||||
|
|
||||||
|
These areas are still settling. Match whatever the nearest route already does
|
||||||
|
instead of introducing new patterns:
|
||||||
|
|
||||||
|
- **Rate limiting.** `lib/server/rateLimit.ts` is an in-memory stopgap marked
|
||||||
|
for replacement. Reuse `rateLimitKey()` where limiting is needed; don't
|
||||||
|
design a new limiter. When returning 429, prefer `rateLimited(retryAfterMs)`
|
||||||
|
from `responses.ts` so the body and `Retry-After` header stay uniform.
|
||||||
|
- **Pagination / filtering.** Only `rules/route.ts` paginates (`take` capped
|
||||||
|
at 100). Mirror it if you add list endpoints; don't invent cursors or
|
||||||
|
offset contracts unilaterally.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
description: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria.
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Coding behavioral guidelines
|
||||||
|
|
||||||
|
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||||
|
|
||||||
|
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||||
|
|
||||||
|
## 1. Think Before Coding
|
||||||
|
|
||||||
|
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||||
|
|
||||||
|
Before implementing:
|
||||||
|
- State your assumptions explicitly. If uncertain, ask.
|
||||||
|
- If multiple interpretations exist, present them - don't pick silently.
|
||||||
|
- If a simpler approach exists, say so. Push back when warranted.
|
||||||
|
- If something is unclear, stop. Name what's confusing. Ask.
|
||||||
|
|
||||||
|
## 2. Simplicity First
|
||||||
|
|
||||||
|
**Minimum code that solves the problem. Nothing speculative.**
|
||||||
|
|
||||||
|
- No features beyond what was asked.
|
||||||
|
- No abstractions for single-use code.
|
||||||
|
- No "flexibility" or "configurability" that wasn't requested.
|
||||||
|
- No error handling for impossible scenarios.
|
||||||
|
- If you write 200 lines and it could be 50, rewrite it.
|
||||||
|
|
||||||
|
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||||
|
|
||||||
|
## 3. Surgical Changes
|
||||||
|
|
||||||
|
**Touch only what you must. Clean up only your own mess.**
|
||||||
|
|
||||||
|
When editing existing code:
|
||||||
|
- Don't "improve" adjacent code, comments, or formatting.
|
||||||
|
- Don't refactor things that aren't broken.
|
||||||
|
- Match existing style, even if you'd do it differently.
|
||||||
|
- If you notice unrelated dead code, mention it - don't delete it.
|
||||||
|
|
||||||
|
When your changes create orphans:
|
||||||
|
- Remove imports/variables/functions that YOUR changes made unused.
|
||||||
|
- Don't remove pre-existing dead code unless asked.
|
||||||
|
|
||||||
|
The test: Every changed line should trace directly to the user's request.
|
||||||
|
|
||||||
|
## 4. Goal-Driven Execution
|
||||||
|
|
||||||
|
**Define success criteria. Loop until verified.**
|
||||||
|
|
||||||
|
Transform tasks into verifiable goals:
|
||||||
|
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||||
|
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||||
|
- "Refactor X" → "Ensure tests pass before and after"
|
||||||
|
|
||||||
|
For multi-step tasks, state a brief plan:
|
||||||
|
```
|
||||||
|
1. [Step] → verify: [check]
|
||||||
|
2. [Step] → verify: [check]
|
||||||
|
3. [Step] → verify: [check]
|
||||||
|
```
|
||||||
|
|
||||||
|
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
description: Component prop conventions — lowercase-canonical enums, Figma traceability
|
||||||
|
globs: app/components/**/*.{ts,tsx}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Component prop alignment
|
||||||
|
|
||||||
|
Figma is the source of truth for component **design** (existence, variants,
|
||||||
|
visual specification). The codebase implements those components using
|
||||||
|
idiomatic TypeScript naming. Enum props are **lowercase** in code; PascalCase
|
||||||
|
is a Figma-side concern only.
|
||||||
|
|
||||||
|
## Enum prop convention
|
||||||
|
|
||||||
|
- Types use lowercase string unions: `"small" | "medium" | "large"`.
|
||||||
|
- Do NOT add PascalCase variants to type unions.
|
||||||
|
- Do NOT call normalizers in containers. The container layer is for `memo`,
|
||||||
|
derived state, prop defaults, and bound logic — not for casing translation.
|
||||||
|
- Each enum prop has a sibling `<COMPONENT>_<PROP>_OPTIONS as const` array
|
||||||
|
exported alongside the type. Storybook `argTypes` and any runtime guard
|
||||||
|
consume that array as the single source of valid values.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const CHIP_PALETTE_OPTIONS = ["primary", "secondary"] as const;
|
||||||
|
export type ChipPaletteValue = (typeof CHIP_PALETTE_OPTIONS)[number];
|
||||||
|
```
|
||||||
|
|
||||||
|
## Figma traceability
|
||||||
|
|
||||||
|
- Container docstring (required on every DS container): `Figma:
|
||||||
|
"<Component Path>" (<node-id>)`.
|
||||||
|
- View root element: `data-figma-node="<id>"` when the view maps to a
|
||||||
|
distinct Figma node.
|
||||||
|
- For create-flow screens, node ids come from `CREATE_FLOW_SCREEN_REGISTRY`
|
||||||
|
in `app/(app)/create/utils/createFlowScreenRegistry.ts`. For everything else,
|
||||||
|
prefer `Figma: "<Path>" (<node-id>)` from the file. If the node id is not
|
||||||
|
wired yet, use `Figma: "<Path>"` plus a short note (e.g. *canonical code under
|
||||||
|
\`controls/\`*) rather than omitting the docstring.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Figma: "Control / Incrementer" (17857:30943). A compact [ - value + ]
|
||||||
|
* row used for numeric step inputs.
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pasting from Figma
|
||||||
|
|
||||||
|
Figma's "Inspect → Code" output emits PascalCase. When importing a snippet,
|
||||||
|
lowercase the enum values before committing — same pattern as removing
|
||||||
|
inline pixel values in favor of design tokens.
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
---
|
||||||
|
description: File-structure conventions for design-system components
|
||||||
|
globs: app/components/**/*.{ts,tsx}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Component file structure
|
||||||
|
|
||||||
|
## Split-file pattern (default)
|
||||||
|
|
||||||
|
Anything in `app/components/controls/**` and `app/components/utility/**` uses
|
||||||
|
a **4-file split**, one folder per component:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/components/controls/<Name>/
|
||||||
|
<Name>.types.ts // Public Props + internal ViewProps
|
||||||
|
<Name>.view.tsx // "use client"; pure render; exports memo(view)
|
||||||
|
<Name>.container.tsx // "use client"; memo; prop normalization & logic
|
||||||
|
index.tsx // re-exports default + public types
|
||||||
|
```
|
||||||
|
|
||||||
|
**Container** (`<Name>.container.tsx`):
|
||||||
|
|
||||||
|
- Marked `"use client"`.
|
||||||
|
- Receives `<Name>Props`; computes derived state (clamps, ids, bounds, prop
|
||||||
|
defaults) and bound event handlers.
|
||||||
|
- Renders `<<Name>View />`. Containers do **not** translate prop casing —
|
||||||
|
enum props are lowercase end-to-end (see `component-props.mdc`).
|
||||||
|
- Default export: `memo(<Name>Container)` with `.displayName = "<Name>"`.
|
||||||
|
- Carries the Figma docstring (`Figma: "<Path>" (<node-id>)`).
|
||||||
|
|
||||||
|
**View** (`<Name>.view.tsx`):
|
||||||
|
|
||||||
|
- Marked `"use client"`.
|
||||||
|
- Pure render of `<Name>ViewProps`. No data fetching, no derived business
|
||||||
|
logic, no enum casing translation.
|
||||||
|
- Default export: `memo(<Name>View)` with `.displayName = "<Name>View"`.
|
||||||
|
|
||||||
|
**Types** (`<Name>.types.ts`):
|
||||||
|
|
||||||
|
- Export `<Name>Props` (consumer-facing).
|
||||||
|
- Export `<Name>ViewProps` (the shape the view consumes — typically a
|
||||||
|
resolved superset of `<Name>Props`).
|
||||||
|
- Export any locally-defined value types (`<Name>SizeValue`, etc.) sourced
|
||||||
|
from the matching `*_OPTIONS` array in `lib/propNormalization.ts`.
|
||||||
|
|
||||||
|
**Index** (`index.tsx`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export { default } from "./<Name>.container";
|
||||||
|
export type { <Name>Props } from "./<Name>.types";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Small presentational packages (buttons)
|
||||||
|
|
||||||
|
`app/components/buttons/<Name>/` holds **`index.tsx`** plus **`<Name>.tsx`**
|
||||||
|
(the **`Button`**, **`InlineTextButton`** packages today). Promote to the full
|
||||||
|
container/view/types split when state or logic outgrows a single module (like **`controls/TextInput`**).
|
||||||
|
|
||||||
|
## `cards/` packages
|
||||||
|
|
||||||
|
Prefer the **container / view / types** layout for **`Selection/`**, **`CardStack/`**, **`Rule/`**,
|
||||||
|
**`Icon/`**, **`Mini/`**, **`TemplateReviewCard/`**. **`Step/`** keeps a single
|
||||||
|
**`<Name>.tsx`** next to **`index.tsx`** until complexity justifies a split.
|
||||||
|
|
||||||
|
## `modals/` packages
|
||||||
|
|
||||||
|
Use the same **container / view / types** split where those files exist (**`Alert`**, **`Create`**, **`Dialog`**, **`Login`**, **`Tooltip`**, **`ModalHeader`**, **`ModalFooter`**).
|
||||||
|
|
||||||
|
## `navigation/` packages
|
||||||
|
|
||||||
|
Use the **container / view / types** split + per-package **`index.tsx`** for **`Top/`**, **`CreateFlowTopNav/`**, **`CreateFlowFooter/`**, **`NavigationItem/`**, **`Link/`**, **`MenuItem/`**. **`TopWithPathname.tsx`** lives inside **`Top/`** as the pathname + session wrapper.
|
||||||
|
|
||||||
|
**Root-level** **`Menu.tsx`**, **`Footer.tsx`**, **`ConditionalNavigation.tsx`**, and **`ConditionalNavigationClient.tsx`** sit beside those folders—no bucket-level barrel. Figma **Navigation / Menu** maps to **`Menu`** + **`MenuItem`** (see **`docs/figma-component-registry.md`**, Navigation conventions) and **`routes.mdc`** for shell behavior.
|
||||||
|
|
||||||
|
## `progress/` packages
|
||||||
|
|
||||||
|
Use the **container / view / types** split + **`index.tsx`** for **`Stepper/`** and **`ProportionBar/`** (same shape as **`controls/`**). See **`docs/figma-component-registry.md`** — **Progress conventions** for Figma **Progress** vs **Control / Proportion**.
|
||||||
|
|
||||||
|
## `sections/` packages
|
||||||
|
|
||||||
|
Section-level compositions are **mixed**: many folders use **`container` / `view` / `types`** (**`FeatureGrid/`**, **`QuoteBlock/`**, …), while **`ContentBanner.tsx`** and **`SectionNumber.tsx`** are **single modules** at the bucket root. Prefer the **split** for **new** composites; see **`docs/figma-component-registry.md`** — **Sections conventions**. **`SectionHeader/`** lives under **`type/`** (Figma Type / SectionHeader). Published rule typography body **`CommunityRule/`** lives under **`type/`** (see **Type conventions**).
|
||||||
|
|
||||||
|
## `type/` packages
|
||||||
|
|
||||||
|
**`type/`** is mostly **`container` / `view` / `types`** + **`index.tsx`** (**`HeaderLockup/`**, **`ContentLockup/`**, **`NumberedList/`**, **`InputLabel/`**). **`SectionHeader/`** is a small presentational package (**`SectionHeader.tsx`** + **`index.tsx`**) for the Figma Type SectionHeader lockup. **`CommunityRule/`**, **`Section/`**, and **`TextBlock/`** are **view + types** packages (Community Rule document tree). See **`docs/figma-component-registry.md`** — **Type conventions**.
|
||||||
|
|
||||||
|
## No package-level barrels
|
||||||
|
|
||||||
|
Do **not** add **`app/components/<bucket>/index.tsx`** that re-exports every
|
||||||
|
sibling under that bucket (there is no `buttons/index.tsx` or `asset/index.tsx`).
|
||||||
|
Import **`…/buttons/Button`**, **`…/asset/icon`**, **`…/asset/Logo`**, etc.—same
|
||||||
|
mental model everywhere.
|
||||||
|
|
||||||
|
**Per-component** **`index.tsx`** entrypoints (**`Logo/index.tsx`**, **`controls/TextInput/index.tsx`**, …) stay as documented above—aggregating an entire **`buttons/`** or **`asset/`** tier in one file does not.
|
||||||
|
|
||||||
|
## Wrapper / group components
|
||||||
|
|
||||||
|
Related composites live in a **sibling folder**, not inside the base
|
||||||
|
component's folder — mirror `CheckboxGroup/` ↔ `Checkbox/`,
|
||||||
|
`IncrementerBlock/` ↔ `Incrementer/`, etc. Each gets its own 4-file split.
|
||||||
|
Consumers import from the folder's `index.tsx`.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
description: Create-flow structure & design-system reuse guardrails
|
||||||
|
globs: app/(app)/create/**/*.{ts,tsx},messages/en/create/**/*.json
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Create-flow guardrails
|
||||||
|
|
||||||
|
## Folder & file layout
|
||||||
|
|
||||||
|
- Screens live in `app/(app)/create/screens/<layoutKind>/<StepIdPascal>Screen.tsx`
|
||||||
|
where `<layoutKind>` mirrors `CreateFlowLayoutKind` (`card`, `select`,
|
||||||
|
`right-rail`, `completed`, …). File + export name is the **step id**, never
|
||||||
|
the layout kind (e.g. `DecisionApproachesScreen`, not `RightRailScreen`).
|
||||||
|
- Step id ↔ layout kind mapping is declared in
|
||||||
|
`app/(app)/create/utils/createFlowScreenRegistry.ts`. Never branch on layout kind
|
||||||
|
inside a screen — pick the matching shell (`CreateFlowStepShell` /
|
||||||
|
`CreateFlowTwoColumnSelectShell`).
|
||||||
|
- Keep create-flow step routing centralized in
|
||||||
|
`app/(app)/create/utils/createFlowPaths.ts` (`createFlowStepPath`,
|
||||||
|
`CREATE_ROUTES`) — do not introduce new hardcoded `/create/...` literals.
|
||||||
|
- Shared create-flow pieces go in `app/(app)/create/components/` (layout shells,
|
||||||
|
field composites). Generic primitives go in `app/components/`.
|
||||||
|
|
||||||
|
## Use the design system — don't hand-roll
|
||||||
|
|
||||||
|
Reach for these before writing new markup:
|
||||||
|
|
||||||
|
| Need | Component |
|
||||||
|
| --- | --- |
|
||||||
|
| Labelled text-area section in a modal | `app/(app)/create/components/ModalTextAreaField` |
|
||||||
|
| Toggle-chip row + inline "+ Add" input | `app/(app)/create/components/ApplicableScopeField` |
|
||||||
|
| `[– value +]` numeric stepper (± label) | `app/components/controls/Incrementer` / `IncrementerBlock` |
|
||||||
|
| Mid-paragraph "expand / see all" link button | `app/components/buttons/InlineTextButton` |
|
||||||
|
| Help-icon + label above a control | `app/components/type/InputLabel` (`helpIcon` prop) |
|
||||||
|
| Toggle chip (dim-but-clickable) | `Chip` with `state="Disabled" disabled={false}` |
|
||||||
|
| Card-click → structured creation modal | `Create` with `backdropVariant="blurredYellow"` |
|
||||||
|
|
||||||
|
If a screen grows a 2nd inline copy of any pattern above, **extract a shared
|
||||||
|
component** rather than duplicate. Local section components inside a screen
|
||||||
|
file are a smell once they're used more than once.
|
||||||
|
|
||||||
|
## Copy & data
|
||||||
|
|
||||||
|
- Step copy lives in `messages/en/create/<stage>/<step>.json` where
|
||||||
|
`<stage>` is one of `community`, `customRule`, `reviewAndComplete`
|
||||||
|
(matches Figma stages — see `docs/create-flow.md`). Cross-cutting chrome
|
||||||
|
(`footer.json`, `topNav.json`, `draftHydration.json`,
|
||||||
|
`templateReview.json`) and shared layout-shell strings (`select.json`,
|
||||||
|
`text.json`, `upload.json`) live at the `create/` root. Wire each new
|
||||||
|
JSON into `messages/en/index.ts` under the matching `create.<stage>.*`
|
||||||
|
namespace (see `localization.mdc`).
|
||||||
|
- Modal `sections` defaults are DB-shaped seed placeholders, not UI
|
||||||
|
constants — expect replacement with live data.
|
||||||
|
- Custom-rule facet mappings (step ids, template-category aliases, selection
|
||||||
|
keys, strip keys) must be sourced from `lib/create/customRuleFacets.ts`
|
||||||
|
(`CUSTOM_RULE_FACETS`) instead of adding new ad-hoc switches/tables.
|
||||||
|
|
||||||
|
## Interaction tracking
|
||||||
|
|
||||||
|
Every user interaction inside a create-flow screen must call
|
||||||
|
`markCreateFlowInteraction()` from `useCreateFlow()` before mutating state —
|
||||||
|
progress / footer logic depends on it.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
description: Custom hooks live in app/hooks; co-locate logic, document via TSDoc.
|
||||||
|
globs: app/hooks/**/*.{ts,tsx}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Custom hooks
|
||||||
|
|
||||||
|
Reusable component logic lives in `app/hooks/`. Each hook is a small, focused
|
||||||
|
module with a TSDoc block that doubles as the API reference (no separate doc
|
||||||
|
file).
|
||||||
|
|
||||||
|
## File layout
|
||||||
|
|
||||||
|
- One file per hook: `app/hooks/use<Name>.ts`.
|
||||||
|
- Re-export from `app/hooks/index.ts`. Consumers import from the barrel:
|
||||||
|
`import { useFoo } from "../hooks";`.
|
||||||
|
- Companion unit test (when there is non-trivial logic): `tests/unit/hooks/`.
|
||||||
|
|
||||||
|
## Authoring rules
|
||||||
|
|
||||||
|
- Marked as a regular function (`export function useFoo() {}`); React handles
|
||||||
|
the `use*` naming convention.
|
||||||
|
- Wrap exposed callbacks in `useCallback` and computed values in `useMemo`
|
||||||
|
so consumers can list them in dependency arrays without churn.
|
||||||
|
- Read DOM/browser APIs only inside `useEffect` so the hook stays SSR-safe.
|
||||||
|
- Never throw on missing globals (e.g. `window`, `gtag`); guard and no-op.
|
||||||
|
|
||||||
|
## TSDoc — the only reference
|
||||||
|
|
||||||
|
Every exported hook gets a TSDoc block with:
|
||||||
|
|
||||||
|
- 1–2 sentence summary.
|
||||||
|
- `@param` per argument and `@returns` describing the shape.
|
||||||
|
- `@example` showing the typical call site.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Detect clicks outside a set of elements (e.g. close a dropdown).
|
||||||
|
*
|
||||||
|
* @param refs Elements that should NOT trigger the handler.
|
||||||
|
* @param handler Invoked when a click lands outside every ref.
|
||||||
|
* @param enabled Toggle without unmounting the consumer (default true).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* useClickOutside([menuRef, buttonRef], () => setOpen(false), open);
|
||||||
|
*/
|
||||||
|
export function useClickOutside(
|
||||||
|
refs: Array<RefObject<HTMLElement>>,
|
||||||
|
handler: (event: MouseEvent | TouchEvent) => void,
|
||||||
|
enabled = true,
|
||||||
|
): void { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Container/view consumption
|
||||||
|
|
||||||
|
Hooks belong in **container** files (per `component-structure.mdc`). Views
|
||||||
|
stay pure and read derived values via props — never call hooks that touch
|
||||||
|
state or side effects from a view.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
description: Text localization via messages/ bundles and useMessages()
|
||||||
|
globs: messages/**/*.{ts,json}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Text localization
|
||||||
|
|
||||||
|
All user-visible copy lives in the typed messages bundle under `messages/en/`
|
||||||
|
and is read via `useMessages()` (fully typed) or `useTranslation()` (dot
|
||||||
|
notation). Never hard-code user-facing strings in components.
|
||||||
|
|
||||||
|
## File layout
|
||||||
|
|
||||||
|
- `messages/en/<area>.json` for single-file areas (`common.json`,
|
||||||
|
`navigation.json`, `metadata.json`).
|
||||||
|
- `messages/en/<folder>/<entry>.json` for areas with multiple buckets:
|
||||||
|
`components/*.json`, `pages/*.json`. One JSON per component / page —
|
||||||
|
don't shoehorn unrelated copy into a shared file.
|
||||||
|
- `messages/en/create/<stage>/<step>.json` — wizard steps grouped by Figma
|
||||||
|
stage (`community`, `customRule`, `reviewAndComplete`). Cross-cutting
|
||||||
|
chrome (footer, top nav, draft hydration, template review) and shared
|
||||||
|
layout-shell strings (`select.json`, `text.json`, `upload.json`) live at
|
||||||
|
the `create/` root.
|
||||||
|
- Optional `"_comment"` at the top of a JSON documents the bundle's purpose.
|
||||||
|
|
||||||
|
## Registration — required
|
||||||
|
|
||||||
|
Every new JSON must be wired into `messages/en/index.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import createConflictManagement from "./create/customRule/conflictManagement.json";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// …
|
||||||
|
create: {
|
||||||
|
customRule: {
|
||||||
|
conflictManagement: createConflictManagement,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The default export **is** the type source for `useMessages()`; skipping this
|
||||||
|
step means consumers can't read your strings and TypeScript won't flag the gap.
|
||||||
|
|
||||||
|
## Access pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useMessages } from "../contexts/MessagesContext";
|
||||||
|
|
||||||
|
const m = useMessages();
|
||||||
|
const title = m.create.customRule.conflictManagement.page.compactTitle; // fully typed
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `useTranslation(namespace)` only when you need dot-path lookup by dynamic
|
||||||
|
key; prefer direct property access for the type safety.
|
||||||
|
|
||||||
|
## Key conventions
|
||||||
|
|
||||||
|
- **Structural keys**: camelCase (`compactTitle`,
|
||||||
|
`sectionHeadings.corePrinciple`).
|
||||||
|
- **Content ids**: match the id consumers already use (card id, step id, URL
|
||||||
|
segment) — typically kebab-case (`"in-person-meetings"`,
|
||||||
|
`"peer-mediation"`).
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
description: App Router route organization (groups, layouts, chrome composition)
|
||||||
|
globs: app/**/*.{ts,tsx}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Route organization
|
||||||
|
|
||||||
|
Top-level routes live inside **route groups** so each surface owns its own
|
||||||
|
layout and chrome. Groups are wrapping folders in `(parens)` — they organize
|
||||||
|
the file tree without affecting URLs.
|
||||||
|
|
||||||
|
## Group map
|
||||||
|
|
||||||
|
| Group | URL surface | Audience | Chrome |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `app/(marketing)/` | `/`, `/learn`, `/blog`, `/templates`, future public pages | Public, indexable | `Top` (via root) + marketing `<Footer />` |
|
||||||
|
| `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
|
||||||
|
surface doesn't fit an existing group, add a new group rather than dropping
|
||||||
|
the folder next to `(marketing)/`.
|
||||||
|
|
||||||
|
## Layout responsibilities
|
||||||
|
|
||||||
|
- **`app/layout.tsx`** — `<html>`, `<body>`, providers (`MessagesProvider`,
|
||||||
|
`AuthModalProvider`), fonts, and `ConditionalNavigation`. Renders
|
||||||
|
`{children}` directly inside the flex column. **Does not** render
|
||||||
|
`<main>` — each group layout owns that.
|
||||||
|
- **`app/(marketing)/layout.tsx`** — wraps with `<main className="flex-1">`
|
||||||
|
and appends the public `<Footer />`.
|
||||||
|
- **`app/(app)/layout.tsx`** / **`(admin)/layout.tsx`** / **`(dev)/layout.tsx`** —
|
||||||
|
wrap with `<main className="flex-1">`. No footer by default; **`app/(app)/profile/layout.tsx`**
|
||||||
|
appends the marketing `<Footer />` for `/profile` only.
|
||||||
|
- **Nested layouts** (e.g. `(app)/create/layout.tsx`) compose feature-specific
|
||||||
|
chrome inside the group's `<main>` — never render `<html>`, `<body>`,
|
||||||
|
`<main>`, or providers.
|
||||||
|
|
||||||
|
If a route needs different chrome than its group provides, prefer adding a
|
||||||
|
**nested layout** under that route — don't introduce pathname-sniffing
|
||||||
|
client components. (`ConditionalNavigation` is the lone tolerated exception
|
||||||
|
because it carries SSR session state; do not add new pathname-conditional
|
||||||
|
chrome components.)
|
||||||
|
|
||||||
|
## Co-located component folders
|
||||||
|
|
||||||
|
Page-private server/client components that are **only** used by routes in a
|
||||||
|
given group go in `_components/` inside that group:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/(marketing)/_components/MarketingRuleStackSection.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
The leading underscore makes Next.js treat the folder as **private** — it's
|
||||||
|
ignored by the router. Use this instead of letting page-only files sit next
|
||||||
|
to `page.tsx`.
|
||||||
|
|
||||||
|
Components reused across groups belong in `app/components/<category>/`
|
||||||
|
(see `component-structure.mdc`).
|
||||||
|
|
||||||
|
## Adding a new route
|
||||||
|
|
||||||
|
1. **Choose the group** by audience: marketing (public), app (signed-in),
|
||||||
|
admin (operators), dev (local-only). When in doubt, ask whether the
|
||||||
|
public marketing footer should appear — if yes, it's `(marketing)`.
|
||||||
|
2. Create `app/(<group>)/<route>/page.tsx`. URLs do **not** include the
|
||||||
|
group name.
|
||||||
|
3. If the route needs its own chrome (e.g. a wizard header), add
|
||||||
|
`app/(<group>)/<route>/layout.tsx`.
|
||||||
|
4. If the route ships private helpers, put them in
|
||||||
|
`app/(<group>)/<route>/_components/` (or
|
||||||
|
`app/(<group>)/_components/` for group-wide page components).
|
||||||
|
|
||||||
|
## Splitting a group
|
||||||
|
|
||||||
|
Promote a sub-cluster to its own group only when **both** are true:
|
||||||
|
|
||||||
|
- It will hold ≥2 routes that share a layout, **or** it has a clearly
|
||||||
|
distinct audience/access model (e.g. a future `(auth)/` for
|
||||||
|
signup/forgot/verify alongside login).
|
||||||
|
- Moving the routes pays for itself by replacing existing pathname
|
||||||
|
conditionals or by composing real shared chrome — not just by tidying
|
||||||
|
the folder list.
|
||||||
|
|
||||||
|
YAGNI applies: a group with one route and no shared layout is just a
|
||||||
|
folder with parens.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
description: Storybook story conventions — location, naming, titles, decorators
|
||||||
|
globs: stories/**/*.{js,jsx,ts,tsx,mdx},.storybook/**/*.{js,ts}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Where stories live
|
||||||
|
|
||||||
|
All stories live in the top-level `stories/` folder. Two layout rules:
|
||||||
|
|
||||||
|
- **Design-system components** mirror `app/components/`. A component at
|
||||||
|
`app/components/<bucket>/<Name>` gets `stories/<bucket>/<Name>.stories.js`.
|
||||||
|
- **Create-flow material** has two carve-outs:
|
||||||
|
- `stories/create-flow/` — shared create-flow pieces that aren't in
|
||||||
|
`app/components/` (e.g. composed wizard fragments).
|
||||||
|
- `stories/pages/` — integration stories that exercise an entire
|
||||||
|
`app/(app)/create/screens/<...>` screen as it appears in the wizard.
|
||||||
|
|
||||||
|
| Source | Story location |
|
||||||
|
| --------------------------------- | --------------------------------------- |
|
||||||
|
| `app/components/controls/Chip` | `stories/controls/Chip.stories.js` |
|
||||||
|
| `app/components/buttons/Button` | `stories/buttons/Button.stories.js` |
|
||||||
|
| `app/(app)/create/screens/.../FooScreen`| `stories/pages/FooPage.stories.js` |
|
||||||
|
| Shared create-flow fragment | `stories/create-flow/<Name>.stories.js` |
|
||||||
|
|
||||||
|
Do **not** colocate `*.stories.*` next to components. The Storybook config
|
||||||
|
(`.storybook/main.js`) only globs `stories/**`.
|
||||||
|
|
||||||
|
# File naming
|
||||||
|
|
||||||
|
- `<ComponentName>.stories.js` — matches 69/70 existing files.
|
||||||
|
- Use `.tsx` only when the story genuinely needs types (rare; prefer JS to
|
||||||
|
match the codebase convention).
|
||||||
|
- Variants get a suffix: `Button.visual.stories.js`,
|
||||||
|
`Footer.responsive.stories.js`.
|
||||||
|
|
||||||
|
# Default export shape (CSF2)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import MyComponent from "../../app/components/<area>/MyComponent";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "Components/<SubFolder>/MyComponent",
|
||||||
|
component: MyComponent,
|
||||||
|
parameters: {
|
||||||
|
layout: "centered",
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: "Short description of what the component is for.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
variant: {
|
||||||
|
control: { type: "select" },
|
||||||
|
options: ["filled", "outline"],
|
||||||
|
description: "The variant (Figma prop)",
|
||||||
|
},
|
||||||
|
onClick: { action: "clicked" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default = { args: { variant: "filled" } };
|
||||||
|
```
|
||||||
|
|
||||||
|
## Title hierarchy
|
||||||
|
|
||||||
|
- Design-system components → `Components/<SubFolder>/<Name>` (e.g.
|
||||||
|
`Components/Controls/Checkbox`).
|
||||||
|
- Pages → `Pages/<PageName>` (folder: `stories/pages/`).
|
||||||
|
- Create flow shared pieces → `Create Flow/<Name>`.
|
||||||
|
|
||||||
|
## `argTypes`
|
||||||
|
|
||||||
|
For every Figma enum prop (`variant`, `size`, `state`, `mode`, `palette`,
|
||||||
|
…) expose a `select` control listing the **lowercase** option set, sourced
|
||||||
|
from the matching `*_OPTIONS` const in `lib/propNormalization.ts`. See
|
||||||
|
`.cursor/rules/component-props.mdc`.
|
||||||
|
|
||||||
|
# Rely on the global preview — don't re-wrap
|
||||||
|
|
||||||
|
`.storybook/preview.js` already provides:
|
||||||
|
|
||||||
|
- `MessagesProvider` with `messages/en` → access copy via `useMessages()`
|
||||||
|
inside stories exactly like app code. Never hard-code user-facing strings.
|
||||||
|
- `app/globals.css` + `.font-inter` wrapper → design tokens and fonts are
|
||||||
|
already present.
|
||||||
|
|
||||||
|
Do **not** add your own `MessagesProvider`, font wrapper, or token setup in a
|
||||||
|
story. If you need a new global, update `preview.js`.
|
||||||
|
|
||||||
|
# Interaction tests (`play`)
|
||||||
|
|
||||||
|
Use `storybook/test` for interaction assertions — not `@testing-library/*`
|
||||||
|
directly. This matches `Checkbox.stories.js` and stays compatible with the
|
||||||
|
Vitest portable-stories runner in `.storybook/vitest.setup.js`.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { within, userEvent, expect } from "storybook/test";
|
||||||
|
|
||||||
|
export const Interactive = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await userEvent.click(canvas.getByRole("checkbox"));
|
||||||
|
expect(canvas.getByRole("checkbox")).toHaveAttribute("aria-checked", "true");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
# Coverage expectation
|
||||||
|
|
||||||
|
Every new component in `app/components/**` ships with a story. Screens in
|
||||||
|
`app/(app)/create/screens/**` ship with a `stories/pages/<Name>Page.stories.js`
|
||||||
|
entry. A new component without a story is considered incomplete.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
description: Tailwind-first styling for all React components
|
||||||
|
globs: app/**/*.{ts,tsx},stories/**/*.{ts,tsx}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tailwind-first styling
|
||||||
|
|
||||||
|
Tailwind v4 is the default styling layer. Reach for utility classes + design
|
||||||
|
tokens **before** anything else.
|
||||||
|
|
||||||
|
## Priority
|
||||||
|
|
||||||
|
1. **Tailwind utilities** — `className="flex items-center gap-4 p-6 rounded-lg"`.
|
||||||
|
Use arbitrary values (`w-[200px]`) and responsive variants (`sm:`, `lg:`)
|
||||||
|
as needed. Design-token CSS variables go in arbitrary values:
|
||||||
|
`bg-[var(--color-surface-default-primary)]`.
|
||||||
|
2. **`style` prop** — only for values that truly change at runtime
|
||||||
|
(`style={{ width: `${dynamicPx}px` }}`).
|
||||||
|
3. **Custom / global CSS** — last resort. Justified for keyframes, third-party
|
||||||
|
overrides, dynamic-count CSS Grid, and similar cases Tailwind can't express.
|
||||||
|
4. **CSS-in-JS / CSS Modules** — don't introduce.
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ Opaque class names bypass the design system
|
||||||
|
<div className="custom-container">
|
||||||
|
<span className="custom-text">Hello</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// ❌ Inline style for a static value
|
||||||
|
<div style={{ padding: 16, borderRadius: 8 }}>…</div>
|
||||||
|
|
||||||
|
// ✅ Tailwind + token
|
||||||
|
<div className="p-4 rounded-lg bg-[var(--color-surface-default-primary)]">…</div>
|
||||||
|
```
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
description: Test file layout & shared harnesses (vitest + Playwright)
|
||||||
|
globs: tests/**/*.{ts,tsx,js,jsx}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Testing conventions
|
||||||
|
|
||||||
|
## Runner split
|
||||||
|
|
||||||
|
- **Vitest** for unit, component, and page-level tests (`tests/components`,
|
||||||
|
`tests/pages`, `tests/unit`, `tests/contexts`, `tests/accessibility`).
|
||||||
|
Run via `npm test` or `npx vitest run`.
|
||||||
|
- **Playwright** for browser e2e and visual regression (`tests/e2e`).
|
||||||
|
Run via `npm run e2e`. Never put Playwright specs outside `tests/e2e/`.
|
||||||
|
|
||||||
|
## File layout
|
||||||
|
|
||||||
|
| Path | Use |
|
||||||
|
| --- | --- |
|
||||||
|
| `tests/components/<Name>.test.tsx` | Design-system component tests. |
|
||||||
|
| `tests/pages/<step>.test.jsx` | Page / screen integration tests. |
|
||||||
|
| `tests/unit/<fn>.test.{ts,js}` | Pure logic — utilities, reducers, hooks without DOM. |
|
||||||
|
| `tests/contexts/<Ctx>.test.tsx` | Context provider tests. |
|
||||||
|
| `tests/accessibility/` | `jest-axe` suites (unit) and `wcag-compliance.spec.ts` (e2e). |
|
||||||
|
| `tests/e2e/` | Playwright specs (user journeys, visual, performance). |
|
||||||
|
|
||||||
|
## Providers — always use `renderWithProviders`
|
||||||
|
|
||||||
|
`render` from `@testing-library/react` **skips** Messages/AuthModal/CreateFlow
|
||||||
|
providers. Import the wrapped version instead:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
renderWithProviders as render,
|
||||||
|
screen,
|
||||||
|
} from "../utils/test-utils";
|
||||||
|
```
|
||||||
|
|
||||||
|
Raw `render` is only acceptable for pure-presentational components that read
|
||||||
|
none of those contexts.
|
||||||
|
|
||||||
|
## DS component suites
|
||||||
|
|
||||||
|
Reuse `componentTestSuite` for standard DS coverage (renders,
|
||||||
|
`jest-axe` a11y, keyboard navigation, disabled/error states) instead of
|
||||||
|
rewriting each check per component:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
componentTestSuite,
|
||||||
|
type ComponentTestSuiteConfig,
|
||||||
|
} from "../utils/componentTestSuite";
|
||||||
|
|
||||||
|
const config: ComponentTestSuiteConfig<Props> = {
|
||||||
|
component: MyComponent,
|
||||||
|
name: "MyComponent",
|
||||||
|
props: baseProps,
|
||||||
|
primaryRole: "button",
|
||||||
|
testCases: { renders: true, accessibility: true, keyboardNavigation: true },
|
||||||
|
};
|
||||||
|
componentTestSuite(config);
|
||||||
|
```
|
||||||
|
|
||||||
|
Custom interaction tests live alongside the suite in the same file.
|
||||||
|
|
||||||
|
## Required imports
|
||||||
|
|
||||||
|
- `import "@testing-library/jest-dom/vitest";` — required for matcher types
|
||||||
|
(`toBeInTheDocument`, `toHaveAttribute`, etc.).
|
||||||
|
- `afterEach(() => cleanup())` in page-level test files where multiple
|
||||||
|
`render` calls run sequentially.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
coverage
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
storybook-static
|
||||||
|
.runner
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Copy to `.env` for local development (never commit real secrets).
|
||||||
|
|
||||||
|
# PostgreSQL — use `docker compose up -d postgres` and match user/db/password.
|
||||||
|
CLOUDRON_POSTGRESQL_URL="postgresql://communityrule:communityrule@localhost:5432/communityrule"
|
||||||
|
|
||||||
|
# Session signing + secret used when hashing magic-link tokens. Min 16 characters; use a long random string in production.
|
||||||
|
SESSION_SECRET="dev-only-change-me-16chars-min"
|
||||||
|
|
||||||
|
# Optional Mailhog (docker compose mailhog service):
|
||||||
|
# CLOUDRON_MAIL_SMTP_SERVER=localhost
|
||||||
|
# CLOUDRON_MAIL_SMTP_PORT=1025
|
||||||
|
# CLOUDRON_MAIL_SMTP_USERNAME=
|
||||||
|
# CLOUDRON_MAIL_SMTP_PASSWORD=
|
||||||
|
|
||||||
|
# Leave mail vars unset in dev to log the magic-link verify URL to the server console instead of sending email.
|
||||||
|
SMTP_FROM="Community Rule <noreply@localhost>"
|
||||||
|
|
||||||
|
# CR-107: inbox for Ask an organizer form submissions (requires CLOUDRON_MAIL_SMTP_* in production).
|
||||||
|
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).
|
||||||
|
# `local` = file-based aggregates under `.next/web-vitals` (default in development). Omit to use defaults.
|
||||||
|
# WEB_VITALS_STORAGE=external
|
||||||
|
|
||||||
|
# Optional: URL shown on /monitor when using external storage (Grafana, Kibana, vendor RUM, etc.).
|
||||||
|
# NEXT_PUBLIC_RUM_DASHBOARD_URL=
|
||||||
|
|
||||||
|
# Writable directory for `POST /api/uploads` (community photo + custom-method attachments).
|
||||||
|
# In production (e.g. Cloudron localstorage mount), set to the mounted path. Local dev example:
|
||||||
|
# UPLOAD_ROOT="/absolute/path/to/community-rule/var/uploads"
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
name: CI Pipeline
|
|
||||||
run-name: ${{ gitea.actor }} triggered CI pipeline
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch: {}
|
|
||||||
push:
|
|
||||||
branches: [main, develop, "fix-runner-trigger"]
|
|
||||||
pull_request:
|
|
||||||
branches: [main, develop]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: [self-hosted, macos-latest]
|
|
||||||
strategy:
|
|
||||||
matrix: { node-version: [18, 20] }
|
|
||||||
env:
|
|
||||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ matrix.node-version }}
|
|
||||||
cache: npm
|
|
||||||
- run: npm ci
|
|
||||||
- run: npm test
|
|
||||||
|
|
||||||
# If the Codecov Action fails on Gitea, replace this with the bash uploader below
|
|
||||||
- name: Upload coverage to Codecov
|
|
||||||
uses: codecov/codecov-action@v3
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
files: ./coverage/lcov.info
|
|
||||||
flags: unittests
|
|
||||||
|
|
||||||
# Bash uploader alternative (uncomment if the action above has issues)
|
|
||||||
# - name: Upload coverage to Codecov (bash)
|
|
||||||
# run: |
|
|
||||||
# curl -s https://codecov.io/bash > codecov.sh
|
|
||||||
# bash codecov.sh -t "${{ secrets.CODECOV_TOKEN }}" -f coverage/lcov.info -F unittests
|
|
||||||
|
|
||||||
e2e:
|
|
||||||
runs-on: [self-hosted, macos-latest]
|
|
||||||
strategy:
|
|
||||||
matrix: { browser: [chromium, firefox, webkit] }
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with: { node-version: 20, cache: npm }
|
|
||||||
- run: npm ci
|
|
||||||
- run: npx playwright install --with-deps ${{ matrix.browser }}
|
|
||||||
- run: npm run build
|
|
||||||
|
|
||||||
# start app, wait, run tests
|
|
||||||
- run: npm run preview &
|
|
||||||
env: { CI: true }
|
|
||||||
- run: npx wait-on http://localhost:3000
|
|
||||||
- run: npx playwright test --project=${{ matrix.browser }}
|
|
||||||
env: { CI: true }
|
|
||||||
|
|
||||||
# package artifacts (keeps file count small)
|
|
||||||
- name: Package E2E artifacts
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
tar -czf playwright-${{ matrix.browser }}.tgz playwright-report test-results || true
|
|
||||||
|
|
||||||
- name: Upload E2E artifacts
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: playwright-results-${{ matrix.browser }}
|
|
||||||
path: playwright-${{ matrix.browser }}.tgz
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
visual-regression:
|
|
||||||
runs-on: [self-hosted, macos-latest]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with: { node-version: 20, cache: npm }
|
|
||||||
- run: npm ci
|
|
||||||
- run: npx playwright install --with-deps
|
|
||||||
- run: npm run build
|
|
||||||
- run: npm run preview &
|
|
||||||
env: { CI: true }
|
|
||||||
- run: npx wait-on http://localhost:3000
|
|
||||||
|
|
||||||
# Seed snapshots on main branch only (one-time setup)
|
|
||||||
- name: Seed snapshots (main only)
|
|
||||||
if: github.ref == 'refs/heads/main'
|
|
||||||
run: PLAYWRIGHT_UPDATE_SNAPSHOTS=1 npx playwright test tests/e2e/visual-regression.spec.ts --project=chromium
|
|
||||||
env: { CI: true }
|
|
||||||
|
|
||||||
# Run visual regression tests
|
|
||||||
- name: Run visual regression tests
|
|
||||||
run: npx playwright test tests/e2e/visual-regression.spec.ts
|
|
||||||
env: { CI: true }
|
|
||||||
|
|
||||||
- name: Package visual artifacts
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
tar -czf visual-regression.tgz test-results tests/e2e/visual-regression.spec.ts-snapshots || true
|
|
||||||
|
|
||||||
- name: Upload visual artifacts
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: visual-regression-results
|
|
||||||
path: visual-regression.tgz
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
performance:
|
|
||||||
runs-on: [self-hosted, macos-latest]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with: { node-version: 20, cache: npm }
|
|
||||||
- run: npm ci
|
|
||||||
|
|
||||||
- name: Install LHCI
|
|
||||||
run: npm i -D @lhci/cli
|
|
||||||
|
|
||||||
# Ensure a Chrome binary is available (works on Linux/macOS runners)
|
|
||||||
- name: Install Chrome via Puppeteer (portable)
|
|
||||||
run: |
|
|
||||||
npx @puppeteer/browsers install chrome@stable -P .cache/puppeteer
|
|
||||||
echo "CHROME_PATH=$(npx @puppeteer/browsers executable-path chrome@stable -P .cache/puppeteer)" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Build application
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Start application
|
|
||||||
run: npm run preview &
|
|
||||||
env: { CI: true }
|
|
||||||
|
|
||||||
- name: Wait for application
|
|
||||||
run: npx wait-on http://localhost:3000
|
|
||||||
|
|
||||||
- name: Run Lighthouse CI
|
|
||||||
run: npx lhci autorun --chrome-path="$CHROME_PATH"
|
|
||||||
env: { CI: true }
|
|
||||||
|
|
||||||
- name: Upload LHCI results
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: lhci-results
|
|
||||||
path: lhci-results
|
|
||||||
|
|
||||||
storybook:
|
|
||||||
runs-on: [self-hosted, macos-latest]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with: { node-version: 20, cache: npm }
|
|
||||||
- run: npm ci
|
|
||||||
- run: npm run storybook:build:github
|
|
||||||
- run: npm run test:sb
|
|
||||||
env: { CI: true }
|
|
||||||
|
|
||||||
lint:
|
|
||||||
runs-on: [self-hosted, macos-latest]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with: { node-version: 20, cache: npm }
|
|
||||||
- run: npm ci
|
|
||||||
- run: npm run lint
|
|
||||||
- run: npx prettier --check "**/*.{js,jsx,ts,tsx,json,css,md}"
|
|
||||||
|
|
||||||
build:
|
|
||||||
runs-on: [self-hosted, macos-latest]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with: { node-version: 20, cache: npm }
|
|
||||||
- run: npm ci
|
|
||||||
- run: npm run build
|
|
||||||
- run: npm run storybook:build:github
|
|
||||||
+38
-8
@@ -10,16 +10,25 @@
|
|||||||
!.yarn/releases
|
!.yarn/releases
|
||||||
!.yarn/versions
|
!.yarn/versions
|
||||||
|
|
||||||
|
# npm cache (should never be committed)
|
||||||
|
.npm-cache/
|
||||||
|
npm-cache/
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
|
# Local user uploads (see UPLOAD_ROOT in .env.example)
|
||||||
|
/var/uploads
|
||||||
|
|
||||||
# Playwright
|
# Playwright
|
||||||
/test-results/
|
/test-results/
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
# Visual regression snapshots (allow these)
|
|
||||||
!tests/e2e/visual-regression.spec.ts-snapshots/
|
# Lighthouse CI results
|
||||||
!tests/e2e/visual-regression.spec.ts-snapshots/*.png
|
/lhci-results/
|
||||||
# Ignore other image files
|
/.lighthouseci/
|
||||||
|
|
||||||
|
# Ignore other image files (but not visual regression snapshots)
|
||||||
*.png
|
*.png
|
||||||
*.jpg
|
*.jpg
|
||||||
*.jpeg
|
*.jpeg
|
||||||
@@ -30,6 +39,10 @@
|
|||||||
*.avi
|
*.avi
|
||||||
*.mkv
|
*.mkv
|
||||||
|
|
||||||
|
# Visual regression snapshots (allow these)
|
||||||
|
!tests/e2e/visual-regression.spec.ts-snapshots/
|
||||||
|
!tests/e2e/visual-regression.spec.ts-snapshots/*.png
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
/out/
|
/out/
|
||||||
@@ -38,9 +51,16 @@
|
|||||||
/build
|
/build
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
/tmp/
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
|
# IDE and editor files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
@@ -49,17 +69,27 @@ yarn-error.log*
|
|||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
*storybook.log
|
*storybook.log
|
||||||
storybook-static
|
storybook-static
|
||||||
|
|
||||||
# storybook config files (to avoid git changes when switching between local and production)
|
# Gitea runner runtime files
|
||||||
.storybook/main.js
|
.runner
|
||||||
.storybook/preview.js
|
.runner.pid
|
||||||
|
act_runner
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
Thumbs.db
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Cursor rules (local development)
|
||||||
|
.cursorrules
|
||||||
|
|||||||
+82
-7
@@ -1,20 +1,95 @@
|
|||||||
{
|
{
|
||||||
"ci": {
|
"ci": {
|
||||||
"collect": {
|
"collect": {
|
||||||
"url": ["http://localhost:3000/"],
|
"url": [
|
||||||
"numberOfRuns": 3
|
"http://127.0.0.1:3010/",
|
||||||
|
"http://127.0.0.1:3010/blog",
|
||||||
|
"http://127.0.0.1:3010/blog/resolving-active-conflicts"
|
||||||
|
],
|
||||||
|
"numberOfRuns": 3,
|
||||||
|
"settings": {
|
||||||
|
"preset": "desktop",
|
||||||
|
"throttling": {
|
||||||
|
"rttMs": 40,
|
||||||
|
"throughputKbps": 10240,
|
||||||
|
"cpuSlowdownMultiplier": 1,
|
||||||
|
"requestLatencyMs": 0,
|
||||||
|
"downloadThroughputKbps": 0,
|
||||||
|
"uploadThroughputKbps": 0
|
||||||
|
},
|
||||||
|
"chromeFlags": [
|
||||||
|
"--disable-web-security",
|
||||||
|
"--disable-features=VizDisplayCompositor",
|
||||||
|
"--ignore-certificate-errors",
|
||||||
|
"--ignore-ssl-errors",
|
||||||
|
"--ignore-certificate-errors-spki-list",
|
||||||
|
"--allow-running-insecure-content",
|
||||||
|
"--disable-extensions",
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-setuid-sandbox",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--disable-gpu",
|
||||||
|
"--headless"
|
||||||
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"assert": {
|
"assert": {
|
||||||
"assertions": {
|
"assertions": {
|
||||||
"categories:performance": ["warn", { "minScore": 0.9 }],
|
"categories:performance": ["error", { "minScore": 0.9 }],
|
||||||
"categories:accessibility": ["error", { "minScore": 0.9 }],
|
"categories:accessibility": ["warn", { "minScore": 0.95 }],
|
||||||
|
"categories:best-practices": ["warn", { "minScore": 0.9 }],
|
||||||
|
"categories:seo": ["warn", { "minScore": 0.9 }],
|
||||||
"first-contentful-paint": ["warn", { "maxNumericValue": 2000 }],
|
"first-contentful-paint": ["warn", { "maxNumericValue": 2000 }],
|
||||||
"interactive": ["warn", { "maxNumericValue": 4000 }]
|
"largest-contentful-paint": ["warn", { "maxNumericValue": 2500 }],
|
||||||
|
"first-meaningful-paint": ["warn", { "maxNumericValue": 2000 }],
|
||||||
|
"speed-index": ["warn", { "maxNumericValue": 3000 }],
|
||||||
|
"interactive": ["warn", { "maxNumericValue": 3000 }],
|
||||||
|
"total-blocking-time": ["warn", { "maxNumericValue": 300 }],
|
||||||
|
"cumulative-layout-shift": ["warn", { "maxNumericValue": 0.1 }],
|
||||||
|
"max-potential-fid": ["warn", { "maxNumericValue": 130 }],
|
||||||
|
"server-response-time": ["warn", { "maxNumericValue": 600 }],
|
||||||
|
"render-blocking-resources": ["warn", { "maxLength": 0 }],
|
||||||
|
"unused-css-rules": ["warn", { "maxLength": 0 }],
|
||||||
|
"unused-javascript": ["warn", { "maxLength": 0 }],
|
||||||
|
"modern-image-formats": ["warn", { "maxLength": 0 }],
|
||||||
|
"uses-optimized-images": ["warn", { "maxLength": 0 }],
|
||||||
|
"uses-text-compression": ["warn", { "maxLength": 0 }],
|
||||||
|
"uses-responsive-images": ["warn", { "maxLength": 0 }],
|
||||||
|
"efficient-animated-content": ["warn", { "maxLength": 0 }],
|
||||||
|
"preload-lcp-image": ["warn", { "maxLength": 0 }],
|
||||||
|
"total-byte-weight": ["warn", { "maxNumericValue": 500000 }],
|
||||||
|
"uses-long-cache-ttl": ["warn", { "maxLength": 0 }],
|
||||||
|
"dom-size": ["warn", { "maxNumericValue": 1500 }],
|
||||||
|
"critical-request-chains": ["warn", { "maxLength": 0 }],
|
||||||
|
"user-timings": ["warn", { "maxLength": 0 }],
|
||||||
|
"bootup-time": ["warn", { "maxNumericValue": 1000 }],
|
||||||
|
"mainthread-work-breakdown": ["warn", { "maxLength": 0 }],
|
||||||
|
"font-display": ["warn", { "maxLength": 0 }],
|
||||||
|
"resource-summary": ["warn", { "maxLength": 0 }],
|
||||||
|
"third-party-summary": ["warn", { "maxLength": 0 }],
|
||||||
|
"largest-contentful-paint-element": ["warn", { "maxLength": 0 }],
|
||||||
|
"layout-shift-elements": ["warn", { "maxLength": 0 }],
|
||||||
|
"long-tasks": ["warn", { "maxLength": 0 }],
|
||||||
|
"non-composited-animations": ["warn", { "maxLength": 0 }],
|
||||||
|
"unsized-images": ["warn", { "maxLength": 0 }]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"target": "filesystem",
|
"target": "temporary-public-storage",
|
||||||
"outputDir": "lhci-results"
|
"outputDir": "./lighthouse-results"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"onlyCategories": ["performance", "accessibility", "best-practices", "seo"],
|
||||||
|
"skipAudits": ["uses-http2"],
|
||||||
|
"formFactor": "desktop",
|
||||||
|
"throttling": {
|
||||||
|
"rttMs": 40,
|
||||||
|
"throughputKbps": 10240,
|
||||||
|
"cpuSlowdownMultiplier": 1,
|
||||||
|
"requestLatencyMs": 0,
|
||||||
|
"downloadThroughputKbps": 0,
|
||||||
|
"uploadThroughputKbps": 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Test/build artifacts
|
||||||
|
coverage/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
lhci-results/
|
||||||
|
.lighthouseci/
|
||||||
|
|
||||||
|
# Storybook build output
|
||||||
|
storybook-static/
|
||||||
|
|
||||||
|
# Misc generated
|
||||||
|
*.log
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"WARNING": "This file is automatically generated by act-runner. Do not edit it manually unless you know what you are doing. Removing this file will cause act runner to re-register as a new runner.",
|
|
||||||
"id": 7,
|
|
||||||
"uuid": "3a8b9f5a-189b-47a3-b0b1-1a06c933cc06",
|
|
||||||
"name": "community-rule-runner-1",
|
|
||||||
"token": "23ecad1165282704291cfa5112a36aebfadfa40a",
|
|
||||||
"address": "https://git.medlab.host",
|
|
||||||
"labels": [
|
|
||||||
"macos-latest:host",
|
|
||||||
"self-hosted:host"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
39731
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--font-inter:
|
||||||
|
"Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
||||||
|
"Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
|
||||||
|
}
|
||||||
+20
-39
@@ -1,54 +1,35 @@
|
|||||||
/** @type { import('@storybook/nextjs-vite').StorybookConfig } */
|
/** @type { import('@storybook/nextjs').StorybookConfig } */
|
||||||
const config = {
|
module.exports = {
|
||||||
stories: [
|
stories: [
|
||||||
"../stories/**/*.mdx",
|
"../stories/**/*.mdx",
|
||||||
"../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)",
|
"../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)",
|
||||||
],
|
],
|
||||||
addons: [
|
addons: [
|
||||||
"@storybook/addon-actions",
|
// Removed @storybook/addon-essentials due to version mismatch with Storybook 10.x
|
||||||
|
// Using individual addons instead. Interaction helpers import from storybook/test
|
||||||
|
// (bundled with storybook@10); @storybook/addon-interactions was merged into SB 8 core.
|
||||||
"@storybook/addon-a11y",
|
"@storybook/addon-a11y",
|
||||||
"@storybook/addon-interactions",
|
|
||||||
"@chromatic-com/storybook",
|
|
||||||
],
|
],
|
||||||
framework: {
|
framework: {
|
||||||
name: "@storybook/nextjs-vite",
|
name: "@storybook/nextjs",
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
staticDirs: ["../public"],
|
staticDirs: ["../public"],
|
||||||
|
|
||||||
// Auto-detect environment and apply appropriate settings
|
// Webpack configuration to resolve Next.js modules for Next.js 16 compatibility
|
||||||
managerHead: (head) => {
|
async webpackFinal(config) {
|
||||||
// Only add base href for GitHub Pages (when CI=true or specific environment)
|
// Ensure Next.js modules are resolved correctly
|
||||||
if (process.env.CI || process.env.STORYBOOK_BASE_PATH) {
|
config.resolve = config.resolve || {};
|
||||||
return `${head}<base href="/communityrulestorybook/">`;
|
config.resolve.alias = {
|
||||||
}
|
...(config.resolve.alias || {}),
|
||||||
return head;
|
|
||||||
},
|
|
||||||
|
|
||||||
previewHead: (head) => {
|
|
||||||
// Only add base href for GitHub Pages
|
|
||||||
if (process.env.CI || process.env.STORYBOOK_BASE_PATH) {
|
|
||||||
return `${head}<base href="/communityrulestorybook/">`;
|
|
||||||
}
|
|
||||||
return head;
|
|
||||||
},
|
|
||||||
|
|
||||||
async viteFinal(cfg) {
|
|
||||||
// Set base path for GitHub Pages when needed
|
|
||||||
if (process.env.CI || process.env.STORYBOOK_BASE_PATH) {
|
|
||||||
cfg.base = "/communityrulestorybook/";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure esbuild treats .js as JSX during dep pre-bundling
|
|
||||||
cfg.optimizeDeps ??= {};
|
|
||||||
cfg.optimizeDeps.esbuildOptions ??= {};
|
|
||||||
cfg.optimizeDeps.esbuildOptions.loader = {
|
|
||||||
...(cfg.optimizeDeps.esbuildOptions.loader || {}),
|
|
||||||
".js": "jsx",
|
|
||||||
".ts": "tsx",
|
|
||||||
};
|
};
|
||||||
return cfg;
|
|
||||||
|
// Ensure node_modules are resolved
|
||||||
|
config.resolve.modules = [
|
||||||
|
...(config.resolve.modules || []),
|
||||||
|
"node_modules",
|
||||||
|
];
|
||||||
|
|
||||||
|
return config;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
|
||||||
|
|||||||
+8
-30
@@ -1,33 +1,11 @@
|
|||||||
import "../app/globals.css";
|
import "../app/globals.css";
|
||||||
|
import "./fonts.css";
|
||||||
// Import Google Fonts for Storybook
|
import { MessagesProvider } from "../app/contexts/MessagesContext";
|
||||||
import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google";
|
import messages from "../messages/en/index";
|
||||||
|
|
||||||
const inter = Inter({
|
|
||||||
subsets: ["latin"],
|
|
||||||
weight: ["400", "500", "600", "700"],
|
|
||||||
variable: "--font-inter",
|
|
||||||
display: "swap",
|
|
||||||
});
|
|
||||||
|
|
||||||
const bricolageGrotesque = Bricolage_Grotesque({
|
|
||||||
subsets: ["latin"],
|
|
||||||
weight: ["400", "500", "700", "800"],
|
|
||||||
variable: "--font-bricolage-grotesque",
|
|
||||||
display: "swap",
|
|
||||||
});
|
|
||||||
|
|
||||||
const spaceGrotesk = Space_Grotesk({
|
|
||||||
subsets: ["latin"],
|
|
||||||
weight: ["400", "500", "700"],
|
|
||||||
variable: "--font-space-grotesk",
|
|
||||||
display: "swap",
|
|
||||||
});
|
|
||||||
|
|
||||||
/** @type { import('@storybook/react').Preview } */
|
/** @type { import('@storybook/react').Preview } */
|
||||||
const preview = {
|
const preview = {
|
||||||
parameters: {
|
parameters: {
|
||||||
actions: { argTypesRegex: "^on[A-Z].*" },
|
|
||||||
controls: {
|
controls: {
|
||||||
matchers: {
|
matchers: {
|
||||||
color: /(background|color)$/i,
|
color: /(background|color)$/i,
|
||||||
@@ -37,11 +15,11 @@ const preview = {
|
|||||||
},
|
},
|
||||||
decorators: [
|
decorators: [
|
||||||
(Story) => (
|
(Story) => (
|
||||||
<div
|
<MessagesProvider messages={messages}>
|
||||||
className={`${inter.variable} ${bricolageGrotesque.variable} ${spaceGrotesk.variable} font-sans`}
|
<div className="font-inter">
|
||||||
>
|
<Story />
|
||||||
<Story />
|
</div>
|
||||||
</div>
|
</MessagesProvider>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
|
import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
|
||||||
import { setProjectAnnotations } from '@storybook/nextjs-vite';
|
import { setProjectAnnotations } from "@storybook/nextjs-vite";
|
||||||
import * as projectAnnotations from './preview';
|
import * as projectAnnotations from "./preview";
|
||||||
|
|
||||||
// This is an important step to apply the right configuration when testing your stories.
|
// This is an important step to apply the right configuration when testing your stories.
|
||||||
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
|
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
|
||||||
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
|
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Agent guide
|
||||||
|
|
||||||
|
Orientation for AI coding agents working in this repo. Per-file conventions
|
||||||
|
live in `.cursor/rules/*.mdc` (auto-loaded by Cursor; other agents should
|
||||||
|
read them on demand). This file is the **map** — load it first, then load
|
||||||
|
the rule(s) matching the file you're editing.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
Next.js 16 / React 19 app for community decision-making and governance.
|
||||||
|
Single-locale (English) today; designed for i18n via `messages/`.
|
||||||
|
|
||||||
|
## Read before editing
|
||||||
|
|
||||||
|
| If you're touching… | Load this rule |
|
||||||
|
| --- | --- |
|
||||||
|
| `app/components/**` | `component-structure.mdc`, `component-props.mdc`, `tailwind-styling.mdc` |
|
||||||
|
| `Alert`, or user-visible notifications / shell errors / success banners | `alerts.mdc` (and `localization.mdc` for copy) |
|
||||||
|
| `app/(app)/create/**` | `create-flow.mdc` (+ component rules) |
|
||||||
|
| `app/api/**` | `api-routes.mdc` |
|
||||||
|
| `app/hooks/**` | `hooks.mdc` |
|
||||||
|
| `app/**/page.tsx` or `app/**/layout.tsx` | `routes.mdc` |
|
||||||
|
| `messages/**` or any user-visible string | `localization.mdc` |
|
||||||
|
| `tests/**` | `testing.mdc` |
|
||||||
|
| `stories/**` | `storybook.mdc` |
|
||||||
|
|
||||||
|
When in doubt about file structure or naming, the rules win over your
|
||||||
|
priors — they reflect deliberate decisions.
|
||||||
|
|
||||||
|
## Cross-cutting principles (no single rule owns these)
|
||||||
|
|
||||||
|
1. **Figma is the source of truth for design.** Container files carry a
|
||||||
|
`Figma: "<Path>" (<node-id>)` docstring; views render Figma intent.
|
||||||
|
Codebase naming uses lowercase conventions (see `component-props.mdc`)
|
||||||
|
even when Figma uses PascalCase enum values.
|
||||||
|
2. **Container / view split is the component pattern.** Never put state
|
||||||
|
or side effects in a `*.view.tsx`. Hooks belong in containers.
|
||||||
|
3. **All user-visible text lives in `messages/`.** Hardcoded strings in
|
||||||
|
components are a bug — even for placeholders.
|
||||||
|
4. **Tests live in `tests/`, not co-located.** Mirror the source path
|
||||||
|
(`app/components/Foo` → `tests/components/Foo.test.tsx`).
|
||||||
|
5. **Routes live inside groups** — `(marketing)`, `(app)`, `(admin)`,
|
||||||
|
`(dev)`. Don't drop a new route folder loose at the top of `app/`.
|
||||||
|
**Admin-only widgets** may live in **`app/(admin)/<route>/_components/`**
|
||||||
|
when only that route uses them (e.g. **`WebVitalsDashboard`** on **`/monitor`**).
|
||||||
|
6. **No new pathname-sniffing chrome.** Compose chrome via group/nested
|
||||||
|
layouts, not `usePathname()` checks. (`ConditionalNavigation` is the
|
||||||
|
sole tolerated exception — it carries SSR session state.)
|
||||||
|
|
||||||
|
## Legacy / scaffolding
|
||||||
|
|
||||||
|
Some code exists temporarily while backend services are stood up:
|
||||||
|
|
||||||
|
- `NEXT_PUBLIC_ENABLE_BACKEND_SYNC` gating
|
||||||
|
- `migrateLegacyCreateFlowState`, `LEGACY_LIVE_KEY`, `LEGACY_DRAFT_KEY`
|
||||||
|
- `/create/right-rail` redirect
|
||||||
|
- `docs/guides/backend-roadmap.md`, `backend-linear-tickets.md`,
|
||||||
|
`template-recommendation-matrix.md`
|
||||||
|
|
||||||
|
**Do not delete** without an explicit ask. Do not add new code in this
|
||||||
|
shape — when adding scaffolding, leave a `// TODO(legacy): …` with the
|
||||||
|
removal trigger.
|
||||||
|
|
||||||
|
## Verification recipe
|
||||||
|
|
||||||
|
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
|
||||||
|
npm run knip # unused files / exports (local; no remote CI)
|
||||||
|
npx vitest run # unit + component (~185 test files)
|
||||||
|
npx next build # production build + route manifest
|
||||||
|
```
|
||||||
|
|
||||||
|
For UI-only changes, also: `npm run storybook` and visually confirm.
|
||||||
|
For E2E-relevant changes: `npm run e2e`.
|
||||||
|
For changes under `prisma/`: `npm run migrate:smoke` (see
|
||||||
|
[docs/testing-guide.md](docs/testing-guide.md) § *Running tests*).
|
||||||
|
|
||||||
|
## Where else to look
|
||||||
|
|
||||||
|
- [README.md](README.md) — human onboarding, scripts, project layout.
|
||||||
|
- [CONTRIBUTING.md](CONTRIBUTING.md) — local Postgres + Prisma + magic-link
|
||||||
|
setup, PR workflow.
|
||||||
|
- [docs/README.md](docs/README.md) — index of user-facing docs.
|
||||||
|
- [docs/create-flow.md](docs/create-flow.md) — wizard URL/persistence canon
|
||||||
|
(read alongside `create-flow.mdc`).
|
||||||
|
- [docs/figma-component-registry.md](docs/figma-component-registry.md) —
|
||||||
|
Figma ↔ component bucket map after refactors (Type, Sections, admin
|
||||||
|
`_components/`, etc.).
|
||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Thanks for working on Community Rule. This file covers local setup, the
|
||||||
|
API surface, and the pull-request workflow. Per-file implementation
|
||||||
|
conventions live in [`.cursor/rules/`](.cursor/rules/) (auto-loaded by
|
||||||
|
Cursor); high-level orientation is in [`AGENTS.md`](AGENTS.md).
|
||||||
|
|
||||||
|
## Local setup
|
||||||
|
|
||||||
|
Prerequisites: Node **20+**, npm **10+**, Docker.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # set SESSION_SECRET (≥16 chars)
|
||||||
|
docker compose up -d postgres mailhog # omit `mailhog` if you don't need
|
||||||
|
# a local inbox
|
||||||
|
npm ci
|
||||||
|
npx prisma migrate dev
|
||||||
|
npx prisma db seed # optional — seeds curated templates
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000). Use
|
||||||
|
`npx prisma studio` to browse the database.
|
||||||
|
|
||||||
|
Deploying to staging or production (MEDLab Cloudron at `my.medlab.host`)
|
||||||
|
is documented in
|
||||||
|
[`docs/guides/ops-backend-deploy.md`](docs/guides/ops-backend-deploy.md).
|
||||||
|
|
||||||
|
### Magic-link sign-in
|
||||||
|
|
||||||
|
1. Go to [/login](http://localhost:3000/login) or click **Log in** in
|
||||||
|
the site header.
|
||||||
|
2. Submit your email.
|
||||||
|
3. Open the verify link in the **same browser** (the session cookie is
|
||||||
|
bound to that origin):
|
||||||
|
- **Without SMTP:** copy the URL from the dev-server log.
|
||||||
|
- **With Mailhog:** open the message at
|
||||||
|
[http://localhost:8025](http://localhost:8025).
|
||||||
|
|
||||||
|
### Prisma migrations
|
||||||
|
|
||||||
|
- **Never edit a migration** that has already been applied to staging,
|
||||||
|
production, or any shared database — add a new migration instead.
|
||||||
|
Full policy: [`docs/guides/backend-roadmap.md`](docs/guides/backend-roadmap.md) §8.
|
||||||
|
- **After any change under `prisma/`**, run `npm run migrate:smoke`
|
||||||
|
(Docker required). A throwaway Postgres on `127.0.0.1:5433` verifies
|
||||||
|
the migration applies cleanly. See
|
||||||
|
[`docs/testing-guide.md`](docs/testing-guide.md) → *Running tests*.
|
||||||
|
|
||||||
|
### Draft persistence
|
||||||
|
|
||||||
|
Signed-in create-flow drafts sync to Postgres via `PUT /api/drafts/me`
|
||||||
|
by default; anonymous progress stays in `localStorage`. Set
|
||||||
|
`NEXT_PUBLIC_ENABLE_BACKEND_SYNC=false` to disable server sync.
|
||||||
|
|
||||||
|
### Create flow
|
||||||
|
|
||||||
|
The custom wizard lives under `/create/…`. Step order, URLs, and Figma
|
||||||
|
stage mapping are canon in
|
||||||
|
[`docs/create-flow.md`](docs/create-flow.md); component conventions are
|
||||||
|
in `.cursor/rules/create-flow.mdc`.
|
||||||
|
|
||||||
|
## API routes
|
||||||
|
|
||||||
|
All routes return JSON. Non-`GET` requests expect
|
||||||
|
`Content-Type: application/json` unless noted (uploads are multipart).
|
||||||
|
|
||||||
|
### Auth & account
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/health` | Liveness + DB connectivity. |
|
||||||
|
| GET | `/api/auth/session` | Current user or `null`. |
|
||||||
|
| POST | `/api/auth/magic-link/request` | Send sign-in link. |
|
||||||
|
| GET | `/api/auth/magic-link/verify` | Validate token, set cookie, redirect. |
|
||||||
|
| POST | `/api/auth/logout` | Clear session. |
|
||||||
|
| DELETE | `/api/user/me` | Delete authenticated account. |
|
||||||
|
| POST | `/api/user/email-change/request` | Send verify link to new address. |
|
||||||
|
| GET | `/api/user/email-change/verify` | Apply email change. |
|
||||||
|
|
||||||
|
### Drafts & uploads
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET, PUT | `/api/drafts/me` | Load / save the signed-in create-flow draft. |
|
||||||
|
| POST | `/api/uploads` | Multipart upload (requires `UPLOAD_ROOT`). |
|
||||||
|
| GET | `/api/uploads/[id]` | Stream a previously uploaded file (public). |
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET, POST | `/api/rules` | List or publish rules. |
|
||||||
|
| GET | `/api/rules/me` | Owner's published rules. |
|
||||||
|
| GET, PATCH, DELETE | `/api/rules/[id]` | Public read; owner update / delete. |
|
||||||
|
| POST | `/api/rules/[id]/duplicate` | Owner clone. |
|
||||||
|
| GET, POST | `/api/rules/[id]/stakeholders` | List / invite stakeholders. |
|
||||||
|
| DELETE | `/api/rules/[id]/stakeholders/[stakeholderId]` | Remove stakeholder. |
|
||||||
|
| POST | `/api/rules/[id]/stakeholders/[stakeholderId]/resend` | Resend invite email. |
|
||||||
|
| GET | `/api/invites/rule-stakeholder/verify` | Verify stakeholder invite token. |
|
||||||
|
|
||||||
|
### Templates & create-flow catalog
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/templates` | List curated templates. Repeatable `facet.<group>=<value>` query params re-rank results. |
|
||||||
|
| GET | `/api/templates/[slug]` | Single template with normalized `{ section, slug }` composition. |
|
||||||
|
| GET | `/api/create-flow/methods` | Built-in governance methods / core values for the wizard. Required `section` query param. |
|
||||||
|
|
||||||
|
Facet semantics and the recommendation matrix:
|
||||||
|
[`docs/guides/template-recommendation-matrix.md`](docs/guides/template-recommendation-matrix.md)
|
||||||
|
§9.
|
||||||
|
|
||||||
|
### Misc
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| POST | `/api/organizer-inquiry` | "Ask an organizer" form submission. |
|
||||||
|
| POST | `/api/use-cases/[slug]/duplicate` | Duplicate a use-case demo rule. |
|
||||||
|
| GET, POST | `/api/web-vitals` | Read / ingest web vitals. Storage mode set by `WEB_VITALS_STORAGE` (`local` in dev, `external` in prod). |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The full testing recipe and philosophy live in
|
||||||
|
[`docs/testing-guide.md`](docs/testing-guide.md). Component conventions
|
||||||
|
and shared helpers are in `.cursor/rules/testing.mdc`.
|
||||||
|
|
||||||
|
A typical pre-merge subset:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc --noEmit
|
||||||
|
npm run knip
|
||||||
|
npm test
|
||||||
|
npx next build
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `npm run e2e` for routing, auth, or critical-flow changes, and
|
||||||
|
`npm run migrate:smoke` for anything under `prisma/`.
|
||||||
|
|
||||||
|
## Pull-request workflow
|
||||||
|
|
||||||
|
1. Branch from `main`: `git checkout -b feature/<short-name>`.
|
||||||
|
2. Make the change and add or update tests.
|
||||||
|
3. Run the relevant subset of the testing recipe above.
|
||||||
|
4. Commit using a conventional-commit prefix: `feat:`, `fix:`,
|
||||||
|
`chore:`, `docs:`, `refactor:`, `test:`.
|
||||||
|
5. Open a pull request; link the Linear ticket if there is one (e.g.
|
||||||
|
`CR-123`).
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"manifestVersion": 2,
|
||||||
|
"id": "com.medlab.communityrule",
|
||||||
|
"title": "Community Rule",
|
||||||
|
"author": "MEDLab",
|
||||||
|
"description": "Community governance and rule-building app",
|
||||||
|
"version": "0.1.8",
|
||||||
|
"httpPort": 3000,
|
||||||
|
"healthCheckPath": "/api/health",
|
||||||
|
"memoryLimit": 805306368,
|
||||||
|
"minBoxVersion": "9.0.0",
|
||||||
|
"addons": {
|
||||||
|
"postgresql": {},
|
||||||
|
"sendmail": {},
|
||||||
|
"localstorage": {}
|
||||||
|
},
|
||||||
|
"website": "https://communityrule.info",
|
||||||
|
"contactEmail": "hello@communityrule.info"
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
# Production image: Next.js standalone output + Prisma, packaged for Cloudron.
|
||||||
|
# Build / push: ./scripts/docker-release.sh
|
||||||
|
# Install: cloudron install (reads CloudronManifest.json from repo root)
|
||||||
|
# See docs/guides/ops-backend-deploy.md §9.
|
||||||
|
|
||||||
|
FROM node:20-bookworm-slim AS base
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
# Copy the Prisma schema so the project's `postinstall` (which runs
|
||||||
|
# `prisma generate`) succeeds during install.
|
||||||
|
COPY prisma ./prisma
|
||||||
|
# `npm install` rather than `npm ci`:
|
||||||
|
# 1. `npm ci` strictly validates the lockfile and refuses when sub-tree
|
||||||
|
# resolutions drift (a recurring nuisance because the lockfile is
|
||||||
|
# generated on darwin-arm64 by default).
|
||||||
|
# 2. `npm install` reuses the lockfile when it can but tolerates
|
||||||
|
# platform-specific reshuffles for Linux-only optional deps
|
||||||
|
# (`lightningcss-linux-*-gnu`, `@tailwindcss/oxide-linux-*-gnu`,
|
||||||
|
# `@next/swc-linux-*-gnu`, etc.) that Next.js needs at build time.
|
||||||
|
RUN npm install --no-audit --fund=false
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
# Standalone output omits TS sources the seed imports; bundle seed + JSON paths
|
||||||
|
# so `node prisma/seed.bundle.cjs` works in the slim runner (no tsx/lib/ tree).
|
||||||
|
RUN ./node_modules/.bin/esbuild prisma/seed.ts \
|
||||||
|
--bundle --platform=node --format=cjs \
|
||||||
|
--outfile=prisma/seed.bundle.cjs \
|
||||||
|
--external:@prisma/client
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
# openssl: Prisma engines. gosu: privilege drop in start.sh after chown.
|
||||||
|
RUN apt-get update -y && apt-get install -y openssl gosu && rm -rf /var/lib/apt/lists/*
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Reuse the `node` user (uid/gid 1000) shipped in node:20-bookworm-slim.
|
||||||
|
# Cloudron's localstorage addon mounts /app/data with root:root ownership at
|
||||||
|
# runtime; start.sh chowns it to node:node before dropping privileges.
|
||||||
|
|
||||||
|
COPY --from=builder --chown=node:node /app/public ./public
|
||||||
|
COPY --from=builder --chown=node:node /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder --chown=node:node /app/prisma ./prisma
|
||||||
|
# Facet/template seed JSON — NOT under /app/data (localstorage mount overlays that).
|
||||||
|
COPY --from=builder --chown=node:node /app/data ./seed-data
|
||||||
|
ENV SEED_DATA_DIR=/app/seed-data
|
||||||
|
|
||||||
|
# Prisma CLI (devDependency) is not in the Next.js standalone trace. Install
|
||||||
|
# globally in the runner so start.sh can run `prisma migrate deploy`.
|
||||||
|
RUN npm install -g prisma@6.19.3
|
||||||
|
|
||||||
|
# Cloudron's runtime rootfs is read-only except /tmp, /run, /app/data.
|
||||||
|
# Three marketing routes use ISR (`revalidate`) and write to .next/cache;
|
||||||
|
# redirect that path to /tmp/next-cache via a baked-in symlink so writes land
|
||||||
|
# on a writable mount at runtime.
|
||||||
|
RUN mkdir -p .next && ln -sfn /tmp/next-cache .next/cache
|
||||||
|
|
||||||
|
COPY --chown=node:node scripts/start.sh /start.sh
|
||||||
|
RUN chmod +x /start.sh
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
CMD ["/start.sh"]
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
CommunityRule
|
||||||
|
Copyright (C) 2020 Media Enterprise Design Lab
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
CommunityRule Copyright (C) 2020 Media Enterprise Design Lab
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||||
@@ -1,181 +1,93 @@
|
|||||||
# Community Rule
|
# Community Rule
|
||||||
|
|
||||||
A Next.js application for community decision-making and governance documentation.
|
A Next.js application for community decision-making and governance
|
||||||
|
documentation — author, browse, and share governance "rules" built from
|
||||||
|
curated templates and a guided wizard.
|
||||||
|
|
||||||
## 🚀 Getting Started
|
Live at [communityrule.info](https://communityrule.info). Packaged as a
|
||||||
|
Cloudron app for MEDLab; see
|
||||||
|
[docs/guides/ops-backend-deploy.md](docs/guides/ops-backend-deploy.md)
|
||||||
|
for the deployment handoff.
|
||||||
|
|
||||||
Run the development server:
|
## Requirements
|
||||||
|
|
||||||
|
- Node.js **20+** (LTS)
|
||||||
|
- npm **10+**
|
||||||
|
- Docker (for local Postgres and Mailhog)
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cp .env.example .env # then set SESSION_SECRET (≥16 chars)
|
||||||
|
docker compose up -d postgres # add `mailhog` for a local inbox
|
||||||
|
npm ci
|
||||||
|
npx prisma migrate dev
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
Open [http://localhost:3000](http://localhost:3000). Without
|
||||||
|
`CLOUDRON_MAIL_SMTP_*` set, magic-link sign-in URLs are printed to the
|
||||||
|
dev-server log instead of emailed.
|
||||||
|
|
||||||
## 🧪 Testing Framework
|
Full local backend, API reference, and PR workflow:
|
||||||
|
[CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
This project includes a comprehensive testing framework with multiple layers of testing:
|
## Scripts
|
||||||
|
|
||||||
### Quick Test Commands
|
| Command | What it does |
|
||||||
|
| --- | --- |
|
||||||
|
| `npm run dev` | Next.js dev server (Turbopack). |
|
||||||
|
| `npm run build` / `npm start` | Production build / serve. |
|
||||||
|
| `npm test` | Vitest unit + component tests with coverage. |
|
||||||
|
| `npm run test:component` | Components only — faster inner loop. |
|
||||||
|
| `npm run e2e` | Playwright E2E + visual regression. |
|
||||||
|
| `npm run migrate:smoke` | Throwaway Postgres + `prisma migrate deploy` (Docker required). |
|
||||||
|
| `npm run storybook` | Storybook on port 6006. |
|
||||||
|
| `npm run knip` | Detect unused files / exports. |
|
||||||
|
| `npm run lhci` | Lighthouse CI performance pass. |
|
||||||
|
|
||||||
```bash
|
See [`package.json`](package.json) for the full list (visual regression,
|
||||||
# Unit tests with coverage
|
bundle analysis, seeding, etc.).
|
||||||
npm test
|
|
||||||
|
|
||||||
# E2E tests
|
## Project layout
|
||||||
npm run e2e
|
|
||||||
|
|
||||||
# Performance tests
|
```text
|
||||||
npm run lhci
|
app/ Next.js app router — route groups (marketing), (app),
|
||||||
|
(admin), (dev); shared components under app/components/;
|
||||||
# Storybook tests
|
admin-only widgets under app/(admin)/<route>/_components/
|
||||||
npm run test:sb
|
lib/ Shared library code (server, validation, create-flow logic)
|
||||||
|
prisma/ Schema, migrations, seed
|
||||||
|
messages/en/ Localized UI copy (single-locale today; English)
|
||||||
|
public/ Static assets
|
||||||
|
stories/ Storybook stories
|
||||||
|
tests/ Vitest + Playwright suites (mirror source paths)
|
||||||
|
docs/ Human-facing documentation — start at docs/README.md
|
||||||
|
.cursor/rules/ Per-file conventions (auto-loaded by Cursor)
|
||||||
|
scripts/ Build, release, and smoke-test scripts
|
||||||
```
|
```
|
||||||
|
|
||||||
### Test Coverage
|
## Tech stack
|
||||||
|
|
||||||
- ✅ **124 Unit Tests** (8 components + 1 integration)
|
Next.js 16 · React 19 · TypeScript · Tailwind CSS 4 · Prisma 6 ·
|
||||||
- ✅ **308 E2E Tests** (4 browsers × 77 tests)
|
PostgreSQL · Vitest · Playwright · Storybook 10 · Lighthouse CI.
|
||||||
- ✅ **92 Visual Regression Screenshots**
|
|
||||||
- ✅ **Performance Budgets**
|
|
||||||
- ✅ **Accessibility Compliance**
|
|
||||||
|
|
||||||
### CI/CD Pipeline
|
## Documentation
|
||||||
|
|
||||||
- **Gitea Actions** with 7 parallel jobs
|
- [docs/README.md](docs/README.md) — index of guides and rules.
|
||||||
- **Cross-browser testing** (Chromium, Firefox, WebKit, Mobile)
|
- [docs/create-flow.md](docs/create-flow.md) — create-rule wizard canon.
|
||||||
- **Visual regression testing**
|
- [docs/testing-guide.md](docs/testing-guide.md) — testing philosophy.
|
||||||
- **Performance monitoring**
|
- [docs/guides/ops-backend-deploy.md](docs/guides/ops-backend-deploy.md)
|
||||||
- **Code coverage reporting**
|
— Cloudron deploy + cutover plan.
|
||||||
|
- [CONTRIBUTING.md](CONTRIBUTING.md) — local backend, API routes, PR workflow.
|
||||||
|
- [AGENTS.md](AGENTS.md) — orientation for AI coding agents.
|
||||||
|
|
||||||
📖 **For detailed testing documentation, see [docs/TESTING.md](docs/TESTING.md)**
|
## License
|
||||||
|
|
||||||
## 📚 Storybook Development
|
Application source code is licensed under the
|
||||||
|
[GNU General Public License v3.0](LICENSE), the same license as the
|
||||||
|
legacy [GitLab project](https://gitlab.com/medlabboulder/communityrule).
|
||||||
|
Copyright (C) 2020 Media Enterprise Design Lab.
|
||||||
|
|
||||||
This project includes Storybook for component development and documentation. The setup automatically detects the environment and applies the appropriate configuration.
|
User-facing content (guides, template copy, marketing text) is licensed
|
||||||
|
under [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/),
|
||||||
### Local Development
|
as stated on [communityrule.info/about](https://communityrule.info/about/).
|
||||||
|
|
||||||
For local Storybook development:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run storybook:local
|
|
||||||
# or simply
|
|
||||||
npm run storybook
|
|
||||||
```
|
|
||||||
|
|
||||||
This will:
|
|
||||||
|
|
||||||
- Start Storybook at `http://localhost:6006`
|
|
||||||
- Use relative paths for assets (no base path)
|
|
||||||
|
|
||||||
### GitHub Pages Deployment
|
|
||||||
|
|
||||||
For GitHub Pages deployment with base path:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run storybook:build:github
|
|
||||||
```
|
|
||||||
|
|
||||||
This will:
|
|
||||||
|
|
||||||
- Build Storybook with `/communityrulestorybook/` base path
|
|
||||||
- Generate files ready for GitHub Pages deployment
|
|
||||||
|
|
||||||
### CI/CD Integration
|
|
||||||
|
|
||||||
The CI pipeline automatically uses the GitHub Pages configuration when building Storybook.
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
The Storybook configuration automatically detects the environment:
|
|
||||||
|
|
||||||
- **Local development**: No base path, relative assets
|
|
||||||
- **CI/Production**: Base path `/communityrulestorybook/` for GitHub Pages
|
|
||||||
|
|
||||||
## 📋 Available Scripts
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
- `npm run dev` - Start Next.js development server
|
|
||||||
- `npm run build` - Build Next.js application for production
|
|
||||||
- `npm run start` - Start Next.js production server
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
- `npm test` - Run unit tests with coverage
|
|
||||||
- `npm run test:watch` - Run tests in watch mode
|
|
||||||
- `npm run test:ui` - Run tests with UI
|
|
||||||
- `npm run e2e` - Run E2E tests
|
|
||||||
- `npm run e2e:ui` - Run E2E tests with UI
|
|
||||||
- `npm run e2e:serve` - Start dev server and run E2E tests
|
|
||||||
- `npm run lhci` - Run performance tests
|
|
||||||
- `npm run test:sb` - Run Storybook tests
|
|
||||||
|
|
||||||
### Storybook
|
|
||||||
|
|
||||||
- `npm run storybook:local` - Start Storybook for local development
|
|
||||||
- `npm run storybook:github` - Start Storybook with GitHub Pages configuration
|
|
||||||
- `npm run storybook:build` - Build Storybook for local deployment
|
|
||||||
- `npm run storybook:build:github` - Build Storybook for GitHub Pages
|
|
||||||
- `npm run storybook` - Start Storybook with current configuration
|
|
||||||
|
|
||||||
## 🏗️ Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
community-rule/
|
|
||||||
├── app/ # Next.js app directory
|
|
||||||
│ ├── components/ # React components
|
|
||||||
│ ├── layout.js # Root layout
|
|
||||||
│ └── page.js # Homepage
|
|
||||||
├── tests/ # Test files
|
|
||||||
│ ├── unit/ # Unit tests (8 components)
|
|
||||||
│ ├── integration/ # Integration tests
|
|
||||||
│ └── e2e/ # E2E tests (4 test suites)
|
|
||||||
├── docs/ # Documentation
|
|
||||||
│ └── TESTING.md # Comprehensive testing guide
|
|
||||||
├── .storybook/ # Storybook configuration
|
|
||||||
├── .gitea/ # Gitea Actions workflows
|
|
||||||
│ └── workflows/
|
|
||||||
│ └── ci.yml # CI/CD pipeline
|
|
||||||
└── public/ # Static assets
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Technology Stack
|
|
||||||
|
|
||||||
- **Framework**: Next.js 15 + React 19
|
|
||||||
- **Styling**: Tailwind CSS 4
|
|
||||||
- **Testing**: Vitest + Playwright + Lighthouse CI
|
|
||||||
- **Documentation**: Storybook 9
|
|
||||||
- **CI/CD**: Gitea Actions
|
|
||||||
- **Hosting**: Gitea (Git hosting)
|
|
||||||
|
|
||||||
## 📖 Documentation
|
|
||||||
|
|
||||||
- **[Testing Framework](docs/TESTING.md)** - Comprehensive testing guide
|
|
||||||
- **[Storybook](http://localhost:6006)** - Component documentation (local)
|
|
||||||
- **[GitHub Pages Storybook](https://your-username.github.io/communityrulestorybook/)** - Public component docs
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
|
||||||
|
|
||||||
1. **Fork the repository**
|
|
||||||
2. **Create a feature branch**: `git checkout -b feature/amazing-feature`
|
|
||||||
3. **Write tests first** (see [Testing Guide](docs/TESTING.md))
|
|
||||||
4. **Make your changes**
|
|
||||||
5. **Run tests**: `npm test && npm run e2e`
|
|
||||||
6. **Commit changes**: `git commit -m "feat: add amazing feature"`
|
|
||||||
7. **Push to branch**: `git push origin feature/amazing-feature`
|
|
||||||
8. **Create Pull Request**
|
|
||||||
|
|
||||||
### Development Workflow
|
|
||||||
|
|
||||||
- All changes must have tests
|
|
||||||
- CI pipeline runs automatically on PRs
|
|
||||||
- Visual regression tests ensure UI consistency
|
|
||||||
- Performance budgets must be met
|
|
||||||
- Accessibility standards must be maintained
|
|
||||||
|
|
||||||
## 📄 License
|
|
||||||
|
|
||||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
||||||
# Test from working commit
|
|
||||||
|
|||||||
@@ -1,258 +0,0 @@
|
|||||||
# Testing Strategy for CommunityRule
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document outlines our comprehensive testing strategy that properly separates unit testing from responsive behavior testing, following best practices for JSDOM limitations and real browser testing.
|
|
||||||
|
|
||||||
## Current Test Status
|
|
||||||
|
|
||||||
- **236 total tests** across the project
|
|
||||||
- **227 tests passing** (96.2% success rate)
|
|
||||||
- **9 tests failing** (performance and interaction tests)
|
|
||||||
- **15 test files** covering all major components
|
|
||||||
- **Performance Monitoring**: Comprehensive regression detection and budget enforcement
|
|
||||||
|
|
||||||
## Testing Philosophy
|
|
||||||
|
|
||||||
### The Problem with JSDOM and Responsive Testing
|
|
||||||
|
|
||||||
**Short take: Unit tests in JSDOM can't truly "switch breakpoints."** JSDOM doesn't evaluate CSS media queries, so Tailwind's `hidden sm:block …` won't change visibility when you "resize" the window.
|
|
||||||
|
|
||||||
### Solution: Proper Test Separation
|
|
||||||
|
|
||||||
- **Unit / component tests (Vitest + RTL):** assert **structure and classes**, not responsive visibility.
|
|
||||||
- **Responsive behavior:** verify with **browser-based tests** (Playwright) or **visual tests** (Chromatic/Storybook) at real viewport widths.
|
|
||||||
|
|
||||||
## Test Categories
|
|
||||||
|
|
||||||
### 1. Unit Tests (Vitest + React Testing Library)
|
|
||||||
|
|
||||||
**Purpose:** Test component structure, accessibility, and configuration data.
|
|
||||||
|
|
||||||
**What to test:**
|
|
||||||
|
|
||||||
- DOM roles/labels exist: `role="banner"`, nav landmark, menu items
|
|
||||||
- The right **Tailwind classes** are present on wrappers (`block sm:hidden`, `hidden md:block`, etc.)
|
|
||||||
- Data-driven bits produce the expected count/order (e.g., `navigationItems`, `avatarImages`, `logoConfig`)
|
|
||||||
- Component configuration and exported data structures
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// tests/unit/Header.structure.test.js
|
|
||||||
test("logo wrappers include breakpoint classes", () => {
|
|
||||||
render(<Header />);
|
|
||||||
const logoWrappers = screen.getAllByTestId("logo-wrapper");
|
|
||||||
|
|
||||||
// Check first logo variant (xs only)
|
|
||||||
expect(logoWrappers[0]).toHaveClass("block", "sm:hidden");
|
|
||||||
|
|
||||||
// Check second logo variant (sm only)
|
|
||||||
expect(logoWrappers[1]).toHaveClass("hidden", "sm:block", "md:hidden");
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Browser-Based Tests (Playwright)
|
|
||||||
|
|
||||||
**Purpose:** Test real responsive behavior at actual viewport widths.
|
|
||||||
|
|
||||||
**What to test:**
|
|
||||||
|
|
||||||
- **Visibility** at real breakpoints
|
|
||||||
- **Layout changes** between breakpoints
|
|
||||||
- **Interactive behavior** at different screen sizes
|
|
||||||
- **Accessibility** across viewports
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// tests/e2e/header.responsive.spec.js
|
|
||||||
const breakpoints = [
|
|
||||||
{ name: "xs", width: 360, height: 700 },
|
|
||||||
{ name: "sm", width: 640, height: 700 },
|
|
||||||
{ name: "md", width: 768, height: 700 },
|
|
||||||
{ name: "lg", width: 1024, height: 700 },
|
|
||||||
{ name: "xl", width: 1280, height: 700 },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const bp of breakpoints) {
|
|
||||||
test(`header layout at ${bp.name}`, async ({ page }) => {
|
|
||||||
await page.setViewportSize({ width: bp.width, height: bp.height });
|
|
||||||
await page.goto("/");
|
|
||||||
|
|
||||||
const nav = page.getByRole("navigation", { name: /main navigation/i });
|
|
||||||
await expect(nav).toBeVisible();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Visual Tests (Storybook + Chromatic)
|
|
||||||
|
|
||||||
**Purpose:** Visual regression testing and design system validation.
|
|
||||||
|
|
||||||
**What to test:**
|
|
||||||
|
|
||||||
- **Visual diffs** per breakpoint
|
|
||||||
- **Design consistency** across viewports
|
|
||||||
- **Component variations** and states
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// stories/Header.responsive.stories.js
|
|
||||||
export default {
|
|
||||||
parameters: {
|
|
||||||
chromatic: {
|
|
||||||
viewports: [360, 640, 768, 1024, 1280],
|
|
||||||
delay: 100,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Component Improvements
|
|
||||||
|
|
||||||
### Header Component Enhancements
|
|
||||||
|
|
||||||
1. **Added Test IDs** for easier testing:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
<div data-testid="logo-wrapper" className={config.breakpoint}>
|
|
||||||
{renderLogo(config.size, config.showText)}
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Exported Configuration** for testing:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
export const navigationItems = [...];
|
|
||||||
export const avatarImages = [...];
|
|
||||||
export const logoConfig = [...];
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Structured Breakpoint Containers**:
|
|
||||||
```jsx
|
|
||||||
<div data-testid="nav-xs" className="block sm:hidden">
|
|
||||||
<div data-testid="nav-sm" className="hidden sm:block md:hidden">
|
|
||||||
<div data-testid="nav-md" className="hidden md:block lg:hidden">
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
tests/
|
|
||||||
├── unit/ # Unit tests (Vitest + RTL)
|
|
||||||
│ ├── Header.test.jsx # CONSOLIDATED: Comprehensive Header tests
|
|
||||||
│ ├── Footer.test.jsx
|
|
||||||
│ ├── Layout.test.jsx
|
|
||||||
│ └── Page.test.jsx
|
|
||||||
├── integration/ # Integration tests
|
|
||||||
│ └── ContentLockup.integration.test.jsx
|
|
||||||
├── e2e/ # Browser tests (Playwright)
|
|
||||||
│ └── header.responsive.spec.js # NEW: Responsive behavior tests
|
|
||||||
└── stories/ # Storybook stories
|
|
||||||
└── Header.responsive.stories.js # NEW: Visual testing
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### Unit Testing (JSDOM)
|
|
||||||
|
|
||||||
1. **Test structure, not visibility**:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// ✅ Good: Test classes exist
|
|
||||||
expect(element).toHaveClass("block", "sm:hidden");
|
|
||||||
|
|
||||||
// ❌ Bad: Test visibility (doesn't work in JSDOM)
|
|
||||||
expect(element).toBeVisible();
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Use test IDs for containers**:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// ✅ Good: Test specific containers
|
|
||||||
const logoWrapper = screen.getByTestId("logo-wrapper");
|
|
||||||
|
|
||||||
// ❌ Bad: Query by complex class strings
|
|
||||||
const logoWrapper = document.querySelector(".block.sm\\:hidden");
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Test configuration data**:
|
|
||||||
```javascript
|
|
||||||
// ✅ Good: Test exported configuration
|
|
||||||
expect(navigationItems).toHaveLength(3);
|
|
||||||
expect(logoConfig).toHaveLength(5);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Browser Testing (Playwright)
|
|
||||||
|
|
||||||
1. **Test real viewport sizes**:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
await page.setViewportSize({ width: 640, height: 700 });
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Test visibility at breakpoints**:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
if (bp.name === "xs") {
|
|
||||||
await expect(page.getByTestId("auth-xs")).toBeVisible();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Test accessibility across viewports**:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const interactiveElements = [
|
|
||||||
page.getByRole("link", { name: /use cases/i }),
|
|
||||||
page.getByRole("button", { name: /create rule/i }),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const element of interactiveElements) {
|
|
||||||
await expect(element).toBeVisible();
|
|
||||||
await expect(element).toBeEnabled();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running Tests
|
|
||||||
|
|
||||||
### Unit Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm test # Run all unit tests
|
|
||||||
npm test tests/unit/ # Run only unit tests
|
|
||||||
npm test Header.structure # Run specific test file
|
|
||||||
```
|
|
||||||
|
|
||||||
### Browser Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx playwright test # Run all browser tests
|
|
||||||
npx playwright test header.responsive.spec.js # Run specific test
|
|
||||||
```
|
|
||||||
|
|
||||||
### Visual Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run storybook # Start Storybook
|
|
||||||
npx chromatic --project-token=xxx # Run visual tests
|
|
||||||
```
|
|
||||||
|
|
||||||
## Future Improvements
|
|
||||||
|
|
||||||
1. **Add more Playwright tests** for other components
|
|
||||||
2. **Set up Chromatic** for visual regression testing
|
|
||||||
3. **Add performance tests** for responsive behavior
|
|
||||||
4. **Create component-specific test utilities**
|
|
||||||
5. **Add accessibility testing** with axe-core
|
|
||||||
|
|
||||||
## Key Takeaways
|
|
||||||
|
|
||||||
1. **JSDOM limitations** require separating structure tests from visibility tests
|
|
||||||
2. **Test IDs** make testing more reliable and maintainable
|
|
||||||
3. **Exported configuration** enables better data structure testing
|
|
||||||
4. **Real browser testing** is essential for responsive behavior
|
|
||||||
5. **Visual testing** catches design regressions across breakpoints
|
|
||||||
|
|
||||||
This strategy provides comprehensive coverage while respecting the limitations of different testing environments.
|
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// Operator/admin dashboards (e.g. `/monitor`) intentionally render without the
|
||||||
|
// public marketing footer. Auth/access is enforced upstream.
|
||||||
|
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||||
|
return <main className="flex-1">{children}</main>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import WebVitalsDashboard from "./_components/WebVitalsDashboard";
|
||||||
|
import Top from "../../components/navigation/Top";
|
||||||
|
import Footer from "../../components/navigation/Footer";
|
||||||
|
import { useMessages } from "../../contexts/MessagesContext";
|
||||||
|
|
||||||
|
export default function MonitorPageContent() {
|
||||||
|
const m = useMessages();
|
||||||
|
const p = m.pages.monitor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[var(--color-surface-default-primary)]">
|
||||||
|
<Top folderTop={false} />
|
||||||
|
|
||||||
|
<main className="container mx-auto px-[var(--spacing-scale-024)] py-[var(--spacing-scale-032)]">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<div className="mb-[var(--spacing-scale-032)]">
|
||||||
|
<h1 className="text-4xl font-bold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-016)]">
|
||||||
|
{p.title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-[var(--font-size-body-large)] text-[var(--color-content-default-secondary)]">
|
||||||
|
{p.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[var(--spacing-scale-032)] mb-[var(--spacing-scale-032)]">
|
||||||
|
<div className="p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
|
||||||
|
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
|
||||||
|
{p.performanceTargets.title}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-[var(--spacing-scale-012)]">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-[var(--font-size-body-medium)]">
|
||||||
|
{p.performanceTargets.loadTime}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-green-600">
|
||||||
|
{p.performanceTargets.loadTimeTarget}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-[var(--font-size-body-medium)]">
|
||||||
|
{p.performanceTargets.lcp}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-green-600">
|
||||||
|
{p.performanceTargets.lcpTarget}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-[var(--font-size-body-medium)]">
|
||||||
|
{p.performanceTargets.fid}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-green-600">
|
||||||
|
{p.performanceTargets.fidTarget}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-[var(--font-size-body-medium)]">
|
||||||
|
{p.performanceTargets.cls}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-green-600">
|
||||||
|
{p.performanceTargets.clsTarget}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-[var(--font-size-body-medium)]">
|
||||||
|
{p.performanceTargets.lighthouse}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-green-600">
|
||||||
|
{p.performanceTargets.lighthouseTarget}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
|
||||||
|
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
|
||||||
|
{p.optimizationStatus.title}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-[var(--spacing-scale-012)]">
|
||||||
|
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||||
|
<span className="text-green-600">✅</span>
|
||||||
|
<span className="text-[var(--font-size-body-medium)]">
|
||||||
|
{p.optimizationStatus.codeSplitting}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||||
|
<span className="text-green-600">✅</span>
|
||||||
|
<span className="text-[var(--font-size-body-medium)]">
|
||||||
|
{p.optimizationStatus.reactMemo}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||||
|
<span className="text-green-600">✅</span>
|
||||||
|
<span className="text-[var(--font-size-body-medium)]">
|
||||||
|
{p.optimizationStatus.imageOptimization}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||||
|
<span className="text-green-600">✅</span>
|
||||||
|
<span className="text-[var(--font-size-body-medium)]">
|
||||||
|
{p.optimizationStatus.fontPreloading}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||||
|
<span className="text-green-600">✅</span>
|
||||||
|
<span className="text-[var(--font-size-body-medium)]">
|
||||||
|
{p.optimizationStatus.bundleAnalysis}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[var(--spacing-scale-008)]">
|
||||||
|
<span className="text-green-600">✅</span>
|
||||||
|
<span className="text-[var(--font-size-body-medium)]">
|
||||||
|
{p.optimizationStatus.errorBoundaries}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WebVitalsDashboard />
|
||||||
|
|
||||||
|
<div className="mt-[var(--spacing-scale-032)] p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
|
||||||
|
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
|
||||||
|
{p.monitoringCommands.title}
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-[var(--spacing-scale-016)]">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||||
|
{p.monitoringCommands.bundleAnalyze.title}
|
||||||
|
</h3>
|
||||||
|
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||||
|
{p.monitoringCommands.bundleAnalyze.command}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||||
|
{p.monitoringCommands.e2ePerformance.title}
|
||||||
|
</h3>
|
||||||
|
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||||
|
{p.monitoringCommands.e2ePerformance.command}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||||
|
{p.monitoringCommands.lhciDesktop.title}
|
||||||
|
</h3>
|
||||||
|
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||||
|
{p.monitoringCommands.lhciDesktop.command}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
|
||||||
|
{p.monitoringCommands.performanceBudget.title}
|
||||||
|
</h3>
|
||||||
|
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
|
||||||
|
{p.monitoringCommands.performanceBudget.command}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Figma: "WebVitalsDashboard" (see registry)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { memo, useEffect, useState } from "react";
|
||||||
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
|
import { logger } from "../../../../../lib/logger";
|
||||||
|
import WebVitalsDashboardView from "./WebVitalsDashboard.view";
|
||||||
|
import type { Metrics, Vitals, VitalData } from "./WebVitalsDashboard.types";
|
||||||
|
|
||||||
|
const createInitialVital = (): VitalData => ({
|
||||||
|
value: 0,
|
||||||
|
rating: "unknown",
|
||||||
|
});
|
||||||
|
|
||||||
|
const createInitialVitals = (): Vitals => ({
|
||||||
|
lcp: createInitialVital(),
|
||||||
|
fid: createInitialVital(),
|
||||||
|
cls: createInitialVital(),
|
||||||
|
fcp: createInitialVital(),
|
||||||
|
ttfb: createInitialVital(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function reportWebVitalToApi(
|
||||||
|
metric: keyof Vitals,
|
||||||
|
value: number,
|
||||||
|
rating: VitalData["rating"],
|
||||||
|
): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (rating === "unknown") return;
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
metric,
|
||||||
|
data: { value, rating },
|
||||||
|
url: window.location.href,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
void fetch("/api/web-vitals", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}).catch((err: unknown) => {
|
||||||
|
logger.error("Web vitals ingest failed:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const WebVitalsDashboardContainer = memo(() => {
|
||||||
|
const m = useMessages();
|
||||||
|
const copy = m.webVitalsDashboard;
|
||||||
|
const [vitals, setVitals] = useState<Vitals>(createInitialVitals);
|
||||||
|
const [metrics, setMetrics] = useState<Metrics>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [storage, setStorage] = useState<"external" | "local">("local");
|
||||||
|
|
||||||
|
const rumDashboardUrl =
|
||||||
|
typeof process.env.NEXT_PUBLIC_RUM_DASHBOARD_URL === "string" &&
|
||||||
|
process.env.NEXT_PUBLIC_RUM_DASHBOARD_URL.trim() !== ""
|
||||||
|
? process.env.NEXT_PUBLIC_RUM_DASHBOARD_URL.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchVitals = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/web-vitals");
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
metrics?: Metrics;
|
||||||
|
storage?: "external" | "local";
|
||||||
|
};
|
||||||
|
setMetrics(data.metrics || {});
|
||||||
|
setStorage(data.storage === "external" ? "external" : "local");
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error fetching web vitals:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchVitals();
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
import("web-vitals").then(
|
||||||
|
({ onCLS, onFID, onFCP, onLCP, onTTFB }) => {
|
||||||
|
onLCP((metric) => {
|
||||||
|
const rating = metric.rating as VitalData["rating"];
|
||||||
|
setVitals((prev) => ({
|
||||||
|
...prev,
|
||||||
|
lcp: {
|
||||||
|
value: Math.round(metric.value),
|
||||||
|
rating,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
reportWebVitalToApi("lcp", Math.round(metric.value), rating);
|
||||||
|
});
|
||||||
|
|
||||||
|
onFID((metric) => {
|
||||||
|
const rating = metric.rating as VitalData["rating"];
|
||||||
|
setVitals((prev) => ({
|
||||||
|
...prev,
|
||||||
|
fid: {
|
||||||
|
value: Math.round(metric.value),
|
||||||
|
rating,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
reportWebVitalToApi("fid", Math.round(metric.value), rating);
|
||||||
|
});
|
||||||
|
|
||||||
|
onCLS((metric) => {
|
||||||
|
const rounded = Math.round(metric.value * 1000) / 1000;
|
||||||
|
const rating = metric.rating as VitalData["rating"];
|
||||||
|
setVitals((prev) => ({
|
||||||
|
...prev,
|
||||||
|
cls: {
|
||||||
|
value: rounded,
|
||||||
|
rating,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
reportWebVitalToApi("cls", rounded, rating);
|
||||||
|
});
|
||||||
|
|
||||||
|
onFCP((metric) => {
|
||||||
|
const rating = metric.rating as VitalData["rating"];
|
||||||
|
setVitals((prev) => ({
|
||||||
|
...prev,
|
||||||
|
fcp: {
|
||||||
|
value: Math.round(metric.value),
|
||||||
|
rating,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
reportWebVitalToApi("fcp", Math.round(metric.value), rating);
|
||||||
|
});
|
||||||
|
|
||||||
|
onTTFB((metric) => {
|
||||||
|
const rating = metric.rating as VitalData["rating"];
|
||||||
|
setVitals((prev) => ({
|
||||||
|
...prev,
|
||||||
|
ttfb: {
|
||||||
|
value: Math.round(metric.value),
|
||||||
|
rating,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
reportWebVitalToApi("ttfb", Math.round(metric.value), rating);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WebVitalsDashboardView
|
||||||
|
vitals={vitals}
|
||||||
|
metrics={metrics}
|
||||||
|
loading={loading}
|
||||||
|
storage={storage}
|
||||||
|
copy={copy}
|
||||||
|
rumDashboardUrl={rumDashboardUrl}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
WebVitalsDashboardContainer.displayName = "WebVitalsDashboard";
|
||||||
|
|
||||||
|
export default WebVitalsDashboardContainer;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type messages from "../../../../../messages/en/index";
|
||||||
|
|
||||||
|
export interface VitalData {
|
||||||
|
value: number;
|
||||||
|
rating: "good" | "needs-improvement" | "poor" | "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Vitals {
|
||||||
|
lcp: VitalData;
|
||||||
|
fid: VitalData;
|
||||||
|
cls: VitalData;
|
||||||
|
fcp: VitalData;
|
||||||
|
ttfb: VitalData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricData {
|
||||||
|
count: number;
|
||||||
|
average: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
goodCount: number;
|
||||||
|
needsImprovementCount: number;
|
||||||
|
poorCount: number;
|
||||||
|
lastUpdated?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Metrics {
|
||||||
|
[key: string]: MetricData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WebVitalsDashboardCopy = typeof messages.webVitalsDashboard;
|
||||||
|
|
||||||
|
export interface WebVitalsDashboardViewProps {
|
||||||
|
vitals: Vitals;
|
||||||
|
metrics: Metrics;
|
||||||
|
loading: boolean;
|
||||||
|
storage: "external" | "local";
|
||||||
|
copy: WebVitalsDashboardCopy;
|
||||||
|
rumDashboardUrl: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import type { WebVitalsDashboardViewProps } from "./WebVitalsDashboard.types";
|
||||||
|
|
||||||
|
const getRatingColor = (rating: string): string => {
|
||||||
|
switch (rating) {
|
||||||
|
case "good":
|
||||||
|
return "text-green-600 bg-green-50";
|
||||||
|
case "needs-improvement":
|
||||||
|
return "text-yellow-600 bg-yellow-50";
|
||||||
|
case "poor":
|
||||||
|
return "text-red-600 bg-red-50";
|
||||||
|
default:
|
||||||
|
return "text-gray-600 bg-gray-50";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRatingIcon = (rating: string): string => {
|
||||||
|
switch (rating) {
|
||||||
|
case "good":
|
||||||
|
return "✅";
|
||||||
|
case "needs-improvement":
|
||||||
|
return "⚠️";
|
||||||
|
case "poor":
|
||||||
|
return "❌";
|
||||||
|
default:
|
||||||
|
return "❓";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatValue(metric: string, value: number): string {
|
||||||
|
if (metric === "cls") {
|
||||||
|
return value.toFixed(3);
|
||||||
|
}
|
||||||
|
return `${value}ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WebVitalsDashboardView({
|
||||||
|
vitals,
|
||||||
|
metrics,
|
||||||
|
loading,
|
||||||
|
storage,
|
||||||
|
copy,
|
||||||
|
rumDashboardUrl,
|
||||||
|
}: WebVitalsDashboardViewProps) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 bg-white rounded-lg shadow-lg">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="p-4 border rounded-lg">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-3/4"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 bg-white rounded-lg shadow-lg">
|
||||||
|
<h2 className="text-2xl font-bold mb-6 text-[var(--color-content-default-primary)]">
|
||||||
|
{copy.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{storage === "external" && (
|
||||||
|
<div
|
||||||
|
className="mb-6 p-4 rounded-lg border border-[var(--color-border-default-primary)] bg-[var(--color-surface-default-secondary)] text-[var(--font-size-body-medium)] text-[var(--color-content-default-secondary)]"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<p className="font-semibold text-[var(--color-content-default-primary)] mb-2">
|
||||||
|
{copy.externalNoticeTitle}
|
||||||
|
</p>
|
||||||
|
<p className="mb-3">{copy.externalNoticeBody}</p>
|
||||||
|
{rumDashboardUrl ? (
|
||||||
|
<a
|
||||||
|
href={rumDashboardUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-[var(--color-content-default-primary)] underline font-medium"
|
||||||
|
>
|
||||||
|
{copy.externalDashboardLinkLabel}
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||||
|
{Object.entries(vitals).map(([metric, data]) => (
|
||||||
|
<div
|
||||||
|
key={metric}
|
||||||
|
className={`p-4 border rounded-lg ${getRatingColor(data.rating)}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="font-semibold text-lg">{metric.toUpperCase()}</h3>
|
||||||
|
<span className="text-2xl">{getRatingIcon(data.rating)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium">
|
||||||
|
{copy.valueLabel}: {formatValue(metric, data.value)}
|
||||||
|
</div>
|
||||||
|
<div className="capitalize">
|
||||||
|
{copy.ratingLabel}: {data.rating.replace("-", " ")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Object.keys(metrics).length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 text-[var(--color-content-default-primary)]">
|
||||||
|
{copy.historicalMetricsTitle}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{Object.entries(metrics).map(([metric, data]) => (
|
||||||
|
<div
|
||||||
|
key={metric}
|
||||||
|
className="p-4 border rounded-lg bg-[var(--color-surface-default-secondary)]"
|
||||||
|
>
|
||||||
|
<h4 className="font-semibold mb-2">{metric.toUpperCase()}</h4>
|
||||||
|
<div className="text-sm space-y-1">
|
||||||
|
<div>
|
||||||
|
{copy.countLabel}: {data.count}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{copy.averageLabel}: {formatValue(metric, data.average)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{copy.rangeLabel}: {formatValue(metric, data.min)} -{" "}
|
||||||
|
{formatValue(metric, data.max)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 text-xs">
|
||||||
|
<span className="text-green-600">
|
||||||
|
{copy.goodLabel}: {data.goodCount}
|
||||||
|
</span>
|
||||||
|
<span className="text-yellow-600">
|
||||||
|
{copy.needsImprovementLabel}: {data.needsImprovementCount}
|
||||||
|
</span>
|
||||||
|
<span className="text-red-600">
|
||||||
|
{copy.poorLabel}: {data.poorCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-4 bg-[var(--color-surface-default-secondary)] rounded-lg">
|
||||||
|
<h3 className="font-semibold mb-2 text-[var(--color-content-default-primary)]">
|
||||||
|
{copy.performanceGuidelinesTitle}
|
||||||
|
</h3>
|
||||||
|
<ul className="text-sm space-y-1 text-[var(--color-content-default-secondary)]">
|
||||||
|
<li>• {copy.guidelines.lcp}</li>
|
||||||
|
<li>• {copy.guidelines.fid}</li>
|
||||||
|
<li>• {copy.guidelines.cls}</li>
|
||||||
|
<li>• {copy.guidelines.fcp}</li>
|
||||||
|
<li>• {copy.guidelines.ttfb}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WebVitalsDashboardView;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./WebVitalsDashboard.container";
|
||||||
|
export * from "./WebVitalsDashboard.types";
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import MonitorPageContent from "./MonitorPageContent";
|
||||||
|
|
||||||
|
export default function MonitorPage() {
|
||||||
|
return <MonitorPageContent />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,931 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Suspense,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
|
||||||
|
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
|
||||||
|
import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
|
||||||
|
import { useCreateFlowFinalize } from "./hooks/useCreateFlowFinalize";
|
||||||
|
import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions";
|
||||||
|
import { useCompletedRuleShareExport } from "./hooks/useCompletedRuleShareExport";
|
||||||
|
import CreateFlowFooter from "../../components/navigation/CreateFlowFooter";
|
||||||
|
import CreateFlowTopNav from "../../components/navigation/CreateFlowTopNav";
|
||||||
|
import {
|
||||||
|
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
|
||||||
|
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
|
||||||
|
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
|
||||||
|
getNextStep,
|
||||||
|
getStepIndex,
|
||||||
|
parseReviewReturnSearchParam,
|
||||||
|
createFlowStepUsesSelectSplitScroll,
|
||||||
|
TEMPLATES_FACET_RECOMMEND_QUERY,
|
||||||
|
TEMPLATES_FACET_RECOMMEND_VALUE,
|
||||||
|
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
|
||||||
|
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
|
||||||
|
} from "./utils/flowSteps";
|
||||||
|
import {
|
||||||
|
CREATE_FLOW_SYNC_DRAFT_QUERY,
|
||||||
|
CREATE_FLOW_SYNC_DRAFT_VALUE,
|
||||||
|
CREATE_ROUTES,
|
||||||
|
createFlowStepPath,
|
||||||
|
createFlowStepPathAfterStrippingReviewReturn,
|
||||||
|
createFlowStepPathWithSyncDraft,
|
||||||
|
} from "./utils/createFlowPaths";
|
||||||
|
import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress";
|
||||||
|
import {
|
||||||
|
createFlowStepUsesCenteredTextLayout,
|
||||||
|
createFlowStepUsesCardLayout,
|
||||||
|
} from "./utils/createFlowScreenRegistry";
|
||||||
|
import Button from "../../components/buttons/Button";
|
||||||
|
import { isValidCreateFlowSaveEmail } from "../../../lib/create/isValidCreateFlowSaveEmail";
|
||||||
|
import { buildCreateFlowDraftPayload } from "../../../lib/create/buildCreateFlowDraftPayload";
|
||||||
|
import {
|
||||||
|
fetchAuthSession,
|
||||||
|
requestMagicLink,
|
||||||
|
} from "../../../lib/create/api";
|
||||||
|
import { safeInternalPath } from "../../../lib/safeInternalPath";
|
||||||
|
import {
|
||||||
|
clearAnonymousCreateFlowStorage,
|
||||||
|
setTransferPendingFlag,
|
||||||
|
writeAnonymousCreateFlowState,
|
||||||
|
} from "./utils/anonymousDraftStorage";
|
||||||
|
import {
|
||||||
|
createFlowStateFromPublishedRule,
|
||||||
|
isPublishedRuleHydratePatchIncomplete,
|
||||||
|
methodSectionsPinsFromPublishedHydratePatch,
|
||||||
|
} from "../../../lib/create/publishedDocumentToCreateFlowState";
|
||||||
|
import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets";
|
||||||
|
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
||||||
|
import { runCompletedStepExit } from "./utils/runCompletedStepExit";
|
||||||
|
import messages from "../../../messages/en/index";
|
||||||
|
import {
|
||||||
|
CREATE_FLOW_FOOTER_BUTTON_CLASS,
|
||||||
|
CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS,
|
||||||
|
} from "./utils/createFlowFooterClassNames";
|
||||||
|
import {
|
||||||
|
CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP,
|
||||||
|
methodCardFacetSectionForConfirmStep,
|
||||||
|
type CustomRuleConfirmFooterStep,
|
||||||
|
} from "./utils/customRuleConfirmFooterSteps";
|
||||||
|
import { getDefaultFooterLabel } from "./utils/createFlowFooterLabels";
|
||||||
|
import { useAuthModal } from "../../contexts/AuthModalContext";
|
||||||
|
import { useMessages, useTranslation } from "../../contexts/MessagesContext";
|
||||||
|
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,
|
||||||
|
useCreateFlowDraftSaveBanner,
|
||||||
|
} from "./context/CreateFlowDraftSaveBannerContext";
|
||||||
|
|
||||||
|
/** First step where Save & Exit is offered (first Create Community select per Figma). */
|
||||||
|
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-structure");
|
||||||
|
|
||||||
|
function CreateFlowSessionShell({ children }: { children: ReactNode }) {
|
||||||
|
const [sessionUser, setSessionUser] = useState<
|
||||||
|
{ id: string; email: string } | null | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void fetchAuthSession().then(({ user }) => {
|
||||||
|
if (!cancelled) setSessionUser(user);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sessionResolved = sessionUser !== undefined;
|
||||||
|
// Mirror in-progress draft to localStorage for ALL visitors once we know who
|
||||||
|
// they are. Refresh-survival is the same UX for guest and signed-in users;
|
||||||
|
// signed-in users additionally get an explicit "Save & Exit" that PUTs to
|
||||||
|
// the server (handled in `useCreateFlowExit`).
|
||||||
|
const enableLocalDraftMirroring = sessionResolved;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreateFlowProvider enableLocalDraftMirroring={enableLocalDraftMirroring}>
|
||||||
|
<CreateFlowDraftSaveBannerProvider>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<CreateFlowLayoutContent
|
||||||
|
sessionUser={sessionUser}
|
||||||
|
sessionResolved={sessionResolved}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</CreateFlowLayoutContent>
|
||||||
|
</Suspense>
|
||||||
|
</CreateFlowDraftSaveBannerProvider>
|
||||||
|
</CreateFlowProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateFlowLayoutContent({
|
||||||
|
children,
|
||||||
|
sessionUser,
|
||||||
|
sessionResolved,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
sessionUser: { id: string; email: string } | null | undefined;
|
||||||
|
sessionResolved: boolean;
|
||||||
|
}) {
|
||||||
|
const { create } = useMessages();
|
||||||
|
const footer = create.footer;
|
||||||
|
const communitySaveMessages = create.community.communitySave;
|
||||||
|
const tLogin = useTranslation("pages.login");
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const reviewReturnTarget = parseReviewReturnSearchParam(searchParams);
|
||||||
|
const { openLogin } = useAuthModal();
|
||||||
|
const skipCommunitySave = sessionResolved && Boolean(sessionUser);
|
||||||
|
const {
|
||||||
|
currentStep,
|
||||||
|
nextStep,
|
||||||
|
previousStep,
|
||||||
|
goToNextStep,
|
||||||
|
goToPreviousStep,
|
||||||
|
templateReviewFooterBackToCreateReview,
|
||||||
|
} = useCreateFlowNavigation(
|
||||||
|
skipCommunitySave ? { skipCommunitySave: true } : undefined,
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
state,
|
||||||
|
clearState,
|
||||||
|
updateState,
|
||||||
|
resetCustomRuleSelections,
|
||||||
|
setMethodSectionsPinCommitted,
|
||||||
|
replaceState,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
} = useCreateFlow();
|
||||||
|
const manageStakeholdersIntent =
|
||||||
|
searchParams?.get(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY) ===
|
||||||
|
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE;
|
||||||
|
const editingPublishedRuleIdTrimmed =
|
||||||
|
state.editingPublishedRuleId?.trim() ?? "";
|
||||||
|
const isConfirmStakeholdersManagePublished =
|
||||||
|
currentStep === "confirm-stakeholders" &&
|
||||||
|
manageStakeholdersIntent &&
|
||||||
|
editingPublishedRuleIdTrimmed.length > 0;
|
||||||
|
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
|
||||||
|
useCreateFlowDraftSaveBanner();
|
||||||
|
const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] =
|
||||||
|
useState(false);
|
||||||
|
const [communitySaveMagicLinkError, setCommunitySaveMagicLinkError] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [communitySaveMagicLinkSuccess, setCommunitySaveMagicLinkSuccess] =
|
||||||
|
useState(false);
|
||||||
|
const [completedFlowBanner, setCompletedFlowBanner] = useState<{
|
||||||
|
key: string;
|
||||||
|
status: "positive" | "danger";
|
||||||
|
title: string;
|
||||||
|
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,
|
||||||
|
mailtoPublishedRule,
|
||||||
|
sharePublishedRuleViaSignal,
|
||||||
|
sharePublishedRuleViaSlack,
|
||||||
|
sharePublishedRuleViaDiscord,
|
||||||
|
onSelectExportFormat: onCompletedExportFormat,
|
||||||
|
} = useCompletedRuleShareExport({
|
||||||
|
setActionBanner: setCompletedFlowBanner,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOpenCompletedShareModal = () => {
|
||||||
|
if (!readLastPublishedRule()) {
|
||||||
|
setCompletedFlowBanner({
|
||||||
|
key: "completedShareNoRule",
|
||||||
|
status: "danger",
|
||||||
|
title: create.reviewAndComplete.completed.shareNoRuleTitle,
|
||||||
|
description: create.reviewAndComplete.completed.shareNoRuleDescription,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShareModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginReturnPath =
|
||||||
|
currentStep === "edit-rule"
|
||||||
|
? createFlowStepPathWithSyncDraft("edit-rule")
|
||||||
|
: createFlowStepPathWithSyncDraft("final-review");
|
||||||
|
|
||||||
|
const {
|
||||||
|
publishBannerMessage,
|
||||||
|
setPublishBannerMessage,
|
||||||
|
isPublishing,
|
||||||
|
finalize: handleFinalize,
|
||||||
|
} = useCreateFlowFinalize({
|
||||||
|
state,
|
||||||
|
router,
|
||||||
|
openLogin,
|
||||||
|
updateState,
|
||||||
|
loginReturnPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
isTemplateReviewRoute,
|
||||||
|
templateReviewSlug,
|
||||||
|
isApplyingTemplate,
|
||||||
|
templateReviewApplyError,
|
||||||
|
setTemplateReviewApplyError,
|
||||||
|
handleCustomize: handleCustomizeTemplate,
|
||||||
|
handleUseWithoutChanges: handleUseTemplateWithoutChanges,
|
||||||
|
} = useTemplateReviewActions({
|
||||||
|
pathname,
|
||||||
|
state,
|
||||||
|
updateState,
|
||||||
|
replaceState,
|
||||||
|
router,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runAuthenticatedExit = useCreateFlowExit({
|
||||||
|
state,
|
||||||
|
currentStep,
|
||||||
|
clearState,
|
||||||
|
router,
|
||||||
|
user: sessionUser ?? null,
|
||||||
|
setDraftSaveBannerMessage,
|
||||||
|
confirmLeave,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleExit = async (opts?: { saveDraft?: boolean }) => {
|
||||||
|
const saveDraft = opts?.saveDraft ?? false;
|
||||||
|
if (!sessionResolved) return;
|
||||||
|
|
||||||
|
// Exit from `/create/completed` is post-publish: the rule is saved, so we
|
||||||
|
// skip the leave-confirm + login prompt and just wipe the in-flight draft.
|
||||||
|
// For signed-in users we also DELETE the server draft so a future visit to
|
||||||
|
// /create starts fresh instead of rehydrating yesterday's work.
|
||||||
|
if (currentStep === "completed") {
|
||||||
|
runCompletedStepExit({
|
||||||
|
clearState,
|
||||||
|
clearAnonymousCreateFlowStorage,
|
||||||
|
router,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionUser === null) {
|
||||||
|
if (saveDraft) return;
|
||||||
|
const returnToTemplateReview =
|
||||||
|
templateReviewSlug != null
|
||||||
|
? `/create/review-template/${encodeURIComponent(templateReviewSlug)}?syncDraft=1`
|
||||||
|
: null;
|
||||||
|
openLogin({
|
||||||
|
variant: "saveProgress",
|
||||||
|
nextPath:
|
||||||
|
returnToTemplateReview ??
|
||||||
|
`${pathname != null && pathname.length > 0 ? pathname : CREATE_ROUTES.createRoot}?${CREATE_FLOW_SYNC_DRAFT_QUERY}=${CREATE_FLOW_SYNC_DRAFT_VALUE}`,
|
||||||
|
backdropVariant: "blurredYellow",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionUser) return;
|
||||||
|
await runAuthenticatedExit(opts);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
sessionResolved &&
|
||||||
|
sessionUser &&
|
||||||
|
currentStep === "community-save"
|
||||||
|
) {
|
||||||
|
router.replace(CREATE_ROUTES.review);
|
||||||
|
}
|
||||||
|
}, [sessionResolved, sessionUser, currentStep, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentStep !== "community-save") {
|
||||||
|
setCommunitySaveMagicLinkError(null);
|
||||||
|
setCommunitySaveMagicLinkSuccess(false);
|
||||||
|
setCommunitySaveMagicLinkSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [currentStep]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentStep !== "edit-rule") return;
|
||||||
|
const last = readLastPublishedRule();
|
||||||
|
if (!last) {
|
||||||
|
router.replace(CREATE_ROUTES.completed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const editingId = state.editingPublishedRuleId?.trim() ?? "";
|
||||||
|
if (editingId.length > 0 && editingId !== last.id) {
|
||||||
|
router.replace(CREATE_ROUTES.completed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const titleOk =
|
||||||
|
typeof state.title === "string" && state.title.trim().length > 0;
|
||||||
|
const sectionsClear = (state.sections?.length ?? 0) === 0;
|
||||||
|
const patch = createFlowStateFromPublishedRule(last);
|
||||||
|
const pinPatch = methodSectionsPinsFromPublishedHydratePatch(patch);
|
||||||
|
const needsPinMerge = METHOD_FACET_API_SECTION_IDS.some(
|
||||||
|
(key) =>
|
||||||
|
pinPatch[key] === true &&
|
||||||
|
state.methodSectionsPinCommitted?.[key] !== true,
|
||||||
|
);
|
||||||
|
/**
|
||||||
|
* Skip repeat merges once template `sections` are cleared **and** published
|
||||||
|
* facet selections are present. Without the selection check, TopNav **Edit**
|
||||||
|
* (`sections: []` before navigate) matched only `sectionsClear` and skipped
|
||||||
|
* the merge — method-card steps saw empty `selected*Ids` until a confirm.
|
||||||
|
*
|
||||||
|
* Still merge {@link methodSectionsPinsFromPublishedHydratePatch}: selections
|
||||||
|
* may already match draft state while compact CardStack pins stayed false
|
||||||
|
* (pins are normally set only on facet **Confirm**).
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
titleOk &&
|
||||||
|
editingId === last.id &&
|
||||||
|
sectionsClear &&
|
||||||
|
!isPublishedRuleHydratePatchIncomplete(state, patch)
|
||||||
|
) {
|
||||||
|
if (needsPinMerge) {
|
||||||
|
updateState({
|
||||||
|
methodSectionsPinCommitted: {
|
||||||
|
...state.methodSectionsPinCommitted,
|
||||||
|
...pinPatch,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateState({
|
||||||
|
...patch,
|
||||||
|
methodSectionsPinCommitted: {
|
||||||
|
...state.methodSectionsPinCommitted,
|
||||||
|
...pinPatch,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
currentStep,
|
||||||
|
router,
|
||||||
|
updateState,
|
||||||
|
state.editingPublishedRuleId,
|
||||||
|
state.title,
|
||||||
|
state.methodSectionsPinCommitted,
|
||||||
|
state.sections?.length,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentStep !== "completed") {
|
||||||
|
setCompletedFlowBanner(null);
|
||||||
|
}
|
||||||
|
}, [currentStep]);
|
||||||
|
|
||||||
|
const handleCommunitySaveMagicLinkSubmit = useCallback(async () => {
|
||||||
|
setCommunitySaveMagicLinkError(null);
|
||||||
|
setCommunitySaveMagicLinkSuccess(false);
|
||||||
|
const raw = state.communitySaveEmail;
|
||||||
|
const trimmed = typeof raw === "string" ? raw.trim().toLowerCase() : "";
|
||||||
|
if (!isValidCreateFlowSaveEmail(trimmed)) return;
|
||||||
|
|
||||||
|
setCommunitySaveMagicLinkSubmitting(true);
|
||||||
|
try {
|
||||||
|
const stepAfterSave = getNextStep("community-save");
|
||||||
|
const segment = stepAfterSave ?? "review";
|
||||||
|
const rawNext = `/create/${segment}?syncDraft=1`;
|
||||||
|
const nextPath = safeInternalPath(rawNext);
|
||||||
|
const draftPayload = buildCreateFlowDraftPayload(state, currentStep);
|
||||||
|
writeAnonymousCreateFlowState({
|
||||||
|
...draftPayload,
|
||||||
|
communitySaveEmail: trimmed,
|
||||||
|
});
|
||||||
|
const result = await requestMagicLink(trimmed, nextPath, {
|
||||||
|
...draftPayload,
|
||||||
|
communitySaveEmail: trimmed,
|
||||||
|
});
|
||||||
|
if (result.ok === false) {
|
||||||
|
if (result.retryAfterMs != null && result.retryAfterMs > 0) {
|
||||||
|
const seconds = Math.ceil(result.retryAfterMs / 1000);
|
||||||
|
setCommunitySaveMagicLinkError(
|
||||||
|
tLogin("errors.rateLimited").replace("{seconds}", String(seconds)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setCommunitySaveMagicLinkError(
|
||||||
|
result.error || tLogin("errors.generic"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTransferPendingFlag();
|
||||||
|
updateState({ communitySaveEmail: trimmed });
|
||||||
|
setCommunitySaveMagicLinkSuccess(true);
|
||||||
|
} catch {
|
||||||
|
setCommunitySaveMagicLinkError(tLogin("errors.network"));
|
||||||
|
} finally {
|
||||||
|
setCommunitySaveMagicLinkSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [state, currentStep, tLogin, updateState]);
|
||||||
|
|
||||||
|
const isCompletedStep = currentStep === "completed";
|
||||||
|
const isRightRailStep = currentStep === "decision-approaches";
|
||||||
|
const isFinalReviewLike =
|
||||||
|
currentStep === "final-review" || currentStep === "edit-rule";
|
||||||
|
const isEditRuleStep = currentStep === "edit-rule";
|
||||||
|
const isCardLayoutStep = createFlowStepUsesCardLayout(currentStep);
|
||||||
|
/** Two-column select / right-rail: below `lg` main scrolls; at `lg+` only the right column scrolls. */
|
||||||
|
const isSelectSplitScrollStep = createFlowStepUsesSelectSplitScroll(
|
||||||
|
currentStep,
|
||||||
|
);
|
||||||
|
const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
|
||||||
|
|
||||||
|
/** At `md+`, main cross-axis: center by default; exceptions stay top-aligned (see product spec). */
|
||||||
|
const mainContentClass = isCompletedStep
|
||||||
|
? "items-stretch overflow-y-auto md:overflow-hidden"
|
||||||
|
: isSelectSplitScrollStep
|
||||||
|
? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden"
|
||||||
|
: isFinalReviewLike || isCardLayoutStep || isTemplateReviewRoute
|
||||||
|
? "items-start justify-center overflow-y-auto"
|
||||||
|
: "items-start justify-center overflow-y-auto md:items-center";
|
||||||
|
|
||||||
|
const isTextStep = createFlowStepUsesCenteredTextLayout(currentStep);
|
||||||
|
const mainMaxMdJustify =
|
||||||
|
isTextStep && !isCompletedStep && !isRightRailStep
|
||||||
|
? "max-md:justify-center"
|
||||||
|
: "max-md:justify-start";
|
||||||
|
const mainMaxMdCross = isCompletedStep
|
||||||
|
? "max-md:flex-col max-md:items-stretch"
|
||||||
|
: "max-md:flex-col max-md:items-center";
|
||||||
|
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
|
||||||
|
const saveDraftOnExit =
|
||||||
|
Boolean(sessionUser) &&
|
||||||
|
(stepIdx >= SAVE_EXIT_FROM_STEP_INDEX || currentStep === "edit-rule");
|
||||||
|
|
||||||
|
const proportionBarProgress = getProportionBarProgressForCreateFlowStep(
|
||||||
|
currentStep,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom Rule stage "confirm selection" steps: all five render the same
|
||||||
|
* primary footer button, differing only by disable predicate and label.
|
||||||
|
* Driving JSX from a config keeps the five sites aligned — adding a new
|
||||||
|
* selection screen means one row here, not a new branch below.
|
||||||
|
*/
|
||||||
|
const customRuleConfirmFooter: CustomRuleConfirmFooterStep | undefined =
|
||||||
|
currentStep != null
|
||||||
|
? CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP.get(currentStep)
|
||||||
|
: undefined;
|
||||||
|
/** Method-card steps tolerate `reviewReturn={edit-rule}` when `edit-rule ∉ FLOW_STEP_ORDER` makes `nextStep` null. Core values stay gated on linear `nextStep`. */
|
||||||
|
const showCustomRuleFooterConfirm =
|
||||||
|
Boolean(customRuleConfirmFooter) &&
|
||||||
|
(nextStep != null ||
|
||||||
|
(reviewReturnTarget != null &&
|
||||||
|
methodCardFacetSectionForConfirmStep(customRuleConfirmFooter.step) !=
|
||||||
|
undefined));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top banner stack above the main column; order is top → bottom.
|
||||||
|
*/
|
||||||
|
const topBanners: Array<{
|
||||||
|
key: string;
|
||||||
|
status: "danger" | "positive";
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}> = [
|
||||||
|
draftSaveBannerMessage
|
||||||
|
? {
|
||||||
|
key: "draftSave",
|
||||||
|
status: "danger" as const,
|
||||||
|
title: messages.create.topNav.draftSaveBannerTitle,
|
||||||
|
description: draftSaveBannerMessage,
|
||||||
|
onClose: () => setDraftSaveBannerMessage(null),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
publishBannerMessage
|
||||||
|
? {
|
||||||
|
key: "publish",
|
||||||
|
status: "danger" as const,
|
||||||
|
title:
|
||||||
|
messages.create.reviewAndComplete.publish.finalizeBannerTitle,
|
||||||
|
description: publishBannerMessage,
|
||||||
|
onClose: () => setPublishBannerMessage(null),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
templateReviewApplyError
|
||||||
|
? {
|
||||||
|
key: "templateApply",
|
||||||
|
status: "danger" as const,
|
||||||
|
title: messages.create.templateReview.errors.applyFailed,
|
||||||
|
description: templateReviewApplyError,
|
||||||
|
onClose: () => setTemplateReviewApplyError(null),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
communitySaveMagicLinkError
|
||||||
|
? {
|
||||||
|
key: "magicLinkError",
|
||||||
|
status: "danger" as const,
|
||||||
|
title: communitySaveMessages.magicLinkErrorTitle,
|
||||||
|
description: communitySaveMagicLinkError,
|
||||||
|
onClose: () => setCommunitySaveMagicLinkError(null),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
communitySaveMagicLinkSuccess
|
||||||
|
? {
|
||||||
|
key: "magicLinkSuccess",
|
||||||
|
status: "positive" as const,
|
||||||
|
title: communitySaveMessages.magicLinkSuccessTitle,
|
||||||
|
description: communitySaveMessages.magicLinkSuccessDescription,
|
||||||
|
onClose: () => setCommunitySaveMagicLinkSuccess(false),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
completedFlowBanner
|
||||||
|
? {
|
||||||
|
key: `completedFlow-${completedFlowBanner.key}`,
|
||||||
|
status: completedFlowBanner.status,
|
||||||
|
title: completedFlowBanner.title,
|
||||||
|
description: completedFlowBanner.description,
|
||||||
|
onClose: () => setCompletedFlowBanner(null),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
].filter((b): b is NonNullable<typeof b> => b !== null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-screen min-h-0 flex-col overflow-hidden bg-black">
|
||||||
|
{topBanners.length > 0 ? (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none fixed left-0 right-0 top-0 z-[200] flex flex-col gap-2 px-[var(--spacing-measures-spacing-500,20px)] pt-[var(--spacing-measures-spacing-300,12px)] md:px-[var(--measures-spacing-1800,64px)]"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{topBanners.map((b) => (
|
||||||
|
<div
|
||||||
|
key={b.key}
|
||||||
|
className="pointer-events-auto mx-auto w-full max-w-[960px]"
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
type="banner"
|
||||||
|
status={b.status}
|
||||||
|
title={b.title}
|
||||||
|
description={b.description}
|
||||||
|
onClose={b.onClose}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<SignedInDraftHydration
|
||||||
|
sessionUser={sessionUser}
|
||||||
|
sessionResolved={sessionResolved}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<PostLoginDraftTransfer sessionUser={sessionUser} />
|
||||||
|
</Suspense>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<CreateFlowPendingAvatarFlush
|
||||||
|
sessionUser={sessionUser}
|
||||||
|
sessionResolved={sessionResolved}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
<Share
|
||||||
|
isOpen={shareModalOpen}
|
||||||
|
onClose={() => setShareModalOpen(false)}
|
||||||
|
onCopyLink={() => void copyPublishedRuleLink()}
|
||||||
|
onEmailShare={mailtoPublishedRule}
|
||||||
|
onSignalShare={() => void sharePublishedRuleViaSignal()}
|
||||||
|
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}
|
||||||
|
hasEdit={isCompletedStep}
|
||||||
|
hasManageStakeholders={isEditRuleStep}
|
||||||
|
saveDraftOnExit={saveDraftOnExit}
|
||||||
|
onShare={
|
||||||
|
isCompletedStep ? () => void handleOpenCompletedShareModal() : undefined
|
||||||
|
}
|
||||||
|
onSelectExportFormat={
|
||||||
|
isCompletedStep ? onCompletedExportFormat : undefined
|
||||||
|
}
|
||||||
|
onEdit={
|
||||||
|
isCompletedStep
|
||||||
|
? () => {
|
||||||
|
const last = readLastPublishedRule();
|
||||||
|
if (!last) return;
|
||||||
|
updateState({
|
||||||
|
editingPublishedRuleId: last.id,
|
||||||
|
sections: [],
|
||||||
|
});
|
||||||
|
router.push(createFlowStepPath("edit-rule"));
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onManageStakeholders={
|
||||||
|
isEditRuleStep
|
||||||
|
? () => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
router.push(
|
||||||
|
createFlowStepPath("confirm-stakeholders", {
|
||||||
|
[CREATE_FLOW_REVIEW_RETURN_QUERY_KEY]: "edit-rule",
|
||||||
|
[CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY]:
|
||||||
|
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onExit={(opts) => void handleExit(opts)}
|
||||||
|
buttonPalette={isCompletedStep ? "inverse" : undefined}
|
||||||
|
className={`shrink-0 ${
|
||||||
|
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : ""
|
||||||
|
}`.trim()}
|
||||||
|
/>
|
||||||
|
<main
|
||||||
|
className={`flex min-h-0 flex-1 w-full ${mainContentClass} ${mainResponsiveLayout}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
{!isCompletedStep && (
|
||||||
|
<CreateFlowFooter
|
||||||
|
className="shrink-0"
|
||||||
|
progressBar={
|
||||||
|
!isTemplateReviewRoute &&
|
||||||
|
!isFinalReviewLike &&
|
||||||
|
reviewReturnTarget !== "edit-rule"
|
||||||
|
}
|
||||||
|
proportionBarProgress={proportionBarProgress}
|
||||||
|
proportionBarVariant="segmented"
|
||||||
|
secondButton={
|
||||||
|
isTemplateReviewRoute ? (
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
|
||||||
|
<Button
|
||||||
|
buttonType="ghost"
|
||||||
|
palette="default"
|
||||||
|
size="xsmall"
|
||||||
|
disabled={isApplyingTemplate}
|
||||||
|
className={CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS}
|
||||||
|
onClick={() => void handleUseTemplateWithoutChanges()}
|
||||||
|
>
|
||||||
|
{messages.create.templateReview.footer.useWithoutChanges}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
buttonType="filled"
|
||||||
|
palette="default"
|
||||||
|
size="xsmall"
|
||||||
|
disabled={isApplyingTemplate}
|
||||||
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
|
onClick={() => void handleCustomizeTemplate()}
|
||||||
|
>
|
||||||
|
{messages.create.templateReview.footer.customize}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : currentStep === "community-name" && nextStep ? (
|
||||||
|
<Button
|
||||||
|
buttonType="filled"
|
||||||
|
palette="default"
|
||||||
|
size="xsmall"
|
||||||
|
disabled={
|
||||||
|
isPublishing ||
|
||||||
|
typeof state.title !== "string" ||
|
||||||
|
state.title.trim().length === 0
|
||||||
|
}
|
||||||
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
|
onClick={() => {
|
||||||
|
goToNextStep();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{footer.confirmName}
|
||||||
|
</Button>
|
||||||
|
) : currentStep === "community-save" && nextStep ? (
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
|
||||||
|
<Button
|
||||||
|
buttonType="outline"
|
||||||
|
palette="default"
|
||||||
|
size="xsmall"
|
||||||
|
disabled={isPublishing}
|
||||||
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
|
onClick={() => {
|
||||||
|
goToNextStep();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{footer.saveLater}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
buttonType="filled"
|
||||||
|
palette="default"
|
||||||
|
size="xsmall"
|
||||||
|
disabled={
|
||||||
|
isPublishing ||
|
||||||
|
communitySaveMagicLinkSubmitting ||
|
||||||
|
communitySaveMagicLinkSuccess ||
|
||||||
|
!isValidCreateFlowSaveEmail(state.communitySaveEmail)
|
||||||
|
}
|
||||||
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
|
onClick={() => {
|
||||||
|
void handleCommunitySaveMagicLinkSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{communitySaveMagicLinkSubmitting
|
||||||
|
? footer.submitEmailSending
|
||||||
|
: footer.submitEmail}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : currentStep === "review" && nextStep ? (
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
|
||||||
|
<Button
|
||||||
|
buttonType="outline"
|
||||||
|
palette="default"
|
||||||
|
size="xsmall"
|
||||||
|
disabled={isPublishing}
|
||||||
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
|
onClick={() => {
|
||||||
|
// Scrub any prior template-customize prefill so entering
|
||||||
|
// the custom-rule stage from review is always a clean slate.
|
||||||
|
resetCustomRuleSelections();
|
||||||
|
goToNextStep();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{footer.createCustom}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
buttonType="filled"
|
||||||
|
palette="default"
|
||||||
|
size="xsmall"
|
||||||
|
disabled={isPublishing}
|
||||||
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
|
onClick={() => {
|
||||||
|
// `fromFlow=1` tells `/templates` to skip the fresh-slate
|
||||||
|
// draft clear it normally runs on template click, so the
|
||||||
|
// user's in-progress Create Community stage survives this
|
||||||
|
// detour. Direct entries to `/templates` (no marker) and
|
||||||
|
// home "Popular templates" clicks always start fresh by
|
||||||
|
// wiping anonymous draft storage at click time.
|
||||||
|
router.push(
|
||||||
|
`/templates?${TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY}=${TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE}&${TEMPLATES_FACET_RECOMMEND_QUERY}=${TEMPLATES_FACET_RECOMMEND_VALUE}`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{footer.createFromTemplate}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : showCustomRuleFooterConfirm &&
|
||||||
|
customRuleConfirmFooter ? (
|
||||||
|
<Button
|
||||||
|
buttonType="filled"
|
||||||
|
palette="default"
|
||||||
|
size="xsmall"
|
||||||
|
disabled={
|
||||||
|
isPublishing ||
|
||||||
|
customRuleConfirmFooter.selectionIds(state).length === 0
|
||||||
|
}
|
||||||
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
|
onClick={() => {
|
||||||
|
const cf = customRuleConfirmFooter;
|
||||||
|
const facet = methodCardFacetSectionForConfirmStep(cf.step);
|
||||||
|
if (facet != null && cf.selectionIds(state).length > 0) {
|
||||||
|
setMethodSectionsPinCommitted(facet, true);
|
||||||
|
}
|
||||||
|
if (reviewReturnTarget) {
|
||||||
|
router.push(
|
||||||
|
createFlowStepPathAfterStrippingReviewReturn(
|
||||||
|
reviewReturnTarget,
|
||||||
|
searchParams,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
goToNextStep();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{footer[customRuleConfirmFooter.footerMessageKey]}
|
||||||
|
</Button>
|
||||||
|
) : isConfirmStakeholdersManagePublished ? (
|
||||||
|
<Button
|
||||||
|
buttonType="filled"
|
||||||
|
palette="default"
|
||||||
|
size="xsmall"
|
||||||
|
disabled={isPublishing}
|
||||||
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
|
onClick={() => {
|
||||||
|
router.push(
|
||||||
|
createFlowStepPathAfterStrippingReviewReturn(
|
||||||
|
"edit-rule",
|
||||||
|
searchParams,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
create.reviewAndComplete.confirmStakeholders.managePublished
|
||||||
|
.footerDone
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
) : nextStep || isFinalReviewLike ? (
|
||||||
|
<Button
|
||||||
|
buttonType="filled"
|
||||||
|
palette="default"
|
||||||
|
size="xsmall"
|
||||||
|
disabled={isPublishing}
|
||||||
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
|
onClick={() => {
|
||||||
|
if (isFinalReviewLike) {
|
||||||
|
void handleFinalize();
|
||||||
|
} else {
|
||||||
|
goToNextStep();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isFinalReviewLike
|
||||||
|
? isPublishing
|
||||||
|
? messages.create.reviewAndComplete.publish
|
||||||
|
.finalizeButtonPublishing
|
||||||
|
: footer.finalizeCommunityRule
|
||||||
|
: getDefaultFooterLabel(currentStep, footer)}
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
onBackClick={
|
||||||
|
isTemplateReviewRoute
|
||||||
|
? () =>
|
||||||
|
router.push(
|
||||||
|
templateReviewFooterBackToCreateReview
|
||||||
|
? CREATE_ROUTES.review
|
||||||
|
: CREATE_ROUTES.root,
|
||||||
|
)
|
||||||
|
: reviewReturnTarget
|
||||||
|
? () => {
|
||||||
|
router.push(
|
||||||
|
createFlowStepPathAfterStrippingReviewReturn(
|
||||||
|
reviewReturnTarget,
|
||||||
|
searchParams,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: previousStep
|
||||||
|
? goToPreviousStep
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateFlowLayoutClient({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return <CreateFlowSessionShell>{children}</CreateFlowSessionShell>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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: () => <CreateFlowLayoutLoading />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function CreateFlowLayoutGate({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return <CreateFlowLayoutClient>{children}</CreateFlowLayoutClient>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import {
|
||||||
|
clearAnonymousCreateFlowStorage,
|
||||||
|
hasTransferPendingFlag,
|
||||||
|
readAnonymousCreateFlowState,
|
||||||
|
} from "./utils/anonymousDraftStorage";
|
||||||
|
import { useCreateFlow } from "./context/CreateFlowContext";
|
||||||
|
import { parseCreateFlowScreenFromPathname } from "./utils/flowSteps";
|
||||||
|
import { fetchDraftFromServer, saveDraftToServer } from "../../../lib/create/api";
|
||||||
|
import { createFlowStateHasKeys } from "../../../lib/create/draftHydrationUtils";
|
||||||
|
import type { CreateFlowState } from "./types";
|
||||||
|
import messages from "../../../messages/en/index";
|
||||||
|
import Alert from "../../components/modals/Alert";
|
||||||
|
|
||||||
|
import { isBackendSyncEnabled } from "../../../lib/create/backendSyncEnabled";
|
||||||
|
|
||||||
|
function buildPayloadWithStep(
|
||||||
|
base: CreateFlowState,
|
||||||
|
pathname: string | null,
|
||||||
|
): CreateFlowState {
|
||||||
|
const step =
|
||||||
|
parseCreateFlowScreenFromPathname(pathname ?? null) ?? undefined;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...(step ? { currentStep: step } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefer the on-device anonymous mirror when present; otherwise use the draft
|
||||||
|
* stored on the magic-link token at request time (written during verify).
|
||||||
|
*/
|
||||||
|
async function resolvePostLoginDraftPayload(
|
||||||
|
local: CreateFlowState,
|
||||||
|
pathname: string | null,
|
||||||
|
): Promise<CreateFlowState | null> {
|
||||||
|
const localPayload = createFlowStateHasKeys(local)
|
||||||
|
? buildPayloadWithStep(local, pathname)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const serverDraft = await fetchDraftFromServer();
|
||||||
|
const serverPayload =
|
||||||
|
serverDraft != null && createFlowStateHasKeys(serverDraft)
|
||||||
|
? buildPayloadWithStep(serverDraft, pathname)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (localPayload && serverPayload) {
|
||||||
|
return { ...serverPayload, ...localPayload };
|
||||||
|
}
|
||||||
|
return localPayload ?? serverPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After magic-link verify, redirects to `/create/...?syncDraft=1` with session cookie.
|
||||||
|
* With backend sync: PUT draft once when the device mirror is non-empty, then hydrates
|
||||||
|
* context. Without sync: hydrates from localStorage and/or the server draft saved at
|
||||||
|
* verify. Never writes an empty payload over an existing server draft.
|
||||||
|
*/
|
||||||
|
export function PostLoginDraftTransfer({
|
||||||
|
sessionUser,
|
||||||
|
}: {
|
||||||
|
sessionUser: { id: string; email: string } | null | undefined;
|
||||||
|
}) {
|
||||||
|
const { replaceState } = useCreateFlow();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const syncDraft = searchParams.get("syncDraft");
|
||||||
|
const [transferError, setTransferError] = useState<string | null>(null);
|
||||||
|
const attemptedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessionUser == null || sessionUser === undefined) return;
|
||||||
|
const wantsTransfer = syncDraft === "1" || hasTransferPendingFlag();
|
||||||
|
if (!wantsTransfer) return;
|
||||||
|
if (attemptedRef.current) return;
|
||||||
|
|
||||||
|
attemptedRef.current = true;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const local = readAnonymousCreateFlowState();
|
||||||
|
const pending = hasTransferPendingFlag();
|
||||||
|
|
||||||
|
if (!createFlowStateHasKeys(local) && !pending) {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.delete("syncDraft");
|
||||||
|
const q = params.toString();
|
||||||
|
if (pathname) {
|
||||||
|
router.replace(q ? `${pathname}?${q}` : pathname);
|
||||||
|
}
|
||||||
|
attemptedRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await resolvePostLoginDraftPayload(local, pathname);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (payload == null || !createFlowStateHasKeys(payload)) {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.delete("syncDraft");
|
||||||
|
const q = params.toString();
|
||||||
|
if (pathname) {
|
||||||
|
router.replace(q ? `${pathname}?${q}` : pathname);
|
||||||
|
}
|
||||||
|
attemptedRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBackendSyncEnabled() && createFlowStateHasKeys(local)) {
|
||||||
|
const saveResult = await saveDraftToServer(payload);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (saveResult.ok === false) {
|
||||||
|
setTransferError(
|
||||||
|
messages.create.topNav.postLoginSaveFailedWithReason.replace(
|
||||||
|
"{reason}",
|
||||||
|
saveResult.message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
attemptedRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAnonymousCreateFlowStorage();
|
||||||
|
replaceState(payload);
|
||||||
|
|
||||||
|
if (pathname) {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.delete("syncDraft");
|
||||||
|
const q = params.toString();
|
||||||
|
router.replace(q ? `${pathname}?${q}` : pathname);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [sessionUser, pathname, syncDraft, replaceState, router, searchParams]);
|
||||||
|
|
||||||
|
if (!transferError) return null;
|
||||||
|
|
||||||
|
const [titleLine, ...rest] = transferError.split(/\n\n+/);
|
||||||
|
const title = (titleLine ?? transferError).trim();
|
||||||
|
const description = rest.join("\n\n").trim() || undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-[150] flex justify-center px-5 md:bottom-6">
|
||||||
|
<div className="pointer-events-auto w-full max-w-[640px]">
|
||||||
|
<Alert
|
||||||
|
type="banner"
|
||||||
|
status="danger"
|
||||||
|
size="s"
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
hasBodyText={Boolean(description)}
|
||||||
|
hasLeadingIcon
|
||||||
|
onClose={() => {
|
||||||
|
setTransferError(null);
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import type { CreateFlowState } from "./types";
|
||||||
|
import { createFlowStateHasKeys } from "../../../lib/create/draftHydrationUtils";
|
||||||
|
import {
|
||||||
|
hasTransferPendingFlag,
|
||||||
|
readAnonymousCreateFlowState,
|
||||||
|
} from "./utils/anonymousDraftStorage";
|
||||||
|
import { useCreateFlow } from "./context/CreateFlowContext";
|
||||||
|
import { fetchDraftFromServer } from "../../../lib/create/api";
|
||||||
|
import messages from "../../../messages/en/index";
|
||||||
|
import Alert from "../../components/modals/Alert";
|
||||||
|
import {
|
||||||
|
isValidStep,
|
||||||
|
parseCreateFlowScreenFromPathname,
|
||||||
|
} from "./utils/flowSteps";
|
||||||
|
|
||||||
|
import { isBackendSyncEnabled } from "../../../lib/create/backendSyncEnabled";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When sync is on and the user is signed in, restore the server-side draft only
|
||||||
|
* when there is no in-flight localStorage draft to defer to. localStorage is
|
||||||
|
* the on-every-keystroke buffer (CreateFlowProvider mirrors state there for
|
||||||
|
* everyone), so a refresh mid-flow already has the freshest data; pulling the
|
||||||
|
* server draft on top would clobber unsaved keystrokes with a stale snapshot.
|
||||||
|
*
|
||||||
|
* Server draft becomes authoritative only when localStorage is empty — i.e.
|
||||||
|
* fresh device, after explicit Save & Exit (which clears localStorage),
|
||||||
|
* after Exit-from-completed clears local state, or after
|
||||||
|
* {@link prepareFreshCreateFlowEntry} (Create rule / new template entry) clears
|
||||||
|
* local + deletes the server draft when sync is on.
|
||||||
|
*
|
||||||
|
* Skips when `?syncDraft=1` or transfer-pending — {@link PostLoginDraftTransfer}
|
||||||
|
* owns that path.
|
||||||
|
*/
|
||||||
|
export function SignedInDraftHydration({
|
||||||
|
sessionUser,
|
||||||
|
sessionResolved,
|
||||||
|
}: {
|
||||||
|
sessionUser: { id: string; email: string } | null | undefined;
|
||||||
|
sessionResolved: boolean;
|
||||||
|
}) {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const syncDraftParam = searchParams.get("syncDraft");
|
||||||
|
const { replaceState, interactionTouched } = useCreateFlow();
|
||||||
|
const touchedRef = useRef(interactionTouched);
|
||||||
|
touchedRef.current = interactionTouched;
|
||||||
|
|
||||||
|
const [loadingHydration, setLoadingHydration] = useState(false);
|
||||||
|
const finishedUserIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isBackendSyncEnabled()) return;
|
||||||
|
if (!sessionResolved) return;
|
||||||
|
if (sessionUser == null || sessionUser === undefined) {
|
||||||
|
finishedUserIdRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = sessionUser.id;
|
||||||
|
if (finishedUserIdRef.current === userId) return;
|
||||||
|
|
||||||
|
if (syncDraftParam === "1" || hasTransferPendingFlag()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local draft wins over server: no fetch, no replaceState. The provider
|
||||||
|
// already hydrated from localStorage at mount, so the user sees their
|
||||||
|
// unsaved keystrokes immediately.
|
||||||
|
if (createFlowStateHasKeys(readAnonymousCreateFlowState())) {
|
||||||
|
finishedUserIdRef.current = userId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlStep = parseCreateFlowScreenFromPathname(pathname ?? null);
|
||||||
|
/** Owner “view published rule” shell — never merge server draft or redirect to `currentStep`. */
|
||||||
|
if (urlStep === "completed") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setLoadingHydration(true);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const serverDraft = await fetchDraftFromServer();
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (touchedRef.current) {
|
||||||
|
finishedUserIdRef.current = userId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverDraft != null && createFlowStateHasKeys(serverDraft)) {
|
||||||
|
const next = serverDraft as CreateFlowState;
|
||||||
|
replaceState(next);
|
||||||
|
const saved = next.currentStep;
|
||||||
|
if (saved && isValidStep(saved)) {
|
||||||
|
const urlStep = parseCreateFlowScreenFromPathname(pathname ?? null);
|
||||||
|
if (urlStep !== saved) {
|
||||||
|
router.replace(`/create/${saved}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finishedUserIdRef.current = userId;
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoadingHydration(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
sessionResolved,
|
||||||
|
sessionUser,
|
||||||
|
syncDraftParam,
|
||||||
|
replaceState,
|
||||||
|
pathname,
|
||||||
|
router,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!loadingHydration) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed left-0 right-0 top-14 z-[170] flex justify-center px-[var(--spacing-measures-spacing-500,20px)] pt-2 md:top-16 md:px-[var(--measures-spacing-1800,64px)]">
|
||||||
|
<div className="pointer-events-auto w-full max-w-[960px]">
|
||||||
|
<Alert
|
||||||
|
type="banner"
|
||||||
|
status="default"
|
||||||
|
size="s"
|
||||||
|
title={messages.create.draftHydration.loadingSavedProgress}
|
||||||
|
hasBodyText={false}
|
||||||
|
hasLeadingIcon={false}
|
||||||
|
hasTrailingIcon={false}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { CreateFlowScreenView } from "../screens/CreateFlowScreenView";
|
||||||
|
import { isValidStep } from "../utils/flowSteps";
|
||||||
|
import type { CreateFlowStep } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single dynamic route for the whole create wizard (every step in `FLOW_STEP_ORDER`).
|
||||||
|
*
|
||||||
|
* Only **canonical** `screenId` values from `CreateFlowStep` are valid. Old placeholder
|
||||||
|
* segments from pre-product shells are not redirected — unknown slugs `notFound()`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ screenId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CreateFlowScreenPage({ params }: PageProps) {
|
||||||
|
const { screenId: raw } = await params;
|
||||||
|
|
||||||
|
if (!isValidStep(raw)) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <CreateFlowScreenView screenId={raw as CreateFlowStep} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared "Applicable Scope" field used by the `decision-approaches` create-flow
|
||||||
|
* modal. Pairs an `InputLabel` with a horizontally-wrapping list of
|
||||||
|
* toggle-chips plus an inline "+ Add" affordance that reveals a pill text input
|
||||||
|
* for creating new scope values. Conflict management uses
|
||||||
|
* `ModalTextAreaField` instead (Figma `20874:172292`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { memo, useState } from "react";
|
||||||
|
import Chip from "../../../components/controls/Chip";
|
||||||
|
import InputLabel from "../../../components/type/InputLabel";
|
||||||
|
|
||||||
|
export interface ApplicableScopeFieldProps {
|
||||||
|
/** Label rendered above the capsule row. */
|
||||||
|
label: string;
|
||||||
|
/** Text for the "+ Add …" affordance (e.g. "Add Applicable Scope"). */
|
||||||
|
addLabel: string;
|
||||||
|
/**
|
||||||
|
* The full list of chip values shown to the user. Each value is a unique
|
||||||
|
* string (chip label).
|
||||||
|
*/
|
||||||
|
scopes: string[];
|
||||||
|
/** Values currently toggled on (rendered in the Chip "Selected" state). */
|
||||||
|
selectedScopes: string[];
|
||||||
|
/** Fired when a chip is clicked; caller toggles inclusion in `selectedScopes`. */
|
||||||
|
onToggleScope: (_scope: string) => void;
|
||||||
|
/**
|
||||||
|
* Fired when the user submits a new scope via the inline input. Duplicate
|
||||||
|
* values (already in `scopes`) are filtered out before the callback fires.
|
||||||
|
*/
|
||||||
|
onAddScope: (_scope: string) => void;
|
||||||
|
/**
|
||||||
|
* Optional placeholder for the inline input. Defaults to `addLabel`.
|
||||||
|
*/
|
||||||
|
inputPlaceholder?: string;
|
||||||
|
/** When true, scope chips and add affordance are non-interactive. */
|
||||||
|
readOnly?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApplicableScopeFieldComponent({
|
||||||
|
label,
|
||||||
|
addLabel,
|
||||||
|
scopes,
|
||||||
|
selectedScopes,
|
||||||
|
onToggleScope,
|
||||||
|
onAddScope,
|
||||||
|
inputPlaceholder,
|
||||||
|
readOnly = false,
|
||||||
|
className = "",
|
||||||
|
}: ApplicableScopeFieldProps) {
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
|
||||||
|
const submitDraft = () => {
|
||||||
|
const trimmed = draft.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setIsAdding(false);
|
||||||
|
setDraft("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!scopes.includes(trimmed)) {
|
||||||
|
onAddScope(trimmed);
|
||||||
|
}
|
||||||
|
setDraft("");
|
||||||
|
setIsAdding(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col gap-2 ${className}`.trim()}>
|
||||||
|
<InputLabel label={label} helpIcon size="s" palette="default" />
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{scopes.map((scope) => {
|
||||||
|
const isSelected = selectedScopes.includes(scope);
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
key={scope}
|
||||||
|
label={scope}
|
||||||
|
state={isSelected ? "selected" : "disabled"}
|
||||||
|
palette="default"
|
||||||
|
size="s"
|
||||||
|
disabled={readOnly}
|
||||||
|
onClick={() => !readOnly && onToggleScope(scope)}
|
||||||
|
ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{readOnly ? null : isAdding ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onBlur={submitDraft}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
submitDraft();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setDraft("");
|
||||||
|
setIsAdding(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={inputPlaceholder ?? addLabel}
|
||||||
|
aria-label={inputPlaceholder ?? addLabel}
|
||||||
|
className="h-[30px] rounded-[9999px] border border-[var(--color-border-default-tertiary)] bg-transparent px-3 font-inter text-[length:var(--sizing-300,12px)] font-medium leading-[14px] text-[color:var(--color-content-default-primary)] outline-none placeholder:text-[color:var(--color-content-default-tertiary)] focus-visible:border-[var(--color-border-default-brand-primary)]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsAdding(true)}
|
||||||
|
className="inline-flex items-center gap-[var(--measures-spacing-050,2px)] rounded-[var(--measures-radius-full,9999px)] px-[var(--space-250,10px)] py-[var(--measures-spacing-200,8px)] font-inter text-[length:var(--sizing-300,12px)] font-medium leading-[14px] text-[color:var(--color-content-default-primary)] hover:bg-[var(--color-surface-default-secondary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-transparent"
|
||||||
|
>
|
||||||
|
<AddGlyph />
|
||||||
|
{addLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddGlyph() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="block size-[14px]"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 5v14M5 12h14"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplicableScopeFieldComponent.displayName = "ApplicableScopeField";
|
||||||
|
|
||||||
|
export default memo(ApplicableScopeFieldComponent);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import HeaderLockup from "../../../components/type/HeaderLockup";
|
||||||
|
import type { HeaderLockupProps } from "../../../components/type/HeaderLockup/HeaderLockup.types";
|
||||||
|
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
|
||||||
|
|
||||||
|
export type CreateFlowHeaderLockupProps = Omit<HeaderLockupProps, "size"> & {
|
||||||
|
/** Omit for responsive `M` below `md`, `L` at/above `md` (matches `--breakpoint-md`). */
|
||||||
|
size?: HeaderLockupProps["size"];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create-flow HeaderLockup: **`L` at/above `md`**, `M` below unless `size` is passed explicitly.
|
||||||
|
*/
|
||||||
|
export function CreateFlowHeaderLockup({
|
||||||
|
size: sizeProp,
|
||||||
|
...rest
|
||||||
|
}: CreateFlowHeaderLockupProps) {
|
||||||
|
const mdUp = useCreateFlowMdUp();
|
||||||
|
const size = sizeProp ?? (mdUp ? "L" : "M");
|
||||||
|
return <HeaderLockup {...rest} size={size} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { CreateFlowHeaderLockup } from "./CreateFlowHeaderLockup";
|
||||||
|
import { CreateFlowStepShell } from "./CreateFlowStepShell";
|
||||||
|
import {
|
||||||
|
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
|
||||||
|
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
||||||
|
} from "./createFlowLayoutTokens";
|
||||||
|
|
||||||
|
/** Shared `Rule` / template card chrome: width + radius; padding comes from `Rule` (L+expanded = 24px). */
|
||||||
|
export const CREATE_FLOW_REVIEW_RULE_LAYOUT_CLASS =
|
||||||
|
"w-full min-w-0 rounded-[12px] md:rounded-[24px] md:max-w-[640px]";
|
||||||
|
|
||||||
|
type CreateFlowLockupCardStepShellProps = {
|
||||||
|
lockupTitle: string;
|
||||||
|
lockupDescription?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Final-review layout: `wideGrid`, two columns from `md:`, column widths from `createFlowLayoutTokens`. */
|
||||||
|
export function CreateFlowLockupCardStepShell({
|
||||||
|
lockupTitle,
|
||||||
|
lockupDescription,
|
||||||
|
children,
|
||||||
|
}: CreateFlowLockupCardStepShellProps) {
|
||||||
|
return (
|
||||||
|
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
|
||||||
|
<div
|
||||||
|
className={`mx-auto flex w-full min-w-0 flex-col gap-4 md:grid md:w-full md:grid-cols-2 md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex min-w-0 flex-col justify-start md:justify-center ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||||
|
>
|
||||||
|
<CreateFlowHeaderLockup
|
||||||
|
title={lockupTitle}
|
||||||
|
description={lockupDescription}
|
||||||
|
justification="left"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`flex min-w-0 flex-col items-stretch ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CreateFlowStepShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||||
|
import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer";
|
||||||
|
import {
|
||||||
|
clearPendingCommunityAvatarFile,
|
||||||
|
readPendingCommunityAvatarFile,
|
||||||
|
} from "../../../../lib/create/pendingCommunityAvatarUpload";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After sign-in, uploads a community avatar staged in IndexedDB (anonymous pick)
|
||||||
|
* and writes `communityAvatarUrl` on success.
|
||||||
|
*/
|
||||||
|
export function CreateFlowPendingAvatarFlush({
|
||||||
|
sessionUser,
|
||||||
|
sessionResolved,
|
||||||
|
}: {
|
||||||
|
sessionUser: { id: string; email: string } | null | undefined;
|
||||||
|
sessionResolved: boolean;
|
||||||
|
}) {
|
||||||
|
const { updateState } = useCreateFlow();
|
||||||
|
/** One successful flush per signed-in user id (survives React StrictMode remounts). */
|
||||||
|
const lastFlushedUserIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionResolved || !sessionUser) return;
|
||||||
|
if (lastFlushedUserIdRef.current === sessionUser.id) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const file = await readPendingCommunityAvatarFile();
|
||||||
|
if (cancelled || !file) return;
|
||||||
|
try {
|
||||||
|
const { url } = await uploadCreateFlowFile(file, "communityAvatar");
|
||||||
|
if (cancelled) return;
|
||||||
|
await clearPendingCommunityAvatarFile();
|
||||||
|
updateState({ communityAvatarUrl: url });
|
||||||
|
lastFlushedUserIdRef.current = sessionUser.id;
|
||||||
|
} catch {
|
||||||
|
// Leave pending blob in place so the user can retry after fixing auth / UPLOAD_ROOT.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [sessionResolved, sessionUser, updateState]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export type CreateFlowStepShellVariant =
|
||||||
|
| "centeredNarrow"
|
||||||
|
| "centeredNarrowBottomPad"
|
||||||
|
| "wideGrid"
|
||||||
|
| "wideGridLoosePadding"
|
||||||
|
| "bare";
|
||||||
|
|
||||||
|
/** Semantic top padding below create-flow top nav (applied at all breakpoints; name is legacy). */
|
||||||
|
export type CreateFlowContentTopBelowMd = "none" | "space-1400" | "space-800";
|
||||||
|
|
||||||
|
const outerByVariant: Record<CreateFlowStepShellVariant, string> = {
|
||||||
|
centeredNarrow:
|
||||||
|
"flex w-full min-w-0 flex-col items-center px-5 md:px-16",
|
||||||
|
centeredNarrowBottomPad:
|
||||||
|
"flex w-full min-w-0 flex-col items-center px-5 pb-28 md:px-[var(--measures-spacing-1800,64px)] md:pb-32",
|
||||||
|
/** Wide two-column steps; 1328px = two 640px columns + 48px gutter. */
|
||||||
|
wideGrid: "w-full min-w-0 max-w-[1328px] shrink-0 px-5 md:px-12",
|
||||||
|
/** Create Community review + card grid (Figma Flow — Review `19706:12135`): max width 1440. */
|
||||||
|
wideGridLoosePadding:
|
||||||
|
"w-full min-w-0 max-w-[1440px] shrink-0 px-5 md:px-16",
|
||||||
|
bare: "w-full min-w-0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentTopBelowMdClass: Record<CreateFlowContentTopBelowMd, string> = {
|
||||||
|
none: "",
|
||||||
|
"space-1400": "pt-[var(--space-1400)]",
|
||||||
|
"space-800": "pt-[var(--space-800)]",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CreateFlowStepShellProps {
|
||||||
|
children: ReactNode;
|
||||||
|
variant?: CreateFlowStepShellVariant;
|
||||||
|
/** Top spacing below top chrome (`CreateFlowTextFieldScreen` defaults to `space-1400`). */
|
||||||
|
contentTopBelowMd?: CreateFlowContentTopBelowMd;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared horizontal padding and width constraints for create-flow step pages.
|
||||||
|
* Horizontal padding uses Tailwind `md:` so it tracks `--breakpoint-md` (640px in `app/tailwind.css`).
|
||||||
|
*/
|
||||||
|
export function CreateFlowStepShell({
|
||||||
|
children,
|
||||||
|
variant = "centeredNarrow",
|
||||||
|
contentTopBelowMd = "none",
|
||||||
|
className = "",
|
||||||
|
}: CreateFlowStepShellProps) {
|
||||||
|
const topClass = contentTopBelowMdClass[contentTopBelowMd];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${outerByVariant[variant]} ${topClass} ${className}`.trim()}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
CreateFlowStepShell,
|
||||||
|
type CreateFlowContentTopBelowMd,
|
||||||
|
} from "./CreateFlowStepShell";
|
||||||
|
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "./createFlowLayoutTokens";
|
||||||
|
|
||||||
|
export type CreateFlowSelectShellLgVerticalAlign = "center" | "start";
|
||||||
|
|
||||||
|
interface CreateFlowTwoColumnSelectShellProps {
|
||||||
|
header: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* Top padding below create-flow chrome. Select steps use `space-1400`; right-rail uses `space-800`
|
||||||
|
* (Figma Flow — Right Rail).
|
||||||
|
*/
|
||||||
|
contentTopBelowMd?: CreateFlowContentTopBelowMd;
|
||||||
|
/**
|
||||||
|
* At `lg+`, layout variant: `"center"` = vertically centered pair (community size/structure).
|
||||||
|
* `"start"` = top-weighted layout with a scrollable right column (core values, right-rail): uses `items-stretch`
|
||||||
|
* so the right column gets a bounded height; `items-start` would grow with content and break scroll.
|
||||||
|
*/
|
||||||
|
lgVerticalAlign?: CreateFlowSelectShellLgVerticalAlign;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-column layout for create-flow select steps (community size/structure, core values) and
|
||||||
|
* {@link DecisionApproachesScreen} (decision approaches). Below `lg` (1024px), one column + main scrolls.
|
||||||
|
* At `lg+`, mirrors {@link CompletedScreen}: static header column + scrollable controls column
|
||||||
|
* (`min-h-0` + `overflow-y-auto` height chain; see completed page right rail).
|
||||||
|
*/
|
||||||
|
export function CreateFlowTwoColumnSelectShell({
|
||||||
|
header,
|
||||||
|
children,
|
||||||
|
contentTopBelowMd = "space-1400",
|
||||||
|
lgVerticalAlign = "center",
|
||||||
|
}: CreateFlowTwoColumnSelectShellProps) {
|
||||||
|
/** `stretch` is required for `min-h-0` + `overflow-y-auto` on the right column. */
|
||||||
|
const rowLgCrossAlignClass =
|
||||||
|
lgVerticalAlign === "start" ? "lg:items-stretch" : "lg:items-center";
|
||||||
|
|
||||||
|
const leftLgMainJustifyClass =
|
||||||
|
lgVerticalAlign === "start" ? "lg:justify-start" : "lg:justify-center";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreateFlowStepShell
|
||||||
|
variant="centeredNarrow"
|
||||||
|
contentTopBelowMd={contentTopBelowMd}
|
||||||
|
className={
|
||||||
|
/* Below `lg`: natural height — same as legacy select screens (main scrolls). */
|
||||||
|
/* At `lg+`: fill main + clip so only the right column scrolls (CompletedScreen pattern). */
|
||||||
|
"w-full min-w-0 max-lg:flex-none lg:min-h-0 lg:h-full lg:max-h-full lg:flex-1 lg:overflow-hidden lg:items-stretch lg:self-stretch"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] md:max-w-[640px] " +
|
||||||
|
"max-lg:flex-none lg:max-h-full lg:max-w-[1328px] lg:min-h-0 lg:flex-1 lg:flex-row lg:flex-nowrap " +
|
||||||
|
`${rowLgCrossAlignClass} lg:justify-center lg:gap-[var(--measures-spacing-1200,48px)] lg:overflow-hidden`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
`flex w-full min-w-0 shrink-0 flex-col items-start gap-[var(--measures-spacing-200,8px)] ` +
|
||||||
|
`lg:flex-1 ${leftLgMainJustifyClass} lg:py-[12px] lg:max-w-[640px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
`scrollbar-hide relative flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-800,32px)] ` +
|
||||||
|
`overflow-x-hidden lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pb-[var(--measures-spacing-300,12px)] ` +
|
||||||
|
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CreateFlowStepShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
+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";
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import ContentLockup from "../../../components/type/ContentLockup";
|
||||||
|
import { useMessages } from "../../../contexts/MessagesContext";
|
||||||
|
import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks";
|
||||||
|
import type { CreateFlowState } from "../types";
|
||||||
|
import CustomMethodCardFieldBlocksSummary from "./CustomMethodCardFieldBlocksSummary";
|
||||||
|
import CustomMethodCardPresetEditPlaceholder from "./CustomMethodCardPresetEditPlaceholder";
|
||||||
|
|
||||||
|
/** Body for Create modals when the card is user-authored (custom UUID). */
|
||||||
|
export default function CustomMethodCardModalBody({
|
||||||
|
cardId,
|
||||||
|
blocksById,
|
||||||
|
/** When set, used instead of `blocksById[cardId]` (e.g. final-review draft). */
|
||||||
|
blocksOverride,
|
||||||
|
onFieldBlocksChange,
|
||||||
|
policyMeta,
|
||||||
|
/**
|
||||||
|
* When false, omit {@link ContentLockup} for title/description (Customize mode:
|
||||||
|
* {@link MethodCardCustomizeModalHeader} already edits them). Summary line still shows.
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
showPolicyContentLockupWhenNoBlocks = true,
|
||||||
|
}: {
|
||||||
|
cardId: string;
|
||||||
|
blocksById: CreateFlowState["customMethodCardFieldBlocksById"];
|
||||||
|
blocksOverride?: CustomMethodCardFieldBlock[] | null;
|
||||||
|
onFieldBlocksChange?: (_blocks: CustomMethodCardFieldBlock[]) => void;
|
||||||
|
policyMeta?: { label: string; supportText: string };
|
||||||
|
showPolicyContentLockupWhenNoBlocks?: boolean;
|
||||||
|
}) {
|
||||||
|
const m = useMessages();
|
||||||
|
const blocks = blocksOverride ?? blocksById?.[cardId];
|
||||||
|
if (blocks && blocks.length > 0) {
|
||||||
|
return (
|
||||||
|
<CustomMethodCardFieldBlocksSummary
|
||||||
|
blocks={blocks}
|
||||||
|
onBlocksChange={onFieldBlocksChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = policyMeta?.label?.trim() ?? "";
|
||||||
|
const support = policyMeta?.supportText?.trim() ?? "";
|
||||||
|
if (label.length > 0 || support.length > 0) {
|
||||||
|
const noFieldsHint = m.create.customRule.customMethodCardWizard.editModal
|
||||||
|
.noCustomFieldsYet;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{showPolicyContentLockupWhenNoBlocks ? (
|
||||||
|
<ContentLockup
|
||||||
|
title={label.length > 0 ? label : undefined}
|
||||||
|
description={support.length > 0 ? support : undefined}
|
||||||
|
variant="modal"
|
||||||
|
alignment="left"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{noFieldsHint.trim().length > 0 ? (
|
||||||
|
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m,15px)] leading-[var(--line-height-body-m,22px)] text-[var(--color-content-default-secondary)]">
|
||||||
|
{noFieldsHint}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <CustomMethodCardPresetEditPlaceholder />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown in method-card Create modals and final-review chip edit when the chip
|
||||||
|
* is user-authored (`customMethodCardMetaById`) — preset section editors do
|
||||||
|
* not apply until structured parity exists with wizard field blocks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { useMessages } from "../../../contexts/MessagesContext";
|
||||||
|
|
||||||
|
function CustomMethodCardPresetEditPlaceholderComponent() {
|
||||||
|
const m = useMessages();
|
||||||
|
const body = m.create.customRule.customMethodCardWizard.editModal.placeholderBody;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m,15px)] leading-[var(--line-height-body-m,22px)] text-[var(--color-content-default-secondary)]">
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomMethodCardPresetEditPlaceholderComponent.displayName =
|
||||||
|
"CustomMethodCardPresetEditPlaceholder";
|
||||||
|
|
||||||
|
export default memo(CustomMethodCardPresetEditPlaceholderComponent);
|
||||||
+433
@@ -0,0 +1,433 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
useMessages,
|
||||||
|
useTranslation,
|
||||||
|
} from "../../../../contexts/MessagesContext";
|
||||||
|
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||||
|
import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants";
|
||||||
|
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
|
||||||
|
import type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.types";
|
||||||
|
import { CustomMethodCardWizardView } from "./CustomMethodCardWizard.view";
|
||||||
|
import type { CustomMethodCardWizardProps } from "./CustomMethodCardWizard.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared 3-step add-custom-method-card flow (Figma Modal / Create — nodes
|
||||||
|
* `20066:14748`, `20094:48551`, `20066:14361`).
|
||||||
|
*/
|
||||||
|
const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||||
|
({ isOpen, onClose, onFinalize, onPersistCustomUploadFile }) => {
|
||||||
|
const m = useMessages();
|
||||||
|
const t = useTranslation("common");
|
||||||
|
const tUpload = useTranslation("create.upload");
|
||||||
|
const w = m.create.customRule.customMethodCardWizard;
|
||||||
|
const menuCopy = m.create.customRule.modalKebabMenu;
|
||||||
|
|
||||||
|
const copy = useMemo(
|
||||||
|
() => ({
|
||||||
|
step1: w.steps["1"],
|
||||||
|
step2: w.steps["2"],
|
||||||
|
step3: w.steps["3"],
|
||||||
|
step3BlocksList: w.step3BlocksList,
|
||||||
|
fieldTypeLabels: {
|
||||||
|
text: w.addCustomField.fieldTypes.text,
|
||||||
|
badges: w.addCustomField.fieldTypes.badges,
|
||||||
|
upload: w.addCustomField.fieldTypes.upload,
|
||||||
|
proportion: w.addCustomField.fieldTypes.proportion,
|
||||||
|
},
|
||||||
|
footerFinalize: w.footer.finalize,
|
||||||
|
fieldModals: w.fieldModals,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
w.addCustomField.fieldTypes,
|
||||||
|
w.fieldModals,
|
||||||
|
w.footer.finalize,
|
||||||
|
w.step3BlocksList,
|
||||||
|
w.steps,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fieldBodiesCopy = useMemo(
|
||||||
|
() => ({
|
||||||
|
requiredHint: copy.fieldModals.requiredHint,
|
||||||
|
text: copy.fieldModals.text,
|
||||||
|
badges: copy.fieldModals.badges,
|
||||||
|
upload: copy.fieldModals.upload,
|
||||||
|
proportion: copy.fieldModals.proportion,
|
||||||
|
}),
|
||||||
|
[copy.fieldModals],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [wizardStep, setWizardStep] = useState<1 | 2 | 3>(1);
|
||||||
|
const [policyTitle, setPolicyTitle] = useState("");
|
||||||
|
const [policyDescription, setPolicyDescription] = useState("");
|
||||||
|
const [addFieldExpanded, setAddFieldExpanded] = useState(false);
|
||||||
|
const [fieldTypeModal, setFieldTypeModal] =
|
||||||
|
useState<AddCustomFieldType | null>(null);
|
||||||
|
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
|
||||||
|
CustomMethodCardFieldBlock[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
const [textBlockTitle, setTextBlockTitle] = useState("");
|
||||||
|
const [textPlaceholderBody, setTextPlaceholderBody] = useState("");
|
||||||
|
const [badgeBlockTitle, setBadgeBlockTitle] = useState("");
|
||||||
|
const [badgeOptions, setBadgeOptions] = useState<string[]>([]);
|
||||||
|
const [uploadBlockTitle, setUploadBlockTitle] = useState("");
|
||||||
|
const [uploadFileName, setUploadFileName] = useState<string | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const [uploadAssetUrl, setUploadAssetUrl] = useState<string | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const [uploadFieldBusy, setUploadFieldBusy] = useState(false);
|
||||||
|
const [uploadFieldError, setUploadFieldError] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [proportionBlockTitle, setProportionBlockTitle] = useState("");
|
||||||
|
const [proportionDefault, setProportionDefault] = useState(50);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const resetFieldTypeDrafts = useCallback(() => {
|
||||||
|
setTextBlockTitle("");
|
||||||
|
setTextPlaceholderBody("");
|
||||||
|
setBadgeBlockTitle("");
|
||||||
|
setBadgeOptions([]);
|
||||||
|
setUploadBlockTitle("");
|
||||||
|
setUploadFileName(undefined);
|
||||||
|
setUploadAssetUrl(undefined);
|
||||||
|
setUploadFieldBusy(false);
|
||||||
|
setUploadFieldError(null);
|
||||||
|
setProportionBlockTitle("");
|
||||||
|
setProportionDefault(50);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setWizardStep(1);
|
||||||
|
setPolicyTitle("");
|
||||||
|
setPolicyDescription("");
|
||||||
|
setAddFieldExpanded(false);
|
||||||
|
setFieldTypeModal(null);
|
||||||
|
setDraftFieldBlocks([]);
|
||||||
|
resetFieldTypeDrafts();
|
||||||
|
}, [resetFieldTypeDrafts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
}, [isOpen, reset]);
|
||||||
|
|
||||||
|
const dismiss = useCallback(() => {
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
}, [onClose, reset]);
|
||||||
|
|
||||||
|
const titleTrim = policyTitle.trim();
|
||||||
|
const descriptionTrim = policyDescription.trim();
|
||||||
|
|
||||||
|
const stepValid = useMemo(() => {
|
||||||
|
const titleOk =
|
||||||
|
titleTrim.length > 0 &&
|
||||||
|
titleTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS;
|
||||||
|
const descriptionOk =
|
||||||
|
descriptionTrim.length > 0 &&
|
||||||
|
descriptionTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS;
|
||||||
|
if (wizardStep === 1) return titleOk;
|
||||||
|
if (wizardStep === 2) return descriptionOk;
|
||||||
|
return titleOk && descriptionOk;
|
||||||
|
}, [
|
||||||
|
descriptionTrim.length,
|
||||||
|
titleTrim.length,
|
||||||
|
wizardStep,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const fieldModalStepValid = useMemo(() => {
|
||||||
|
if (!fieldTypeModal) return false;
|
||||||
|
if (fieldTypeModal === "text") {
|
||||||
|
const t0 = textBlockTitle.trim();
|
||||||
|
return (
|
||||||
|
t0.length > 0 &&
|
||||||
|
t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (fieldTypeModal === "badges") {
|
||||||
|
const t0 = badgeBlockTitle.trim();
|
||||||
|
return (
|
||||||
|
t0.length > 0 &&
|
||||||
|
t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (fieldTypeModal === "upload") {
|
||||||
|
const t0 = uploadBlockTitle.trim();
|
||||||
|
const titleOk =
|
||||||
|
t0.length > 0 &&
|
||||||
|
t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS;
|
||||||
|
if (!titleOk) return false;
|
||||||
|
if (onPersistCustomUploadFile) {
|
||||||
|
return Boolean(uploadAssetUrl?.trim());
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const t0 = proportionBlockTitle.trim();
|
||||||
|
return (
|
||||||
|
t0.length > 0 &&
|
||||||
|
t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS &&
|
||||||
|
proportionDefault >= 1 &&
|
||||||
|
proportionDefault <= 100
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
badgeBlockTitle,
|
||||||
|
fieldTypeModal,
|
||||||
|
proportionBlockTitle,
|
||||||
|
proportionDefault,
|
||||||
|
textBlockTitle,
|
||||||
|
uploadBlockTitle,
|
||||||
|
uploadAssetUrl,
|
||||||
|
onPersistCustomUploadFile,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const headerTitle =
|
||||||
|
wizardStep === 1
|
||||||
|
? copy.step1.title
|
||||||
|
: wizardStep === 2
|
||||||
|
? copy.step2.title
|
||||||
|
: copy.step3.title;
|
||||||
|
|
||||||
|
const headerDescription =
|
||||||
|
wizardStep === 1
|
||||||
|
? copy.step1.description
|
||||||
|
: wizardStep === 2
|
||||||
|
? copy.step2.description
|
||||||
|
: copy.step3.description;
|
||||||
|
|
||||||
|
const fieldModalHeader = fieldTypeModal
|
||||||
|
? copy.fieldModals[fieldTypeModal]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const shellTitle = fieldModalHeader?.title ?? headerTitle;
|
||||||
|
const shellDescription = fieldModalHeader?.description ?? headerDescription;
|
||||||
|
|
||||||
|
const nextLabel = fieldTypeModal
|
||||||
|
? copy.fieldModals.addField
|
||||||
|
: wizardStep === 3
|
||||||
|
? copy.footerFinalize
|
||||||
|
: t("buttons.next");
|
||||||
|
|
||||||
|
const shellNextDisabled = fieldTypeModal
|
||||||
|
? !fieldModalStepValid
|
||||||
|
: !stepValid;
|
||||||
|
|
||||||
|
const handleShellClose = useCallback(() => {
|
||||||
|
if (fieldTypeModal) {
|
||||||
|
setFieldTypeModal(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dismiss();
|
||||||
|
}, [dismiss, fieldTypeModal]);
|
||||||
|
|
||||||
|
const kebabMenuItems = useMemo<ModalHeaderMenuItem[]>(() => [], []);
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
if (fieldTypeModal) {
|
||||||
|
setFieldTypeModal(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (wizardStep === 1) {
|
||||||
|
dismiss();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWizardStep((s) => (s === 2 ? 1 : 2));
|
||||||
|
}, [dismiss, fieldTypeModal, wizardStep]);
|
||||||
|
|
||||||
|
const handleSelectFieldType = useCallback((ft: AddCustomFieldType) => {
|
||||||
|
resetFieldTypeDrafts();
|
||||||
|
setFieldTypeModal(ft);
|
||||||
|
}, [resetFieldTypeDrafts]);
|
||||||
|
|
||||||
|
const handleFileChosen = useCallback(
|
||||||
|
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
setUploadFileName(file?.name);
|
||||||
|
setUploadAssetUrl(undefined);
|
||||||
|
setUploadFieldError(null);
|
||||||
|
if (!file || !onPersistCustomUploadFile) return;
|
||||||
|
setUploadFieldBusy(true);
|
||||||
|
try {
|
||||||
|
const { url } = await onPersistCustomUploadFile(file);
|
||||||
|
setUploadAssetUrl(url);
|
||||||
|
} catch {
|
||||||
|
setUploadFieldError(tUpload("errors.generic"));
|
||||||
|
} finally {
|
||||||
|
setUploadFieldBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onPersistCustomUploadFile, tUpload],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClearPendingUpload = useCallback(() => {
|
||||||
|
setUploadFileName(undefined);
|
||||||
|
setUploadAssetUrl(undefined);
|
||||||
|
setUploadFieldError(null);
|
||||||
|
setUploadFieldBusy(false);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBadgeAddOption = useCallback((label: string) => {
|
||||||
|
setBadgeOptions((prev) =>
|
||||||
|
prev.includes(label) ? prev : [...prev, label],
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const appendFieldBlock = useCallback(() => {
|
||||||
|
if (!fieldTypeModal || !fieldModalStepValid) return;
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
let block: CustomMethodCardFieldBlock;
|
||||||
|
switch (fieldTypeModal) {
|
||||||
|
case "text":
|
||||||
|
block = {
|
||||||
|
kind: "text",
|
||||||
|
id,
|
||||||
|
blockTitle: textBlockTitle.trim(),
|
||||||
|
placeholderText: textPlaceholderBody,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case "badges":
|
||||||
|
block = {
|
||||||
|
kind: "badges",
|
||||||
|
id,
|
||||||
|
blockTitle: badgeBlockTitle.trim(),
|
||||||
|
options: [...badgeOptions],
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case "upload":
|
||||||
|
block = {
|
||||||
|
kind: "upload",
|
||||||
|
id,
|
||||||
|
blockTitle: uploadBlockTitle.trim(),
|
||||||
|
fileName: uploadFileName,
|
||||||
|
...(uploadAssetUrl?.trim()
|
||||||
|
? { assetUrl: uploadAssetUrl.trim() }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
block = {
|
||||||
|
kind: "proportion",
|
||||||
|
id,
|
||||||
|
blockTitle: proportionBlockTitle.trim(),
|
||||||
|
defaultPercent: proportionDefault,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
setDraftFieldBlocks((prev) => [...prev, block]);
|
||||||
|
setFieldTypeModal(null);
|
||||||
|
}, [
|
||||||
|
badgeBlockTitle,
|
||||||
|
badgeOptions,
|
||||||
|
fieldModalStepValid,
|
||||||
|
fieldTypeModal,
|
||||||
|
proportionBlockTitle,
|
||||||
|
proportionDefault,
|
||||||
|
textBlockTitle,
|
||||||
|
textPlaceholderBody,
|
||||||
|
uploadBlockTitle,
|
||||||
|
uploadFileName,
|
||||||
|
uploadAssetUrl,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleNext = useCallback(() => {
|
||||||
|
if (fieldTypeModal) {
|
||||||
|
appendFieldBlock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!stepValid) return;
|
||||||
|
if (wizardStep === 3) {
|
||||||
|
onFinalize({
|
||||||
|
title: titleTrim,
|
||||||
|
description: descriptionTrim,
|
||||||
|
fieldBlocks: draftFieldBlocks,
|
||||||
|
});
|
||||||
|
dismiss();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWizardStep((s) => (s === 1 ? 2 : 3));
|
||||||
|
}, [
|
||||||
|
appendFieldBlock,
|
||||||
|
descriptionTrim,
|
||||||
|
dismiss,
|
||||||
|
draftFieldBlocks,
|
||||||
|
fieldTypeModal,
|
||||||
|
onFinalize,
|
||||||
|
stepValid,
|
||||||
|
titleTrim,
|
||||||
|
wizardStep,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomMethodCardWizardView
|
||||||
|
isOpen={isOpen}
|
||||||
|
onDismiss={handleShellClose}
|
||||||
|
wizardStep={wizardStep}
|
||||||
|
title={shellTitle}
|
||||||
|
description={shellDescription}
|
||||||
|
policyTitle={policyTitle}
|
||||||
|
policyDescription={policyDescription}
|
||||||
|
addFieldExpanded={addFieldExpanded}
|
||||||
|
copy={copy}
|
||||||
|
maxChars={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
||||||
|
onPolicyTitleChange={setPolicyTitle}
|
||||||
|
onPolicyDescriptionChange={setPolicyDescription}
|
||||||
|
onPressAddCustomField={() => setAddFieldExpanded(true)}
|
||||||
|
onSelectFieldType={handleSelectFieldType}
|
||||||
|
fieldTypeModal={fieldTypeModal}
|
||||||
|
fieldBodiesCopy={fieldBodiesCopy}
|
||||||
|
fieldBodiesProps={{
|
||||||
|
textBlockTitle,
|
||||||
|
textPlaceholderBody,
|
||||||
|
onTextBlockTitleChange: setTextBlockTitle,
|
||||||
|
onTextPlaceholderBodyChange: setTextPlaceholderBody,
|
||||||
|
badgeBlockTitle,
|
||||||
|
badgeOptions,
|
||||||
|
onBadgeBlockTitleChange: setBadgeBlockTitle,
|
||||||
|
onBadgeAddOption: handleBadgeAddOption,
|
||||||
|
uploadBlockTitle,
|
||||||
|
onUploadBlockTitleChange: setUploadBlockTitle,
|
||||||
|
fileInputRef,
|
||||||
|
onFileChosen: handleFileChosen,
|
||||||
|
onClearPendingUpload: handleClearPendingUpload,
|
||||||
|
uploadAssetPreviewUrl: uploadAssetUrl,
|
||||||
|
uploadPersisting:
|
||||||
|
Boolean(fieldTypeModal === "upload" && uploadFieldBusy),
|
||||||
|
uploadBusyHint: tUpload("uploading"),
|
||||||
|
uploadErrorMessage:
|
||||||
|
fieldTypeModal === "upload" ? uploadFieldError : null,
|
||||||
|
proportionBlockTitle,
|
||||||
|
proportionDefault,
|
||||||
|
onProportionBlockTitleChange: setProportionBlockTitle,
|
||||||
|
onProportionDefaultChange: setProportionDefault,
|
||||||
|
}}
|
||||||
|
nextDisabled={shellNextDisabled}
|
||||||
|
nextLabel={nextLabel}
|
||||||
|
showBackButton
|
||||||
|
onBack={handleBack}
|
||||||
|
onNext={handleNext}
|
||||||
|
stepper={!fieldTypeModal}
|
||||||
|
draftFieldBlocks={draftFieldBlocks}
|
||||||
|
onDraftFieldBlocksReorder={setDraftFieldBlocks}
|
||||||
|
kebabMoreOptionsAriaLabel={menuCopy.triggerAriaLabel}
|
||||||
|
kebabMenuAriaLabel={menuCopy.menuAriaLabel}
|
||||||
|
kebabMenuItems={kebabMenuItems}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
CustomMethodCardWizardContainer.displayName = "CustomMethodCardWizard";
|
||||||
|
|
||||||
|
export default CustomMethodCardWizardContainer;
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import type { RefObject } from "react";
|
||||||
|
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
|
||||||
|
import type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.types";
|
||||||
|
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||||
|
|
||||||
|
export interface CustomMethodCardWizardFieldBodiesCopy {
|
||||||
|
requiredHint: string;
|
||||||
|
text: {
|
||||||
|
blockTitleLabel: string;
|
||||||
|
blockTitlePlaceholder: string;
|
||||||
|
placeholderLabel: string;
|
||||||
|
placeholderFieldPlaceholder: string;
|
||||||
|
};
|
||||||
|
badges: {
|
||||||
|
blockTitleLabel: string;
|
||||||
|
blockTitlePlaceholder: string;
|
||||||
|
optionsLabel: string;
|
||||||
|
addOptionLabel: string;
|
||||||
|
};
|
||||||
|
upload: {
|
||||||
|
blockTitleLabel: string;
|
||||||
|
blockTitlePlaceholder: string;
|
||||||
|
uploadFileInputAriaLabel: string;
|
||||||
|
uploadHint: string;
|
||||||
|
uploadPreviewImageAlt: string;
|
||||||
|
clearPendingUploadAriaLabel: string;
|
||||||
|
clearPendingUploadTooltip: string;
|
||||||
|
};
|
||||||
|
proportion: {
|
||||||
|
blockTitleLabel: string;
|
||||||
|
blockTitlePlaceholder: string;
|
||||||
|
defaultLabel: string;
|
||||||
|
decrementAriaLabel: string;
|
||||||
|
incrementAriaLabel: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomMethodCardWizardCopy {
|
||||||
|
step1: { title: string; description: string; fieldPlaceholder: string };
|
||||||
|
step2: { title: string; description: string; fieldPlaceholder: string };
|
||||||
|
step3: { title: string; description: string };
|
||||||
|
step3BlocksList: {
|
||||||
|
listLabel: string;
|
||||||
|
dragHandleAriaLabel: string;
|
||||||
|
};
|
||||||
|
fieldTypeLabels: Record<AddCustomFieldType, string>;
|
||||||
|
footerFinalize: string;
|
||||||
|
fieldModals: {
|
||||||
|
addField: string;
|
||||||
|
requiredHint: string;
|
||||||
|
text: CustomMethodCardWizardFieldBodiesCopy["text"] & {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
badges: CustomMethodCardWizardFieldBodiesCopy["badges"] & {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
upload: CustomMethodCardWizardFieldBodiesCopy["upload"] & {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
proportion: CustomMethodCardWizardFieldBodiesCopy["proportion"] & {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomMethodCardWizardProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Called when the user completes step 3; parent assigns id and persists state. */
|
||||||
|
onFinalize: (payload: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
fieldBlocks: CustomMethodCardFieldBlock[];
|
||||||
|
}) => void;
|
||||||
|
/**
|
||||||
|
* Persists custom-method upload files to `POST /api/uploads` (purpose
|
||||||
|
* `customMethodAttachment`). When omitted, upload field only stores `fileName`.
|
||||||
|
*/
|
||||||
|
onPersistCustomUploadFile?: (file: File) => Promise<{ url: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomMethodCardWizardFieldBodiesViewProps {
|
||||||
|
fieldType: AddCustomFieldType;
|
||||||
|
copy: CustomMethodCardWizardFieldBodiesCopy;
|
||||||
|
textBlockTitle: string;
|
||||||
|
textPlaceholderBody: string;
|
||||||
|
onTextBlockTitleChange: (_v: string) => void;
|
||||||
|
onTextPlaceholderBodyChange: (_v: string) => void;
|
||||||
|
badgeBlockTitle: string;
|
||||||
|
badgeOptions: string[];
|
||||||
|
onBadgeBlockTitleChange: (_v: string) => void;
|
||||||
|
onBadgeAddOption: (_v: string) => void;
|
||||||
|
uploadBlockTitle: string;
|
||||||
|
onUploadBlockTitleChange: (_v: string) => void;
|
||||||
|
fileInputRef: RefObject<HTMLInputElement | null>;
|
||||||
|
onFileChosen: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
/** Clears chosen file, preview URL, and related errors so the user can pick again. */
|
||||||
|
onClearPendingUpload: () => void;
|
||||||
|
/** When set after a successful upload, shows an inline image preview in the modal. */
|
||||||
|
uploadAssetPreviewUrl?: string | null;
|
||||||
|
/** Shown under the upload control while saving to the server. */
|
||||||
|
uploadPersisting?: boolean;
|
||||||
|
/** Replaces upload hint text while `uploadPersisting` is true. */
|
||||||
|
uploadBusyHint?: string;
|
||||||
|
uploadErrorMessage?: string | null;
|
||||||
|
proportionBlockTitle: string;
|
||||||
|
proportionDefault: number;
|
||||||
|
onProportionBlockTitleChange: (_v: string) => void;
|
||||||
|
onProportionDefaultChange: (_v: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomMethodCardWizardViewProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onDismiss: () => void;
|
||||||
|
wizardStep: 1 | 2 | 3;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
policyTitle: string;
|
||||||
|
policyDescription: string;
|
||||||
|
addFieldExpanded: boolean;
|
||||||
|
copy: CustomMethodCardWizardCopy;
|
||||||
|
maxChars: number;
|
||||||
|
onPolicyTitleChange: (v: string) => void;
|
||||||
|
onPolicyDescriptionChange: (v: string) => void;
|
||||||
|
onPressAddCustomField: () => void;
|
||||||
|
onSelectFieldType: (t: AddCustomFieldType) => void;
|
||||||
|
fieldTypeModal: AddCustomFieldType | null;
|
||||||
|
fieldBodiesCopy: CustomMethodCardWizardFieldBodiesCopy;
|
||||||
|
fieldBodiesProps: Omit<
|
||||||
|
CustomMethodCardWizardFieldBodiesViewProps,
|
||||||
|
"fieldType" | "copy"
|
||||||
|
>;
|
||||||
|
draftFieldBlocks: CustomMethodCardFieldBlock[];
|
||||||
|
onDraftFieldBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void;
|
||||||
|
nextDisabled: boolean;
|
||||||
|
nextLabel: string;
|
||||||
|
showBackButton: boolean;
|
||||||
|
onBack: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
stepper: boolean;
|
||||||
|
kebabMoreOptionsAriaLabel: string;
|
||||||
|
kebabMenuAriaLabel: string;
|
||||||
|
kebabMenuItems: ModalHeaderMenuItem[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import Create from "../../../../components/modals/Create";
|
||||||
|
import InputWithCounter from "../../../../components/controls/InputWithCounter";
|
||||||
|
import TextArea from "../../../../components/controls/TextArea";
|
||||||
|
import AddCustomField from "../../../../components/controls/AddCustomField";
|
||||||
|
import { CustomMethodCardWizardFieldBodiesView } from "./CustomMethodCardWizardFieldBodies.view";
|
||||||
|
import { CustomMethodCardWizardBlocksList } from "./CustomMethodCardWizardBlocksList.container";
|
||||||
|
import type { CustomMethodCardWizardViewProps } from "./CustomMethodCardWizard.types";
|
||||||
|
|
||||||
|
function CustomMethodCardWizardViewComponent({
|
||||||
|
isOpen,
|
||||||
|
onDismiss,
|
||||||
|
wizardStep,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
policyTitle,
|
||||||
|
policyDescription,
|
||||||
|
addFieldExpanded,
|
||||||
|
copy,
|
||||||
|
maxChars,
|
||||||
|
onPolicyTitleChange,
|
||||||
|
onPolicyDescriptionChange,
|
||||||
|
onPressAddCustomField,
|
||||||
|
onSelectFieldType,
|
||||||
|
fieldTypeModal,
|
||||||
|
fieldBodiesCopy,
|
||||||
|
fieldBodiesProps,
|
||||||
|
nextDisabled,
|
||||||
|
nextLabel,
|
||||||
|
showBackButton,
|
||||||
|
onBack,
|
||||||
|
onNext,
|
||||||
|
stepper,
|
||||||
|
draftFieldBlocks,
|
||||||
|
onDraftFieldBlocksReorder,
|
||||||
|
kebabMoreOptionsAriaLabel,
|
||||||
|
kebabMenuAriaLabel,
|
||||||
|
kebabMenuItems,
|
||||||
|
}: CustomMethodCardWizardViewProps) {
|
||||||
|
return (
|
||||||
|
<Create
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onDismiss}
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
showBackButton={showBackButton}
|
||||||
|
showNextButton
|
||||||
|
onBack={onBack}
|
||||||
|
onNext={onNext}
|
||||||
|
nextButtonText={nextLabel}
|
||||||
|
nextButtonDisabled={nextDisabled}
|
||||||
|
currentStep={wizardStep}
|
||||||
|
totalSteps={3}
|
||||||
|
stepper={stepper}
|
||||||
|
backdropVariant="blurredYellow"
|
||||||
|
kebabTriggerAriaLabel={kebabMoreOptionsAriaLabel}
|
||||||
|
kebabMenuAriaLabel={kebabMenuAriaLabel}
|
||||||
|
kebabMenuItems={kebabMenuItems}
|
||||||
|
>
|
||||||
|
{fieldTypeModal ? (
|
||||||
|
<CustomMethodCardWizardFieldBodiesView
|
||||||
|
fieldType={fieldTypeModal}
|
||||||
|
copy={fieldBodiesCopy}
|
||||||
|
{...fieldBodiesProps}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!fieldTypeModal && wizardStep === 1 ? (
|
||||||
|
<InputWithCounter
|
||||||
|
placeholder={copy.step1.fieldPlaceholder}
|
||||||
|
value={policyTitle}
|
||||||
|
onChange={onPolicyTitleChange}
|
||||||
|
maxLength={maxChars}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!fieldTypeModal && wizardStep === 2 ? (
|
||||||
|
<TextArea
|
||||||
|
appearance="default"
|
||||||
|
formHeader={false}
|
||||||
|
placeholder={copy.step2.fieldPlaceholder}
|
||||||
|
value={policyDescription}
|
||||||
|
maxLength={maxChars}
|
||||||
|
onChange={(e) => onPolicyDescriptionChange(e.target.value)}
|
||||||
|
textHint={`${policyDescription.length}/${maxChars}`}
|
||||||
|
className="w-full"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!fieldTypeModal && wizardStep === 3 ? (
|
||||||
|
<div className="flex w-full flex-col gap-4">
|
||||||
|
{draftFieldBlocks.length > 0 ? (
|
||||||
|
<CustomMethodCardWizardBlocksList
|
||||||
|
blocks={draftFieldBlocks}
|
||||||
|
fieldTypeLabels={copy.fieldTypeLabels}
|
||||||
|
dragHandleAriaLabel={copy.step3BlocksList.dragHandleAriaLabel}
|
||||||
|
listLabel={copy.step3BlocksList.listLabel}
|
||||||
|
onBlocksReorder={onDraftFieldBlocksReorder}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AddCustomField
|
||||||
|
active={addFieldExpanded}
|
||||||
|
onPressAdd={onPressAddCustomField}
|
||||||
|
onSelectFieldType={onSelectFieldType}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Create>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomMethodCardWizardView = memo(
|
||||||
|
CustomMethodCardWizardViewComponent,
|
||||||
|
);
|
||||||
|
CustomMethodCardWizardView.displayName = "CustomMethodCardWizardView";
|
||||||
+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;
|
||||||
|
}
|
||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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 { CustomMethodCardWizardBlocksListViewProps } from "./CustomMethodCardWizardBlocksList.types";
|
||||||
|
|
||||||
|
function DragHandleGlyph({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<circle cx={4} cy={4} r={1.25} fill="currentColor" />
|
||||||
|
<circle cx={12} cy={4} r={1.25} fill="currentColor" />
|
||||||
|
<circle cx={4} cy={8} r={1.25} fill="currentColor" />
|
||||||
|
<circle cx={12} cy={8} r={1.25} fill="currentColor" />
|
||||||
|
<circle cx={4} cy={12} r={1.25} fill="currentColor" />
|
||||||
|
<circle cx={12} cy={12} r={1.25} fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomMethodCardWizardBlocksListViewComponent({
|
||||||
|
blocks,
|
||||||
|
fieldTypeLabels,
|
||||||
|
dragHandleAriaLabel,
|
||||||
|
listLabel,
|
||||||
|
draggingIndex,
|
||||||
|
overIndex,
|
||||||
|
onDragStart,
|
||||||
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
|
onDragEnd,
|
||||||
|
}: CustomMethodCardWizardBlocksListViewProps) {
|
||||||
|
return (
|
||||||
|
<ul className="flex list-none flex-col gap-2 p-0" aria-label={listLabel}>
|
||||||
|
{blocks.map((block, index) => {
|
||||||
|
const kind = block.kind as AddCustomFieldType;
|
||||||
|
const typeLabel = fieldTypeLabels[kind];
|
||||||
|
const isOver = overIndex === index && draggingIndex !== index;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={block.id}
|
||||||
|
className={`flex min-h-[52px] items-stretch gap-2 rounded-[var(--measures-radius-medium,8px)] border border-[var(--color-border-default-primary)] bg-[var(--color-surface-default-secondary)] pl-1 pr-3 py-2 transition-shadow ${
|
||||||
|
isOver
|
||||||
|
? "ring-2 ring-[var(--color-border-invert-primary)] ring-offset-2 ring-offset-[var(--color-surface-default-primary)]"
|
||||||
|
: ""
|
||||||
|
} ${draggingIndex === index ? "opacity-60" : ""}`}
|
||||||
|
onDragOver={onDragOver(index)}
|
||||||
|
onDrop={onDrop(index)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
draggable
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<DragHandleGlyph />
|
||||||
|
</button>
|
||||||
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center self-center">
|
||||||
|
<Icon
|
||||||
|
name={ADD_CUSTOM_FIELD_TYPE_ICONS[kind]}
|
||||||
|
size={24}
|
||||||
|
className="text-[var(--color-content-default-brand-primary,#fefcc9)]"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
|
||||||
|
<span className="truncate font-inter text-[14px] font-medium leading-[18px] text-[var(--color-content-default-primary)]">
|
||||||
|
{block.blockTitle.trim() || typeLabel}
|
||||||
|
</span>
|
||||||
|
<span className="font-inter text-[12px] leading-4 text-[var(--color-content-default-secondary)]">
|
||||||
|
{typeLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomMethodCardWizardBlocksListView = memo(
|
||||||
|
CustomMethodCardWizardBlocksListViewComponent,
|
||||||
|
);
|
||||||
|
CustomMethodCardWizardBlocksListView.displayName =
|
||||||
|
"CustomMethodCardWizardBlocksListView";
|
||||||
+213
@@ -0,0 +1,213 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
|
||||||
|
import InputWithCounter from "../../../../components/controls/InputWithCounter";
|
||||||
|
import TextArea from "../../../../components/controls/TextArea";
|
||||||
|
import TextInput from "../../../../components/controls/TextInput";
|
||||||
|
import Upload from "../../../../components/controls/Upload";
|
||||||
|
import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
|
||||||
|
import InputLabel from "../../../../components/type/InputLabel";
|
||||||
|
import ApplicableScopeField from "../ApplicableScopeField";
|
||||||
|
import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants";
|
||||||
|
import type { CustomMethodCardWizardFieldBodiesViewProps } from "./CustomMethodCardWizard.types";
|
||||||
|
|
||||||
|
const TEXT_PLACEHOLDER_MAX = 8000;
|
||||||
|
|
||||||
|
function CustomMethodCardWizardFieldBodiesViewComponent({
|
||||||
|
fieldType,
|
||||||
|
copy,
|
||||||
|
textBlockTitle,
|
||||||
|
textPlaceholderBody,
|
||||||
|
onTextBlockTitleChange,
|
||||||
|
onTextPlaceholderBodyChange,
|
||||||
|
badgeBlockTitle,
|
||||||
|
badgeOptions,
|
||||||
|
onBadgeBlockTitleChange,
|
||||||
|
onBadgeAddOption,
|
||||||
|
uploadBlockTitle,
|
||||||
|
onUploadBlockTitleChange,
|
||||||
|
fileInputRef,
|
||||||
|
onFileChosen,
|
||||||
|
onClearPendingUpload,
|
||||||
|
uploadAssetPreviewUrl = null,
|
||||||
|
uploadPersisting = false,
|
||||||
|
uploadBusyHint,
|
||||||
|
uploadErrorMessage = null,
|
||||||
|
proportionBlockTitle,
|
||||||
|
proportionDefault,
|
||||||
|
onProportionBlockTitleChange,
|
||||||
|
onProportionDefaultChange,
|
||||||
|
}: CustomMethodCardWizardFieldBodiesViewProps) {
|
||||||
|
const uploadPreviewTrimmed = uploadAssetPreviewUrl?.trim() ?? "";
|
||||||
|
const hasUploadPreview = uploadPreviewTrimmed.length > 0;
|
||||||
|
|
||||||
|
if (fieldType === "text") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-[var(--spacing-scale-024)]">
|
||||||
|
<InputWithCounter
|
||||||
|
label={copy.text.blockTitleLabel}
|
||||||
|
placeholder={copy.text.blockTitlePlaceholder}
|
||||||
|
value={textBlockTitle}
|
||||||
|
onChange={onTextBlockTitleChange}
|
||||||
|
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
||||||
|
showHelpIcon
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<InputLabel
|
||||||
|
label={copy.text.placeholderLabel}
|
||||||
|
helpIcon
|
||||||
|
size="s"
|
||||||
|
palette="default"
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
formHeader={false}
|
||||||
|
appearance="embedded"
|
||||||
|
value={textPlaceholderBody}
|
||||||
|
onChange={(e) => onTextPlaceholderBodyChange(e.target.value)}
|
||||||
|
maxLength={TEXT_PLACEHOLDER_MAX}
|
||||||
|
placeholder={copy.text.placeholderFieldPlaceholder}
|
||||||
|
textHint={`${textPlaceholderBody.length}/${TEXT_PLACEHOLDER_MAX}`}
|
||||||
|
className="w-full"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldType === "badges") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-[var(--spacing-scale-024)]">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<InputLabel
|
||||||
|
label={copy.badges.blockTitleLabel}
|
||||||
|
helpIcon
|
||||||
|
helperText={copy.requiredHint}
|
||||||
|
size="s"
|
||||||
|
palette="default"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
formHeader={false}
|
||||||
|
placeholder={copy.badges.blockTitlePlaceholder}
|
||||||
|
value={badgeBlockTitle}
|
||||||
|
onChange={(e) => onBadgeBlockTitleChange(e.target.value)}
|
||||||
|
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
||||||
|
showHelpIcon={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ApplicableScopeField
|
||||||
|
label={copy.badges.optionsLabel}
|
||||||
|
addLabel={copy.badges.addOptionLabel}
|
||||||
|
scopes={badgeOptions}
|
||||||
|
selectedScopes={badgeOptions}
|
||||||
|
onToggleScope={() => {
|
||||||
|
/* product: all badge options stay selected */
|
||||||
|
}}
|
||||||
|
onAddScope={onBadgeAddOption}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldType === "upload") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-[var(--spacing-scale-024)]">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
className="sr-only"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={copy.upload.uploadFileInputAriaLabel}
|
||||||
|
onChange={onFileChosen}
|
||||||
|
/>
|
||||||
|
<InputWithCounter
|
||||||
|
label={copy.upload.blockTitleLabel}
|
||||||
|
placeholder={copy.upload.blockTitlePlaceholder}
|
||||||
|
value={uploadBlockTitle}
|
||||||
|
onChange={onUploadBlockTitleChange}
|
||||||
|
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
||||||
|
showHelpIcon
|
||||||
|
/>
|
||||||
|
{hasUploadPreview ? (
|
||||||
|
<div className="relative inline-block max-w-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClearPendingUpload}
|
||||||
|
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={copy.upload.clearPendingUploadAriaLabel}
|
||||||
|
title={copy.upload.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 -- blob or same-origin upload URL */}
|
||||||
|
<img
|
||||||
|
src={uploadPreviewTrimmed}
|
||||||
|
alt={copy.upload.uploadPreviewImageAlt}
|
||||||
|
className="max-h-[160px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Upload
|
||||||
|
active={!uploadPersisting}
|
||||||
|
hintText={
|
||||||
|
uploadPersisting && uploadBusyHint
|
||||||
|
? uploadBusyHint
|
||||||
|
: copy.upload.uploadHint
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (!uploadPersisting) fileInputRef.current?.click();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{uploadErrorMessage ? (
|
||||||
|
<p
|
||||||
|
className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-s)] text-[var(--color-content-default-secondary)]"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{uploadErrorMessage}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-[var(--spacing-scale-024)]">
|
||||||
|
<InputWithCounter
|
||||||
|
label={copy.proportion.blockTitleLabel}
|
||||||
|
placeholder={copy.proportion.blockTitlePlaceholder}
|
||||||
|
value={proportionBlockTitle}
|
||||||
|
onChange={onProportionBlockTitleChange}
|
||||||
|
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
||||||
|
showHelpIcon
|
||||||
|
/>
|
||||||
|
<IncrementerBlock
|
||||||
|
label={copy.proportion.defaultLabel}
|
||||||
|
value={proportionDefault}
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
onChange={onProportionDefaultChange}
|
||||||
|
formatValue={(v) => `${v}%`}
|
||||||
|
decrementAriaLabel={copy.proportion.decrementAriaLabel}
|
||||||
|
incrementAriaLabel={copy.proportion.incrementAriaLabel}
|
||||||
|
blockClassName="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomMethodCardWizardFieldBodiesView = memo(
|
||||||
|
CustomMethodCardWizardFieldBodiesViewComponent,
|
||||||
|
);
|
||||||
|
CustomMethodCardWizardFieldBodiesView.displayName =
|
||||||
|
"CustomMethodCardWizardFieldBodiesView";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./CustomMethodCardWizard.container";
|
||||||
|
export type { CustomMethodCardWizardProps } from "./CustomMethodCardWizard.types";
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit published rule: community description with the same 200-char limit as
|
||||||
|
* {@link CreateFlowScreenView} `community-context` step.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import Create from "../../../components/modals/Create";
|
||||||
|
import TextInput from "../../../components/controls/TextInput";
|
||||||
|
import ContentLockup from "../../../components/type/ContentLockup";
|
||||||
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
|
|
||||||
|
/** Matches `community-context` step and `createFlowSchemas` communityContext.max(200). */
|
||||||
|
export const COMMUNITY_CONTEXT_FIELD_MAX_LENGTH = 200;
|
||||||
|
|
||||||
|
export interface FinalReviewCommunityContextEditModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Current `communityContext` (trimmed for display; draft seeds from raw state in parent). */
|
||||||
|
initialValue: string;
|
||||||
|
onSave: (_value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FinalReviewCommunityContextEditModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
initialValue,
|
||||||
|
onSave,
|
||||||
|
}: FinalReviewCommunityContextEditModalProps) {
|
||||||
|
const tModal = useTranslation(
|
||||||
|
"create.reviewAndComplete.finalReview.communityContextEditModal",
|
||||||
|
);
|
||||||
|
const tField = useTranslation("create.community.communityContext");
|
||||||
|
const tSave = useTranslation(
|
||||||
|
"create.reviewAndComplete.finalReview.chipEditModal",
|
||||||
|
);
|
||||||
|
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const initialRef = useRef("");
|
||||||
|
const seededOpenRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
seededOpenRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (seededOpenRef.current) return;
|
||||||
|
seededOpenRef.current = true;
|
||||||
|
const seed = initialValue;
|
||||||
|
setDraft(seed);
|
||||||
|
initialRef.current = seed;
|
||||||
|
}, [isOpen, initialValue]);
|
||||||
|
|
||||||
|
const isDirty = useMemo(
|
||||||
|
() => draft !== initialRef.current,
|
||||||
|
[draft],
|
||||||
|
);
|
||||||
|
|
||||||
|
const characterHint = tField("characterCountTemplate")
|
||||||
|
.replace("{current}", String(draft.length))
|
||||||
|
.replace("{max}", String(COMMUNITY_CONTEXT_FIELD_MAX_LENGTH));
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!isDirty) return;
|
||||||
|
const trimmed = draft.trimEnd();
|
||||||
|
const capped = trimmed.slice(0, COMMUNITY_CONTEXT_FIELD_MAX_LENGTH);
|
||||||
|
onSave(capped);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Create
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
backdropVariant="blurredYellow"
|
||||||
|
headerContent={
|
||||||
|
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
|
||||||
|
<ContentLockup
|
||||||
|
title={tModal("title")}
|
||||||
|
description={tModal("description")}
|
||||||
|
variant="modal"
|
||||||
|
alignment="left"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
showBackButton={false}
|
||||||
|
showNextButton
|
||||||
|
nextButtonText={tSave("saveButton")}
|
||||||
|
nextButtonDisabled={!isDirty}
|
||||||
|
onNext={handleSave}
|
||||||
|
ariaLabel={tModal("title")}
|
||||||
|
>
|
||||||
|
<div className="pb-2">
|
||||||
|
<TextInput
|
||||||
|
className="!transition-none"
|
||||||
|
type="text"
|
||||||
|
placeholder={tField("placeholder")}
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDraft(e.target.value);
|
||||||
|
}}
|
||||||
|
inputSize="medium"
|
||||||
|
formHeader={false}
|
||||||
|
textHint={characterHint}
|
||||||
|
maxLength={COMMUNITY_CONTEXT_FIELD_MAX_LENGTH}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Create>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit published rule: community name with the same 48-char limit as
|
||||||
|
* {@link CreateFlowTextFieldScreen} `community-name` step.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import Create from "../../../components/modals/Create";
|
||||||
|
import TextInput from "../../../components/controls/TextInput";
|
||||||
|
import ContentLockup from "../../../components/type/ContentLockup";
|
||||||
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
|
|
||||||
|
/** Matches `community-name` step (`CreateFlowTextFieldScreen` `maxLength={48}`). */
|
||||||
|
export const COMMUNITY_TITLE_FIELD_MAX_LENGTH = 48;
|
||||||
|
|
||||||
|
export interface FinalReviewTitleEditModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialValue: string;
|
||||||
|
onSave: (_value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FinalReviewTitleEditModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
initialValue,
|
||||||
|
onSave,
|
||||||
|
}: FinalReviewTitleEditModalProps) {
|
||||||
|
const tModal = useTranslation(
|
||||||
|
"create.reviewAndComplete.finalReview.titleEditModal",
|
||||||
|
);
|
||||||
|
const tField = useTranslation("create.community.communityName");
|
||||||
|
const tSave = useTranslation(
|
||||||
|
"create.reviewAndComplete.finalReview.chipEditModal",
|
||||||
|
);
|
||||||
|
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const initialRef = useRef("");
|
||||||
|
const seededOpenRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
seededOpenRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (seededOpenRef.current) return;
|
||||||
|
seededOpenRef.current = true;
|
||||||
|
const seed = initialValue;
|
||||||
|
setDraft(seed);
|
||||||
|
initialRef.current = seed;
|
||||||
|
}, [isOpen, initialValue]);
|
||||||
|
|
||||||
|
const isDirty = useMemo(() => draft !== initialRef.current, [draft]);
|
||||||
|
|
||||||
|
const trimmedDraft = draft.trim();
|
||||||
|
const canSave = isDirty && trimmedDraft.length > 0;
|
||||||
|
|
||||||
|
const characterHint = tField("characterCountTemplate")
|
||||||
|
.replace("{current}", String(draft.length))
|
||||||
|
.replace("{max}", String(COMMUNITY_TITLE_FIELD_MAX_LENGTH));
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!canSave) return;
|
||||||
|
const capped = trimmedDraft.slice(0, COMMUNITY_TITLE_FIELD_MAX_LENGTH);
|
||||||
|
onSave(capped);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Create
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
backdropVariant="blurredYellow"
|
||||||
|
headerContent={
|
||||||
|
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
|
||||||
|
<ContentLockup
|
||||||
|
title={tModal("title")}
|
||||||
|
description={tModal("description")}
|
||||||
|
variant="modal"
|
||||||
|
alignment="left"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
showBackButton={false}
|
||||||
|
showNextButton
|
||||||
|
nextButtonText={tSave("saveButton")}
|
||||||
|
nextButtonDisabled={!canSave}
|
||||||
|
onNext={handleSave}
|
||||||
|
ariaLabel={tModal("title")}
|
||||||
|
>
|
||||||
|
<div className="pb-2">
|
||||||
|
<TextInput
|
||||||
|
className="!transition-none"
|
||||||
|
type="text"
|
||||||
|
placeholder={tField("placeholder")}
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDraft(e.target.value);
|
||||||
|
}}
|
||||||
|
inputSize="medium"
|
||||||
|
formHeader={false}
|
||||||
|
textHint={characterHint}
|
||||||
|
maxLength={COMMUNITY_TITLE_FIELD_MAX_LENGTH}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Create>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editable policy title + description for method-card Create modals in Customize mode.
|
||||||
|
* View mode continues to use {@link ContentLockup} via the `Create` modal defaults.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import TextInput from "../../../components/controls/TextInput";
|
||||||
|
import ModalTextAreaField from "./ModalTextAreaField";
|
||||||
|
|
||||||
|
export interface MethodCardCustomizeModalHeaderProps {
|
||||||
|
titleLabel: string;
|
||||||
|
descriptionLabel: string;
|
||||||
|
titleValue: string;
|
||||||
|
descriptionValue: string;
|
||||||
|
onTitleChange: (_value: string) => void;
|
||||||
|
onDescriptionChange: (_value: string) => void;
|
||||||
|
/** @default 3 */
|
||||||
|
descriptionRows?: number;
|
||||||
|
/** When false, only the policy title row is rendered (core values rename). */
|
||||||
|
showDescription?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MethodCardCustomizeModalHeader({
|
||||||
|
titleLabel,
|
||||||
|
descriptionLabel,
|
||||||
|
titleValue,
|
||||||
|
descriptionValue,
|
||||||
|
onTitleChange,
|
||||||
|
onDescriptionChange,
|
||||||
|
descriptionRows = 3,
|
||||||
|
showDescription = true,
|
||||||
|
}: MethodCardCustomizeModalHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--color-surface-default-primary)] flex shrink-0 flex-col gap-4 px-[24px] py-[12px]">
|
||||||
|
<TextInput
|
||||||
|
label={titleLabel}
|
||||||
|
value={titleValue}
|
||||||
|
onChange={(e) => onTitleChange(e.target.value)}
|
||||||
|
inputSize="medium"
|
||||||
|
/>
|
||||||
|
{showDescription ? (
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={descriptionLabel}
|
||||||
|
value={descriptionValue}
|
||||||
|
onChange={onDescriptionChange}
|
||||||
|
rows={descriptionRows}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared "labelled text area" field used by every create flow modal section.
|
||||||
|
* Pairs an `InputLabel` (with help icon) with a `TextArea` set to the embedded
|
||||||
|
* appearance — matching the Figma "Control / Text Area" pattern.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { memo, useId } from "react";
|
||||||
|
import TextArea from "../../../components/controls/TextArea";
|
||||||
|
import InputLabel from "../../../components/type/InputLabel";
|
||||||
|
|
||||||
|
export interface ModalTextAreaFieldProps {
|
||||||
|
/** Label rendered above the text area. */
|
||||||
|
label: string;
|
||||||
|
/** Show the help "?" icon next to the label (default `true`). */
|
||||||
|
helpIcon?: boolean;
|
||||||
|
/** Current text value. */
|
||||||
|
value: string;
|
||||||
|
/** Fired on every change with the new value (no event). */
|
||||||
|
onChange: (_value: string) => void;
|
||||||
|
/** Optional rows for the underlying `<textarea>` (default 4). */
|
||||||
|
rows?: number;
|
||||||
|
/** Optional placeholder. */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Disable the field. */
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModalTextAreaFieldComponent({
|
||||||
|
label,
|
||||||
|
helpIcon = true,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
rows = 4,
|
||||||
|
placeholder,
|
||||||
|
disabled = false,
|
||||||
|
className = "",
|
||||||
|
}: ModalTextAreaFieldProps) {
|
||||||
|
const labelId = useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col gap-2 ${className}`.trim()}>
|
||||||
|
<div id={labelId}>
|
||||||
|
<InputLabel
|
||||||
|
label={label}
|
||||||
|
helpIcon={helpIcon}
|
||||||
|
size="s"
|
||||||
|
palette="default"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<TextArea
|
||||||
|
formHeader={false}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
size="large"
|
||||||
|
rows={rows}
|
||||||
|
appearance="embedded"
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-labelledby={labelId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalTextAreaFieldComponent.displayName = "ModalTextAreaField";
|
||||||
|
|
||||||
|
export default memo(ModalTextAreaFieldComponent);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/** Single column/section: full width under `md`, max 640px from `--breakpoint-md` up. */
|
||||||
|
export const CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS =
|
||||||
|
"w-full min-w-0 md:max-w-[640px]";
|
||||||
|
|
||||||
|
/** Grid cell: same cap as column max, centered when the track is wider than 640px. */
|
||||||
|
export const CREATE_FLOW_MD_UP_GRID_CELL_CLASS =
|
||||||
|
"w-full min-w-0 md:mx-auto md:max-w-[640px]";
|
||||||
|
|
||||||
|
/** Two 640px columns + `--measures-spacing-1200` (48px) gutter. */
|
||||||
|
export const CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS = "md:max-w-[1328px]";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card-stack steps only (Figma compact card stack): wider than header lockup so the card grid /
|
||||||
|
* pyramid fits (max 860px). Header lockup stays {@link CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}.
|
||||||
|
* Card–card gap uses `gap-2` in `CardStack` (same on mobile and md+).
|
||||||
|
*/
|
||||||
|
export const CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS =
|
||||||
|
"w-full min-w-0 md:max-w-[min(100%,860px)]";
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { ModalHeaderMenuItem } from "../../../components/modals/ModalHeader/ModalHeader.types";
|
||||||
|
|
||||||
|
export interface CustomRuleModalKebabMenuCopy {
|
||||||
|
items: {
|
||||||
|
customize: string;
|
||||||
|
duplicate: string;
|
||||||
|
remove: string;
|
||||||
|
};
|
||||||
|
saveEdits: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomRuleModalKebabHandlers {
|
||||||
|
showCustomize?: boolean;
|
||||||
|
onCustomize?: () => void;
|
||||||
|
onDuplicate?: () => void;
|
||||||
|
showRemove?: boolean;
|
||||||
|
onRemove?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCustomRuleModalKebabMenu(
|
||||||
|
copy: CustomRuleModalKebabMenuCopy,
|
||||||
|
handlers: CustomRuleModalKebabHandlers,
|
||||||
|
): ModalHeaderMenuItem[] {
|
||||||
|
const items: ModalHeaderMenuItem[] = [];
|
||||||
|
if (handlers.showCustomize && handlers.onCustomize) {
|
||||||
|
items.push({
|
||||||
|
id: "customize",
|
||||||
|
label: copy.items.customize,
|
||||||
|
leadingIcon: "custom",
|
||||||
|
onClick: handlers.onCustomize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (handlers.onDuplicate) {
|
||||||
|
items.push({
|
||||||
|
id: "duplicate",
|
||||||
|
label: copy.items.duplicate,
|
||||||
|
leadingIcon: "content_copy",
|
||||||
|
onClick: handlers.onDuplicate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (handlers.showRemove && handlers.onRemove) {
|
||||||
|
items.push({
|
||||||
|
id: "remove",
|
||||||
|
label: copy.items.remove,
|
||||||
|
leadingIcon: "warning",
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: handlers.onRemove,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controlled section editor for a communication-method chip. Used by both
|
||||||
|
* the custom-rule `communication-methods` add-method modal and the
|
||||||
|
* `final-review` chip edit modal — caller owns draft state and decides when
|
||||||
|
* to persist or discard.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { memo, useCallback } from "react";
|
||||||
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
|
import ModalTextAreaField from "../ModalTextAreaField";
|
||||||
|
import type { CommunicationMethodDetailEntry } from "../../types";
|
||||||
|
|
||||||
|
export interface CommunicationMethodEditFieldsProps {
|
||||||
|
value: CommunicationMethodDetailEntry;
|
||||||
|
onChange: (_next: CommunicationMethodDetailEntry) => void;
|
||||||
|
/** When true, fields are not editable (view mode). */
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELDS: ReadonlyArray<keyof CommunicationMethodDetailEntry> = [
|
||||||
|
"corePrinciple",
|
||||||
|
"logisticsAdmin",
|
||||||
|
"codeOfConduct",
|
||||||
|
];
|
||||||
|
|
||||||
|
function CommunicationMethodEditFieldsComponent({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
readOnly = false,
|
||||||
|
}: CommunicationMethodEditFieldsProps) {
|
||||||
|
const m = useMessages();
|
||||||
|
const t = m.create.customRule.communication;
|
||||||
|
|
||||||
|
const patch = useCallback(
|
||||||
|
<K extends keyof CommunicationMethodDetailEntry>(
|
||||||
|
key: K,
|
||||||
|
next: CommunicationMethodDetailEntry[K],
|
||||||
|
) => {
|
||||||
|
onChange({ ...value, [key]: next });
|
||||||
|
},
|
||||||
|
[value, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{FIELDS.map((field) => (
|
||||||
|
<ModalTextAreaField
|
||||||
|
key={field}
|
||||||
|
label={t.sectionHeadings[field]}
|
||||||
|
rows={6}
|
||||||
|
value={value[field]}
|
||||||
|
onChange={(v) => patch(field, v)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CommunicationMethodEditFieldsComponent.displayName =
|
||||||
|
"CommunicationMethodEditFields";
|
||||||
|
|
||||||
|
export default memo(CommunicationMethodEditFieldsComponent);
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controlled section editor for a conflict-management chip. Used by both the
|
||||||
|
* custom-rule `conflict-management` add-method modal and the `final-review`
|
||||||
|
* chip edit modal. Caller owns draft state and persistence.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { memo, useCallback } from "react";
|
||||||
|
import { formatConflictApplicableScopeForTextarea } from "../../../../../lib/create/ruleSectionsFromMethodSelections";
|
||||||
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
|
import ModalTextAreaField from "../ModalTextAreaField";
|
||||||
|
import type { ConflictManagementDetailEntry } from "../../types";
|
||||||
|
|
||||||
|
function conflictScopeTextareaValue(value: ConflictManagementDetailEntry): string {
|
||||||
|
return formatConflictApplicableScopeForTextarea(
|
||||||
|
value.selectedApplicableScope,
|
||||||
|
value.applicableScope,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function conflictDetailWithScopeTextarea(
|
||||||
|
value: ConflictManagementDetailEntry,
|
||||||
|
text: string,
|
||||||
|
): ConflictManagementDetailEntry {
|
||||||
|
const lines = text
|
||||||
|
.split("\n")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
applicableScope: lines,
|
||||||
|
selectedApplicableScope: [...lines],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConflictManagementEditFieldsProps {
|
||||||
|
value: ConflictManagementDetailEntry;
|
||||||
|
onChange: (_next: ConflictManagementDetailEntry) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConflictManagementEditFieldsComponent({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
readOnly = false,
|
||||||
|
}: ConflictManagementEditFieldsProps) {
|
||||||
|
const m = useMessages();
|
||||||
|
const t = m.create.customRule.conflictManagement;
|
||||||
|
|
||||||
|
const patch = useCallback(
|
||||||
|
<K extends keyof ConflictManagementDetailEntry>(
|
||||||
|
key: K,
|
||||||
|
next: ConflictManagementDetailEntry[K],
|
||||||
|
) => {
|
||||||
|
onChange({ ...value, [key]: next });
|
||||||
|
},
|
||||||
|
[value, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={t.sectionHeadings.corePrinciple}
|
||||||
|
value={value.corePrinciple}
|
||||||
|
onChange={(v) => patch("corePrinciple", v)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={t.sectionHeadings.applicableScope}
|
||||||
|
value={conflictScopeTextareaValue(value)}
|
||||||
|
placeholder={t.applicableScopePlaceholder}
|
||||||
|
onChange={(v) => onChange(conflictDetailWithScopeTextarea(value, v))}
|
||||||
|
rows={4}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={t.sectionHeadings.processProtocol}
|
||||||
|
value={value.processProtocol}
|
||||||
|
onChange={(v) => patch("processProtocol", v)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={t.sectionHeadings.restorationFallbacks}
|
||||||
|
value={value.restorationFallbacks}
|
||||||
|
onChange={(v) => patch("restorationFallbacks", v)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ConflictManagementEditFieldsComponent.displayName =
|
||||||
|
"ConflictManagementEditFields";
|
||||||
|
|
||||||
|
export default memo(ConflictManagementEditFieldsComponent);
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controlled meaning/signals field set for a core-value chip. Rendered both
|
||||||
|
* by `core-values` (custom-rule selection step) and `final-review` (chip
|
||||||
|
* edit modal). Holds no state — the parent owns the draft and decides when
|
||||||
|
* to persist (`updateState`) or discard.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { memo, useCallback } from "react";
|
||||||
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
|
import ModalTextAreaField from "../ModalTextAreaField";
|
||||||
|
import type { CoreValueDetailEntry } from "../../types";
|
||||||
|
|
||||||
|
export interface CoreValueEditFieldsProps {
|
||||||
|
value: CoreValueDetailEntry;
|
||||||
|
onChange: (_next: CoreValueDetailEntry) => void;
|
||||||
|
/** View mode until the user taps **Customize**. */
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CoreValueEditFieldsComponent({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
readOnly = false,
|
||||||
|
}: CoreValueEditFieldsProps) {
|
||||||
|
const m = useMessages();
|
||||||
|
const t = m.create.customRule.coreValues.detailModal;
|
||||||
|
|
||||||
|
const patch = useCallback(
|
||||||
|
<K extends keyof CoreValueDetailEntry>(
|
||||||
|
key: K,
|
||||||
|
next: CoreValueDetailEntry[K],
|
||||||
|
) => {
|
||||||
|
onChange({ ...value, [key]: next });
|
||||||
|
},
|
||||||
|
[value, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-[var(--measures-spacing-600,24px)] pb-2">
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={t.meaningLabel}
|
||||||
|
value={value.meaning}
|
||||||
|
onChange={(v) => patch("meaning", v)}
|
||||||
|
rows={4}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={t.signalsLabel}
|
||||||
|
value={value.signals}
|
||||||
|
onChange={(v) => patch("signals", v)}
|
||||||
|
rows={4}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CoreValueEditFieldsComponent.displayName = "CoreValueEditFields";
|
||||||
|
|
||||||
|
export default memo(CoreValueEditFieldsComponent);
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controlled section editor for a decision-approach chip. Used by both the
|
||||||
|
* custom-rule `decision-approaches` add-method modal and the `final-review`
|
||||||
|
* chip edit modal. Caller owns draft state — Confirm/Save persistence and
|
||||||
|
* `markCreateFlowInteraction` live in the parent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { memo, useCallback } from "react";
|
||||||
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
|
import ModalTextAreaField from "../ModalTextAreaField";
|
||||||
|
import ApplicableScopeField from "../ApplicableScopeField";
|
||||||
|
import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
|
||||||
|
import type { DecisionApproachDetailEntry } from "../../types";
|
||||||
|
|
||||||
|
export interface DecisionApproachEditFieldsProps {
|
||||||
|
value: DecisionApproachDetailEntry;
|
||||||
|
onChange: (_next: DecisionApproachDetailEntry) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONSENSUS_LEVEL_MIN = 0;
|
||||||
|
const CONSENSUS_LEVEL_MAX = 100;
|
||||||
|
const CONSENSUS_LEVEL_STEP = 5;
|
||||||
|
|
||||||
|
function DecisionApproachEditFieldsComponent({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
readOnly = false,
|
||||||
|
}: DecisionApproachEditFieldsProps) {
|
||||||
|
const m = useMessages();
|
||||||
|
const t = m.create.customRule.decisionApproaches;
|
||||||
|
|
||||||
|
const patch = useCallback(
|
||||||
|
<K extends keyof DecisionApproachDetailEntry>(
|
||||||
|
key: K,
|
||||||
|
next: DecisionApproachDetailEntry[K],
|
||||||
|
) => {
|
||||||
|
onChange({ ...value, [key]: next });
|
||||||
|
},
|
||||||
|
[value, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={t.sectionHeadings.corePrinciple}
|
||||||
|
value={value.corePrinciple}
|
||||||
|
onChange={(v) => patch("corePrinciple", v)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<ApplicableScopeField
|
||||||
|
label={t.sectionHeadings.applicableScope}
|
||||||
|
addLabel={t.scopeAddButtonLabel}
|
||||||
|
scopes={value.applicableScope}
|
||||||
|
selectedScopes={value.selectedApplicableScope}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onToggleScope={(scope) =>
|
||||||
|
patch(
|
||||||
|
"selectedApplicableScope",
|
||||||
|
value.selectedApplicableScope.includes(scope)
|
||||||
|
? value.selectedApplicableScope.filter((s) => s !== scope)
|
||||||
|
: [...value.selectedApplicableScope, scope],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onAddScope={(scope) =>
|
||||||
|
patch("applicableScope", [...value.applicableScope, scope])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={t.sectionHeadings.stepByStepInstructions}
|
||||||
|
value={value.stepByStepInstructions}
|
||||||
|
onChange={(v) => patch("stepByStepInstructions", v)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<IncrementerBlock
|
||||||
|
label={t.sectionHeadings.consensusLevel}
|
||||||
|
value={value.consensusLevel}
|
||||||
|
min={CONSENSUS_LEVEL_MIN}
|
||||||
|
max={CONSENSUS_LEVEL_MAX}
|
||||||
|
step={CONSENSUS_LEVEL_STEP}
|
||||||
|
onChange={(next) => patch("consensusLevel", next)}
|
||||||
|
formatValue={(v) => `${v}%`}
|
||||||
|
decrementAriaLabel="Decrease consensus level"
|
||||||
|
incrementAriaLabel="Increase consensus level"
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={t.sectionHeadings.objectionsDeadlocks}
|
||||||
|
value={value.objectionsDeadlocks}
|
||||||
|
onChange={(v) => patch("objectionsDeadlocks", v)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DecisionApproachEditFieldsComponent.displayName =
|
||||||
|
"DecisionApproachEditFields";
|
||||||
|
|
||||||
|
export default memo(DecisionApproachEditFieldsComponent);
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controlled section editor for a membership-method chip. Used by both the
|
||||||
|
* custom-rule `membership-methods` add-method modal and the `final-review`
|
||||||
|
* chip edit modal — caller owns draft state and decides when to persist or
|
||||||
|
* discard.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { memo, useCallback } from "react";
|
||||||
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
|
import ModalTextAreaField from "../ModalTextAreaField";
|
||||||
|
import type { MembershipMethodDetailEntry } from "../../types";
|
||||||
|
|
||||||
|
export interface MembershipMethodEditFieldsProps {
|
||||||
|
value: MembershipMethodDetailEntry;
|
||||||
|
onChange: (_next: MembershipMethodDetailEntry) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELDS: ReadonlyArray<keyof MembershipMethodDetailEntry> = [
|
||||||
|
"eligibility",
|
||||||
|
"joiningProcess",
|
||||||
|
"expectations",
|
||||||
|
];
|
||||||
|
|
||||||
|
function MembershipMethodEditFieldsComponent({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
readOnly = false,
|
||||||
|
}: MembershipMethodEditFieldsProps) {
|
||||||
|
const m = useMessages();
|
||||||
|
const t = m.create.customRule.membership;
|
||||||
|
|
||||||
|
const patch = useCallback(
|
||||||
|
<K extends keyof MembershipMethodDetailEntry>(
|
||||||
|
key: K,
|
||||||
|
next: MembershipMethodDetailEntry[K],
|
||||||
|
) => {
|
||||||
|
onChange({ ...value, [key]: next });
|
||||||
|
},
|
||||||
|
[value, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{FIELDS.map((field) => (
|
||||||
|
<ModalTextAreaField
|
||||||
|
key={field}
|
||||||
|
label={t.sectionHeadings[field]}
|
||||||
|
rows={6}
|
||||||
|
value={value[field]}
|
||||||
|
onChange={(v) => patch(field, v)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
MembershipMethodEditFieldsComponent.displayName =
|
||||||
|
"MembershipMethodEditFields";
|
||||||
|
|
||||||
|
export default memo(MembershipMethodEditFieldsComponent);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export { default as CoreValueEditFields } from "./CoreValueEditFields";
|
||||||
|
export type { CoreValueEditFieldsProps } from "./CoreValueEditFields";
|
||||||
|
|
||||||
|
export { default as CommunicationMethodEditFields } from "./CommunicationMethodEditFields";
|
||||||
|
export type { CommunicationMethodEditFieldsProps } from "./CommunicationMethodEditFields";
|
||||||
|
|
||||||
|
export { default as MembershipMethodEditFields } from "./MembershipMethodEditFields";
|
||||||
|
export type { MembershipMethodEditFieldsProps } from "./MembershipMethodEditFields";
|
||||||
|
|
||||||
|
export { default as DecisionApproachEditFields } from "./DecisionApproachEditFields";
|
||||||
|
export type { DecisionApproachEditFieldsProps } from "./DecisionApproachEditFields";
|
||||||
|
|
||||||
|
export { default as ConflictManagementEditFields } from "./ConflictManagementEditFields";
|
||||||
|
export type { ConflictManagementEditFieldsProps } from "./ConflictManagementEditFields";
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import type {
|
||||||
|
CreateFlowMethodCardFacetSection,
|
||||||
|
CreateFlowState,
|
||||||
|
CreateFlowContextValue,
|
||||||
|
CreateFlowStep,
|
||||||
|
} from "../types";
|
||||||
|
import {
|
||||||
|
clearAnonymousCreateFlowStorage,
|
||||||
|
clearLegacyCreateFlowKeysOnce,
|
||||||
|
hasTransferPendingFlag,
|
||||||
|
readAnonymousCreateFlowState,
|
||||||
|
writeAnonymousCreateFlowState,
|
||||||
|
} from "../utils/anonymousDraftStorage";
|
||||||
|
import { stripCustomRuleSelectionFields } from "../../../../lib/create/stripCustomRuleSelectionFields";
|
||||||
|
import {
|
||||||
|
clearCoreValueDetailsLocalStorage,
|
||||||
|
readCoreValueDetailsFromLocalStorage,
|
||||||
|
writeCoreValueDetailsToLocalStorage,
|
||||||
|
} from "../utils/coreValueDetailsLocalStorage";
|
||||||
|
|
||||||
|
const CreateFlowContext = createContext<CreateFlowContextValue | null>(null);
|
||||||
|
|
||||||
|
interface CreateFlowProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
initialStep?: CreateFlowStep | null;
|
||||||
|
/**
|
||||||
|
* When true (session resolved, guest or signed-in), mirror in-flight draft to
|
||||||
|
* `create-flow-anonymous` in localStorage so refresh / dev-restart never wipes
|
||||||
|
* progress. When false, in-memory only (e.g. unit tests, pre-session-resolve).
|
||||||
|
*
|
||||||
|
* Signed-in users additionally get an explicit "Save & Exit" that PUTs to the
|
||||||
|
* server (`useCreateFlowExit`); the server draft is the cross-device snapshot,
|
||||||
|
* localStorage is the on-every-keystroke buffer.
|
||||||
|
*/
|
||||||
|
enableLocalDraftMirroring?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create flow state. All users mirror in-flight state to localStorage when
|
||||||
|
* `enableLocalDraftMirroring` is true; signed-in users layer an explicit
|
||||||
|
* server-draft snapshot on top via {@link useCreateFlowExit}.
|
||||||
|
*/
|
||||||
|
export function CreateFlowProvider({
|
||||||
|
children,
|
||||||
|
initialStep = null,
|
||||||
|
enableLocalDraftMirroring = false,
|
||||||
|
}: CreateFlowProviderProps) {
|
||||||
|
const [state, setState] = useState<CreateFlowState>(() => {
|
||||||
|
const base = enableLocalDraftMirroring
|
||||||
|
? readAnonymousCreateFlowState()
|
||||||
|
: {};
|
||||||
|
const storedDetails = readCoreValueDetailsFromLocalStorage();
|
||||||
|
if (Object.keys(storedDetails).length === 0) return base;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
coreValueDetailsByChipId: {
|
||||||
|
...storedDetails,
|
||||||
|
...(base.coreValueDetailsByChipId ?? {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const [interactionTouched, setInteractionTouched] = useState(false);
|
||||||
|
const [currentStep] = useState<CreateFlowStep | null>(initialStep);
|
||||||
|
const prevPersistRef = useRef(enableLocalDraftMirroring);
|
||||||
|
const persistWriteSkipRef = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clearLegacyCreateFlowKeysOnce();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Session resolved after initial paint: hydrate from localStorage, merging
|
||||||
|
// with anything already in state. We can't bail on `prev` being non-empty:
|
||||||
|
// the initializer pre-populates `coreValueDetailsByChipId` from a separate
|
||||||
|
// localStorage key, so `prev` is virtually always non-empty here.
|
||||||
|
// Merge strategy: `prev` wins for fields the user might have touched between
|
||||||
|
// mount and session-resolve; `from` fills in anything else; coreValueDetails
|
||||||
|
// is union-merged (prev wins per chip id since it loaded from the dedicated
|
||||||
|
// `create-flow-core-value-details` key).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enableLocalDraftMirroring) {
|
||||||
|
prevPersistRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wasOff = !prevPersistRef.current;
|
||||||
|
prevPersistRef.current = true;
|
||||||
|
if (!wasOff) return;
|
||||||
|
if (hasTransferPendingFlag()) return;
|
||||||
|
if (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
new URLSearchParams(window.location.search).get("syncDraft") === "1"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const from = readAnonymousCreateFlowState();
|
||||||
|
if (Object.keys(from).length === 0) return;
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- hydrate local draft when mirroring turns on
|
||||||
|
setState((prev) => {
|
||||||
|
const merged: CreateFlowState = { ...from, ...prev };
|
||||||
|
const fromDetails = from.coreValueDetailsByChipId;
|
||||||
|
const prevDetails = prev.coreValueDetailsByChipId;
|
||||||
|
if (fromDetails || prevDetails) {
|
||||||
|
merged.coreValueDetailsByChipId = {
|
||||||
|
...(fromDetails ?? {}),
|
||||||
|
...(prevDetails ?? {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
}, [enableLocalDraftMirroring]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enableLocalDraftMirroring) {
|
||||||
|
// Reset so the next OFF→ON transition skips its first write again.
|
||||||
|
persistWriteSkipRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Skip the very first write that runs on the same render where mirroring
|
||||||
|
// turned ON — the hydrate effect (above) is racing to setState the loaded
|
||||||
|
// draft, and writing the still-empty pre-hydrate state here would clobber
|
||||||
|
// localStorage. The next render (with the hydrated state) will write
|
||||||
|
// normally. Without this guard, drafts get wiped during HMR / any
|
||||||
|
// auth-session refetch that re-toggles `enableLocalDraftMirroring`.
|
||||||
|
if (persistWriteSkipRef.current) {
|
||||||
|
persistWriteSkipRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeAnonymousCreateFlowState(state);
|
||||||
|
}, [state, enableLocalDraftMirroring]);
|
||||||
|
|
||||||
|
/** Meaning/signals for core values: survives refresh for signed-in users; merged with anonymous draft when both exist. */
|
||||||
|
useEffect(() => {
|
||||||
|
writeCoreValueDetailsToLocalStorage(state.coreValueDetailsByChipId);
|
||||||
|
}, [state.coreValueDetailsByChipId]);
|
||||||
|
|
||||||
|
const markCreateFlowInteraction = useCallback(() => {
|
||||||
|
setInteractionTouched(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setMethodSectionsPinCommitted = useCallback(
|
||||||
|
(section: CreateFlowMethodCardFacetSection, committed: boolean) => {
|
||||||
|
setState((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
methodSectionsPinCommitted: {
|
||||||
|
...(prevState.methodSectionsPinCommitted ?? {}),
|
||||||
|
[section]: committed,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateState = useCallback((updates: Partial<CreateFlowState>) => {
|
||||||
|
setState((prevState) => {
|
||||||
|
const merged: CreateFlowState = { ...prevState, ...updates };
|
||||||
|
if (updates.communityStructureChipSnapshots !== undefined) {
|
||||||
|
merged.communityStructureChipSnapshots = {
|
||||||
|
...(prevState.communityStructureChipSnapshots ?? {}),
|
||||||
|
...updates.communityStructureChipSnapshots,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (updates.coreValueDetailsByChipId !== undefined) {
|
||||||
|
merged.coreValueDetailsByChipId = {
|
||||||
|
...(prevState.coreValueDetailsByChipId ?? {}),
|
||||||
|
...updates.coreValueDetailsByChipId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const replaceState = useCallback(
|
||||||
|
(next: CreateFlowState | ((prev: CreateFlowState) => CreateFlowState)) => {
|
||||||
|
setState(next);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearState = useCallback(() => {
|
||||||
|
setState({});
|
||||||
|
setInteractionTouched(false);
|
||||||
|
clearAnonymousCreateFlowStorage();
|
||||||
|
clearCoreValueDetailsLocalStorage();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keys cleared here match `STRIP_CUSTOM_RULE_SELECTION_STATE_KEYS` from
|
||||||
|
// `lib/create/customRuleFacets.ts` (CUSTOM_RULE_FACETS / CR-92).
|
||||||
|
const resetCustomRuleSelections = useCallback(() => {
|
||||||
|
setState((prev) => stripCustomRuleSelectionFields(prev));
|
||||||
|
// Effect on `state.coreValueDetailsByChipId` clears its dedicated
|
||||||
|
// localStorage key when the field goes undefined, so we don't need to
|
||||||
|
// touch `clearCoreValueDetailsLocalStorage()` directly here.
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const contextValue: CreateFlowContextValue = {
|
||||||
|
state,
|
||||||
|
currentStep,
|
||||||
|
updateState,
|
||||||
|
replaceState,
|
||||||
|
clearState,
|
||||||
|
resetCustomRuleSelections,
|
||||||
|
setMethodSectionsPinCommitted,
|
||||||
|
interactionTouched,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreateFlowContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</CreateFlowContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateFlow(): CreateFlowContextValue {
|
||||||
|
const context = useContext(CreateFlowContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useCreateFlow must be used within CreateFlowProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
type CreateFlowDraftSaveBannerContextValue = {
|
||||||
|
draftSaveBannerMessage: string | null;
|
||||||
|
setDraftSaveBannerMessage: (_message: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CreateFlowDraftSaveBannerContext =
|
||||||
|
createContext<CreateFlowDraftSaveBannerContextValue | null>(null);
|
||||||
|
|
||||||
|
export function CreateFlowDraftSaveBannerProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [draftSaveBannerMessage, setDraftSaveBannerMessage] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
draftSaveBannerMessage,
|
||||||
|
setDraftSaveBannerMessage,
|
||||||
|
}),
|
||||||
|
[draftSaveBannerMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreateFlowDraftSaveBannerContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</CreateFlowDraftSaveBannerContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateFlowDraftSaveBanner(): CreateFlowDraftSaveBannerContextValue {
|
||||||
|
const ctx = useContext(CreateFlowDraftSaveBannerContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error(
|
||||||
|
"useCreateFlowDraftSaveBanner must be used within CreateFlowDraftSaveBannerProvider",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
|
import { readLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
|
||||||
|
import {
|
||||||
|
buildMailtoShareHref,
|
||||||
|
buildSlackWebShareUrl,
|
||||||
|
DISCORD_NATIVE_DM_HUB_URL,
|
||||||
|
DISCORD_WEB_DM_HUB_URL,
|
||||||
|
scheduleNativeSchemeThenFallback,
|
||||||
|
SLACK_NATIVE_OPEN_URL,
|
||||||
|
type NativeFallbackTimers,
|
||||||
|
type NativeNavigateDeps,
|
||||||
|
} from "../../../../lib/create/shareChannels";
|
||||||
|
import {
|
||||||
|
buildPublicRuleUrl,
|
||||||
|
downloadStoredRuleAsPdf,
|
||||||
|
downloadTextFile,
|
||||||
|
exportFilenameBase,
|
||||||
|
exportStoredRuleAsCsv,
|
||||||
|
exportStoredRuleAsMarkdown,
|
||||||
|
} from "../../../../lib/create/ruleExport";
|
||||||
|
|
||||||
|
export type CompletedFlowActionBanner = {
|
||||||
|
key: string;
|
||||||
|
status: "positive" | "danger";
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function browserNativeShareNavigateDeps(win: Window): NativeNavigateDeps {
|
||||||
|
return {
|
||||||
|
assignLocationHref: (url: string): void => {
|
||||||
|
// Transient <a>: same-tab custom-protocol handshake as location.href without replacing the SPA.
|
||||||
|
const anchor = win.document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.rel = "noreferrer noopener";
|
||||||
|
anchor.style.position = "absolute";
|
||||||
|
anchor.style.left = "-9999px";
|
||||||
|
win.document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
},
|
||||||
|
getVisibilityState: (): Document["visibilityState"] =>
|
||||||
|
win.document.visibilityState,
|
||||||
|
onVisibilityChange: (listener: () => void): void => {
|
||||||
|
win.document.addEventListener("visibilitychange", listener);
|
||||||
|
},
|
||||||
|
offVisibilityChange: (listener: () => void): void => {
|
||||||
|
win.document.removeEventListener("visibilitychange", listener);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserNativeTimers(win: Window): NativeFallbackTimers {
|
||||||
|
return {
|
||||||
|
setTimeout: (cb: () => void, ms: number): unknown => win.setTimeout(cb, ms),
|
||||||
|
clearTimeout: (handle: unknown): void =>
|
||||||
|
win.clearTimeout(
|
||||||
|
handle as ReturnType<typeof win.setTimeout>,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After native app handoff, the page can stay `visibilityState === "visible"` while
|
||||||
|
* focus moves to the other app. Skip clipboard fallbacks in that case to avoid
|
||||||
|
* `NotAllowedError` noise when Slack/compose already succeeded.
|
||||||
|
*/
|
||||||
|
function shouldSkipShareClipboardFallback(win: Window): boolean {
|
||||||
|
return (
|
||||||
|
win.document.visibilityState === "hidden" || !win.document.hasFocus()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePublishedRuleShareContext(windowObj: Window): {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
} | null {
|
||||||
|
const rule = readLastPublishedRule();
|
||||||
|
if (!rule) return null;
|
||||||
|
const url = buildPublicRuleUrl(windowObj.location.origin, rule.id);
|
||||||
|
const summary =
|
||||||
|
typeof rule.summary === "string" ? rule.summary.trim() : "";
|
||||||
|
const text = summary.length > 0 ? summary : rule.title;
|
||||||
|
return { url, title: rule.title, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share / export handlers for the completed step (`readLastPublishedRule`).
|
||||||
|
*/
|
||||||
|
export function useCompletedRuleShareExport({
|
||||||
|
setActionBanner,
|
||||||
|
}: {
|
||||||
|
setActionBanner: (_: CompletedFlowActionBanner | null) => void;
|
||||||
|
}): {
|
||||||
|
copyPublishedRuleLink: () => Promise<void>;
|
||||||
|
mailtoPublishedRule: () => void;
|
||||||
|
sharePublishedRuleViaSignal: () => Promise<void>;
|
||||||
|
sharePublishedRuleViaSlack: () => Promise<void>;
|
||||||
|
sharePublishedRuleViaDiscord: () => Promise<void>;
|
||||||
|
onSelectExportFormat: (_format: "pdf" | "csv" | "markdown") => void;
|
||||||
|
} {
|
||||||
|
const t = useTranslation("create.reviewAndComplete.completed");
|
||||||
|
|
||||||
|
const bannerNoRule = useCallback(() => {
|
||||||
|
setActionBanner({
|
||||||
|
key: "completedShareNoRule",
|
||||||
|
status: "danger",
|
||||||
|
title: t("shareNoRuleTitle"),
|
||||||
|
description: t("shareNoRuleDescription"),
|
||||||
|
});
|
||||||
|
}, [setActionBanner, t]);
|
||||||
|
|
||||||
|
const bannerCopied = useCallback(() => {
|
||||||
|
setActionBanner({
|
||||||
|
key: "completedShareCopied",
|
||||||
|
status: "positive",
|
||||||
|
title: t("shareLinkCopiedTitle"),
|
||||||
|
description: t("shareLinkCopiedDescription"),
|
||||||
|
});
|
||||||
|
}, [setActionBanner, t]);
|
||||||
|
|
||||||
|
const bannerCopyFailed = useCallback(() => {
|
||||||
|
setActionBanner({
|
||||||
|
key: "completedShareCopyFailed",
|
||||||
|
status: "danger",
|
||||||
|
title: t("shareCopyFailedTitle"),
|
||||||
|
description: t("shareCopyFailedDescription"),
|
||||||
|
});
|
||||||
|
}, [setActionBanner, t]);
|
||||||
|
|
||||||
|
const copyUrlToClipboard = useCallback(
|
||||||
|
async (
|
||||||
|
url: string,
|
||||||
|
banner?: () => void,
|
||||||
|
options?: { suppressFailureWhenDocumentNotFocused?: boolean },
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
(banner ?? bannerCopied)();
|
||||||
|
} catch {
|
||||||
|
if (
|
||||||
|
options?.suppressFailureWhenDocumentNotFocused === true &&
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
shouldSkipShareClipboardFallback(window)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bannerCopyFailed();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[bannerCopied, bannerCopyFailed],
|
||||||
|
);
|
||||||
|
|
||||||
|
const copyPublishedRuleLink = useCallback(async () => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const ctx = resolvePublishedRuleShareContext(window);
|
||||||
|
if (!ctx) {
|
||||||
|
bannerNoRule();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await copyUrlToClipboard(ctx.url);
|
||||||
|
}, [bannerNoRule, copyUrlToClipboard]);
|
||||||
|
|
||||||
|
const mailtoPublishedRule = useCallback(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const ctx = resolvePublishedRuleShareContext(window);
|
||||||
|
if (!ctx) {
|
||||||
|
bannerNoRule();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = `${ctx.text}\n\n${ctx.url}`;
|
||||||
|
window.location.href = buildMailtoShareHref({
|
||||||
|
subject: ctx.title,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}, [bannerNoRule]);
|
||||||
|
|
||||||
|
const tryNavigatorShareAbortOk = useCallback(
|
||||||
|
async (data: ShareData): Promise<boolean> => {
|
||||||
|
if (typeof navigator.share !== "function") return false;
|
||||||
|
const can =
|
||||||
|
typeof navigator.canShare !== "function" || navigator.canShare(data);
|
||||||
|
if (!can) return false;
|
||||||
|
try {
|
||||||
|
await navigator.share(data);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as { name?: string };
|
||||||
|
if (err?.name === "AbortError") return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Prefer URL-only share data when the platform allows it (common on mobile). */
|
||||||
|
const shareViaWebShareApiOrFalse = useCallback(
|
||||||
|
async (ctx: { url: string; title: string; text: string }) => {
|
||||||
|
const urlOnly: ShareData = { url: ctx.url };
|
||||||
|
if (await tryNavigatorShareAbortOk(urlOnly)) return true;
|
||||||
|
const full: ShareData = {
|
||||||
|
title: ctx.title,
|
||||||
|
text: ctx.text,
|
||||||
|
url: ctx.url,
|
||||||
|
};
|
||||||
|
return tryNavigatorShareAbortOk(full);
|
||||||
|
},
|
||||||
|
[tryNavigatorShareAbortOk],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sharePublishedRuleViaSignal = useCallback(async () => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const ctx = resolvePublishedRuleShareContext(window);
|
||||||
|
if (!ctx) {
|
||||||
|
bannerNoRule();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (await shareViaWebShareApiOrFalse(ctx)) return;
|
||||||
|
await copyUrlToClipboard(ctx.url);
|
||||||
|
}, [bannerNoRule, copyUrlToClipboard, shareViaWebShareApiOrFalse]);
|
||||||
|
|
||||||
|
const sharePublishedRuleViaSlack = useCallback(async () => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const ctx = resolvePublishedRuleShareContext(window);
|
||||||
|
if (!ctx) {
|
||||||
|
bannerNoRule();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runSlackWebComposeFallback = async (): Promise<void> => {
|
||||||
|
const slackUrl = buildSlackWebShareUrl(ctx.url);
|
||||||
|
const popup = window.open(
|
||||||
|
slackUrl,
|
||||||
|
"_blank",
|
||||||
|
"noopener,noreferrer",
|
||||||
|
);
|
||||||
|
if (popup) return;
|
||||||
|
|
||||||
|
if (shouldSkipShareClipboardFallback(window)) return;
|
||||||
|
|
||||||
|
if (await shareViaWebShareApiOrFalse(ctx)) return;
|
||||||
|
|
||||||
|
if (shouldSkipShareClipboardFallback(window)) return;
|
||||||
|
|
||||||
|
await copyUrlToClipboard(
|
||||||
|
ctx.url,
|
||||||
|
() =>
|
||||||
|
setActionBanner({
|
||||||
|
key: "completedShareSlackFallback",
|
||||||
|
status: "positive",
|
||||||
|
title: t("shareSlackFallbackTitle"),
|
||||||
|
description: t("shareSlackFallbackDescription"),
|
||||||
|
}),
|
||||||
|
{ suppressFailureWhenDocumentNotFocused: true },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
scheduleNativeSchemeThenFallback(
|
||||||
|
SLACK_NATIVE_OPEN_URL,
|
||||||
|
() => void runSlackWebComposeFallback(),
|
||||||
|
browserNativeShareNavigateDeps(window),
|
||||||
|
browserNativeTimers(window),
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
bannerNoRule,
|
||||||
|
copyUrlToClipboard,
|
||||||
|
shareViaWebShareApiOrFalse,
|
||||||
|
setActionBanner,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sharePublishedRuleViaDiscord = useCallback(async () => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const ctx = resolvePublishedRuleShareContext(window);
|
||||||
|
if (!ctx) {
|
||||||
|
bannerNoRule();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await shareViaWebShareApiOrFalse(ctx)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(ctx.url);
|
||||||
|
setActionBanner({
|
||||||
|
key: "completedShareDiscordPaste",
|
||||||
|
status: "positive",
|
||||||
|
title: t("shareDiscordPasteTitle"),
|
||||||
|
description: t("shareDiscordPasteDescription"),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
bannerCopyFailed();
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleNativeSchemeThenFallback(
|
||||||
|
DISCORD_NATIVE_DM_HUB_URL,
|
||||||
|
() =>
|
||||||
|
void window.open(
|
||||||
|
DISCORD_WEB_DM_HUB_URL,
|
||||||
|
"_blank",
|
||||||
|
"noopener,noreferrer",
|
||||||
|
),
|
||||||
|
browserNativeShareNavigateDeps(window),
|
||||||
|
browserNativeTimers(window),
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
bannerCopyFailed,
|
||||||
|
bannerNoRule,
|
||||||
|
shareViaWebShareApiOrFalse,
|
||||||
|
setActionBanner,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const onSelectExportFormat = useCallback(
|
||||||
|
(format: "pdf" | "csv" | "markdown") => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const rule = readLastPublishedRule();
|
||||||
|
if (!rule) {
|
||||||
|
setActionBanner({
|
||||||
|
key: "completedExportNoRule",
|
||||||
|
status: "danger",
|
||||||
|
title: t("shareNoRuleTitle"),
|
||||||
|
description: t("shareNoRuleDescription"),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = exportFilenameBase(rule);
|
||||||
|
try {
|
||||||
|
if (format === "pdf") {
|
||||||
|
downloadStoredRuleAsPdf(rule);
|
||||||
|
} else if (format === "csv") {
|
||||||
|
const csv = exportStoredRuleAsCsv(rule);
|
||||||
|
downloadTextFile(
|
||||||
|
`${base}-community-rule.csv`,
|
||||||
|
csv,
|
||||||
|
"text/csv;charset=utf-8",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const md = exportStoredRuleAsMarkdown(rule);
|
||||||
|
downloadTextFile(
|
||||||
|
`${base}-community-rule.md`,
|
||||||
|
md,
|
||||||
|
"text/markdown;charset=utf-8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error && e.message === "exportEmptyDocument";
|
||||||
|
setActionBanner({
|
||||||
|
key: "completedExportFailed",
|
||||||
|
status: "danger",
|
||||||
|
title: msg ? t("exportEmptyDocumentTitle") : t("exportFailedTitle"),
|
||||||
|
description: msg
|
||||||
|
? t("exportEmptyDocumentDescription")
|
||||||
|
: t("exportFailedDescription"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setActionBanner, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
copyPublishedRuleLink,
|
||||||
|
mailtoPublishedRule,
|
||||||
|
sharePublishedRuleViaSignal,
|
||||||
|
sharePublishedRuleViaSlack,
|
||||||
|
sharePublishedRuleViaDiscord,
|
||||||
|
onSelectExportFormat,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
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";
|
||||||
|
|
||||||
|
export type CreateFlowExitClearState = () => void;
|
||||||
|
|
||||||
|
type AppRouterLike = { push: (_href: string) => void };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leave the create flow for a **signed-in** user. Caller must not invoke for anonymous users.
|
||||||
|
*/
|
||||||
|
export function useCreateFlowExit({
|
||||||
|
state,
|
||||||
|
currentStep,
|
||||||
|
clearState,
|
||||||
|
router,
|
||||||
|
user,
|
||||||
|
setDraftSaveBannerMessage,
|
||||||
|
confirmLeave,
|
||||||
|
}: {
|
||||||
|
state: CreateFlowState;
|
||||||
|
currentStep: CreateFlowStep | null;
|
||||||
|
clearState: CreateFlowExitClearState;
|
||||||
|
router: AppRouterLike;
|
||||||
|
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 denying leave. */
|
||||||
|
confirmLeave?: () => Promise<boolean>;
|
||||||
|
}): (_options?: { saveDraft?: boolean }) => Promise<void> {
|
||||||
|
return useCallback(
|
||||||
|
async (options?: { saveDraft?: boolean }) => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const saveDraft = options?.saveDraft ?? false;
|
||||||
|
|
||||||
|
if (!saveDraft) {
|
||||||
|
const confirmFn = confirmLeave ?? (async () => false);
|
||||||
|
const confirmed = await confirmFn();
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveDraft && isBackendSyncEnabled()) {
|
||||||
|
const editingId =
|
||||||
|
typeof state.editingPublishedRuleId === "string"
|
||||||
|
? state.editingPublishedRuleId.trim()
|
||||||
|
: "";
|
||||||
|
if (editingId.length > 0) {
|
||||||
|
const payloadResult = buildPublishPayload(state);
|
||||||
|
if (payloadResult.ok === false) {
|
||||||
|
setDraftSaveBannerMessage?.(
|
||||||
|
payloadResult.error === "missingCommunityName"
|
||||||
|
? messages.create.reviewAndComplete.publish
|
||||||
|
.missingCommunityName
|
||||||
|
: payloadResult.error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { title, summary, document } = payloadResult;
|
||||||
|
const updateResult = await updatePublishedRule(editingId, {
|
||||||
|
title,
|
||||||
|
summary: summary ?? null,
|
||||||
|
document,
|
||||||
|
});
|
||||||
|
if (updateResult.ok === true) {
|
||||||
|
writeLastPublishedRule({
|
||||||
|
id: editingId,
|
||||||
|
title,
|
||||||
|
summary: summary ?? null,
|
||||||
|
document,
|
||||||
|
});
|
||||||
|
setDraftSaveBannerMessage?.(null);
|
||||||
|
} else {
|
||||||
|
setDraftSaveBannerMessage?.(updateResult.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const payload: CreateFlowState = {
|
||||||
|
...state,
|
||||||
|
...(currentStep ? { currentStep } : {}),
|
||||||
|
};
|
||||||
|
const result = await saveDraftToServer(payload);
|
||||||
|
if (result.ok === true) {
|
||||||
|
setDraftSaveBannerMessage?.(null);
|
||||||
|
} else {
|
||||||
|
setDraftSaveBannerMessage?.(result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearState();
|
||||||
|
router.push("/");
|
||||||
|
},
|
||||||
|
[
|
||||||
|
state,
|
||||||
|
currentStep,
|
||||||
|
clearState,
|
||||||
|
router,
|
||||||
|
user,
|
||||||
|
setDraftSaveBannerMessage,
|
||||||
|
confirmLeave,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
|
||||||
|
import { publishRule, updatePublishedRule } from "../../../../lib/create/api";
|
||||||
|
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
|
||||||
|
import messages from "../../../../messages/en/index";
|
||||||
|
import type { CreateFlowState } from "../types";
|
||||||
|
import {
|
||||||
|
CREATE_FLOW_COMPLETED_CELEBRATE_QUERY,
|
||||||
|
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
|
||||||
|
} from "../utils/flowSteps";
|
||||||
|
import { createFlowStepPath } from "../utils/createFlowPaths";
|
||||||
|
|
||||||
|
type AppRouterLike = { push: (_href: string) => void };
|
||||||
|
|
||||||
|
type OpenLogin = (args: {
|
||||||
|
variant: "default" | "saveProgress";
|
||||||
|
nextPath: string;
|
||||||
|
backdropVariant: "blurredYellow";
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
export type UseCreateFlowFinalizeResult = {
|
||||||
|
publishBannerMessage: string | null;
|
||||||
|
setPublishBannerMessage: (_message: string | null) => void;
|
||||||
|
isPublishing: boolean;
|
||||||
|
finalize: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Final Review → publish: banner + `isPublishing`, consumed by `CreateFlowLayoutClient`. */
|
||||||
|
export function useCreateFlowFinalize({
|
||||||
|
state,
|
||||||
|
router,
|
||||||
|
openLogin,
|
||||||
|
updateState,
|
||||||
|
loginReturnPath,
|
||||||
|
}: {
|
||||||
|
state: CreateFlowState;
|
||||||
|
router: AppRouterLike;
|
||||||
|
openLogin: OpenLogin;
|
||||||
|
updateState: (_patch: Partial<CreateFlowState>) => void;
|
||||||
|
/** Session gate return path (`?syncDraft=1`) — differs for `/create/edit-rule` vs `/create/final-review`. */
|
||||||
|
loginReturnPath: string;
|
||||||
|
}): UseCreateFlowFinalizeResult {
|
||||||
|
const [publishBannerMessage, setPublishBannerMessage] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [isPublishing, setIsPublishing] = useState(false);
|
||||||
|
|
||||||
|
const finalize = useCallback(async () => {
|
||||||
|
setPublishBannerMessage(null);
|
||||||
|
const payloadResult = buildPublishPayload(state);
|
||||||
|
if (payloadResult.ok === false) {
|
||||||
|
setPublishBannerMessage(
|
||||||
|
payloadResult.error === "missingCommunityName"
|
||||||
|
? messages.create.reviewAndComplete.publish.missingCommunityName
|
||||||
|
: payloadResult.error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { title, summary, document: ruleDocument } = payloadResult;
|
||||||
|
setIsPublishing(true);
|
||||||
|
|
||||||
|
const editingId =
|
||||||
|
typeof state.editingPublishedRuleId === "string"
|
||||||
|
? state.editingPublishedRuleId.trim()
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (editingId.length > 0) {
|
||||||
|
const updateResult = await updatePublishedRule(editingId, {
|
||||||
|
title,
|
||||||
|
summary: summary ?? null,
|
||||||
|
document: ruleDocument,
|
||||||
|
});
|
||||||
|
setIsPublishing(false);
|
||||||
|
if (updateResult.ok === true) {
|
||||||
|
writeLastPublishedRule({
|
||||||
|
id: editingId,
|
||||||
|
title,
|
||||||
|
summary: summary ?? null,
|
||||||
|
document: ruleDocument,
|
||||||
|
});
|
||||||
|
updateState({ editingPublishedRuleId: undefined });
|
||||||
|
router.push(createFlowStepPath("completed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (updateResult.status === 401) {
|
||||||
|
openLogin({
|
||||||
|
variant: "default",
|
||||||
|
nextPath: loginReturnPath,
|
||||||
|
backdropVariant: "blurredYellow",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPublishBannerMessage(
|
||||||
|
updateResult.error.trim() !== ""
|
||||||
|
? updateResult.error
|
||||||
|
: messages.create.reviewAndComplete.publish.genericPublishFailed,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stakeholderEmails = (state.stakeholderEmails ?? []).filter(
|
||||||
|
(e) => typeof e === "string" && e.trim() !== "",
|
||||||
|
);
|
||||||
|
const publishResult = await publishRule({
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
document: ruleDocument,
|
||||||
|
...(stakeholderEmails.length > 0 ? { stakeholderEmails } : {}),
|
||||||
|
});
|
||||||
|
setIsPublishing(false);
|
||||||
|
if (publishResult.ok === true) {
|
||||||
|
writeLastPublishedRule({
|
||||||
|
id: publishResult.id,
|
||||||
|
title,
|
||||||
|
summary: summary ?? null,
|
||||||
|
document: ruleDocument,
|
||||||
|
});
|
||||||
|
router.push(
|
||||||
|
createFlowStepPath("completed", {
|
||||||
|
[CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]:
|
||||||
|
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (publishResult.status === 401) {
|
||||||
|
openLogin({
|
||||||
|
variant: "default",
|
||||||
|
nextPath: loginReturnPath,
|
||||||
|
backdropVariant: "blurredYellow",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPublishBannerMessage(
|
||||||
|
publishResult.error.trim() !== ""
|
||||||
|
? publishResult.error
|
||||||
|
: messages.create.reviewAndComplete.publish.genericPublishFailed,
|
||||||
|
);
|
||||||
|
}, [state, router, openLogin, updateState, loginReturnPath]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
publishBannerMessage,
|
||||||
|
setPublishBannerMessage,
|
||||||
|
isPublishing,
|
||||||
|
finalize,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useMediaQuery } from "../../../hooks/useMediaQuery";
|
||||||
|
|
||||||
|
/** `--breakpoint-lg` (1024px); same SSR/first-paint pattern as `useCreateFlowMdUp`. */
|
||||||
|
const CREATE_FLOW_MIN_WIDTH_LG = "(min-width: 1024px)";
|
||||||
|
|
||||||
|
/** True at viewport ≥1024px (e.g. review grid column split with Tailwind `lg:`). */
|
||||||
|
export function useCreateFlowLgUp(): boolean {
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
const isLgOrLarger = useMediaQuery(CREATE_FLOW_MIN_WIDTH_LG);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer until mount for SSR/first-paint alignment
|
||||||
|
setIsMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return !isMounted || isLgOrLarger;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useMediaQuery } from "../../../hooks/useMediaQuery";
|
||||||
|
|
||||||
|
/** `--breakpoint-md` (640px); same SSR/first-paint pattern as `useCreateFlowLgUp`. */
|
||||||
|
const CREATE_FLOW_MIN_WIDTH_MD = "(min-width: 640px)";
|
||||||
|
|
||||||
|
/** True at viewport ≥640px (pairs with Tailwind `md:` on create-flow screens). */
|
||||||
|
export function useCreateFlowMdUp(): boolean {
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
const isMdOrLarger = useMediaQuery(CREATE_FLOW_MIN_WIDTH_MD);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer until mount for SSR/first-paint alignment
|
||||||
|
setIsMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return !isMounted || isMdOrLarger;
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useCallback, useLayoutEffect, useMemo } from "react";
|
||||||
|
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||||
|
import type { CreateFlowStep } from "../types";
|
||||||
|
import {
|
||||||
|
type CreateFlowNavigationOptions,
|
||||||
|
type CreateFlowReviewReturnTarget,
|
||||||
|
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
|
||||||
|
buildTemplateReviewHref,
|
||||||
|
getNextStep,
|
||||||
|
getPreviousStep,
|
||||||
|
parseCreateFlowScreenFromPathname,
|
||||||
|
resolveCreateFlowBackTarget,
|
||||||
|
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
|
||||||
|
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
|
||||||
|
} from "../utils/flowSteps";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options passed to navigation handlers (e.g. for blur before navigate)
|
||||||
|
*/
|
||||||
|
const blurActiveElement = (): void => {
|
||||||
|
if (
|
||||||
|
typeof document !== "undefined" &&
|
||||||
|
document.activeElement instanceof HTMLElement
|
||||||
|
) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for Create Rule Flow navigation.
|
||||||
|
*
|
||||||
|
* Resolves the active step from `/create/{screenId}` via
|
||||||
|
* {@link parseCreateFlowScreenFromPathname} (flowSteps). Footer Back uses
|
||||||
|
* {@link resolveCreateFlowBackTarget} so template **Use without changes**
|
||||||
|
* (which skips the custom-rule segment) returns to `/create/review-template/{slug}`
|
||||||
|
* from `confirm-stakeholders` instead of `conflict-management`.
|
||||||
|
*
|
||||||
|
* Template review footer Back uses {@link buildTemplateReviewHref}’s
|
||||||
|
* `?fromFlow=1` marker (and persisted `templateReviewEntryFromCreateFlow`) so
|
||||||
|
* users who came from `/create/review` return there instead of `/`.
|
||||||
|
*/
|
||||||
|
export function useCreateFlowNavigation(
|
||||||
|
options?: CreateFlowNavigationOptions,
|
||||||
|
): {
|
||||||
|
currentStep: CreateFlowStep | null;
|
||||||
|
goToNextStep: () => void;
|
||||||
|
goToPreviousStep: () => void;
|
||||||
|
goToStep: (
|
||||||
|
_step: CreateFlowStep,
|
||||||
|
_navOpts?: { reviewReturn?: CreateFlowReviewReturnTarget },
|
||||||
|
) => void;
|
||||||
|
canGoNext: () => boolean;
|
||||||
|
canGoBack: () => boolean;
|
||||||
|
nextStep: CreateFlowStep | null;
|
||||||
|
previousStep: CreateFlowStep | null;
|
||||||
|
/** On `/create/review-template/…`, footer Back should go to `/create/review`. */
|
||||||
|
templateReviewFooterBackToCreateReview: boolean;
|
||||||
|
} {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const { state, updateState } = useCreateFlow();
|
||||||
|
|
||||||
|
const validStep = parseCreateFlowScreenFromPathname(pathname ?? null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!pathname?.includes("/create/review-template/")) return;
|
||||||
|
if (
|
||||||
|
searchParams.get(TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY) !==
|
||||||
|
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.templateReviewEntryFromCreateFlow === true) return;
|
||||||
|
updateState({ templateReviewEntryFromCreateFlow: true });
|
||||||
|
}, [
|
||||||
|
pathname,
|
||||||
|
searchParams,
|
||||||
|
state.templateReviewEntryFromCreateFlow,
|
||||||
|
updateState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const nextStep = getNextStep(validStep, options);
|
||||||
|
const previousStep = getPreviousStep(validStep, options);
|
||||||
|
|
||||||
|
const backTarget = useMemo(
|
||||||
|
() =>
|
||||||
|
resolveCreateFlowBackTarget(
|
||||||
|
validStep,
|
||||||
|
options,
|
||||||
|
state.templateReviewBackSlug,
|
||||||
|
),
|
||||||
|
[validStep, options?.skipCommunitySave, state.templateReviewBackSlug],
|
||||||
|
);
|
||||||
|
|
||||||
|
const goToNextStep = useCallback(() => {
|
||||||
|
blurActiveElement();
|
||||||
|
if (nextStep) {
|
||||||
|
router.push(`/create/${nextStep}`);
|
||||||
|
}
|
||||||
|
}, [router, nextStep]);
|
||||||
|
|
||||||
|
const goToPreviousStep = useCallback(() => {
|
||||||
|
blurActiveElement();
|
||||||
|
if (!backTarget) return;
|
||||||
|
if (backTarget.kind === "templateReview") {
|
||||||
|
router.push(
|
||||||
|
buildTemplateReviewHref(backTarget.slug, {
|
||||||
|
fromCreateWizard: state.templateReviewEntryFromCreateFlow === true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/create/${backTarget.step}`);
|
||||||
|
}, [router, backTarget, state.templateReviewEntryFromCreateFlow]);
|
||||||
|
|
||||||
|
const templateReviewFooterBackToCreateReview = useMemo(
|
||||||
|
() =>
|
||||||
|
Boolean(state.templateReviewEntryFromCreateFlow) ||
|
||||||
|
(pathname?.includes("/create/review-template/") &&
|
||||||
|
searchParams.get(TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY) ===
|
||||||
|
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE),
|
||||||
|
[state.templateReviewEntryFromCreateFlow, pathname, searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const goToStep = useCallback(
|
||||||
|
(
|
||||||
|
step: CreateFlowStep,
|
||||||
|
navOpts?: { reviewReturn?: CreateFlowReviewReturnTarget },
|
||||||
|
) => {
|
||||||
|
blurActiveElement();
|
||||||
|
const params = new URLSearchParams(searchParams?.toString() ?? "");
|
||||||
|
if (navOpts?.reviewReturn != null) {
|
||||||
|
params.set(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY, navOpts.reviewReturn);
|
||||||
|
} else {
|
||||||
|
params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
|
||||||
|
}
|
||||||
|
const qs = params.toString();
|
||||||
|
router.push(qs.length > 0 ? `/create/${step}?${qs}` : `/create/${step}`);
|
||||||
|
},
|
||||||
|
[router, searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const canGoNext = useCallback(() => nextStep !== null, [nextStep]);
|
||||||
|
const canGoBack = useCallback(() => backTarget != null, [backTarget]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentStep: validStep,
|
||||||
|
goToNextStep,
|
||||||
|
goToPreviousStep,
|
||||||
|
goToStep,
|
||||||
|
canGoNext,
|
||||||
|
canGoBack,
|
||||||
|
nextStep,
|
||||||
|
previousStep,
|
||||||
|
templateReviewFooterBackToCreateReview,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useMediaQuery } from "../../../hooks/useMediaQuery";
|
||||||
|
|
||||||
|
/** `--breakpoint-sm2` (440px); pairs with Tailwind `sm2:` on create-flow chrome. */
|
||||||
|
const CREATE_FLOW_MIN_WIDTH_SM2 = "(min-width: 440px)";
|
||||||
|
|
||||||
|
/** True at viewport ≥440px. */
|
||||||
|
export function useCreateFlowSm2Up(): boolean {
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
const isSm2OrLarger = useMediaQuery(CREATE_FLOW_MIN_WIDTH_SM2);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- defer until mount for SSR/first-paint alignment
|
||||||
|
setIsMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return !isMounted || isSm2OrLarger;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||||
|
import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable writer for `customMethodCardFieldBlocksById[id]` used from facet card
|
||||||
|
* modals. Uses {@link replaceState} so merges read the latest draft (no stale
|
||||||
|
* closure over `customMethodCardFieldBlocksById`).
|
||||||
|
*/
|
||||||
|
export function useCustomMethodCardFieldBlocksChange(cardId: string | null) {
|
||||||
|
const { replaceState, markCreateFlowInteraction } = useCreateFlow();
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
(nextBlocks: CustomMethodCardFieldBlock[]) => {
|
||||||
|
if (!cardId) return;
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
replaceState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
customMethodCardFieldBlocksById: {
|
||||||
|
...(prev.customMethodCardFieldBlocksById ?? {}),
|
||||||
|
[cardId]: nextBlocks,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[cardId, markCreateFlowInteraction, replaceState],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import messages from "../../../../messages/en/index";
|
||||||
|
import { useAsyncConfirm } from "../../../hooks/useAsyncConfirm";
|
||||||
|
import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks";
|
||||||
|
import {
|
||||||
|
confirmDiscardMethodCardCustomizeSession,
|
||||||
|
isMethodCardCustomizeSessionDirty,
|
||||||
|
type MethodCardCustomizeSnapshot,
|
||||||
|
type MethodCardHeaderDraft,
|
||||||
|
} from "../../../../lib/create/methodCardCustomizeSession";
|
||||||
|
|
||||||
|
const copy = messages.create.customRule.modalKebabMenu;
|
||||||
|
|
||||||
|
const confirmOptions = {
|
||||||
|
title: copy.discardUnsavedCustomizeChangesTitle,
|
||||||
|
description: copy.discardUnsavedCustomizeChangesDescription,
|
||||||
|
proceedText: copy.discardUnsavedCustomizeChangesProceed,
|
||||||
|
cancelText: copy.discardUnsavedCustomizeChangesCancel,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create-flow confirm for exiting customize mode with unsaved edits.
|
||||||
|
*
|
||||||
|
* @returns Async helpers plus `confirmDialog` to render once in the screen JSX.
|
||||||
|
*/
|
||||||
|
export function useDiscardCustomizeConfirm() {
|
||||||
|
const { requestConfirm, confirmDialog } = useAsyncConfirm();
|
||||||
|
|
||||||
|
const runConfirm = useCallback(
|
||||||
|
() => requestConfirm(confirmOptions),
|
||||||
|
[requestConfirm],
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirmDiscard = useCallback(
|
||||||
|
async <TDraft,>(
|
||||||
|
modalEditUnlocked: boolean,
|
||||||
|
snapshot: MethodCardCustomizeSnapshot<TDraft> | null,
|
||||||
|
pendingDraft: TDraft | null,
|
||||||
|
draftFieldBlocks: CustomMethodCardFieldBlock[] | null,
|
||||||
|
headerDraft: MethodCardHeaderDraft | null,
|
||||||
|
) =>
|
||||||
|
confirmDiscardMethodCardCustomizeSession(
|
||||||
|
modalEditUnlocked,
|
||||||
|
snapshot,
|
||||||
|
pendingDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
headerDraft,
|
||||||
|
runConfirm,
|
||||||
|
),
|
||||||
|
[runConfirm],
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirmDirtyCustomizeCancel = useCallback(
|
||||||
|
async <TDraft,>(
|
||||||
|
snapshot: MethodCardCustomizeSnapshot<TDraft>,
|
||||||
|
pendingDraft: TDraft | null,
|
||||||
|
draftFieldBlocks: CustomMethodCardFieldBlock[] | null,
|
||||||
|
headerDraft: MethodCardHeaderDraft | null,
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
!isMethodCardCustomizeSessionDirty(
|
||||||
|
snapshot,
|
||||||
|
pendingDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
headerDraft,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return runConfirm();
|
||||||
|
},
|
||||||
|
[runConfirm],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog };
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { buildFacetQueryString } from "../../../../lib/create/buildFacetQueryString";
|
||||||
|
import type { MethodFacetApiSectionId } from "../../../../lib/create/customRuleFacets";
|
||||||
|
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card-deck section ids served by `/api/create-flow/methods` (CR-88 §9.2).
|
||||||
|
* Same tuple as {@link METHOD_FACET_API_SECTION_IDS} (`CUSTOM_RULE_FACETS`, CR-92).
|
||||||
|
*/
|
||||||
|
export type RecommendationSection = MethodFacetApiSectionId;
|
||||||
|
|
||||||
|
export type FacetRecommendationsResult = {
|
||||||
|
/** `true` once the network call completes (or short-circuits with no facets). */
|
||||||
|
isReady: boolean;
|
||||||
|
/** `slug → score`; missing slug means `0`. */
|
||||||
|
scoresBySlug: Record<string, number>;
|
||||||
|
/**
|
||||||
|
* `true` iff the user has selected at least one community facet. When
|
||||||
|
* `false`, callers should preserve authoring order rather than reranking.
|
||||||
|
*/
|
||||||
|
hasAnyFacets: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_SCORES: Record<string, number> = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls `GET /api/create-flow/methods?section=<section>&facet.*=...` for the
|
||||||
|
* card-deck step `section` and returns a `slug → score` map for re-ranking
|
||||||
|
* the messages-file `methods[]` array (CR-88 §10).
|
||||||
|
*
|
||||||
|
* Returns `{ isReady: true, scoresBySlug: {} }` when the user has not selected
|
||||||
|
* any community facets — callers fall back to the authoring order.
|
||||||
|
*
|
||||||
|
* Network failures resolve to `scoresBySlug: {}` so the wizard is never
|
||||||
|
* blocked on the recommendation backend.
|
||||||
|
*/
|
||||||
|
export function useFacetRecommendations(
|
||||||
|
section: RecommendationSection,
|
||||||
|
): FacetRecommendationsResult {
|
||||||
|
const { state } = useCreateFlow();
|
||||||
|
const queryString = useMemo(
|
||||||
|
() => buildFacetQueryString(state),
|
||||||
|
[state],
|
||||||
|
);
|
||||||
|
const hasAnyFacets = queryString.length > 0;
|
||||||
|
|
||||||
|
const [result, setResult] = useState<FacetRecommendationsResult>({
|
||||||
|
isReady: !hasAnyFacets,
|
||||||
|
scoresBySlug: EMPTY_SCORES,
|
||||||
|
hasAnyFacets,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track the last successful request input so we don't re-fetch on every state poke.
|
||||||
|
const lastQueryRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasAnyFacets) {
|
||||||
|
setResult({
|
||||||
|
isReady: true,
|
||||||
|
scoresBySlug: EMPTY_SCORES,
|
||||||
|
hasAnyFacets: false,
|
||||||
|
});
|
||||||
|
lastQueryRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestKey = `${section}?${queryString}`;
|
||||||
|
if (lastQueryRef.current === requestKey) return;
|
||||||
|
lastQueryRef.current = requestKey;
|
||||||
|
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
setResult((prev) => ({ ...prev, isReady: false, hasAnyFacets: true }));
|
||||||
|
fetch(`/api/create-flow/methods?section=${section}&${queryString}`, {
|
||||||
|
credentials: "include",
|
||||||
|
signal: ctrl.signal,
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||||
|
return (await res.json()) as {
|
||||||
|
methods?: { slug: string; matches?: { score?: number } }[];
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.then((json) => {
|
||||||
|
const scoresBySlug: Record<string, number> = {};
|
||||||
|
for (const m of json.methods ?? []) {
|
||||||
|
if (typeof m.slug === "string") {
|
||||||
|
scoresBySlug[m.slug] = m.matches?.score ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setResult({ isReady: true, scoresBySlug, hasAnyFacets: true });
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if ((e as { name?: string }).name === "AbortError") return;
|
||||||
|
setResult({
|
||||||
|
isReady: true,
|
||||||
|
scoresBySlug: EMPTY_SCORES,
|
||||||
|
hasAnyFacets: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ctrl.abort();
|
||||||
|
// Clear the dedup key so React 19 Strict Mode's mount → unmount → mount
|
||||||
|
// cycle (and any future remount) re-issues the request instead of
|
||||||
|
// returning early on the same key.
|
||||||
|
if (lastQueryRef.current === requestKey) {
|
||||||
|
lastQueryRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [section, queryString, hasAnyFacets]);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable comparator for re-ranking a messages-file `methods[]` array. Higher
|
||||||
|
* `scoresBySlug[id]` first; ties fall back to authoring index, so a
|
||||||
|
* zero-facet user sees the original ordering verbatim.
|
||||||
|
*/
|
||||||
|
export function rankMethodsByScore<T extends { id: string }>(
|
||||||
|
methods: readonly T[],
|
||||||
|
scoresBySlug: Record<string, number>,
|
||||||
|
): T[] {
|
||||||
|
const indexById = new Map<string, number>();
|
||||||
|
methods.forEach((m, i) => indexById.set(m.id, i));
|
||||||
|
return [...methods].sort((a, b) => {
|
||||||
|
const sa = scoresBySlug[a.id] ?? 0;
|
||||||
|
const sb = scoresBySlug[b.id] ?? 0;
|
||||||
|
if (sa !== sb) return sb - sa;
|
||||||
|
return (indexById.get(a.id) ?? 0) - (indexById.get(b.id) ?? 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks (a) which method ids fill the compact card stack and (b) which of
|
||||||
|
* those should render with the "Recommended" tag. The messages JSON no
|
||||||
|
* longer carries a static `recommended` flag — both selections come
|
||||||
|
* entirely from facet scores (CR-88 §10).
|
||||||
|
*
|
||||||
|
* Behavior:
|
||||||
|
* - Facets selected & at least one method scored > 0 →
|
||||||
|
* `compactCardIds` = up to `limit` top-scored methods (1..limit cards;
|
||||||
|
* never padded with unrecommended fillers). All shown cards get the
|
||||||
|
* "Recommended" badge.
|
||||||
|
* - No facets selected, or every method scored 0 → `compactCardIds` =
|
||||||
|
* first `limit` in ranked/authoring order, `recommendedIds` empty (no
|
||||||
|
* badges shown — honest "no signal yet" fallback).
|
||||||
|
*
|
||||||
|
* `CardStack.view` is responsible for laying out variable-length compact
|
||||||
|
* arrays gracefully (uses `.map`/`.slice` and length-guarded indexing).
|
||||||
|
*/
|
||||||
|
export function deriveCompactCards<T extends { id: string }>(
|
||||||
|
rankedMethods: readonly T[],
|
||||||
|
scoresBySlug: Record<string, number>,
|
||||||
|
hasAnyFacets: boolean,
|
||||||
|
limit: number,
|
||||||
|
): { compactCardIds: string[]; recommendedIds: Set<string> } {
|
||||||
|
const fallback = () => ({
|
||||||
|
compactCardIds: rankedMethods.slice(0, limit).map((m) => m.id),
|
||||||
|
recommendedIds: new Set<string>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasAnyFacets) return fallback();
|
||||||
|
|
||||||
|
const matched = rankedMethods.filter(
|
||||||
|
(m) => (scoresBySlug[m.id] ?? 0) > 0,
|
||||||
|
);
|
||||||
|
if (matched.length === 0) return fallback();
|
||||||
|
|
||||||
|
const top = matched.slice(0, limit);
|
||||||
|
return {
|
||||||
|
compactCardIds: top.map((m) => m.id),
|
||||||
|
recommendedIds: new Set(top.map((m) => m.id)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
mergeCompactCardIdsWithPinnedSelected,
|
||||||
|
orderRankedMethodsWithPinnedSelection,
|
||||||
|
} from "../../../../lib/create/methodCardDisplayOrder";
|
||||||
|
import {
|
||||||
|
deriveCompactCards,
|
||||||
|
rankMethodsByScore,
|
||||||
|
useFacetRecommendations,
|
||||||
|
type RecommendationSection,
|
||||||
|
} from "./useFacetRecommendations";
|
||||||
|
|
||||||
|
type MethodEntry = { id: string; label: string; supportText: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies score ranking, compact-slot rules, then surfaces selected ids first in
|
||||||
|
* `selected*Ids` order (most-recent add at index 0 via
|
||||||
|
* {@link moveFacetSelectionIdToFront}). Selection-first applies whenever the facet
|
||||||
|
* has any selection — not only after footer Confirm (`methodSectionsPinCommitted`).
|
||||||
|
*/
|
||||||
|
export function useMethodCardDeckOrdering(
|
||||||
|
section: RecommendationSection,
|
||||||
|
methods: readonly MethodEntry[],
|
||||||
|
selectedIds: readonly string[],
|
||||||
|
) {
|
||||||
|
const { scoresBySlug, hasAnyFacets } = useFacetRecommendations(section);
|
||||||
|
|
||||||
|
const rankedMethods = useMemo(
|
||||||
|
() => rankMethodsByScore(methods, scoresBySlug),
|
||||||
|
[methods, scoresBySlug],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectionShowcaseActive = selectedIds.length > 0;
|
||||||
|
|
||||||
|
const displayMethods = useMemo(
|
||||||
|
() =>
|
||||||
|
orderRankedMethodsWithPinnedSelection(
|
||||||
|
rankedMethods,
|
||||||
|
selectedIds,
|
||||||
|
selectionShowcaseActive,
|
||||||
|
),
|
||||||
|
[rankedMethods, selectedIds, selectionShowcaseActive],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { compactCardIds: baseCompactCardIds, recommendedIds } = useMemo(
|
||||||
|
() =>
|
||||||
|
deriveCompactCards(
|
||||||
|
rankedMethods,
|
||||||
|
scoresBySlug,
|
||||||
|
hasAnyFacets,
|
||||||
|
/* limit */ 5,
|
||||||
|
),
|
||||||
|
[rankedMethods, scoresBySlug, hasAnyFacets],
|
||||||
|
);
|
||||||
|
|
||||||
|
const compactCardIds = useMemo(
|
||||||
|
() =>
|
||||||
|
mergeCompactCardIdsWithPinnedSelected(
|
||||||
|
displayMethods.map((m) => m.id),
|
||||||
|
baseCompactCardIds,
|
||||||
|
selectedIds,
|
||||||
|
selectionShowcaseActive,
|
||||||
|
5,
|
||||||
|
),
|
||||||
|
[displayMethods, baseCompactCardIds, selectedIds, selectionShowcaseActive],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sampleCards = useMemo(
|
||||||
|
() =>
|
||||||
|
displayMethods.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
label: entry.label,
|
||||||
|
supportText: entry.supportText,
|
||||||
|
recommended: recommendedIds.has(entry.id),
|
||||||
|
})),
|
||||||
|
[displayMethods, recommendedIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const methodById = useMemo(
|
||||||
|
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
|
||||||
|
[rankedMethods],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rankedMethods,
|
||||||
|
displayMethods,
|
||||||
|
compactCardIds,
|
||||||
|
recommendedIds,
|
||||||
|
sampleCards,
|
||||||
|
methodById,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { buildTemplateCustomizePrefill } from "../../../../lib/create/applyTemplatePrefill";
|
||||||
|
import { loadTemplateReviewBySlug } from "../../../../lib/create/loadTemplateReviewBySlug";
|
||||||
|
import { methodSectionsPinsForHydratedSelections } from "../../../../lib/create/publishedDocumentToCreateFlowState";
|
||||||
|
import { stripCustomRuleSelectionFields } from "../../../../lib/create/stripCustomRuleSelectionFields";
|
||||||
|
import messages from "../../../../messages/en/index";
|
||||||
|
import type {
|
||||||
|
CreateFlowContextValue,
|
||||||
|
CreateFlowState,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
type AppRouterLike = { push: (_href: string) => void };
|
||||||
|
type UpdateState = CreateFlowContextValue["updateState"];
|
||||||
|
type ReplaceStateFn = CreateFlowContextValue["replaceState"];
|
||||||
|
|
||||||
|
export type UseTemplateReviewActionsResult = {
|
||||||
|
/** True iff the current pathname is a template-review route (locale/basePath tolerant). */
|
||||||
|
isTemplateReviewRoute: boolean;
|
||||||
|
/** Decoded slug parsed out of the template-review pathname, or null. */
|
||||||
|
templateReviewSlug: string | null;
|
||||||
|
/** True between the fetch start and resolution for either action. */
|
||||||
|
isApplyingTemplate: boolean;
|
||||||
|
/** Set when the template fetch failed or the body was malformed. Cleared at the start of each action. */
|
||||||
|
templateReviewApplyError: string | null;
|
||||||
|
setTemplateReviewApplyError: (_message: string | null) => void;
|
||||||
|
/**
|
||||||
|
* Customize: apply the template's selections onto state and route to
|
||||||
|
* `/create/core-values` (if community name is set) or `/create/informational`
|
||||||
|
* with a `pendingTemplateAction` pin so `/create/review` can later replace
|
||||||
|
* itself with `/create/core-values`.
|
||||||
|
*/
|
||||||
|
handleCustomize: () => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Use without changes: scrub any prior customize picks, seed core values +
|
||||||
|
* method-card selections from the template body (same id mapping as
|
||||||
|
* Customize) so drilling from final-review via + shows selected cards, drop
|
||||||
|
* the Values row from `state.sections`, and route to
|
||||||
|
* `/create/confirm-stakeholders` (or `/create/informational` with a pin to
|
||||||
|
* skip past `/create/review` to `/create/confirm-stakeholders` later).
|
||||||
|
*/
|
||||||
|
handleUseWithoutChanges: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encapsulates the two template-review footer actions (Customize / Use
|
||||||
|
* without changes) plus the small amount of state they share (in-flight
|
||||||
|
* flag, error banner, parsed slug). Called from `CreateFlowLayoutClient`
|
||||||
|
* once; extracting it here keeps the layout shell focused on rendering
|
||||||
|
* rather than orchestrating template fetch + state seeding.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const {
|
||||||
|
* isTemplateReviewRoute,
|
||||||
|
* templateReviewSlug,
|
||||||
|
* isApplyingTemplate,
|
||||||
|
* templateReviewApplyError,
|
||||||
|
* setTemplateReviewApplyError,
|
||||||
|
* handleCustomize,
|
||||||
|
* handleUseWithoutChanges,
|
||||||
|
* } = useTemplateReviewActions({ pathname, state, updateState, replaceState, router });
|
||||||
|
*/
|
||||||
|
export function useTemplateReviewActions({
|
||||||
|
pathname,
|
||||||
|
state,
|
||||||
|
updateState,
|
||||||
|
replaceState,
|
||||||
|
router,
|
||||||
|
}: {
|
||||||
|
pathname: string | null | undefined;
|
||||||
|
state: CreateFlowState;
|
||||||
|
updateState: UpdateState;
|
||||||
|
replaceState: ReplaceStateFn;
|
||||||
|
router: AppRouterLike;
|
||||||
|
}): UseTemplateReviewActionsResult {
|
||||||
|
const [isApplyingTemplate, setIsApplyingTemplate] = useState(false);
|
||||||
|
const [templateReviewApplyError, setTemplateReviewApplyError] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const templateReviewSlug = useMemo(() => {
|
||||||
|
const m = pathname?.match(/\/create\/review-template\/([^/?#]+)/);
|
||||||
|
return m?.[1] ? decodeURIComponent(m[1]) : null;
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
const isTemplateReviewRoute = Boolean(
|
||||||
|
pathname?.includes("/create/review-template/"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCustomize = useCallback(async () => {
|
||||||
|
if (!templateReviewSlug) return;
|
||||||
|
setTemplateReviewApplyError(null);
|
||||||
|
setIsApplyingTemplate(true);
|
||||||
|
const loaded = await loadTemplateReviewBySlug(templateReviewSlug);
|
||||||
|
setIsApplyingTemplate(false);
|
||||||
|
if (loaded.ok === false) {
|
||||||
|
setTemplateReviewApplyError(loaded.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prefill = buildTemplateCustomizePrefill(loaded.template.body);
|
||||||
|
const pinPatch = methodSectionsPinsForHydratedSelections(prefill);
|
||||||
|
const hasCommunityName =
|
||||||
|
typeof state.title === "string" && state.title.trim().length > 0;
|
||||||
|
updateState({
|
||||||
|
...prefill,
|
||||||
|
methodSectionsPinCommitted: {
|
||||||
|
...state.methodSectionsPinCommitted,
|
||||||
|
...pinPatch,
|
||||||
|
},
|
||||||
|
templateReviewBackSlug: undefined,
|
||||||
|
...(hasCommunityName
|
||||||
|
? { pendingTemplateAction: undefined }
|
||||||
|
: {
|
||||||
|
pendingTemplateAction: {
|
||||||
|
slug: templateReviewSlug,
|
||||||
|
mode: "customize",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
router.push(
|
||||||
|
hasCommunityName ? "/create/core-values" : "/create/informational",
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
router,
|
||||||
|
state.methodSectionsPinCommitted,
|
||||||
|
state.title,
|
||||||
|
templateReviewSlug,
|
||||||
|
updateState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleUseWithoutChanges = useCallback(async () => {
|
||||||
|
if (!templateReviewSlug) return;
|
||||||
|
setTemplateReviewApplyError(null);
|
||||||
|
setIsApplyingTemplate(true);
|
||||||
|
const loaded = await loadTemplateReviewBySlug(templateReviewSlug);
|
||||||
|
setIsApplyingTemplate(false);
|
||||||
|
if (loaded.ok === false) {
|
||||||
|
setTemplateReviewApplyError(loaded.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { template } = loaded;
|
||||||
|
const doc = template.body;
|
||||||
|
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
||||||
|
setTemplateReviewApplyError(
|
||||||
|
messages.create.templateReview.errors.applyFailed,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sectionsRaw = (doc as { sections?: unknown }).sections;
|
||||||
|
const sections = Array.isArray(sectionsRaw)
|
||||||
|
? (sectionsRaw as Record<string, unknown>[])
|
||||||
|
: [];
|
||||||
|
if (sections.length === 0) {
|
||||||
|
setTemplateReviewApplyError(
|
||||||
|
messages.create.templateReview.errors.applyFailed,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCommunityName =
|
||||||
|
typeof state.title === "string" && state.title.trim().length > 0;
|
||||||
|
|
||||||
|
// Atomic read-modify-write: strip prior custom-rule picks and merge template
|
||||||
|
// body in one replaceState so method ids are never lost across React batching
|
||||||
|
// (reset + update separately could leave selections undefined in Strict Mode).
|
||||||
|
replaceState((prev) => {
|
||||||
|
const base = stripCustomRuleSelectionFields(prev);
|
||||||
|
const customizePrefill = buildTemplateCustomizePrefill(doc);
|
||||||
|
const hasValuesSeed =
|
||||||
|
customizePrefill.selectedCoreValueIds !== undefined;
|
||||||
|
|
||||||
|
const sectionsWithoutValues = hasValuesSeed
|
||||||
|
? sections.filter((s) => {
|
||||||
|
const name = (s as { categoryName?: unknown }).categoryName;
|
||||||
|
if (typeof name !== "string") return true;
|
||||||
|
const key = name.toLowerCase().replace(/[^a-z]+/g, "");
|
||||||
|
return key !== "values" && key !== "corevalues";
|
||||||
|
})
|
||||||
|
: sections;
|
||||||
|
|
||||||
|
const hasCommunityName =
|
||||||
|
typeof prev.title === "string" && prev.title.trim().length > 0;
|
||||||
|
|
||||||
|
const pinPatch =
|
||||||
|
methodSectionsPinsForHydratedSelections(customizePrefill);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...(hasValuesSeed
|
||||||
|
? {
|
||||||
|
selectedCoreValueIds: customizePrefill.selectedCoreValueIds,
|
||||||
|
coreValuesChipsSnapshot:
|
||||||
|
customizePrefill.coreValuesChipsSnapshot,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(customizePrefill.selectedCommunicationMethodIds !== undefined
|
||||||
|
? {
|
||||||
|
selectedCommunicationMethodIds:
|
||||||
|
customizePrefill.selectedCommunicationMethodIds,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(customizePrefill.selectedMembershipMethodIds !== undefined
|
||||||
|
? {
|
||||||
|
selectedMembershipMethodIds:
|
||||||
|
customizePrefill.selectedMembershipMethodIds,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(customizePrefill.selectedDecisionApproachIds !== undefined
|
||||||
|
? {
|
||||||
|
selectedDecisionApproachIds:
|
||||||
|
customizePrefill.selectedDecisionApproachIds,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(customizePrefill.selectedConflictManagementIds !== undefined
|
||||||
|
? {
|
||||||
|
selectedConflictManagementIds:
|
||||||
|
customizePrefill.selectedConflictManagementIds,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
sections: sectionsWithoutValues,
|
||||||
|
methodSectionsPinCommitted: pinPatch,
|
||||||
|
templateReviewBackSlug: templateReviewSlug,
|
||||||
|
...(hasCommunityName
|
||||||
|
? { pendingTemplateAction: undefined }
|
||||||
|
: {
|
||||||
|
pendingTemplateAction: {
|
||||||
|
slug: templateReviewSlug,
|
||||||
|
mode: "useWithoutChanges",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
router.push(
|
||||||
|
hasCommunityName
|
||||||
|
? "/create/confirm-stakeholders"
|
||||||
|
: "/create/informational",
|
||||||
|
);
|
||||||
|
}, [replaceState, router, state.title, templateReviewSlug]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isTemplateReviewRoute,
|
||||||
|
templateReviewSlug,
|
||||||
|
isApplyingTemplate,
|
||||||
|
templateReviewApplyError,
|
||||||
|
setTemplateReviewApplyError,
|
||||||
|
handleCustomize,
|
||||||
|
handleUseWithoutChanges,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import CreateFlowLayoutGate from "./CreateFlowLayoutGate";
|
||||||
|
|
||||||
|
export default function CreateFlowLayout({ children }: { children: ReactNode }) {
|
||||||
|
return <CreateFlowLayoutGate>{children}</CreateFlowLayoutGate>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { FIRST_STEP } from "./utils/flowSteps";
|
||||||
|
|
||||||
|
/** `/create` redirects to the first wizard step (Figma frame 1). */
|
||||||
|
export default function CreateIndexPage() {
|
||||||
|
redirect(`/create/${FIRST_STEP}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { use, useEffect, useState } from "react";
|
||||||
|
import { TemplateReviewCard } from "../../../../components/cards/TemplateReviewCard";
|
||||||
|
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||||
|
import {
|
||||||
|
fetchTemplateBySlug,
|
||||||
|
isTemplatesFetchAborted,
|
||||||
|
type RuleTemplateDto,
|
||||||
|
} from "../../../../../lib/create/fetchTemplates";
|
||||||
|
import messages from "../../../../../messages/en/index";
|
||||||
|
import Alert from "../../../../components/modals/Alert";
|
||||||
|
import {
|
||||||
|
CREATE_FLOW_REVIEW_RULE_LAYOUT_CLASS,
|
||||||
|
CreateFlowLockupCardStepShell,
|
||||||
|
} from "../../components/CreateFlowLockupCardStepShell";
|
||||||
|
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||||
|
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
||||||
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Template review route — same shell/grid as final-review; Figma `22142-898702`. */
|
||||||
|
export default function ReviewTemplatePage({ params }: PageProps) {
|
||||||
|
const { slug: rawSlug } = use(params);
|
||||||
|
const slug = decodeURIComponent(rawSlug);
|
||||||
|
const mdUp = useCreateFlowMdUp();
|
||||||
|
const t = useTranslation("create.templateReview");
|
||||||
|
|
||||||
|
const [template, setTemplate] = useState<RuleTemplateDto | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ac = new AbortController();
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await fetchTemplateBySlug(slug, {
|
||||||
|
signal: ac.signal,
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
if (result === null) {
|
||||||
|
setError(messages.create.templateReview.errors.notFound);
|
||||||
|
setTemplate(null);
|
||||||
|
} else if ("error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
setTemplate(null);
|
||||||
|
} else {
|
||||||
|
setTemplate(result);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled || isTemplatesFetchAborted(e)) return;
|
||||||
|
setError(messages.create.templateReview.errors.loadFailed);
|
||||||
|
setTemplate(null);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
ac.abort();
|
||||||
|
};
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
|
||||||
|
<div
|
||||||
|
className={`flex shrink-0 items-center justify-start pb-16 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
||||||
|
>
|
||||||
|
<p className="text-[var(--color-content-default-secondary,#a3a3a3)]">
|
||||||
|
{t("loading")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CreateFlowStepShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !template) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none fixed left-0 right-0 top-14 z-[120] flex justify-center px-5 pt-3 md:top-20 md:px-12"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div className="pointer-events-auto w-full max-w-[960px]">
|
||||||
|
<Alert
|
||||||
|
type="banner"
|
||||||
|
status="danger"
|
||||||
|
title={t("errors.loadFailed")}
|
||||||
|
description={error ?? t("errors.notFound")}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
|
||||||
|
<div
|
||||||
|
className={`min-h-[40vh] shrink-0 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</CreateFlowStepShell>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreateFlowLockupCardStepShell
|
||||||
|
lockupTitle={t("intro.title")}
|
||||||
|
lockupDescription={t("intro.description")}
|
||||||
|
>
|
||||||
|
<TemplateReviewCard
|
||||||
|
template={template}
|
||||||
|
ruleCardClassName={CREATE_FLOW_REVIEW_RULE_LAYOUT_CLASS}
|
||||||
|
size={mdUp ? "L" : "M"}
|
||||||
|
/>
|
||||||
|
</CreateFlowLockupCardStepShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { CreateFlowStep } from "../types";
|
||||||
|
import { renderCreateFlowScreen } from "./createFlowScreenComponents";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps each wizard `screenId` to its screen component.
|
||||||
|
*
|
||||||
|
* **Folder rule (Figma):** subfolders match `CREATE_FLOW_SCREEN_REGISTRY[].layoutKind`
|
||||||
|
* — `select/` (two-column chip flows), `card/` (compact card-stack steps), `text/`, etc.
|
||||||
|
* The URL segment (`communication-methods`) is not the folder name; see `createFlowScreenRegistry.ts`.
|
||||||
|
*
|
||||||
|
* Implementation lives in {@link renderCreateFlowScreen} (`createFlowScreenComponents.tsx`)
|
||||||
|
* so the registry metadata and this router stay easier to keep in sync (CR-92 §3).
|
||||||
|
*/
|
||||||
|
export function CreateFlowScreenView({
|
||||||
|
screenId,
|
||||||
|
}: {
|
||||||
|
screenId: CreateFlowStep;
|
||||||
|
}): ReactNode {
|
||||||
|
return renderCreateFlowScreen(screenId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,833 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `communication-methods` step — Figma “Flow — Compact Card Stack” (node `20246-15828`).
|
||||||
|
* Registry: `layoutKind: "card"` (`CREATE_FLOW_SCREEN_REGISTRY["communication-methods"]`).
|
||||||
|
*
|
||||||
|
* Lives under `screens/card/` (not `select/`): Figma **card stack** layout is a distinct shell from
|
||||||
|
* two-column chip **select** frames. Future card-stack steps get their own `*Screen.tsx` here and
|
||||||
|
* reuse `CardStack` / `CreateFlowStepShell` as needed.
|
||||||
|
*
|
||||||
|
* Card click opens the Figma create modal (node `20246-15829`) with three
|
||||||
|
* editable sections rendered by {@link CommunicationMethodEditFields}. The primary
|
||||||
|
* action is **Add Platform** for an unselected card; a selected card in view mode has
|
||||||
|
* no footer primary — **Remove** is available from the kebab (same behavior as legacy
|
||||||
|
* footer remove via {@link removeMethodCardFromFacetSelection}).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useMemo, useRef } from "react";
|
||||||
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
|
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
||||||
|
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||||
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
|
import CardStack from "../../../../components/cards/CardStack";
|
||||||
|
import Create from "../../../../components/modals/Create";
|
||||||
|
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
||||||
|
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||||
|
import {
|
||||||
|
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
|
||||||
|
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
|
||||||
|
} from "../../components/createFlowLayoutTokens";
|
||||||
|
import { CommunicationMethodEditFields } from "../../components/methodEditFields";
|
||||||
|
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
||||||
|
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
||||||
|
import { communicationPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
|
||||||
|
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||||
|
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
|
||||||
|
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
|
||||||
|
import { isCustomMethodCardId } from "../../../../../lib/create/isCustomMethodCardId";
|
||||||
|
import { communicationMethodFacetMatchesPreset } from "../../../../../lib/create/methodCardFacetMatchesPresetForId";
|
||||||
|
import { usesWizardFieldBlocksModalBody } from "../../../../../lib/create/usesWizardFieldBlocksModalBody";
|
||||||
|
import { removeMethodCardFromFacetSelection } from "../../../../../lib/create/removeMethodCardFromFacetSelection";
|
||||||
|
import {
|
||||||
|
cloneMethodCardBlocksForDuplicate,
|
||||||
|
cloneMethodCardDetailsForDuplicate,
|
||||||
|
duplicateMethodCardTitle,
|
||||||
|
forkMethodCardFacetMapsForDuplicate,
|
||||||
|
omitIdFromStringRecord,
|
||||||
|
} from "../../../../../lib/create/duplicateMethodCardModalDraft";
|
||||||
|
import type { CommunicationMethodDetailEntry } from "../../types";
|
||||||
|
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
|
||||||
|
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
|
||||||
|
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
||||||
|
import {
|
||||||
|
captureMethodCardCustomizeSnapshot,
|
||||||
|
type MethodCardCustomizeSnapshot,
|
||||||
|
type MethodCardHeaderDraft,
|
||||||
|
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||||
|
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
|
||||||
|
|
||||||
|
export function CommunicationMethodsScreen() {
|
||||||
|
const m = useMessages();
|
||||||
|
const comm = m.create.customRule.communication;
|
||||||
|
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
||||||
|
const mdUp = useCreateFlowMdUp();
|
||||||
|
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
|
||||||
|
useDiscardCustomizeConfirm();
|
||||||
|
const { state, updateState, replaceState, markCreateFlowInteraction } =
|
||||||
|
useCreateFlow();
|
||||||
|
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
|
||||||
|
const customizeSnapshotRef = useRef<
|
||||||
|
MethodCardCustomizeSnapshot<CommunicationMethodDetailEntry> | null
|
||||||
|
>(null);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
|
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
|
||||||
|
const [pendingDraft, setPendingDraft] =
|
||||||
|
useState<CommunicationMethodDetailEntry | null>(null);
|
||||||
|
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
|
||||||
|
const [modalEditUnlocked, setModalEditUnlocked] = useState(false);
|
||||||
|
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
|
||||||
|
CustomMethodCardFieldBlock[] | null
|
||||||
|
>(null);
|
||||||
|
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
|
||||||
|
useState<MethodCardHeaderDraft | null>(null);
|
||||||
|
|
||||||
|
const selectedIds = state.selectedCommunicationMethodIds ?? [];
|
||||||
|
|
||||||
|
const mergedMethods = useMemo(
|
||||||
|
() =>
|
||||||
|
mergePresetMethodsWithCustom(
|
||||||
|
comm.methods,
|
||||||
|
selectedIds,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
),
|
||||||
|
[comm.methods, selectedIds, state.customMethodCardMetaById],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
|
||||||
|
"communication",
|
||||||
|
mergedMethods,
|
||||||
|
selectedIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenAddWizard = useCallback(() => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setAddCustomWizardOpen(true);
|
||||||
|
}, [markCreateFlowInteraction]);
|
||||||
|
|
||||||
|
const title = expanded ? comm.page.expandedTitle : comm.page.compactTitle;
|
||||||
|
|
||||||
|
const description = expanded ? (
|
||||||
|
<>
|
||||||
|
{comm.page.expandedDescriptionBefore}
|
||||||
|
<InlineTextButton onClick={handleOpenAddWizard}>
|
||||||
|
{comm.page.compactDescriptionLinkLabel}
|
||||||
|
</InlineTextButton>
|
||||||
|
{comm.page.expandedDescriptionAfter}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{comm.page.compactDescriptionBefore}
|
||||||
|
<InlineTextButton onClick={handleOpenAddWizard}>
|
||||||
|
{comm.page.compactDescriptionLinkLabel}
|
||||||
|
</InlineTextButton>
|
||||||
|
{comm.page.compactDescriptionAfter}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const seedDraft = useCallback(
|
||||||
|
(id: string): CommunicationMethodDetailEntry => {
|
||||||
|
const saved = state.communicationMethodDetailsById?.[id];
|
||||||
|
if (saved) {
|
||||||
|
return { ...saved };
|
||||||
|
}
|
||||||
|
return communicationPresetFor(id);
|
||||||
|
},
|
||||||
|
[state.communicationMethodDetailsById],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCardClick = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
setModalEditUnlocked(false);
|
||||||
|
setDraftFieldBlocks(null);
|
||||||
|
setCustomizeHeaderDraft(null);
|
||||||
|
setPendingCardId(id);
|
||||||
|
setPendingDraft(seedDraft(id));
|
||||||
|
setCreateModalOpen(true);
|
||||||
|
},
|
||||||
|
[markCreateFlowInteraction, seedDraft],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDraftChange = useCallback(
|
||||||
|
(next: CommunicationMethodDetailEntry) => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setPendingDraft(next);
|
||||||
|
},
|
||||||
|
[markCreateFlowInteraction],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSelectedCardModal =
|
||||||
|
pendingCardId !== null && selectedIds.includes(pendingCardId);
|
||||||
|
const fieldsLocked = !modalEditUnlocked;
|
||||||
|
|
||||||
|
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
|
||||||
|
|
||||||
|
const customFacetDetailsMatchPreset = useMemo(() => {
|
||||||
|
if (!pendingCardId || !pendingDraft) return false;
|
||||||
|
if (!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return communicationMethodFacetMatchesPreset(pendingDraft, pendingCardId);
|
||||||
|
}, [
|
||||||
|
pendingCardId,
|
||||||
|
pendingDraft,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const modalUsesWizardFieldBlocksBody = useMemo(
|
||||||
|
() =>
|
||||||
|
Boolean(
|
||||||
|
pendingCardId &&
|
||||||
|
usesWizardFieldBlocksModalBody({
|
||||||
|
methodId: pendingCardId,
|
||||||
|
meta: state.customMethodCardMetaById,
|
||||||
|
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||||
|
modalEditUnlocked,
|
||||||
|
draftFieldBlocks,
|
||||||
|
customFacetDetailsMatchPreset,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
customFacetDetailsMatchPreset,
|
||||||
|
draftFieldBlocks,
|
||||||
|
modalEditUnlocked,
|
||||||
|
pendingCardId,
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateModalClose = useCallback(async () => {
|
||||||
|
if (
|
||||||
|
!(await confirmDiscard(
|
||||||
|
modalEditUnlocked,
|
||||||
|
customizeSnapshotRef.current,
|
||||||
|
pendingDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
customizeHeaderDraft,
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
const ephemeralId = pendingEphemeralDuplicateIdRef.current;
|
||||||
|
if (ephemeralId) {
|
||||||
|
pendingEphemeralDuplicateIdRef.current = null;
|
||||||
|
replaceState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
customMethodCardMetaById: omitIdFromStringRecord(
|
||||||
|
prev.customMethodCardMetaById,
|
||||||
|
ephemeralId,
|
||||||
|
),
|
||||||
|
communicationMethodDetailsById: omitIdFromStringRecord(
|
||||||
|
prev.communicationMethodDetailsById,
|
||||||
|
ephemeralId,
|
||||||
|
),
|
||||||
|
customMethodCardFieldBlocksById: omitIdFromStringRecord(
|
||||||
|
prev.customMethodCardFieldBlocksById,
|
||||||
|
ephemeralId,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
setCreateModalOpen(false);
|
||||||
|
setPendingCardId(null);
|
||||||
|
setPendingDraft(null);
|
||||||
|
setModalEditUnlocked(false);
|
||||||
|
setDraftFieldBlocks(null);
|
||||||
|
setCustomizeHeaderDraft(null);
|
||||||
|
}, [
|
||||||
|
confirmDiscard,
|
||||||
|
customizeHeaderDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
modalEditUnlocked,
|
||||||
|
pendingDraft,
|
||||||
|
replaceState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleCancelCustomize = useCallback(async () => {
|
||||||
|
if (!modalEditUnlocked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const snap = customizeSnapshotRef.current;
|
||||||
|
if (!snap) {
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
setModalEditUnlocked(false);
|
||||||
|
setDraftFieldBlocks(null);
|
||||||
|
setCustomizeHeaderDraft(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!(await confirmDirtyCustomizeCancel(
|
||||||
|
snap,
|
||||||
|
pendingDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
customizeHeaderDraft,
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingDraft(structuredClone(snap.pendingDraft));
|
||||||
|
setDraftFieldBlocks(null);
|
||||||
|
setModalEditUnlocked(false);
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
setCustomizeHeaderDraft(null);
|
||||||
|
}, [
|
||||||
|
confirmDirtyCustomizeCancel,
|
||||||
|
customizeHeaderDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
modalEditUnlocked,
|
||||||
|
pendingDraft,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleRemoveSelectedFromModal = useCallback(async () => {
|
||||||
|
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
if (
|
||||||
|
!(await confirmDiscard(
|
||||||
|
modalEditUnlocked,
|
||||||
|
customizeSnapshotRef.current,
|
||||||
|
pendingDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
customizeHeaderDraft,
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
updateState(
|
||||||
|
removeMethodCardFromFacetSelection(
|
||||||
|
state,
|
||||||
|
"communication",
|
||||||
|
pendingCardId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await handleCreateModalClose();
|
||||||
|
}, [
|
||||||
|
confirmDiscard,
|
||||||
|
customizeHeaderDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
handleCreateModalClose,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
modalEditUnlocked,
|
||||||
|
pendingDraft,
|
||||||
|
pendingCardId,
|
||||||
|
selectedIds,
|
||||||
|
state,
|
||||||
|
updateState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleCustomize = useCallback(() => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
if (!pendingDraft || !pendingCardId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const persistedBlocks =
|
||||||
|
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [];
|
||||||
|
const initialFieldBlocks =
|
||||||
|
persistedBlocks.length > 0
|
||||||
|
? structuredClone(persistedBlocks)
|
||||||
|
: isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||||
|
? []
|
||||||
|
: null;
|
||||||
|
const method = methodById.get(pendingCardId);
|
||||||
|
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
||||||
|
const headerDraft: MethodCardHeaderDraft = {
|
||||||
|
title: meta?.label ?? method?.label ?? comm.confirmModal.title,
|
||||||
|
description:
|
||||||
|
meta?.supportText ??
|
||||||
|
method?.supportText ??
|
||||||
|
comm.confirmModal.description,
|
||||||
|
};
|
||||||
|
setCustomizeHeaderDraft(headerDraft);
|
||||||
|
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
||||||
|
pendingDraft,
|
||||||
|
initialFieldBlocks,
|
||||||
|
headerDraft,
|
||||||
|
);
|
||||||
|
setDraftFieldBlocks(initialFieldBlocks);
|
||||||
|
setModalEditUnlocked(true);
|
||||||
|
}, [
|
||||||
|
comm.confirmModal.description,
|
||||||
|
comm.confirmModal.title,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
methodById,
|
||||||
|
pendingCardId,
|
||||||
|
pendingDraft,
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDuplicateCustomCard = useCallback(() => {
|
||||||
|
if (
|
||||||
|
!pendingCardId ||
|
||||||
|
!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
const newId = crypto.randomUUID();
|
||||||
|
const meta = state.customMethodCardMetaById![pendingCardId]!;
|
||||||
|
const detailsClone = cloneMethodCardDetailsForDuplicate(
|
||||||
|
pendingDraft,
|
||||||
|
state.communicationMethodDetailsById?.[pendingCardId],
|
||||||
|
() => communicationPresetFor(newId),
|
||||||
|
);
|
||||||
|
const blocksClone = structuredClone(
|
||||||
|
modalEditUnlocked &&
|
||||||
|
draftFieldBlocks !== null &&
|
||||||
|
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||||
|
? draftFieldBlocks
|
||||||
|
: cloneMethodCardBlocksForDuplicate(
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
pendingCardId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const suffix = modalKebabMenu.duplicateTitleSuffix;
|
||||||
|
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
|
||||||
|
const maps = forkMethodCardFacetMapsForDuplicate({
|
||||||
|
customMethodCardMetaById: state.customMethodCardMetaById,
|
||||||
|
facetDetailsById: state.communicationMethodDetailsById,
|
||||||
|
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||||
|
omitId: priorEphemeral,
|
||||||
|
});
|
||||||
|
maps.customMethodCardMetaById[newId] = {
|
||||||
|
label: duplicateMethodCardTitle(meta.label, suffix),
|
||||||
|
supportText: meta.supportText,
|
||||||
|
};
|
||||||
|
maps.facetDetailsById[newId] = detailsClone;
|
||||||
|
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
|
||||||
|
updateState({
|
||||||
|
customMethodCardMetaById: maps.customMethodCardMetaById,
|
||||||
|
communicationMethodDetailsById: maps.facetDetailsById,
|
||||||
|
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
|
||||||
|
});
|
||||||
|
pendingEphemeralDuplicateIdRef.current = newId;
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
setPendingCardId(newId);
|
||||||
|
setPendingDraft(structuredClone(detailsClone));
|
||||||
|
setModalEditUnlocked(false);
|
||||||
|
setDraftFieldBlocks(null);
|
||||||
|
setCustomizeHeaderDraft(null);
|
||||||
|
}, [
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
modalKebabMenu.duplicateTitleSuffix,
|
||||||
|
pendingCardId,
|
||||||
|
pendingDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
modalEditUnlocked,
|
||||||
|
state.communicationMethodDetailsById,
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
updateState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDuplicatePrefabCard = useCallback(() => {
|
||||||
|
if (
|
||||||
|
!pendingCardId ||
|
||||||
|
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const method = methodById.get(pendingCardId);
|
||||||
|
if (!method || !pendingDraft) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
const newId = crypto.randomUUID();
|
||||||
|
const detailsClone = cloneMethodCardDetailsForDuplicate(
|
||||||
|
pendingDraft,
|
||||||
|
state.communicationMethodDetailsById?.[pendingCardId],
|
||||||
|
() => communicationPresetFor(newId),
|
||||||
|
);
|
||||||
|
const blocksClone = structuredClone(
|
||||||
|
modalEditUnlocked &&
|
||||||
|
draftFieldBlocks !== null &&
|
||||||
|
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||||
|
? draftFieldBlocks
|
||||||
|
: cloneMethodCardBlocksForDuplicate(
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
pendingCardId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const suffix = modalKebabMenu.duplicateTitleSuffix;
|
||||||
|
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
|
||||||
|
const maps = forkMethodCardFacetMapsForDuplicate({
|
||||||
|
customMethodCardMetaById: state.customMethodCardMetaById,
|
||||||
|
facetDetailsById: state.communicationMethodDetailsById,
|
||||||
|
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||||
|
omitId: priorEphemeral,
|
||||||
|
});
|
||||||
|
maps.customMethodCardMetaById[newId] = {
|
||||||
|
label: duplicateMethodCardTitle(method.label, suffix),
|
||||||
|
supportText: method.supportText,
|
||||||
|
};
|
||||||
|
maps.facetDetailsById[newId] = detailsClone;
|
||||||
|
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
|
||||||
|
updateState({
|
||||||
|
customMethodCardMetaById: maps.customMethodCardMetaById,
|
||||||
|
communicationMethodDetailsById: maps.facetDetailsById,
|
||||||
|
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
|
||||||
|
});
|
||||||
|
pendingEphemeralDuplicateIdRef.current = newId;
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
setPendingCardId(newId);
|
||||||
|
setPendingDraft(structuredClone(detailsClone));
|
||||||
|
setModalEditUnlocked(false);
|
||||||
|
setDraftFieldBlocks(null);
|
||||||
|
setCustomizeHeaderDraft(null);
|
||||||
|
}, [
|
||||||
|
draftFieldBlocks,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
methodById,
|
||||||
|
modalEditUnlocked,
|
||||||
|
modalKebabMenu.duplicateTitleSuffix,
|
||||||
|
pendingCardId,
|
||||||
|
pendingDraft,
|
||||||
|
state.communicationMethodDetailsById,
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
updateState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const kebabMenuItems = useMemo(
|
||||||
|
() =>
|
||||||
|
buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
||||||
|
showCustomize: !modalEditUnlocked,
|
||||||
|
onCustomize: handleCustomize,
|
||||||
|
onDuplicate:
|
||||||
|
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
|
||||||
|
? undefined
|
||||||
|
: isCustomMethodCardId(
|
||||||
|
pendingCardId,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
)
|
||||||
|
? handleDuplicateCustomCard
|
||||||
|
: handleDuplicatePrefabCard,
|
||||||
|
showRemove: isSelectedCardModal,
|
||||||
|
onRemove: handleRemoveSelectedFromModal,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
handleCustomize,
|
||||||
|
handleDuplicateCustomCard,
|
||||||
|
handleDuplicatePrefabCard,
|
||||||
|
handleRemoveSelectedFromModal,
|
||||||
|
isSelectedCardModal,
|
||||||
|
modalEditUnlocked,
|
||||||
|
modalKebabMenu,
|
||||||
|
pendingCardId,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
state.editingPublishedRuleId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const modalConfig = pendingCardId
|
||||||
|
? (() => {
|
||||||
|
const method = methodById.get(pendingCardId);
|
||||||
|
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
||||||
|
const saveLabel = modalKebabMenu.saveEdits;
|
||||||
|
return {
|
||||||
|
title: meta?.label ?? method?.label ?? comm.confirmModal.title,
|
||||||
|
description:
|
||||||
|
meta?.supportText ??
|
||||||
|
method?.supportText ??
|
||||||
|
comm.confirmModal.description,
|
||||||
|
nextButtonText: modalEditUnlocked
|
||||||
|
? saveLabel
|
||||||
|
: comm.addPlatform.nextButtonText,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
: {
|
||||||
|
title: comm.confirmModal.title,
|
||||||
|
description: comm.confirmModal.description,
|
||||||
|
nextButtonText: comm.confirmModal.nextButtonText,
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseAddWizard = useCallback(() => {
|
||||||
|
setAddCustomWizardOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFinalizeCustomCard = useCallback(
|
||||||
|
({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
fieldBlocks,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
fieldBlocks: CustomMethodCardFieldBlock[];
|
||||||
|
}) => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
updateState({
|
||||||
|
selectedCommunicationMethodIds: moveFacetSelectionIdToFront(
|
||||||
|
selectedIds,
|
||||||
|
id,
|
||||||
|
),
|
||||||
|
customMethodCardMetaById: {
|
||||||
|
...(state.customMethodCardMetaById ?? {}),
|
||||||
|
[id]: { label: title, supportText: description },
|
||||||
|
},
|
||||||
|
communicationMethodDetailsById: {
|
||||||
|
...(state.communicationMethodDetailsById ?? {}),
|
||||||
|
[id]: communicationPresetFor(id),
|
||||||
|
},
|
||||||
|
customMethodCardFieldBlocksById: {
|
||||||
|
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||||
|
[id]: fieldBlocks,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
selectedIds,
|
||||||
|
state.communicationMethodDetailsById,
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
updateState,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateModalPrimary = useCallback(() => {
|
||||||
|
if (!pendingCardId) {
|
||||||
|
handleCreateModalClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
|
||||||
|
if (selectedIds.includes(pendingCardId)) {
|
||||||
|
if (modalEditUnlocked) {
|
||||||
|
if (!customizeHeaderDraft) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextMeta = methodCardMetaWithCustomizeHeader(
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
pendingCardId,
|
||||||
|
customizeHeaderDraft,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
pendingCardId &&
|
||||||
|
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
||||||
|
usesWizardFieldBlocksModalBody({
|
||||||
|
methodId: pendingCardId,
|
||||||
|
meta: state.customMethodCardMetaById,
|
||||||
|
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||||
|
modalEditUnlocked,
|
||||||
|
draftFieldBlocks,
|
||||||
|
customFacetDetailsMatchPreset,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
updateState({
|
||||||
|
customMethodCardMetaById: nextMeta,
|
||||||
|
customMethodCardFieldBlocksById: {
|
||||||
|
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||||
|
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (pendingDraft) {
|
||||||
|
updateState({
|
||||||
|
customMethodCardMetaById: nextMeta,
|
||||||
|
communicationMethodDetailsById: {
|
||||||
|
...(state.communicationMethodDetailsById ?? {}),
|
||||||
|
[pendingCardId]: pendingDraft,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
setModalEditUnlocked(false);
|
||||||
|
setDraftFieldBlocks(null);
|
||||||
|
setCustomizeHeaderDraft(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modalEditUnlocked) {
|
||||||
|
if (!customizeHeaderDraft) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextMeta = methodCardMetaWithCustomizeHeader(
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
pendingCardId,
|
||||||
|
customizeHeaderDraft,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
pendingCardId &&
|
||||||
|
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
||||||
|
usesWizardFieldBlocksModalBody({
|
||||||
|
methodId: pendingCardId,
|
||||||
|
meta: state.customMethodCardMetaById,
|
||||||
|
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||||
|
modalEditUnlocked,
|
||||||
|
draftFieldBlocks,
|
||||||
|
customFacetDetailsMatchPreset,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
updateState({
|
||||||
|
customMethodCardMetaById: nextMeta,
|
||||||
|
customMethodCardFieldBlocksById: {
|
||||||
|
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||||
|
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (pendingDraft) {
|
||||||
|
updateState({
|
||||||
|
customMethodCardMetaById: nextMeta,
|
||||||
|
communicationMethodDetailsById: {
|
||||||
|
...(state.communicationMethodDetailsById ?? {}),
|
||||||
|
[pendingCardId]: pendingDraft,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
setModalEditUnlocked(false);
|
||||||
|
setDraftFieldBlocks(null);
|
||||||
|
setCustomizeHeaderDraft(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingDraft) {
|
||||||
|
handleCreateModalClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateState({
|
||||||
|
selectedCommunicationMethodIds: moveFacetSelectionIdToFront(
|
||||||
|
selectedIds,
|
||||||
|
pendingCardId,
|
||||||
|
),
|
||||||
|
communicationMethodDetailsById: {
|
||||||
|
...(state.communicationMethodDetailsById ?? {}),
|
||||||
|
[pendingCardId]: pendingDraft,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
pendingEphemeralDuplicateIdRef.current = null;
|
||||||
|
handleCreateModalClose();
|
||||||
|
}, [
|
||||||
|
customizeHeaderDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
handleCreateModalClose,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
modalEditUnlocked,
|
||||||
|
pendingCardId,
|
||||||
|
pendingDraft,
|
||||||
|
selectedIds,
|
||||||
|
state,
|
||||||
|
updateState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CreateFlowStepShell
|
||||||
|
variant="wideGridLoosePadding"
|
||||||
|
contentTopBelowMd="space-800"
|
||||||
|
>
|
||||||
|
<div className="flex w-full min-w-0 flex-col items-center gap-6">
|
||||||
|
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
|
||||||
|
<CreateFlowHeaderLockup
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
justification="center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
|
||||||
|
<CardStack
|
||||||
|
cards={sampleCards}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
onCardSelect={handleCardClick}
|
||||||
|
expanded={expanded}
|
||||||
|
onToggleExpand={() => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setExpanded((prev) => !prev);
|
||||||
|
}}
|
||||||
|
hasMore={true}
|
||||||
|
toggleLabel={comm.page.seeAllLink}
|
||||||
|
compactRecommendedLimit={5}
|
||||||
|
compactCardIds={compactCardIds}
|
||||||
|
compactDesktopLayout="flexWrap"
|
||||||
|
headerLockupSize={mdUp ? "L" : "M"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Create
|
||||||
|
isOpen={createModalOpen}
|
||||||
|
onClose={handleCreateModalClose}
|
||||||
|
headerContent={
|
||||||
|
modalEditUnlocked && customizeHeaderDraft ? (
|
||||||
|
<MethodCardCustomizeModalHeader
|
||||||
|
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
|
||||||
|
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
|
||||||
|
titleValue={customizeHeaderDraft.title}
|
||||||
|
descriptionValue={customizeHeaderDraft.description}
|
||||||
|
onTitleChange={(title) =>
|
||||||
|
setCustomizeHeaderDraft((prev) =>
|
||||||
|
prev ? { ...prev, title } : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDescriptionChange={(description) =>
|
||||||
|
setCustomizeHeaderDraft((prev) =>
|
||||||
|
prev ? { ...prev, description } : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
onNext={handleCreateModalPrimary}
|
||||||
|
title={modalConfig.title}
|
||||||
|
description={modalConfig.description}
|
||||||
|
nextButtonText={modalConfig.nextButtonText}
|
||||||
|
showBackButton={modalEditUnlocked}
|
||||||
|
onBack={handleCancelCustomize}
|
||||||
|
backButtonText={modalKebabMenu.cancelCustomize}
|
||||||
|
showNextButton={showMethodModalPrimary}
|
||||||
|
backdropVariant="blurredYellow"
|
||||||
|
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
||||||
|
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
|
||||||
|
kebabMenuItems={kebabMenuItems}
|
||||||
|
>
|
||||||
|
{pendingCardId && pendingDraft ? (
|
||||||
|
modalUsesWizardFieldBlocksBody ? (
|
||||||
|
<CustomMethodCardModalBody
|
||||||
|
cardId={pendingCardId}
|
||||||
|
blocksById={state.customMethodCardFieldBlocksById}
|
||||||
|
blocksOverride={
|
||||||
|
modalEditUnlocked && draftFieldBlocks !== null
|
||||||
|
? draftFieldBlocks
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
|
||||||
|
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked}
|
||||||
|
onFieldBlocksChange={
|
||||||
|
fieldsLocked
|
||||||
|
? undefined
|
||||||
|
: (next) => setDraftFieldBlocks(next)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CommunicationMethodEditFields
|
||||||
|
value={pendingDraft}
|
||||||
|
onChange={handleDraftChange}
|
||||||
|
readOnly={fieldsLocked}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</Create>
|
||||||
|
</CreateFlowStepShell>
|
||||||
|
<CustomMethodCardWizard
|
||||||
|
isOpen={addCustomWizardOpen}
|
||||||
|
onClose={handleCloseAddWizard}
|
||||||
|
onFinalize={handleFinalizeCustomCard}
|
||||||
|
onPersistCustomUploadFile={(file) =>
|
||||||
|
uploadCreateFlowFile(file, "customMethodAttachment")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{confirmDialog}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,832 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `conflict-management` step — Figma compact card stack (node `20879-15979`).
|
||||||
|
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["conflict-management"]`.
|
||||||
|
*
|
||||||
|
* Card click opens the Figma "Add Approach" create modal (node `20874-172292`)
|
||||||
|
* with four controls rendered by {@link ConflictManagementEditFields}: Core
|
||||||
|
* Principle, Applicable Scope (text area), Process Protocol, and Restoration
|
||||||
|
* & Fallbacks. The same field set is reused on `/create/final-review` — see
|
||||||
|
* `FinalReviewChipEditModal`. Confirm persists both the chip selection and
|
||||||
|
* any user edits as a `conflictManagementDetailsById[id]` override.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useMemo, useRef } from "react";
|
||||||
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
|
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
||||||
|
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||||
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
|
import CardStack from "../../../../components/cards/CardStack";
|
||||||
|
import Create from "../../../../components/modals/Create";
|
||||||
|
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
||||||
|
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||||
|
import {
|
||||||
|
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
|
||||||
|
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
|
||||||
|
} from "../../components/createFlowLayoutTokens";
|
||||||
|
import { ConflictManagementEditFields } from "../../components/methodEditFields";
|
||||||
|
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
||||||
|
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
||||||
|
import { conflictManagementPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
|
||||||
|
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||||
|
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
|
||||||
|
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
|
||||||
|
import { isCustomMethodCardId } from "../../../../../lib/create/isCustomMethodCardId";
|
||||||
|
import { conflictManagementFacetMatchesPreset } from "../../../../../lib/create/methodCardFacetMatchesPresetForId";
|
||||||
|
import { usesWizardFieldBlocksModalBody } from "../../../../../lib/create/usesWizardFieldBlocksModalBody";
|
||||||
|
import { removeMethodCardFromFacetSelection } from "../../../../../lib/create/removeMethodCardFromFacetSelection";
|
||||||
|
import {
|
||||||
|
cloneMethodCardBlocksForDuplicate,
|
||||||
|
cloneMethodCardDetailsForDuplicate,
|
||||||
|
duplicateMethodCardTitle,
|
||||||
|
forkMethodCardFacetMapsForDuplicate,
|
||||||
|
omitIdFromStringRecord,
|
||||||
|
} from "../../../../../lib/create/duplicateMethodCardModalDraft";
|
||||||
|
import type { ConflictManagementDetailEntry } from "../../types";
|
||||||
|
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
|
||||||
|
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
|
||||||
|
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
||||||
|
import {
|
||||||
|
captureMethodCardCustomizeSnapshot,
|
||||||
|
type MethodCardCustomizeSnapshot,
|
||||||
|
type MethodCardHeaderDraft,
|
||||||
|
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||||
|
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
|
||||||
|
|
||||||
|
export function ConflictManagementScreen() {
|
||||||
|
const m = useMessages();
|
||||||
|
const cm = m.create.customRule.conflictManagement;
|
||||||
|
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
||||||
|
const mdUp = useCreateFlowMdUp();
|
||||||
|
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
|
||||||
|
useDiscardCustomizeConfirm();
|
||||||
|
const { state, updateState, replaceState, markCreateFlowInteraction } =
|
||||||
|
useCreateFlow();
|
||||||
|
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
|
||||||
|
const customizeSnapshotRef = useRef<
|
||||||
|
MethodCardCustomizeSnapshot<ConflictManagementDetailEntry> | null
|
||||||
|
>(null);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
|
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
|
||||||
|
const [pendingDraft, setPendingDraft] =
|
||||||
|
useState<ConflictManagementDetailEntry | null>(null);
|
||||||
|
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
|
||||||
|
const [modalEditUnlocked, setModalEditUnlocked] = useState(false);
|
||||||
|
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
|
||||||
|
CustomMethodCardFieldBlock[] | null
|
||||||
|
>(null);
|
||||||
|
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
|
||||||
|
useState<MethodCardHeaderDraft | null>(null);
|
||||||
|
|
||||||
|
const selectedIds = state.selectedConflictManagementIds ?? [];
|
||||||
|
|
||||||
|
const mergedMethods = useMemo(
|
||||||
|
() =>
|
||||||
|
mergePresetMethodsWithCustom(
|
||||||
|
cm.methods,
|
||||||
|
selectedIds,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
),
|
||||||
|
[cm.methods, selectedIds, state.customMethodCardMetaById],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
|
||||||
|
"conflictManagement",
|
||||||
|
mergedMethods,
|
||||||
|
selectedIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenAddWizard = useCallback(() => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setAddCustomWizardOpen(true);
|
||||||
|
}, [markCreateFlowInteraction]);
|
||||||
|
|
||||||
|
const title = expanded ? cm.page.expandedTitle : cm.page.compactTitle;
|
||||||
|
|
||||||
|
const description = expanded ? (
|
||||||
|
<>
|
||||||
|
{cm.page.expandedDescriptionBefore}
|
||||||
|
<InlineTextButton onClick={handleOpenAddWizard}>
|
||||||
|
{cm.page.compactDescriptionLinkLabel}
|
||||||
|
</InlineTextButton>
|
||||||
|
{cm.page.expandedDescriptionAfter}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{cm.page.compactDescriptionBefore}
|
||||||
|
<InlineTextButton onClick={handleOpenAddWizard}>
|
||||||
|
{cm.page.compactDescriptionLinkLabel}
|
||||||
|
</InlineTextButton>
|
||||||
|
{cm.page.compactDescriptionAfter}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const seedDraft = useCallback(
|
||||||
|
(id: string): ConflictManagementDetailEntry => {
|
||||||
|
const saved = state.conflictManagementDetailsById?.[id];
|
||||||
|
if (saved) {
|
||||||
|
return {
|
||||||
|
...saved,
|
||||||
|
applicableScope: [...saved.applicableScope],
|
||||||
|
selectedApplicableScope: [...saved.selectedApplicableScope],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return conflictManagementPresetFor(id);
|
||||||
|
},
|
||||||
|
[state.conflictManagementDetailsById],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCardClick = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
setModalEditUnlocked(false);
|
||||||
|
setDraftFieldBlocks(null);
|
||||||
|
setCustomizeHeaderDraft(null);
|
||||||
|
setPendingCardId(id);
|
||||||
|
setPendingDraft(seedDraft(id));
|
||||||
|
setCreateModalOpen(true);
|
||||||
|
},
|
||||||
|
[markCreateFlowInteraction, seedDraft],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDraftChange = useCallback(
|
||||||
|
(next: ConflictManagementDetailEntry) => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setPendingDraft(next);
|
||||||
|
},
|
||||||
|
[markCreateFlowInteraction],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSelectedCardModal =
|
||||||
|
pendingCardId !== null && selectedIds.includes(pendingCardId);
|
||||||
|
const fieldsLocked = !modalEditUnlocked;
|
||||||
|
|
||||||
|
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
|
||||||
|
|
||||||
|
const customFacetDetailsMatchPreset = useMemo(() => {
|
||||||
|
if (!pendingCardId || !pendingDraft) return false;
|
||||||
|
if (!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return conflictManagementFacetMatchesPreset(pendingDraft, pendingCardId);
|
||||||
|
}, [
|
||||||
|
pendingCardId,
|
||||||
|
pendingDraft,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const modalUsesWizardFieldBlocksBody = useMemo(
|
||||||
|
() =>
|
||||||
|
Boolean(
|
||||||
|
pendingCardId &&
|
||||||
|
usesWizardFieldBlocksModalBody({
|
||||||
|
methodId: pendingCardId,
|
||||||
|
meta: state.customMethodCardMetaById,
|
||||||
|
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||||
|
modalEditUnlocked,
|
||||||
|
draftFieldBlocks,
|
||||||
|
customFacetDetailsMatchPreset,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
customFacetDetailsMatchPreset,
|
||||||
|
draftFieldBlocks,
|
||||||
|
modalEditUnlocked,
|
||||||
|
pendingCardId,
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateModalClose = useCallback(async () => {
|
||||||
|
if (
|
||||||
|
!(await confirmDiscard(
|
||||||
|
modalEditUnlocked,
|
||||||
|
customizeSnapshotRef.current,
|
||||||
|
pendingDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
customizeHeaderDraft,
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
const ephemeralId = pendingEphemeralDuplicateIdRef.current;
|
||||||
|
if (ephemeralId) {
|
||||||
|
pendingEphemeralDuplicateIdRef.current = null;
|
||||||
|
replaceState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
customMethodCardMetaById: omitIdFromStringRecord(
|
||||||
|
prev.customMethodCardMetaById,
|
||||||
|
ephemeralId,
|
||||||
|
),
|
||||||
|
conflictManagementDetailsById: omitIdFromStringRecord(
|
||||||
|
prev.conflictManagementDetailsById,
|
||||||
|
ephemeralId,
|
||||||
|
),
|
||||||
|
customMethodCardFieldBlocksById: omitIdFromStringRecord(
|
||||||
|
prev.customMethodCardFieldBlocksById,
|
||||||
|
ephemeralId,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
setCreateModalOpen(false);
|
||||||
|
setPendingCardId(null);
|
||||||
|
setPendingDraft(null);
|
||||||
|
setModalEditUnlocked(false);
|
||||||
|
setDraftFieldBlocks(null);
|
||||||
|
setCustomizeHeaderDraft(null);
|
||||||
|
}, [
|
||||||
|
confirmDiscard,
|
||||||
|
customizeHeaderDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
modalEditUnlocked,
|
||||||
|
pendingDraft,
|
||||||
|
replaceState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleCancelCustomize = useCallback(async () => {
|
||||||
|
if (!modalEditUnlocked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const snap = customizeSnapshotRef.current;
|
||||||
|
if (!snap) {
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
setModalEditUnlocked(false);
|
||||||
|
setDraftFieldBlocks(null);
|
||||||
|
setCustomizeHeaderDraft(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!(await confirmDirtyCustomizeCancel(
|
||||||
|
snap,
|
||||||
|
pendingDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
customizeHeaderDraft,
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingDraft(structuredClone(snap.pendingDraft));
|
||||||
|
setDraftFieldBlocks(null);
|
||||||
|
setModalEditUnlocked(false);
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
setCustomizeHeaderDraft(null);
|
||||||
|
}, [
|
||||||
|
confirmDirtyCustomizeCancel,
|
||||||
|
customizeHeaderDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
modalEditUnlocked,
|
||||||
|
pendingDraft,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleRemoveSelectedFromModal = useCallback(async () => {
|
||||||
|
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
if (
|
||||||
|
!(await confirmDiscard(
|
||||||
|
modalEditUnlocked,
|
||||||
|
customizeSnapshotRef.current,
|
||||||
|
pendingDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
customizeHeaderDraft,
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
updateState(
|
||||||
|
removeMethodCardFromFacetSelection(
|
||||||
|
state,
|
||||||
|
"conflictManagement",
|
||||||
|
pendingCardId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await handleCreateModalClose();
|
||||||
|
}, [
|
||||||
|
confirmDiscard,
|
||||||
|
customizeHeaderDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
handleCreateModalClose,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
modalEditUnlocked,
|
||||||
|
pendingDraft,
|
||||||
|
pendingCardId,
|
||||||
|
selectedIds,
|
||||||
|
state,
|
||||||
|
updateState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleCustomize = useCallback(() => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
if (!pendingDraft || !pendingCardId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const initialFieldBlocks =
|
||||||
|
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||||
|
? structuredClone(
|
||||||
|
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [],
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const method = methodById.get(pendingCardId);
|
||||||
|
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
||||||
|
const headerDraft: MethodCardHeaderDraft = {
|
||||||
|
title: meta?.label ?? method?.label ?? cm.confirmModal.title,
|
||||||
|
description:
|
||||||
|
meta?.supportText ??
|
||||||
|
method?.supportText ??
|
||||||
|
cm.confirmModal.description,
|
||||||
|
};
|
||||||
|
setCustomizeHeaderDraft(headerDraft);
|
||||||
|
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
||||||
|
pendingDraft,
|
||||||
|
initialFieldBlocks,
|
||||||
|
headerDraft,
|
||||||
|
);
|
||||||
|
setDraftFieldBlocks(initialFieldBlocks);
|
||||||
|
setModalEditUnlocked(true);
|
||||||
|
}, [
|
||||||
|
cm.confirmModal.description,
|
||||||
|
cm.confirmModal.title,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
methodById,
|
||||||
|
pendingCardId,
|
||||||
|
pendingDraft,
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDuplicateCustomCard = useCallback(() => {
|
||||||
|
if (
|
||||||
|
!pendingCardId ||
|
||||||
|
!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
const newId = crypto.randomUUID();
|
||||||
|
const meta = state.customMethodCardMetaById![pendingCardId]!;
|
||||||
|
const detailsClone = cloneMethodCardDetailsForDuplicate(
|
||||||
|
pendingDraft,
|
||||||
|
state.conflictManagementDetailsById?.[pendingCardId],
|
||||||
|
() => conflictManagementPresetFor(newId),
|
||||||
|
);
|
||||||
|
const blocksClone = structuredClone(
|
||||||
|
modalEditUnlocked &&
|
||||||
|
draftFieldBlocks !== null &&
|
||||||
|
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||||
|
? draftFieldBlocks
|
||||||
|
: cloneMethodCardBlocksForDuplicate(
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
pendingCardId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const suffix = modalKebabMenu.duplicateTitleSuffix;
|
||||||
|
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
|
||||||
|
const maps = forkMethodCardFacetMapsForDuplicate({
|
||||||
|
customMethodCardMetaById: state.customMethodCardMetaById,
|
||||||
|
facetDetailsById: state.conflictManagementDetailsById,
|
||||||
|
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||||
|
omitId: priorEphemeral,
|
||||||
|
});
|
||||||
|
maps.customMethodCardMetaById[newId] = {
|
||||||
|
label: duplicateMethodCardTitle(meta.label, suffix),
|
||||||
|
supportText: meta.supportText,
|
||||||
|
};
|
||||||
|
maps.facetDetailsById[newId] = detailsClone;
|
||||||
|
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
|
||||||
|
updateState({
|
||||||
|
customMethodCardMetaById: maps.customMethodCardMetaById,
|
||||||
|
conflictManagementDetailsById: maps.facetDetailsById,
|
||||||
|
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
|
||||||
|
});
|
||||||
|
pendingEphemeralDuplicateIdRef.current = newId;
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
setPendingCardId(newId);
|
||||||
|
setPendingDraft(structuredClone(detailsClone));
|
||||||
|
setModalEditUnlocked(false);
|
||||||
|
setDraftFieldBlocks(null);
|
||||||
|
setCustomizeHeaderDraft(null);
|
||||||
|
}, [
|
||||||
|
draftFieldBlocks,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
modalEditUnlocked,
|
||||||
|
modalKebabMenu.duplicateTitleSuffix,
|
||||||
|
pendingCardId,
|
||||||
|
pendingDraft,
|
||||||
|
state.conflictManagementDetailsById,
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
updateState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDuplicatePrefabCard = useCallback(() => {
|
||||||
|
if (
|
||||||
|
!pendingCardId ||
|
||||||
|
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const method = methodById.get(pendingCardId);
|
||||||
|
if (!method || !pendingDraft) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
const newId = crypto.randomUUID();
|
||||||
|
const detailsClone = cloneMethodCardDetailsForDuplicate(
|
||||||
|
pendingDraft,
|
||||||
|
state.conflictManagementDetailsById?.[pendingCardId],
|
||||||
|
() => conflictManagementPresetFor(newId),
|
||||||
|
);
|
||||||
|
const blocksClone = structuredClone(
|
||||||
|
modalEditUnlocked &&
|
||||||
|
draftFieldBlocks !== null &&
|
||||||
|
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||||
|
? draftFieldBlocks
|
||||||
|
: cloneMethodCardBlocksForDuplicate(
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
pendingCardId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const suffix = modalKebabMenu.duplicateTitleSuffix;
|
||||||
|
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
|
||||||
|
const maps = forkMethodCardFacetMapsForDuplicate({
|
||||||
|
customMethodCardMetaById: state.customMethodCardMetaById,
|
||||||
|
facetDetailsById: state.conflictManagementDetailsById,
|
||||||
|
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||||
|
omitId: priorEphemeral,
|
||||||
|
});
|
||||||
|
maps.customMethodCardMetaById[newId] = {
|
||||||
|
label: duplicateMethodCardTitle(method.label, suffix),
|
||||||
|
supportText: method.supportText,
|
||||||
|
};
|
||||||
|
maps.facetDetailsById[newId] = detailsClone;
|
||||||
|
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
|
||||||
|
updateState({
|
||||||
|
customMethodCardMetaById: maps.customMethodCardMetaById,
|
||||||
|
conflictManagementDetailsById: maps.facetDetailsById,
|
||||||
|
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
|
||||||
|
});
|
||||||
|
pendingEphemeralDuplicateIdRef.current = newId;
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
setPendingCardId(newId);
|
||||||
|
setPendingDraft(structuredClone(detailsClone));
|
||||||
|
setModalEditUnlocked(false);
|
||||||
|
setDraftFieldBlocks(null);
|
||||||
|
setCustomizeHeaderDraft(null);
|
||||||
|
}, [
|
||||||
|
draftFieldBlocks,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
methodById,
|
||||||
|
modalEditUnlocked,
|
||||||
|
modalKebabMenu.duplicateTitleSuffix,
|
||||||
|
pendingCardId,
|
||||||
|
pendingDraft,
|
||||||
|
state.conflictManagementDetailsById,
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
updateState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const kebabMenuItems = useMemo(
|
||||||
|
() =>
|
||||||
|
buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
||||||
|
showCustomize: !modalEditUnlocked,
|
||||||
|
onCustomize: handleCustomize,
|
||||||
|
onDuplicate:
|
||||||
|
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
|
||||||
|
? undefined
|
||||||
|
: isCustomMethodCardId(
|
||||||
|
pendingCardId,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
)
|
||||||
|
? handleDuplicateCustomCard
|
||||||
|
: handleDuplicatePrefabCard,
|
||||||
|
showRemove: isSelectedCardModal,
|
||||||
|
onRemove: handleRemoveSelectedFromModal,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
handleCustomize,
|
||||||
|
handleDuplicateCustomCard,
|
||||||
|
handleDuplicatePrefabCard,
|
||||||
|
handleRemoveSelectedFromModal,
|
||||||
|
isSelectedCardModal,
|
||||||
|
modalEditUnlocked,
|
||||||
|
modalKebabMenu,
|
||||||
|
pendingCardId,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
state.editingPublishedRuleId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const modalConfig = pendingCardId
|
||||||
|
? (() => {
|
||||||
|
const method = methodById.get(pendingCardId);
|
||||||
|
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
||||||
|
const saveLabel = modalKebabMenu.saveEdits;
|
||||||
|
return {
|
||||||
|
title: meta?.label ?? method?.label ?? cm.confirmModal.title,
|
||||||
|
description:
|
||||||
|
meta?.supportText ??
|
||||||
|
method?.supportText ??
|
||||||
|
cm.confirmModal.description,
|
||||||
|
nextButtonText: modalEditUnlocked
|
||||||
|
? saveLabel
|
||||||
|
: cm.addApproach.nextButtonText,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
: {
|
||||||
|
title: cm.confirmModal.title,
|
||||||
|
description: cm.confirmModal.description,
|
||||||
|
nextButtonText: cm.confirmModal.nextButtonText,
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseAddWizard = useCallback(() => {
|
||||||
|
setAddCustomWizardOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFinalizeCustomCard = useCallback(
|
||||||
|
({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
fieldBlocks,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
fieldBlocks: CustomMethodCardFieldBlock[];
|
||||||
|
}) => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
updateState({
|
||||||
|
selectedConflictManagementIds: moveFacetSelectionIdToFront(
|
||||||
|
selectedIds,
|
||||||
|
id,
|
||||||
|
),
|
||||||
|
customMethodCardMetaById: {
|
||||||
|
...(state.customMethodCardMetaById ?? {}),
|
||||||
|
[id]: { label: title, supportText: description },
|
||||||
|
},
|
||||||
|
conflictManagementDetailsById: {
|
||||||
|
...(state.conflictManagementDetailsById ?? {}),
|
||||||
|
[id]: conflictManagementPresetFor(id),
|
||||||
|
},
|
||||||
|
customMethodCardFieldBlocksById: {
|
||||||
|
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||||
|
[id]: fieldBlocks,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
selectedIds,
|
||||||
|
state.conflictManagementDetailsById,
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
updateState,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateModalPrimary = useCallback(() => {
|
||||||
|
if (!pendingCardId) {
|
||||||
|
handleCreateModalClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
|
||||||
|
if (selectedIds.includes(pendingCardId)) {
|
||||||
|
if (modalEditUnlocked) {
|
||||||
|
if (!customizeHeaderDraft) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextMeta = methodCardMetaWithCustomizeHeader(
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
pendingCardId,
|
||||||
|
customizeHeaderDraft,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
pendingCardId &&
|
||||||
|
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
||||||
|
usesWizardFieldBlocksModalBody({
|
||||||
|
methodId: pendingCardId,
|
||||||
|
meta: state.customMethodCardMetaById,
|
||||||
|
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||||
|
modalEditUnlocked,
|
||||||
|
draftFieldBlocks,
|
||||||
|
customFacetDetailsMatchPreset,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
updateState({
|
||||||
|
customMethodCardMetaById: nextMeta,
|
||||||
|
customMethodCardFieldBlocksById: {
|
||||||
|
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||||
|
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (pendingDraft) {
|
||||||
|
updateState({
|
||||||
|
customMethodCardMetaById: nextMeta,
|
||||||
|
conflictManagementDetailsById: {
|
||||||
|
...(state.conflictManagementDetailsById ?? {}),
|
||||||
|
[pendingCardId]: pendingDraft,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
setModalEditUnlocked(false);
|
||||||
|
setDraftFieldBlocks(null);
|
||||||
|
setCustomizeHeaderDraft(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modalEditUnlocked) {
|
||||||
|
if (!customizeHeaderDraft) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextMeta = methodCardMetaWithCustomizeHeader(
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
pendingCardId,
|
||||||
|
customizeHeaderDraft,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
pendingCardId &&
|
||||||
|
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
||||||
|
usesWizardFieldBlocksModalBody({
|
||||||
|
methodId: pendingCardId,
|
||||||
|
meta: state.customMethodCardMetaById,
|
||||||
|
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||||
|
modalEditUnlocked,
|
||||||
|
draftFieldBlocks,
|
||||||
|
customFacetDetailsMatchPreset,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
updateState({
|
||||||
|
customMethodCardMetaById: nextMeta,
|
||||||
|
customMethodCardFieldBlocksById: {
|
||||||
|
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||||
|
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (pendingDraft) {
|
||||||
|
updateState({
|
||||||
|
customMethodCardMetaById: nextMeta,
|
||||||
|
conflictManagementDetailsById: {
|
||||||
|
...(state.conflictManagementDetailsById ?? {}),
|
||||||
|
[pendingCardId]: pendingDraft,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
setModalEditUnlocked(false);
|
||||||
|
setDraftFieldBlocks(null);
|
||||||
|
setCustomizeHeaderDraft(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingDraft) {
|
||||||
|
handleCreateModalClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateState({
|
||||||
|
selectedConflictManagementIds: moveFacetSelectionIdToFront(
|
||||||
|
selectedIds,
|
||||||
|
pendingCardId,
|
||||||
|
),
|
||||||
|
conflictManagementDetailsById: {
|
||||||
|
...(state.conflictManagementDetailsById ?? {}),
|
||||||
|
[pendingCardId]: pendingDraft,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
pendingEphemeralDuplicateIdRef.current = null;
|
||||||
|
handleCreateModalClose();
|
||||||
|
}, [
|
||||||
|
customizeHeaderDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
handleCreateModalClose,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
modalEditUnlocked,
|
||||||
|
pendingCardId,
|
||||||
|
pendingDraft,
|
||||||
|
selectedIds,
|
||||||
|
state,
|
||||||
|
updateState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CreateFlowStepShell
|
||||||
|
variant="wideGridLoosePadding"
|
||||||
|
contentTopBelowMd="space-800"
|
||||||
|
>
|
||||||
|
<div className="flex w-full min-w-0 flex-col items-center gap-6">
|
||||||
|
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
|
||||||
|
<CreateFlowHeaderLockup
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
justification="center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
|
||||||
|
<CardStack
|
||||||
|
cards={sampleCards}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
onCardSelect={handleCardClick}
|
||||||
|
expanded={expanded}
|
||||||
|
onToggleExpand={() => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setExpanded((prev) => !prev);
|
||||||
|
}}
|
||||||
|
hasMore={true}
|
||||||
|
toggleLabel={cm.page.seeAllLink}
|
||||||
|
compactRecommendedLimit={5}
|
||||||
|
compactCardIds={compactCardIds}
|
||||||
|
compactDesktopLayout="pyramidFive"
|
||||||
|
headerLockupSize={mdUp ? "L" : "M"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Create
|
||||||
|
isOpen={createModalOpen}
|
||||||
|
onClose={handleCreateModalClose}
|
||||||
|
headerContent={
|
||||||
|
modalEditUnlocked && customizeHeaderDraft ? (
|
||||||
|
<MethodCardCustomizeModalHeader
|
||||||
|
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
|
||||||
|
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
|
||||||
|
titleValue={customizeHeaderDraft.title}
|
||||||
|
descriptionValue={customizeHeaderDraft.description}
|
||||||
|
onTitleChange={(title) =>
|
||||||
|
setCustomizeHeaderDraft((prev) =>
|
||||||
|
prev ? { ...prev, title } : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDescriptionChange={(description) =>
|
||||||
|
setCustomizeHeaderDraft((prev) =>
|
||||||
|
prev ? { ...prev, description } : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
onNext={handleCreateModalPrimary}
|
||||||
|
title={modalConfig.title}
|
||||||
|
description={modalConfig.description}
|
||||||
|
nextButtonText={modalConfig.nextButtonText}
|
||||||
|
showBackButton={modalEditUnlocked}
|
||||||
|
onBack={handleCancelCustomize}
|
||||||
|
backButtonText={modalKebabMenu.cancelCustomize}
|
||||||
|
showNextButton={showMethodModalPrimary}
|
||||||
|
backdropVariant="blurredYellow"
|
||||||
|
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
||||||
|
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
|
||||||
|
kebabMenuItems={kebabMenuItems}
|
||||||
|
>
|
||||||
|
{pendingCardId && pendingDraft ? (
|
||||||
|
modalUsesWizardFieldBlocksBody ? (
|
||||||
|
<CustomMethodCardModalBody
|
||||||
|
cardId={pendingCardId}
|
||||||
|
blocksById={state.customMethodCardFieldBlocksById}
|
||||||
|
blocksOverride={
|
||||||
|
modalEditUnlocked && draftFieldBlocks !== null
|
||||||
|
? draftFieldBlocks
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
|
||||||
|
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked}
|
||||||
|
onFieldBlocksChange={
|
||||||
|
fieldsLocked
|
||||||
|
? undefined
|
||||||
|
: (next) => setDraftFieldBlocks(next)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ConflictManagementEditFields
|
||||||
|
value={pendingDraft}
|
||||||
|
onChange={handleDraftChange}
|
||||||
|
readOnly={fieldsLocked}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</Create>
|
||||||
|
</CreateFlowStepShell>
|
||||||
|
<CustomMethodCardWizard
|
||||||
|
isOpen={addCustomWizardOpen}
|
||||||
|
onClose={handleCloseAddWizard}
|
||||||
|
onFinalize={handleFinalizeCustomCard}
|
||||||
|
onPersistCustomUploadFile={(file) =>
|
||||||
|
uploadCreateFlowFile(file, "customMethodAttachment")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{confirmDialog}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,825 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `membership-methods` step — Figma compact card stack (node `20858-13947`).
|
||||||
|
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["membership-methods"]`.
|
||||||
|
*
|
||||||
|
* Card click opens the Figma create modal (node `20858-13948`) with three
|
||||||
|
* editable sections rendered by {@link MembershipMethodEditFields}. The same
|
||||||
|
* field set is reused on `/create/final-review` — see `FinalReviewChipEditModal`.
|
||||||
|
* Confirm persists both the chip selection and any user edits as a
|
||||||
|
* `membershipMethodDetailsById[id]` override; section defaults come from
|
||||||
|
* `messages/en/create/customRule/membership.json` and will be replaced with
|
||||||
|
* DB-driven content.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useMemo, useRef } from "react";
|
||||||
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
|
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
||||||
|
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||||
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
|
import CardStack from "../../../../components/cards/CardStack";
|
||||||
|
import Create from "../../../../components/modals/Create";
|
||||||
|
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
||||||
|
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||||
|
import {
|
||||||
|
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
|
||||||
|
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
|
||||||
|
} from "../../components/createFlowLayoutTokens";
|
||||||
|
import { MembershipMethodEditFields } from "../../components/methodEditFields";
|
||||||
|
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
||||||
|
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
||||||
|
import { membershipPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
|
||||||
|
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||||
|
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
|
||||||
|
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
|
||||||
|
import { isCustomMethodCardId } from "../../../../../lib/create/isCustomMethodCardId";
|
||||||
|
import { membershipMethodFacetMatchesPreset } from "../../../../../lib/create/methodCardFacetMatchesPresetForId";
|
||||||
|
import { usesWizardFieldBlocksModalBody } from "../../../../../lib/create/usesWizardFieldBlocksModalBody";
|
||||||
|
import { removeMethodCardFromFacetSelection } from "../../../../../lib/create/removeMethodCardFromFacetSelection";
|
||||||
|
import {
|
||||||
|
cloneMethodCardBlocksForDuplicate,
|
||||||
|
cloneMethodCardDetailsForDuplicate,
|
||||||
|
duplicateMethodCardTitle,
|
||||||
|
forkMethodCardFacetMapsForDuplicate,
|
||||||
|
omitIdFromStringRecord,
|
||||||
|
} from "../../../../../lib/create/duplicateMethodCardModalDraft";
|
||||||
|
import type { MembershipMethodDetailEntry } from "../../types";
|
||||||
|
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
|
||||||
|
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
|
||||||
|
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
||||||
|
import {
|
||||||
|
captureMethodCardCustomizeSnapshot,
|
||||||
|
type MethodCardCustomizeSnapshot,
|
||||||
|
type MethodCardHeaderDraft,
|
||||||
|
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||||
|
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
|
||||||
|
|
||||||
|
export function MembershipMethodsScreen() {
|
||||||
|
const m = useMessages();
|
||||||
|
const mem = m.create.customRule.membership;
|
||||||
|
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
||||||
|
const mdUp = useCreateFlowMdUp();
|
||||||
|
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
|
||||||
|
useDiscardCustomizeConfirm();
|
||||||
|
const { state, updateState, replaceState, markCreateFlowInteraction } =
|
||||||
|
useCreateFlow();
|
||||||
|
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
|
||||||
|
const customizeSnapshotRef = useRef<
|
||||||
|
MethodCardCustomizeSnapshot<MembershipMethodDetailEntry> | null
|
||||||
|
>(null);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
|
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
|
||||||
|
const [pendingDraft, setPendingDraft] =
|
||||||
|
useState<MembershipMethodDetailEntry | null>(null);
|
||||||
|
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
|
||||||
|
const [modalEditUnlocked, setModalEditUnlocked] = useState(false);
|
||||||
|
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
|
||||||
|
CustomMethodCardFieldBlock[] | null
|
||||||
|
>(null);
|
||||||
|
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
|
||||||
|
useState<MethodCardHeaderDraft | null>(null);
|
||||||
|
|
||||||
|
const selectedIds = state.selectedMembershipMethodIds ?? [];
|
||||||
|
|
||||||
|
const mergedMethods = useMemo(
|
||||||
|
() =>
|
||||||
|
mergePresetMethodsWithCustom(
|
||||||
|
mem.methods,
|
||||||
|
selectedIds,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
),
|
||||||
|
[mem.methods, selectedIds, state.customMethodCardMetaById],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
|
||||||
|
"membership",
|
||||||
|
mergedMethods,
|
||||||
|
selectedIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenAddWizard = useCallback(() => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setAddCustomWizardOpen(true);
|
||||||
|
}, [markCreateFlowInteraction]);
|
||||||
|
|
||||||
|
const title = expanded ? mem.page.expandedTitle : mem.page.compactTitle;
|
||||||
|
|
||||||
|
const description = expanded ? (
|
||||||
|
<>
|
||||||
|
{mem.page.expandedDescriptionBefore}
|
||||||
|
<InlineTextButton onClick={handleOpenAddWizard}>
|
||||||
|
{mem.page.compactDescriptionLinkLabel}
|
||||||
|
</InlineTextButton>
|
||||||
|
{mem.page.expandedDescriptionAfter}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{mem.page.compactDescriptionBefore}
|
||||||
|
<InlineTextButton onClick={handleOpenAddWizard}>
|
||||||
|
{mem.page.compactDescriptionLinkLabel}
|
||||||
|
</InlineTextButton>
|
||||||
|
{mem.page.compactDescriptionAfter}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const seedDraft = useCallback(
|
||||||
|
(id: string): MembershipMethodDetailEntry => {
|
||||||
|
const saved = state.membershipMethodDetailsById?.[id];
|
||||||
|
if (saved) {
|
||||||
|
return { ...saved };
|
||||||
|
}
|
||||||
|
return membershipPresetFor(id);
|
||||||
|
},
|
||||||
|
[state.membershipMethodDetailsById],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCardClick = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
setModalEditUnlocked(false);
|
||||||
|
setDraftFieldBlocks(null);
|
||||||
|
setCustomizeHeaderDraft(null);
|
||||||
|
setPendingCardId(id);
|
||||||
|
setPendingDraft(seedDraft(id));
|
||||||
|
setCreateModalOpen(true);
|
||||||
|
},
|
||||||
|
[markCreateFlowInteraction, seedDraft],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDraftChange = useCallback(
|
||||||
|
(next: MembershipMethodDetailEntry) => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setPendingDraft(next);
|
||||||
|
},
|
||||||
|
[markCreateFlowInteraction],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSelectedCardModal =
|
||||||
|
pendingCardId !== null && selectedIds.includes(pendingCardId);
|
||||||
|
const fieldsLocked = !modalEditUnlocked;
|
||||||
|
|
||||||
|
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
|
||||||
|
|
||||||
|
const customFacetDetailsMatchPreset = useMemo(() => {
|
||||||
|
if (!pendingCardId || !pendingDraft) return false;
|
||||||
|
if (!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return membershipMethodFacetMatchesPreset(pendingDraft, pendingCardId);
|
||||||
|
}, [
|
||||||
|
pendingCardId,
|
||||||
|
pendingDraft,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const modalUsesWizardFieldBlocksBody = useMemo(
|
||||||
|
() =>
|
||||||
|
Boolean(
|
||||||
|
pendingCardId &&
|
||||||
|
usesWizardFieldBlocksModalBody({
|
||||||
|
methodId: pendingCardId,
|
||||||
|
meta: state.customMethodCardMetaById,
|
||||||
|
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||||
|
modalEditUnlocked,
|
||||||
|
draftFieldBlocks,
|
||||||
|
customFacetDetailsMatchPreset,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
customFacetDetailsMatchPreset,
|
||||||
|
draftFieldBlocks,
|
||||||
|
modalEditUnlocked,
|
||||||
|
pendingCardId,
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateModalClose = useCallback(async () => {
|
||||||
|
if (
|
||||||
|
!(await confirmDiscard(
|
||||||
|
modalEditUnlocked,
|
||||||
|
customizeSnapshotRef.current,
|
||||||
|
pendingDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
customizeHeaderDraft,
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
const ephemeralId = pendingEphemeralDuplicateIdRef.current;
|
||||||
|
if (ephemeralId) {
|
||||||
|
pendingEphemeralDuplicateIdRef.current = null;
|
||||||
|
replaceState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
customMethodCardMetaById: omitIdFromStringRecord(
|
||||||
|
prev.customMethodCardMetaById,
|
||||||
|
ephemeralId,
|
||||||
|
),
|
||||||
|
membershipMethodDetailsById: omitIdFromStringRecord(
|
||||||
|
prev.membershipMethodDetailsById,
|
||||||
|
ephemeralId,
|
||||||
|
),
|
||||||
|
customMethodCardFieldBlocksById: omitIdFromStringRecord(
|
||||||
|
prev.customMethodCardFieldBlocksById,
|
||||||
|
ephemeralId,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
setCreateModalOpen(false);
|
||||||
|
setPendingCardId(null);
|
||||||
|
setPendingDraft(null);
|
||||||
|
setModalEditUnlocked(false);
|
||||||
|
setDraftFieldBlocks(null);
|
||||||
|
setCustomizeHeaderDraft(null);
|
||||||
|
}, [
|
||||||
|
confirmDiscard,
|
||||||
|
customizeHeaderDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
modalEditUnlocked,
|
||||||
|
pendingDraft,
|
||||||
|
replaceState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleCancelCustomize = useCallback(async () => {
|
||||||
|
if (!modalEditUnlocked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const snap = customizeSnapshotRef.current;
|
||||||
|
if (!snap) {
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
setModalEditUnlocked(false);
|
||||||
|
setDraftFieldBlocks(null);
|
||||||
|
setCustomizeHeaderDraft(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!(await confirmDirtyCustomizeCancel(
|
||||||
|
snap,
|
||||||
|
pendingDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
customizeHeaderDraft,
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingDraft(structuredClone(snap.pendingDraft));
|
||||||
|
setDraftFieldBlocks(null);
|
||||||
|
setModalEditUnlocked(false);
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
setCustomizeHeaderDraft(null);
|
||||||
|
}, [
|
||||||
|
confirmDirtyCustomizeCancel,
|
||||||
|
customizeHeaderDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
modalEditUnlocked,
|
||||||
|
pendingDraft,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleRemoveSelectedFromModal = useCallback(async () => {
|
||||||
|
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
if (
|
||||||
|
!(await confirmDiscard(
|
||||||
|
modalEditUnlocked,
|
||||||
|
customizeSnapshotRef.current,
|
||||||
|
pendingDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
customizeHeaderDraft,
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
updateState(
|
||||||
|
removeMethodCardFromFacetSelection(state, "membership", pendingCardId),
|
||||||
|
);
|
||||||
|
await handleCreateModalClose();
|
||||||
|
}, [
|
||||||
|
confirmDiscard,
|
||||||
|
customizeHeaderDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
handleCreateModalClose,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
modalEditUnlocked,
|
||||||
|
pendingDraft,
|
||||||
|
pendingCardId,
|
||||||
|
selectedIds,
|
||||||
|
state,
|
||||||
|
updateState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleCustomize = useCallback(() => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
if (!pendingDraft || !pendingCardId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const initialFieldBlocks =
|
||||||
|
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||||
|
? structuredClone(
|
||||||
|
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [],
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const method = methodById.get(pendingCardId);
|
||||||
|
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
||||||
|
const headerDraft: MethodCardHeaderDraft = {
|
||||||
|
title: meta?.label ?? method?.label ?? mem.confirmModal.title,
|
||||||
|
description:
|
||||||
|
meta?.supportText ??
|
||||||
|
method?.supportText ??
|
||||||
|
mem.confirmModal.description,
|
||||||
|
};
|
||||||
|
setCustomizeHeaderDraft(headerDraft);
|
||||||
|
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
||||||
|
pendingDraft,
|
||||||
|
initialFieldBlocks,
|
||||||
|
headerDraft,
|
||||||
|
);
|
||||||
|
setDraftFieldBlocks(initialFieldBlocks);
|
||||||
|
setModalEditUnlocked(true);
|
||||||
|
}, [
|
||||||
|
mem.confirmModal.description,
|
||||||
|
mem.confirmModal.title,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
methodById,
|
||||||
|
pendingCardId,
|
||||||
|
pendingDraft,
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDuplicateCustomCard = useCallback(() => {
|
||||||
|
if (
|
||||||
|
!pendingCardId ||
|
||||||
|
!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
const newId = crypto.randomUUID();
|
||||||
|
const meta = state.customMethodCardMetaById![pendingCardId]!;
|
||||||
|
const detailsClone = cloneMethodCardDetailsForDuplicate(
|
||||||
|
pendingDraft,
|
||||||
|
state.membershipMethodDetailsById?.[pendingCardId],
|
||||||
|
() => membershipPresetFor(newId),
|
||||||
|
);
|
||||||
|
const blocksClone = structuredClone(
|
||||||
|
modalEditUnlocked &&
|
||||||
|
draftFieldBlocks !== null &&
|
||||||
|
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||||
|
? draftFieldBlocks
|
||||||
|
: cloneMethodCardBlocksForDuplicate(
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
pendingCardId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const suffix = modalKebabMenu.duplicateTitleSuffix;
|
||||||
|
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
|
||||||
|
const maps = forkMethodCardFacetMapsForDuplicate({
|
||||||
|
customMethodCardMetaById: state.customMethodCardMetaById,
|
||||||
|
facetDetailsById: state.membershipMethodDetailsById,
|
||||||
|
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||||
|
omitId: priorEphemeral,
|
||||||
|
});
|
||||||
|
maps.customMethodCardMetaById[newId] = {
|
||||||
|
label: duplicateMethodCardTitle(meta.label, suffix),
|
||||||
|
supportText: meta.supportText,
|
||||||
|
};
|
||||||
|
maps.facetDetailsById[newId] = detailsClone;
|
||||||
|
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
|
||||||
|
updateState({
|
||||||
|
customMethodCardMetaById: maps.customMethodCardMetaById,
|
||||||
|
membershipMethodDetailsById: maps.facetDetailsById,
|
||||||
|
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
|
||||||
|
});
|
||||||
|
pendingEphemeralDuplicateIdRef.current = newId;
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
setPendingCardId(newId);
|
||||||
|
setPendingDraft(structuredClone(detailsClone));
|
||||||
|
setModalEditUnlocked(false);
|
||||||
|
setDraftFieldBlocks(null);
|
||||||
|
setCustomizeHeaderDraft(null);
|
||||||
|
}, [
|
||||||
|
draftFieldBlocks,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
modalEditUnlocked,
|
||||||
|
modalKebabMenu.duplicateTitleSuffix,
|
||||||
|
pendingCardId,
|
||||||
|
pendingDraft,
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
state.membershipMethodDetailsById,
|
||||||
|
updateState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDuplicatePrefabCard = useCallback(() => {
|
||||||
|
if (
|
||||||
|
!pendingCardId ||
|
||||||
|
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const method = methodById.get(pendingCardId);
|
||||||
|
if (!method || !pendingDraft) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
const newId = crypto.randomUUID();
|
||||||
|
const detailsClone = cloneMethodCardDetailsForDuplicate(
|
||||||
|
pendingDraft,
|
||||||
|
state.membershipMethodDetailsById?.[pendingCardId],
|
||||||
|
() => membershipPresetFor(newId),
|
||||||
|
);
|
||||||
|
const blocksClone = structuredClone(
|
||||||
|
modalEditUnlocked &&
|
||||||
|
draftFieldBlocks !== null &&
|
||||||
|
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||||
|
? draftFieldBlocks
|
||||||
|
: cloneMethodCardBlocksForDuplicate(
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
pendingCardId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const suffix = modalKebabMenu.duplicateTitleSuffix;
|
||||||
|
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
|
||||||
|
const maps = forkMethodCardFacetMapsForDuplicate({
|
||||||
|
customMethodCardMetaById: state.customMethodCardMetaById,
|
||||||
|
facetDetailsById: state.membershipMethodDetailsById,
|
||||||
|
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||||
|
omitId: priorEphemeral,
|
||||||
|
});
|
||||||
|
maps.customMethodCardMetaById[newId] = {
|
||||||
|
label: duplicateMethodCardTitle(method.label, suffix),
|
||||||
|
supportText: method.supportText,
|
||||||
|
};
|
||||||
|
maps.facetDetailsById[newId] = detailsClone;
|
||||||
|
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
|
||||||
|
updateState({
|
||||||
|
customMethodCardMetaById: maps.customMethodCardMetaById,
|
||||||
|
membershipMethodDetailsById: maps.facetDetailsById,
|
||||||
|
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
|
||||||
|
});
|
||||||
|
pendingEphemeralDuplicateIdRef.current = newId;
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
setPendingCardId(newId);
|
||||||
|
setPendingDraft(structuredClone(detailsClone));
|
||||||
|
setModalEditUnlocked(false);
|
||||||
|
setDraftFieldBlocks(null);
|
||||||
|
setCustomizeHeaderDraft(null);
|
||||||
|
}, [
|
||||||
|
draftFieldBlocks,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
methodById,
|
||||||
|
modalEditUnlocked,
|
||||||
|
modalKebabMenu.duplicateTitleSuffix,
|
||||||
|
pendingCardId,
|
||||||
|
pendingDraft,
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
state.membershipMethodDetailsById,
|
||||||
|
updateState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const kebabMenuItems = useMemo(
|
||||||
|
() =>
|
||||||
|
buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
||||||
|
showCustomize: !modalEditUnlocked,
|
||||||
|
onCustomize: handleCustomize,
|
||||||
|
onDuplicate:
|
||||||
|
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
|
||||||
|
? undefined
|
||||||
|
: isCustomMethodCardId(
|
||||||
|
pendingCardId,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
)
|
||||||
|
? handleDuplicateCustomCard
|
||||||
|
: handleDuplicatePrefabCard,
|
||||||
|
showRemove: isSelectedCardModal,
|
||||||
|
onRemove: handleRemoveSelectedFromModal,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
handleCustomize,
|
||||||
|
handleDuplicateCustomCard,
|
||||||
|
handleDuplicatePrefabCard,
|
||||||
|
handleRemoveSelectedFromModal,
|
||||||
|
isSelectedCardModal,
|
||||||
|
modalEditUnlocked,
|
||||||
|
modalKebabMenu,
|
||||||
|
pendingCardId,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
state.editingPublishedRuleId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const modalConfig = pendingCardId
|
||||||
|
? (() => {
|
||||||
|
const method = methodById.get(pendingCardId);
|
||||||
|
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
||||||
|
const saveLabel = modalKebabMenu.saveEdits;
|
||||||
|
return {
|
||||||
|
title: meta?.label ?? method?.label ?? mem.confirmModal.title,
|
||||||
|
description:
|
||||||
|
meta?.supportText ??
|
||||||
|
method?.supportText ??
|
||||||
|
mem.confirmModal.description,
|
||||||
|
nextButtonText: modalEditUnlocked
|
||||||
|
? saveLabel
|
||||||
|
: mem.addPlatform.nextButtonText,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
: {
|
||||||
|
title: mem.confirmModal.title,
|
||||||
|
description: mem.confirmModal.description,
|
||||||
|
nextButtonText: mem.confirmModal.nextButtonText,
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseAddWizard = useCallback(() => {
|
||||||
|
setAddCustomWizardOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFinalizeCustomCard = useCallback(
|
||||||
|
({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
fieldBlocks,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
fieldBlocks: CustomMethodCardFieldBlock[];
|
||||||
|
}) => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
updateState({
|
||||||
|
selectedMembershipMethodIds: moveFacetSelectionIdToFront(
|
||||||
|
selectedIds,
|
||||||
|
id,
|
||||||
|
),
|
||||||
|
customMethodCardMetaById: {
|
||||||
|
...(state.customMethodCardMetaById ?? {}),
|
||||||
|
[id]: { label: title, supportText: description },
|
||||||
|
},
|
||||||
|
membershipMethodDetailsById: {
|
||||||
|
...(state.membershipMethodDetailsById ?? {}),
|
||||||
|
[id]: membershipPresetFor(id),
|
||||||
|
},
|
||||||
|
customMethodCardFieldBlocksById: {
|
||||||
|
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||||
|
[id]: fieldBlocks,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
selectedIds,
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
state.membershipMethodDetailsById,
|
||||||
|
updateState,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateModalPrimary = useCallback(() => {
|
||||||
|
if (!pendingCardId) {
|
||||||
|
handleCreateModalClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
|
||||||
|
if (selectedIds.includes(pendingCardId)) {
|
||||||
|
if (modalEditUnlocked) {
|
||||||
|
if (!customizeHeaderDraft) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextMeta = methodCardMetaWithCustomizeHeader(
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
pendingCardId,
|
||||||
|
customizeHeaderDraft,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
pendingCardId &&
|
||||||
|
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
||||||
|
usesWizardFieldBlocksModalBody({
|
||||||
|
methodId: pendingCardId,
|
||||||
|
meta: state.customMethodCardMetaById,
|
||||||
|
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||||
|
modalEditUnlocked,
|
||||||
|
draftFieldBlocks,
|
||||||
|
customFacetDetailsMatchPreset,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
updateState({
|
||||||
|
customMethodCardMetaById: nextMeta,
|
||||||
|
customMethodCardFieldBlocksById: {
|
||||||
|
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||||
|
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (pendingDraft) {
|
||||||
|
updateState({
|
||||||
|
customMethodCardMetaById: nextMeta,
|
||||||
|
membershipMethodDetailsById: {
|
||||||
|
...(state.membershipMethodDetailsById ?? {}),
|
||||||
|
[pendingCardId]: pendingDraft,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
setModalEditUnlocked(false);
|
||||||
|
setDraftFieldBlocks(null);
|
||||||
|
setCustomizeHeaderDraft(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modalEditUnlocked) {
|
||||||
|
if (!customizeHeaderDraft) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextMeta = methodCardMetaWithCustomizeHeader(
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
pendingCardId,
|
||||||
|
customizeHeaderDraft,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
pendingCardId &&
|
||||||
|
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
||||||
|
usesWizardFieldBlocksModalBody({
|
||||||
|
methodId: pendingCardId,
|
||||||
|
meta: state.customMethodCardMetaById,
|
||||||
|
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||||
|
modalEditUnlocked,
|
||||||
|
draftFieldBlocks,
|
||||||
|
customFacetDetailsMatchPreset,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
updateState({
|
||||||
|
customMethodCardMetaById: nextMeta,
|
||||||
|
customMethodCardFieldBlocksById: {
|
||||||
|
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||||
|
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (pendingDraft) {
|
||||||
|
updateState({
|
||||||
|
customMethodCardMetaById: nextMeta,
|
||||||
|
membershipMethodDetailsById: {
|
||||||
|
...(state.membershipMethodDetailsById ?? {}),
|
||||||
|
[pendingCardId]: pendingDraft,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
customizeSnapshotRef.current = null;
|
||||||
|
setModalEditUnlocked(false);
|
||||||
|
setDraftFieldBlocks(null);
|
||||||
|
setCustomizeHeaderDraft(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingDraft) {
|
||||||
|
handleCreateModalClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateState({
|
||||||
|
selectedMembershipMethodIds: moveFacetSelectionIdToFront(
|
||||||
|
selectedIds,
|
||||||
|
pendingCardId,
|
||||||
|
),
|
||||||
|
membershipMethodDetailsById: {
|
||||||
|
...(state.membershipMethodDetailsById ?? {}),
|
||||||
|
[pendingCardId]: pendingDraft,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
pendingEphemeralDuplicateIdRef.current = null;
|
||||||
|
handleCreateModalClose();
|
||||||
|
}, [
|
||||||
|
customizeHeaderDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
handleCreateModalClose,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
modalEditUnlocked,
|
||||||
|
pendingCardId,
|
||||||
|
pendingDraft,
|
||||||
|
selectedIds,
|
||||||
|
state,
|
||||||
|
updateState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CreateFlowStepShell
|
||||||
|
variant="wideGridLoosePadding"
|
||||||
|
contentTopBelowMd="space-800"
|
||||||
|
>
|
||||||
|
<div className="flex w-full min-w-0 flex-col items-center gap-6">
|
||||||
|
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
|
||||||
|
<CreateFlowHeaderLockup
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
justification="center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
|
||||||
|
<CardStack
|
||||||
|
cards={sampleCards}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
onCardSelect={handleCardClick}
|
||||||
|
expanded={expanded}
|
||||||
|
onToggleExpand={() => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setExpanded((prev) => !prev);
|
||||||
|
}}
|
||||||
|
hasMore={true}
|
||||||
|
toggleLabel={mem.page.seeAllLink}
|
||||||
|
compactRecommendedLimit={5}
|
||||||
|
compactCardIds={compactCardIds}
|
||||||
|
compactDesktopLayout="pyramidFive"
|
||||||
|
headerLockupSize={mdUp ? "L" : "M"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Create
|
||||||
|
isOpen={createModalOpen}
|
||||||
|
onClose={handleCreateModalClose}
|
||||||
|
headerContent={
|
||||||
|
modalEditUnlocked && customizeHeaderDraft ? (
|
||||||
|
<MethodCardCustomizeModalHeader
|
||||||
|
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
|
||||||
|
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
|
||||||
|
titleValue={customizeHeaderDraft.title}
|
||||||
|
descriptionValue={customizeHeaderDraft.description}
|
||||||
|
onTitleChange={(title) =>
|
||||||
|
setCustomizeHeaderDraft((prev) =>
|
||||||
|
prev ? { ...prev, title } : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDescriptionChange={(description) =>
|
||||||
|
setCustomizeHeaderDraft((prev) =>
|
||||||
|
prev ? { ...prev, description } : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
onNext={handleCreateModalPrimary}
|
||||||
|
title={modalConfig.title}
|
||||||
|
description={modalConfig.description}
|
||||||
|
nextButtonText={modalConfig.nextButtonText}
|
||||||
|
showBackButton={modalEditUnlocked}
|
||||||
|
onBack={handleCancelCustomize}
|
||||||
|
backButtonText={modalKebabMenu.cancelCustomize}
|
||||||
|
showNextButton={showMethodModalPrimary}
|
||||||
|
backdropVariant="blurredYellow"
|
||||||
|
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
||||||
|
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
|
||||||
|
kebabMenuItems={kebabMenuItems}
|
||||||
|
>
|
||||||
|
{pendingCardId && pendingDraft ? (
|
||||||
|
modalUsesWizardFieldBlocksBody ? (
|
||||||
|
<CustomMethodCardModalBody
|
||||||
|
cardId={pendingCardId}
|
||||||
|
blocksById={state.customMethodCardFieldBlocksById}
|
||||||
|
blocksOverride={
|
||||||
|
modalEditUnlocked && draftFieldBlocks !== null
|
||||||
|
? draftFieldBlocks
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
|
||||||
|
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked}
|
||||||
|
onFieldBlocksChange={
|
||||||
|
fieldsLocked
|
||||||
|
? undefined
|
||||||
|
: (next) => setDraftFieldBlocks(next)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MembershipMethodEditFields
|
||||||
|
value={pendingDraft}
|
||||||
|
onChange={handleDraftChange}
|
||||||
|
readOnly={fieldsLocked}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</Create>
|
||||||
|
</CreateFlowStepShell>
|
||||||
|
<CustomMethodCardWizard
|
||||||
|
isOpen={addCustomWizardOpen}
|
||||||
|
onClose={handleCloseAddWizard}
|
||||||
|
onFinalize={handleFinalizeCustomCard}
|
||||||
|
onPersistCustomUploadFile={(file) =>
|
||||||
|
uploadCreateFlowFile(file, "customMethodAttachment")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{confirmDialog}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user