Compare commits

..

3 Commits

1516 changed files with 112802 additions and 109910 deletions
-48
View File
@@ -1,48 +0,0 @@
---
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`.
-85
View File
@@ -1,85 +0,0 @@
---
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.
-70
View File
@@ -1,70 +0,0 @@
---
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.
-52
View File
@@ -1,52 +0,0 @@
---
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.
-102
View File
@@ -1,102 +0,0 @@
---
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`.
-63
View File
@@ -1,63 +0,0 @@
---
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.
-59
View File
@@ -1,59 +0,0 @@
---
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:
- 12 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.
-65
View File
@@ -1,65 +0,0 @@
---
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"`).
-90
View File
@@ -1,90 +0,0 @@
---
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.
-114
View File
@@ -1,114 +0,0 @@
---
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.
-37
View File
@@ -1,37 +0,0 @@
---
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>
```
-72
View File
@@ -1,72 +0,0 @@
---
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.
-10
View File
@@ -1,10 +0,0 @@
node_modules
.next
.git
.env*
!.env.example
coverage
playwright-report
test-results
storybook-static
.runner
-34
View File
@@ -1,34 +0,0 @@
# 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"
+187
View File
@@ -0,0 +1,187 @@
name: CI Pipeline
run-name: ${{ gitea.actor }} triggered CI pipeline
on:
workflow_dispatch: {}
push:
branches: [main, develop] # only direct pushes/merges to protected branches
pull_request:
branches: [main, develop] # PRs into main/develop
types: [opened, reopened, synchronize]
jobs:
canary:
runs-on: [self-hosted, macos-latest]
steps:
- run: |
uname -a
node -v || true
echo "Runner labels OK ✅"
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: gitea.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
+8 -38
View File
@@ -10,25 +10,16 @@
!.yarn/releases
!.yarn/versions
# npm cache (should never be committed)
.npm-cache/
npm-cache/
# testing
/coverage
# Local user uploads (see UPLOAD_ROOT in .env.example)
/var/uploads
# Playwright
/test-results/
/playwright-report/
# Lighthouse CI results
/lhci-results/
/.lighthouseci/
# Ignore other image files (but not visual regression snapshots)
# Visual regression snapshots (allow these)
!tests/e2e/visual-regression.spec.ts-snapshots/
!tests/e2e/visual-regression.spec.ts-snapshots/*.png
# Ignore other image files
*.png
*.jpg
*.jpeg
@@ -39,10 +30,6 @@ npm-cache/
*.avi
*.mkv
# Visual regression snapshots (allow these)
!tests/e2e/visual-regression.spec.ts-snapshots/
!tests/e2e/visual-regression.spec.ts-snapshots/*.png
# next.js
/.next/
/out/
@@ -51,16 +38,9 @@ npm-cache/
/build
# misc
/tmp/
.DS_Store
*.pem
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# debug
npm-debug.log*
yarn-debug.log*
@@ -69,27 +49,17 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
tsconfig.tsbuildinfo
next-env.d.ts
*storybook.log
storybook-static
# Gitea runner runtime files
.runner
.runner.pid
act_runner
# OS files
Thumbs.db
.DS_Store
# Cursor rules (local development)
.cursorrules
# storybook config files (to avoid git changes when switching between local and production)
.storybook/main.js
.storybook/preview.js
+7 -82
View File
@@ -1,95 +1,20 @@
{
"ci": {
"collect": {
"url": [
"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"
]
}
"url": ["http://localhost:3000/"],
"numberOfRuns": 3
},
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"categories:accessibility": ["warn", { "minScore": 0.95 }],
"categories:best-practices": ["warn", { "minScore": 0.9 }],
"categories:seo": ["warn", { "minScore": 0.9 }],
"categories:performance": ["warn", { "minScore": 0.9 }],
"categories:accessibility": ["error", { "minScore": 0.9 }],
"first-contentful-paint": ["warn", { "maxNumericValue": 2000 }],
"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 }]
"interactive": ["warn", { "maxNumericValue": 4000 }]
}
},
"upload": {
"target": "temporary-public-storage",
"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
"target": "filesystem",
"outputDir": "lhci-results"
}
}
}
-18
View File
@@ -1,18 +0,0 @@
.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
+12
View File
@@ -0,0 +1,12 @@
{
"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": 12,
"uuid": "1f114e7b-9330-40fc-9c96-816b07f3e4c2",
"name": "community-rule-test-runner",
"token": "ba42513830cbc9e2eb6abf86ee119fadfcb7a14b",
"address": "https://git.medlab.host",
"labels": [
"self-hosted:host",
"macos-latest:host"
]
}
+1
View File
@@ -0,0 +1 @@
45727
-7
View File
@@ -1,7 +0,0 @@
@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";
}
+39 -20
View File
@@ -1,35 +1,54 @@
/** @type { import('@storybook/nextjs').StorybookConfig } */
module.exports = {
/** @type { import('@storybook/nextjs-vite').StorybookConfig } */
const config = {
stories: [
"../stories/**/*.mdx",
"../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)",
],
addons: [
// 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-actions",
"@storybook/addon-a11y",
"@storybook/addon-interactions",
"@chromatic-com/storybook",
],
framework: {
name: "@storybook/nextjs",
name: "@storybook/nextjs-vite",
options: {},
},
staticDirs: ["../public"],
// Webpack configuration to resolve Next.js modules for Next.js 16 compatibility
async webpackFinal(config) {
// Ensure Next.js modules are resolved correctly
config.resolve = config.resolve || {};
config.resolve.alias = {
...(config.resolve.alias || {}),
// Auto-detect environment and apply appropriate settings
managerHead: (head) => {
// Only add base href for GitHub Pages (when CI=true or specific environment)
if (process.env.CI || process.env.STORYBOOK_BASE_PATH) {
return `${head}<base href="/communityrulestorybook/">`;
}
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",
};
// Ensure node_modules are resolved
config.resolve.modules = [
...(config.resolve.modules || []),
"node_modules",
];
return config;
return cfg;
},
};
export default config;
+30 -8
View File
@@ -1,11 +1,33 @@
import "../app/globals.css";
import "./fonts.css";
import { MessagesProvider } from "../app/contexts/MessagesContext";
import messages from "../messages/en/index";
// Import Google Fonts for Storybook
import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google";
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 } */
const preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
@@ -15,11 +37,11 @@ const preview = {
},
decorators: [
(Story) => (
<MessagesProvider messages={messages}>
<div className="font-inter">
<Story />
</div>
</MessagesProvider>
<div
className={`${inter.variable} ${bricolageGrotesque.variable} ${spaceGrotesk.variable} font-sans`}
>
<Story />
</div>
),
],
};
+3 -3
View File
@@ -1,7 +1,7 @@
import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
import { setProjectAnnotations } from "@storybook/nextjs-vite";
import * as projectAnnotations from "./preview";
import { setProjectAnnotations } from '@storybook/nextjs-vite';
import * as projectAnnotations from './preview';
// 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
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
-91
View File
@@ -1,91 +0,0 @@
# 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
View File
@@ -1,148 +0,0 @@
# 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`).
-19
View File
@@ -1,19 +0,0 @@
{
"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
View File
@@ -1,71 +0,0 @@
# 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"]
-674
View File
@@ -1,674 +0,0 @@
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>.
+157 -70
View File
@@ -1,93 +1,180 @@
# Community Rule
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.
A Next.js application for community decision-making and governance documentation.
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.
## 🚀 Getting Started
## Requirements
- Node.js **20+** (LTS)
- npm **10+**
- Docker (for local Postgres and Mailhog)
## Quick start
Run the development server:
```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
```
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.
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Full local backend, API reference, and PR workflow:
[CONTRIBUTING.md](CONTRIBUTING.md).
## 🧪 Testing Framework
## Scripts
This project includes a comprehensive testing framework with multiple layers of testing:
| 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. |
### Quick Test Commands
See [`package.json`](package.json) for the full list (visual regression,
bundle analysis, seeding, etc.).
```bash
# Unit tests with coverage
npm test
## Project layout
# E2E tests
npm run e2e
```text
app/ Next.js app router — route groups (marketing), (app),
(admin), (dev); shared components under app/components/;
admin-only widgets under app/(admin)/<route>/_components/
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
# Performance tests
npm run lhci
# Storybook tests
npm run test:sb
```
## Tech stack
### Test Coverage
Next.js 16 · React 19 · TypeScript · Tailwind CSS 4 · Prisma 6 ·
PostgreSQL · Vitest · Playwright · Storybook 10 · Lighthouse CI.
-**124 Unit Tests** (8 components + 1 integration)
-**308 E2E Tests** (4 browsers × 77 tests)
-**92 Visual Regression Screenshots**
-**Performance Budgets**
-**Accessibility Compliance**
## Documentation
### CI/CD Pipeline
- [docs/README.md](docs/README.md) — index of guides and rules.
- [docs/create-flow.md](docs/create-flow.md) — create-rule wizard canon.
- [docs/testing-guide.md](docs/testing-guide.md) — testing philosophy.
- [docs/guides/ops-backend-deploy.md](docs/guides/ops-backend-deploy.md)
— Cloudron deploy + cutover plan.
- [CONTRIBUTING.md](CONTRIBUTING.md) — local backend, API routes, PR workflow.
- [AGENTS.md](AGENTS.md) — orientation for AI coding agents.
- **Gitea Actions** with 7 parallel jobs
- **Cross-browser testing** (Chromium, Firefox, WebKit, Mobile)
- **Visual regression testing**
- **Performance monitoring**
- **Code coverage reporting**
## License
📖 **For detailed testing documentation, see [docs/TESTING.md](docs/TESTING.md)**
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.
## 📚 Storybook Development
User-facing content (guides, template copy, marketing text) is licensed
under [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/),
as stated on [communityrule.info/about](https://communityrule.info/about/).
This project includes Storybook for component development and documentation. The setup automatically detects the environment and applies the appropriate configuration.
### Local Development
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.
+258
View File
@@ -0,0 +1,258 @@
# 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.
Executable
BIN
View File
Binary file not shown.
-7
View File
@@ -1,7 +0,0 @@
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>;
}
-168
View File
@@ -1,168 +0,0 @@
"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>
);
}
@@ -1,166 +0,0 @@
"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;
@@ -1,40 +0,0 @@
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;
}
@@ -1,169 +0,0 @@
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;
@@ -1,2 +0,0 @@
export { default } from "./WebVitalsDashboard.container";
export * from "./WebVitalsDashboard.types";
-5
View File
@@ -1,5 +0,0 @@
import MonitorPageContent from "./MonitorPageContent";
export default function MonitorPage() {
return <MonitorPageContent />;
}
-931
View File
@@ -1,931 +0,0 @@
"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>;
}
-32
View File
@@ -1,32 +0,0 @@
"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>;
}
-171
View File
@@ -1,171 +0,0 @@
"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>
);
}
-145
View File
@@ -1,145 +0,0 @@
"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>
);
}
-25
View File
@@ -1,25 +0,0 @@
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} />;
}
@@ -1,146 +0,0 @@
"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);
@@ -1,22 +0,0 @@
"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} />;
}
@@ -1,49 +0,0 @@
"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>
);
}
@@ -1,51 +0,0 @@
"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;
}
@@ -1,60 +0,0 @@
"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>
);
}
@@ -1,84 +0,0 @@
"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>
);
}
@@ -1,66 +0,0 @@
"use client";
/**
* Controlled field blocks for wizard-authored method cards in Create modals
* (facet screens + final-review chip edit). When `onBlocksChange` is omitted,
* blocks render read-only (disabled controls).
*
* Layout matches preset method editors ({@link CommunicationMethodEditFields},
* {@link DecisionApproachEditFields}): {@link ModalTextAreaField},
* {@link ApplicableScopeField} chip rows, {@link IncrementerBlock}.
*/
import { memo, useCallback } 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;
@@ -1,55 +0,0 @@
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;
};
@@ -1,198 +0,0 @@
"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";
@@ -1,110 +0,0 @@
"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";
@@ -1,100 +0,0 @@
"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";
@@ -1,2 +0,0 @@
export { default } from "./CustomMethodCardFieldBlocksSummary.container";
export type { CustomMethodCardFieldBlocksSummaryProps } from "./CustomMethodCardFieldBlocksSummary.types";
@@ -1,68 +0,0 @@
"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 />;
}
@@ -1,26 +0,0 @@
"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);
@@ -1,433 +0,0 @@
"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;
@@ -1,148 +0,0 @@
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[];
}
@@ -1,115 +0,0 @@
"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";
@@ -1,77 +0,0 @@
"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";
@@ -1,21 +0,0 @@
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import type { DragEvent } from "react";
export interface CustomMethodCardWizardBlocksListProps {
blocks: CustomMethodCardFieldBlock[];
fieldTypeLabels: Record<AddCustomFieldType, string>;
dragHandleAriaLabel: string;
listLabel: string;
onBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void;
}
export interface CustomMethodCardWizardBlocksListViewProps
extends CustomMethodCardWizardBlocksListProps {
draggingIndex: number | null;
overIndex: number | null;
onDragStart: (_index: number) => (_e: DragEvent) => void;
onDragOver: (_index: number) => (_e: DragEvent) => void;
onDrop: (_index: number) => (_e: DragEvent) => void;
onDragEnd: () => void;
}
@@ -1,95 +0,0 @@
"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";
@@ -1,213 +0,0 @@
"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";
@@ -1,2 +0,0 @@
export { default } from "./CustomMethodCardWizard.container";
export type { CustomMethodCardWizardProps } from "./CustomMethodCardWizard.types";
File diff suppressed because it is too large Load Diff
@@ -1,111 +0,0 @@
"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>
);
}
@@ -1,109 +0,0 @@
"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>
);
}
@@ -1,52 +0,0 @@
"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>
);
}
@@ -1,70 +0,0 @@
"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);
@@ -1,18 +0,0 @@
/** 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}.
* Cardcard 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)]";
@@ -1,51 +0,0 @@
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;
}
@@ -1,65 +0,0 @@
"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);
@@ -1,96 +0,0 @@
"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);
@@ -1,62 +0,0 @@
"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);
@@ -1,102 +0,0 @@
"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);
@@ -1,64 +0,0 @@
"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);
@@ -1,14 +0,0 @@
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";
@@ -1,230 +0,0 @@
"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;
}
@@ -1,51 +0,0 @@
"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;
}
@@ -1,374 +0,0 @@
"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,
};
}
-111
View File
@@ -1,111 +0,0 @@
"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,
],
);
}
@@ -1,149 +0,0 @@
"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,
};
}
@@ -1,20 +0,0 @@
"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;
}
@@ -1,20 +0,0 @@
"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;
}
@@ -1,161 +0,0 @@
"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,
};
}
@@ -1,20 +0,0 @@
"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;
}
@@ -1,29 +0,0 @@
"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],
);
}
@@ -1,78 +0,0 @@
"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 };
}
@@ -1,176 +0,0 @@
"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)),
};
}
@@ -1,94 +0,0 @@
"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,
};
}
@@ -1,250 +0,0 @@
"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,
};
}
-6
View File
@@ -1,6 +0,0 @@
import type { ReactNode } from "react";
import CreateFlowLayoutGate from "./CreateFlowLayoutGate";
export default function CreateFlowLayout({ children }: { children: ReactNode }) {
return <CreateFlowLayoutGate>{children}</CreateFlowLayoutGate>;
}
-7
View File
@@ -1,7 +0,0 @@
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}`);
}
@@ -1,126 +0,0 @@
"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>
);
}
@@ -1,23 +0,0 @@
"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);
}
@@ -1,833 +0,0 @@
"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}
</>
);
}
@@ -1,832 +0,0 @@
"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}
</>
);
}
@@ -1,825 +0,0 @@
"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