Merge pull request 'Create flow: Community + custom rule UI, template review, facet recommendations, and app/docs reorg' (#46) from adilallo/feature/BackendImplementation4 into main
Reviewed-on: #46
This commit was merged in pull request #46.
This commit is contained in:
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
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 `DATABASE_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`)
|
||||||
|
live in `lib/server/responses.ts`; add new shared responses there when a
|
||||||
|
pattern repeats in two routes.
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
- **Error response shape.** Currently `{ error: string }` + HTTP status. No
|
||||||
|
error codes yet — don't add a taxonomy until one is designed.
|
||||||
|
- **Pagination / filtering.** Only `rules/route.ts` paginates (`take` capped
|
||||||
|
at 100). Mirror it if you add list endpoints; don't invent cursors or
|
||||||
|
offset contracts unilaterally.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
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,
|
||||||
|
pull the node id from the Figma file directly. Use `TODO(figma)` as a
|
||||||
|
placeholder rather than omitting the docstring entirely.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Figma: "Control / Incrementer" (17857:30943). A compact [ - value + ]
|
||||||
|
* row used for numeric step inputs.
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pasting from Figma
|
||||||
|
|
||||||
|
Figma's "Inspect → Code" output emits PascalCase. When importing a snippet,
|
||||||
|
lowercase the enum values before committing — same pattern as removing
|
||||||
|
inline pixel values in favor of design tokens.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
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";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Single-file pattern (exception)
|
||||||
|
|
||||||
|
`app/components/buttons/*.tsx` and other trivially-presentational components
|
||||||
|
can stay as a single file when they have **no derived state and only a
|
||||||
|
handful of props** (e.g. `Button.tsx`, `InlineTextButton.tsx`). If you find
|
||||||
|
yourself adding state, side effects, or enum logic, promote it to the split
|
||||||
|
pattern.
|
||||||
|
|
||||||
|
## Wrapper / group components
|
||||||
|
|
||||||
|
Related composites live in a **sibling folder**, not inside the base
|
||||||
|
component's folder — mirror `CheckboxGroup/` ↔ `Checkbox/`,
|
||||||
|
`IncrementerBlock/` ↔ `Incrementer/`, etc. Each gets its own 4-file split.
|
||||||
|
Consumers import from the folder's `index.tsx`.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
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`).
|
||||||
|
- 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/utility/InputLabel` (`helpIcon` prop) |
|
||||||
|
| Toggle chip (dim-but-clickable) | `Chip` with `state="Disabled" disabled={false}` |
|
||||||
|
| Card-click → structured creation modal | `Create` with `backdropVariant="loginYellow"` |
|
||||||
|
|
||||||
|
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.
|
||||||
|
- Modal `sections` defaults are DB-shaped seed placeholders, not UI
|
||||||
|
constants — expect replacement with live data.
|
||||||
|
|
||||||
|
## Interaction tracking
|
||||||
|
|
||||||
|
Every user interaction inside a create-flow screen must call
|
||||||
|
`markCreateFlowInteraction()` from `useCreateFlow()` before mutating state —
|
||||||
|
progress / footer logic depends on it.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
description: Custom hooks live in app/hooks; co-locate logic, document via TSDoc.
|
||||||
|
globs: app/hooks/**/*.{ts,tsx}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Custom hooks
|
||||||
|
|
||||||
|
Reusable component logic lives in `app/hooks/`. Each hook is a small, focused
|
||||||
|
module with a TSDoc block that doubles as the API reference (no separate doc
|
||||||
|
file).
|
||||||
|
|
||||||
|
## File layout
|
||||||
|
|
||||||
|
- One file per hook: `app/hooks/use<Name>.ts`.
|
||||||
|
- Re-export from `app/hooks/index.ts`. Consumers import from the barrel:
|
||||||
|
`import { useFoo } from "../hooks";`.
|
||||||
|
- Companion unit test (when there is non-trivial logic): `tests/unit/hooks/`.
|
||||||
|
|
||||||
|
## Authoring rules
|
||||||
|
|
||||||
|
- Marked as a regular function (`export function useFoo() {}`); React handles
|
||||||
|
the `use*` naming convention.
|
||||||
|
- Wrap exposed callbacks in `useCallback` and computed values in `useMemo`
|
||||||
|
so consumers can list them in dependency arrays without churn.
|
||||||
|
- Read DOM/browser APIs only inside `useEffect` so the hook stays SSR-safe.
|
||||||
|
- Never throw on missing globals (e.g. `window`, `gtag`); guard and no-op.
|
||||||
|
|
||||||
|
## TSDoc — the only reference
|
||||||
|
|
||||||
|
Every exported hook gets a TSDoc block with:
|
||||||
|
|
||||||
|
- 1–2 sentence summary.
|
||||||
|
- `@param` per argument and `@returns` describing the shape.
|
||||||
|
- `@example` showing the typical call site.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Detect clicks outside a set of elements (e.g. close a dropdown).
|
||||||
|
*
|
||||||
|
* @param refs Elements that should NOT trigger the handler.
|
||||||
|
* @param handler Invoked when a click lands outside every ref.
|
||||||
|
* @param enabled Toggle without unmounting the consumer (default true).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* useClickOutside([menuRef, buttonRef], () => setOpen(false), open);
|
||||||
|
*/
|
||||||
|
export function useClickOutside(
|
||||||
|
refs: Array<RefObject<HTMLElement>>,
|
||||||
|
handler: (event: MouseEvent | TouchEvent) => void,
|
||||||
|
enabled = true,
|
||||||
|
): void { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Container/view consumption
|
||||||
|
|
||||||
|
Hooks belong in **container** files (per `component-structure.mdc`). Views
|
||||||
|
stay pure and read derived values via props — never call hooks that touch
|
||||||
|
state or side effects from a view.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
description: Text localization via messages/ bundles and useMessages()
|
||||||
|
globs: messages/**/*.{ts,json}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Text localization
|
||||||
|
|
||||||
|
All user-visible copy lives in the typed messages bundle under `messages/en/`
|
||||||
|
and is read via `useMessages()` (fully typed) or `useTranslation()` (dot
|
||||||
|
notation). Never hard-code user-facing strings in components.
|
||||||
|
|
||||||
|
## File layout
|
||||||
|
|
||||||
|
- `messages/en/<area>.json` for single-file areas (`common.json`,
|
||||||
|
`navigation.json`, `metadata.json`).
|
||||||
|
- `messages/en/<folder>/<entry>.json` for areas with multiple buckets:
|
||||||
|
`components/*.json`, `pages/*.json`. One JSON per component / page —
|
||||||
|
don't shoehorn unrelated copy into a shared file.
|
||||||
|
- `messages/en/create/<stage>/<step>.json` — wizard steps grouped by Figma
|
||||||
|
stage (`community`, `customRule`, `reviewAndComplete`). Cross-cutting
|
||||||
|
chrome (footer, top nav, draft hydration, template review) and shared
|
||||||
|
layout-shell strings (`select.json`, `text.json`, `upload.json`) live at
|
||||||
|
the `create/` root.
|
||||||
|
- Optional `"_comment"` at the top of a JSON documents the bundle's purpose.
|
||||||
|
|
||||||
|
## Registration — required
|
||||||
|
|
||||||
|
Every new JSON must be wired into `messages/en/index.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import createConflictManagement from "./create/customRule/conflictManagement.json";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// …
|
||||||
|
create: {
|
||||||
|
customRule: {
|
||||||
|
conflictManagement: createConflictManagement,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The default export **is** the type source for `useMessages()`; skipping this
|
||||||
|
step means consumers can't read your strings and TypeScript won't flag the gap.
|
||||||
|
|
||||||
|
## Access pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useMessages } from "../contexts/MessagesContext";
|
||||||
|
|
||||||
|
const m = useMessages();
|
||||||
|
const title = m.create.customRule.conflictManagement.page.compactTitle; // fully typed
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `useTranslation(namespace)` only when you need dot-path lookup by dynamic
|
||||||
|
key; prefer direct property access for the type safety.
|
||||||
|
|
||||||
|
## Key conventions
|
||||||
|
|
||||||
|
- **Structural keys**: camelCase (`compactTitle`,
|
||||||
|
`sectionHeadings.corePrinciple`).
|
||||||
|
- **Content ids**: match the id consumers already use (card id, step id, URL
|
||||||
|
segment) — typically kebab-case (`"in-person-meetings"`,
|
||||||
|
`"peer-mediation"`).
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
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 | TopNav (via root) + marketing `<Footer />` |
|
||||||
|
| `app/(app)/` | `/create/*`, `/login`, `/profile`, future signed-in surfaces | Authenticated product | TopNav (via root) — no footer |
|
||||||
|
| `app/(admin)/` | `/monitor`, future ops dashboards | Operators | TopNav (via root) — no footer |
|
||||||
|
| `app/(dev)/` | `/components-preview`, future dev previews | Local dev (NODE_ENV gated) | TopNav (via root) — no footer |
|
||||||
|
| `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.
|
||||||
|
- **Nested layouts** (e.g. `(app)/create/layout.tsx`) compose feature-specific
|
||||||
|
chrome inside the group's `<main>` — never render `<html>`, `<body>`,
|
||||||
|
`<main>`, or providers.
|
||||||
|
|
||||||
|
If a route needs different chrome than its group provides, prefer adding a
|
||||||
|
**nested layout** under that route — don't introduce pathname-sniffing
|
||||||
|
client components. (`ConditionalNavigation` is the lone tolerated exception
|
||||||
|
because it carries SSR session state; do not add new pathname-conditional
|
||||||
|
chrome components.)
|
||||||
|
|
||||||
|
## Co-located component folders
|
||||||
|
|
||||||
|
Page-private server/client components that are **only** used by routes in a
|
||||||
|
given group go in `_components/` inside that group:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/(marketing)/_components/MarketingRuleStackSection.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
The leading underscore makes Next.js treat the folder as **private** — it's
|
||||||
|
ignored by the router. Use this instead of letting page-only files sit next
|
||||||
|
to `page.tsx`.
|
||||||
|
|
||||||
|
Components reused across groups belong in `app/components/<category>/`
|
||||||
|
(see `component-structure.mdc`).
|
||||||
|
|
||||||
|
## Adding a new route
|
||||||
|
|
||||||
|
1. **Choose the group** by audience: marketing (public), app (signed-in),
|
||||||
|
admin (operators), dev (local-only). When in doubt, ask whether the
|
||||||
|
public marketing footer should appear — if yes, it's `(marketing)`.
|
||||||
|
2. Create `app/(<group>)/<route>/page.tsx`. URLs do **not** include the
|
||||||
|
group name.
|
||||||
|
3. If the route needs its own chrome (e.g. a wizard header), add
|
||||||
|
`app/(<group>)/<route>/layout.tsx`.
|
||||||
|
4. If the route ships private helpers, put them in
|
||||||
|
`app/(<group>)/<route>/_components/` (or
|
||||||
|
`app/(<group>)/_components/` for group-wide page components).
|
||||||
|
|
||||||
|
## Splitting a group
|
||||||
|
|
||||||
|
Promote a sub-cluster to its own group only when **both** are true:
|
||||||
|
|
||||||
|
- It will hold ≥2 routes that share a layout, **or** it has a clearly
|
||||||
|
distinct audience/access model (e.g. a future `(auth)/` for
|
||||||
|
signup/forgot/verify alongside login).
|
||||||
|
- Moving the routes pays for itself by replacing existing pathname
|
||||||
|
conditionals or by composing real shared chrome — not just by tidying
|
||||||
|
the folder list.
|
||||||
|
|
||||||
|
YAGNI applies: a group with one route and no shared layout is just a
|
||||||
|
folder with parens.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
description: Storybook story conventions — location, naming, titles, decorators
|
||||||
|
globs: stories/**/*.{js,jsx,ts,tsx,mdx},.storybook/**/*.{js,ts}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Where stories live
|
||||||
|
|
||||||
|
All stories live in the top-level `stories/` folder. Two layout rules:
|
||||||
|
|
||||||
|
- **Design-system components** mirror `app/components/`. A component at
|
||||||
|
`app/components/<bucket>/<Name>` gets `stories/<bucket>/<Name>.stories.js`.
|
||||||
|
- **Create-flow material** has two carve-outs:
|
||||||
|
- `stories/create-flow/` — shared create-flow pieces that aren't in
|
||||||
|
`app/components/` (e.g. composed wizard fragments).
|
||||||
|
- `stories/pages/` — integration stories that exercise an entire
|
||||||
|
`app/(app)/create/screens/<...>` screen as it appears in the wizard.
|
||||||
|
|
||||||
|
| Source | Story location |
|
||||||
|
| --------------------------------- | --------------------------------------- |
|
||||||
|
| `app/components/controls/Chip` | `stories/controls/Chip.stories.js` |
|
||||||
|
| `app/components/buttons/Button` | `stories/buttons/Button.stories.js` |
|
||||||
|
| `app/(app)/create/screens/.../FooScreen`| `stories/pages/FooPage.stories.js` |
|
||||||
|
| Shared create-flow fragment | `stories/create-flow/<Name>.stories.js` |
|
||||||
|
|
||||||
|
Do **not** colocate `*.stories.*` next to components. The Storybook config
|
||||||
|
(`.storybook/main.js`) only globs `stories/**`.
|
||||||
|
|
||||||
|
# File naming
|
||||||
|
|
||||||
|
- `<ComponentName>.stories.js` — matches 69/70 existing files.
|
||||||
|
- Use `.tsx` only when the story genuinely needs types (rare; prefer JS to
|
||||||
|
match the codebase convention).
|
||||||
|
- Variants get a suffix: `Button.visual.stories.js`,
|
||||||
|
`Footer.responsive.stories.js`.
|
||||||
|
|
||||||
|
# Default export shape (CSF2)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import MyComponent from "../../app/components/<area>/MyComponent";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "Components/<SubFolder>/MyComponent",
|
||||||
|
component: MyComponent,
|
||||||
|
parameters: {
|
||||||
|
layout: "centered",
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: "Short description of what the component is for.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
variant: {
|
||||||
|
control: { type: "select" },
|
||||||
|
options: ["filled", "outline"],
|
||||||
|
description: "The variant (Figma prop)",
|
||||||
|
},
|
||||||
|
onClick: { action: "clicked" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default = { args: { variant: "filled" } };
|
||||||
|
```
|
||||||
|
|
||||||
|
## Title hierarchy
|
||||||
|
|
||||||
|
- Design-system components → `Components/<SubFolder>/<Name>` (e.g.
|
||||||
|
`Components/Controls/Checkbox`).
|
||||||
|
- Pages → `Pages/<PageName>` (folder: `stories/pages/`).
|
||||||
|
- Create flow shared pieces → `Create Flow/<Name>`.
|
||||||
|
|
||||||
|
## `argTypes`
|
||||||
|
|
||||||
|
For every Figma enum prop (`variant`, `size`, `state`, `mode`, `palette`,
|
||||||
|
…) expose a `select` control listing the **lowercase** option set, sourced
|
||||||
|
from the matching `*_OPTIONS` const in `lib/propNormalization.ts`. See
|
||||||
|
`.cursor/rules/component-props.mdc`.
|
||||||
|
|
||||||
|
# Rely on the global preview — don't re-wrap
|
||||||
|
|
||||||
|
`.storybook/preview.js` already provides:
|
||||||
|
|
||||||
|
- `MessagesProvider` with `messages/en` → access copy via `useMessages()`
|
||||||
|
inside stories exactly like app code. Never hard-code user-facing strings.
|
||||||
|
- `app/globals.css` + `.font-inter` wrapper → design tokens and fonts are
|
||||||
|
already present.
|
||||||
|
|
||||||
|
Do **not** add your own `MessagesProvider`, font wrapper, or token setup in a
|
||||||
|
story. If you need a new global, update `preview.js`.
|
||||||
|
|
||||||
|
# Interaction tests (`play`)
|
||||||
|
|
||||||
|
Use `@storybook/test` for interaction assertions — not `@testing-library/*`
|
||||||
|
directly. This matches `Checkbox.stories.js` and stays compatible with the
|
||||||
|
Vitest portable-stories runner in `.storybook/vitest.setup.js`.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { within, userEvent, expect } from "@storybook/test";
|
||||||
|
|
||||||
|
export const Interactive = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await userEvent.click(canvas.getByRole("checkbox"));
|
||||||
|
expect(canvas.getByRole("checkbox")).toHaveAttribute("aria-checked", "true");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
# Coverage expectation
|
||||||
|
|
||||||
|
Every new component in `app/components/**` ships with a story. Screens in
|
||||||
|
`app/(app)/create/screens/**` ship with a `stories/pages/<Name>Page.stories.js`
|
||||||
|
entry. A new component without a story is considered incomplete.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
description: Tailwind-first styling for all React components
|
||||||
|
globs: app/**/*.{ts,tsx},stories/**/*.{ts,tsx}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tailwind-first styling
|
||||||
|
|
||||||
|
Tailwind v4 is the default styling layer. Reach for utility classes + design
|
||||||
|
tokens **before** anything else.
|
||||||
|
|
||||||
|
## Priority
|
||||||
|
|
||||||
|
1. **Tailwind utilities** — `className="flex items-center gap-4 p-6 rounded-lg"`.
|
||||||
|
Use arbitrary values (`w-[200px]`) and responsive variants (`sm:`, `lg:`)
|
||||||
|
as needed. Design-token CSS variables go in arbitrary values:
|
||||||
|
`bg-[var(--color-surface-default-primary)]`.
|
||||||
|
2. **`style` prop** — only for values that truly change at runtime
|
||||||
|
(`style={{ width: `${dynamicPx}px` }}`).
|
||||||
|
3. **Custom / global CSS** — last resort. Justified for keyframes, third-party
|
||||||
|
overrides, dynamic-count CSS Grid, and similar cases Tailwind can't express.
|
||||||
|
4. **CSS-in-JS / CSS Modules** — don't introduce.
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ Opaque class names bypass the design system
|
||||||
|
<div className="custom-container">
|
||||||
|
<span className="custom-text">Hello</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// ❌ Inline style for a static value
|
||||||
|
<div style={{ padding: 16, borderRadius: 8 }}>…</div>
|
||||||
|
|
||||||
|
// ✅ Tailwind + token
|
||||||
|
<div className="p-4 rounded-lg bg-[var(--color-surface-default-primary)]">…</div>
|
||||||
|
```
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
description: Test file layout & shared harnesses (vitest + Playwright)
|
||||||
|
globs: tests/**/*.{ts,tsx,js,jsx}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Testing conventions
|
||||||
|
|
||||||
|
## Runner split
|
||||||
|
|
||||||
|
- **Vitest** for unit, component, and page-level tests (`tests/components`,
|
||||||
|
`tests/pages`, `tests/unit`, `tests/contexts`, `tests/accessibility`).
|
||||||
|
Run via `npm test` or `npx vitest run`.
|
||||||
|
- **Playwright** for browser e2e and visual regression (`tests/e2e`).
|
||||||
|
Run via `npm run e2e`. Never put Playwright specs outside `tests/e2e/`.
|
||||||
|
|
||||||
|
## File layout
|
||||||
|
|
||||||
|
| Path | Use |
|
||||||
|
| --- | --- |
|
||||||
|
| `tests/components/<Name>.test.tsx` | Design-system component tests. |
|
||||||
|
| `tests/pages/<step>.test.jsx` | Page / screen integration tests. |
|
||||||
|
| `tests/unit/<fn>.test.{ts,js}` | Pure logic — utilities, reducers, hooks without DOM. |
|
||||||
|
| `tests/contexts/<Ctx>.test.tsx` | Context provider tests. |
|
||||||
|
| `tests/accessibility/` | `jest-axe` suites (unit) and `wcag-compliance.spec.ts` (e2e). |
|
||||||
|
| `tests/e2e/` | Playwright specs (user journeys, visual, performance). |
|
||||||
|
|
||||||
|
## Providers — always use `renderWithProviders`
|
||||||
|
|
||||||
|
`render` from `@testing-library/react` **skips** Messages/AuthModal/CreateFlow
|
||||||
|
providers. Import the wrapped version instead:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
renderWithProviders as render,
|
||||||
|
screen,
|
||||||
|
} from "../utils/test-utils";
|
||||||
|
```
|
||||||
|
|
||||||
|
Raw `render` is only acceptable for pure-presentational components that read
|
||||||
|
none of those contexts.
|
||||||
|
|
||||||
|
## DS component suites
|
||||||
|
|
||||||
|
Reuse `componentTestSuite` for standard DS coverage (renders,
|
||||||
|
`jest-axe` a11y, keyboard navigation, disabled/error states) instead of
|
||||||
|
rewriting each check per component:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
componentTestSuite,
|
||||||
|
type ComponentTestSuiteConfig,
|
||||||
|
} from "../utils/componentTestSuite";
|
||||||
|
|
||||||
|
const config: ComponentTestSuiteConfig<Props> = {
|
||||||
|
component: MyComponent,
|
||||||
|
name: "MyComponent",
|
||||||
|
props: baseProps,
|
||||||
|
primaryRole: "button",
|
||||||
|
testCases: { renders: true, accessibility: true, keyboardNavigation: true },
|
||||||
|
};
|
||||||
|
componentTestSuite(config);
|
||||||
|
```
|
||||||
|
|
||||||
|
Custom interaction tests live alongside the suite in the same file.
|
||||||
|
|
||||||
|
## Required imports
|
||||||
|
|
||||||
|
- `import "@testing-library/jest-dom/vitest";` — required for matcher types
|
||||||
|
(`toBeInTheDocument`, `toHaveAttribute`, etc.).
|
||||||
|
- `afterEach(() => cleanup())` in page-level test files where multiple
|
||||||
|
`render` calls run sequentially.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# 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` |
|
||||||
|
| `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/`.
|
||||||
|
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. They mirror CI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf .next # only if you moved/renamed routes or layouts
|
||||||
|
npx tsc --noEmit # type check
|
||||||
|
npx vitest run # unit + component (101 files / ~700 tests)
|
||||||
|
npx next build # production build + route manifest
|
||||||
|
```
|
||||||
|
|
||||||
|
For UI-only changes, also: `npm run storybook` and visually confirm.
|
||||||
|
For E2E-relevant changes: `npm run e2e`.
|
||||||
|
|
||||||
|
## 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`).
|
||||||
+60
-33
@@ -1,51 +1,78 @@
|
|||||||
# Contributing
|
# Contributing
|
||||||
|
|
||||||
## Backend (local)
|
## Local backend
|
||||||
|
|
||||||
1. Copy [`.env.example`](.env.example) to `.env` and set `SESSION_SECRET` (at least 16 characters).
|
1. Copy [`.env.example`](.env.example) to `.env` and set `SESSION_SECRET`
|
||||||
2. `docker compose up -d postgres mailhog` — omit `mailhog` if you only need Postgres; with `SMTP_URL` unset, the **magic-link verify URL** is printed in the dev server log (see `.env.example`).
|
(at least 16 characters).
|
||||||
3. Install dependencies: `npm ci`
|
2. `docker compose up -d postgres mailhog` — omit `mailhog` if you only
|
||||||
4. Apply migrations: `npx prisma migrate dev`
|
need Postgres. Without `SMTP_URL`, the **magic-link verify URL** is
|
||||||
5. (Optional) Seed curated rule templates: `npx prisma db seed` — requires `DATABASE_URL` and applied migrations. Safe to re-run; rows are upserted by `slug` so duplicates are not created.
|
printed in the dev server log.
|
||||||
6. Run the app: `npm run dev`
|
3. `npm ci`
|
||||||
|
4. `npx prisma migrate dev`
|
||||||
|
5. *(Optional)* `npx prisma db seed` — seeds curated rule templates.
|
||||||
|
Idempotent; rows upsert by `slug`.
|
||||||
|
6. `npm run dev`
|
||||||
|
|
||||||
Use `npx prisma studio` to inspect the database.
|
Use `npx prisma studio` to inspect the database.
|
||||||
|
|
||||||
### Prisma migrations (important)
|
### Prisma migrations
|
||||||
|
|
||||||
- **Do not edit** migration files that have **already been applied** to **staging, production, or any shared database**. Changing history breaks `migrate deploy` and other environments.
|
- **Never edit** a migration that has already been applied to staging,
|
||||||
- To fix a bad migration, add a **new** migration that corrects the schema. See [docs/backend-roadmap.md](docs/backend-roadmap.md) §8 for the full policy.
|
production, or any shared database. Add a **new** migration that
|
||||||
|
corrects the schema instead. Full policy:
|
||||||
|
[docs/guides/backend-roadmap.md](docs/guides/backend-roadmap.md) §8.
|
||||||
|
|
||||||
### API routes (overview)
|
### API routes
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
| Method | Path | Purpose |
|
||||||
| ---------- | ------------------------------ | --------------------------------------------- |
|
| --- | --- | --- |
|
||||||
| GET | `/api/health` | Liveness / DB check |
|
| GET | `/api/health` | Liveness / DB check. |
|
||||||
| GET | `/api/auth/session` | Current user or null |
|
| GET | `/api/auth/session` | Current user or null. |
|
||||||
| POST | `/api/auth/magic-link/request` | Send sign-in link email |
|
| POST | `/api/auth/magic-link/request` | Send sign-in link email. |
|
||||||
| GET | `/api/auth/magic-link/verify` | Validate token, set session cookie, redirect |
|
| GET | `/api/auth/magic-link/verify` | Validate token, set cookie, redirect. |
|
||||||
| POST | `/api/auth/logout` | Clear session |
|
| POST | `/api/auth/logout` | Clear session. |
|
||||||
| GET / PUT | `/api/drafts/me` | Load or save create-flow JSON (authenticated) |
|
| GET / PUT | `/api/drafts/me` | Load or save the create-flow draft. |
|
||||||
| GET / POST | `/api/rules` | List or publish rules (each **Finalize** creates a new published row until an update/edit-published API exists) |
|
| GET / POST | `/api/rules` | List or publish rules. |
|
||||||
| GET | `/api/templates` | List curated templates |
|
| GET | `/api/templates` | List curated templates. Optional repeatable `facet.<group>=<value>` query params re-rank results (and may include `scores` in the JSON). See [docs/guides/template-recommendation-matrix.md](docs/guides/template-recommendation-matrix.md) §9.1. |
|
||||||
|
| GET | `/api/create-flow/methods` | Facet-aware scores for custom-rule card steps: required `section` (`communication` \| `membership` \| `decisionApproaches` \| `conflictManagement`) and optional `facet.*` params (same facet groups as `/api/templates`). Returns `methods` with match metadata for re-ordering in the wizard. |
|
||||||
|
| POST / GET | `/api/web-vitals` | Ingest or read aggregated web vitals (file-based store under `.next` today; not ideal for multi-instance — see [docs/guides/backend-roadmap.md](docs/guides/backend-roadmap.md) §7). |
|
||||||
|
|
||||||
### Email magic link (sign-in)
|
### Magic-link sign-in
|
||||||
|
|
||||||
- Open **[http://localhost:3000/login](http://localhost:3000/login)** or use **Log in** in the site header (modal or full page).
|
- Visit **[/login](http://localhost:3000/login)** or use **Log in** in the
|
||||||
- Enter email and request a link. Complete sign-in by opening the link in the **same browser** you use for the app (session cookie).
|
site header.
|
||||||
- **No `SMTP_URL`:** the full **`GET /api/auth/magic-link/verify?...`** URL is printed in the **dev server terminal** — paste it into the browser address bar.
|
- Without `SMTP_URL`: copy the verify URL from the dev server terminal.
|
||||||
- **Mailhog:** with Compose Mailhog running, set `SMTP_URL=smtp://localhost:1025` and open the link from the message in the Mailhog UI ([http://localhost:8025](http://localhost:8025)).
|
- With Mailhog: set `SMTP_URL=smtp://localhost:1025` and open the message
|
||||||
|
at [http://localhost:8025](http://localhost:8025).
|
||||||
**Staging / production:** Sign-in links use the app’s origin. Ensure your reverse proxy sets **`Host`** (and TLS) so links in email match the URL users open. See [docs/backend-roadmap.md](docs/backend-roadmap.md) §9.
|
- Open the link in the **same browser** as the app (session cookie).
|
||||||
|
|
||||||
### Optional draft sync
|
### Optional draft sync
|
||||||
|
|
||||||
Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` in `.env` so **signed-in** users can persist create-flow drafts to Postgres via **Save & Exit** and so **anonymous** progress can be **uploaded after magic-link sign-in** from the save-progress exit modal. Without it, server **PUT** `/api/drafts/me` is skipped; anonymous work stays in **browser `localStorage`**, but after sign-in with a `?syncDraft=1` return URL the app still **merges that local draft into the in-memory create flow** (no server write) so you can continue and publish.
|
`NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` enables Postgres draft persistence
|
||||||
|
via `PUT /api/drafts/me` for signed-in users and post-sign-in upload of
|
||||||
|
anonymous drafts. Without it, anonymous progress stays in `localStorage`
|
||||||
|
and signed-in progress stays in memory until **Save & Exit**.
|
||||||
|
|
||||||
### Create flow URLs (custom wizard)
|
### Create flow
|
||||||
|
|
||||||
The **custom** create-rule wizard lives under **`/create/…`**. The header links to **`/create`**, which redirects to the first step. **Semantic** URL segments (e.g. `community-name`, `community-size`) match Figma intent; order is **`FLOW_STEP_ORDER`** in `app/create/utils/flowSteps.ts`, with UI from **`app/create/[screenId]/page.tsx`** and **`CREATE_FLOW_SCREEN_REGISTRY`** for Figma traceability. **Figma** stages: **Create Community** (through `review`), **Create Custom CommunityRule** (`cards`–`right-rail`), **Review and complete** (`confirm-stakeholders`–`completed`). **`/create/review-template/[slug]`** is a template **preview** only. Full tables and persistence are in **[docs/create-flow.md](docs/create-flow.md)**; engineering tracking: Linear **CR-89** / Ticket 17 in [docs/backend-linear-tickets.md](docs/backend-linear-tickets.md).
|
The custom wizard lives under `/create/…`. Step order, URLs, and Figma
|
||||||
|
stage mapping are canon in [docs/create-flow.md](docs/create-flow.md).
|
||||||
|
Engineering tracking: Linear **CR-89** (**Done**) /
|
||||||
|
[docs/guides/backend-linear-tickets.md](docs/guides/backend-linear-tickets.md)
|
||||||
|
Ticket 17.
|
||||||
|
|
||||||
## Frontend / tests
|
## Frontend & tests
|
||||||
|
|
||||||
See [docs/TESTING_GUIDE.md](docs/TESTING_GUIDE.md) and the root [README.md](README.md).
|
- Code conventions are enforced by `.cursor/rules/*.mdc` — Cursor surfaces
|
||||||
|
the relevant rule when editing matching files.
|
||||||
|
- See [docs/testing-guide.md](docs/testing-guide.md) for testing
|
||||||
|
philosophy and `.cursor/rules/testing.mdc` for layout/helpers.
|
||||||
|
|
||||||
|
## Pull request workflow
|
||||||
|
|
||||||
|
1. Branch from `main`: `git checkout -b feature/<short-name>`.
|
||||||
|
2. Make the change and add/update tests.
|
||||||
|
3. `npm test && npm run e2e` (and `npm run storybook:build` if you touched
|
||||||
|
stories).
|
||||||
|
4. Commit using a clear message (`feat:`, `fix:`, `chore:`, …).
|
||||||
|
5. Open a PR; CI runs unit, E2E, visual regression, and Lighthouse.
|
||||||
|
|||||||
@@ -1,252 +1,64 @@
|
|||||||
# Community Rule
|
# Community Rule
|
||||||
|
|
||||||
A Next.js application for community decision-making and governance documentation.
|
A Next.js application for community decision-making and governance
|
||||||
|
documentation.
|
||||||
|
|
||||||
## 📋 Requirements
|
## Requirements
|
||||||
|
|
||||||
- **Node.js**: 20.0.0 or higher (LTS recommended)
|
- Node.js **20+** (LTS)
|
||||||
- **npm**: 10.0.0 or higher
|
- npm **10+**
|
||||||
|
|
||||||
## 🚀 Getting Started
|
## Getting started
|
||||||
|
|
||||||
Run the development server:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm ci
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
Open [http://localhost:3000](http://localhost:3000).
|
||||||
|
|
||||||
Backend (Postgres, Prisma, API routes) setup is documented in [CONTRIBUTING.md](CONTRIBUTING.md).
|
Backend setup (Postgres, Prisma, magic-link auth) is documented in
|
||||||
|
[CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
## 🧪 Testing Framework
|
## Common scripts
|
||||||
|
|
||||||
This project uses a simplified, component‑first testing model:
|
| 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` | Faster inner loop — components only. |
|
||||||
|
| `npm run e2e` | Playwright E2E + visual regression. |
|
||||||
|
| `npm run storybook` | Storybook on port 6006. |
|
||||||
|
| `npm run lhci` | Lighthouse CI performance pass. |
|
||||||
|
|
||||||
- **Component tests (Vitest + RTL)** live in `tests/components/` with a single file per component.
|
## Project layout
|
||||||
- **E2E tests (Playwright)** cover critical user journeys and visual regression.
|
|
||||||
|
|
||||||
### Quick Test Commands
|
```text
|
||||||
|
app/ Next.js app router (routes, components, hooks, contexts)
|
||||||
```bash
|
lib/ Shared library code (i18n, validation, utilities)
|
||||||
# All component tests with coverage
|
messages/en/ Localized UI copy (see docs/guides/i18n-translation-workflow.md)
|
||||||
npm test
|
prisma/ Database schema, migrations, seed
|
||||||
|
public/ Static assets
|
||||||
# Component tests only (new structure)
|
stories/ Storybook stories
|
||||||
npm run test:component
|
tests/ Vitest + Playwright suites
|
||||||
|
docs/ User-facing documentation (start with docs/README.md)
|
||||||
# E2E tests only
|
.cursor/rules/ Implementation conventions enforced by Cursor
|
||||||
npm run test:e2e
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Test Coverage
|
## Tech stack
|
||||||
|
|
||||||
- ✅ **428 Unit Tests** (94.88% coverage - exceeds 85% target)
|
Next.js 16 · React 19 · TypeScript · Tailwind CSS 4 · Prisma · Vitest ·
|
||||||
- ✅ **92 E2E Tests** across 4 browsers
|
Playwright · Storybook 10 · Lighthouse CI.
|
||||||
- ✅ **23 Visual Regression Tests** per browser
|
|
||||||
- ✅ **Performance Budgets** with Lighthouse CI
|
|
||||||
- ✅ **WCAG 2.1 AA Compliance** with automated testing
|
|
||||||
- ✅ **Bundle Analysis** with automated monitoring
|
|
||||||
- ✅ **Web Vitals Tracking** with real-time metrics
|
|
||||||
|
|
||||||
### CI/CD Pipeline
|
## Documentation
|
||||||
|
|
||||||
- **Gitea Actions** with 7 parallel jobs
|
- [docs/README.md](docs/README.md) — index of guides and rules.
|
||||||
- **Cross-browser testing** (Chromium, Firefox, WebKit, Mobile)
|
- [docs/create-flow.md](docs/create-flow.md) — create-rule wizard canon.
|
||||||
- **Visual regression testing**
|
- [docs/testing-guide.md](docs/testing-guide.md) — testing philosophy.
|
||||||
- **Performance monitoring**
|
- [CONTRIBUTING.md](CONTRIBUTING.md) — local backend, API routes, PR
|
||||||
- **Code coverage reporting**
|
workflow.
|
||||||
|
|
||||||
📖 **For detailed testing documentation, see `docs/TESTING_GUIDE.md` and [docs/README.md](docs/README.md)**
|
## License
|
||||||
|
|
||||||
## ⚡ Performance Optimizations
|
[MIT](LICENSE).
|
||||||
|
|
||||||
This project includes comprehensive performance optimizations for sub-2-second load times:
|
|
||||||
|
|
||||||
### Frontend Optimizations
|
|
||||||
|
|
||||||
- **✅ Code Splitting**: Dynamic imports for non-critical components
|
|
||||||
- **✅ React.memo**: Applied to all 30+ components to prevent unnecessary re-renders
|
|
||||||
- **✅ Image Optimization**: Enhanced `next/image` with lazy loading and blur placeholders
|
|
||||||
- **✅ Font Optimization**: Preloading and fallbacks for all fonts
|
|
||||||
- **✅ Bundle Analysis**: Real-time monitoring with performance budgets
|
|
||||||
- **✅ Error Boundaries**: Comprehensive error handling
|
|
||||||
|
|
||||||
### Performance Monitoring
|
|
||||||
|
|
||||||
Performance testing is handled by:
|
|
||||||
|
|
||||||
- **Lighthouse CI** (`.lighthouserc.json`): Comprehensive performance testing in CI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run lhci # Run Lighthouse CI
|
|
||||||
npm run lhci:mobile # Mobile preset
|
|
||||||
npm run lhci:desktop # Desktop preset
|
|
||||||
npm run performance:budget # With performance budgets
|
|
||||||
```
|
|
||||||
|
|
||||||
- **E2E Performance Tests** (`tests/e2e/performance.spec.ts`): Essential performance checks
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run e2e:performance # Run E2E performance tests
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Bundle Analysis**: Analyze bundle sizes
|
|
||||||
```bash
|
|
||||||
npm run bundle:analyze # Analyze bundle sizes
|
|
||||||
```
|
|
||||||
|
|
||||||
### Performance Targets
|
|
||||||
|
|
||||||
- **Bundle Size**: <250KB gzipped (currently 101KB) ✅
|
|
||||||
- **Core Web Vitals**: All metrics in "Good" range ✅
|
|
||||||
- **Lighthouse Score**: >90 on all critical pages ✅
|
|
||||||
- **Load Time**: <2 seconds on 3G connections ✅
|
|
||||||
|
|
||||||
## 📚 Storybook Development
|
|
||||||
|
|
||||||
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 all component tests with coverage
|
|
||||||
- `npm run test:component` - Run tests in `tests/components/` only
|
|
||||||
- `npm run test:watch` - Run tests in watch mode
|
|
||||||
- `npm run test:ui` - Run tests with UI
|
|
||||||
- `npm run test:e2e` - Run E2E tests only
|
|
||||||
- `npm run e2e` - Alias for Playwright 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
|
|
||||||
|
|
||||||
### 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
|
|
||||||
│ ├── hooks/ # Custom React hooks
|
|
||||||
│ ├── layout.tsx # Root layout
|
|
||||||
│ └── page.tsx # Homepage
|
|
||||||
├── config/ # Project-specific configuration
|
|
||||||
│ ├── gitea-runner.yaml # Gitea runner configuration
|
|
||||||
│ └── runner-config.yaml # Runner configuration
|
|
||||||
├── docs/ # Documentation
|
|
||||||
│ ├── README.md # Documentation index
|
|
||||||
│ ├── TESTING_GUIDE.md # Testing guide
|
|
||||||
│ ├── CUSTOM_HOOKS.md # Custom hooks documentation
|
|
||||||
│ └── guides/ # Guides
|
|
||||||
│ └── content-creation.md # Content creation guide
|
|
||||||
├── scripts/ # Utility scripts
|
|
||||||
│ ├── start-runner.sh # Start Gitea runner
|
|
||||||
│ ├── status-runner.sh # Check runner status
|
|
||||||
│ └── stop-runner.sh # Stop Gitea runner
|
|
||||||
├── tests/ # Test files
|
|
||||||
│ ├── components/ # Component tests (Vitest + RTL)
|
|
||||||
│ ├── pages/ # Page-level tests
|
|
||||||
│ ├── e2e/ # E2E tests (Playwright)
|
|
||||||
│ ├── utils/ # Test utilities (componentTestSuite, etc.)
|
|
||||||
│ ├── msw/ # MSW server setup
|
|
||||||
│ └── accessibility/ # E2E accessibility checks
|
|
||||||
├── .storybook/ # Storybook configuration
|
|
||||||
├── .gitea/ # Gitea Actions workflows
|
|
||||||
│ └── workflows/
|
|
||||||
│ └── ci.yaml # CI/CD pipeline
|
|
||||||
└── public/ # Static assets
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Technology Stack
|
|
||||||
|
|
||||||
- **Framework**: Next.js 16 + React 19
|
|
||||||
- **Runtime**: Node.js 20+ (LTS)
|
|
||||||
- **Styling**: Tailwind CSS 4
|
|
||||||
- **Testing**: Vitest + Playwright + Lighthouse CI
|
|
||||||
- **Documentation**: Storybook 10
|
|
||||||
- **CI/CD**: Gitea Actions
|
|
||||||
- **Hosting**: Gitea (Git hosting)
|
|
||||||
|
|
||||||
## 📖 Documentation
|
|
||||||
|
|
||||||
- **[Documentation Index](docs/README.md)** - Complete documentation guide
|
|
||||||
- **[Testing Guide](docs/TESTING_GUIDE.md)** - Testing strategy, component tests, E2E tests, and accessibility
|
|
||||||
- **[Custom Hooks](docs/CUSTOM_HOOKS.md)** - Documentation for custom React hooks
|
|
||||||
- **[Content Creation Guide](docs/guides/content-creation.md)** - Guide for creating blog content
|
|
||||||
- **[Storybook](http://localhost:6006)** - Component documentation (local)
|
|
||||||
|
|
||||||
## 🤝 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_GUIDE.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.
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// Operator/admin dashboards (e.g. `/monitor`) intentionally render without the
|
||||||
|
// public marketing footer. Auth/access is enforced upstream.
|
||||||
|
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||||
|
return <main className="flex-1">{children}</main>;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import WebVitalsDashboard from "../../components/WebVitalsDashboard";
|
import WebVitalsDashboard from "../../components/sections/WebVitalsDashboard";
|
||||||
import TopNav from "../../components/navigation/TopNav";
|
import TopNav from "../../components/navigation/TopNav";
|
||||||
import Footer from "../../components/navigation/Footer";
|
import Footer from "../../components/navigation/Footer";
|
||||||
|
|
||||||
|
|||||||
+251
-251
@@ -11,32 +11,43 @@ import { usePathname, useRouter } from "next/navigation";
|
|||||||
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
|
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
|
||||||
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
|
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
|
||||||
import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
|
import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
|
||||||
import CreateFlowTopNav from "../components/utility/CreateFlowTopNav";
|
import { useCreateFlowFinalize } from "./hooks/useCreateFlowFinalize";
|
||||||
|
import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions";
|
||||||
|
import CreateFlowTopNav from "../../components/utility/CreateFlowTopNav";
|
||||||
import { getNextStep, getStepIndex } from "./utils/flowSteps";
|
import { getNextStep, getStepIndex } from "./utils/flowSteps";
|
||||||
import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress";
|
import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress";
|
||||||
import { createFlowStepUsesCenteredTextLayout } from "./utils/createFlowScreenRegistry";
|
import {
|
||||||
import CreateFlowFooter from "../components/utility/CreateFlowFooter";
|
createFlowStepUsesCenteredTextLayout,
|
||||||
import Button from "../components/buttons/Button";
|
createFlowStepUsesCardLayout,
|
||||||
import { buildPublishPayload } from "../../lib/create/buildPublishPayload";
|
} from "./utils/createFlowScreenRegistry";
|
||||||
import { isValidCreateFlowSaveEmail } from "../../lib/create/isValidCreateFlowSaveEmail";
|
import CreateFlowFooter from "../../components/utility/CreateFlowFooter";
|
||||||
|
import Button from "../../components/buttons/Button";
|
||||||
|
import { isValidCreateFlowSaveEmail } from "../../../lib/create/isValidCreateFlowSaveEmail";
|
||||||
import {
|
import {
|
||||||
fetchAuthSession,
|
fetchAuthSession,
|
||||||
publishRule,
|
|
||||||
requestMagicLink,
|
requestMagicLink,
|
||||||
} from "../../lib/create/api";
|
} from "../../../lib/create/api";
|
||||||
import { safeInternalPath } from "../../lib/safeInternalPath";
|
import { safeInternalPath } from "../../../lib/safeInternalPath";
|
||||||
import { setTransferPendingFlag } from "./utils/anonymousDraftStorage";
|
|
||||||
import { writeLastPublishedRule } from "../../lib/create/lastPublishedRule";
|
|
||||||
import {
|
import {
|
||||||
fetchTemplateBySlug,
|
clearAnonymousCreateFlowStorage,
|
||||||
type RuleTemplateDto,
|
setTransferPendingFlag,
|
||||||
} from "../../lib/create/fetchTemplates";
|
} from "./utils/anonymousDraftStorage";
|
||||||
import messages from "../../messages/en/index";
|
import { deleteServerDraft } from "../../../lib/create/api";
|
||||||
import { useAuthModal } from "../contexts/AuthModalContext";
|
import messages from "../../../messages/en/index";
|
||||||
import { useMessages, useTranslation } from "../contexts/MessagesContext";
|
import {
|
||||||
|
CREATE_FLOW_FOOTER_BUTTON_CLASS,
|
||||||
|
CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS,
|
||||||
|
} from "./utils/createFlowFooterClassNames";
|
||||||
|
import {
|
||||||
|
CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP,
|
||||||
|
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 { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
|
||||||
import { SignedInDraftHydration } from "./SignedInDraftHydration";
|
import { SignedInDraftHydration } from "./SignedInDraftHydration";
|
||||||
import Alert from "../components/modals/Alert";
|
import Alert from "../../components/modals/Alert";
|
||||||
import {
|
import {
|
||||||
CreateFlowDraftSaveBannerProvider,
|
CreateFlowDraftSaveBannerProvider,
|
||||||
useCreateFlowDraftSaveBanner,
|
useCreateFlowDraftSaveBanner,
|
||||||
@@ -61,17 +72,23 @@ function CreateFlowSessionShell({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sessionResolved = sessionUser !== undefined;
|
const sessionResolved = sessionUser !== undefined;
|
||||||
const enableAnonymousPersistence = sessionResolved && sessionUser === null;
|
// 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 (
|
return (
|
||||||
<CreateFlowProvider enableAnonymousPersistence={enableAnonymousPersistence}>
|
<CreateFlowProvider enableLocalDraftMirroring={enableLocalDraftMirroring}>
|
||||||
<CreateFlowDraftSaveBannerProvider>
|
<CreateFlowDraftSaveBannerProvider>
|
||||||
<CreateFlowLayoutContent
|
<Suspense fallback={null}>
|
||||||
sessionUser={sessionUser}
|
<CreateFlowLayoutContent
|
||||||
sessionResolved={sessionResolved}
|
sessionUser={sessionUser}
|
||||||
>
|
sessionResolved={sessionResolved}
|
||||||
{children}
|
>
|
||||||
</CreateFlowLayoutContent>
|
{children}
|
||||||
|
</CreateFlowLayoutContent>
|
||||||
|
</Suspense>
|
||||||
</CreateFlowDraftSaveBannerProvider>
|
</CreateFlowDraftSaveBannerProvider>
|
||||||
</CreateFlowProvider>
|
</CreateFlowProvider>
|
||||||
);
|
);
|
||||||
@@ -88,29 +105,26 @@ function CreateFlowLayoutContent({
|
|||||||
}) {
|
}) {
|
||||||
const { create } = useMessages();
|
const { create } = useMessages();
|
||||||
const footer = create.footer;
|
const footer = create.footer;
|
||||||
const communitySaveMessages = create.communitySave;
|
const communitySaveMessages = create.community.communitySave;
|
||||||
const tLogin = useTranslation("pages.login");
|
const tLogin = useTranslation("pages.login");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { openLogin } = useAuthModal();
|
const { openLogin } = useAuthModal();
|
||||||
|
const skipCommunitySave = sessionResolved && Boolean(sessionUser);
|
||||||
const {
|
const {
|
||||||
currentStep,
|
currentStep,
|
||||||
nextStep,
|
nextStep,
|
||||||
previousStep,
|
previousStep,
|
||||||
goToNextStep,
|
goToNextStep,
|
||||||
goToPreviousStep,
|
goToPreviousStep,
|
||||||
} = useCreateFlowNavigation();
|
templateReviewFooterBackToCreateReview,
|
||||||
const { state, clearState, updateState } = useCreateFlow();
|
} = useCreateFlowNavigation(
|
||||||
|
skipCommunitySave ? { skipCommunitySave: true } : undefined,
|
||||||
|
);
|
||||||
|
const { state, clearState, updateState, resetCustomRuleSelections } =
|
||||||
|
useCreateFlow();
|
||||||
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
|
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
|
||||||
useCreateFlowDraftSaveBanner();
|
useCreateFlowDraftSaveBanner();
|
||||||
const [publishBannerMessage, setPublishBannerMessage] = useState<
|
|
||||||
string | null
|
|
||||||
>(null);
|
|
||||||
const [isPublishing, setIsPublishing] = useState(false);
|
|
||||||
const [templateReviewApplyError, setTemplateReviewApplyError] = useState<
|
|
||||||
string | null
|
|
||||||
>(null);
|
|
||||||
const [isApplyingTemplate, setIsApplyingTemplate] = useState(false);
|
|
||||||
const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] =
|
const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [communitySaveMagicLinkError, setCommunitySaveMagicLinkError] = useState<
|
const [communitySaveMagicLinkError, setCommunitySaveMagicLinkError] = useState<
|
||||||
@@ -119,93 +133,28 @@ function CreateFlowLayoutContent({
|
|||||||
const [communitySaveMagicLinkSuccess, setCommunitySaveMagicLinkSuccess] =
|
const [communitySaveMagicLinkSuccess, setCommunitySaveMagicLinkSuccess] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
||||||
const templateReviewMatch = pathname?.match(
|
const {
|
||||||
/\/create\/review-template\/([^/?#]+)/,
|
publishBannerMessage,
|
||||||
);
|
setPublishBannerMessage,
|
||||||
const templateReviewSlug = templateReviewMatch?.[1]
|
isPublishing,
|
||||||
? decodeURIComponent(templateReviewMatch[1])
|
finalize: handleFinalize,
|
||||||
: null;
|
} = useCreateFlowFinalize({ state, router, openLogin });
|
||||||
/** Match anywhere in path so locale/basePath variants still get template footer + layout. */
|
|
||||||
const isTemplateReviewRoute = Boolean(
|
|
||||||
pathname?.includes("/create/review-template/"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFinalize = useCallback(async () => {
|
const {
|
||||||
setPublishBannerMessage(null);
|
isTemplateReviewRoute,
|
||||||
const payloadResult = buildPublishPayload(state);
|
templateReviewSlug,
|
||||||
if (payloadResult.ok === false) {
|
isApplyingTemplate,
|
||||||
setPublishBannerMessage(
|
templateReviewApplyError,
|
||||||
payloadResult.error === "missingCommunityName"
|
setTemplateReviewApplyError,
|
||||||
? messages.create.publish.missingCommunityName
|
handleCustomize: handleCustomizeTemplate,
|
||||||
: payloadResult.error,
|
handleUseWithoutChanges: handleUseTemplateWithoutChanges,
|
||||||
);
|
} = useTemplateReviewActions({
|
||||||
return;
|
pathname,
|
||||||
}
|
state,
|
||||||
const { title, summary, document: ruleDocument } = payloadResult;
|
updateState,
|
||||||
setIsPublishing(true);
|
resetCustomRuleSelections,
|
||||||
const publishResult = await publishRule({
|
router,
|
||||||
title,
|
});
|
||||||
summary,
|
|
||||||
document: ruleDocument,
|
|
||||||
});
|
|
||||||
setIsPublishing(false);
|
|
||||||
if (publishResult.ok === true) {
|
|
||||||
writeLastPublishedRule({
|
|
||||||
id: publishResult.id,
|
|
||||||
title,
|
|
||||||
summary: summary ?? null,
|
|
||||||
document: ruleDocument,
|
|
||||||
});
|
|
||||||
router.push("/create/completed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (publishResult.status === 401) {
|
|
||||||
openLogin({
|
|
||||||
variant: "default",
|
|
||||||
nextPath: "/create/final-review?syncDraft=1",
|
|
||||||
backdropVariant: "blurredYellow",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPublishBannerMessage(
|
|
||||||
publishResult.error.trim() !== ""
|
|
||||||
? publishResult.error
|
|
||||||
: messages.create.publish.genericPublishFailed,
|
|
||||||
);
|
|
||||||
}, [state, router, openLogin]);
|
|
||||||
|
|
||||||
const handleUseTemplateWithoutChanges = useCallback(async () => {
|
|
||||||
if (!templateReviewSlug) return;
|
|
||||||
setTemplateReviewApplyError(null);
|
|
||||||
setIsApplyingTemplate(true);
|
|
||||||
const result = await fetchTemplateBySlug(templateReviewSlug);
|
|
||||||
setIsApplyingTemplate(false);
|
|
||||||
if (result === null) {
|
|
||||||
setTemplateReviewApplyError(messages.create.templateReview.errors.notFound);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if ("error" in result) {
|
|
||||||
setTemplateReviewApplyError(result.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const template: RuleTemplateDto = result;
|
|
||||||
const doc = template.body;
|
|
||||||
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
|
||||||
setTemplateReviewApplyError(messages.create.templateReview.errors.applyFailed);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const summaryRaw =
|
|
||||||
typeof template.description === "string"
|
|
||||||
? template.description.trim()
|
|
||||||
: "";
|
|
||||||
writeLastPublishedRule({
|
|
||||||
id: `template:${template.slug}`,
|
|
||||||
title: template.title,
|
|
||||||
summary: summaryRaw.length > 0 ? summaryRaw : null,
|
|
||||||
document: doc as Record<string, unknown>,
|
|
||||||
});
|
|
||||||
router.push("/create/completed");
|
|
||||||
}, [router, templateReviewSlug]);
|
|
||||||
|
|
||||||
const runAuthenticatedExit = useCreateFlowExit({
|
const runAuthenticatedExit = useCreateFlowExit({
|
||||||
state,
|
state,
|
||||||
@@ -220,6 +169,20 @@ function CreateFlowLayoutContent({
|
|||||||
const saveDraft = opts?.saveDraft ?? false;
|
const saveDraft = opts?.saveDraft ?? false;
|
||||||
if (!sessionResolved) return;
|
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") {
|
||||||
|
clearState();
|
||||||
|
clearAnonymousCreateFlowStorage();
|
||||||
|
if (sessionUser) {
|
||||||
|
void deleteServerDraft();
|
||||||
|
}
|
||||||
|
router.push("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (sessionUser === null) {
|
if (sessionUser === null) {
|
||||||
if (saveDraft) return;
|
if (saveDraft) return;
|
||||||
const returnToTemplateReview =
|
const returnToTemplateReview =
|
||||||
@@ -240,6 +203,16 @@ function CreateFlowLayoutContent({
|
|||||||
await runAuthenticatedExit(opts);
|
await runAuthenticatedExit(opts);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
sessionResolved &&
|
||||||
|
sessionUser &&
|
||||||
|
currentStep === "community-save"
|
||||||
|
) {
|
||||||
|
router.replace("/create/review");
|
||||||
|
}
|
||||||
|
}, [sessionResolved, sessionUser, currentStep, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentStep !== "community-save") {
|
if (currentStep !== "community-save") {
|
||||||
setCommunitySaveMagicLinkError(null);
|
setCommunitySaveMagicLinkError(null);
|
||||||
@@ -286,17 +259,23 @@ function CreateFlowLayoutContent({
|
|||||||
}, [state.communitySaveEmail, tLogin, updateState]);
|
}, [state.communitySaveEmail, tLogin, updateState]);
|
||||||
|
|
||||||
const isCompletedStep = currentStep === "completed";
|
const isCompletedStep = currentStep === "completed";
|
||||||
const isRightRailStep = currentStep === "right-rail";
|
const isRightRailStep = currentStep === "decision-approaches";
|
||||||
const isFinalReviewStep = currentStep === "final-review";
|
const isFinalReviewStep = currentStep === "final-review";
|
||||||
const isCardsStep = currentStep === "cards";
|
const isCardLayoutStep = createFlowStepUsesCardLayout(currentStep);
|
||||||
|
/** Two-column select / right-rail: below `lg` main scrolls; at `lg+` only the right column scrolls. */
|
||||||
|
const isSelectSplitScrollStep =
|
||||||
|
currentStep === "community-size" ||
|
||||||
|
currentStep === "community-structure" ||
|
||||||
|
currentStep === "core-values" ||
|
||||||
|
currentStep === "decision-approaches";
|
||||||
const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
|
const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
|
||||||
|
|
||||||
/** At `md+`, main cross-axis: center by default; exceptions stay top-aligned (see product spec). */
|
/** At `md+`, main cross-axis: center by default; exceptions stay top-aligned (see product spec). */
|
||||||
const mainContentClass = isCompletedStep
|
const mainContentClass = isCompletedStep
|
||||||
? "items-stretch overflow-y-auto md:overflow-hidden"
|
? "items-stretch overflow-y-auto md:overflow-hidden"
|
||||||
: isRightRailStep
|
: isSelectSplitScrollStep
|
||||||
? "items-stretch overflow-hidden"
|
? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden"
|
||||||
: isFinalReviewStep || isCardsStep || isTemplateReviewRoute
|
: isFinalReviewStep || isCardLayoutStep || isTemplateReviewRoute
|
||||||
? "items-start justify-center overflow-y-auto"
|
? "items-start justify-center overflow-y-auto"
|
||||||
: "items-start justify-center overflow-y-auto md:items-center";
|
: "items-start justify-center overflow-y-auto md:items-center";
|
||||||
|
|
||||||
@@ -305,10 +284,9 @@ function CreateFlowLayoutContent({
|
|||||||
isTextStep && !isCompletedStep && !isRightRailStep
|
isTextStep && !isCompletedStep && !isRightRailStep
|
||||||
? "max-md:justify-center"
|
? "max-md:justify-center"
|
||||||
: "max-md:justify-start";
|
: "max-md:justify-start";
|
||||||
const mainMaxMdCross =
|
const mainMaxMdCross = isCompletedStep
|
||||||
isCompletedStep || isRightRailStep
|
? "max-md:flex-col max-md:items-stretch"
|
||||||
? "max-md:flex-col max-md:items-stretch"
|
: "max-md:flex-col max-md:items-center";
|
||||||
: "max-md:flex-col max-md:items-center";
|
|
||||||
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
|
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
|
||||||
const saveDraftOnExit =
|
const saveDraftOnExit =
|
||||||
Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX;
|
Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX;
|
||||||
@@ -317,83 +295,101 @@ function CreateFlowLayoutContent({
|
|||||||
currentStep,
|
currentStep,
|
||||||
);
|
);
|
||||||
|
|
||||||
const footerPrimaryButtonClass =
|
/**
|
||||||
"md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]";
|
* 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;
|
||||||
|
|
||||||
const hasTopOverlays =
|
/**
|
||||||
Boolean(draftSaveBannerMessage) ||
|
* Top banner stack rendered above the main column when any of the
|
||||||
Boolean(publishBannerMessage) ||
|
* shell-level statuses are active. Each entry maps to one `<Alert>`;
|
||||||
Boolean(templateReviewApplyError) ||
|
* we filter out empty messages so the wrapper only mounts when at
|
||||||
Boolean(communitySaveMagicLinkError) ||
|
* least one banner is actually showing. Order here is the visual
|
||||||
Boolean(communitySaveMagicLinkSuccess);
|
* stacking order (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,
|
||||||
|
].filter((b): b is NonNullable<typeof b> => b !== null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-screen min-h-0 flex-col overflow-hidden bg-black">
|
<div className="relative flex h-screen min-h-0 flex-col overflow-hidden bg-black">
|
||||||
{hasTopOverlays ? (
|
{topBanners.length > 0 ? (
|
||||||
<div
|
<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)]"
|
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"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
{draftSaveBannerMessage ? (
|
{topBanners.map((b) => (
|
||||||
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
|
<div
|
||||||
|
key={b.key}
|
||||||
|
className="pointer-events-auto mx-auto w-full max-w-[960px]"
|
||||||
|
>
|
||||||
<Alert
|
<Alert
|
||||||
type="banner"
|
type="banner"
|
||||||
status="danger"
|
status={b.status}
|
||||||
title={messages.create.topNav.draftSaveBannerTitle}
|
title={b.title}
|
||||||
description={draftSaveBannerMessage}
|
description={b.description}
|
||||||
onClose={() => setDraftSaveBannerMessage(null)}
|
onClose={b.onClose}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
))}
|
||||||
{publishBannerMessage ? (
|
|
||||||
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
|
|
||||||
<Alert
|
|
||||||
type="banner"
|
|
||||||
status="danger"
|
|
||||||
title={messages.create.publish.finalizeBannerTitle}
|
|
||||||
description={publishBannerMessage}
|
|
||||||
onClose={() => setPublishBannerMessage(null)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{templateReviewApplyError ? (
|
|
||||||
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
|
|
||||||
<Alert
|
|
||||||
type="banner"
|
|
||||||
status="danger"
|
|
||||||
title={messages.create.templateReview.errors.applyFailed}
|
|
||||||
description={templateReviewApplyError}
|
|
||||||
onClose={() => setTemplateReviewApplyError(null)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{communitySaveMagicLinkError ? (
|
|
||||||
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
|
|
||||||
<Alert
|
|
||||||
type="banner"
|
|
||||||
status="danger"
|
|
||||||
title={communitySaveMessages.magicLinkErrorTitle}
|
|
||||||
description={communitySaveMagicLinkError}
|
|
||||||
onClose={() => setCommunitySaveMagicLinkError(null)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{communitySaveMagicLinkSuccess ? (
|
|
||||||
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
|
|
||||||
<Alert
|
|
||||||
type="banner"
|
|
||||||
status="positive"
|
|
||||||
title={communitySaveMessages.magicLinkSuccessTitle}
|
|
||||||
description={communitySaveMessages.magicLinkSuccessDescription}
|
|
||||||
onClose={() => setCommunitySaveMagicLinkSuccess(false)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
@@ -440,7 +436,7 @@ function CreateFlowLayoutContent({
|
|||||||
palette="default"
|
palette="default"
|
||||||
size="xsmall"
|
size="xsmall"
|
||||||
disabled={isApplyingTemplate}
|
disabled={isApplyingTemplate}
|
||||||
className="md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)] !text-white"
|
className={CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS}
|
||||||
onClick={() => void handleUseTemplateWithoutChanges()}
|
onClick={() => void handleUseTemplateWithoutChanges()}
|
||||||
>
|
>
|
||||||
{messages.create.templateReview.footer.useWithoutChanges}
|
{messages.create.templateReview.footer.useWithoutChanges}
|
||||||
@@ -450,48 +446,29 @@ function CreateFlowLayoutContent({
|
|||||||
palette="default"
|
palette="default"
|
||||||
size="xsmall"
|
size="xsmall"
|
||||||
disabled={isApplyingTemplate}
|
disabled={isApplyingTemplate}
|
||||||
title={
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
messages.create.templateReview.footer.customizeAriaHint
|
onClick={() => void handleCustomizeTemplate()}
|
||||||
}
|
|
||||||
className="md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]"
|
|
||||||
onClick={() => {
|
|
||||||
if (!templateReviewSlug) return;
|
|
||||||
// Preserve template slug for a future customize / prefill ticket (informational does not read it yet).
|
|
||||||
router.push(
|
|
||||||
`/create/informational?template=${encodeURIComponent(templateReviewSlug)}`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{messages.create.templateReview.footer.customize}
|
{messages.create.templateReview.footer.customize}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : currentStep === "community-name" && nextStep ? (
|
) : currentStep === "community-name" && nextStep ? (
|
||||||
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
|
<Button
|
||||||
<Button
|
buttonType="filled"
|
||||||
buttonType="outline"
|
palette="default"
|
||||||
palette="inverse"
|
size="xsmall"
|
||||||
size="xsmall"
|
disabled={
|
||||||
disabled={isPublishing}
|
isPublishing ||
|
||||||
className={footerPrimaryButtonClass}
|
typeof state.title !== "string" ||
|
||||||
onClick={() => {
|
state.title.trim().length === 0
|
||||||
goToNextStep();
|
}
|
||||||
}}
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
>
|
onClick={() => {
|
||||||
{footer.next}
|
goToNextStep();
|
||||||
</Button>
|
}}
|
||||||
<Button
|
>
|
||||||
buttonType="filled"
|
{footer.confirmName}
|
||||||
palette="default"
|
</Button>
|
||||||
size="xsmall"
|
|
||||||
disabled={isPublishing}
|
|
||||||
className={footerPrimaryButtonClass}
|
|
||||||
onClick={() => {
|
|
||||||
goToNextStep();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{footer.confirmName}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : currentStep === "community-save" && nextStep ? (
|
) : currentStep === "community-save" && nextStep ? (
|
||||||
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
|
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
|
||||||
<Button
|
<Button
|
||||||
@@ -499,7 +476,7 @@ function CreateFlowLayoutContent({
|
|||||||
palette="default"
|
palette="default"
|
||||||
size="xsmall"
|
size="xsmall"
|
||||||
disabled={isPublishing}
|
disabled={isPublishing}
|
||||||
className={footerPrimaryButtonClass}
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
goToNextStep();
|
goToNextStep();
|
||||||
}}
|
}}
|
||||||
@@ -516,7 +493,7 @@ function CreateFlowLayoutContent({
|
|||||||
communitySaveMagicLinkSuccess ||
|
communitySaveMagicLinkSuccess ||
|
||||||
!isValidCreateFlowSaveEmail(state.communitySaveEmail)
|
!isValidCreateFlowSaveEmail(state.communitySaveEmail)
|
||||||
}
|
}
|
||||||
className={footerPrimaryButtonClass}
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void handleCommunitySaveMagicLinkSubmit();
|
void handleCommunitySaveMagicLinkSubmit();
|
||||||
}}
|
}}
|
||||||
@@ -533,8 +510,11 @@ function CreateFlowLayoutContent({
|
|||||||
palette="default"
|
palette="default"
|
||||||
size="xsmall"
|
size="xsmall"
|
||||||
disabled={isPublishing}
|
disabled={isPublishing}
|
||||||
className={footerPrimaryButtonClass}
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
// Scrub any prior template-customize prefill so entering
|
||||||
|
// the custom-rule stage from review is always a clean slate.
|
||||||
|
resetCustomRuleSelections();
|
||||||
goToNextStep();
|
goToNextStep();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -545,21 +525,43 @@ function CreateFlowLayoutContent({
|
|||||||
palette="default"
|
palette="default"
|
||||||
size="xsmall"
|
size="xsmall"
|
||||||
disabled={isPublishing}
|
disabled={isPublishing}
|
||||||
className={footerPrimaryButtonClass}
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push("/templates");
|
// `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?fromFlow=1");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{footer.createFromTemplate}
|
{footer.createFromTemplate}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
) : customRuleConfirmFooter && nextStep ? (
|
||||||
|
<Button
|
||||||
|
buttonType="filled"
|
||||||
|
palette="default"
|
||||||
|
size="xsmall"
|
||||||
|
disabled={
|
||||||
|
isPublishing ||
|
||||||
|
customRuleConfirmFooter.selectionIds(state).length === 0
|
||||||
|
}
|
||||||
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
|
onClick={() => {
|
||||||
|
goToNextStep();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{footer[customRuleConfirmFooter.footerMessageKey]}
|
||||||
|
</Button>
|
||||||
) : nextStep ? (
|
) : nextStep ? (
|
||||||
<Button
|
<Button
|
||||||
buttonType="filled"
|
buttonType="filled"
|
||||||
palette="default"
|
palette="default"
|
||||||
size="xsmall"
|
size="xsmall"
|
||||||
disabled={isPublishing}
|
disabled={isPublishing}
|
||||||
className={footerPrimaryButtonClass}
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (currentStep === "final-review") {
|
if (currentStep === "final-review") {
|
||||||
void handleFinalize();
|
void handleFinalize();
|
||||||
@@ -570,23 +572,21 @@ function CreateFlowLayoutContent({
|
|||||||
>
|
>
|
||||||
{currentStep === "final-review"
|
{currentStep === "final-review"
|
||||||
? isPublishing
|
? isPublishing
|
||||||
? messages.create.publish.finalizeButtonPublishing
|
? messages.create.reviewAndComplete.publish
|
||||||
|
.finalizeButtonPublishing
|
||||||
: footer.finalizeCommunityRule
|
: footer.finalizeCommunityRule
|
||||||
: currentStep === "confirm-stakeholders"
|
: getDefaultFooterLabel(currentStep, footer)}
|
||||||
? footer.confirmStakeholders
|
|
||||||
: currentStep === "community-context"
|
|
||||||
? footer.confirmDescription
|
|
||||||
: currentStep === "community-structure"
|
|
||||||
? footer.confirmDetails
|
|
||||||
: currentStep === "community-size"
|
|
||||||
? footer.confirmMembers
|
|
||||||
: footer.next}
|
|
||||||
</Button>
|
</Button>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
onBackClick={
|
onBackClick={
|
||||||
isTemplateReviewRoute
|
isTemplateReviewRoute
|
||||||
? () => router.push("/")
|
? () =>
|
||||||
|
router.push(
|
||||||
|
templateReviewFooterBackToCreateReview
|
||||||
|
? "/create/review"
|
||||||
|
: "/",
|
||||||
|
)
|
||||||
: previousStep
|
: previousStep
|
||||||
? goToPreviousStep
|
? goToPreviousStep
|
||||||
: undefined
|
: undefined
|
||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
} from "./utils/anonymousDraftStorage";
|
} from "./utils/anonymousDraftStorage";
|
||||||
import { useCreateFlow } from "./context/CreateFlowContext";
|
import { useCreateFlow } from "./context/CreateFlowContext";
|
||||||
import { parseCreateFlowScreenFromPathname } from "./utils/flowSteps";
|
import { parseCreateFlowScreenFromPathname } from "./utils/flowSteps";
|
||||||
import { saveDraftToServer } from "../../lib/create/api";
|
import { saveDraftToServer } from "../../../lib/create/api";
|
||||||
import messages from "../../messages/en/index";
|
import messages from "../../../messages/en/index";
|
||||||
|
|
||||||
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
||||||
|
|
||||||
+23
-38
@@ -3,24 +3,30 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import type { CreateFlowState } from "./types";
|
import type { CreateFlowState } from "./types";
|
||||||
import { createFlowStateHasKeys } from "../../lib/create/draftHydrationUtils";
|
import { createFlowStateHasKeys } from "../../../lib/create/draftHydrationUtils";
|
||||||
import {
|
import {
|
||||||
clearAnonymousCreateFlowStorage,
|
|
||||||
hasTransferPendingFlag,
|
hasTransferPendingFlag,
|
||||||
readAnonymousCreateFlowState,
|
readAnonymousCreateFlowState,
|
||||||
} from "./utils/anonymousDraftStorage";
|
} from "./utils/anonymousDraftStorage";
|
||||||
import { useCreateFlow } from "./context/CreateFlowContext";
|
import { useCreateFlow } from "./context/CreateFlowContext";
|
||||||
import { fetchDraftFromServer } from "../../lib/create/api";
|
import { fetchDraftFromServer } from "../../../lib/create/api";
|
||||||
import messages from "../../messages/en/index";
|
import messages from "../../../messages/en/index";
|
||||||
|
|
||||||
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When sync is on and the user is signed in, fetch `GET /api/drafts/me` once and merge into context.
|
* When sync is on and the user is signed in, restore the server-side draft only
|
||||||
* Skips when `?syncDraft=1` or transfer-pending — {@link PostLoginDraftTransfer} owns that path.
|
* 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.
|
||||||
*
|
*
|
||||||
* **Conflict:** If both server draft and `create-flow-anonymous` are non-empty, `window.confirm`
|
* Server draft becomes authoritative only when localStorage is empty — i.e.
|
||||||
* chooses account draft (OK) vs browser copy (Cancel); browser storage is cleared after resolution.
|
* fresh device, after explicit Save & Exit (which clears localStorage), or
|
||||||
|
* after Exit-from-completed clears local state.
|
||||||
|
*
|
||||||
|
* Skips when `?syncDraft=1` or transfer-pending — {@link PostLoginDraftTransfer}
|
||||||
|
* owns that path.
|
||||||
*/
|
*/
|
||||||
export function SignedInDraftHydration({
|
export function SignedInDraftHydration({
|
||||||
sessionUser,
|
sessionUser,
|
||||||
@@ -54,6 +60,14 @@ export function SignedInDraftHydration({
|
|||||||
return;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoadingHydration(true);
|
setLoadingHydration(true);
|
||||||
|
|
||||||
@@ -62,43 +76,14 @@ export function SignedInDraftHydration({
|
|||||||
const serverDraft = await fetchDraftFromServer();
|
const serverDraft = await fetchDraftFromServer();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
const localDraft = readAnonymousCreateFlowState();
|
|
||||||
const hasServer =
|
|
||||||
serverDraft != null && createFlowStateHasKeys(serverDraft);
|
|
||||||
const hasLocal = createFlowStateHasKeys(localDraft);
|
|
||||||
|
|
||||||
if (touchedRef.current) {
|
if (touchedRef.current) {
|
||||||
finishedUserIdRef.current = userId;
|
finishedUserIdRef.current = userId;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasServer && hasLocal) {
|
if (serverDraft != null && createFlowStateHasKeys(serverDraft)) {
|
||||||
const useAccount =
|
|
||||||
typeof window !== "undefined" &&
|
|
||||||
window.confirm(messages.create.draftHydration.conflictPrompt);
|
|
||||||
if (cancelled) return;
|
|
||||||
if (useAccount) {
|
|
||||||
replaceState(serverDraft as CreateFlowState);
|
|
||||||
} else {
|
|
||||||
replaceState(localDraft);
|
|
||||||
}
|
|
||||||
clearAnonymousCreateFlowStorage();
|
|
||||||
finishedUserIdRef.current = userId;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasServer) {
|
|
||||||
replaceState(serverDraft as CreateFlowState);
|
replaceState(serverDraft as CreateFlowState);
|
||||||
clearAnonymousCreateFlowStorage();
|
|
||||||
finishedUserIdRef.current = userId;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasLocal) {
|
|
||||||
replaceState(localDraft);
|
|
||||||
clearAnonymousCreateFlowStorage();
|
|
||||||
}
|
|
||||||
|
|
||||||
finishedUserIdRef.current = userId;
|
finishedUserIdRef.current = userId;
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoadingHydration(false);
|
if (!cancelled) setLoadingHydration(false);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { CreateFlowScreenView } from "../screens/CreateFlowScreenView";
|
||||||
|
import { isValidStep } from "../utils/flowSteps";
|
||||||
|
import type { CreateFlowStep } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single dynamic route for the whole create wizard (every step in `FLOW_STEP_ORDER`).
|
||||||
|
*
|
||||||
|
* Only **canonical** `screenId` values from `CreateFlowStep` are valid. Old placeholder
|
||||||
|
* segments from pre-product shells are not redirected — unknown slugs `notFound()`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ screenId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CreateFlowScreenPage({ params }: PageProps) {
|
||||||
|
const { screenId: raw } = await params;
|
||||||
|
|
||||||
|
if (!isValidStep(raw)) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <CreateFlowScreenView screenId={raw as CreateFlowStep} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared "Applicable Scope" field used by the `decision-approaches` and
|
||||||
|
* `conflict-management` create flow modals. 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { memo, useState } from "react";
|
||||||
|
import Chip from "../../../components/controls/Chip";
|
||||||
|
import InputLabel from "../../../components/utility/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;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApplicableScopeFieldComponent({
|
||||||
|
label,
|
||||||
|
addLabel,
|
||||||
|
scopes,
|
||||||
|
selectedScopes,
|
||||||
|
onToggleScope,
|
||||||
|
onAddScope,
|
||||||
|
inputPlaceholder,
|
||||||
|
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={false}
|
||||||
|
onClick={() => onToggleScope(scope)}
|
||||||
|
ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{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);
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
import HeaderLockup from "../../../components/type/HeaderLockup";
|
||||||
import type { HeaderLockupProps } from "../../components/type/HeaderLockup/HeaderLockup.types";
|
import type { HeaderLockupProps } from "../../../components/type/HeaderLockup/HeaderLockup.types";
|
||||||
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
|
||||||
|
|
||||||
export type CreateFlowHeaderLockupProps = Omit<HeaderLockupProps, "size"> & {
|
export type CreateFlowHeaderLockupProps = Omit<HeaderLockupProps, "size"> & {
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
CreateFlowStepShell,
|
||||||
|
type CreateFlowContentTopBelowMd,
|
||||||
|
} from "./CreateFlowStepShell";
|
||||||
|
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "./createFlowLayoutTokens";
|
||||||
|
|
||||||
|
export type CreateFlowSelectShellLgVerticalAlign = "center" | "start";
|
||||||
|
|
||||||
|
interface CreateFlowTwoColumnSelectShellProps {
|
||||||
|
header: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* Top padding below create-flow chrome. Select steps use `space-1400`; right-rail uses `space-800`
|
||||||
|
* (Figma Flow — Right Rail).
|
||||||
|
*/
|
||||||
|
contentTopBelowMd?: CreateFlowContentTopBelowMd;
|
||||||
|
/**
|
||||||
|
* At `lg+`, layout variant: `"center"` = vertically centered pair (community size/structure).
|
||||||
|
* `"start"` = top-weighted layout with a scrollable right column (core values, right-rail): uses `items-stretch`
|
||||||
|
* so the right column gets a bounded height; `items-start` would grow with content and break scroll.
|
||||||
|
*/
|
||||||
|
lgVerticalAlign?: CreateFlowSelectShellLgVerticalAlign;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-column layout for create-flow select steps (community size/structure, core values) and
|
||||||
|
* {@link DecisionApproachesScreen} (decision approaches). Below `lg` (1024px), one column + main scrolls.
|
||||||
|
* At `lg+`, mirrors {@link CompletedScreen}: static header column + scrollable controls column
|
||||||
|
* (`min-h-0` + `overflow-y-auto` height chain; see completed page right rail).
|
||||||
|
*/
|
||||||
|
export function CreateFlowTwoColumnSelectShell({
|
||||||
|
header,
|
||||||
|
children,
|
||||||
|
contentTopBelowMd = "space-1400",
|
||||||
|
lgVerticalAlign = "center",
|
||||||
|
}: CreateFlowTwoColumnSelectShellProps) {
|
||||||
|
/** `stretch` is required for `min-h-0` + `overflow-y-auto` on the right column. */
|
||||||
|
const rowLgCrossAlignClass =
|
||||||
|
lgVerticalAlign === "start" ? "lg:items-stretch" : "lg:items-center";
|
||||||
|
|
||||||
|
const leftLgMainJustifyClass =
|
||||||
|
lgVerticalAlign === "start" ? "lg:justify-start" : "lg:justify-center";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreateFlowStepShell
|
||||||
|
variant="centeredNarrow"
|
||||||
|
contentTopBelowMd={contentTopBelowMd}
|
||||||
|
className={
|
||||||
|
/* Below `lg`: natural height — same as legacy select screens (main scrolls). */
|
||||||
|
/* At `lg+`: fill main + clip so only the right column scrolls (CompletedScreen pattern). */
|
||||||
|
"w-full min-w-0 max-lg:flex-none lg:min-h-0 lg:h-full lg:max-h-full lg:flex-1 lg:overflow-hidden lg:items-stretch lg:self-stretch"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] md:max-w-[640px] " +
|
||||||
|
"max-lg:flex-none lg:max-h-full lg:max-w-[1328px] lg:min-h-0 lg:flex-1 lg:flex-row lg:flex-nowrap " +
|
||||||
|
`${rowLgCrossAlignClass} lg:justify-center lg:gap-[var(--measures-spacing-1200,48px)] lg:overflow-hidden`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
`flex w-full min-w-0 shrink-0 flex-col items-start gap-[var(--measures-spacing-200,8px)] ` +
|
||||||
|
`lg:flex-1 ${leftLgMainJustifyClass} lg:py-[12px] lg:max-w-[640px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
`scrollbar-hide relative flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-800,32px)] ` +
|
||||||
|
`overflow-x-hidden lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pb-[var(--measures-spacing-300,12px)] ` +
|
||||||
|
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CreateFlowStepShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editable mirror of {@link TemplateChipDetailModal} for the final-review
|
||||||
|
* screen. Each chip on `/create/final-review` opens this modal — same field
|
||||||
|
* set as the matching custom-rule add-method modals, but with a **Save**
|
||||||
|
* button instead of **Add**:
|
||||||
|
*
|
||||||
|
* - Initial field values come from the matching `{group}DetailsById` state
|
||||||
|
* override when present; otherwise from the preset defaults shipped in
|
||||||
|
* `messages/en/create/customRule/*.json` (see {@link finalReviewChipPresets}).
|
||||||
|
* - Save is disabled until the user edits any field (cheap structural
|
||||||
|
* compare against the seeded snapshot). Saving writes the draft into
|
||||||
|
* `CreateFlowState` via the caller's `onSave` handler and closes; the
|
||||||
|
* state then rides along through the existing localStorage mirror,
|
||||||
|
* signed-in server draft PUT (Save & Exit), and `buildPublishPayload`
|
||||||
|
* (Finalize).
|
||||||
|
* - Closing the modal without saving discards any edits — the parent never
|
||||||
|
* hears about them.
|
||||||
|
*
|
||||||
|
* The actual field rendering lives in `components/methodEditFields/*` and
|
||||||
|
* is shared with the custom-rule add-method modals so the two surfaces stay
|
||||||
|
* in lockstep automatically.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import Create from "../../../components/modals/Create";
|
||||||
|
import ContentLockup from "../../../components/type/ContentLockup";
|
||||||
|
import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
|
||||||
|
import {
|
||||||
|
CommunicationMethodEditFields,
|
||||||
|
ConflictManagementEditFields,
|
||||||
|
CoreValueEditFields,
|
||||||
|
DecisionApproachEditFields,
|
||||||
|
MembershipMethodEditFields,
|
||||||
|
} from "./methodEditFields";
|
||||||
|
import {
|
||||||
|
communicationPresetFor,
|
||||||
|
conflictManagementPresetFor,
|
||||||
|
coreValuePresetFor,
|
||||||
|
decisionApproachPresetFor,
|
||||||
|
membershipPresetFor,
|
||||||
|
} from "../../../../lib/create/finalReviewChipPresets";
|
||||||
|
import type {
|
||||||
|
CommunicationMethodDetailEntry,
|
||||||
|
ConflictManagementDetailEntry,
|
||||||
|
CoreValueDetailEntry,
|
||||||
|
CreateFlowState,
|
||||||
|
DecisionApproachDetailEntry,
|
||||||
|
MembershipMethodDetailEntry,
|
||||||
|
} from "../types";
|
||||||
|
import type { TemplateFacetGroupKey } from "../../../../lib/create/templateReviewMapping";
|
||||||
|
|
||||||
|
export type FinalReviewChipEditTarget = {
|
||||||
|
/** Stable key for override lookup: preset id (methods) or chip id (core values). */
|
||||||
|
overrideKey: string;
|
||||||
|
/** Category group that decides which field set to render. */
|
||||||
|
groupKey: TemplateFacetGroupKey;
|
||||||
|
/** Display label shown at the top of the modal (localized chip label). */
|
||||||
|
chipLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FinalReviewChipEditPatch =
|
||||||
|
| { groupKey: "coreValues"; overrideKey: string; value: CoreValueDetailEntry }
|
||||||
|
| {
|
||||||
|
groupKey: "communication";
|
||||||
|
overrideKey: string;
|
||||||
|
value: CommunicationMethodDetailEntry;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
groupKey: "membership";
|
||||||
|
overrideKey: string;
|
||||||
|
value: MembershipMethodDetailEntry;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
groupKey: "decisionApproaches";
|
||||||
|
overrideKey: string;
|
||||||
|
value: DecisionApproachDetailEntry;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
groupKey: "conflictManagement";
|
||||||
|
overrideKey: string;
|
||||||
|
value: ConflictManagementDetailEntry;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FinalReviewChipEditModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/**
|
||||||
|
* Chip being edited. Passed `null` while the modal is closing so the
|
||||||
|
* component can cleanly reset its internal draft state.
|
||||||
|
*/
|
||||||
|
target: FinalReviewChipEditTarget | null;
|
||||||
|
/** Current flow state — used to seed the modal from saved overrides. */
|
||||||
|
state: CreateFlowState;
|
||||||
|
/** Called with the typed patch when the user clicks Save. */
|
||||||
|
onSave: (_patch: FinalReviewChipEditPatch) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discriminated union of every group's draft + value. Storing both the
|
||||||
|
* `groupKey` and the `value` together keeps render-time switches exhaustive
|
||||||
|
* and prevents the four method group states from drifting apart (which is
|
||||||
|
* the bug that motivated extracting `methodEditFields/*` in the first place).
|
||||||
|
*/
|
||||||
|
type Draft =
|
||||||
|
| { groupKey: "coreValues"; value: CoreValueDetailEntry }
|
||||||
|
| { groupKey: "communication"; value: CommunicationMethodDetailEntry }
|
||||||
|
| { groupKey: "membership"; value: MembershipMethodDetailEntry }
|
||||||
|
| { groupKey: "decisionApproaches"; value: DecisionApproachDetailEntry }
|
||||||
|
| { groupKey: "conflictManagement"; value: ConflictManagementDetailEntry };
|
||||||
|
|
||||||
|
export function FinalReviewChipEditModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
target,
|
||||||
|
state,
|
||||||
|
onSave,
|
||||||
|
}: FinalReviewChipEditModalProps) {
|
||||||
|
const m = useMessages();
|
||||||
|
const tCv = m.create.customRule.coreValues;
|
||||||
|
const tComm = m.create.customRule.communication;
|
||||||
|
const tMem = m.create.customRule.membership;
|
||||||
|
const tDa = m.create.customRule.decisionApproaches;
|
||||||
|
const tCm = m.create.customRule.conflictManagement;
|
||||||
|
const tModal = useTranslation(
|
||||||
|
"create.reviewAndComplete.finalReview.chipEditModal",
|
||||||
|
);
|
||||||
|
|
||||||
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
|
/**
|
||||||
|
* JSON-stringified seed used for the cheap dirty check. Re-captured on
|
||||||
|
* every (re)open so reopening a chip after a save shows Save-disabled
|
||||||
|
* again until the user makes a fresh edit.
|
||||||
|
*/
|
||||||
|
const initialSnapshotRef = useRef<string>("");
|
||||||
|
const seededTargetRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !target) return;
|
||||||
|
const targetKey = `${target.groupKey}:${target.overrideKey}`;
|
||||||
|
if (seededTargetRef.current === targetKey) return;
|
||||||
|
|
||||||
|
const seed = seedDraftForTarget(target, state);
|
||||||
|
setDraft(seed);
|
||||||
|
initialSnapshotRef.current = JSON.stringify(seed.value);
|
||||||
|
seededTargetRef.current = targetKey;
|
||||||
|
}, [isOpen, target, state]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) seededTargetRef.current = null;
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const isDirty = useMemo(() => {
|
||||||
|
if (!draft) return false;
|
||||||
|
return JSON.stringify(draft.value) !== initialSnapshotRef.current;
|
||||||
|
}, [draft]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!target || !draft || !isDirty) return;
|
||||||
|
onSave({
|
||||||
|
groupKey: draft.groupKey,
|
||||||
|
overrideKey: target.overrideKey,
|
||||||
|
value: draft.value,
|
||||||
|
} as FinalReviewChipEditPatch);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtitle = useMemo(() => {
|
||||||
|
if (!target) return "";
|
||||||
|
return subtitleForTarget(target, { tCv, tComm, tMem, tDa, tCm });
|
||||||
|
}, [target, tCv, tComm, tMem, tDa, tCm]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Create
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
backdropVariant="loginYellow"
|
||||||
|
headerContent={
|
||||||
|
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
|
||||||
|
<ContentLockup
|
||||||
|
title={target?.chipLabel ?? ""}
|
||||||
|
description={subtitle}
|
||||||
|
variant="modal"
|
||||||
|
alignment="left"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
showBackButton={false}
|
||||||
|
showNextButton
|
||||||
|
nextButtonText={tModal("saveButton")}
|
||||||
|
nextButtonDisabled={!isDirty}
|
||||||
|
onNext={handleSave}
|
||||||
|
ariaLabel={target?.chipLabel || "Edit chip details"}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-[var(--measures-spacing-600,24px)] pb-2">
|
||||||
|
{draft?.groupKey === "coreValues" && (
|
||||||
|
<CoreValueEditFields
|
||||||
|
value={draft.value}
|
||||||
|
onChange={(value) => setDraft({ groupKey: "coreValues", value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{draft?.groupKey === "communication" && (
|
||||||
|
<CommunicationMethodEditFields
|
||||||
|
value={draft.value}
|
||||||
|
onChange={(value) =>
|
||||||
|
setDraft({ groupKey: "communication", value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{draft?.groupKey === "membership" && (
|
||||||
|
<MembershipMethodEditFields
|
||||||
|
value={draft.value}
|
||||||
|
onChange={(value) => setDraft({ groupKey: "membership", value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{draft?.groupKey === "decisionApproaches" && (
|
||||||
|
<DecisionApproachEditFields
|
||||||
|
value={draft.value}
|
||||||
|
onChange={(value) =>
|
||||||
|
setDraft({ groupKey: "decisionApproaches", value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{draft?.groupKey === "conflictManagement" && (
|
||||||
|
<ConflictManagementEditFields
|
||||||
|
value={draft.value}
|
||||||
|
onChange={(value) =>
|
||||||
|
setDraft({ groupKey: "conflictManagement", value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Create>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ------------------------------------------------------
|
||||||
|
|
||||||
|
function seedDraftForTarget(
|
||||||
|
target: FinalReviewChipEditTarget,
|
||||||
|
state: CreateFlowState,
|
||||||
|
): Draft {
|
||||||
|
switch (target.groupKey) {
|
||||||
|
case "coreValues": {
|
||||||
|
const saved = state.coreValueDetailsByChipId?.[target.overrideKey];
|
||||||
|
const preset = coreValuePresetFor(target.overrideKey);
|
||||||
|
return {
|
||||||
|
groupKey: "coreValues",
|
||||||
|
value: {
|
||||||
|
meaning: saved?.meaning ?? preset.meaning,
|
||||||
|
signals: saved?.signals ?? preset.signals,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "communication": {
|
||||||
|
const saved =
|
||||||
|
state.communicationMethodDetailsById?.[target.overrideKey] ??
|
||||||
|
communicationPresetFor(target.overrideKey);
|
||||||
|
return { groupKey: "communication", value: { ...saved } };
|
||||||
|
}
|
||||||
|
case "membership": {
|
||||||
|
const saved =
|
||||||
|
state.membershipMethodDetailsById?.[target.overrideKey] ??
|
||||||
|
membershipPresetFor(target.overrideKey);
|
||||||
|
return { groupKey: "membership", value: { ...saved } };
|
||||||
|
}
|
||||||
|
case "decisionApproaches": {
|
||||||
|
const saved =
|
||||||
|
state.decisionApproachDetailsById?.[target.overrideKey] ??
|
||||||
|
decisionApproachPresetFor(target.overrideKey);
|
||||||
|
return {
|
||||||
|
groupKey: "decisionApproaches",
|
||||||
|
value: {
|
||||||
|
...saved,
|
||||||
|
applicableScope: [...saved.applicableScope],
|
||||||
|
selectedApplicableScope: [...saved.selectedApplicableScope],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "conflictManagement": {
|
||||||
|
const saved =
|
||||||
|
state.conflictManagementDetailsById?.[target.overrideKey] ??
|
||||||
|
conflictManagementPresetFor(target.overrideKey);
|
||||||
|
return {
|
||||||
|
groupKey: "conflictManagement",
|
||||||
|
value: {
|
||||||
|
...saved,
|
||||||
|
applicableScope: [...saved.applicableScope],
|
||||||
|
selectedApplicableScope: [...saved.selectedApplicableScope],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubtitleMessages = {
|
||||||
|
tCv: ReturnType<typeof useMessages>["create"]["customRule"]["coreValues"];
|
||||||
|
tComm: ReturnType<typeof useMessages>["create"]["customRule"]["communication"];
|
||||||
|
tMem: ReturnType<typeof useMessages>["create"]["customRule"]["membership"];
|
||||||
|
tDa: ReturnType<
|
||||||
|
typeof useMessages
|
||||||
|
>["create"]["customRule"]["decisionApproaches"];
|
||||||
|
tCm: ReturnType<
|
||||||
|
typeof useMessages
|
||||||
|
>["create"]["customRule"]["conflictManagement"];
|
||||||
|
};
|
||||||
|
|
||||||
|
function subtitleForTarget(
|
||||||
|
target: FinalReviewChipEditTarget,
|
||||||
|
msgs: SubtitleMessages,
|
||||||
|
): string {
|
||||||
|
switch (target.groupKey) {
|
||||||
|
case "coreValues":
|
||||||
|
return msgs.tCv.detailModal.subtitle;
|
||||||
|
case "communication":
|
||||||
|
return findMethodSupportText(msgs.tComm.methods, target.overrideKey);
|
||||||
|
case "membership":
|
||||||
|
return findMethodSupportText(msgs.tMem.methods, target.overrideKey);
|
||||||
|
case "decisionApproaches":
|
||||||
|
return findMethodSupportText(msgs.tDa.methods, target.overrideKey);
|
||||||
|
case "conflictManagement":
|
||||||
|
return findMethodSupportText(msgs.tCm.methods, target.overrideKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMethodSupportText(
|
||||||
|
methods: readonly { id: string; supportText: string }[],
|
||||||
|
id: string,
|
||||||
|
): string {
|
||||||
|
for (const method of methods) {
|
||||||
|
if (method.id === id) return method.supportText;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared "labelled text area" field used by every create flow modal section.
|
||||||
|
* Pairs an `InputLabel` (with help icon) with a `TextArea` set to the embedded
|
||||||
|
* appearance — matching the Figma "Control / Text Area" pattern.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { memo, useId } from "react";
|
||||||
|
import TextArea from "../../../components/controls/TextArea";
|
||||||
|
import InputLabel from "../../../components/utility/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);
|
||||||
+8
@@ -8,3 +8,11 @@ export const CREATE_FLOW_MD_UP_GRID_CELL_CLASS =
|
|||||||
|
|
||||||
/** Two 640px columns + `--measures-spacing-1200` (48px) gutter. */
|
/** Two 640px columns + `--measures-spacing-1200` (48px) gutter. */
|
||||||
export const CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS = "md:max-w-[1328px]";
|
export const CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS = "md:max-w-[1328px]";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card-stack steps only (Figma compact card stack): wider than header lockup so the card grid /
|
||||||
|
* pyramid fits (max 860px). Header lockup stays {@link CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}.
|
||||||
|
* Card–card gap uses `gap-2` in `CardStack` (same on mobile and md+).
|
||||||
|
*/
|
||||||
|
export const CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS =
|
||||||
|
"w-full min-w-0 md:max-w-[min(100%,860px)]";
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELDS: ReadonlyArray<keyof CommunicationMethodDetailEntry> = [
|
||||||
|
"corePrinciple",
|
||||||
|
"logisticsAdmin",
|
||||||
|
"codeOfConduct",
|
||||||
|
];
|
||||||
|
|
||||||
|
function CommunicationMethodEditFieldsComponent({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: 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)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CommunicationMethodEditFieldsComponent.displayName =
|
||||||
|
"CommunicationMethodEditFields";
|
||||||
|
|
||||||
|
export default memo(CommunicationMethodEditFieldsComponent);
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"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 { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
|
import ModalTextAreaField from "../ModalTextAreaField";
|
||||||
|
import ApplicableScopeField from "../ApplicableScopeField";
|
||||||
|
import type { ConflictManagementDetailEntry } from "../../types";
|
||||||
|
|
||||||
|
export interface ConflictManagementEditFieldsProps {
|
||||||
|
value: ConflictManagementDetailEntry;
|
||||||
|
onChange: (_next: ConflictManagementDetailEntry) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConflictManagementEditFieldsComponent({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: 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)}
|
||||||
|
/>
|
||||||
|
<ApplicableScopeField
|
||||||
|
label={t.sectionHeadings.applicableScope}
|
||||||
|
addLabel={t.scopeAddButtonLabel}
|
||||||
|
scopes={value.applicableScope}
|
||||||
|
selectedScopes={value.selectedApplicableScope}
|
||||||
|
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.processProtocol}
|
||||||
|
value={value.processProtocol}
|
||||||
|
onChange={(v) => patch("processProtocol", v)}
|
||||||
|
/>
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={t.sectionHeadings.restorationFallbacks}
|
||||||
|
value={value.restorationFallbacks}
|
||||||
|
onChange={(v) => patch("restorationFallbacks", v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ConflictManagementEditFieldsComponent.displayName =
|
||||||
|
"ConflictManagementEditFields";
|
||||||
|
|
||||||
|
export default memo(ConflictManagementEditFieldsComponent);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CoreValueEditFieldsComponent({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: 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}
|
||||||
|
/>
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={t.signalsLabel}
|
||||||
|
value={value.signals}
|
||||||
|
onChange={(v) => patch("signals", v)}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CoreValueEditFieldsComponent.displayName = "CoreValueEditFields";
|
||||||
|
|
||||||
|
export default memo(CoreValueEditFieldsComponent);
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONSENSUS_LEVEL_MIN = 0;
|
||||||
|
const CONSENSUS_LEVEL_MAX = 100;
|
||||||
|
const CONSENSUS_LEVEL_STEP = 5;
|
||||||
|
|
||||||
|
function DecisionApproachEditFieldsComponent({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: 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)}
|
||||||
|
/>
|
||||||
|
<ApplicableScopeField
|
||||||
|
label={t.sectionHeadings.applicableScope}
|
||||||
|
addLabel={t.scopeAddButtonLabel}
|
||||||
|
scopes={value.applicableScope}
|
||||||
|
selectedScopes={value.selectedApplicableScope}
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={t.sectionHeadings.objectionsDeadlocks}
|
||||||
|
value={value.objectionsDeadlocks}
|
||||||
|
onChange={(v) => patch("objectionsDeadlocks", v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DecisionApproachEditFieldsComponent.displayName =
|
||||||
|
"DecisionApproachEditFields";
|
||||||
|
|
||||||
|
export default memo(DecisionApproachEditFieldsComponent);
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELDS: ReadonlyArray<keyof MembershipMethodDetailEntry> = [
|
||||||
|
"eligibility",
|
||||||
|
"joiningProcess",
|
||||||
|
"expectations",
|
||||||
|
];
|
||||||
|
|
||||||
|
function MembershipMethodEditFieldsComponent({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: 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)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
MembershipMethodEditFieldsComponent.displayName =
|
||||||
|
"MembershipMethodEditFields";
|
||||||
|
|
||||||
|
export default memo(MembershipMethodEditFieldsComponent);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export { default as CoreValueEditFields } from "./CoreValueEditFields";
|
||||||
|
export type { CoreValueEditFieldsProps } from "./CoreValueEditFields";
|
||||||
|
|
||||||
|
export { default as CommunicationMethodEditFields } from "./CommunicationMethodEditFields";
|
||||||
|
export type { CommunicationMethodEditFieldsProps } from "./CommunicationMethodEditFields";
|
||||||
|
|
||||||
|
export { default as MembershipMethodEditFields } from "./MembershipMethodEditFields";
|
||||||
|
export type { MembershipMethodEditFieldsProps } from "./MembershipMethodEditFields";
|
||||||
|
|
||||||
|
export { default as DecisionApproachEditFields } from "./DecisionApproachEditFields";
|
||||||
|
export type { DecisionApproachEditFieldsProps } from "./DecisionApproachEditFields";
|
||||||
|
|
||||||
|
export { default as ConflictManagementEditFields } from "./ConflictManagementEditFields";
|
||||||
|
export type { ConflictManagementEditFieldsProps } from "./ConflictManagementEditFields";
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import type {
|
||||||
|
CreateFlowState,
|
||||||
|
CreateFlowContextValue,
|
||||||
|
CreateFlowStep,
|
||||||
|
} from "../types";
|
||||||
|
import {
|
||||||
|
clearAnonymousCreateFlowStorage,
|
||||||
|
clearLegacyCreateFlowKeysOnce,
|
||||||
|
readAnonymousCreateFlowState,
|
||||||
|
writeAnonymousCreateFlowState,
|
||||||
|
} from "../utils/anonymousDraftStorage";
|
||||||
|
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;
|
||||||
|
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 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) => {
|
||||||
|
setState(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearState = useCallback(() => {
|
||||||
|
setState({});
|
||||||
|
setInteractionTouched(false);
|
||||||
|
clearAnonymousCreateFlowStorage();
|
||||||
|
clearCoreValueDetailsLocalStorage();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keys produced by the Create Custom stage screens + `buildTemplateCustomizePrefill`.
|
||||||
|
// Kept in sync with `CreateFlowState` comments marked "Create Custom —".
|
||||||
|
const resetCustomRuleSelections = useCallback(() => {
|
||||||
|
setState((prev) => {
|
||||||
|
const {
|
||||||
|
selectedCoreValueIds: _a,
|
||||||
|
coreValuesChipsSnapshot: _b,
|
||||||
|
coreValueDetailsByChipId: _c,
|
||||||
|
selectedCommunicationMethodIds: _d,
|
||||||
|
selectedMembershipMethodIds: _e,
|
||||||
|
selectedDecisionApproachIds: _f,
|
||||||
|
selectedConflictManagementIds: _g,
|
||||||
|
...rest
|
||||||
|
} = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
// 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,
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import type { CreateFlowState, CreateFlowStep } from "../types";
|
import type { CreateFlowState, CreateFlowStep } from "../types";
|
||||||
import { saveDraftToServer } from "../../../lib/create/api";
|
import { saveDraftToServer } from "../../../../lib/create/api";
|
||||||
import messages from "../../../messages/en/index";
|
import messages from "../../../../messages/en/index";
|
||||||
|
|
||||||
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
||||||
|
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
|
||||||
|
import { publishRule } from "../../../../lib/create/api";
|
||||||
|
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
|
||||||
|
import messages from "../../../../messages/en/index";
|
||||||
|
import type { CreateFlowState } from "../types";
|
||||||
|
|
||||||
|
type AppRouterLike = { push: (_href: string) => void };
|
||||||
|
|
||||||
|
type OpenLogin = (args: {
|
||||||
|
variant: "default" | "saveProgress";
|
||||||
|
nextPath: string;
|
||||||
|
backdropVariant: "blurredYellow";
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
export type UseCreateFlowFinalizeResult = {
|
||||||
|
/** Set when publish fails (validation, server error, or empty server message). Reset on each `finalize()` invocation. */
|
||||||
|
publishBannerMessage: string | null;
|
||||||
|
setPublishBannerMessage: (_message: string | null) => void;
|
||||||
|
/** True from the moment the publish request fires until the response resolves. */
|
||||||
|
isPublishing: boolean;
|
||||||
|
/**
|
||||||
|
* Build a publish payload from the current `CreateFlowState`, post it to
|
||||||
|
* `publishRule`, and route to `/create/completed` on success.
|
||||||
|
*
|
||||||
|
* Failure modes:
|
||||||
|
* - Payload validation fails → surface the localized banner message.
|
||||||
|
* - 401 from the API → re-open the login modal targeting `/create/final-review?syncDraft=1` so the user can retry post-auth.
|
||||||
|
* - Any other failure → show either the trimmed server message or a generic localized fallback.
|
||||||
|
*/
|
||||||
|
finalize: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encapsulates the Final Review → publish flow that previously lived inline
|
||||||
|
* in `CreateFlowLayoutClient`. Keeps publish state (banner + in-flight flag)
|
||||||
|
* co-located with the publish handler so the layout shell only has to wire
|
||||||
|
* the resulting message into its banner stack.
|
||||||
|
*/
|
||||||
|
export function useCreateFlowFinalize({
|
||||||
|
state,
|
||||||
|
router,
|
||||||
|
openLogin,
|
||||||
|
}: {
|
||||||
|
state: CreateFlowState;
|
||||||
|
router: AppRouterLike;
|
||||||
|
openLogin: OpenLogin;
|
||||||
|
}): 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 publishResult = await publishRule({
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
document: ruleDocument,
|
||||||
|
});
|
||||||
|
setIsPublishing(false);
|
||||||
|
if (publishResult.ok === true) {
|
||||||
|
writeLastPublishedRule({
|
||||||
|
id: publishResult.id,
|
||||||
|
title,
|
||||||
|
summary: summary ?? null,
|
||||||
|
document: ruleDocument,
|
||||||
|
});
|
||||||
|
router.push("/create/completed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (publishResult.status === 401) {
|
||||||
|
openLogin({
|
||||||
|
variant: "default",
|
||||||
|
nextPath: "/create/final-review?syncDraft=1",
|
||||||
|
backdropVariant: "blurredYellow",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPublishBannerMessage(
|
||||||
|
publishResult.error.trim() !== ""
|
||||||
|
? publishResult.error
|
||||||
|
: messages.create.reviewAndComplete.publish.genericPublishFailed,
|
||||||
|
);
|
||||||
|
}, [state, router, openLogin]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
publishBannerMessage,
|
||||||
|
setPublishBannerMessage,
|
||||||
|
isPublishing,
|
||||||
|
finalize,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
import { useMediaQuery } from "../../../hooks/useMediaQuery";
|
||||||
|
|
||||||
/** `--breakpoint-lg` (1024px); same SSR/first-paint pattern as `useCreateFlowMdUp`. */
|
/** `--breakpoint-lg` (1024px); same SSR/first-paint pattern as `useCreateFlowMdUp`. */
|
||||||
const CREATE_FLOW_MIN_WIDTH_LG = "(min-width: 1024px)";
|
const CREATE_FLOW_MIN_WIDTH_LG = "(min-width: 1024px)";
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
import { useMediaQuery } from "../../../hooks/useMediaQuery";
|
||||||
|
|
||||||
/** `--breakpoint-md` (640px); same SSR/first-paint pattern as `useCreateFlowLgUp`. */
|
/** `--breakpoint-md` (640px); same SSR/first-paint pattern as `useCreateFlowLgUp`. */
|
||||||
const CREATE_FLOW_MIN_WIDTH_MD = "(min-width: 640px)";
|
const CREATE_FLOW_MIN_WIDTH_MD = "(min-width: 640px)";
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"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,
|
||||||
|
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) => 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) => {
|
||||||
|
blurActiveElement();
|
||||||
|
router.push(`/create/${step}`);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const canGoNext = useCallback(() => nextStep !== null, [nextStep]);
|
||||||
|
const canGoBack = useCallback(() => backTarget != null, [backTarget]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentStep: validStep,
|
||||||
|
goToNextStep,
|
||||||
|
goToPreviousStep,
|
||||||
|
goToStep,
|
||||||
|
canGoNext,
|
||||||
|
canGoBack,
|
||||||
|
nextStep,
|
||||||
|
previousStep,
|
||||||
|
templateReviewFooterBackToCreateReview,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import facetGroups from "../../../../data/create/customRule/_facetGroups.json";
|
||||||
|
import {
|
||||||
|
type CreateFlowState,
|
||||||
|
} from "../types";
|
||||||
|
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card-deck section ids served by `/api/create-flow/methods` (CR-88 §9.2).
|
||||||
|
*/
|
||||||
|
export type RecommendationSection =
|
||||||
|
| "communication"
|
||||||
|
| "membership"
|
||||||
|
| "decisionApproaches"
|
||||||
|
| "conflictManagement";
|
||||||
|
|
||||||
|
const FACET_GROUPS = ["size", "orgType", "scale", "maturity"] as const;
|
||||||
|
type FacetGroupId = (typeof FACET_GROUPS)[number];
|
||||||
|
|
||||||
|
/** Reverse map chipId → canonical facet value id, per group. */
|
||||||
|
const CHIP_TO_VALUE_BY_GROUP: Record<FacetGroupId, Record<string, string>> = (() => {
|
||||||
|
const out: Record<FacetGroupId, Record<string, string>> = {
|
||||||
|
size: {},
|
||||||
|
orgType: {},
|
||||||
|
scale: {},
|
||||||
|
maturity: {},
|
||||||
|
};
|
||||||
|
for (const group of FACET_GROUPS) {
|
||||||
|
const block = (facetGroups as Record<string, unknown>)[group];
|
||||||
|
if (block && typeof block === "object" && "values" in block) {
|
||||||
|
const values = (block as { values: Record<string, { chipId: string }> })
|
||||||
|
.values;
|
||||||
|
for (const [valueId, entry] of Object.entries(values)) {
|
||||||
|
out[group][entry.chipId] = valueId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
})();
|
||||||
|
|
||||||
|
/** Chip-id state accessors per group. */
|
||||||
|
const STATE_KEY_BY_GROUP: Record<FacetGroupId, keyof CreateFlowState> = {
|
||||||
|
size: "selectedCommunitySizeIds",
|
||||||
|
orgType: "selectedOrganizationTypeIds",
|
||||||
|
scale: "selectedScaleIds",
|
||||||
|
maturity: "selectedMaturityIds",
|
||||||
|
};
|
||||||
|
|
||||||
|
function readChipIds(
|
||||||
|
state: CreateFlowState,
|
||||||
|
group: FacetGroupId,
|
||||||
|
): string[] {
|
||||||
|
const value = state[STATE_KEY_BY_GROUP[group]];
|
||||||
|
return Array.isArray(value) ? (value as string[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFacetQuery(state: CreateFlowState): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const group of FACET_GROUPS) {
|
||||||
|
const valuesById = CHIP_TO_VALUE_BY_GROUP[group];
|
||||||
|
for (const chipId of readChipIds(state, group)) {
|
||||||
|
const valueId = valuesById[chipId];
|
||||||
|
if (valueId) {
|
||||||
|
params.append(`facet.${group}`, valueId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
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(() => buildFacetQuery(state), [state]);
|
||||||
|
const hasAnyFacets = queryString.length > 0;
|
||||||
|
|
||||||
|
const [result, setResult] = useState<FacetRecommendationsResult>({
|
||||||
|
isReady: !hasAnyFacets,
|
||||||
|
scoresBySlug: EMPTY_SCORES,
|
||||||
|
hasAnyFacets,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track the last successful request input so we don't re-fetch on every state poke.
|
||||||
|
const lastQueryRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasAnyFacets) {
|
||||||
|
setResult({
|
||||||
|
isReady: true,
|
||||||
|
scoresBySlug: EMPTY_SCORES,
|
||||||
|
hasAnyFacets: false,
|
||||||
|
});
|
||||||
|
lastQueryRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestKey = `${section}?${queryString}`;
|
||||||
|
if (lastQueryRef.current === requestKey) return;
|
||||||
|
lastQueryRef.current = requestKey;
|
||||||
|
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
setResult((prev) => ({ ...prev, isReady: false, hasAnyFacets: true }));
|
||||||
|
fetch(`/api/create-flow/methods?section=${section}&${queryString}`, {
|
||||||
|
credentials: "include",
|
||||||
|
signal: ctrl.signal,
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||||
|
return (await res.json()) as {
|
||||||
|
methods?: { slug: string; matches?: { score?: number } }[];
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.then((json) => {
|
||||||
|
const scoresBySlug: Record<string, number> = {};
|
||||||
|
for (const m of json.methods ?? []) {
|
||||||
|
if (typeof m.slug === "string") {
|
||||||
|
scoresBySlug[m.slug] = m.matches?.score ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setResult({ isReady: true, scoresBySlug, hasAnyFacets: true });
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if ((e as { name?: string }).name === "AbortError") return;
|
||||||
|
setResult({
|
||||||
|
isReady: true,
|
||||||
|
scoresBySlug: EMPTY_SCORES,
|
||||||
|
hasAnyFacets: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ctrl.abort();
|
||||||
|
// Clear the dedup key so React 19 Strict Mode's mount → unmount → mount
|
||||||
|
// cycle (and any future remount) re-issues the request instead of
|
||||||
|
// returning early on the same key.
|
||||||
|
if (lastQueryRef.current === requestKey) {
|
||||||
|
lastQueryRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [section, queryString, hasAnyFacets]);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable comparator for re-ranking a messages-file `methods[]` array. Higher
|
||||||
|
* `scoresBySlug[id]` first; ties fall back to authoring index, so a
|
||||||
|
* zero-facet user sees the original ordering verbatim.
|
||||||
|
*/
|
||||||
|
export function rankMethodsByScore<T extends { id: string }>(
|
||||||
|
methods: readonly T[],
|
||||||
|
scoresBySlug: Record<string, number>,
|
||||||
|
): T[] {
|
||||||
|
const indexById = new Map<string, number>();
|
||||||
|
methods.forEach((m, i) => indexById.set(m.id, i));
|
||||||
|
return [...methods].sort((a, b) => {
|
||||||
|
const sa = scoresBySlug[a.id] ?? 0;
|
||||||
|
const sb = scoresBySlug[b.id] ?? 0;
|
||||||
|
if (sa !== sb) return sb - sa;
|
||||||
|
return (indexById.get(a.id) ?? 0) - (indexById.get(b.id) ?? 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks (a) which method ids fill the compact card stack and (b) which of
|
||||||
|
* those should render with the "Recommended" tag. The messages JSON no
|
||||||
|
* longer carries a static `recommended` flag — both selections come
|
||||||
|
* entirely from facet scores (CR-88 §10).
|
||||||
|
*
|
||||||
|
* Behavior:
|
||||||
|
* - Facets selected & at least one method scored > 0 →
|
||||||
|
* `compactCardIds` = up to `limit` top-scored methods (1..limit cards;
|
||||||
|
* never padded with unrecommended fillers). All shown cards get the
|
||||||
|
* "Recommended" badge.
|
||||||
|
* - No facets selected, or every method scored 0 → `compactCardIds` =
|
||||||
|
* first `limit` in ranked/authoring order, `recommendedIds` empty (no
|
||||||
|
* badges shown — honest "no signal yet" fallback).
|
||||||
|
*
|
||||||
|
* `CardStack.view` is responsible for laying out variable-length compact
|
||||||
|
* arrays gracefully (uses `.map`/`.slice` and length-guarded indexing).
|
||||||
|
*/
|
||||||
|
export function deriveCompactCards<T extends { id: string }>(
|
||||||
|
rankedMethods: readonly T[],
|
||||||
|
scoresBySlug: Record<string, number>,
|
||||||
|
hasAnyFacets: boolean,
|
||||||
|
limit: number,
|
||||||
|
): { compactCardIds: string[]; recommendedIds: Set<string> } {
|
||||||
|
const fallback = () => ({
|
||||||
|
compactCardIds: rankedMethods.slice(0, limit).map((m) => m.id),
|
||||||
|
recommendedIds: new Set<string>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasAnyFacets) return fallback();
|
||||||
|
|
||||||
|
const matched = rankedMethods.filter(
|
||||||
|
(m) => (scoresBySlug[m.id] ?? 0) > 0,
|
||||||
|
);
|
||||||
|
if (matched.length === 0) return fallback();
|
||||||
|
|
||||||
|
const top = matched.slice(0, limit);
|
||||||
|
return {
|
||||||
|
compactCardIds: top.map((m) => m.id),
|
||||||
|
recommendedIds: new Set(top.map((m) => m.id)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
buildCoreValuesPrefillFromTemplateBody,
|
||||||
|
buildTemplateCustomizePrefill,
|
||||||
|
} from "../../../../lib/create/applyTemplatePrefill";
|
||||||
|
import { loadTemplateReviewBySlug } from "../../../../lib/create/loadTemplateReviewBySlug";
|
||||||
|
import messages from "../../../../messages/en/index";
|
||||||
|
import type { CreateFlowState } from "../types";
|
||||||
|
|
||||||
|
type AppRouterLike = { push: (_href: string) => void };
|
||||||
|
type UpdateState = (_patch: Partial<CreateFlowState>) => void;
|
||||||
|
|
||||||
|
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 the core-values
|
||||||
|
* snapshot from the template's Values section, drop that section 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, resetCustomRuleSelections, router });
|
||||||
|
*/
|
||||||
|
export function useTemplateReviewActions({
|
||||||
|
pathname,
|
||||||
|
state,
|
||||||
|
updateState,
|
||||||
|
resetCustomRuleSelections,
|
||||||
|
router,
|
||||||
|
}: {
|
||||||
|
pathname: string | null | undefined;
|
||||||
|
state: CreateFlowState;
|
||||||
|
updateState: UpdateState;
|
||||||
|
resetCustomRuleSelections: () => void;
|
||||||
|
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 hasCommunityName =
|
||||||
|
typeof state.title === "string" && state.title.trim().length > 0;
|
||||||
|
updateState({
|
||||||
|
...prefill,
|
||||||
|
templateReviewBackSlug: undefined,
|
||||||
|
...(hasCommunityName
|
||||||
|
? { pendingTemplateAction: undefined }
|
||||||
|
: {
|
||||||
|
pendingTemplateAction: {
|
||||||
|
slug: templateReviewSlug,
|
||||||
|
mode: "customize",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
router.push(
|
||||||
|
hasCommunityName ? "/create/core-values" : "/create/informational",
|
||||||
|
);
|
||||||
|
}, [router, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Using the template verbatim: scrub any prior customize picks so they
|
||||||
|
// don't bleed into `document.coreValues` at publish time.
|
||||||
|
resetCustomRuleSelections();
|
||||||
|
|
||||||
|
// Seed the core-values snapshot from the Values section so the
|
||||||
|
// final-review chip modal can edit them (it keys edits by chip id).
|
||||||
|
// The Values entries themselves are then dropped from `sections` to
|
||||||
|
// avoid publishing `document.coreValues` and `document.sections.Values`
|
||||||
|
// for the same data — matches the "Customize" path's data shape.
|
||||||
|
const coreValuesPrefill = buildCoreValuesPrefillFromTemplateBody(doc);
|
||||||
|
const sectionsWithoutValues =
|
||||||
|
Object.keys(coreValuesPrefill).length > 0
|
||||||
|
? 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 summaryRaw =
|
||||||
|
typeof template.description === "string"
|
||||||
|
? template.description.trim()
|
||||||
|
: "";
|
||||||
|
const hasCommunityName =
|
||||||
|
typeof state.title === "string" && state.title.trim().length > 0;
|
||||||
|
updateState({
|
||||||
|
...coreValuesPrefill,
|
||||||
|
sections: sectionsWithoutValues,
|
||||||
|
...(summaryRaw.length > 0 ? { summary: summaryRaw } : {}),
|
||||||
|
templateReviewBackSlug: templateReviewSlug,
|
||||||
|
...(hasCommunityName
|
||||||
|
? { pendingTemplateAction: undefined }
|
||||||
|
: {
|
||||||
|
pendingTemplateAction: {
|
||||||
|
slug: templateReviewSlug,
|
||||||
|
mode: "useWithoutChanges",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
router.push(
|
||||||
|
hasCommunityName
|
||||||
|
? "/create/confirm-stakeholders"
|
||||||
|
: "/create/informational",
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
resetCustomRuleSelections,
|
||||||
|
router,
|
||||||
|
state.title,
|
||||||
|
templateReviewSlug,
|
||||||
|
updateState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isTemplateReviewRoute,
|
||||||
|
templateReviewSlug,
|
||||||
|
isApplyingTemplate,
|
||||||
|
templateReviewApplyError,
|
||||||
|
setTemplateReviewApplyError,
|
||||||
|
handleCustomize,
|
||||||
|
handleUseWithoutChanges,
|
||||||
|
};
|
||||||
|
}
|
||||||
+5
-5
@@ -1,15 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { use, useEffect, useState } from "react";
|
import { use, useEffect, useState } from "react";
|
||||||
import { TemplateReviewCard } from "../../../components/cards/TemplateReviewCard";
|
import { TemplateReviewCard } from "../../../../components/cards/TemplateReviewCard";
|
||||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||||
import {
|
import {
|
||||||
fetchTemplateBySlug,
|
fetchTemplateBySlug,
|
||||||
isTemplatesFetchAborted,
|
isTemplatesFetchAborted,
|
||||||
type RuleTemplateDto,
|
type RuleTemplateDto,
|
||||||
} from "../../../../lib/create/fetchTemplates";
|
} from "../../../../../lib/create/fetchTemplates";
|
||||||
import messages from "../../../../messages/en/index";
|
import messages from "../../../../../messages/en/index";
|
||||||
import Alert from "../../../components/modals/Alert";
|
import Alert from "../../../../components/modals/Alert";
|
||||||
import {
|
import {
|
||||||
CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS,
|
CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS,
|
||||||
CreateFlowLockupCardStepShell,
|
CreateFlowLockupCardStepShell,
|
||||||
+23
-10
@@ -6,16 +6,23 @@ import { InformationalScreen } from "./informational/InformationalScreen";
|
|||||||
import { CreateFlowTextFieldScreen } from "./text/CreateFlowTextFieldScreen";
|
import { CreateFlowTextFieldScreen } from "./text/CreateFlowTextFieldScreen";
|
||||||
import { CommunitySizeSelectScreen } from "./select/CommunitySizeSelectScreen";
|
import { CommunitySizeSelectScreen } from "./select/CommunitySizeSelectScreen";
|
||||||
import { CommunityStructureSelectScreen } from "./select/CommunityStructureSelectScreen";
|
import { CommunityStructureSelectScreen } from "./select/CommunityStructureSelectScreen";
|
||||||
|
import { CoreValuesSelectScreen } from "./select/CoreValuesSelectScreen";
|
||||||
import { ConfirmStakeholdersScreen } from "./select/ConfirmStakeholdersScreen";
|
import { ConfirmStakeholdersScreen } from "./select/ConfirmStakeholdersScreen";
|
||||||
import { CommunityUploadScreen } from "./upload/CommunityUploadScreen";
|
import { CommunityUploadScreen } from "./upload/CommunityUploadScreen";
|
||||||
import { CommunityReviewScreen } from "./review/CommunityReviewScreen";
|
import { CommunityReviewScreen } from "./review/CommunityReviewScreen";
|
||||||
import { FinalReviewScreen } from "./review/FinalReviewScreen";
|
import { FinalReviewScreen } from "./review/FinalReviewScreen";
|
||||||
import { CardsScreen } from "./card/CardsScreen";
|
import { CommunicationMethodsScreen } from "./card/CommunicationMethodsScreen";
|
||||||
import { RightRailScreen } from "./right-rail/RightRailScreen";
|
import { MembershipMethodsScreen } from "./card/MembershipMethodsScreen";
|
||||||
|
import { ConflictManagementScreen } from "./card/ConflictManagementScreen";
|
||||||
|
import { DecisionApproachesScreen } from "./right-rail/DecisionApproachesScreen";
|
||||||
import { CompletedScreen } from "./completed/CompletedScreen";
|
import { CompletedScreen } from "./completed/CompletedScreen";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the create-flow screen for a validated `screenId` (URL segment under /create/).
|
* 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`.
|
||||||
*/
|
*/
|
||||||
export function CreateFlowScreenView({
|
export function CreateFlowScreenView({
|
||||||
screenId,
|
screenId,
|
||||||
@@ -28,7 +35,7 @@ export function CreateFlowScreenView({
|
|||||||
case "community-name":
|
case "community-name":
|
||||||
return (
|
return (
|
||||||
<CreateFlowTextFieldScreen
|
<CreateFlowTextFieldScreen
|
||||||
messageNamespace="create.communityName"
|
messageNamespace="create.community.communityName"
|
||||||
stateField="title"
|
stateField="title"
|
||||||
maxLength={48}
|
maxLength={48}
|
||||||
/>
|
/>
|
||||||
@@ -38,7 +45,7 @@ export function CreateFlowScreenView({
|
|||||||
case "community-context":
|
case "community-context":
|
||||||
return (
|
return (
|
||||||
<CreateFlowTextFieldScreen
|
<CreateFlowTextFieldScreen
|
||||||
messageNamespace="create.communityContext"
|
messageNamespace="create.community.communityContext"
|
||||||
stateField="communityContext"
|
stateField="communityContext"
|
||||||
maxLength={48}
|
maxLength={48}
|
||||||
mainAlign="center"
|
mainAlign="center"
|
||||||
@@ -51,7 +58,7 @@ export function CreateFlowScreenView({
|
|||||||
case "community-save":
|
case "community-save":
|
||||||
return (
|
return (
|
||||||
<CreateFlowTextFieldScreen
|
<CreateFlowTextFieldScreen
|
||||||
messageNamespace="create.communitySave"
|
messageNamespace="create.community.communitySave"
|
||||||
stateField="communitySaveEmail"
|
stateField="communitySaveEmail"
|
||||||
maxLength={254}
|
maxLength={254}
|
||||||
mainAlign="center"
|
mainAlign="center"
|
||||||
@@ -62,10 +69,16 @@ export function CreateFlowScreenView({
|
|||||||
);
|
);
|
||||||
case "review":
|
case "review":
|
||||||
return <CommunityReviewScreen />;
|
return <CommunityReviewScreen />;
|
||||||
case "cards":
|
case "core-values":
|
||||||
return <CardsScreen />;
|
return <CoreValuesSelectScreen />;
|
||||||
case "right-rail":
|
case "communication-methods":
|
||||||
return <RightRailScreen />;
|
return <CommunicationMethodsScreen />;
|
||||||
|
case "membership-methods":
|
||||||
|
return <MembershipMethodsScreen />;
|
||||||
|
case "decision-approaches":
|
||||||
|
return <DecisionApproachesScreen />;
|
||||||
|
case "conflict-management":
|
||||||
|
return <ConflictManagementScreen />;
|
||||||
case "confirm-stakeholders":
|
case "confirm-stakeholders":
|
||||||
return <ConfirmStakeholdersScreen />;
|
return <ConfirmStakeholdersScreen />;
|
||||||
case "final-review":
|
case "final-review":
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"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 "Add Platform" create modal (node `20246-15829`) with three
|
||||||
|
* editable sections rendered by {@link CommunicationMethodEditFields}. The same field set is
|
||||||
|
* reused on `/create/final-review` — see `FinalReviewChipEditModal`. Confirm persists both
|
||||||
|
* the chip selection and any user edits as a `communicationMethodDetailsById[id]` override.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useMemo } from "react";
|
||||||
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
|
import {
|
||||||
|
deriveCompactCards,
|
||||||
|
rankMethodsByScore,
|
||||||
|
useFacetRecommendations,
|
||||||
|
} from "../../hooks/useFacetRecommendations";
|
||||||
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
|
import CardStack from "../../../../components/utility/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 { communicationPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
|
||||||
|
import type { CommunicationMethodDetailEntry } from "../../types";
|
||||||
|
|
||||||
|
export function CommunicationMethodsScreen() {
|
||||||
|
const m = useMessages();
|
||||||
|
const comm = m.create.customRule.communication;
|
||||||
|
const mdUp = useCreateFlowMdUp();
|
||||||
|
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||||
|
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 selectedIds = state.selectedCommunicationMethodIds ?? [];
|
||||||
|
|
||||||
|
const { scoresBySlug, hasAnyFacets } =
|
||||||
|
useFacetRecommendations("communication");
|
||||||
|
const rankedMethods = useMemo(
|
||||||
|
() => rankMethodsByScore(comm.methods, scoresBySlug),
|
||||||
|
[comm.methods, scoresBySlug],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { compactCardIds, recommendedIds } = useMemo(
|
||||||
|
() => deriveCompactCards(rankedMethods, scoresBySlug, hasAnyFacets, 5),
|
||||||
|
[rankedMethods, scoresBySlug, hasAnyFacets],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sampleCards = useMemo(
|
||||||
|
() =>
|
||||||
|
rankedMethods.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
label: entry.label,
|
||||||
|
supportText: entry.supportText,
|
||||||
|
recommended: recommendedIds.has(entry.id),
|
||||||
|
})),
|
||||||
|
[rankedMethods, recommendedIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const methodById = useMemo(
|
||||||
|
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
|
||||||
|
[rankedMethods],
|
||||||
|
);
|
||||||
|
|
||||||
|
const title = expanded ? comm.page.expandedTitle : comm.page.compactTitle;
|
||||||
|
|
||||||
|
const description = expanded ? (
|
||||||
|
comm.page.expandedDescription
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{comm.page.compactDescriptionBefore}
|
||||||
|
<InlineTextButton
|
||||||
|
onClick={() => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setExpanded(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{comm.page.compactDescriptionLinkLabel}
|
||||||
|
</InlineTextButton>
|
||||||
|
{comm.page.compactDescriptionAfter}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const modalConfig = pendingCardId
|
||||||
|
? (() => {
|
||||||
|
const method = methodById.get(pendingCardId);
|
||||||
|
return {
|
||||||
|
title: method?.label ?? comm.confirmModal.title,
|
||||||
|
description: method?.supportText ?? comm.confirmModal.description,
|
||||||
|
nextButtonText: comm.addPlatform.nextButtonText,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
: {
|
||||||
|
title: comm.confirmModal.title,
|
||||||
|
description: comm.confirmModal.description,
|
||||||
|
nextButtonText: comm.confirmModal.nextButtonText,
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
setPendingCardId(id);
|
||||||
|
setPendingDraft(seedDraft(id));
|
||||||
|
setCreateModalOpen(true);
|
||||||
|
},
|
||||||
|
[markCreateFlowInteraction, seedDraft],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDraftChange = useCallback(
|
||||||
|
(next: CommunicationMethodDetailEntry) => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setPendingDraft(next);
|
||||||
|
},
|
||||||
|
[markCreateFlowInteraction],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateModalClose = useCallback(() => {
|
||||||
|
setCreateModalOpen(false);
|
||||||
|
setPendingCardId(null);
|
||||||
|
setPendingDraft(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateModalConfirm = useCallback(() => {
|
||||||
|
if (!pendingCardId || !pendingDraft) {
|
||||||
|
handleCreateModalClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
updateState({
|
||||||
|
selectedCommunicationMethodIds: selectedIds.includes(pendingCardId)
|
||||||
|
? selectedIds
|
||||||
|
: [...selectedIds, pendingCardId],
|
||||||
|
communicationMethodDetailsById: {
|
||||||
|
...(state.communicationMethodDetailsById ?? {}),
|
||||||
|
[pendingCardId]: pendingDraft,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
handleCreateModalClose();
|
||||||
|
}, [
|
||||||
|
handleCreateModalClose,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
pendingCardId,
|
||||||
|
pendingDraft,
|
||||||
|
selectedIds,
|
||||||
|
state.communicationMethodDetailsById,
|
||||||
|
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}
|
||||||
|
onNext={handleCreateModalConfirm}
|
||||||
|
title={modalConfig.title}
|
||||||
|
description={modalConfig.description}
|
||||||
|
nextButtonText={modalConfig.nextButtonText}
|
||||||
|
showBackButton={false}
|
||||||
|
backdropVariant="loginYellow"
|
||||||
|
>
|
||||||
|
{pendingCardId && pendingDraft ? (
|
||||||
|
<CommunicationMethodEditFields
|
||||||
|
key={pendingCardId}
|
||||||
|
value={pendingDraft}
|
||||||
|
onChange={handleDraftChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Create>
|
||||||
|
</CreateFlowStepShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"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 (capsules), 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 } from "react";
|
||||||
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
|
import {
|
||||||
|
deriveCompactCards,
|
||||||
|
rankMethodsByScore,
|
||||||
|
useFacetRecommendations,
|
||||||
|
} from "../../hooks/useFacetRecommendations";
|
||||||
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
|
import CardStack from "../../../../components/utility/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 { conflictManagementPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
|
||||||
|
import type { ConflictManagementDetailEntry } from "../../types";
|
||||||
|
|
||||||
|
export function ConflictManagementScreen() {
|
||||||
|
const m = useMessages();
|
||||||
|
const cm = m.create.customRule.conflictManagement;
|
||||||
|
const mdUp = useCreateFlowMdUp();
|
||||||
|
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||||
|
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 selectedIds = state.selectedConflictManagementIds ?? [];
|
||||||
|
|
||||||
|
const { scoresBySlug, hasAnyFacets } =
|
||||||
|
useFacetRecommendations("conflictManagement");
|
||||||
|
const rankedMethods = useMemo(
|
||||||
|
() => rankMethodsByScore(cm.methods, scoresBySlug),
|
||||||
|
[cm.methods, scoresBySlug],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { compactCardIds, recommendedIds } = useMemo(
|
||||||
|
() => deriveCompactCards(rankedMethods, scoresBySlug, hasAnyFacets, 5),
|
||||||
|
[rankedMethods, scoresBySlug, hasAnyFacets],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sampleCards = useMemo(
|
||||||
|
() =>
|
||||||
|
rankedMethods.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
label: entry.label,
|
||||||
|
supportText: entry.supportText,
|
||||||
|
recommended: recommendedIds.has(entry.id),
|
||||||
|
})),
|
||||||
|
[rankedMethods, recommendedIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const methodById = useMemo(
|
||||||
|
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
|
||||||
|
[rankedMethods],
|
||||||
|
);
|
||||||
|
|
||||||
|
const title = expanded ? cm.page.expandedTitle : cm.page.compactTitle;
|
||||||
|
|
||||||
|
const description = expanded ? (
|
||||||
|
cm.page.expandedDescription
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{cm.page.compactDescriptionBefore}
|
||||||
|
<InlineTextButton
|
||||||
|
onClick={() => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setExpanded(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cm.page.compactDescriptionLinkLabel}
|
||||||
|
</InlineTextButton>
|
||||||
|
{cm.page.compactDescriptionAfter}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const modalConfig = pendingCardId
|
||||||
|
? (() => {
|
||||||
|
const method = methodById.get(pendingCardId);
|
||||||
|
return {
|
||||||
|
title: method?.label ?? cm.confirmModal.title,
|
||||||
|
description: method?.supportText ?? cm.confirmModal.description,
|
||||||
|
nextButtonText: cm.addApproach.nextButtonText,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
: {
|
||||||
|
title: cm.confirmModal.title,
|
||||||
|
description: cm.confirmModal.description,
|
||||||
|
nextButtonText: cm.confirmModal.nextButtonText,
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
setPendingCardId(id);
|
||||||
|
setPendingDraft(seedDraft(id));
|
||||||
|
setCreateModalOpen(true);
|
||||||
|
},
|
||||||
|
[markCreateFlowInteraction, seedDraft],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDraftChange = useCallback(
|
||||||
|
(next: ConflictManagementDetailEntry) => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setPendingDraft(next);
|
||||||
|
},
|
||||||
|
[markCreateFlowInteraction],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateModalClose = useCallback(() => {
|
||||||
|
setCreateModalOpen(false);
|
||||||
|
setPendingCardId(null);
|
||||||
|
setPendingDraft(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateModalConfirm = useCallback(() => {
|
||||||
|
if (!pendingCardId || !pendingDraft) {
|
||||||
|
handleCreateModalClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
updateState({
|
||||||
|
selectedConflictManagementIds: selectedIds.includes(pendingCardId)
|
||||||
|
? selectedIds
|
||||||
|
: [...selectedIds, pendingCardId],
|
||||||
|
conflictManagementDetailsById: {
|
||||||
|
...(state.conflictManagementDetailsById ?? {}),
|
||||||
|
[pendingCardId]: pendingDraft,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
handleCreateModalClose();
|
||||||
|
}, [
|
||||||
|
handleCreateModalClose,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
pendingCardId,
|
||||||
|
pendingDraft,
|
||||||
|
selectedIds,
|
||||||
|
state.conflictManagementDetailsById,
|
||||||
|
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}
|
||||||
|
onNext={handleCreateModalConfirm}
|
||||||
|
title={modalConfig.title}
|
||||||
|
description={modalConfig.description}
|
||||||
|
nextButtonText={modalConfig.nextButtonText}
|
||||||
|
showBackButton={false}
|
||||||
|
backdropVariant="loginYellow"
|
||||||
|
>
|
||||||
|
{pendingCardId && pendingDraft ? (
|
||||||
|
<ConflictManagementEditFields
|
||||||
|
key={pendingCardId}
|
||||||
|
value={pendingDraft}
|
||||||
|
onChange={handleDraftChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Create>
|
||||||
|
</CreateFlowStepShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"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 } from "react";
|
||||||
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
|
import {
|
||||||
|
deriveCompactCards,
|
||||||
|
rankMethodsByScore,
|
||||||
|
useFacetRecommendations,
|
||||||
|
} from "../../hooks/useFacetRecommendations";
|
||||||
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
|
import CardStack from "../../../../components/utility/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 { membershipPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
|
||||||
|
import type { MembershipMethodDetailEntry } from "../../types";
|
||||||
|
|
||||||
|
export function MembershipMethodsScreen() {
|
||||||
|
const m = useMessages();
|
||||||
|
const mem = m.create.customRule.membership;
|
||||||
|
const mdUp = useCreateFlowMdUp();
|
||||||
|
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||||
|
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 selectedIds = state.selectedMembershipMethodIds ?? [];
|
||||||
|
|
||||||
|
const { scoresBySlug, hasAnyFacets } =
|
||||||
|
useFacetRecommendations("membership");
|
||||||
|
const rankedMethods = useMemo(
|
||||||
|
() => rankMethodsByScore(mem.methods, scoresBySlug),
|
||||||
|
[mem.methods, scoresBySlug],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { compactCardIds, recommendedIds } = useMemo(
|
||||||
|
() => deriveCompactCards(rankedMethods, scoresBySlug, hasAnyFacets, 5),
|
||||||
|
[rankedMethods, scoresBySlug, hasAnyFacets],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sampleCards = useMemo(
|
||||||
|
() =>
|
||||||
|
rankedMethods.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
label: entry.label,
|
||||||
|
supportText: entry.supportText,
|
||||||
|
recommended: recommendedIds.has(entry.id),
|
||||||
|
})),
|
||||||
|
[rankedMethods, recommendedIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const methodById = useMemo(
|
||||||
|
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
|
||||||
|
[rankedMethods],
|
||||||
|
);
|
||||||
|
|
||||||
|
const title = expanded ? mem.page.expandedTitle : mem.page.compactTitle;
|
||||||
|
|
||||||
|
const description = expanded ? (
|
||||||
|
mem.page.expandedDescription
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{mem.page.compactDescriptionBefore}
|
||||||
|
<InlineTextButton
|
||||||
|
onClick={() => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setExpanded(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mem.page.compactDescriptionLinkLabel}
|
||||||
|
</InlineTextButton>
|
||||||
|
{mem.page.compactDescriptionAfter}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const modalConfig = pendingCardId
|
||||||
|
? (() => {
|
||||||
|
const method = methodById.get(pendingCardId);
|
||||||
|
return {
|
||||||
|
title: method?.label ?? mem.confirmModal.title,
|
||||||
|
description: method?.supportText ?? mem.confirmModal.description,
|
||||||
|
nextButtonText: mem.addPlatform.nextButtonText,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
: {
|
||||||
|
title: mem.confirmModal.title,
|
||||||
|
description: mem.confirmModal.description,
|
||||||
|
nextButtonText: mem.confirmModal.nextButtonText,
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
setPendingCardId(id);
|
||||||
|
setPendingDraft(seedDraft(id));
|
||||||
|
setCreateModalOpen(true);
|
||||||
|
},
|
||||||
|
[markCreateFlowInteraction, seedDraft],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDraftChange = useCallback(
|
||||||
|
(next: MembershipMethodDetailEntry) => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setPendingDraft(next);
|
||||||
|
},
|
||||||
|
[markCreateFlowInteraction],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateModalClose = useCallback(() => {
|
||||||
|
setCreateModalOpen(false);
|
||||||
|
setPendingCardId(null);
|
||||||
|
setPendingDraft(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateModalConfirm = useCallback(() => {
|
||||||
|
if (!pendingCardId || !pendingDraft) {
|
||||||
|
handleCreateModalClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
updateState({
|
||||||
|
selectedMembershipMethodIds: selectedIds.includes(pendingCardId)
|
||||||
|
? selectedIds
|
||||||
|
: [...selectedIds, pendingCardId],
|
||||||
|
membershipMethodDetailsById: {
|
||||||
|
...(state.membershipMethodDetailsById ?? {}),
|
||||||
|
[pendingCardId]: pendingDraft,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
handleCreateModalClose();
|
||||||
|
}, [
|
||||||
|
handleCreateModalClose,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
pendingCardId,
|
||||||
|
pendingDraft,
|
||||||
|
selectedIds,
|
||||||
|
state.membershipMethodDetailsById,
|
||||||
|
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}
|
||||||
|
onNext={handleCreateModalConfirm}
|
||||||
|
title={modalConfig.title}
|
||||||
|
description={modalConfig.description}
|
||||||
|
nextButtonText={modalConfig.nextButtonText}
|
||||||
|
showBackButton={false}
|
||||||
|
backdropVariant="loginYellow"
|
||||||
|
>
|
||||||
|
{pendingCardId && pendingDraft ? (
|
||||||
|
<MembershipMethodEditFields
|
||||||
|
key={pendingCardId}
|
||||||
|
value={pendingDraft}
|
||||||
|
onChange={handleDraftChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Create>
|
||||||
|
</CreateFlowStepShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
+7
-7
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import CommunityRuleDocument from "../../../components/sections/CommunityRuleDocument";
|
import CommunityRuleDocument from "../../../../components/sections/CommunityRuleDocument";
|
||||||
import type { CommunityRuleDocumentSection } from "../../../components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
|
import type { CommunityRuleDocumentSection } from "../../../../components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
|
||||||
import Alert from "../../../components/modals/Alert";
|
import Alert from "../../../../components/modals/Alert";
|
||||||
import { useMessages } from "../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { parseDocumentSectionsForDisplay } from "../../../../lib/create/buildPublishPayload";
|
import { parseDocumentSectionsForDisplay } from "../../../../../lib/create/buildPublishPayload";
|
||||||
import { readLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
|
import { readLastPublishedRule } from "../../../../../lib/create/lastPublishedRule";
|
||||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
export function CompletedScreen() {
|
export function CompletedScreen() {
|
||||||
const mdUp = useCreateFlowMdUp();
|
const mdUp = useCreateFlowMdUp();
|
||||||
const m = useMessages();
|
const m = useMessages();
|
||||||
const completed = m.create.completed;
|
const completed = m.create.reviewAndComplete.completed;
|
||||||
|
|
||||||
const fallbackSections = useMemo(
|
const fallbackSections = useMemo(
|
||||||
() =>
|
() =>
|
||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import NumberedList from "../../../components/type/NumberedList";
|
import NumberedList from "../../../../components/type/NumberedList";
|
||||||
import { useMessages } from "../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||||
@@ -14,7 +14,7 @@ import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowL
|
|||||||
*/
|
*/
|
||||||
export function InformationalScreen() {
|
export function InformationalScreen() {
|
||||||
const mdUp = useCreateFlowMdUp();
|
const mdUp = useCreateFlowMdUp();
|
||||||
const copy = useMessages().create.informational;
|
const copy = useMessages().create.community.informational;
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import RuleCard from "../../../../components/cards/RuleCard";
|
||||||
|
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||||
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
|
import { useCreateFlowLgUp } from "../../hooks/useCreateFlowLgUp";
|
||||||
|
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||||
|
import {
|
||||||
|
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
|
||||||
|
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
||||||
|
} from "../../components/createFlowLayoutTokens";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Targets for a `pendingTemplateAction` redirect. Customize resumes the
|
||||||
|
* custom-rule stage with chips already prefilled; useWithoutChanges jumps to
|
||||||
|
* the review-and-complete stage since the template body is already in state.
|
||||||
|
*/
|
||||||
|
const PENDING_TEMPLATE_REDIRECT_TARGET: Record<
|
||||||
|
"customize" | "useWithoutChanges",
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
customize: "/create/core-values",
|
||||||
|
useWithoutChanges: "/create/confirm-stakeholders",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */
|
||||||
|
export function CommunityReviewScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const lgUp = useCreateFlowLgUp();
|
||||||
|
const t = useTranslation("create.community.review");
|
||||||
|
const { state, updateState } = useCreateFlow();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the user picked "Customize" or "Use without changes" from a template
|
||||||
|
* before entering community stage, we pinned `pendingTemplateAction` so
|
||||||
|
* this screen can skip itself — they already expressed their intent, no
|
||||||
|
* reason to make them re-pick from the review footer. We `replace` (not
|
||||||
|
* `push`) so Back from the destination goes to `community-save` instead of
|
||||||
|
* bouncing through here again. The action is cleared synchronously via
|
||||||
|
* `updateState` to guarantee the redirect only fires once: later visits to
|
||||||
|
* `/create/review` (e.g. navigating here directly) render normally.
|
||||||
|
*
|
||||||
|
* Ref guard covers React 18 StrictMode's double-mount in dev so we don't
|
||||||
|
* fire `router.replace` twice on the same transition.
|
||||||
|
*/
|
||||||
|
const firedRedirectRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (firedRedirectRef.current) return;
|
||||||
|
const pending = state.pendingTemplateAction;
|
||||||
|
if (!pending) return;
|
||||||
|
const target = PENDING_TEMPLATE_REDIRECT_TARGET[pending.mode];
|
||||||
|
if (!target) return;
|
||||||
|
firedRedirectRef.current = true;
|
||||||
|
updateState({ pendingTemplateAction: undefined });
|
||||||
|
router.replace(target);
|
||||||
|
}, [router, state.pendingTemplateAction, updateState]);
|
||||||
|
|
||||||
|
const cardTitle =
|
||||||
|
typeof state.title === "string" && state.title.trim().length > 0
|
||||||
|
? state.title.trim()
|
||||||
|
: t("ruleCard.title");
|
||||||
|
/**
|
||||||
|
* No placeholder fallback: if the user skipped `community-context`, leave
|
||||||
|
* the card description off rather than render the old "Mutual Aid Monday
|
||||||
|
* is a grassroots community…" sample, which read as real user copy.
|
||||||
|
*/
|
||||||
|
const cardDescription =
|
||||||
|
typeof state.communityContext === "string" &&
|
||||||
|
state.communityContext.trim().length > 0
|
||||||
|
? state.communityContext.trim()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreateFlowStepShell
|
||||||
|
variant="wideGridLoosePadding"
|
||||||
|
contentTopBelowMd="space-1400"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex w-full min-w-0 flex-col items-center gap-6 lg:mx-auto lg:w-full lg:grid lg:grid-cols-2 lg:items-center lg:justify-items-center lg:gap-x-[var(--measures-spacing-1200,48px)] lg:gap-y-6 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex flex-col justify-center lg:min-h-[212px] ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||||
|
>
|
||||||
|
<CreateFlowHeaderLockup
|
||||||
|
title={t("header.title")}
|
||||||
|
description={t("header.description")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={CREATE_FLOW_MD_UP_GRID_CELL_CLASS}>
|
||||||
|
<RuleCard
|
||||||
|
title={cardTitle}
|
||||||
|
description={cardDescription}
|
||||||
|
size={lgUp ? "L" : "M"}
|
||||||
|
expanded={false}
|
||||||
|
backgroundColor="bg-[var(--color-teal-teal50,#c9fef9)]"
|
||||||
|
logoUrl="/assets/Vector_MutualAid.svg"
|
||||||
|
logoAlt={cardTitle}
|
||||||
|
className="rounded-[24px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CreateFlowStepShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import RuleCard from "../../../../components/cards/RuleCard";
|
||||||
|
import type { Category } from "../../../../components/cards/RuleCard/RuleCard.types";
|
||||||
|
import { TemplateChipDetailModal } from "../../../../components/cards/TemplateReviewCard/TemplateChipDetailModal";
|
||||||
|
import { useMessages, useTranslation } from "../../../../contexts/MessagesContext";
|
||||||
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
|
import {
|
||||||
|
CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS,
|
||||||
|
CreateFlowLockupCardStepShell,
|
||||||
|
} from "../../components/CreateFlowLockupCardStepShell";
|
||||||
|
import {
|
||||||
|
buildFinalReviewCategoryRowsDetailed,
|
||||||
|
type FinalReviewCategoryRowDetailed,
|
||||||
|
} from "../../../../../lib/create/buildFinalReviewCategories";
|
||||||
|
import { applyFinalReviewChipEditPatch } from "../../../../../lib/create/applyFinalReviewChipEditPatch";
|
||||||
|
import type { TemplateChipDetail } from "../../../../../lib/create/templateReviewMapping";
|
||||||
|
import {
|
||||||
|
FinalReviewChipEditModal,
|
||||||
|
type FinalReviewChipEditPatch,
|
||||||
|
type FinalReviewChipEditTarget,
|
||||||
|
} from "../../components/FinalReviewChipEditModal";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `finalReview.json.categories` ships a demo ordering + localized names
|
||||||
|
* (Values / Communication / Membership / Decision-making / Conflict
|
||||||
|
* management). We reuse that ordering for the state-derived rows so the
|
||||||
|
* RuleCard layout stays stable across customize / use-without-changes /
|
||||||
|
* plain-custom flows, and fall back to the demo chips when state resolves
|
||||||
|
* to nothing selected.
|
||||||
|
*/
|
||||||
|
function readFallbackCategoryRows(
|
||||||
|
categories: readonly { name: string; chips: readonly string[] }[],
|
||||||
|
): {
|
||||||
|
names: {
|
||||||
|
values: string;
|
||||||
|
communication: string;
|
||||||
|
membership: string;
|
||||||
|
decisions: string;
|
||||||
|
conflict: string;
|
||||||
|
};
|
||||||
|
rows: FinalReviewCategoryRowDetailed[];
|
||||||
|
} {
|
||||||
|
const get = (i: number): string =>
|
||||||
|
typeof categories[i]?.name === "string" ? categories[i].name : "";
|
||||||
|
return {
|
||||||
|
names: {
|
||||||
|
values: get(0),
|
||||||
|
communication: get(1),
|
||||||
|
membership: get(2),
|
||||||
|
decisions: get(3),
|
||||||
|
conflict: get(4),
|
||||||
|
},
|
||||||
|
rows: categories.map((c) => ({
|
||||||
|
name: c.name,
|
||||||
|
groupKey: null,
|
||||||
|
entries: [...c.chips].map((label) => ({
|
||||||
|
label,
|
||||||
|
groupKey: null,
|
||||||
|
overrideKey: null,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FinalReviewScreen() {
|
||||||
|
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||||
|
const mdUp = useCreateFlowMdUp();
|
||||||
|
const t = useTranslation("create.reviewAndComplete.finalReview");
|
||||||
|
const m = useMessages();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two modals coexist on this screen:
|
||||||
|
*
|
||||||
|
* - {@link FinalReviewChipEditModal} — editable Save-button version used
|
||||||
|
* whenever the chip resolves to a stable `overrideKey` (core-value
|
||||||
|
* chip id, or a method preset id). Writes through to
|
||||||
|
* `{group}DetailsById` state fields on Save; close-without-save is a
|
||||||
|
* no-op so any typed edits are discarded.
|
||||||
|
* - {@link TemplateChipDetailModal} — read-only fallback for chips we
|
||||||
|
* can't map to an override key (e.g. template body entries on the
|
||||||
|
* "Use without changes" path where no preset matches the title).
|
||||||
|
*
|
||||||
|
* `activeEditTarget` drives the editable modal; `activeReadOnlyDetail`
|
||||||
|
* drives the read-only modal; only one is ever non-null at a time.
|
||||||
|
*/
|
||||||
|
const [activeEditTarget, setActiveEditTarget] =
|
||||||
|
useState<FinalReviewChipEditTarget | null>(null);
|
||||||
|
const [activeReadOnlyDetail, setActiveReadOnlyDetail] =
|
||||||
|
useState<TemplateChipDetail | null>(null);
|
||||||
|
|
||||||
|
const handleSave = useCallback(
|
||||||
|
(patch: FinalReviewChipEditPatch) => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
updateState(applyFinalReviewChipEditPatch(state, patch));
|
||||||
|
},
|
||||||
|
[markCreateFlowInteraction, updateState, state],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { categories: finalReviewCategories, chipLookup } = useMemo(() => {
|
||||||
|
const { names, rows: fallbackRows } = readFallbackCategoryRows(
|
||||||
|
m.create.reviewAndComplete.finalReview.categories,
|
||||||
|
);
|
||||||
|
const derived = buildFinalReviewCategoryRowsDetailed(state, names);
|
||||||
|
const rowsToRender: readonly FinalReviewCategoryRowDetailed[] =
|
||||||
|
derived.length > 0 ? derived : fallbackRows;
|
||||||
|
|
||||||
|
const lookup = new Map<
|
||||||
|
string,
|
||||||
|
{ target: FinalReviewChipEditTarget | null; readOnly: TemplateChipDetail }
|
||||||
|
>();
|
||||||
|
|
||||||
|
const cats: Category[] = rowsToRender.map((row) => {
|
||||||
|
const chipOptions = row.entries.map((entry, idx) => {
|
||||||
|
const chipId = `${row.name}-${idx}`;
|
||||||
|
const readOnly: TemplateChipDetail = {
|
||||||
|
chipId,
|
||||||
|
chipLabel: entry.label,
|
||||||
|
categoryName: row.name,
|
||||||
|
groupKey: entry.groupKey,
|
||||||
|
body: "",
|
||||||
|
};
|
||||||
|
const target: FinalReviewChipEditTarget | null =
|
||||||
|
entry.groupKey && entry.overrideKey
|
||||||
|
? {
|
||||||
|
overrideKey: entry.overrideKey,
|
||||||
|
groupKey: entry.groupKey,
|
||||||
|
chipLabel: entry.label,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
lookup.set(chipId, { target, readOnly });
|
||||||
|
return {
|
||||||
|
id: chipId,
|
||||||
|
label: entry.label,
|
||||||
|
state: "unselected" as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
name: row.name,
|
||||||
|
chipOptions,
|
||||||
|
onChipClick: (_categoryName: string, chipId: string) => {
|
||||||
|
const hit = lookup.get(chipId);
|
||||||
|
if (!hit) return;
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
if (hit.target) {
|
||||||
|
setActiveEditTarget(hit.target);
|
||||||
|
} else {
|
||||||
|
setActiveReadOnlyDetail(hit.readOnly);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { categories: cats, chipLookup: lookup };
|
||||||
|
}, [
|
||||||
|
m.create.reviewAndComplete.finalReview.categories,
|
||||||
|
state,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
]);
|
||||||
|
void chipLookup;
|
||||||
|
|
||||||
|
const ruleCardTitle = useMemo(() => {
|
||||||
|
const raw = typeof state.title === "string" ? state.title.trim() : "";
|
||||||
|
return raw.length > 0 ? raw : t("ruleCardTitleFallback");
|
||||||
|
}, [state.title, t]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match {@link CommunityReviewScreen}: the card body is the free-text
|
||||||
|
* `community-context` field only — not `summary` (template / one-line
|
||||||
|
* rule summary can carry template-review copy).
|
||||||
|
*/
|
||||||
|
const ruleCardDescription = useMemo(() => {
|
||||||
|
const raw =
|
||||||
|
typeof state.communityContext === "string"
|
||||||
|
? state.communityContext.trim()
|
||||||
|
: "";
|
||||||
|
return raw.length > 0 ? raw : undefined;
|
||||||
|
}, [state.communityContext]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreateFlowLockupCardStepShell
|
||||||
|
lockupTitle={t("title")}
|
||||||
|
lockupDescription={t("description")}
|
||||||
|
>
|
||||||
|
<RuleCard
|
||||||
|
title={ruleCardTitle}
|
||||||
|
description={ruleCardDescription}
|
||||||
|
size={mdUp ? "L" : "M"}
|
||||||
|
expanded={true}
|
||||||
|
backgroundColor="bg-[#c9fef9]"
|
||||||
|
logoUrl="/assets/Vector_MutualAid.svg"
|
||||||
|
logoAlt={ruleCardTitle}
|
||||||
|
categories={finalReviewCategories}
|
||||||
|
className={CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS}
|
||||||
|
onClick={() => {}}
|
||||||
|
/>
|
||||||
|
<FinalReviewChipEditModal
|
||||||
|
isOpen={activeEditTarget !== null}
|
||||||
|
onClose={() => setActiveEditTarget(null)}
|
||||||
|
target={activeEditTarget}
|
||||||
|
state={state}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
<TemplateChipDetailModal
|
||||||
|
isOpen={activeReadOnlyDetail !== null}
|
||||||
|
onClose={() => setActiveReadOnlyDetail(null)}
|
||||||
|
detail={activeReadOnlyDetail}
|
||||||
|
/>
|
||||||
|
</CreateFlowLockupCardStepShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `decision-approaches` step — Figma “Flow — Right Rail” (node `20523-23509`).
|
||||||
|
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["decision-approaches"]` (`layoutKind: "right-rail"`).
|
||||||
|
*
|
||||||
|
* Layout matches {@link CreateFlowTwoColumnSelectShell}: one column below `lg` (1024px), two columns
|
||||||
|
* at `lg+` with a scrollable rail — same breakpoint and height chain as select steps, distinct content.
|
||||||
|
*
|
||||||
|
* Card click opens the Figma "Add Approach" create modal (node `20870-72155`) with five controls
|
||||||
|
* rendered by {@link DecisionApproachEditFields}: Core Principle, Applicable Scope, Step-by-Step
|
||||||
|
* Instructions, Consensus Level, and Objections & Deadlocks. The same field set is reused on
|
||||||
|
* `/create/final-review` — see `FinalReviewChipEditModal`. Confirm persists both the chip
|
||||||
|
* selection and any user edits as a `decisionApproachDetailsById[id]` override; section
|
||||||
|
* defaults come from `messages/en/create/customRule/decisionApproaches.json` and will be
|
||||||
|
* replaced with DB-driven content.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useMemo } from "react";
|
||||||
|
import DecisionMakingSidebar from "../../../../components/utility/DecisionMakingSidebar";
|
||||||
|
import CardStack from "../../../../components/utility/CardStack";
|
||||||
|
import Create from "../../../../components/modals/Create";
|
||||||
|
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
||||||
|
import type { InfoMessageBoxItem } from "../../../../components/utility/InfoMessageBox/InfoMessageBox.types";
|
||||||
|
import type { CardStackItem } from "../../../../components/utility/CardStack/CardStack.types";
|
||||||
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
|
import {
|
||||||
|
deriveCompactCards,
|
||||||
|
rankMethodsByScore,
|
||||||
|
useFacetRecommendations,
|
||||||
|
} from "../../hooks/useFacetRecommendations";
|
||||||
|
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||||
|
import { DecisionApproachEditFields } from "../../components/methodEditFields";
|
||||||
|
import { decisionApproachPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
|
||||||
|
import type { DecisionApproachDetailEntry } from "../../types";
|
||||||
|
|
||||||
|
export function DecisionApproachesScreen() {
|
||||||
|
const m = useMessages();
|
||||||
|
const da = m.create.customRule.decisionApproaches;
|
||||||
|
const mdUp = useCreateFlowMdUp();
|
||||||
|
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||||
|
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
|
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
|
||||||
|
const [pendingDraft, setPendingDraft] =
|
||||||
|
useState<DecisionApproachDetailEntry | null>(null);
|
||||||
|
|
||||||
|
const selectedIds = state.selectedDecisionApproachIds ?? [];
|
||||||
|
|
||||||
|
const messageBoxItems: InfoMessageBoxItem[] = useMemo(
|
||||||
|
() =>
|
||||||
|
da.messageBox.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
label: item.label,
|
||||||
|
})),
|
||||||
|
[da.messageBox.items],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { scoresBySlug, hasAnyFacets } =
|
||||||
|
useFacetRecommendations("decisionApproaches");
|
||||||
|
const rankedMethods = useMemo(
|
||||||
|
() => rankMethodsByScore(da.methods, scoresBySlug),
|
||||||
|
[da.methods, scoresBySlug],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { compactCardIds, recommendedIds } = useMemo(
|
||||||
|
() => deriveCompactCards(rankedMethods, scoresBySlug, hasAnyFacets, 5),
|
||||||
|
[rankedMethods, scoresBySlug, hasAnyFacets],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sampleCards: CardStackItem[] = useMemo(
|
||||||
|
() =>
|
||||||
|
rankedMethods.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
label: entry.label,
|
||||||
|
supportText: entry.supportText,
|
||||||
|
recommended: recommendedIds.has(entry.id),
|
||||||
|
})),
|
||||||
|
[rankedMethods, recommendedIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const methodById = useMemo(
|
||||||
|
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
|
||||||
|
[rankedMethods],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sidebarDescription = (
|
||||||
|
<>
|
||||||
|
{da.sidebar.descriptionBefore}
|
||||||
|
<InlineTextButton
|
||||||
|
onClick={() => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setExpanded(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{da.sidebar.descriptionLinkLabel}
|
||||||
|
</InlineTextButton>
|
||||||
|
{da.sidebar.descriptionAfter}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMessageBoxCheckboxChange = useCallback(
|
||||||
|
(id: string, checked: boolean) => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setMessageBoxCheckedIds((prev) =>
|
||||||
|
checked ? [...prev, id] : prev.filter((x) => x !== id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[markCreateFlowInteraction],
|
||||||
|
);
|
||||||
|
|
||||||
|
const seedDraft = useCallback(
|
||||||
|
(id: string): DecisionApproachDetailEntry => {
|
||||||
|
const saved = state.decisionApproachDetailsById?.[id];
|
||||||
|
if (saved) {
|
||||||
|
return {
|
||||||
|
...saved,
|
||||||
|
applicableScope: [...saved.applicableScope],
|
||||||
|
selectedApplicableScope: [...saved.selectedApplicableScope],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return decisionApproachPresetFor(id);
|
||||||
|
},
|
||||||
|
[state.decisionApproachDetailsById],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCardSelect = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setPendingCardId(id);
|
||||||
|
setPendingDraft(seedDraft(id));
|
||||||
|
setCreateModalOpen(true);
|
||||||
|
},
|
||||||
|
[markCreateFlowInteraction, seedDraft],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDraftChange = useCallback(
|
||||||
|
(next: DecisionApproachDetailEntry) => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setPendingDraft(next);
|
||||||
|
},
|
||||||
|
[markCreateFlowInteraction],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleExpand = useCallback(() => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setExpanded((prev) => !prev);
|
||||||
|
}, [markCreateFlowInteraction]);
|
||||||
|
|
||||||
|
const handleCreateModalClose = useCallback(() => {
|
||||||
|
setCreateModalOpen(false);
|
||||||
|
setPendingCardId(null);
|
||||||
|
setPendingDraft(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateModalConfirm = useCallback(() => {
|
||||||
|
if (!pendingCardId || !pendingDraft) {
|
||||||
|
handleCreateModalClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
updateState({
|
||||||
|
selectedDecisionApproachIds: selectedIds.includes(pendingCardId)
|
||||||
|
? selectedIds
|
||||||
|
: [...selectedIds, pendingCardId],
|
||||||
|
decisionApproachDetailsById: {
|
||||||
|
...(state.decisionApproachDetailsById ?? {}),
|
||||||
|
[pendingCardId]: pendingDraft,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
handleCreateModalClose();
|
||||||
|
}, [
|
||||||
|
handleCreateModalClose,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
pendingCardId,
|
||||||
|
pendingDraft,
|
||||||
|
selectedIds,
|
||||||
|
state.decisionApproachDetailsById,
|
||||||
|
updateState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const modalConfig = pendingCardId
|
||||||
|
? (() => {
|
||||||
|
const method = methodById.get(pendingCardId);
|
||||||
|
return {
|
||||||
|
title: method?.label ?? da.confirmModal.title,
|
||||||
|
description: method?.supportText ?? da.confirmModal.description,
|
||||||
|
nextButtonText: da.addApproach.nextButtonText,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
: {
|
||||||
|
title: da.confirmModal.title,
|
||||||
|
description: da.confirmModal.description,
|
||||||
|
nextButtonText: da.confirmModal.nextButtonText,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreateFlowTwoColumnSelectShell
|
||||||
|
contentTopBelowMd="space-800"
|
||||||
|
lgVerticalAlign="start"
|
||||||
|
header={
|
||||||
|
<DecisionMakingSidebar
|
||||||
|
title={da.sidebar.title}
|
||||||
|
description={sidebarDescription}
|
||||||
|
messageBoxTitle={da.messageBox.title}
|
||||||
|
messageBoxItems={messageBoxItems}
|
||||||
|
messageBoxCheckedIds={messageBoxCheckedIds}
|
||||||
|
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
|
||||||
|
size={mdUp ? "L" : "M"}
|
||||||
|
justification={mdUp ? "left" : "center"}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex w-full min-w-0 flex-col items-stretch gap-6 py-0">
|
||||||
|
<CardStack
|
||||||
|
cards={sampleCards}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
onCardSelect={handleCardSelect}
|
||||||
|
expanded={expanded}
|
||||||
|
onToggleExpand={handleToggleExpand}
|
||||||
|
hasMore={true}
|
||||||
|
toggleLabel={da.cardStack.toggleSeeAll}
|
||||||
|
showLessLabel={da.cardStack.toggleShowLess}
|
||||||
|
title=""
|
||||||
|
description=""
|
||||||
|
layout="singleStack"
|
||||||
|
compactRecommendedLimit={5}
|
||||||
|
compactCardIds={compactCardIds}
|
||||||
|
className="w-full"
|
||||||
|
headerLockupSize={mdUp ? "L" : "M"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Create
|
||||||
|
isOpen={createModalOpen}
|
||||||
|
onClose={handleCreateModalClose}
|
||||||
|
onNext={handleCreateModalConfirm}
|
||||||
|
title={modalConfig.title}
|
||||||
|
description={modalConfig.description}
|
||||||
|
nextButtonText={modalConfig.nextButtonText}
|
||||||
|
showBackButton={false}
|
||||||
|
backdropVariant="loginYellow"
|
||||||
|
>
|
||||||
|
{pendingCardId && pendingDraft ? (
|
||||||
|
<DecisionApproachEditFields
|
||||||
|
key={pendingCardId}
|
||||||
|
value={pendingDraft}
|
||||||
|
onChange={handleDraftChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Create>
|
||||||
|
</CreateFlowTwoColumnSelectShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
+25
-36
@@ -1,13 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import MultiSelect from "../../../components/controls/MultiSelect";
|
import MultiSelect from "../../../../components/controls/MultiSelect";
|
||||||
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
|
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
|
||||||
import { useMessages } from "../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
|
||||||
|
|
||||||
function chipRowsFromLabels(
|
function chipRowsFromLabels(
|
||||||
rows: readonly { label: string }[],
|
rows: readonly { label: string }[],
|
||||||
@@ -15,20 +14,20 @@ function chipRowsFromLabels(
|
|||||||
return rows.map((row, i) => ({
|
return rows.map((row, i) => ({
|
||||||
id: String(i + 1),
|
id: String(i + 1),
|
||||||
label: row.label,
|
label: row.label,
|
||||||
state: "Unselected" as const,
|
state: "unselected" as const,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedIdsFromOptions(options: ChipOption[]): string[] {
|
function selectedIdsFromOptions(options: ChipOption[]): string[] {
|
||||||
return options
|
return options
|
||||||
.filter((o) => o.state === "Selected")
|
.filter((o) => o.state === "selected")
|
||||||
.map((o) => o.id);
|
.map((o) => o.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create Community — Figma `20094:41317`, chips only (layout tokens shared with structure select). */
|
/** Create Community — Figma `20094:41317`, chips only (layout tokens shared with structure select). */
|
||||||
export function CommunitySizeSelectScreen() {
|
export function CommunitySizeSelectScreen() {
|
||||||
const m = useMessages();
|
const m = useMessages();
|
||||||
const cs = m.create.communitySize;
|
const cs = m.create.community.communitySize;
|
||||||
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
|
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
|
||||||
|
|
||||||
const [communitySizeOptions, setCommunitySizeOptions] = useState<
|
const [communitySizeOptions, setCommunitySizeOptions] = useState<
|
||||||
@@ -38,7 +37,7 @@ export function CommunitySizeSelectScreen() {
|
|||||||
const selected = new Set(state.selectedCommunitySizeIds ?? []);
|
const selected = new Set(state.selectedCommunitySizeIds ?? []);
|
||||||
return base.map((opt) => ({
|
return base.map((opt) => ({
|
||||||
...opt,
|
...opt,
|
||||||
state: selected.has(opt.id) ? ("Selected" as const) : ("Unselected" as const),
|
state: selected.has(opt.id) ? ("selected" as const) : ("unselected" as const),
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,13 +45,13 @@ export function CommunitySizeSelectScreen() {
|
|||||||
const selected = new Set(state.selectedCommunitySizeIds ?? []);
|
const selected = new Set(state.selectedCommunitySizeIds ?? []);
|
||||||
setCommunitySizeOptions((prev) =>
|
setCommunitySizeOptions((prev) =>
|
||||||
prev.map((opt) =>
|
prev.map((opt) =>
|
||||||
opt.state === "Custom"
|
opt.state === "custom"
|
||||||
? opt
|
? opt
|
||||||
: {
|
: {
|
||||||
...opt,
|
...opt,
|
||||||
state: selected.has(opt.id)
|
state: selected.has(opt.id)
|
||||||
? ("Selected" as const)
|
? ("selected" as const)
|
||||||
: ("Unselected" as const),
|
: ("unselected" as const),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -72,9 +71,9 @@ export function CommunitySizeSelectScreen() {
|
|||||||
? {
|
? {
|
||||||
...opt,
|
...opt,
|
||||||
state:
|
state:
|
||||||
opt.state === "Selected"
|
opt.state === "selected"
|
||||||
? ("Unselected" as const)
|
? ("unselected" as const)
|
||||||
: ("Selected" as const),
|
: ("selected" as const),
|
||||||
}
|
}
|
||||||
: opt,
|
: opt,
|
||||||
);
|
);
|
||||||
@@ -84,7 +83,7 @@ export function CommunitySizeSelectScreen() {
|
|||||||
const multiSelectBlock = (
|
const multiSelectBlock = (
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
size="M"
|
size="m"
|
||||||
options={communitySizeOptions}
|
options={communitySizeOptions}
|
||||||
onChipClick={handleCommunitySizeClick}
|
onChipClick={handleCommunitySizeClick}
|
||||||
addButton={false}
|
addButton={false}
|
||||||
@@ -92,26 +91,16 @@ export function CommunitySizeSelectScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CreateFlowStepShell
|
<CreateFlowTwoColumnSelectShell
|
||||||
variant="centeredNarrow"
|
header={
|
||||||
contentTopBelowMd="space-1400"
|
<CreateFlowHeaderLockup
|
||||||
|
title={cs.header.title}
|
||||||
|
description={cs.header.description}
|
||||||
|
justification="left"
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] md:max-w-[640px] lg:max-w-[1328px] lg:flex-row lg:flex-nowrap lg:items-center lg:justify-center lg:gap-[var(--measures-spacing-1200,48px)]">
|
{multiSelectBlock}
|
||||||
<div
|
</CreateFlowTwoColumnSelectShell>
|
||||||
className={`flex flex-col items-start gap-[var(--measures-spacing-200,8px)] lg:flex-1 lg:justify-center lg:py-[12px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
|
||||||
>
|
|
||||||
<CreateFlowHeaderLockup
|
|
||||||
title={cs.header.title}
|
|
||||||
description={cs.header.description}
|
|
||||||
justification="left"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`flex flex-col items-start gap-[var(--measures-spacing-800,32px)] lg:flex-1 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
|
||||||
>
|
|
||||||
{multiSelectBlock}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CreateFlowStepShell>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+137
-66
@@ -7,17 +7,17 @@ import {
|
|||||||
type Dispatch,
|
type Dispatch,
|
||||||
type SetStateAction,
|
type SetStateAction,
|
||||||
} from "react";
|
} from "react";
|
||||||
import MultiSelect from "../../../components/controls/MultiSelect";
|
import MultiSelect from "../../../../components/controls/MultiSelect";
|
||||||
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
|
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
|
||||||
import { useMessages } from "../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
|
import type { CommunityStructureChipSnapshotRow } from "../../types";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
|
||||||
|
|
||||||
function createListCustomHandlers(
|
function createListCustomHandlers(
|
||||||
setList: Dispatch<SetStateAction<ChipOption[]>>,
|
setList: Dispatch<SetStateAction<ChipOption[]>>,
|
||||||
confirmState: "Unselected" | "Selected",
|
confirmState: "unselected" | "selected",
|
||||||
onInteraction?: () => void,
|
onInteraction?: () => void,
|
||||||
) {
|
) {
|
||||||
const touch = () => onInteraction?.();
|
const touch = () => onInteraction?.();
|
||||||
@@ -26,7 +26,7 @@ function createListCustomHandlers(
|
|||||||
touch();
|
touch();
|
||||||
setList((prev) => [
|
setList((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{ id: crypto.randomUUID(), label: "", state: "Custom" },
|
{ id: crypto.randomUUID(), label: "", state: "custom" },
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
onCustomChipConfirm: (chipId: string, value: string) => {
|
onCustomChipConfirm: (chipId: string, value: string) => {
|
||||||
@@ -52,7 +52,7 @@ function chipRowsFromLabels(
|
|||||||
return rows.map((row, i) => ({
|
return rows.map((row, i) => ({
|
||||||
id: String(i + 1),
|
id: String(i + 1),
|
||||||
label: row.label,
|
label: row.label,
|
||||||
state: "Unselected" as const,
|
state: "unselected" as const,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,73 +62,139 @@ function applySavedSelection(
|
|||||||
): ChipOption[] {
|
): ChipOption[] {
|
||||||
const selected = new Set(saved ?? []);
|
const selected = new Set(saved ?? []);
|
||||||
return options.map((opt) =>
|
return options.map((opt) =>
|
||||||
opt.state === "Custom"
|
opt.state === "custom"
|
||||||
? opt
|
? opt
|
||||||
: {
|
: {
|
||||||
...opt,
|
...opt,
|
||||||
state: selected.has(opt.id)
|
state: selected.has(opt.id)
|
||||||
? ("Selected" as const)
|
? ("selected" as const)
|
||||||
: ("Unselected" as const),
|
: ("unselected" as const),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedIdsFromOptions(options: ChipOption[]): string[] {
|
function selectedIdsFromOptions(options: ChipOption[]): string[] {
|
||||||
return options
|
return options
|
||||||
.filter((o) => o.state === "Selected")
|
.filter((o) => o.state === "selected")
|
||||||
.map((o) => o.id);
|
.map((o) => o.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chipOptionsToSnapshotRows(
|
||||||
|
options: ChipOption[],
|
||||||
|
): CommunityStructureChipSnapshotRow[] {
|
||||||
|
return options.map((o) => ({
|
||||||
|
id: o.id,
|
||||||
|
label: o.label,
|
||||||
|
...(o.state !== undefined ? { state: o.state } : {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns chips when a draft snapshot exists; otherwise null (use preset rows + selected ids). */
|
||||||
|
function snapshotRowsToChipOptions(
|
||||||
|
rows: CommunityStructureChipSnapshotRow[] | undefined,
|
||||||
|
): ChipOption[] | null {
|
||||||
|
if (!Array.isArray(rows) || rows.length === 0) return null;
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
label: r.label,
|
||||||
|
...(r.state !== undefined
|
||||||
|
? { state: r.state as ChipOption["state"] }
|
||||||
|
: {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/** Create Community step 3 — Figma `20094:18244` (responsive grid + column caps via `createFlowLayoutTokens`). */
|
/** Create Community step 3 — Figma `20094:18244` (responsive grid + column caps via `createFlowLayoutTokens`). */
|
||||||
export function CommunityStructureSelectScreen() {
|
export function CommunityStructureSelectScreen() {
|
||||||
const m = useMessages();
|
const m = useMessages();
|
||||||
const cs = m.create.communityStructure;
|
const cs = m.create.community.communityStructure;
|
||||||
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
|
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
|
||||||
|
|
||||||
const [organizationTypeOptions, setOrganizationTypeOptions] = useState<
|
const [organizationTypeOptions, setOrganizationTypeOptions] = useState<
|
||||||
ChipOption[]
|
ChipOption[]
|
||||||
>(() =>
|
>(() => {
|
||||||
applySavedSelection(
|
const fromSnap = snapshotRowsToChipOptions(
|
||||||
|
state.communityStructureChipSnapshots?.organizationTypes,
|
||||||
|
);
|
||||||
|
if (fromSnap) return fromSnap;
|
||||||
|
return applySavedSelection(
|
||||||
chipRowsFromLabels(cs.organizationTypes),
|
chipRowsFromLabels(cs.organizationTypes),
|
||||||
state.selectedOrganizationTypeIds,
|
state.selectedOrganizationTypeIds,
|
||||||
),
|
);
|
||||||
);
|
});
|
||||||
|
|
||||||
const [scaleOptions, setScaleOptions] = useState<ChipOption[]>(() =>
|
const [scaleOptions, setScaleOptions] = useState<ChipOption[]>(() => {
|
||||||
applySavedSelection(
|
const fromSnap = snapshotRowsToChipOptions(
|
||||||
|
state.communityStructureChipSnapshots?.scale,
|
||||||
|
);
|
||||||
|
if (fromSnap) return fromSnap;
|
||||||
|
return applySavedSelection(
|
||||||
chipRowsFromLabels(cs.scaleOptions),
|
chipRowsFromLabels(cs.scaleOptions),
|
||||||
state.selectedScaleIds,
|
state.selectedScaleIds,
|
||||||
),
|
);
|
||||||
);
|
});
|
||||||
|
|
||||||
const [maturityOptions, setMaturityOptions] = useState<ChipOption[]>(() =>
|
const [maturityOptions, setMaturityOptions] = useState<ChipOption[]>(() => {
|
||||||
applySavedSelection(
|
const fromSnap = snapshotRowsToChipOptions(
|
||||||
|
state.communityStructureChipSnapshots?.maturity,
|
||||||
|
);
|
||||||
|
if (fromSnap) return fromSnap;
|
||||||
|
return applySavedSelection(
|
||||||
chipRowsFromLabels(cs.maturityOptions),
|
chipRowsFromLabels(cs.maturityOptions),
|
||||||
state.selectedMaturityIds,
|
state.selectedMaturityIds,
|
||||||
),
|
);
|
||||||
);
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const fromSnap = snapshotRowsToChipOptions(
|
||||||
|
state.communityStructureChipSnapshots?.organizationTypes,
|
||||||
|
);
|
||||||
|
if (fromSnap) {
|
||||||
|
setOrganizationTypeOptions(fromSnap);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setOrganizationTypeOptions((prev) =>
|
setOrganizationTypeOptions((prev) =>
|
||||||
applySavedSelection(prev, state.selectedOrganizationTypeIds),
|
applySavedSelection(prev, state.selectedOrganizationTypeIds),
|
||||||
);
|
);
|
||||||
}, [state.selectedOrganizationTypeIds]);
|
}, [
|
||||||
|
state.communityStructureChipSnapshots?.organizationTypes,
|
||||||
|
state.selectedOrganizationTypeIds,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const fromSnap = snapshotRowsToChipOptions(
|
||||||
|
state.communityStructureChipSnapshots?.scale,
|
||||||
|
);
|
||||||
|
if (fromSnap) {
|
||||||
|
setScaleOptions(fromSnap);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setScaleOptions((prev) => applySavedSelection(prev, state.selectedScaleIds));
|
setScaleOptions((prev) => applySavedSelection(prev, state.selectedScaleIds));
|
||||||
}, [state.selectedScaleIds]);
|
}, [
|
||||||
|
state.communityStructureChipSnapshots?.scale,
|
||||||
|
state.selectedScaleIds,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const fromSnap = snapshotRowsToChipOptions(
|
||||||
|
state.communityStructureChipSnapshots?.maturity,
|
||||||
|
);
|
||||||
|
if (fromSnap) {
|
||||||
|
setMaturityOptions(fromSnap);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setMaturityOptions((prev) =>
|
setMaturityOptions((prev) =>
|
||||||
applySavedSelection(prev, state.selectedMaturityIds),
|
applySavedSelection(prev, state.selectedMaturityIds),
|
||||||
);
|
);
|
||||||
}, [state.selectedMaturityIds]);
|
}, [
|
||||||
|
state.communityStructureChipSnapshots?.maturity,
|
||||||
|
state.selectedMaturityIds,
|
||||||
|
]);
|
||||||
|
|
||||||
const organizationCustomHandlers = useMemo(
|
const organizationCustomHandlers = useMemo(
|
||||||
() =>
|
() =>
|
||||||
createListCustomHandlers(
|
createListCustomHandlers(
|
||||||
setOrganizationTypeOptions,
|
setOrganizationTypeOptions,
|
||||||
"Unselected",
|
"unselected",
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
),
|
),
|
||||||
[markCreateFlowInteraction],
|
[markCreateFlowInteraction],
|
||||||
@@ -137,7 +203,7 @@ export function CommunityStructureSelectScreen() {
|
|||||||
() =>
|
() =>
|
||||||
createListCustomHandlers(
|
createListCustomHandlers(
|
||||||
setScaleOptions,
|
setScaleOptions,
|
||||||
"Unselected",
|
"unselected",
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
),
|
),
|
||||||
[markCreateFlowInteraction],
|
[markCreateFlowInteraction],
|
||||||
@@ -146,7 +212,7 @@ export function CommunityStructureSelectScreen() {
|
|||||||
() =>
|
() =>
|
||||||
createListCustomHandlers(
|
createListCustomHandlers(
|
||||||
setMaturityOptions,
|
setMaturityOptions,
|
||||||
"Unselected",
|
"unselected",
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
),
|
),
|
||||||
[markCreateFlowInteraction],
|
[markCreateFlowInteraction],
|
||||||
@@ -155,19 +221,34 @@ export function CommunityStructureSelectScreen() {
|
|||||||
const persistOrg = (next: ChipOption[]) => {
|
const persistOrg = (next: ChipOption[]) => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
setOrganizationTypeOptions(next);
|
setOrganizationTypeOptions(next);
|
||||||
updateState({ selectedOrganizationTypeIds: selectedIdsFromOptions(next) });
|
updateState({
|
||||||
|
selectedOrganizationTypeIds: selectedIdsFromOptions(next),
|
||||||
|
communityStructureChipSnapshots: {
|
||||||
|
organizationTypes: chipOptionsToSnapshotRows(next),
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const persistScale = (next: ChipOption[]) => {
|
const persistScale = (next: ChipOption[]) => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
setScaleOptions(next);
|
setScaleOptions(next);
|
||||||
updateState({ selectedScaleIds: selectedIdsFromOptions(next) });
|
updateState({
|
||||||
|
selectedScaleIds: selectedIdsFromOptions(next),
|
||||||
|
communityStructureChipSnapshots: {
|
||||||
|
scale: chipOptionsToSnapshotRows(next),
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const persistMaturity = (next: ChipOption[]) => {
|
const persistMaturity = (next: ChipOption[]) => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
setMaturityOptions(next);
|
setMaturityOptions(next);
|
||||||
updateState({ selectedMaturityIds: selectedIdsFromOptions(next) });
|
updateState({
|
||||||
|
selectedMaturityIds: selectedIdsFromOptions(next),
|
||||||
|
communityStructureChipSnapshots: {
|
||||||
|
maturity: chipOptionsToSnapshotRows(next),
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOrganizationTypeClick = (chipId: string) => {
|
const handleOrganizationTypeClick = (chipId: string) => {
|
||||||
@@ -177,9 +258,9 @@ export function CommunityStructureSelectScreen() {
|
|||||||
? {
|
? {
|
||||||
...opt,
|
...opt,
|
||||||
state:
|
state:
|
||||||
opt.state === "Selected"
|
opt.state === "selected"
|
||||||
? ("Unselected" as const)
|
? ("unselected" as const)
|
||||||
: ("Selected" as const),
|
: ("selected" as const),
|
||||||
}
|
}
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
@@ -193,9 +274,9 @@ export function CommunityStructureSelectScreen() {
|
|||||||
? {
|
? {
|
||||||
...opt,
|
...opt,
|
||||||
state:
|
state:
|
||||||
opt.state === "Selected"
|
opt.state === "selected"
|
||||||
? ("Unselected" as const)
|
? ("unselected" as const)
|
||||||
: ("Selected" as const),
|
: ("selected" as const),
|
||||||
}
|
}
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
@@ -209,9 +290,9 @@ export function CommunityStructureSelectScreen() {
|
|||||||
? {
|
? {
|
||||||
...opt,
|
...opt,
|
||||||
state:
|
state:
|
||||||
opt.state === "Selected"
|
opt.state === "selected"
|
||||||
? ("Unselected" as const)
|
? ("unselected" as const)
|
||||||
: ("Selected" as const),
|
: ("selected" as const),
|
||||||
}
|
}
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
@@ -223,7 +304,7 @@ export function CommunityStructureSelectScreen() {
|
|||||||
<MultiSelect
|
<MultiSelect
|
||||||
label={cs.organizationMultiSelect.label}
|
label={cs.organizationMultiSelect.label}
|
||||||
showHelpIcon
|
showHelpIcon
|
||||||
size="S"
|
size="s"
|
||||||
options={organizationTypeOptions}
|
options={organizationTypeOptions}
|
||||||
onChipClick={handleOrganizationTypeClick}
|
onChipClick={handleOrganizationTypeClick}
|
||||||
{...organizationCustomHandlers}
|
{...organizationCustomHandlers}
|
||||||
@@ -233,7 +314,7 @@ export function CommunityStructureSelectScreen() {
|
|||||||
<MultiSelect
|
<MultiSelect
|
||||||
label={cs.scaleMultiSelect.label}
|
label={cs.scaleMultiSelect.label}
|
||||||
showHelpIcon
|
showHelpIcon
|
||||||
size="S"
|
size="s"
|
||||||
options={scaleOptions}
|
options={scaleOptions}
|
||||||
onChipClick={handleScaleClick}
|
onChipClick={handleScaleClick}
|
||||||
{...scaleCustomHandlers}
|
{...scaleCustomHandlers}
|
||||||
@@ -243,7 +324,7 @@ export function CommunityStructureSelectScreen() {
|
|||||||
<MultiSelect
|
<MultiSelect
|
||||||
label={cs.maturityMultiSelect.label}
|
label={cs.maturityMultiSelect.label}
|
||||||
showHelpIcon
|
showHelpIcon
|
||||||
size="S"
|
size="s"
|
||||||
options={maturityOptions}
|
options={maturityOptions}
|
||||||
onChipClick={handleMaturityClick}
|
onChipClick={handleMaturityClick}
|
||||||
{...maturityCustomHandlers}
|
{...maturityCustomHandlers}
|
||||||
@@ -254,26 +335,16 @@ export function CommunityStructureSelectScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CreateFlowStepShell
|
<CreateFlowTwoColumnSelectShell
|
||||||
variant="centeredNarrow"
|
header={
|
||||||
contentTopBelowMd="space-1400"
|
<CreateFlowHeaderLockup
|
||||||
|
title={cs.header.title}
|
||||||
|
description={cs.header.description}
|
||||||
|
justification="left"
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] md:max-w-[640px] lg:max-w-[1328px] lg:flex-row lg:flex-nowrap lg:items-center lg:justify-center lg:gap-[var(--measures-spacing-1200,48px)]">
|
{multiSelectBlock}
|
||||||
<div
|
</CreateFlowTwoColumnSelectShell>
|
||||||
className={`flex flex-col items-start gap-[var(--measures-spacing-200,8px)] lg:flex-1 lg:justify-center lg:py-[12px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
|
||||||
>
|
|
||||||
<CreateFlowHeaderLockup
|
|
||||||
title={cs.header.title}
|
|
||||||
description={cs.header.description}
|
|
||||||
justification="left"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`flex flex-col items-start gap-[var(--measures-spacing-800,32px)] lg:flex-1 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
|
||||||
>
|
|
||||||
{multiSelectBlock}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CreateFlowStepShell>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+8
-8
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import MultiSelect from "../../../components/controls/MultiSelect";
|
import MultiSelect from "../../../../components/controls/MultiSelect";
|
||||||
import Alert from "../../../components/modals/Alert";
|
import Alert from "../../../../components/modals/Alert";
|
||||||
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
|
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
|
||||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||||
@@ -12,7 +12,7 @@ import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowL
|
|||||||
|
|
||||||
export function ConfirmStakeholdersScreen() {
|
export function ConfirmStakeholdersScreen() {
|
||||||
const { markCreateFlowInteraction } = useCreateFlow();
|
const { markCreateFlowInteraction } = useCreateFlow();
|
||||||
const t = useTranslation("create.confirmStakeholders");
|
const t = useTranslation("create.reviewAndComplete.confirmStakeholders");
|
||||||
const [toastDismissed, setToastDismissed] = useState(false);
|
const [toastDismissed, setToastDismissed] = useState(false);
|
||||||
const [stakeholderOptions, setStakeholderOptions] = useState<ChipOption[]>(
|
const [stakeholderOptions, setStakeholderOptions] = useState<ChipOption[]>(
|
||||||
[],
|
[],
|
||||||
@@ -22,7 +22,7 @@ export function ConfirmStakeholdersScreen() {
|
|||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
setStakeholderOptions((prev) => [
|
setStakeholderOptions((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{ id: crypto.randomUUID(), label: "", state: "Custom" },
|
{ id: crypto.randomUUID(), label: "", state: "custom" },
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ export function ConfirmStakeholdersScreen() {
|
|||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
setStakeholderOptions((prev) =>
|
setStakeholderOptions((prev) =>
|
||||||
prev.map((opt) =>
|
prev.map((opt) =>
|
||||||
opt.id === chipId ? { ...opt, label: value, state: "Selected" } : opt,
|
opt.id === chipId ? { ...opt, label: value, state: "selected" } : opt,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -64,7 +64,7 @@ export function ConfirmStakeholdersScreen() {
|
|||||||
<MultiSelect
|
<MultiSelect
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
showHelpIcon={false}
|
showHelpIcon={false}
|
||||||
size="S"
|
size="s"
|
||||||
options={stakeholderOptions}
|
options={stakeholderOptions}
|
||||||
onChipClick={handleChipClick}
|
onChipClick={handleChipClick}
|
||||||
onAddClick={handleAddStakeholder}
|
onAddClick={handleAddStakeholder}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import MultiSelect from "../../../../components/controls/MultiSelect";
|
||||||
|
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
|
||||||
|
import Create from "../../../../components/modals/Create";
|
||||||
|
import ContentLockup from "../../../../components/type/ContentLockup";
|
||||||
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
|
import type {
|
||||||
|
CommunityStructureChipSnapshotRow,
|
||||||
|
CoreValueDetailEntry,
|
||||||
|
} from "../../types";
|
||||||
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
|
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||||
|
import { CoreValueEditFields } from "../../components/methodEditFields";
|
||||||
|
|
||||||
|
const MAX_CORE_VALUES = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why three sessions, not two:
|
||||||
|
*
|
||||||
|
* - `pending` — preset chip just selected; modal opened to capture
|
||||||
|
* meaning/signals. Dismiss = unselect the chip (keep it in the
|
||||||
|
* preset row, just not selected).
|
||||||
|
* - `customPending` — brand-new custom chip just created via the Add
|
||||||
|
* value flow; modal opened with empty fields. Dismiss = drop the
|
||||||
|
* chip entirely (it was never confirmed via the Add Value button).
|
||||||
|
* - `editing` — chip already exists & is selected; modal reopened to
|
||||||
|
* tweak meaning/signals. Dismiss = no-op (chip stays as-is).
|
||||||
|
*/
|
||||||
|
type ModalSession = "pending" | "customPending" | "editing";
|
||||||
|
|
||||||
|
/** Row in `coreValues.json` `values` — string (legacy) or `{ label, meaning, signals }`. */
|
||||||
|
type CoreValuePresetJson =
|
||||||
|
| string
|
||||||
|
| { label: string; meaning?: string; signals?: string };
|
||||||
|
|
||||||
|
type CoreValuePreset = {
|
||||||
|
label: string;
|
||||||
|
meaning: string;
|
||||||
|
signals: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeCoreValuePresets(
|
||||||
|
values: readonly CoreValuePresetJson[],
|
||||||
|
): CoreValuePreset[] {
|
||||||
|
return values.map((v) => {
|
||||||
|
if (typeof v === "string") {
|
||||||
|
return { label: v, meaning: "", signals: "" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: v.label,
|
||||||
|
meaning: typeof v.meaning === "string" ? v.meaning : "",
|
||||||
|
signals: typeof v.signals === "string" ? v.signals : "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipRowsFromPresets(presets: readonly CoreValuePreset[]): ChipOption[] {
|
||||||
|
return presets.map((row, i) => ({
|
||||||
|
id: String(i + 1),
|
||||||
|
label: row.label,
|
||||||
|
state: "unselected" as const,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySavedSelection(
|
||||||
|
options: ChipOption[],
|
||||||
|
saved: string[] | undefined,
|
||||||
|
): ChipOption[] {
|
||||||
|
const selected = new Set(saved ?? []);
|
||||||
|
return options.map((opt) =>
|
||||||
|
opt.state === "custom"
|
||||||
|
? opt
|
||||||
|
: {
|
||||||
|
...opt,
|
||||||
|
state: selected.has(opt.id)
|
||||||
|
? ("selected" as const)
|
||||||
|
: ("unselected" as const),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedIdsFromOptions(options: ChipOption[]): string[] {
|
||||||
|
return options
|
||||||
|
.filter((o) => o.state === "selected")
|
||||||
|
.map((o) => o.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipOptionsToSnapshotRows(
|
||||||
|
options: ChipOption[],
|
||||||
|
): CommunityStructureChipSnapshotRow[] {
|
||||||
|
return options.map((o) => ({
|
||||||
|
id: o.id,
|
||||||
|
label: o.label,
|
||||||
|
...(o.state !== undefined ? { state: o.state } : {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotRowsToChipOptions(
|
||||||
|
rows: CommunityStructureChipSnapshotRow[] | undefined,
|
||||||
|
): ChipOption[] | null {
|
||||||
|
if (!Array.isArray(rows) || rows.length === 0) return null;
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
label: r.label,
|
||||||
|
...(r.state !== undefined
|
||||||
|
? { state: r.state as ChipOption["state"] }
|
||||||
|
: {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_DETAIL: CoreValueDetailEntry = { meaning: "", signals: "" };
|
||||||
|
|
||||||
|
/** Create Custom — Core Values (Figma `20264:68378`). Up to five selections; preset list + custom chips. */
|
||||||
|
export function CoreValuesSelectScreen() {
|
||||||
|
const m = useMessages();
|
||||||
|
const cv = m.create.customRule.coreValues;
|
||||||
|
const presets = useMemo(
|
||||||
|
() => normalizeCoreValuePresets(cv.values as CoreValuePresetJson[]),
|
||||||
|
[cv.values],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
|
||||||
|
|
||||||
|
const [coreValueOptions, setCoreValueOptions] = useState<ChipOption[]>(
|
||||||
|
() => {
|
||||||
|
const fromSnap = snapshotRowsToChipOptions(state.coreValuesChipsSnapshot);
|
||||||
|
if (fromSnap) return fromSnap;
|
||||||
|
return applySavedSelection(
|
||||||
|
chipRowsFromPresets(presets),
|
||||||
|
state.selectedCoreValueIds,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const [activeModalChipId, setActiveModalChipId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [modalSession, setModalSession] = useState<ModalSession | null>(null);
|
||||||
|
const [draft, setDraft] = useState<CoreValueDetailEntry>(EMPTY_DETAIL);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fromSnap = snapshotRowsToChipOptions(state.coreValuesChipsSnapshot);
|
||||||
|
if (fromSnap) {
|
||||||
|
setCoreValueOptions(fromSnap);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCoreValueOptions((prev) =>
|
||||||
|
applySavedSelection(prev, state.selectedCoreValueIds),
|
||||||
|
);
|
||||||
|
}, [state.coreValuesChipsSnapshot, state.selectedCoreValueIds]);
|
||||||
|
|
||||||
|
/** Sync chips to create-flow draft. Never call `updateState` from inside a `setCoreValueOptions` updater — defer with `queueMicrotask`. */
|
||||||
|
const syncCoreValuesToDraft = useCallback(
|
||||||
|
(next: ChipOption[]) => {
|
||||||
|
updateState({
|
||||||
|
selectedCoreValueIds: selectedIdsFromOptions(next),
|
||||||
|
coreValuesChipsSnapshot: chipOptionsToSnapshotRows(next),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const persistCoreValues = useCallback(
|
||||||
|
(next: ChipOption[]) => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setCoreValueOptions(next);
|
||||||
|
syncCoreValuesToDraft(next);
|
||||||
|
},
|
||||||
|
[markCreateFlowInteraction, syncCoreValuesToDraft],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Default meaning/signals from `coreValues.json` `values` for each preset label. */
|
||||||
|
const getPresetTexts = useCallback(
|
||||||
|
(valueLabel: string): CoreValueDetailEntry => {
|
||||||
|
const row = presets.find((p) => p.label === valueLabel);
|
||||||
|
if (!row) return EMPTY_DETAIL;
|
||||||
|
return { meaning: row.meaning, signals: row.signals };
|
||||||
|
},
|
||||||
|
[presets],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getInitialTexts = useCallback(
|
||||||
|
(chipId: string, valueLabel: string): CoreValueDetailEntry => {
|
||||||
|
const saved = state.coreValueDetailsByChipId?.[chipId];
|
||||||
|
const preset = getPresetTexts(valueLabel);
|
||||||
|
return {
|
||||||
|
meaning: saved?.meaning ?? preset.meaning,
|
||||||
|
signals: saved?.signals ?? preset.signals,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[state.coreValueDetailsByChipId, getPresetTexts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openModal = useCallback(
|
||||||
|
(chipId: string, session: ModalSession, valueLabel: string) => {
|
||||||
|
setDraft(getInitialTexts(chipId, valueLabel));
|
||||||
|
setActiveModalChipId(chipId);
|
||||||
|
setModalSession(session);
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
},
|
||||||
|
[getInitialTexts, markCreateFlowInteraction],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDraftChange = useCallback(
|
||||||
|
(next: CoreValueDetailEntry) => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setDraft(next);
|
||||||
|
},
|
||||||
|
[markCreateFlowInteraction],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleModalDismiss = useCallback(() => {
|
||||||
|
if (activeModalChipId && modalSession === "pending") {
|
||||||
|
const next = coreValueOptions.map((opt) =>
|
||||||
|
opt.id === activeModalChipId
|
||||||
|
? { ...opt, state: "unselected" as const }
|
||||||
|
: opt,
|
||||||
|
);
|
||||||
|
persistCoreValues(next);
|
||||||
|
} else if (activeModalChipId && modalSession === "customPending") {
|
||||||
|
// Custom chip never confirmed via Add Value — drop it from both
|
||||||
|
// the local options and the create-flow draft so refresh / back
|
||||||
|
// navigation doesn't resurrect a phantom chip.
|
||||||
|
const next = coreValueOptions.filter(
|
||||||
|
(opt) => opt.id !== activeModalChipId,
|
||||||
|
);
|
||||||
|
persistCoreValues(next);
|
||||||
|
}
|
||||||
|
setActiveModalChipId(null);
|
||||||
|
setModalSession(null);
|
||||||
|
}, [activeModalChipId, modalSession, coreValueOptions, persistCoreValues]);
|
||||||
|
|
||||||
|
const handleModalConfirm = useCallback(() => {
|
||||||
|
if (!activeModalChipId) return;
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
updateState({
|
||||||
|
coreValueDetailsByChipId: {
|
||||||
|
...(state.coreValueDetailsByChipId ?? {}),
|
||||||
|
[activeModalChipId]: draft,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setActiveModalChipId(null);
|
||||||
|
setModalSession(null);
|
||||||
|
}, [
|
||||||
|
activeModalChipId,
|
||||||
|
draft,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
state.coreValueDetailsByChipId,
|
||||||
|
updateState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleChipClick = (chipId: string) => {
|
||||||
|
const target = coreValueOptions.find((o) => o.id === chipId);
|
||||||
|
if (!target || target.state === "custom") return;
|
||||||
|
|
||||||
|
const selectedCount = coreValueOptions.filter(
|
||||||
|
(o) => o.state === "selected",
|
||||||
|
).length;
|
||||||
|
|
||||||
|
if (target.state === "selected") {
|
||||||
|
const next: ChipOption[] = coreValueOptions.map((opt) =>
|
||||||
|
opt.id === chipId
|
||||||
|
? { ...opt, state: "unselected" as const }
|
||||||
|
: opt,
|
||||||
|
);
|
||||||
|
persistCoreValues(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCount >= MAX_CORE_VALUES) return;
|
||||||
|
|
||||||
|
const next: ChipOption[] = coreValueOptions.map((opt) =>
|
||||||
|
opt.id === chipId
|
||||||
|
? { ...opt, state: "selected" as const }
|
||||||
|
: opt,
|
||||||
|
);
|
||||||
|
persistCoreValues(next);
|
||||||
|
openModal(chipId, "pending", target.label);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addHandlers = {
|
||||||
|
onAddClick: () => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setCoreValueOptions((prev) => {
|
||||||
|
const next: ChipOption[] = [
|
||||||
|
...prev,
|
||||||
|
{ id: crypto.randomUUID(), label: "", state: "custom" },
|
||||||
|
];
|
||||||
|
queueMicrotask(() => syncCoreValuesToDraft(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onCustomChipConfirm: (chipId: string, value: string) => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setCoreValueOptions((prev) => {
|
||||||
|
const withLabel = prev.map((opt) =>
|
||||||
|
opt.id === chipId
|
||||||
|
? { ...opt, label: value, state: "unselected" as const }
|
||||||
|
: opt,
|
||||||
|
);
|
||||||
|
const selectedCount = withLabel.filter(
|
||||||
|
(o) => o.state === "selected",
|
||||||
|
).length;
|
||||||
|
const canSelect = selectedCount < MAX_CORE_VALUES;
|
||||||
|
const next = canSelect
|
||||||
|
? withLabel.map((opt) =>
|
||||||
|
opt.id === chipId
|
||||||
|
? { ...opt, state: "selected" as const }
|
||||||
|
: opt,
|
||||||
|
)
|
||||||
|
: withLabel;
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
syncCoreValuesToDraft(next);
|
||||||
|
// Both branches treat the chip as a brand-new draft until the
|
||||||
|
// user confirms via Add Value — dismissal removes it.
|
||||||
|
openModal(chipId, "customPending", value);
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onCustomChipClose: (chipId: string) => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setCoreValueOptions((prev) => {
|
||||||
|
const next = prev.filter((o) => o.id !== chipId);
|
||||||
|
queueMicrotask(() => syncCoreValuesToDraft(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalChipLabel =
|
||||||
|
coreValueOptions.find((o) => o.id === activeModalChipId)?.label ?? "";
|
||||||
|
|
||||||
|
const description = (
|
||||||
|
<>
|
||||||
|
<span className="leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)]">
|
||||||
|
{cv.header.descriptionLead}{" "}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addHandlers.onAddClick}
|
||||||
|
className="cursor-pointer font-inter font-normal leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] hover:opacity-90"
|
||||||
|
>
|
||||||
|
{cv.header.addLink}
|
||||||
|
</button>
|
||||||
|
<span className="leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)]">
|
||||||
|
{" "}
|
||||||
|
{cv.header.descriptionTrail}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const detailModal = cv.detailModal;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreateFlowTwoColumnSelectShell
|
||||||
|
lgVerticalAlign="start"
|
||||||
|
header={
|
||||||
|
<CreateFlowHeaderLockup
|
||||||
|
title={cv.header.title}
|
||||||
|
description={description}
|
||||||
|
justification="left"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MultiSelect
|
||||||
|
formHeader={false}
|
||||||
|
size="m"
|
||||||
|
options={coreValueOptions}
|
||||||
|
onChipClick={handleChipClick}
|
||||||
|
onAddClick={addHandlers.onAddClick}
|
||||||
|
onCustomChipConfirm={addHandlers.onCustomChipConfirm}
|
||||||
|
onCustomChipClose={addHandlers.onCustomChipClose}
|
||||||
|
addButton
|
||||||
|
addButtonText={cv.multiSelect.addButtonText}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{detailModal && (
|
||||||
|
<Create
|
||||||
|
isOpen={activeModalChipId !== null}
|
||||||
|
onClose={handleModalDismiss}
|
||||||
|
backdropVariant="loginYellow"
|
||||||
|
headerContent={
|
||||||
|
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
|
||||||
|
<ContentLockup
|
||||||
|
title={modalChipLabel}
|
||||||
|
description={detailModal.subtitle}
|
||||||
|
variant="modal"
|
||||||
|
alignment="left"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
showBackButton={false}
|
||||||
|
showNextButton
|
||||||
|
onNext={handleModalConfirm}
|
||||||
|
nextButtonText={detailModal.addValueButton}
|
||||||
|
ariaLabel={modalChipLabel || "Core value details"}
|
||||||
|
>
|
||||||
|
<CoreValueEditFields value={draft} onChange={handleDraftChange} />
|
||||||
|
</Create>
|
||||||
|
)}
|
||||||
|
</CreateFlowTwoColumnSelectShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
+3
-3
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, type HTMLInputTypeAttribute } from "react";
|
import { useState, useEffect, type HTMLInputTypeAttribute } from "react";
|
||||||
import TextInput from "../../../components/controls/TextInput";
|
import TextInput from "../../../../components/controls/TextInput";
|
||||||
import type { HeaderLockupJustificationValue } from "../../../components/type/HeaderLockup/HeaderLockup.types";
|
import type { HeaderLockupJustificationValue } from "../../../../components/type/HeaderLockup/HeaderLockup.types";
|
||||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Upload from "../../../components/controls/Upload";
|
import Upload from "../../../../components/controls/Upload";
|
||||||
import { useMessages } from "../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||||
@@ -10,7 +10,7 @@ import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowL
|
|||||||
/** Create Community — Figma Flow — Upload `20094:41524`. */
|
/** Create Community — Figma Flow — Upload `20094:41524`. */
|
||||||
export function CommunityUploadScreen() {
|
export function CommunityUploadScreen() {
|
||||||
const m = useMessages();
|
const m = useMessages();
|
||||||
const u = m.create.communityUpload;
|
const u = m.create.community.communityUpload;
|
||||||
const { markCreateFlowInteraction } = useCreateFlow();
|
const { markCreateFlowInteraction } = useCreateFlow();
|
||||||
|
|
||||||
const handleUploadClick = () => {
|
const handleUploadClick = () => {
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* Type definitions for the Create Rule Flow
|
||||||
|
*
|
||||||
|
* These types define the structure for the full-screen create rule flow,
|
||||||
|
* including step types, state management, and context interfaces.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valid step IDs for the create rule flow (URL segment after `/create/`).
|
||||||
|
* Create Community order matches Figma; `review` closes that stage per design.
|
||||||
|
*/
|
||||||
|
export type CreateFlowStep =
|
||||||
|
| "informational"
|
||||||
|
| "community-name"
|
||||||
|
| "community-size"
|
||||||
|
| "community-context"
|
||||||
|
| "community-structure"
|
||||||
|
| "community-upload"
|
||||||
|
| "community-save"
|
||||||
|
| "review"
|
||||||
|
| "core-values"
|
||||||
|
| "communication-methods"
|
||||||
|
| "membership-methods"
|
||||||
|
| "decision-approaches"
|
||||||
|
| "conflict-management"
|
||||||
|
| "confirm-stakeholders"
|
||||||
|
| "final-review"
|
||||||
|
| "completed";
|
||||||
|
|
||||||
|
/** String keys used by generic text-field steps for `CreateFlowState`. */
|
||||||
|
export type CreateFlowTextStateField =
|
||||||
|
| "title"
|
||||||
|
| "summary"
|
||||||
|
| "communityContext"
|
||||||
|
| "communitySaveEmail";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialized chip row for `community-structure` (preset + custom labels).
|
||||||
|
* Stored in drafts so custom chips survive refresh and server sync.
|
||||||
|
*/
|
||||||
|
export type CommunityStructureChipSnapshotRow = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
state?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Meaning + violation signals copy for a core value chip (draft + publish). */
|
||||||
|
export type CoreValueDetailEntry = {
|
||||||
|
meaning: string;
|
||||||
|
signals: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-chip edited sections written by the `final-review` edit modal and
|
||||||
|
* merged back onto presets at publish time. Shapes mirror the custom-rule
|
||||||
|
* add-method modals (see `app/(app)/create/screens/card/*`) so the same
|
||||||
|
* field widgets can render both surfaces.
|
||||||
|
*/
|
||||||
|
export type CommunicationMethodDetailEntry = {
|
||||||
|
corePrinciple: string;
|
||||||
|
logisticsAdmin: string;
|
||||||
|
codeOfConduct: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MembershipMethodDetailEntry = {
|
||||||
|
eligibility: string;
|
||||||
|
joiningProcess: string;
|
||||||
|
expectations: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DecisionApproachDetailEntry = {
|
||||||
|
corePrinciple: string;
|
||||||
|
applicableScope: string[];
|
||||||
|
selectedApplicableScope: string[];
|
||||||
|
stepByStepInstructions: string;
|
||||||
|
consensusLevel: number;
|
||||||
|
objectionsDeadlocks: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConflictManagementDetailEntry = {
|
||||||
|
corePrinciple: string;
|
||||||
|
applicableScope: string[];
|
||||||
|
selectedApplicableScope: string[];
|
||||||
|
processProtocol: string;
|
||||||
|
restorationFallbacks: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flow state for inputs across create-flow steps.
|
||||||
|
* Validated on `PUT /api/drafts/me` via `createFlowStateSchema` (Zod + JSON safety checks).
|
||||||
|
* Additional string keys are allowed at runtime for forward-compatible step data.
|
||||||
|
*/
|
||||||
|
export interface CreateFlowState {
|
||||||
|
title?: string;
|
||||||
|
summary?: string;
|
||||||
|
/** Additional copy fields for multi-step Create Community text frames (Figma). */
|
||||||
|
communityContext?: string;
|
||||||
|
/** Email collected on the “Save your progress” step (Figma Flow — Text `20097:14948`). */
|
||||||
|
communitySaveEmail?: string;
|
||||||
|
/** Selected chip ids from `community-size` (MultiSelect). */
|
||||||
|
selectedCommunitySizeIds?: string[];
|
||||||
|
/** Selected chip ids from `community-structure` (organization types). */
|
||||||
|
selectedOrganizationTypeIds?: string[];
|
||||||
|
/** Selected chip ids from `community-structure` (scale). */
|
||||||
|
selectedScaleIds?: string[];
|
||||||
|
/** Selected chip ids from `community-structure` (maturity). */
|
||||||
|
selectedMaturityIds?: string[];
|
||||||
|
/**
|
||||||
|
* Full chip lists for `community-structure` (needed so custom chips round-trip in drafts).
|
||||||
|
* IDs alone are insufficient because custom rows are not reconstructible from copy JSON.
|
||||||
|
*/
|
||||||
|
communityStructureChipSnapshots?: {
|
||||||
|
organizationTypes?: CommunityStructureChipSnapshotRow[];
|
||||||
|
scale?: CommunityStructureChipSnapshotRow[];
|
||||||
|
maturity?: CommunityStructureChipSnapshotRow[];
|
||||||
|
};
|
||||||
|
/** Create Custom — core values step (max five `selectedCoreValueIds`). */
|
||||||
|
selectedCoreValueIds?: string[];
|
||||||
|
/** Full chip rows for core values (custom labels). */
|
||||||
|
coreValuesChipsSnapshot?: CommunityStructureChipSnapshotRow[];
|
||||||
|
/** User-authored detail text keyed by chip id (preset ids or custom UUIDs). */
|
||||||
|
coreValueDetailsByChipId?: Record<string, CoreValueDetailEntry>;
|
||||||
|
/** Create Custom — communication methods step (`/create/communication-methods`); card ids from `create.customRule.communication` presets. */
|
||||||
|
selectedCommunicationMethodIds?: string[];
|
||||||
|
/** Create Custom — membership / join patterns (`/create/membership-methods`); card ids from `create.customRule.membership` presets. */
|
||||||
|
selectedMembershipMethodIds?: string[];
|
||||||
|
/** Create Custom — decision approaches (`/create/decision-approaches`); card ids from `create.customRule.decisionApproaches` presets. */
|
||||||
|
selectedDecisionApproachIds?: string[];
|
||||||
|
/** Create Custom — conflict management (`/create/conflict-management`); card ids from `create.customRule.conflictManagement` presets. */
|
||||||
|
selectedConflictManagementIds?: string[];
|
||||||
|
/**
|
||||||
|
* User edits from the `final-review` edit modal, keyed by preset method id
|
||||||
|
* (e.g. `"signal"`). Merged onto preset defaults at publish time so the
|
||||||
|
* stored rule reflects the author's customizations. Edits persist to the
|
||||||
|
* anonymous localStorage draft and signed-in server draft automatically.
|
||||||
|
*/
|
||||||
|
communicationMethodDetailsById?: Record<
|
||||||
|
string,
|
||||||
|
CommunicationMethodDetailEntry
|
||||||
|
>;
|
||||||
|
membershipMethodDetailsById?: Record<string, MembershipMethodDetailEntry>;
|
||||||
|
decisionApproachDetailsById?: Record<string, DecisionApproachDetailEntry>;
|
||||||
|
conflictManagementDetailsById?: Record<
|
||||||
|
string,
|
||||||
|
ConflictManagementDetailEntry
|
||||||
|
>;
|
||||||
|
/**
|
||||||
|
* Set when a user picks a template (Customize or Use without changes) before
|
||||||
|
* completing the community stage. The community-review screen consumes this
|
||||||
|
* to `router.replace` past `/create/review` to the correct downstream step
|
||||||
|
* (`core-values` for customize; `confirm-stakeholders` for use-without-changes)
|
||||||
|
* once community data is captured. Cleared the moment the redirect fires, so
|
||||||
|
* later visits to `/create/review` render normally.
|
||||||
|
*/
|
||||||
|
pendingTemplateAction?: {
|
||||||
|
slug: string;
|
||||||
|
mode: "customize" | "useWithoutChanges";
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Set when the user chooses **Use without changes** on a template-review
|
||||||
|
* page. The custom-rule segment (`core-values` … `conflict-management`) is
|
||||||
|
* skipped, so linear `getPreviousStep("confirm-stakeholders")` would wrongly
|
||||||
|
* point at `conflict-management`. Navigation uses this slug so Back from
|
||||||
|
* `confirm-stakeholders` returns to `/create/review-template/{slug}`.
|
||||||
|
* Cleared when the user picks **Customize** from template review (normal
|
||||||
|
* linear back applies) or when the flow state is cleared.
|
||||||
|
*/
|
||||||
|
templateReviewBackSlug?: string;
|
||||||
|
/**
|
||||||
|
* True when the user opened `/create/review-template/{slug}` from the create
|
||||||
|
* wizard (`/templates?fromFlow=1` after `/create/review`). Persisted so Back
|
||||||
|
* from template review targets `/create/review` and so returning from
|
||||||
|
* `confirm-stakeholders` can re-apply `?fromFlow=1` on the template URL.
|
||||||
|
*/
|
||||||
|
templateReviewEntryFromCreateFlow?: boolean;
|
||||||
|
currentStep?: CreateFlowStep;
|
||||||
|
/** Section drafts; structure will tighten as steps persist real shapes. */
|
||||||
|
sections?: Record<string, unknown>[];
|
||||||
|
/** Stakeholder placeholders until the confirm-stakeholders step defines a schema. */
|
||||||
|
stakeholders?: Record<string, unknown>[];
|
||||||
|
/** Extra step-specific fields (must be JSON-serializable for server draft sync). */
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context value interface for CreateFlowContext
|
||||||
|
* Provides state management and navigation capabilities
|
||||||
|
*/
|
||||||
|
export interface CreateFlowContextValue {
|
||||||
|
state: CreateFlowState;
|
||||||
|
currentStep: CreateFlowStep | null;
|
||||||
|
updateState: (_updates: Partial<CreateFlowState>) => void;
|
||||||
|
/** Replace entire flow state (e.g. hydrate from server draft). */
|
||||||
|
replaceState: (_next: CreateFlowState) => void;
|
||||||
|
/** Reset flow state and clear anonymous localStorage draft keys when present. */
|
||||||
|
clearState: () => void;
|
||||||
|
/**
|
||||||
|
* Scrub only the Create Custom stage selections (core values, communication,
|
||||||
|
* membership, decision approaches, conflict management) from state. Keeps
|
||||||
|
* the community stage (title, context, size, structure) intact so users can
|
||||||
|
* re-enter the custom-rule flow from `/create/review` with a clean slate
|
||||||
|
* after a prior "Customize template" prefill.
|
||||||
|
*/
|
||||||
|
resetCustomRuleSelections: () => void;
|
||||||
|
/**
|
||||||
|
* True after the user has edited any control inside the wizard. Screens flip
|
||||||
|
* it via {@link markCreateFlowInteraction} from their event handlers.
|
||||||
|
*
|
||||||
|
* Current consumer: {@link SignedInDraftHydration} — when a signed-in user
|
||||||
|
* has already started editing, we skip replaying their server draft on top
|
||||||
|
* of in-progress local state. Save & Exit visibility is driven by step
|
||||||
|
* index (`SAVE_EXIT_FROM_STEP_INDEX` in `CreateFlowLayoutClient`), not this
|
||||||
|
* flag.
|
||||||
|
*/
|
||||||
|
interactionTouched: boolean;
|
||||||
|
markCreateFlowInteraction: () => void;
|
||||||
|
}
|
||||||
+1
-10
@@ -1,5 +1,5 @@
|
|||||||
import type { CreateFlowState } from "../types";
|
import type { CreateFlowState } from "../types";
|
||||||
import { migrateLegacyCreateFlowState } from "../../../lib/create/migrateLegacyCreateFlowState";
|
import { migrateLegacyCreateFlowState } from "../../../../lib/create/migrateLegacyCreateFlowState";
|
||||||
|
|
||||||
/** Anonymous in-progress create flow (local only until magic-link transfer). */
|
/** Anonymous in-progress create flow (local only until magic-link transfer). */
|
||||||
export const CREATE_FLOW_ANONYMOUS_KEY = "create-flow-anonymous" as const;
|
export const CREATE_FLOW_ANONYMOUS_KEY = "create-flow-anonymous" as const;
|
||||||
@@ -75,15 +75,6 @@ export function hasTransferPendingFlag(): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearTransferPendingFlag(): void {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
try {
|
|
||||||
window.localStorage.removeItem(CREATE_FLOW_TRANSFER_PENDING_KEY);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One-time cleanup of pre–anonymous-draft keys. */
|
/** One-time cleanup of pre–anonymous-draft keys. */
|
||||||
export function clearLegacyCreateFlowKeysOnce(): void {
|
export function clearLegacyCreateFlowKeysOnce(): void {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { clearAnonymousCreateFlowStorage } from "./anonymousDraftStorage";
|
||||||
|
import { clearCoreValueDetailsLocalStorage } from "./coreValueDetailsLocalStorage";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wipe the anonymous in-progress create-flow draft from `localStorage` (both
|
||||||
|
* the main `create-flow-anonymous` blob and the separate core-value details
|
||||||
|
* key). Intended for call sites that navigate **into** the create flow from
|
||||||
|
* outside and want a fresh slate — today that's the marketing "Popular
|
||||||
|
* templates" click handler on the home page and the `/templates` index page
|
||||||
|
* (when not in-flow). `CreateFlowProvider` reads `localStorage` during its
|
||||||
|
* `useState` initializer, so clearing *before* pushing the next route means
|
||||||
|
* the provider mounts empty and the Create Community stage starts clean.
|
||||||
|
*
|
||||||
|
* Note: this only touches localStorage. It does **not** delete the
|
||||||
|
* authenticated user's server draft (`/api/drafts/me`). Server drafts are
|
||||||
|
* loaded deliberately from the profile page, not re-hydrated into the flow
|
||||||
|
* on every entry, so there's nothing to wipe here for signed-in users.
|
||||||
|
*/
|
||||||
|
export function clearCreateFlowPersistedDrafts(): void {
|
||||||
|
clearAnonymousCreateFlowStorage();
|
||||||
|
clearCoreValueDetailsLocalStorage();
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { CoreValueDetailEntry } from "../types";
|
||||||
|
|
||||||
|
/** Persists meaning/signals per chip id across refresh (esp. signed-in create flow, in-memory only). */
|
||||||
|
export const CORE_VALUE_DETAILS_STORAGE_KEY =
|
||||||
|
"create-flow-core-value-details" as const;
|
||||||
|
|
||||||
|
export function readCoreValueDetailsFromLocalStorage(): Record<
|
||||||
|
string,
|
||||||
|
CoreValueDetailEntry
|
||||||
|
> {
|
||||||
|
if (typeof window === "undefined") return {};
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(CORE_VALUE_DETAILS_STORAGE_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!parsed || typeof parsed !== "object") return {};
|
||||||
|
const out: Record<string, CoreValueDetailEntry> = {};
|
||||||
|
for (const [k, v] of Object.entries(parsed)) {
|
||||||
|
if (!v || typeof v !== "object") continue;
|
||||||
|
const o = v as Record<string, unknown>;
|
||||||
|
if (typeof o.meaning !== "string" || typeof o.signals !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out[k] = { meaning: o.meaning, signals: o.signals };
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeCoreValueDetailsToLocalStorage(
|
||||||
|
value: Record<string, CoreValueDetailEntry> | undefined,
|
||||||
|
): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
if (!value || Object.keys(value).length === 0) {
|
||||||
|
window.localStorage.removeItem(CORE_VALUE_DETAILS_STORAGE_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.localStorage.setItem(
|
||||||
|
CORE_VALUE_DETAILS_STORAGE_KEY,
|
||||||
|
JSON.stringify(value),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// quota / private mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCoreValueDetailsLocalStorage(): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(CORE_VALUE_DETAILS_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Typography + padding overrides applied to the primary/secondary buttons
|
||||||
|
* rendered inside `CreateFlowFooter`. The footer slot expects a compact
|
||||||
|
* size regardless of the default `<Button size="xsmall">` output, and both
|
||||||
|
* the Create Community / Custom Rule / Review flows and the template-review
|
||||||
|
* footer share the same override string — keeping it here prevents drift
|
||||||
|
* between those two call sites.
|
||||||
|
*
|
||||||
|
* The `!` prefixes bypass Button's own size tokens; the extra spacing vars
|
||||||
|
* mirror the Figma compact footer button spec. When the design system
|
||||||
|
* exposes a native size that matches, this module should collapse.
|
||||||
|
*/
|
||||||
|
export const CREATE_FLOW_FOOTER_BUTTON_CLASS =
|
||||||
|
"md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] " +
|
||||||
|
"!px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] " +
|
||||||
|
"!py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template-review "Use without changes" (ghost variant) renders on a dark
|
||||||
|
* backdrop and needs an explicit text-color override in addition to the
|
||||||
|
* shared compact sizing. Composed from the base class so any future tweak
|
||||||
|
* to typography/padding propagates automatically.
|
||||||
|
*/
|
||||||
|
export const CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS = `${CREATE_FLOW_FOOTER_BUTTON_CLASS} !text-white`;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type footerMessages from "../../../../messages/en/create/footer.json";
|
||||||
|
import type { CreateFlowStep } from "../types";
|
||||||
|
|
||||||
|
type FooterMessages = typeof footerMessages;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-step label override for the default "next-step" primary footer
|
||||||
|
* button (the catch-all branch in `CreateFlowLayoutClient`'s footer that
|
||||||
|
* fires `goToNextStep` for steps without a bespoke footer). Steps absent
|
||||||
|
* from this map fall back to `footer.next`.
|
||||||
|
*
|
||||||
|
* `final-review` is handled separately by the caller because its label
|
||||||
|
* also depends on the in-flight publish flag (`finalizeButtonPublishing`
|
||||||
|
* vs `finalizeCommunityRule`).
|
||||||
|
*/
|
||||||
|
const DEFAULT_FOOTER_LABEL_BY_STEP: ReadonlyMap<
|
||||||
|
CreateFlowStep,
|
||||||
|
keyof FooterMessages
|
||||||
|
> = new Map<CreateFlowStep, keyof FooterMessages>([
|
||||||
|
["confirm-stakeholders", "confirmStakeholders"],
|
||||||
|
["community-context", "confirmDescription"],
|
||||||
|
["community-structure", "confirmDetails"],
|
||||||
|
["community-size", "confirmMembers"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the localized label for the default "next-step" footer button.
|
||||||
|
* Returns the per-step override when one is registered, otherwise
|
||||||
|
* `footer.next`. Caller still owns the `final-review` special case.
|
||||||
|
*/
|
||||||
|
export function getDefaultFooterLabel(
|
||||||
|
step: CreateFlowStep | null | undefined,
|
||||||
|
footer: FooterMessages,
|
||||||
|
): string {
|
||||||
|
if (step == null) return footer.next;
|
||||||
|
const key = DEFAULT_FOOTER_LABEL_BY_STEP.get(step);
|
||||||
|
return key != null ? footer[key] : footer.next;
|
||||||
|
}
|
||||||
+6
-3
@@ -1,4 +1,4 @@
|
|||||||
import type { ProportionBarState } from "../../components/progress/ProportionBar/ProportionBar.types";
|
import type { ProportionBarState } from "../../../components/progress/ProportionBar/ProportionBar.types";
|
||||||
import type { CreateFlowStep } from "../types";
|
import type { CreateFlowStep } from "../types";
|
||||||
import { FLOW_STEP_ORDER, getStepIndex } from "./flowSteps";
|
import { FLOW_STEP_ORDER, getStepIndex } from "./flowSteps";
|
||||||
|
|
||||||
@@ -15,8 +15,11 @@ const PROPORTION_BY_STEP_INDEX: readonly ProportionBarState[] = [
|
|||||||
"1-5", // community-upload
|
"1-5", // community-upload
|
||||||
"2-0", // community-save
|
"2-0", // community-save
|
||||||
"2-0", // review (Figma Flow — Review `19706:12135`: same segment fill as end of Create Community)
|
"2-0", // review (Figma Flow — Review `19706:12135`: same segment fill as end of Create Community)
|
||||||
"2-2", // cards
|
"2-0", // core-values (same segment as review / end of Create Community)
|
||||||
"3-0", // right-rail
|
"2-1", // communication-methods (Figma — Compact Card Stack)
|
||||||
|
"2-2", // membership-methods (Figma — Compact Card Stack `20858:13947`)
|
||||||
|
"2-3", // decision-approaches (Figma Flow — Right Rail `20523:23509`)
|
||||||
|
"3-0", // conflict-management (Figma Flow — Compact Card Stack `20879:15979`; start of Review segment)
|
||||||
"3-1", // confirm-stakeholders
|
"3-1", // confirm-stakeholders
|
||||||
"3-2", // final-review
|
"3-2", // final-review
|
||||||
"3-2", // completed
|
"3-2", // completed
|
||||||
+50
-21
@@ -2,9 +2,10 @@ import type { CreateFlowStep } from "../types";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Figma layout families for the create flow (not encoded in the URL).
|
* Figma layout families for the create flow (not encoded in the URL).
|
||||||
* Registry and `app/create/screens/` are organized by these kinds.
|
* `app/(app)/create/screens/<kind>/` mirrors these names: e.g. `layoutKind: "select"` → `screens/select/`,
|
||||||
|
* `"card"` → `screens/card/` (compact card-stack frames, distinct from two-column chip selects).
|
||||||
*/
|
*/
|
||||||
export type CreateFlowLayoutKind =
|
type CreateFlowLayoutKind =
|
||||||
| "informational"
|
| "informational"
|
||||||
| "text"
|
| "text"
|
||||||
| "select"
|
| "select"
|
||||||
@@ -14,12 +15,14 @@ export type CreateFlowLayoutKind =
|
|||||||
| "right-rail"
|
| "right-rail"
|
||||||
| "completed";
|
| "completed";
|
||||||
|
|
||||||
export interface CreateFlowScreenDefinition {
|
interface CreateFlowScreenDefinition {
|
||||||
layoutKind: CreateFlowLayoutKind;
|
layoutKind: CreateFlowLayoutKind;
|
||||||
/** Figma node id (file Community-Rule-System), dev mode. */
|
/** Figma node id (file Community-Rule-System), dev mode. */
|
||||||
figmaNodeId: string;
|
figmaNodeId: string;
|
||||||
/**
|
/**
|
||||||
* Namespace for `useTranslation`, e.g. `create.communityName`.
|
* Namespace for `useTranslation`, e.g. `create.community.communityName`.
|
||||||
|
* Stage prefix (`community` / `customRule` / `reviewAndComplete`) matches the
|
||||||
|
* messages folder layout — see `messages/en/index.ts` and `docs/guides/template-recommendation-matrix.md` §1c.
|
||||||
* Not all screens use i18n the same way (e.g. card step uses `useMessages` elsewhere).
|
* Not all screens use i18n the same way (e.g. card step uses `useMessages` elsewhere).
|
||||||
*/
|
*/
|
||||||
messageNamespace: string;
|
messageNamespace: string;
|
||||||
@@ -39,79 +42,97 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record<
|
|||||||
informational: {
|
informational: {
|
||||||
layoutKind: "informational",
|
layoutKind: "informational",
|
||||||
figmaNodeId: "20094-16005",
|
figmaNodeId: "20094-16005",
|
||||||
messageNamespace: "create.informational",
|
messageNamespace: "create.community.informational",
|
||||||
centeredBodyBelowMd: false,
|
centeredBodyBelowMd: false,
|
||||||
},
|
},
|
||||||
"community-name": {
|
"community-name": {
|
||||||
layoutKind: "text",
|
layoutKind: "text",
|
||||||
figmaNodeId: "20094-18187",
|
figmaNodeId: "20094-18187",
|
||||||
messageNamespace: "create.communityName",
|
messageNamespace: "create.community.communityName",
|
||||||
centeredBodyBelowMd: true,
|
centeredBodyBelowMd: true,
|
||||||
},
|
},
|
||||||
"community-size": {
|
"community-size": {
|
||||||
layoutKind: "select",
|
layoutKind: "select",
|
||||||
figmaNodeId: "20094-41317",
|
figmaNodeId: "20094-41317",
|
||||||
messageNamespace: "create.communitySize",
|
messageNamespace: "create.community.communitySize",
|
||||||
centeredBodyBelowMd: false,
|
centeredBodyBelowMd: false,
|
||||||
},
|
},
|
||||||
"community-context": {
|
"community-context": {
|
||||||
layoutKind: "text",
|
layoutKind: "text",
|
||||||
figmaNodeId: "20094-41243",
|
figmaNodeId: "20094-41243",
|
||||||
messageNamespace: "create.communityContext",
|
messageNamespace: "create.community.communityContext",
|
||||||
centeredBodyBelowMd: true,
|
centeredBodyBelowMd: true,
|
||||||
},
|
},
|
||||||
"community-structure": {
|
"community-structure": {
|
||||||
layoutKind: "select",
|
layoutKind: "select",
|
||||||
figmaNodeId: "20094-18244",
|
figmaNodeId: "20094-18244",
|
||||||
messageNamespace: "create.communityStructure",
|
messageNamespace: "create.community.communityStructure",
|
||||||
centeredBodyBelowMd: false,
|
centeredBodyBelowMd: false,
|
||||||
},
|
},
|
||||||
"community-upload": {
|
"community-upload": {
|
||||||
layoutKind: "upload",
|
layoutKind: "upload",
|
||||||
figmaNodeId: "20094-41524",
|
figmaNodeId: "20094-41524",
|
||||||
messageNamespace: "create.communityUpload",
|
messageNamespace: "create.community.communityUpload",
|
||||||
centeredBodyBelowMd: false,
|
centeredBodyBelowMd: false,
|
||||||
},
|
},
|
||||||
"community-save": {
|
"community-save": {
|
||||||
layoutKind: "text",
|
layoutKind: "text",
|
||||||
figmaNodeId: "20097-14948",
|
figmaNodeId: "20097-14948",
|
||||||
messageNamespace: "create.communitySave",
|
messageNamespace: "create.community.communitySave",
|
||||||
centeredBodyBelowMd: true,
|
centeredBodyBelowMd: true,
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
layoutKind: "review",
|
layoutKind: "review",
|
||||||
figmaNodeId: "19706-12135",
|
figmaNodeId: "19706-12135",
|
||||||
messageNamespace: "create.review",
|
messageNamespace: "create.community.review",
|
||||||
centeredBodyBelowMd: false,
|
centeredBodyBelowMd: false,
|
||||||
},
|
},
|
||||||
cards: {
|
"core-values": {
|
||||||
|
layoutKind: "select",
|
||||||
|
figmaNodeId: "20264-68378",
|
||||||
|
messageNamespace: "create.customRule.coreValues",
|
||||||
|
centeredBodyBelowMd: false,
|
||||||
|
},
|
||||||
|
"communication-methods": {
|
||||||
layoutKind: "card",
|
layoutKind: "card",
|
||||||
figmaNodeId: "TBD-cards",
|
figmaNodeId: "20246-15828",
|
||||||
messageNamespace: "create.communication",
|
messageNamespace: "create.customRule.communication",
|
||||||
centeredBodyBelowMd: false,
|
centeredBodyBelowMd: false,
|
||||||
},
|
},
|
||||||
"right-rail": {
|
"membership-methods": {
|
||||||
|
layoutKind: "card",
|
||||||
|
figmaNodeId: "20858-13947",
|
||||||
|
messageNamespace: "create.customRule.membership",
|
||||||
|
centeredBodyBelowMd: false,
|
||||||
|
},
|
||||||
|
"decision-approaches": {
|
||||||
layoutKind: "right-rail",
|
layoutKind: "right-rail",
|
||||||
figmaNodeId: "TBD-right-rail",
|
figmaNodeId: "20523-23509",
|
||||||
messageNamespace: "create.rightRail",
|
messageNamespace: "create.customRule.decisionApproaches",
|
||||||
|
centeredBodyBelowMd: false,
|
||||||
|
},
|
||||||
|
"conflict-management": {
|
||||||
|
layoutKind: "card",
|
||||||
|
figmaNodeId: "20879-15979",
|
||||||
|
messageNamespace: "create.customRule.conflictManagement",
|
||||||
centeredBodyBelowMd: false,
|
centeredBodyBelowMd: false,
|
||||||
},
|
},
|
||||||
"confirm-stakeholders": {
|
"confirm-stakeholders": {
|
||||||
layoutKind: "select",
|
layoutKind: "select",
|
||||||
figmaNodeId: "21104-46594",
|
figmaNodeId: "21104-46594",
|
||||||
messageNamespace: "create.confirmStakeholders",
|
messageNamespace: "create.reviewAndComplete.confirmStakeholders",
|
||||||
centeredBodyBelowMd: false,
|
centeredBodyBelowMd: false,
|
||||||
},
|
},
|
||||||
"final-review": {
|
"final-review": {
|
||||||
layoutKind: "review",
|
layoutKind: "review",
|
||||||
figmaNodeId: "20907-212767",
|
figmaNodeId: "20907-212767",
|
||||||
messageNamespace: "create.finalReview",
|
messageNamespace: "create.reviewAndComplete.finalReview",
|
||||||
centeredBodyBelowMd: false,
|
centeredBodyBelowMd: false,
|
||||||
},
|
},
|
||||||
completed: {
|
completed: {
|
||||||
layoutKind: "completed",
|
layoutKind: "completed",
|
||||||
figmaNodeId: "20907-213286",
|
figmaNodeId: "20907-213286",
|
||||||
messageNamespace: "create.completed",
|
messageNamespace: "create.reviewAndComplete.completed",
|
||||||
centeredBodyBelowMd: false,
|
centeredBodyBelowMd: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -122,3 +143,11 @@ export function createFlowStepUsesCenteredTextLayout(
|
|||||||
if (!step) return false;
|
if (!step) return false;
|
||||||
return CREATE_FLOW_SCREEN_REGISTRY[step].centeredBodyBelowMd;
|
return CREATE_FLOW_SCREEN_REGISTRY[step].centeredBodyBelowMd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Steps whose main area uses the CardStack-style layout (`layoutKind: "card"`). */
|
||||||
|
export function createFlowStepUsesCardLayout(
|
||||||
|
step: CreateFlowStep | null,
|
||||||
|
): boolean {
|
||||||
|
if (!step) return false;
|
||||||
|
return CREATE_FLOW_SCREEN_REGISTRY[step].layoutKind === "card";
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { CreateFlowState, CreateFlowStep } from "../types";
|
||||||
|
import type footerMessages from "../../../../messages/en/create/footer.json";
|
||||||
|
|
||||||
|
type FooterMessageKey = keyof typeof footerMessages;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binding for each Custom Rule stage step whose footer primary button
|
||||||
|
* gates the user on "has at least one chip selected?". All five screens
|
||||||
|
* render the same `<Button …>`; only the disable predicate and the
|
||||||
|
* footer message differ — this table is the single source of truth for
|
||||||
|
* both, so `CreateFlowLayoutClient` can render one JSX block for the
|
||||||
|
* whole group.
|
||||||
|
*
|
||||||
|
* `selectionIds` returns the currently-selected ids array from flow
|
||||||
|
* state for that step (empty array when nothing has been selected or
|
||||||
|
* the field hasn't been touched). Returning a fresh array on empty is
|
||||||
|
* fine: these are read-only length checks, not memo keys.
|
||||||
|
*
|
||||||
|
* Note: the Confirm Stakeholders step has its own dedicated label copy
|
||||||
|
* and is not gated on a selection count, so it stays out of this table.
|
||||||
|
* Template-review and Community Save also have bespoke two-button
|
||||||
|
* layouts and are intentionally excluded.
|
||||||
|
*/
|
||||||
|
export type CustomRuleConfirmFooterStep = {
|
||||||
|
step: Extract<
|
||||||
|
CreateFlowStep,
|
||||||
|
| "core-values"
|
||||||
|
| "communication-methods"
|
||||||
|
| "membership-methods"
|
||||||
|
| "decision-approaches"
|
||||||
|
| "conflict-management"
|
||||||
|
>;
|
||||||
|
footerMessageKey: FooterMessageKey;
|
||||||
|
selectionIds: (state: CreateFlowState) => readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CUSTOM_RULE_CONFIRM_FOOTER_STEPS: readonly CustomRuleConfirmFooterStep[] =
|
||||||
|
[
|
||||||
|
{
|
||||||
|
step: "core-values",
|
||||||
|
footerMessageKey: "confirmCoreValues",
|
||||||
|
selectionIds: (s) => s.selectedCoreValueIds ?? [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: "communication-methods",
|
||||||
|
footerMessageKey: "confirmCommunication",
|
||||||
|
selectionIds: (s) => s.selectedCommunicationMethodIds ?? [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: "membership-methods",
|
||||||
|
footerMessageKey: "confirmMembership",
|
||||||
|
selectionIds: (s) => s.selectedMembershipMethodIds ?? [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: "decision-approaches",
|
||||||
|
footerMessageKey: "confirmDecisionApproaches",
|
||||||
|
selectionIds: (s) => s.selectedDecisionApproachIds ?? [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: "conflict-management",
|
||||||
|
footerMessageKey: "confirmConflictManagement",
|
||||||
|
selectionIds: (s) => s.selectedConflictManagementIds ?? [],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP: ReadonlyMap<
|
||||||
|
CreateFlowStep,
|
||||||
|
CustomRuleConfirmFooterStep
|
||||||
|
> = new Map(CUSTOM_RULE_CONFIRM_FOOTER_STEPS.map((e) => [e.step, e]));
|
||||||
@@ -20,8 +20,11 @@ export const FLOW_STEP_ORDER: readonly CreateFlowStep[] = [
|
|||||||
"community-upload",
|
"community-upload",
|
||||||
"community-save",
|
"community-save",
|
||||||
"review",
|
"review",
|
||||||
"cards",
|
"core-values",
|
||||||
"right-rail",
|
"communication-methods",
|
||||||
|
"membership-methods",
|
||||||
|
"decision-approaches",
|
||||||
|
"conflict-management",
|
||||||
"confirm-stakeholders",
|
"confirm-stakeholders",
|
||||||
"final-review",
|
"final-review",
|
||||||
"completed",
|
"completed",
|
||||||
@@ -37,16 +40,26 @@ export const VALID_STEPS: readonly CreateFlowStep[] = FLOW_STEP_ORDER;
|
|||||||
*/
|
*/
|
||||||
export const FIRST_STEP: CreateFlowStep = FLOW_STEP_ORDER[0];
|
export const FIRST_STEP: CreateFlowStep = FLOW_STEP_ORDER[0];
|
||||||
|
|
||||||
|
/** Options for navigation when the email / magic-link save step is not shown (signed-in users). */
|
||||||
|
export type CreateFlowNavigationOptions = {
|
||||||
|
skipCommunitySave?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the next step in the flow, or null if current is last/invalid
|
* Returns the next step in the flow, or null if current is last/invalid
|
||||||
*/
|
*/
|
||||||
export function getNextStep(
|
export function getNextStep(
|
||||||
currentStep: CreateFlowStep | null | undefined,
|
currentStep: CreateFlowStep | null | undefined,
|
||||||
|
options?: CreateFlowNavigationOptions,
|
||||||
): CreateFlowStep | null {
|
): CreateFlowStep | null {
|
||||||
if (!currentStep) return null;
|
if (!currentStep) return null;
|
||||||
const index = FLOW_STEP_ORDER.indexOf(currentStep);
|
const index = FLOW_STEP_ORDER.indexOf(currentStep);
|
||||||
if (index === -1 || index === FLOW_STEP_ORDER.length - 1) return null;
|
if (index === -1 || index === FLOW_STEP_ORDER.length - 1) return null;
|
||||||
return FLOW_STEP_ORDER[index + 1] as CreateFlowStep;
|
const next = FLOW_STEP_ORDER[index + 1] as CreateFlowStep;
|
||||||
|
if (options?.skipCommunitySave && next === "community-save") {
|
||||||
|
return getNextStep("community-save", options);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,11 +67,42 @@ export function getNextStep(
|
|||||||
*/
|
*/
|
||||||
export function getPreviousStep(
|
export function getPreviousStep(
|
||||||
currentStep: CreateFlowStep | null | undefined,
|
currentStep: CreateFlowStep | null | undefined,
|
||||||
|
options?: CreateFlowNavigationOptions,
|
||||||
): CreateFlowStep | null {
|
): CreateFlowStep | null {
|
||||||
if (!currentStep) return null;
|
if (!currentStep) return null;
|
||||||
const index = FLOW_STEP_ORDER.indexOf(currentStep);
|
const index = FLOW_STEP_ORDER.indexOf(currentStep);
|
||||||
if (index <= 0) return null;
|
if (index <= 0) return null;
|
||||||
return FLOW_STEP_ORDER[index - 1] as CreateFlowStep;
|
const prev = FLOW_STEP_ORDER[index - 1] as CreateFlowStep;
|
||||||
|
if (options?.skipCommunitySave && prev === "community-save") {
|
||||||
|
return getPreviousStep("community-save", options);
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the create-flow footer Back action should go. Usually the previous
|
||||||
|
* step in {@link FLOW_STEP_ORDER}; when the user reached `confirm-stakeholders`
|
||||||
|
* via template **Use without changes**, Back returns to template review instead
|
||||||
|
* of `conflict-management` (that segment was skipped).
|
||||||
|
*/
|
||||||
|
export type CreateFlowBackTarget =
|
||||||
|
| { kind: "step"; step: CreateFlowStep }
|
||||||
|
| { kind: "templateReview"; slug: string };
|
||||||
|
|
||||||
|
export function resolveCreateFlowBackTarget(
|
||||||
|
currentStep: CreateFlowStep | null | undefined,
|
||||||
|
options: CreateFlowNavigationOptions | undefined,
|
||||||
|
templateReviewBackSlug: string | undefined | null,
|
||||||
|
): CreateFlowBackTarget | null {
|
||||||
|
const slug =
|
||||||
|
typeof templateReviewBackSlug === "string"
|
||||||
|
? templateReviewBackSlug.trim()
|
||||||
|
: "";
|
||||||
|
if (currentStep === "confirm-stakeholders" && slug.length > 0) {
|
||||||
|
return { kind: "templateReview", slug };
|
||||||
|
}
|
||||||
|
const prev = getPreviousStep(currentStep, options);
|
||||||
|
return prev != null ? { kind: "step", step: prev } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,3 +144,22 @@ export function parseCreateFlowScreenFromPathname(
|
|||||||
|
|
||||||
return isValidStep(segment) ? segment : null;
|
return isValidStep(segment) ? segment : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Same query as `/templates?fromFlow=1` — template was picked after `/create/review`. */
|
||||||
|
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY = "fromFlow" as const;
|
||||||
|
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/create/review-template/{slug}` with optional marker so chrome can send
|
||||||
|
* footer Back to `/create/review` instead of marketing home.
|
||||||
|
*/
|
||||||
|
export function buildTemplateReviewHref(
|
||||||
|
slug: string,
|
||||||
|
options?: { fromCreateWizard?: boolean },
|
||||||
|
): string {
|
||||||
|
const path = `/create/review-template/${encodeURIComponent(slug)}`;
|
||||||
|
if (options?.fromCreateWizard) {
|
||||||
|
return `${path}?${TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY}=${TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE}`;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// Signed-in product surfaces (`/create/*`, `/login`, `/profile`) intentionally
|
||||||
|
// run without the marketing footer. Per-route chrome (e.g. CreateFlow's own
|
||||||
|
// header/footer lockup) is composed in nested layouts.
|
||||||
|
export default function AppLayout({ children }: { children: ReactNode }) {
|
||||||
|
return <main className="flex-1">{children}</main>;
|
||||||
|
}
|
||||||
@@ -3,18 +3,19 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslation } from "../contexts/MessagesContext";
|
import { useTranslation } from "../../contexts/MessagesContext";
|
||||||
import Login from "../components/modals/Login";
|
import Login from "../../components/modals/Login";
|
||||||
import LoginForm from "../components/modals/Login/LoginForm";
|
import LoginForm from "../../components/modals/Login/LoginForm";
|
||||||
|
|
||||||
const loginPageBgClass =
|
const loginPageBgClass =
|
||||||
"min-h-[100dvh] bg-[var(--color-surface-inverse-brand-primary)]";
|
"min-h-[100dvh] bg-[var(--color-surface-inverse-brand-primary)]";
|
||||||
|
|
||||||
function LoginLoadingFallback() {
|
function LoginLoadingFallback() {
|
||||||
|
const t = useTranslation("pages.login");
|
||||||
return (
|
return (
|
||||||
<div className={`${loginPageBgClass} flex items-center justify-center`}>
|
<div className={`${loginPageBgClass} flex items-center justify-center`}>
|
||||||
<p className="font-inter text-[14px] text-[var(--color-content-default-primary)]">
|
<p className="font-inter text-[14px] text-[var(--color-content-default-primary)]">
|
||||||
Loading…
|
{t("loadingFallback")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "../contexts/MessagesContext";
|
import { useTranslation } from "../../contexts/MessagesContext";
|
||||||
import Button from "../components/buttons/Button";
|
import Button from "../../components/buttons/Button";
|
||||||
import { fetchAuthSession, logout } from "../../lib/create/api";
|
import { fetchAuthSession, logout } from "../../../lib/create/api";
|
||||||
|
|
||||||
export default function ProfilePageClient() {
|
export default function ProfilePageClient() {
|
||||||
const t = useTranslation("pages.profile");
|
const t = useTranslation("pages.profile");
|
||||||
@@ -14,27 +14,26 @@ let ruleCardIdCounter = 0;
|
|||||||
interface ChipData {
|
interface ChipData {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
state: "Unselected" | "Selected" | "Custom";
|
state: "unselected" | "selected" | "custom";
|
||||||
palette: "Default" | "Inverse";
|
palette: "default" | "inverse";
|
||||||
size: "S" | "M";
|
size: "s" | "m";
|
||||||
}
|
}
|
||||||
|
|
||||||
// MultiSelect example component with state management
|
function MultiSelectExample({ size }: { size: "s" | "m" }) {
|
||||||
function MultiSelectExample({ size }: { size: "S" | "M" }) {
|
|
||||||
const [options, setOptions] = useState<
|
const [options, setOptions] = useState<
|
||||||
Array<{
|
Array<{
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
state: "Unselected" | "Selected" | "Custom";
|
state: "unselected" | "selected" | "custom";
|
||||||
}>
|
}>
|
||||||
>([
|
>([
|
||||||
{ id: "1", label: "1 member", state: "Unselected" },
|
{ id: "1", label: "1 member", state: "unselected" },
|
||||||
{ id: "2", label: "2-10 members", state: "Unselected" },
|
{ id: "2", label: "2-10 members", state: "unselected" },
|
||||||
{ id: "3", label: "10-24 members", state: "Unselected" },
|
{ id: "3", label: "10-24 members", state: "unselected" },
|
||||||
{ id: "4", label: "24-64 members", state: "Unselected" },
|
{ id: "4", label: "24-64 members", state: "unselected" },
|
||||||
{ id: "5", label: "64-128 members", state: "Unselected" },
|
{ id: "5", label: "64-128 members", state: "unselected" },
|
||||||
{ id: "6", label: "125-1000 members", state: "Unselected" },
|
{ id: "6", label: "125-1000 members", state: "unselected" },
|
||||||
{ id: "7", label: "1000+ members", state: "Unselected" },
|
{ id: "7", label: "1000+ members", state: "unselected" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleChipClick = (chipId: string) => {
|
const handleChipClick = (chipId: string) => {
|
||||||
@@ -43,7 +42,7 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) {
|
|||||||
opt.id === chipId
|
opt.id === chipId
|
||||||
? {
|
? {
|
||||||
...opt,
|
...opt,
|
||||||
state: opt.state === "Selected" ? "Unselected" : "Selected",
|
state: opt.state === "selected" ? "unselected" : "selected",
|
||||||
}
|
}
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
@@ -52,14 +51,14 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) {
|
|||||||
|
|
||||||
const handleAddClick = () => {
|
const handleAddClick = () => {
|
||||||
const newId = `custom-${Date.now()}`;
|
const newId = `custom-${Date.now()}`;
|
||||||
setOptions((prev) => [...prev, { id: newId, label: "", state: "Custom" }]);
|
setOptions((prev) => [...prev, { id: newId, label: "", state: "custom" }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCustomConfirm = (chipId: string, value: string) => {
|
const handleCustomConfirm = (chipId: string, value: string) => {
|
||||||
setOptions((prev) =>
|
setOptions((prev) =>
|
||||||
prev.map((opt) =>
|
prev.map((opt) =>
|
||||||
opt.id === chipId
|
opt.id === chipId
|
||||||
? { ...opt, label: value, state: "Selected" as const }
|
? { ...opt, label: value, state: "selected" as const }
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -72,7 +71,7 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-[var(--spacing-scale-016)]">
|
<div className="space-y-[var(--spacing-scale-016)]">
|
||||||
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)]">
|
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)]">
|
||||||
{size === "S" ? "Small (S)" : "Medium (M)"}
|
{size === "s" ? "Small (S)" : "Medium (M)"}
|
||||||
</h3>
|
</h3>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
label="Label"
|
label="Label"
|
||||||
@@ -91,12 +90,12 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) {
|
|||||||
|
|
||||||
export default function ComponentsPreview() {
|
export default function ComponentsPreview() {
|
||||||
const [chipStates, setChipStates] = useState<
|
const [chipStates, setChipStates] = useState<
|
||||||
Record<string, "Unselected" | "Selected">
|
Record<string, "unselected" | "selected">
|
||||||
>({
|
>({
|
||||||
"default-s": "Unselected",
|
"default-s": "unselected",
|
||||||
"default-m": "Unselected",
|
"default-m": "unselected",
|
||||||
"inverse-s": "Unselected",
|
"inverse-s": "unselected",
|
||||||
"inverse-m": "Unselected",
|
"inverse-m": "unselected",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Manage custom chips separately
|
// Manage custom chips separately
|
||||||
@@ -104,16 +103,16 @@ export default function ComponentsPreview() {
|
|||||||
{
|
{
|
||||||
id: "custom-1",
|
id: "custom-1",
|
||||||
label: "",
|
label: "",
|
||||||
state: "Custom",
|
state: "custom",
|
||||||
palette: "Default",
|
palette: "default",
|
||||||
size: "S",
|
size: "s",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "custom-2",
|
id: "custom-2",
|
||||||
label: "",
|
label: "",
|
||||||
state: "Custom",
|
state: "custom",
|
||||||
palette: "Default",
|
palette: "default",
|
||||||
size: "M",
|
size: "m",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -124,7 +123,7 @@ export default function ComponentsPreview() {
|
|||||||
chipOptions: Array<{
|
chipOptions: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
state: "Unselected" | "Selected" | "Custom";
|
state: "unselected" | "selected" | "custom";
|
||||||
}>;
|
}>;
|
||||||
onChipClick?: (_categoryName: string, _chipId: string) => void;
|
onChipClick?: (_categoryName: string, _chipId: string) => void;
|
||||||
onAddClick?: (_categoryName: string) => void;
|
onAddClick?: (_categoryName: string) => void;
|
||||||
@@ -139,11 +138,11 @@ export default function ComponentsPreview() {
|
|||||||
{
|
{
|
||||||
name: "Values",
|
name: "Values",
|
||||||
chipOptions: [
|
chipOptions: [
|
||||||
{ id: "values-1", label: "Consciousness", state: "Unselected" },
|
{ id: "values-1", label: "Consciousness", state: "unselected" },
|
||||||
{ id: "values-2", label: "Ecology", state: "Unselected" },
|
{ id: "values-2", label: "Ecology", state: "unselected" },
|
||||||
{ id: "values-3", label: "Abundance", state: "Unselected" },
|
{ id: "values-3", label: "Abundance", state: "unselected" },
|
||||||
{ id: "values-4", label: "Art", state: "Unselected" },
|
{ id: "values-4", label: "Art", state: "unselected" },
|
||||||
{ id: "values-5", label: "Decisiveness", state: "Unselected" },
|
{ id: "values-5", label: "Decisiveness", state: "unselected" },
|
||||||
],
|
],
|
||||||
onChipClick: (categoryName: string, chipId: string) => {
|
onChipClick: (categoryName: string, chipId: string) => {
|
||||||
setRuleCardCategories((prev) =>
|
setRuleCardCategories((prev) =>
|
||||||
@@ -156,9 +155,9 @@ export default function ComponentsPreview() {
|
|||||||
? {
|
? {
|
||||||
...opt,
|
...opt,
|
||||||
state:
|
state:
|
||||||
opt.state === "Selected"
|
opt.state === "selected"
|
||||||
? "Unselected"
|
? "unselected"
|
||||||
: "Selected",
|
: "selected",
|
||||||
}
|
}
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
@@ -176,7 +175,7 @@ export default function ComponentsPreview() {
|
|||||||
...cat,
|
...cat,
|
||||||
chipOptions: [
|
chipOptions: [
|
||||||
...cat.chipOptions,
|
...cat.chipOptions,
|
||||||
{ id: newId, label: "", state: "Custom" },
|
{ id: newId, label: "", state: "custom" },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: cat,
|
: cat,
|
||||||
@@ -195,7 +194,7 @@ export default function ComponentsPreview() {
|
|||||||
...cat,
|
...cat,
|
||||||
chipOptions: cat.chipOptions.map((opt) =>
|
chipOptions: cat.chipOptions.map((opt) =>
|
||||||
opt.id === chipId
|
opt.id === chipId
|
||||||
? { ...opt, label: value, state: "Selected" }
|
? { ...opt, label: value, state: "selected" }
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -220,7 +219,7 @@ export default function ComponentsPreview() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Communication",
|
name: "Communication",
|
||||||
chipOptions: [{ id: "comm-1", label: "Signal", state: "Unselected" }],
|
chipOptions: [{ id: "comm-1", label: "Signal", state: "unselected" }],
|
||||||
onChipClick: (categoryName: string, chipId: string) => {
|
onChipClick: (categoryName: string, chipId: string) => {
|
||||||
setRuleCardCategories((prev) =>
|
setRuleCardCategories((prev) =>
|
||||||
prev.map((cat) =>
|
prev.map((cat) =>
|
||||||
@@ -232,9 +231,9 @@ export default function ComponentsPreview() {
|
|||||||
? {
|
? {
|
||||||
...opt,
|
...opt,
|
||||||
state:
|
state:
|
||||||
opt.state === "Selected"
|
opt.state === "selected"
|
||||||
? "Unselected"
|
? "unselected"
|
||||||
: "Selected",
|
: "selected",
|
||||||
}
|
}
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
@@ -252,7 +251,7 @@ export default function ComponentsPreview() {
|
|||||||
...cat,
|
...cat,
|
||||||
chipOptions: [
|
chipOptions: [
|
||||||
...cat.chipOptions,
|
...cat.chipOptions,
|
||||||
{ id: newId, label: "", state: "Custom" },
|
{ id: newId, label: "", state: "custom" },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: cat,
|
: cat,
|
||||||
@@ -271,7 +270,7 @@ export default function ComponentsPreview() {
|
|||||||
...cat,
|
...cat,
|
||||||
chipOptions: cat.chipOptions.map((opt) =>
|
chipOptions: cat.chipOptions.map((opt) =>
|
||||||
opt.id === chipId
|
opt.id === chipId
|
||||||
? { ...opt, label: value, state: "Selected" }
|
? { ...opt, label: value, state: "selected" }
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -297,7 +296,7 @@ export default function ComponentsPreview() {
|
|||||||
{
|
{
|
||||||
name: "Membership",
|
name: "Membership",
|
||||||
chipOptions: [
|
chipOptions: [
|
||||||
{ id: "membership-1", label: "Open Admission", state: "Unselected" },
|
{ id: "membership-1", label: "Open Admission", state: "unselected" },
|
||||||
],
|
],
|
||||||
onChipClick: (categoryName: string, chipId: string) => {
|
onChipClick: (categoryName: string, chipId: string) => {
|
||||||
setRuleCardCategories((prev) =>
|
setRuleCardCategories((prev) =>
|
||||||
@@ -310,9 +309,9 @@ export default function ComponentsPreview() {
|
|||||||
? {
|
? {
|
||||||
...opt,
|
...opt,
|
||||||
state:
|
state:
|
||||||
opt.state === "Selected"
|
opt.state === "selected"
|
||||||
? "Unselected"
|
? "unselected"
|
||||||
: "Selected",
|
: "selected",
|
||||||
}
|
}
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
@@ -330,7 +329,7 @@ export default function ComponentsPreview() {
|
|||||||
...cat,
|
...cat,
|
||||||
chipOptions: [
|
chipOptions: [
|
||||||
...cat.chipOptions,
|
...cat.chipOptions,
|
||||||
{ id: newId, label: "", state: "Custom" },
|
{ id: newId, label: "", state: "custom" },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: cat,
|
: cat,
|
||||||
@@ -349,7 +348,7 @@ export default function ComponentsPreview() {
|
|||||||
...cat,
|
...cat,
|
||||||
chipOptions: cat.chipOptions.map((opt) =>
|
chipOptions: cat.chipOptions.map((opt) =>
|
||||||
opt.id === chipId
|
opt.id === chipId
|
||||||
? { ...opt, label: value, state: "Selected" }
|
? { ...opt, label: value, state: "selected" }
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -375,8 +374,8 @@ export default function ComponentsPreview() {
|
|||||||
{
|
{
|
||||||
name: "Decision-making",
|
name: "Decision-making",
|
||||||
chipOptions: [
|
chipOptions: [
|
||||||
{ id: "decision-1", label: "Lazy Consensus", state: "Unselected" },
|
{ id: "decision-1", label: "Lazy Consensus", state: "unselected" },
|
||||||
{ id: "decision-2", label: "Modified Consensus", state: "Unselected" },
|
{ id: "decision-2", label: "Modified Consensus", state: "unselected" },
|
||||||
],
|
],
|
||||||
onChipClick: (categoryName: string, chipId: string) => {
|
onChipClick: (categoryName: string, chipId: string) => {
|
||||||
setRuleCardCategories((prev) =>
|
setRuleCardCategories((prev) =>
|
||||||
@@ -389,9 +388,9 @@ export default function ComponentsPreview() {
|
|||||||
? {
|
? {
|
||||||
...opt,
|
...opt,
|
||||||
state:
|
state:
|
||||||
opt.state === "Selected"
|
opt.state === "selected"
|
||||||
? "Unselected"
|
? "unselected"
|
||||||
: "Selected",
|
: "selected",
|
||||||
}
|
}
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
@@ -409,7 +408,7 @@ export default function ComponentsPreview() {
|
|||||||
...cat,
|
...cat,
|
||||||
chipOptions: [
|
chipOptions: [
|
||||||
...cat.chipOptions,
|
...cat.chipOptions,
|
||||||
{ id: newId, label: "", state: "Custom" },
|
{ id: newId, label: "", state: "custom" },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: cat,
|
: cat,
|
||||||
@@ -428,7 +427,7 @@ export default function ComponentsPreview() {
|
|||||||
...cat,
|
...cat,
|
||||||
chipOptions: cat.chipOptions.map((opt) =>
|
chipOptions: cat.chipOptions.map((opt) =>
|
||||||
opt.id === chipId
|
opt.id === chipId
|
||||||
? { ...opt, label: value, state: "Selected" }
|
? { ...opt, label: value, state: "selected" }
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -454,8 +453,8 @@ export default function ComponentsPreview() {
|
|||||||
{
|
{
|
||||||
name: "Conflict management",
|
name: "Conflict management",
|
||||||
chipOptions: [
|
chipOptions: [
|
||||||
{ id: "conflict-1", label: "Code of Conduct", state: "Unselected" },
|
{ id: "conflict-1", label: "Code of Conduct", state: "unselected" },
|
||||||
{ id: "conflict-2", label: "Restorative Justice", state: "Unselected" },
|
{ id: "conflict-2", label: "Restorative Justice", state: "unselected" },
|
||||||
],
|
],
|
||||||
onChipClick: (categoryName: string, chipId: string) => {
|
onChipClick: (categoryName: string, chipId: string) => {
|
||||||
setRuleCardCategories((prev) =>
|
setRuleCardCategories((prev) =>
|
||||||
@@ -468,9 +467,9 @@ export default function ComponentsPreview() {
|
|||||||
? {
|
? {
|
||||||
...opt,
|
...opt,
|
||||||
state:
|
state:
|
||||||
opt.state === "Selected"
|
opt.state === "selected"
|
||||||
? "Unselected"
|
? "unselected"
|
||||||
: "Selected",
|
: "selected",
|
||||||
}
|
}
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
@@ -488,7 +487,7 @@ export default function ComponentsPreview() {
|
|||||||
...cat,
|
...cat,
|
||||||
chipOptions: [
|
chipOptions: [
|
||||||
...cat.chipOptions,
|
...cat.chipOptions,
|
||||||
{ id: newId, label: "", state: "Custom" },
|
{ id: newId, label: "", state: "custom" },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: cat,
|
: cat,
|
||||||
@@ -507,7 +506,7 @@ export default function ComponentsPreview() {
|
|||||||
...cat,
|
...cat,
|
||||||
chipOptions: cat.chipOptions.map((opt) =>
|
chipOptions: cat.chipOptions.map((opt) =>
|
||||||
opt.id === chipId
|
opt.id === chipId
|
||||||
? { ...opt, label: value, state: "Selected" }
|
? { ...opt, label: value, state: "selected" }
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -560,45 +559,45 @@ export default function ComponentsPreview() {
|
|||||||
<Chip
|
<Chip
|
||||||
label="Small"
|
label="Small"
|
||||||
state={chipStates["default-s"]}
|
state={chipStates["default-s"]}
|
||||||
palette="Default"
|
palette="default"
|
||||||
size="S"
|
size="s"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setChipStates((prev) => ({
|
setChipStates((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
"default-s":
|
"default-s":
|
||||||
prev["default-s"] === "Selected"
|
prev["default-s"] === "selected"
|
||||||
? "Unselected"
|
? "unselected"
|
||||||
: "Selected",
|
: "selected",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Chip
|
<Chip
|
||||||
label="Medium"
|
label="Medium"
|
||||||
state={chipStates["default-m"]}
|
state={chipStates["default-m"]}
|
||||||
palette="Default"
|
palette="default"
|
||||||
size="M"
|
size="m"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setChipStates((prev) => ({
|
setChipStates((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
"default-m":
|
"default-m":
|
||||||
prev["default-m"] === "Selected"
|
prev["default-m"] === "selected"
|
||||||
? "Unselected"
|
? "unselected"
|
||||||
: "Selected",
|
: "selected",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Chip
|
<Chip
|
||||||
label="Disabled"
|
label="Disabled"
|
||||||
state="Disabled"
|
state="disabled"
|
||||||
palette="Default"
|
palette="default"
|
||||||
size="S"
|
size="s"
|
||||||
/>
|
/>
|
||||||
{customChips
|
{customChips
|
||||||
.filter((chip) => chip.palette === "Default")
|
.filter((chip) => chip.palette === "default")
|
||||||
.map((chip) => (
|
.map((chip) => (
|
||||||
<Chip
|
<Chip
|
||||||
key={chip.id}
|
key={chip.id}
|
||||||
label={chip.state === "Custom" ? "" : chip.label}
|
label={chip.state === "custom" ? "" : chip.label}
|
||||||
state={chip.state}
|
state={chip.state}
|
||||||
palette={chip.palette}
|
palette={chip.palette}
|
||||||
size={chip.size}
|
size={chip.size}
|
||||||
@@ -607,7 +606,7 @@ export default function ComponentsPreview() {
|
|||||||
setCustomChips((prev) =>
|
setCustomChips((prev) =>
|
||||||
prev.map((c) =>
|
prev.map((c) =>
|
||||||
c.id === chip.id
|
c.id === chip.id
|
||||||
? { ...c, label: value, state: "Selected" }
|
? { ...c, label: value, state: "selected" }
|
||||||
: c,
|
: c,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -622,8 +621,8 @@ export default function ComponentsPreview() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// Only toggle if the chip is in Selected or Unselected state (not Custom)
|
// Only toggle if the chip is in Selected or Unselected state (not Custom)
|
||||||
if (
|
if (
|
||||||
chip.state === "Selected" ||
|
chip.state === "selected" ||
|
||||||
chip.state === "Unselected"
|
chip.state === "unselected"
|
||||||
) {
|
) {
|
||||||
setCustomChips((prev) =>
|
setCustomChips((prev) =>
|
||||||
prev.map((c) =>
|
prev.map((c) =>
|
||||||
@@ -631,9 +630,9 @@ export default function ComponentsPreview() {
|
|||||||
? {
|
? {
|
||||||
...c,
|
...c,
|
||||||
state:
|
state:
|
||||||
c.state === "Selected"
|
c.state === "selected"
|
||||||
? "Unselected"
|
? "unselected"
|
||||||
: "Selected",
|
: "selected",
|
||||||
}
|
}
|
||||||
: c,
|
: c,
|
||||||
),
|
),
|
||||||
@@ -652,9 +651,9 @@ export default function ComponentsPreview() {
|
|||||||
{
|
{
|
||||||
id: newId,
|
id: newId,
|
||||||
label: "",
|
label: "",
|
||||||
state: "Custom",
|
state: "custom",
|
||||||
palette: "Default",
|
palette: "default",
|
||||||
size: "S",
|
size: "s",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}}
|
}}
|
||||||
@@ -698,38 +697,38 @@ export default function ComponentsPreview() {
|
|||||||
<Chip
|
<Chip
|
||||||
label="Small"
|
label="Small"
|
||||||
state={chipStates["inverse-s"]}
|
state={chipStates["inverse-s"]}
|
||||||
palette="Inverse"
|
palette="inverse"
|
||||||
size="S"
|
size="s"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setChipStates((prev) => ({
|
setChipStates((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
"inverse-s":
|
"inverse-s":
|
||||||
prev["inverse-s"] === "Selected"
|
prev["inverse-s"] === "selected"
|
||||||
? "Unselected"
|
? "unselected"
|
||||||
: "Selected",
|
: "selected",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Chip
|
<Chip
|
||||||
label="Medium"
|
label="Medium"
|
||||||
state={chipStates["inverse-m"]}
|
state={chipStates["inverse-m"]}
|
||||||
palette="Inverse"
|
palette="inverse"
|
||||||
size="M"
|
size="m"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setChipStates((prev) => ({
|
setChipStates((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
"inverse-m":
|
"inverse-m":
|
||||||
prev["inverse-m"] === "Selected"
|
prev["inverse-m"] === "selected"
|
||||||
? "Unselected"
|
? "unselected"
|
||||||
: "Selected",
|
: "selected",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Chip
|
<Chip
|
||||||
label="Disabled"
|
label="Disabled"
|
||||||
state="Disabled"
|
state="disabled"
|
||||||
palette="Inverse"
|
palette="inverse"
|
||||||
size="S"
|
size="s"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -959,10 +958,10 @@ export default function ComponentsPreview() {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-300,12px)] p-[var(--spacing-scale-032)] space-y-[var(--spacing-scale-024)]">
|
<div className="bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-300,12px)] p-[var(--spacing-scale-032)] space-y-[var(--spacing-scale-024)]">
|
||||||
{/* Small size */}
|
{/* Small size */}
|
||||||
<MultiSelectExample size="S" />
|
<MultiSelectExample size="s" />
|
||||||
|
|
||||||
{/* Medium size */}
|
{/* Medium size */}
|
||||||
<MultiSelectExample size="M" />
|
<MultiSelectExample size="m" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// Development-only previews (e.g. `/components-preview`) — no public chrome.
|
||||||
|
// Routes here are gated by NODE_ENV checks at the page level.
|
||||||
|
export default function DevLayout({ children }: { children: ReactNode }) {
|
||||||
|
return <main className="flex-1">{children}</main>;
|
||||||
|
}
|
||||||
+4
-4
@@ -1,9 +1,9 @@
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { listRuleTemplatesFromDb } from "../../lib/server/ruleTemplates";
|
import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
|
||||||
import { GOVERNANCE_TEMPLATE_HOME_SLUGS } from "../../lib/templates/governanceTemplateCatalog";
|
import { GOVERNANCE_TEMPLATE_HOME_SLUGS } from "../../../lib/templates/governanceTemplateCatalog";
|
||||||
import { gridEntriesForSlugOrderWithCatalogFallback } from "../../lib/templates/templateGridPresentation";
|
import { gridEntriesForSlugOrderWithCatalogFallback } from "../../../lib/templates/templateGridPresentation";
|
||||||
|
|
||||||
const RuleStack = dynamic(() => import("../components/sections/RuleStack"), {
|
const RuleStack = dynamic(() => import("../../components/sections/RuleStack"), {
|
||||||
loading: () => (
|
loading: () => (
|
||||||
<section className="py-[var(--spacing-scale-032)] min-h-[400px]" />
|
<section className="py-[var(--spacing-scale-032)] min-h-[400px]" />
|
||||||
),
|
),
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// Site footer is part of the public marketing chrome only — not rendered for
|
||||||
|
// signed-in product surfaces, admin dashboards, or dev previews. See
|
||||||
|
// `.cursor/rules/routes.mdc` for the full chrome composition map.
|
||||||
|
const Footer = dynamic(() => import("../components/navigation/Footer"), {
|
||||||
|
loading: () => (
|
||||||
|
<footer className="bg-[var(--color-surface-default-primary)] w-full min-h-[200px]" />
|
||||||
|
),
|
||||||
|
ssr: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function MarketingLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import messages from "../../messages/en/index";
|
|||||||
import { getTranslation } from "../../lib/i18n/getTranslation";
|
import { getTranslation } from "../../lib/i18n/getTranslation";
|
||||||
import HeroBanner from "../components/sections/HeroBanner";
|
import HeroBanner from "../components/sections/HeroBanner";
|
||||||
import AskOrganizer from "../components/sections/AskOrganizer";
|
import AskOrganizer from "../components/sections/AskOrganizer";
|
||||||
import { MarketingRuleStackSection } from "./MarketingRuleStackSection";
|
import { MarketingRuleStackSection } from "./_components/MarketingRuleStackSection";
|
||||||
|
|
||||||
// Code split below-the-fold components to reduce initial bundle size
|
// Code split below-the-fold components to reduce initial bundle size
|
||||||
const LogoWall = dynamic(() => import("../components/sections/LogoWall"), {
|
const LogoWall = dynamic(() => import("../components/sections/LogoWall"), {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { Suspense } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||||
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
|
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
|
||||||
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
|
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
|
||||||
|
import { clearCreateFlowPersistedDrafts } from "../../(app)/create/utils/clearCreateFlowPersistedDrafts";
|
||||||
|
import { buildTemplateReviewHref } from "../../(app)/create/utils/flowSteps";
|
||||||
import { useTranslation } from "../../contexts/MessagesContext";
|
import { useTranslation } from "../../contexts/MessagesContext";
|
||||||
|
|
||||||
export interface TemplatesPageClientProps {
|
export interface TemplatesPageClientProps {
|
||||||
@@ -17,7 +20,6 @@ export interface TemplatesPageClientProps {
|
|||||||
export default function TemplatesPageClient({
|
export default function TemplatesPageClient({
|
||||||
initialGridEntries,
|
initialGridEntries,
|
||||||
}: TemplatesPageClientProps) {
|
}: TemplatesPageClientProps) {
|
||||||
const router = useRouter();
|
|
||||||
const t = useTranslation("pages.templates");
|
const t = useTranslation("pages.templates");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -39,16 +41,58 @@ export default function TemplatesPageClient({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 min-[1024px]:mt-8">
|
<div className="mt-6 min-[1024px]:mt-8">
|
||||||
<GovernanceTemplateGrid
|
{/* Suspense boundary required by `useSearchParams` below
|
||||||
entries={initialGridEntries}
|
(Next.js 15+ static-generation contract). */}
|
||||||
onTemplateClick={(slug) => {
|
<Suspense
|
||||||
router.push(
|
fallback={<TemplatesGrid entries={initialGridEntries} fromFlow={false} />}
|
||||||
`/create/review-template/${encodeURIComponent(slug)}`,
|
>
|
||||||
);
|
<TemplatesGridWithSearchParams entries={initialGridEntries} />
|
||||||
}}
|
</Suspense>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads `fromFlow=1` off the URL so we can skip the fresh-slate clear when
|
||||||
|
* the user arrived from `/create/review`'s "Create from template" button.
|
||||||
|
* That button pushes `/templates?fromFlow=1` so their in-progress community
|
||||||
|
* stage is preserved when they pick a template here.
|
||||||
|
*/
|
||||||
|
function TemplatesGridWithSearchParams({
|
||||||
|
entries,
|
||||||
|
}: {
|
||||||
|
entries: TemplateGridCardEntry[];
|
||||||
|
}) {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const fromFlow = searchParams.get("fromFlow") === "1";
|
||||||
|
return <TemplatesGrid entries={entries} fromFlow={fromFlow} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplatesGrid({
|
||||||
|
entries,
|
||||||
|
fromFlow,
|
||||||
|
}: {
|
||||||
|
entries: TemplateGridCardEntry[];
|
||||||
|
fromFlow: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<GovernanceTemplateGrid
|
||||||
|
entries={entries}
|
||||||
|
onTemplateClick={(slug) => {
|
||||||
|
if (!fromFlow) {
|
||||||
|
// Direct entry to `/templates`: treat template click as a fresh
|
||||||
|
// create-flow start and wipe any stale anonymous draft before
|
||||||
|
// navigating. In-flow entry (`?fromFlow=1`) skips the clear so
|
||||||
|
// the user's community stage survives the detour through here.
|
||||||
|
clearCreateFlowPersistedDrafts();
|
||||||
|
}
|
||||||
|
router.push(
|
||||||
|
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
||||||
|
import { listMethodRecommendations } from "../../../../lib/server/methodRecommendations";
|
||||||
|
import { dbUnavailable } from "../../../../lib/server/responses";
|
||||||
|
import {
|
||||||
|
SECTION_IDS,
|
||||||
|
type SectionId,
|
||||||
|
parseRequestedFacetsFromSearchParams,
|
||||||
|
} from "../../../../lib/server/validation/methodFacetsSchemas";
|
||||||
|
|
||||||
|
const SECTION_SET = new Set<string>(SECTION_IDS);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/create-flow/methods?section=<section>[&facet.*=...]
|
||||||
|
*
|
||||||
|
* Returns slugs + per-method match scores for one of the four card-deck
|
||||||
|
* sections; the wizard renders by looking up the slug in the section's
|
||||||
|
* messages file (`useMessages().create.customRule.<section>.methods`).
|
||||||
|
*
|
||||||
|
* See `docs/guides/template-recommendation-matrix.md` §9.2 / §10.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
if (!isDatabaseConfigured()) {
|
||||||
|
return dbUnavailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionParam = request.nextUrl.searchParams.get("section");
|
||||||
|
if (!sectionParam || !SECTION_SET.has(sectionParam)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
code: "validation_error",
|
||||||
|
message: `Unknown section. Expected one of: ${SECTION_IDS.join(", ")}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const section = sectionParam as SectionId;
|
||||||
|
|
||||||
|
const facets = parseRequestedFacetsFromSearchParams(
|
||||||
|
request.nextUrl.searchParams,
|
||||||
|
);
|
||||||
|
const result = await listMethodRecommendations({ section, facets });
|
||||||
|
if (!result) {
|
||||||
|
// DB query failed; return empty so the wizard falls back to its messages
|
||||||
|
// deck in authoring order (§10).
|
||||||
|
return NextResponse.json({ section, methods: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const methods = result.rankedSlugs.map((slug) => ({
|
||||||
|
slug,
|
||||||
|
matches: result.matchesBySlug[slug] ?? { score: 0, matchedFacets: [] },
|
||||||
|
}));
|
||||||
|
return NextResponse.json({ section, methods });
|
||||||
|
}
|
||||||
@@ -68,3 +68,20 @@ export async function PUT(request: NextRequest) {
|
|||||||
draft: { payload: draft.payload, updatedAt: draft.updatedAt },
|
draft: { payload: draft.payload, updatedAt: draft.updatedAt },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function DELETE() {
|
||||||
|
if (!isDatabaseConfigured()) {
|
||||||
|
return dbUnavailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent: missing draft is a no-op so callers can fire-and-forget after
|
||||||
|
// publish / exit without worrying about prior state.
|
||||||
|
await prisma.ruleDraft.deleteMany({ where: { userId: user.id } });
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
import { isDatabaseConfigured } from "../../../lib/server/env";
|
import { isDatabaseConfigured } from "../../../lib/server/env";
|
||||||
import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
|
import { listRankedRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
|
||||||
import { dbUnavailable } from "../../../lib/server/responses";
|
import { dbUnavailable } from "../../../lib/server/responses";
|
||||||
|
import { parseRequestedFacetsFromSearchParams } from "../../../lib/server/validation/methodFacetsSchemas";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Curated rule templates for recommendations (seed via Prisma Studio or a script).
|
* GET /api/templates
|
||||||
|
*
|
||||||
|
* No params → curated ordering (`featured` desc, `sortOrder` asc, `title`
|
||||||
|
* asc). With `facet.<group>=<value>` query params (repeatable per group),
|
||||||
|
* templates are re-ranked by composed-method match count; ties fall back to
|
||||||
|
* the curated order, score-0 templates remain at the end.
|
||||||
|
*
|
||||||
|
* See `docs/guides/template-recommendation-matrix.md` §9.1.
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
if (!isDatabaseConfigured()) {
|
if (!isDatabaseConfigured()) {
|
||||||
return dbUnavailable();
|
return dbUnavailable();
|
||||||
}
|
}
|
||||||
|
|
||||||
const templates = await listRuleTemplatesFromDb();
|
const facets = parseRequestedFacetsFromSearchParams(
|
||||||
|
request.nextUrl.searchParams,
|
||||||
|
);
|
||||||
|
const { templates, scores } = await listRankedRuleTemplatesFromDb(facets);
|
||||||
|
const hasScores = Object.keys(scores).length > 0;
|
||||||
|
|
||||||
return NextResponse.json({ templates });
|
return NextResponse.json(
|
||||||
|
hasScores ? { templates, scores } : { templates },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,34 +5,26 @@ import type {
|
|||||||
ButtonPaletteValue,
|
ButtonPaletteValue,
|
||||||
ButtonStateValue,
|
ButtonStateValue,
|
||||||
} from "../../../lib/propNormalization";
|
} from "../../../lib/propNormalization";
|
||||||
import {
|
|
||||||
normalizeSize,
|
|
||||||
normalizeButtonType,
|
|
||||||
normalizeButtonPalette,
|
|
||||||
} from "../../../lib/propNormalization";
|
|
||||||
|
|
||||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
/**
|
/**
|
||||||
* Button type (Figma prop). Accepts both lowercase and PascalCase (case-insensitive).
|
* Button type (Figma prop).
|
||||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
|
||||||
* @default "filled"
|
* @default "filled"
|
||||||
*/
|
*/
|
||||||
buttonType?: ButtonTypeValue;
|
buttonType?: ButtonTypeValue;
|
||||||
/**
|
/**
|
||||||
* Button palette (Figma prop). Accepts both lowercase and PascalCase (case-insensitive).
|
* Button palette (Figma prop).
|
||||||
* Figma uses "Invert", codebase uses "inverse" - both are supported.
|
|
||||||
* @default "default"
|
* @default "default"
|
||||||
*/
|
*/
|
||||||
palette?: ButtonPaletteValue;
|
palette?: ButtonPaletteValue;
|
||||||
/**
|
/**
|
||||||
* Button size. Accepts both lowercase and PascalCase (case-insensitive).
|
* Button size.
|
||||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
|
||||||
* @default "xsmall"
|
* @default "xsmall"
|
||||||
*/
|
*/
|
||||||
size?: SizeValue;
|
size?: SizeValue;
|
||||||
/**
|
/**
|
||||||
* Button state (Figma prop). Accepts both lowercase and PascalCase (case-insensitive).
|
* Button state (Figma prop).
|
||||||
* @default "default"
|
* @default "default"
|
||||||
*/
|
*/
|
||||||
state?: ButtonStateValue;
|
state?: ButtonStateValue;
|
||||||
@@ -83,12 +75,9 @@ const Button = memo<ButtonProps>(
|
|||||||
ariaLabel,
|
ariaLabel,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
// Normalize props
|
const buttonType = typeProp ?? "filled";
|
||||||
const buttonType = normalizeButtonType(typeProp, "filled");
|
const buttonPalette = paletteProp ?? "default";
|
||||||
const buttonPalette = normalizeButtonPalette(paletteProp, "default");
|
const size = sizeProp;
|
||||||
const size = normalizeSize(sizeProp);
|
|
||||||
// State prop is for Figma alignment - actual state is handled by CSS pseudo-classes
|
|
||||||
// We accept it for API alignment but don't use it for styling (CSS handles states)
|
|
||||||
|
|
||||||
// Map type + palette to variant string for styling (internal use only)
|
// Map type + palette to variant string for styling (internal use only)
|
||||||
const getVariantFromTypeAndPalette = (
|
const getVariantFromTypeAndPalette = (
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
|
||||||
|
export interface InlineTextButtonProps {
|
||||||
|
/**
|
||||||
|
* Button label content.
|
||||||
|
*/
|
||||||
|
children: React.ReactNode;
|
||||||
|
/**
|
||||||
|
* Click handler.
|
||||||
|
*/
|
||||||
|
onClick?: (_event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
|
/**
|
||||||
|
* Extra class names. Use `className` to override typography/color when the
|
||||||
|
* button must inherit parent font-size/leading (e.g. mid-paragraph usage).
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
ariaLabel?: string;
|
||||||
|
type?: "button" | "submit" | "reset";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small text-styled button for in-paragraph "link"-like controls (expand,
|
||||||
|
* add, etc.). The Figma "link" treatment is a tertiary-colored underline with
|
||||||
|
* a 3px underline-offset and inherited typography, which sits between a real
|
||||||
|
* anchor and a styled `Button`.
|
||||||
|
*
|
||||||
|
* Use this anywhere a `<button>` is needed inline with body copy — do not use
|
||||||
|
* for primary/secondary actions (reach for `Button` instead).
|
||||||
|
*/
|
||||||
|
function InlineTextButtonComponent({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
className = "",
|
||||||
|
disabled = false,
|
||||||
|
ariaLabel,
|
||||||
|
type = "button",
|
||||||
|
}: InlineTextButtonProps) {
|
||||||
|
const baseClasses =
|
||||||
|
"cursor-pointer border-none bg-transparent p-0 font-inter font-normal text-[length:inherit] leading-[inherit] text-[color:var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)] disabled:cursor-not-allowed disabled:opacity-60";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={`${baseClasses} ${className}`.trim()}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
InlineTextButtonComponent.displayName = "InlineTextButton";
|
||||||
|
|
||||||
|
export default memo(InlineTextButtonComponent);
|
||||||
@@ -3,80 +3,57 @@
|
|||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import SectionNumber from "../sections/SectionNumber";
|
import SectionNumber from "../sections/SectionNumber";
|
||||||
|
|
||||||
import { normalizeNumberCardSize } from "../../../lib/propNormalization";
|
export type NumberCardSizeValue = "small" | "medium" | "large" | "xlarge";
|
||||||
|
|
||||||
export type NumberCardSizeValue =
|
|
||||||
| "Small"
|
|
||||||
| "Medium"
|
|
||||||
| "Large"
|
|
||||||
| "XLarge"
|
|
||||||
| "small"
|
|
||||||
| "medium"
|
|
||||||
| "large"
|
|
||||||
| "xlarge";
|
|
||||||
|
|
||||||
interface NumberCardProps {
|
interface NumberCardProps {
|
||||||
number: number;
|
number: number;
|
||||||
text: string;
|
text: string;
|
||||||
/**
|
|
||||||
* Number card size. Accepts both PascalCase (Figma default) and lowercase (case-insensitive).
|
|
||||||
* Figma uses PascalCase, codebase uses PascalCase - both are supported.
|
|
||||||
*/
|
|
||||||
size?: NumberCardSizeValue;
|
size?: NumberCardSizeValue;
|
||||||
iconShape?: string;
|
iconShape?: string;
|
||||||
iconColor?: string;
|
iconColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NumberCard = memo<NumberCardProps>(({ number, text, size: sizeProp }) => {
|
const NumberCard = memo<NumberCardProps>(({ number, text, size: sizeProp }) => {
|
||||||
// Base classes common to all sizes
|
|
||||||
const baseClasses =
|
const baseClasses =
|
||||||
"bg-[var(--color-surface-inverse-primary)] rounded-[12px] shadow-lg";
|
"bg-[var(--color-surface-inverse-primary)] rounded-[12px] shadow-lg";
|
||||||
|
|
||||||
// If size prop is provided, use explicit size classes
|
|
||||||
// Otherwise, use responsive breakpoints for backward compatibility
|
|
||||||
if (sizeProp) {
|
if (sizeProp) {
|
||||||
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
|
const size = sizeProp;
|
||||||
const size = normalizeNumberCardSize(sizeProp);
|
|
||||||
// Size-specific classes
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
Small: "flex flex-col items-end justify-center gap-4 p-5 relative",
|
small: "flex flex-col items-end justify-center gap-4 p-5 relative",
|
||||||
Medium: "flex flex-row items-center gap-8 p-8 relative",
|
medium: "flex flex-row items-center gap-8 p-8 relative",
|
||||||
Large:
|
large:
|
||||||
"flex flex-col items-start justify-end gap-[22px] h-[238px] p-8 relative",
|
"flex flex-col items-start justify-end gap-[22px] h-[238px] p-8 relative",
|
||||||
XLarge:
|
xlarge:
|
||||||
"flex flex-col items-start justify-end gap-[22px] h-[238px] p-8 relative",
|
"flex flex-col items-start justify-end gap-[22px] h-[238px] p-8 relative",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Text size classes
|
|
||||||
const textClasses = {
|
const textClasses = {
|
||||||
Small:
|
small:
|
||||||
"font-bricolage-grotesque font-medium text-[24px] leading-[32px] text-[#141414]",
|
"font-bricolage-grotesque font-medium text-[24px] leading-[32px] text-[#141414]",
|
||||||
Medium:
|
medium:
|
||||||
"font-bricolage-grotesque font-medium text-[24px] leading-[24px] text-[#141414]",
|
"font-bricolage-grotesque font-medium text-[24px] leading-[24px] text-[#141414]",
|
||||||
Large:
|
large:
|
||||||
"font-bricolage-grotesque font-medium text-[24px] leading-[24px] text-[#141414]",
|
"font-bricolage-grotesque font-medium text-[24px] leading-[24px] text-[#141414]",
|
||||||
XLarge:
|
xlarge:
|
||||||
"font-bricolage-grotesque font-medium text-[32px] leading-[32px] text-[#141414]",
|
"font-bricolage-grotesque font-medium text-[32px] leading-[32px] text-[#141414]",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Section number wrapper classes - Small doesn't need a wrapper
|
|
||||||
const sectionNumberWrapperClasses = {
|
const sectionNumberWrapperClasses = {
|
||||||
Small: "relative shrink-0",
|
small: "relative shrink-0",
|
||||||
Medium: "flex justify-start flex-shrink-0",
|
medium: "flex justify-start flex-shrink-0",
|
||||||
Large: "absolute top-8 right-8",
|
large: "absolute top-8 right-8",
|
||||||
XLarge: "absolute top-8 right-8",
|
xlarge: "absolute top-8 right-8",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Content container classes
|
|
||||||
const contentClasses = {
|
const contentClasses = {
|
||||||
Small: "min-w-full relative shrink-0",
|
small: "min-w-full relative shrink-0",
|
||||||
Medium: "flex-1",
|
medium: "flex-1",
|
||||||
Large: "absolute bottom-8 left-8 right-16",
|
large: "absolute bottom-8 left-8 right-16",
|
||||||
XLarge: "absolute bottom-8 left-8 right-16",
|
xlarge: "absolute bottom-8 left-8 right-16",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Small variant has section number as direct child, others need wrapper
|
if (size === "small") {
|
||||||
if (size === "Small") {
|
|
||||||
return (
|
return (
|
||||||
<div className={`${baseClasses} ${sizeClasses[size]}`}>
|
<div className={`${baseClasses} ${sizeClasses[size]}`}>
|
||||||
{/* Section Number - Direct child for Small */}
|
{/* Section Number - Direct child for Small */}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { RuleCardView } from "./RuleCard.view";
|
import { RuleCardView } from "./RuleCard.view";
|
||||||
import type { RuleCardProps } from "./RuleCard.types";
|
import type { RuleCardProps } from "./RuleCard.types";
|
||||||
import { normalizeRuleCardSize } from "../../../../lib/propNormalization";
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -32,9 +31,9 @@ const RuleCardContainer = memo<RuleCardProps>(
|
|||||||
logoUrl,
|
logoUrl,
|
||||||
logoAlt,
|
logoAlt,
|
||||||
communityInitials,
|
communityInitials,
|
||||||
|
hideCategoryAddButton = false,
|
||||||
}) => {
|
}) => {
|
||||||
// Normalize size prop
|
const size = sizeProp ?? "L";
|
||||||
const size = normalizeRuleCardSize(sizeProp, "L");
|
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
// Basic analytics event tracking
|
// Basic analytics event tracking
|
||||||
@@ -78,6 +77,7 @@ const RuleCardContainer = memo<RuleCardProps>(
|
|||||||
logoUrl={logoUrl}
|
logoUrl={logoUrl}
|
||||||
logoAlt={logoAlt}
|
logoAlt={logoAlt}
|
||||||
communityInitials={communityInitials}
|
communityInitials={communityInitials}
|
||||||
|
hideCategoryAddButton={hideCategoryAddButton}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ export interface RuleCardProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
size?: "XS" | "S" | "M" | "L" | "xs" | "s" | "m" | "l";
|
size?: "XS" | "S" | "M" | "L";
|
||||||
categories?: Category[];
|
categories?: Category[];
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
logoAlt?: string;
|
logoAlt?: string;
|
||||||
communityInitials?: string;
|
communityInitials?: string;
|
||||||
|
/** Hide the per-category "+" add chip affordance (e.g. read-only template review). */
|
||||||
|
hideCategoryAddButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuleCardViewProps {
|
export interface RuleCardViewProps {
|
||||||
@@ -42,4 +44,5 @@ export interface RuleCardViewProps {
|
|||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
logoAlt?: string;
|
logoAlt?: string;
|
||||||
communityInitials?: string;
|
communityInitials?: string;
|
||||||
|
hideCategoryAddButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function RuleCardView({
|
|||||||
logoUrl,
|
logoUrl,
|
||||||
logoAlt,
|
logoAlt,
|
||||||
communityInitials,
|
communityInitials,
|
||||||
|
hideCategoryAddButton = false,
|
||||||
}: RuleCardViewProps) {
|
}: RuleCardViewProps) {
|
||||||
const t = useTranslation("ruleCard");
|
const t = useTranslation("ruleCard");
|
||||||
const ariaLabel = t("ariaLabel")?.replace("{title}", title) || title;
|
const ariaLabel = t("ariaLabel")?.replace("{title}", title) || title;
|
||||||
@@ -261,8 +262,8 @@ export function RuleCardView({
|
|||||||
key={categoryIndex}
|
key={categoryIndex}
|
||||||
label={category.name}
|
label={category.name}
|
||||||
showHelpIcon={false}
|
showHelpIcon={false}
|
||||||
size="S"
|
size="s"
|
||||||
palette="Inverse"
|
palette="inverse"
|
||||||
options={category.chipOptions}
|
options={category.chipOptions}
|
||||||
onChipClick={(chipId) => {
|
onChipClick={(chipId) => {
|
||||||
category.onChipClick?.(category.name, chipId);
|
category.onChipClick?.(category.name, chipId);
|
||||||
@@ -280,7 +281,7 @@ export function RuleCardView({
|
|||||||
onCustomChipClose={(chipId) => {
|
onCustomChipClose={(chipId) => {
|
||||||
category.onCustomChipClose?.(category.name, chipId);
|
category.onCustomChipClose?.(category.name, chipId);
|
||||||
}}
|
}}
|
||||||
addButton={true}
|
addButton={!hideCategoryAddButton}
|
||||||
addButtonText="" // Empty text for icon-only circular button
|
addButtonText="" // Empty text for icon-only circular button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import Create from "../../modals/Create";
|
||||||
|
import Chip from "../../controls/Chip";
|
||||||
|
import InputLabel from "../../utility/InputLabel";
|
||||||
|
import ContentLockup from "../../type/ContentLockup";
|
||||||
|
import ModalTextAreaField from "../../../(app)/create/components/ModalTextAreaField";
|
||||||
|
import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
|
||||||
|
import type { TemplateChipDetail } from "../../../../lib/create/templateReviewMapping";
|
||||||
|
|
||||||
|
export interface TemplateChipDetailModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
detail: TemplateChipDetail | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only mirror of the custom-rule per-chip modals. Shows the exact text
|
||||||
|
* from `messages/en/create/customRule/*.json` for the matched preset — never
|
||||||
|
* the template `body` placeholder. When no preset is found for the chip label,
|
||||||
|
* the modal surfaces a clear "details not available" note rather than falling
|
||||||
|
* back to seed copy.
|
||||||
|
*/
|
||||||
|
export function TemplateChipDetailModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
detail,
|
||||||
|
}: TemplateChipDetailModalProps) {
|
||||||
|
const m = useMessages();
|
||||||
|
const t = useTranslation("create.templateReview.chipDetailModal");
|
||||||
|
|
||||||
|
const resolved = useMemo(() => resolveChipContent(detail, m), [detail, m]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Create
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
backdropVariant="loginYellow"
|
||||||
|
headerContent={
|
||||||
|
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
|
||||||
|
<ContentLockup
|
||||||
|
title={resolved?.title ?? ""}
|
||||||
|
description={resolved?.subtitle ?? ""}
|
||||||
|
variant="modal"
|
||||||
|
alignment="left"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
showBackButton={false}
|
||||||
|
showNextButton
|
||||||
|
onNext={onClose}
|
||||||
|
nextButtonText={t("closeButton")}
|
||||||
|
ariaLabel={resolved?.title || "Template entry details"}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-[var(--measures-spacing-600,24px)] pb-2">
|
||||||
|
{resolved?.body ?? (
|
||||||
|
<p className="font-inter text-[14px] leading-[20px] text-[color:var(--color-content-default-secondary,#a3a3a3)]">
|
||||||
|
{t("fallback.bodyLabel")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Create>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResolvedChipContent = {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
body: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveChipContent(
|
||||||
|
detail: TemplateChipDetail | null,
|
||||||
|
m: ReturnType<typeof useMessages>,
|
||||||
|
): ResolvedChipContent | null {
|
||||||
|
if (!detail) return null;
|
||||||
|
const title = detail.chipLabel;
|
||||||
|
|
||||||
|
switch (detail.groupKey) {
|
||||||
|
case "coreValues": {
|
||||||
|
const cv = m.create.customRule.coreValues;
|
||||||
|
const preset = findCoreValuePreset(cv.values, detail.chipLabel);
|
||||||
|
if (!preset) return noPresetFallback(title);
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
subtitle: cv.detailModal.subtitle,
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={cv.detailModal.meaningLabel}
|
||||||
|
value={preset.meaning}
|
||||||
|
onChange={noop}
|
||||||
|
disabled
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={cv.detailModal.signalsLabel}
|
||||||
|
value={preset.signals}
|
||||||
|
onChange={noop}
|
||||||
|
disabled
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "communication": {
|
||||||
|
const comm = m.create.customRule.communication;
|
||||||
|
const preset = findMethodByLabel(comm.methods, detail.chipLabel);
|
||||||
|
if (!preset) return noPresetFallback(title);
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
subtitle: preset.supportText,
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={comm.sectionHeadings.corePrinciple}
|
||||||
|
value={preset.sections.corePrinciple}
|
||||||
|
onChange={noop}
|
||||||
|
disabled
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={comm.sectionHeadings.logisticsAdmin}
|
||||||
|
value={preset.sections.logisticsAdmin}
|
||||||
|
onChange={noop}
|
||||||
|
disabled
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={comm.sectionHeadings.codeOfConduct}
|
||||||
|
value={preset.sections.codeOfConduct}
|
||||||
|
onChange={noop}
|
||||||
|
disabled
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "membership": {
|
||||||
|
const mem = m.create.customRule.membership;
|
||||||
|
const preset = findMethodByLabel(mem.methods, detail.chipLabel);
|
||||||
|
if (!preset) return noPresetFallback(title);
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
subtitle: preset.supportText,
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={mem.sectionHeadings.eligibility}
|
||||||
|
value={preset.sections.eligibility}
|
||||||
|
onChange={noop}
|
||||||
|
disabled
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={mem.sectionHeadings.joiningProcess}
|
||||||
|
value={preset.sections.joiningProcess}
|
||||||
|
onChange={noop}
|
||||||
|
disabled
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={mem.sectionHeadings.expectations}
|
||||||
|
value={preset.sections.expectations}
|
||||||
|
onChange={noop}
|
||||||
|
disabled
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "decisionApproaches": {
|
||||||
|
const da = m.create.customRule.decisionApproaches;
|
||||||
|
const preset = findMethodByLabel(da.methods, detail.chipLabel);
|
||||||
|
if (!preset) return noPresetFallback(title);
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
subtitle: preset.supportText,
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={da.sectionHeadings.corePrinciple}
|
||||||
|
value={preset.sections.corePrinciple}
|
||||||
|
onChange={noop}
|
||||||
|
disabled
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<ReadOnlyScopeField
|
||||||
|
label={da.sectionHeadings.applicableScope}
|
||||||
|
scopes={preset.sections.applicableScope}
|
||||||
|
/>
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={da.sectionHeadings.stepByStepInstructions}
|
||||||
|
value={preset.sections.stepByStepInstructions}
|
||||||
|
onChange={noop}
|
||||||
|
disabled
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<ReadOnlyValueField
|
||||||
|
label={da.sectionHeadings.consensusLevel}
|
||||||
|
value={`${preset.sections.consensusLevel}%`}
|
||||||
|
/>
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={da.sectionHeadings.objectionsDeadlocks}
|
||||||
|
value={preset.sections.objectionsDeadlocks}
|
||||||
|
onChange={noop}
|
||||||
|
disabled
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "conflictManagement": {
|
||||||
|
const cm = m.create.customRule.conflictManagement;
|
||||||
|
const preset = findMethodByLabel(cm.methods, detail.chipLabel);
|
||||||
|
if (!preset) return noPresetFallback(title);
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
subtitle: preset.supportText,
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={cm.sectionHeadings.corePrinciple}
|
||||||
|
value={preset.sections.corePrinciple}
|
||||||
|
onChange={noop}
|
||||||
|
disabled
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<ReadOnlyScopeField
|
||||||
|
label={cm.sectionHeadings.applicableScope}
|
||||||
|
scopes={preset.sections.applicableScope}
|
||||||
|
/>
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={cm.sectionHeadings.processProtocol}
|
||||||
|
value={preset.sections.processProtocol}
|
||||||
|
onChange={noop}
|
||||||
|
disabled
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<ModalTextAreaField
|
||||||
|
label={cm.sectionHeadings.restorationFallbacks}
|
||||||
|
value={preset.sections.restorationFallbacks}
|
||||||
|
onChange={noop}
|
||||||
|
disabled
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return noPresetFallback(title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function noPresetFallback(title: string): ResolvedChipContent {
|
||||||
|
return { title, subtitle: "", body: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function noop() {
|
||||||
|
/* read-only */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal read-only Applicable Scope row — locked chips shown as "selected"
|
||||||
|
* without the "+ Add" affordance.
|
||||||
|
*/
|
||||||
|
function ReadOnlyScopeField({
|
||||||
|
label,
|
||||||
|
scopes,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
scopes: readonly string[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<InputLabel label={label} helpIcon size="s" palette="default" />
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{scopes.map((scope) => (
|
||||||
|
<Chip
|
||||||
|
key={scope}
|
||||||
|
label={scope}
|
||||||
|
state="selected"
|
||||||
|
palette="default"
|
||||||
|
size="s"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReadOnlyValueField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<InputLabel label={label} helpIcon size="s" palette="default" />
|
||||||
|
<span className="font-inter text-[16px] font-medium leading-[20px] text-[color:var(--color-content-default-primary)]">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Case-insensitive, trim-tolerant method lookup by `label`. */
|
||||||
|
function findMethodByLabel<T extends { label: string }>(
|
||||||
|
methods: readonly T[],
|
||||||
|
label: string,
|
||||||
|
): T | undefined {
|
||||||
|
const normalized = label.trim().toLowerCase();
|
||||||
|
return methods.find((m) => m.label.trim().toLowerCase() === normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoreValuePreset = { label: string; meaning: string; signals: string };
|
||||||
|
|
||||||
|
function findCoreValuePreset(
|
||||||
|
values: readonly unknown[],
|
||||||
|
label: string,
|
||||||
|
): CoreValuePreset | undefined {
|
||||||
|
const normalized = label.trim().toLowerCase();
|
||||||
|
for (const v of values) {
|
||||||
|
if (
|
||||||
|
v &&
|
||||||
|
typeof v === "object" &&
|
||||||
|
"label" in v &&
|
||||||
|
typeof (v as CoreValuePreset).label === "string" &&
|
||||||
|
(v as CoreValuePreset).label.trim().toLowerCase() === normalized
|
||||||
|
) {
|
||||||
|
const preset = v as Partial<CoreValuePreset>;
|
||||||
|
return {
|
||||||
|
label: preset.label ?? label,
|
||||||
|
meaning: preset.meaning ?? "",
|
||||||
|
signals: preset.signals ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -1,18 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import RuleCard from "../RuleCard";
|
import RuleCard from "../RuleCard";
|
||||||
import type { RuleCardProps } from "../RuleCard/RuleCard.types";
|
import type {
|
||||||
|
Category,
|
||||||
|
RuleCardProps,
|
||||||
|
} from "../RuleCard/RuleCard.types";
|
||||||
import { getAssetPath } from "../../../../lib/assetUtils";
|
import { getAssetPath } from "../../../../lib/assetUtils";
|
||||||
import type { RuleTemplateDto } from "../../../../lib/create/fetchTemplates";
|
import type { RuleTemplateDto } from "../../../../lib/create/fetchTemplates";
|
||||||
import {
|
import {
|
||||||
templateBodyToCategories,
|
templateBodyToReviewData,
|
||||||
templateSummaryFromBody,
|
templateSummaryFromBody,
|
||||||
} from "../../../../lib/create/templateReviewMapping";
|
} from "../../../../lib/create/templateReviewMapping";
|
||||||
import {
|
import {
|
||||||
getGovernanceTemplateCatalogEntry,
|
getGovernanceTemplateCatalogEntry,
|
||||||
} from "../../../../lib/templates/governanceTemplateCatalog";
|
} from "../../../../lib/templates/governanceTemplateCatalog";
|
||||||
import { TEMPLATE_GRID_FALLBACK_PRESENTATION } from "../../../../lib/templates/templateGridPresentation";
|
import { TEMPLATE_GRID_FALLBACK_PRESENTATION } from "../../../../lib/templates/templateGridPresentation";
|
||||||
|
import { TemplateChipDetailModal } from "./TemplateChipDetailModal";
|
||||||
|
|
||||||
export interface TemplateReviewCardProps {
|
export interface TemplateReviewCardProps {
|
||||||
template: RuleTemplateDto;
|
template: RuleTemplateDto;
|
||||||
@@ -24,7 +29,9 @@ export interface TemplateReviewCardProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Expanded RuleCard for template review: surfaces + icon from Figma catalog (21764-16435);
|
* Expanded RuleCard for template review: surfaces + icon from Figma catalog (21764-16435);
|
||||||
* tag rows from API `body`.
|
* tag rows from API `body`. Chip clicks open a read-only detail modal per
|
||||||
|
* facet group (values / communication / membership / decision-making / conflict
|
||||||
|
* management) so reviewers can see what each chip means without editing.
|
||||||
*/
|
*/
|
||||||
export function TemplateReviewCard({
|
export function TemplateReviewCard({
|
||||||
template,
|
template,
|
||||||
@@ -33,33 +40,60 @@ export function TemplateReviewCard({
|
|||||||
}: TemplateReviewCardProps) {
|
}: TemplateReviewCardProps) {
|
||||||
const catalog = getGovernanceTemplateCatalogEntry(template.slug);
|
const catalog = getGovernanceTemplateCatalogEntry(template.slug);
|
||||||
const pres = catalog ?? TEMPLATE_GRID_FALLBACK_PRESENTATION;
|
const pres = catalog ?? TEMPLATE_GRID_FALLBACK_PRESENTATION;
|
||||||
const categories = templateBodyToCategories(template.body);
|
const { categories: rawCategories, chipDetailsByChipId } = useMemo(
|
||||||
|
() => templateBodyToReviewData(template.body),
|
||||||
|
[template.body],
|
||||||
|
);
|
||||||
const summary = templateSummaryFromBody(template.description, template.body);
|
const summary = templateSummaryFromBody(template.description, template.body);
|
||||||
|
|
||||||
|
const [activeChipId, setActiveChipId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const categories = useMemo<Category[]>(
|
||||||
|
() =>
|
||||||
|
rawCategories.map((category) => ({
|
||||||
|
...category,
|
||||||
|
onChipClick: (_categoryName, chipId) => {
|
||||||
|
setActiveChipId(chipId);
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
[rawCategories],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeDetail =
|
||||||
|
activeChipId !== null ? chipDetailsByChipId[activeChipId] ?? null : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RuleCard
|
<>
|
||||||
title={template.title}
|
<RuleCard
|
||||||
description={summary}
|
title={template.title}
|
||||||
expanded
|
description={summary}
|
||||||
size={size}
|
expanded
|
||||||
categories={categories}
|
size={size}
|
||||||
backgroundColor={pres.backgroundColor}
|
categories={categories}
|
||||||
className={ruleCardClassName}
|
backgroundColor={pres.backgroundColor}
|
||||||
onClick={() => {}}
|
className={ruleCardClassName}
|
||||||
icon={
|
onClick={() => {}}
|
||||||
<Image
|
hideCategoryAddButton
|
||||||
src={getAssetPath(pres.iconPath)}
|
icon={
|
||||||
alt={template.title}
|
<Image
|
||||||
width={90}
|
src={getAssetPath(pres.iconPath)}
|
||||||
height={90}
|
alt={template.title}
|
||||||
className="
|
width={90}
|
||||||
max-[639px]:w-[40px] max-[639px]:h-[40px]
|
height={90}
|
||||||
min-[640px]:max-[1023px]:w-[56px] min-[640px]:max-[1023px]:h-[56px]
|
className="
|
||||||
min-[1024px]:max-[1439px]:w-[56px] min-[1024px]:max-[1439px]:h-[56px]
|
max-[639px]:w-[40px] max-[639px]:h-[40px]
|
||||||
min-[1440px]:w-[90px] min-[1440px]:h-[90px]
|
min-[640px]:max-[1023px]:w-[56px] min-[640px]:max-[1023px]:h-[56px]
|
||||||
"
|
min-[1024px]:max-[1439px]:w-[56px] min-[1024px]:max-[1439px]:h-[56px]
|
||||||
/>
|
min-[1440px]:w-[90px] min-[1440px]:h-[90px]
|
||||||
}
|
"
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TemplateChipDetailModal
|
||||||
|
isOpen={activeChipId !== null}
|
||||||
|
onClose={() => setActiveChipId(null)}
|
||||||
|
detail={activeDetail}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,10 @@ import { memo } from "react";
|
|||||||
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
|
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
|
||||||
import ContentContainerView from "./ContentContainer.view";
|
import ContentContainerView from "./ContentContainer.view";
|
||||||
import type { ContentContainerProps } from "./ContentContainer.types";
|
import type { ContentContainerProps } from "./ContentContainer.types";
|
||||||
import { normalizeContentContainerSize } from "../../../../lib/propNormalization";
|
|
||||||
|
|
||||||
const ContentContainerContainer = memo<ContentContainerProps>(
|
const ContentContainerContainer = memo<ContentContainerProps>(
|
||||||
({ post, width = "200px", size: sizeProp = "responsive" }) => {
|
({ post, width = "200px", size: sizeProp = "responsive" }) => {
|
||||||
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
|
const size = sizeProp;
|
||||||
const size = normalizeContentContainerSize(sizeProp);
|
|
||||||
// Get the corresponding icon based on the same logic as background images
|
// Get the corresponding icon based on the same logic as background images
|
||||||
const getIconImage = (slug: string): string => {
|
const getIconImage = (slug: string): string => {
|
||||||
const icons = [
|
const icons = [
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
import type { BlogPost } from "../../../../lib/content";
|
import type { BlogPost } from "../../../../lib/content";
|
||||||
|
|
||||||
export type ContentContainerSizeValue =
|
export type ContentContainerSizeValue = "xs" | "responsive";
|
||||||
| "xs"
|
|
||||||
| "responsive"
|
|
||||||
| "Xs"
|
|
||||||
| "Responsive";
|
|
||||||
|
|
||||||
export interface ContentContainerProps {
|
export interface ContentContainerProps {
|
||||||
post: BlogPost;
|
post: BlogPost;
|
||||||
width?: string;
|
width?: string;
|
||||||
/**
|
/**
|
||||||
* Content container size. Accepts both lowercase and PascalCase (case-insensitive).
|
* Content container size.
|
||||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
|
||||||
*/
|
*/
|
||||||
size?: ContentContainerSizeValue;
|
size?: ContentContainerSizeValue;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-3
@@ -4,12 +4,10 @@ import { memo } from "react";
|
|||||||
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
|
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
|
||||||
import ContentThumbnailTemplateView from "./ContentThumbnailTemplate.view";
|
import ContentThumbnailTemplateView from "./ContentThumbnailTemplate.view";
|
||||||
import type { ContentThumbnailTemplateProps } from "./ContentThumbnailTemplate.types";
|
import type { ContentThumbnailTemplateProps } from "./ContentThumbnailTemplate.types";
|
||||||
import { normalizeContentThumbnailVariant } from "../../../../lib/propNormalization";
|
|
||||||
|
|
||||||
const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
|
const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
|
||||||
({ post, className = "", variant: variantProp = "vertical" }) => {
|
({ post, className = "", variant: variantProp = "vertical" }) => {
|
||||||
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
|
const variant = variantProp;
|
||||||
const variant = normalizeContentThumbnailVariant(variantProp);
|
|
||||||
// Get article-specific background image from frontmatter
|
// Get article-specific background image from frontmatter
|
||||||
const getBackgroundImage = (
|
const getBackgroundImage = (
|
||||||
post: ContentThumbnailTemplateProps["post"],
|
post: ContentThumbnailTemplateProps["post"],
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
import type { BlogPost } from "../../../../lib/content";
|
import type { BlogPost } from "../../../../lib/content";
|
||||||
|
|
||||||
export type ContentThumbnailTemplateVariantValue =
|
export type ContentThumbnailTemplateVariantValue = "vertical" | "horizontal";
|
||||||
| "vertical"
|
|
||||||
| "horizontal"
|
|
||||||
| "Vertical"
|
|
||||||
| "Horizontal";
|
|
||||||
|
|
||||||
export interface ContentThumbnailTemplateProps {
|
export interface ContentThumbnailTemplateProps {
|
||||||
post: BlogPost;
|
post: BlogPost;
|
||||||
className?: string;
|
className?: string;
|
||||||
/**
|
/**
|
||||||
* Content thumbnail variant. Accepts both lowercase and PascalCase (case-insensitive).
|
* Content thumbnail variant.
|
||||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
|
||||||
*/
|
*/
|
||||||
variant?: ContentThumbnailTemplateVariantValue;
|
variant?: ContentThumbnailTemplateVariantValue;
|
||||||
slugOrder?: string[];
|
slugOrder?: string[];
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user