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
|
||||
|
||||
## Backend (local)
|
||||
## Local backend
|
||||
|
||||
1. Copy [`.env.example`](.env.example) to `.env` and set `SESSION_SECRET` (at least 16 characters).
|
||||
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`).
|
||||
3. Install dependencies: `npm ci`
|
||||
4. Apply migrations: `npx prisma migrate dev`
|
||||
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.
|
||||
6. Run the app: `npm run dev`
|
||||
1. Copy [`.env.example`](.env.example) to `.env` and set `SESSION_SECRET`
|
||||
(at least 16 characters).
|
||||
2. `docker compose up -d postgres mailhog` — omit `mailhog` if you only
|
||||
need Postgres. Without `SMTP_URL`, the **magic-link verify URL** is
|
||||
printed in the dev server log.
|
||||
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.
|
||||
|
||||
### 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.
|
||||
- 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.
|
||||
- **Never edit** a migration that has already been applied to staging,
|
||||
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 |
|
||||
| ---------- | ------------------------------ | --------------------------------------------- |
|
||||
| GET | `/api/health` | Liveness / DB check |
|
||||
| GET | `/api/auth/session` | Current user or null |
|
||||
| POST | `/api/auth/magic-link/request` | Send sign-in link email |
|
||||
| GET | `/api/auth/magic-link/verify` | Validate token, set session cookie, redirect |
|
||||
| POST | `/api/auth/logout` | Clear session |
|
||||
| GET / PUT | `/api/drafts/me` | Load or save create-flow JSON (authenticated) |
|
||||
| GET / POST | `/api/rules` | List or publish rules (each **Finalize** creates a new published row until an update/edit-published API exists) |
|
||||
| GET | `/api/templates` | List curated templates |
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/health` | Liveness / DB check. |
|
||||
| GET | `/api/auth/session` | Current user or null. |
|
||||
| POST | `/api/auth/magic-link/request` | Send sign-in link email. |
|
||||
| GET | `/api/auth/magic-link/verify` | Validate token, set cookie, redirect. |
|
||||
| POST | `/api/auth/logout` | Clear session. |
|
||||
| GET / PUT | `/api/drafts/me` | Load or save the create-flow draft. |
|
||||
| GET / POST | `/api/rules` | List or publish rules. |
|
||||
| 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).
|
||||
- Enter email and request a link. Complete sign-in by opening the link in the **same browser** you use for the app (session cookie).
|
||||
- **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.
|
||||
- **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)).
|
||||
|
||||
**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.
|
||||
- Visit **[/login](http://localhost:3000/login)** or use **Log in** in the
|
||||
site header.
|
||||
- Without `SMTP_URL`: copy the verify URL from the dev server terminal.
|
||||
- With Mailhog: set `SMTP_URL=smtp://localhost:1025` and open the message
|
||||
at [http://localhost:8025](http://localhost:8025).
|
||||
- Open the link in the **same browser** as the app (session cookie).
|
||||
|
||||
### 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
|
||||
|
||||
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)
|
||||
- **npm**: 10.0.0 or higher
|
||||
- Node.js **20+** (LTS)
|
||||
- npm **10+**
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
Run the development server:
|
||||
## Getting started
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
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.
|
||||
- **E2E tests (Playwright)** cover critical user journeys and visual regression.
|
||||
## Project layout
|
||||
|
||||
### Quick Test Commands
|
||||
|
||||
```bash
|
||||
# All component tests with coverage
|
||||
npm test
|
||||
|
||||
# Component tests only (new structure)
|
||||
npm run test:component
|
||||
|
||||
# E2E tests only
|
||||
npm run test:e2e
|
||||
```text
|
||||
app/ Next.js app router (routes, components, hooks, contexts)
|
||||
lib/ Shared library code (i18n, validation, utilities)
|
||||
messages/en/ Localized UI copy (see docs/guides/i18n-translation-workflow.md)
|
||||
prisma/ Database schema, migrations, seed
|
||||
public/ Static assets
|
||||
stories/ Storybook stories
|
||||
tests/ Vitest + Playwright suites
|
||||
docs/ User-facing documentation (start with docs/README.md)
|
||||
.cursor/rules/ Implementation conventions enforced by Cursor
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
## Tech stack
|
||||
|
||||
- ✅ **428 Unit Tests** (94.88% coverage - exceeds 85% target)
|
||||
- ✅ **92 E2E Tests** across 4 browsers
|
||||
- ✅ **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
|
||||
Next.js 16 · React 19 · TypeScript · Tailwind CSS 4 · Prisma · Vitest ·
|
||||
Playwright · Storybook 10 · Lighthouse CI.
|
||||
|
||||
### CI/CD Pipeline
|
||||
## Documentation
|
||||
|
||||
- **Gitea Actions** with 7 parallel jobs
|
||||
- **Cross-browser testing** (Chromium, Firefox, WebKit, Mobile)
|
||||
- **Visual regression testing**
|
||||
- **Performance monitoring**
|
||||
- **Code coverage reporting**
|
||||
- [docs/README.md](docs/README.md) — index of guides and rules.
|
||||
- [docs/create-flow.md](docs/create-flow.md) — create-rule wizard canon.
|
||||
- [docs/testing-guide.md](docs/testing-guide.md) — testing philosophy.
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — local backend, API routes, PR
|
||||
workflow.
|
||||
|
||||
📖 **For detailed testing documentation, see `docs/TESTING_GUIDE.md` and [docs/README.md](docs/README.md)**
|
||||
## License
|
||||
|
||||
## ⚡ Performance Optimizations
|
||||
|
||||
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.
|
||||
[MIT](LICENSE).
|
||||
|
||||
@@ -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 Footer from "../../components/navigation/Footer";
|
||||
|
||||
|
||||
+251
-251
@@ -11,32 +11,43 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
|
||||
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
|
||||
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 { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress";
|
||||
import { createFlowStepUsesCenteredTextLayout } from "./utils/createFlowScreenRegistry";
|
||||
import CreateFlowFooter from "../components/utility/CreateFlowFooter";
|
||||
import Button from "../components/buttons/Button";
|
||||
import { buildPublishPayload } from "../../lib/create/buildPublishPayload";
|
||||
import { isValidCreateFlowSaveEmail } from "../../lib/create/isValidCreateFlowSaveEmail";
|
||||
import {
|
||||
createFlowStepUsesCenteredTextLayout,
|
||||
createFlowStepUsesCardLayout,
|
||||
} from "./utils/createFlowScreenRegistry";
|
||||
import CreateFlowFooter from "../../components/utility/CreateFlowFooter";
|
||||
import Button from "../../components/buttons/Button";
|
||||
import { isValidCreateFlowSaveEmail } from "../../../lib/create/isValidCreateFlowSaveEmail";
|
||||
import {
|
||||
fetchAuthSession,
|
||||
publishRule,
|
||||
requestMagicLink,
|
||||
} from "../../lib/create/api";
|
||||
import { safeInternalPath } from "../../lib/safeInternalPath";
|
||||
import { setTransferPendingFlag } from "./utils/anonymousDraftStorage";
|
||||
import { writeLastPublishedRule } from "../../lib/create/lastPublishedRule";
|
||||
} from "../../../lib/create/api";
|
||||
import { safeInternalPath } from "../../../lib/safeInternalPath";
|
||||
import {
|
||||
fetchTemplateBySlug,
|
||||
type RuleTemplateDto,
|
||||
} from "../../lib/create/fetchTemplates";
|
||||
import messages from "../../messages/en/index";
|
||||
import { useAuthModal } from "../contexts/AuthModalContext";
|
||||
import { useMessages, useTranslation } from "../contexts/MessagesContext";
|
||||
clearAnonymousCreateFlowStorage,
|
||||
setTransferPendingFlag,
|
||||
} from "./utils/anonymousDraftStorage";
|
||||
import { deleteServerDraft } from "../../../lib/create/api";
|
||||
import messages from "../../../messages/en/index";
|
||||
import {
|
||||
CREATE_FLOW_FOOTER_BUTTON_CLASS,
|
||||
CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS,
|
||||
} from "./utils/createFlowFooterClassNames";
|
||||
import {
|
||||
CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP,
|
||||
type CustomRuleConfirmFooterStep,
|
||||
} from "./utils/customRuleConfirmFooterSteps";
|
||||
import { getDefaultFooterLabel } from "./utils/createFlowFooterLabels";
|
||||
import { useAuthModal } from "../../contexts/AuthModalContext";
|
||||
import { useMessages, useTranslation } from "../../contexts/MessagesContext";
|
||||
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
|
||||
import { SignedInDraftHydration } from "./SignedInDraftHydration";
|
||||
import Alert from "../components/modals/Alert";
|
||||
import Alert from "../../components/modals/Alert";
|
||||
import {
|
||||
CreateFlowDraftSaveBannerProvider,
|
||||
useCreateFlowDraftSaveBanner,
|
||||
@@ -61,17 +72,23 @@ function CreateFlowSessionShell({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
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 (
|
||||
<CreateFlowProvider enableAnonymousPersistence={enableAnonymousPersistence}>
|
||||
<CreateFlowProvider enableLocalDraftMirroring={enableLocalDraftMirroring}>
|
||||
<CreateFlowDraftSaveBannerProvider>
|
||||
<CreateFlowLayoutContent
|
||||
sessionUser={sessionUser}
|
||||
sessionResolved={sessionResolved}
|
||||
>
|
||||
{children}
|
||||
</CreateFlowLayoutContent>
|
||||
<Suspense fallback={null}>
|
||||
<CreateFlowLayoutContent
|
||||
sessionUser={sessionUser}
|
||||
sessionResolved={sessionResolved}
|
||||
>
|
||||
{children}
|
||||
</CreateFlowLayoutContent>
|
||||
</Suspense>
|
||||
</CreateFlowDraftSaveBannerProvider>
|
||||
</CreateFlowProvider>
|
||||
);
|
||||
@@ -88,29 +105,26 @@ function CreateFlowLayoutContent({
|
||||
}) {
|
||||
const { create } = useMessages();
|
||||
const footer = create.footer;
|
||||
const communitySaveMessages = create.communitySave;
|
||||
const communitySaveMessages = create.community.communitySave;
|
||||
const tLogin = useTranslation("pages.login");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { openLogin } = useAuthModal();
|
||||
const skipCommunitySave = sessionResolved && Boolean(sessionUser);
|
||||
const {
|
||||
currentStep,
|
||||
nextStep,
|
||||
previousStep,
|
||||
goToNextStep,
|
||||
goToPreviousStep,
|
||||
} = useCreateFlowNavigation();
|
||||
const { state, clearState, updateState } = useCreateFlow();
|
||||
templateReviewFooterBackToCreateReview,
|
||||
} = useCreateFlowNavigation(
|
||||
skipCommunitySave ? { skipCommunitySave: true } : undefined,
|
||||
);
|
||||
const { state, clearState, updateState, resetCustomRuleSelections } =
|
||||
useCreateFlow();
|
||||
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
|
||||
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] =
|
||||
useState(false);
|
||||
const [communitySaveMagicLinkError, setCommunitySaveMagicLinkError] = useState<
|
||||
@@ -119,93 +133,28 @@ function CreateFlowLayoutContent({
|
||||
const [communitySaveMagicLinkSuccess, setCommunitySaveMagicLinkSuccess] =
|
||||
useState(false);
|
||||
|
||||
const templateReviewMatch = pathname?.match(
|
||||
/\/create\/review-template\/([^/?#]+)/,
|
||||
);
|
||||
const templateReviewSlug = templateReviewMatch?.[1]
|
||||
? decodeURIComponent(templateReviewMatch[1])
|
||||
: null;
|
||||
/** Match anywhere in path so locale/basePath variants still get template footer + layout. */
|
||||
const isTemplateReviewRoute = Boolean(
|
||||
pathname?.includes("/create/review-template/"),
|
||||
);
|
||||
const {
|
||||
publishBannerMessage,
|
||||
setPublishBannerMessage,
|
||||
isPublishing,
|
||||
finalize: handleFinalize,
|
||||
} = useCreateFlowFinalize({ state, router, openLogin });
|
||||
|
||||
const handleFinalize = useCallback(async () => {
|
||||
setPublishBannerMessage(null);
|
||||
const payloadResult = buildPublishPayload(state);
|
||||
if (payloadResult.ok === false) {
|
||||
setPublishBannerMessage(
|
||||
payloadResult.error === "missingCommunityName"
|
||||
? messages.create.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.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 {
|
||||
isTemplateReviewRoute,
|
||||
templateReviewSlug,
|
||||
isApplyingTemplate,
|
||||
templateReviewApplyError,
|
||||
setTemplateReviewApplyError,
|
||||
handleCustomize: handleCustomizeTemplate,
|
||||
handleUseWithoutChanges: handleUseTemplateWithoutChanges,
|
||||
} = useTemplateReviewActions({
|
||||
pathname,
|
||||
state,
|
||||
updateState,
|
||||
resetCustomRuleSelections,
|
||||
router,
|
||||
});
|
||||
|
||||
const runAuthenticatedExit = useCreateFlowExit({
|
||||
state,
|
||||
@@ -220,6 +169,20 @@ function CreateFlowLayoutContent({
|
||||
const saveDraft = opts?.saveDraft ?? false;
|
||||
if (!sessionResolved) return;
|
||||
|
||||
// Exit from `/create/completed` is post-publish: the rule is saved, so we
|
||||
// skip the leave-confirm + login prompt and just wipe the in-flight draft.
|
||||
// For signed-in users we also DELETE the server draft so a future visit to
|
||||
// /create starts fresh instead of rehydrating yesterday's work.
|
||||
if (currentStep === "completed") {
|
||||
clearState();
|
||||
clearAnonymousCreateFlowStorage();
|
||||
if (sessionUser) {
|
||||
void deleteServerDraft();
|
||||
}
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionUser === null) {
|
||||
if (saveDraft) return;
|
||||
const returnToTemplateReview =
|
||||
@@ -240,6 +203,16 @@ function CreateFlowLayoutContent({
|
||||
await runAuthenticatedExit(opts);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
sessionResolved &&
|
||||
sessionUser &&
|
||||
currentStep === "community-save"
|
||||
) {
|
||||
router.replace("/create/review");
|
||||
}
|
||||
}, [sessionResolved, sessionUser, currentStep, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep !== "community-save") {
|
||||
setCommunitySaveMagicLinkError(null);
|
||||
@@ -286,17 +259,23 @@ function CreateFlowLayoutContent({
|
||||
}, [state.communitySaveEmail, tLogin, updateState]);
|
||||
|
||||
const isCompletedStep = currentStep === "completed";
|
||||
const isRightRailStep = currentStep === "right-rail";
|
||||
const isRightRailStep = currentStep === "decision-approaches";
|
||||
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;
|
||||
|
||||
/** At `md+`, main cross-axis: center by default; exceptions stay top-aligned (see product spec). */
|
||||
const mainContentClass = isCompletedStep
|
||||
? "items-stretch overflow-y-auto md:overflow-hidden"
|
||||
: isRightRailStep
|
||||
? "items-stretch overflow-hidden"
|
||||
: isFinalReviewStep || isCardsStep || isTemplateReviewRoute
|
||||
: isSelectSplitScrollStep
|
||||
? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden"
|
||||
: isFinalReviewStep || isCardLayoutStep || isTemplateReviewRoute
|
||||
? "items-start justify-center overflow-y-auto"
|
||||
: "items-start justify-center overflow-y-auto md:items-center";
|
||||
|
||||
@@ -305,10 +284,9 @@ function CreateFlowLayoutContent({
|
||||
isTextStep && !isCompletedStep && !isRightRailStep
|
||||
? "max-md:justify-center"
|
||||
: "max-md:justify-start";
|
||||
const mainMaxMdCross =
|
||||
isCompletedStep || isRightRailStep
|
||||
? "max-md:flex-col max-md:items-stretch"
|
||||
: "max-md:flex-col max-md:items-center";
|
||||
const mainMaxMdCross = isCompletedStep
|
||||
? "max-md:flex-col max-md:items-stretch"
|
||||
: "max-md:flex-col max-md:items-center";
|
||||
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
|
||||
const saveDraftOnExit =
|
||||
Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX;
|
||||
@@ -317,83 +295,101 @@ function CreateFlowLayoutContent({
|
||||
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) ||
|
||||
Boolean(publishBannerMessage) ||
|
||||
Boolean(templateReviewApplyError) ||
|
||||
Boolean(communitySaveMagicLinkError) ||
|
||||
Boolean(communitySaveMagicLinkSuccess);
|
||||
/**
|
||||
* Top banner stack rendered above the main column when any of the
|
||||
* shell-level statuses are active. Each entry maps to one `<Alert>`;
|
||||
* we filter out empty messages so the wrapper only mounts when at
|
||||
* least one banner is actually showing. Order here is the visual
|
||||
* 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 (
|
||||
<div className="relative flex h-screen min-h-0 flex-col overflow-hidden bg-black">
|
||||
{hasTopOverlays ? (
|
||||
{topBanners.length > 0 ? (
|
||||
<div
|
||||
className="pointer-events-none fixed left-0 right-0 top-0 z-[200] flex flex-col gap-2 px-[var(--spacing-measures-spacing-500,20px)] pt-[var(--spacing-measures-spacing-300,12px)] md:px-[var(--measures-spacing-1800,64px)]"
|
||||
aria-live="polite"
|
||||
>
|
||||
{draftSaveBannerMessage ? (
|
||||
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
|
||||
{topBanners.map((b) => (
|
||||
<div
|
||||
key={b.key}
|
||||
className="pointer-events-auto mx-auto w-full max-w-[960px]"
|
||||
>
|
||||
<Alert
|
||||
type="banner"
|
||||
status="danger"
|
||||
title={messages.create.topNav.draftSaveBannerTitle}
|
||||
description={draftSaveBannerMessage}
|
||||
onClose={() => setDraftSaveBannerMessage(null)}
|
||||
status={b.status}
|
||||
title={b.title}
|
||||
description={b.description}
|
||||
onClose={b.onClose}
|
||||
className="w-full"
|
||||
/>
|
||||
</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>
|
||||
) : null}
|
||||
<Suspense fallback={null}>
|
||||
@@ -440,7 +436,7 @@ function CreateFlowLayoutContent({
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
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()}
|
||||
>
|
||||
{messages.create.templateReview.footer.useWithoutChanges}
|
||||
@@ -450,48 +446,29 @@ function CreateFlowLayoutContent({
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={isApplyingTemplate}
|
||||
title={
|
||||
messages.create.templateReview.footer.customizeAriaHint
|
||||
}
|
||||
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)}`,
|
||||
);
|
||||
}}
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||
onClick={() => void handleCustomizeTemplate()}
|
||||
>
|
||||
{messages.create.templateReview.footer.customize}
|
||||
</Button>
|
||||
</div>
|
||||
) : currentStep === "community-name" && nextStep ? (
|
||||
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
|
||||
<Button
|
||||
buttonType="outline"
|
||||
palette="inverse"
|
||||
size="xsmall"
|
||||
disabled={isPublishing}
|
||||
className={footerPrimaryButtonClass}
|
||||
onClick={() => {
|
||||
goToNextStep();
|
||||
}}
|
||||
>
|
||||
{footer.next}
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={isPublishing}
|
||||
className={footerPrimaryButtonClass}
|
||||
onClick={() => {
|
||||
goToNextStep();
|
||||
}}
|
||||
>
|
||||
{footer.confirmName}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={
|
||||
isPublishing ||
|
||||
typeof state.title !== "string" ||
|
||||
state.title.trim().length === 0
|
||||
}
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||
onClick={() => {
|
||||
goToNextStep();
|
||||
}}
|
||||
>
|
||||
{footer.confirmName}
|
||||
</Button>
|
||||
) : currentStep === "community-save" && nextStep ? (
|
||||
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
|
||||
<Button
|
||||
@@ -499,7 +476,7 @@ function CreateFlowLayoutContent({
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={isPublishing}
|
||||
className={footerPrimaryButtonClass}
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||
onClick={() => {
|
||||
goToNextStep();
|
||||
}}
|
||||
@@ -516,7 +493,7 @@ function CreateFlowLayoutContent({
|
||||
communitySaveMagicLinkSuccess ||
|
||||
!isValidCreateFlowSaveEmail(state.communitySaveEmail)
|
||||
}
|
||||
className={footerPrimaryButtonClass}
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||
onClick={() => {
|
||||
void handleCommunitySaveMagicLinkSubmit();
|
||||
}}
|
||||
@@ -533,8 +510,11 @@ function CreateFlowLayoutContent({
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={isPublishing}
|
||||
className={footerPrimaryButtonClass}
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||
onClick={() => {
|
||||
// Scrub any prior template-customize prefill so entering
|
||||
// the custom-rule stage from review is always a clean slate.
|
||||
resetCustomRuleSelections();
|
||||
goToNextStep();
|
||||
}}
|
||||
>
|
||||
@@ -545,21 +525,43 @@ function CreateFlowLayoutContent({
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={isPublishing}
|
||||
className={footerPrimaryButtonClass}
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||
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}
|
||||
</Button>
|
||||
</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 ? (
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
disabled={isPublishing}
|
||||
className={footerPrimaryButtonClass}
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||
onClick={() => {
|
||||
if (currentStep === "final-review") {
|
||||
void handleFinalize();
|
||||
@@ -570,23 +572,21 @@ function CreateFlowLayoutContent({
|
||||
>
|
||||
{currentStep === "final-review"
|
||||
? isPublishing
|
||||
? messages.create.publish.finalizeButtonPublishing
|
||||
? messages.create.reviewAndComplete.publish
|
||||
.finalizeButtonPublishing
|
||||
: footer.finalizeCommunityRule
|
||||
: currentStep === "confirm-stakeholders"
|
||||
? footer.confirmStakeholders
|
||||
: currentStep === "community-context"
|
||||
? footer.confirmDescription
|
||||
: currentStep === "community-structure"
|
||||
? footer.confirmDetails
|
||||
: currentStep === "community-size"
|
||||
? footer.confirmMembers
|
||||
: footer.next}
|
||||
: getDefaultFooterLabel(currentStep, footer)}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
onBackClick={
|
||||
isTemplateReviewRoute
|
||||
? () => router.push("/")
|
||||
? () =>
|
||||
router.push(
|
||||
templateReviewFooterBackToCreateReview
|
||||
? "/create/review"
|
||||
: "/",
|
||||
)
|
||||
: previousStep
|
||||
? goToPreviousStep
|
||||
: undefined
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
} from "./utils/anonymousDraftStorage";
|
||||
import { useCreateFlow } from "./context/CreateFlowContext";
|
||||
import { parseCreateFlowScreenFromPathname } from "./utils/flowSteps";
|
||||
import { saveDraftToServer } from "../../lib/create/api";
|
||||
import messages from "../../messages/en/index";
|
||||
import { saveDraftToServer } from "../../../lib/create/api";
|
||||
import messages from "../../../messages/en/index";
|
||||
|
||||
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
||||
|
||||
+23
-38
@@ -3,24 +3,30 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import type { CreateFlowState } from "./types";
|
||||
import { createFlowStateHasKeys } from "../../lib/create/draftHydrationUtils";
|
||||
import { createFlowStateHasKeys } from "../../../lib/create/draftHydrationUtils";
|
||||
import {
|
||||
clearAnonymousCreateFlowStorage,
|
||||
hasTransferPendingFlag,
|
||||
readAnonymousCreateFlowState,
|
||||
} from "./utils/anonymousDraftStorage";
|
||||
import { useCreateFlow } from "./context/CreateFlowContext";
|
||||
import { fetchDraftFromServer } from "../../lib/create/api";
|
||||
import messages from "../../messages/en/index";
|
||||
import { fetchDraftFromServer } from "../../../lib/create/api";
|
||||
import messages from "../../../messages/en/index";
|
||||
|
||||
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.
|
||||
* Skips when `?syncDraft=1` or transfer-pending — {@link PostLoginDraftTransfer} owns that path.
|
||||
* When sync is on and the user is signed in, restore the server-side draft only
|
||||
* when there is no in-flight localStorage draft to defer to. localStorage is
|
||||
* the on-every-keystroke buffer (CreateFlowProvider mirrors state there for
|
||||
* everyone), so a refresh mid-flow already has the freshest data; pulling the
|
||||
* server draft on top would clobber unsaved keystrokes with a stale snapshot.
|
||||
*
|
||||
* **Conflict:** If both server draft and `create-flow-anonymous` are non-empty, `window.confirm`
|
||||
* chooses account draft (OK) vs browser copy (Cancel); browser storage is cleared after resolution.
|
||||
* Server draft becomes authoritative only when localStorage is empty — i.e.
|
||||
* 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({
|
||||
sessionUser,
|
||||
@@ -54,6 +60,14 @@ export function SignedInDraftHydration({
|
||||
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;
|
||||
setLoadingHydration(true);
|
||||
|
||||
@@ -62,43 +76,14 @@ export function SignedInDraftHydration({
|
||||
const serverDraft = await fetchDraftFromServer();
|
||||
if (cancelled) return;
|
||||
|
||||
const localDraft = readAnonymousCreateFlowState();
|
||||
const hasServer =
|
||||
serverDraft != null && createFlowStateHasKeys(serverDraft);
|
||||
const hasLocal = createFlowStateHasKeys(localDraft);
|
||||
|
||||
if (touchedRef.current) {
|
||||
finishedUserIdRef.current = userId;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasServer && hasLocal) {
|
||||
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) {
|
||||
if (serverDraft != null && createFlowStateHasKeys(serverDraft)) {
|
||||
replaceState(serverDraft as CreateFlowState);
|
||||
clearAnonymousCreateFlowStorage();
|
||||
finishedUserIdRef.current = userId;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasLocal) {
|
||||
replaceState(localDraft);
|
||||
clearAnonymousCreateFlowStorage();
|
||||
}
|
||||
|
||||
finishedUserIdRef.current = userId;
|
||||
} finally {
|
||||
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";
|
||||
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import type { HeaderLockupProps } from "../../components/type/HeaderLockup/HeaderLockup.types";
|
||||
import HeaderLockup from "../../../components/type/HeaderLockup";
|
||||
import type { HeaderLockupProps } from "../../../components/type/HeaderLockup/HeaderLockup.types";
|
||||
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
|
||||
|
||||
export type CreateFlowHeaderLockupProps = Omit<HeaderLockupProps, "size"> & {
|
||||
@@ -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. */
|
||||
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 type { CreateFlowState, CreateFlowStep } from "../types";
|
||||
import { saveDraftToServer } from "../../../lib/create/api";
|
||||
import messages from "../../../messages/en/index";
|
||||
import { saveDraftToServer } from "../../../../lib/create/api";
|
||||
import messages from "../../../../messages/en/index";
|
||||
|
||||
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";
|
||||
|
||||
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`. */
|
||||
const CREATE_FLOW_MIN_WIDTH_LG = "(min-width: 1024px)";
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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`. */
|
||||
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";
|
||||
|
||||
import { use, useEffect, useState } from "react";
|
||||
import { TemplateReviewCard } from "../../../components/cards/TemplateReviewCard";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import { TemplateReviewCard } from "../../../../components/cards/TemplateReviewCard";
|
||||
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||
import {
|
||||
fetchTemplateBySlug,
|
||||
isTemplatesFetchAborted,
|
||||
type RuleTemplateDto,
|
||||
} from "../../../../lib/create/fetchTemplates";
|
||||
import messages from "../../../../messages/en/index";
|
||||
import Alert from "../../../components/modals/Alert";
|
||||
} from "../../../../../lib/create/fetchTemplates";
|
||||
import messages from "../../../../../messages/en/index";
|
||||
import Alert from "../../../../components/modals/Alert";
|
||||
import {
|
||||
CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS,
|
||||
CreateFlowLockupCardStepShell,
|
||||
+23
-10
@@ -6,16 +6,23 @@ import { InformationalScreen } from "./informational/InformationalScreen";
|
||||
import { CreateFlowTextFieldScreen } from "./text/CreateFlowTextFieldScreen";
|
||||
import { CommunitySizeSelectScreen } from "./select/CommunitySizeSelectScreen";
|
||||
import { CommunityStructureSelectScreen } from "./select/CommunityStructureSelectScreen";
|
||||
import { CoreValuesSelectScreen } from "./select/CoreValuesSelectScreen";
|
||||
import { ConfirmStakeholdersScreen } from "./select/ConfirmStakeholdersScreen";
|
||||
import { CommunityUploadScreen } from "./upload/CommunityUploadScreen";
|
||||
import { CommunityReviewScreen } from "./review/CommunityReviewScreen";
|
||||
import { FinalReviewScreen } from "./review/FinalReviewScreen";
|
||||
import { CardsScreen } from "./card/CardsScreen";
|
||||
import { RightRailScreen } from "./right-rail/RightRailScreen";
|
||||
import { CommunicationMethodsScreen } from "./card/CommunicationMethodsScreen";
|
||||
import { MembershipMethodsScreen } from "./card/MembershipMethodsScreen";
|
||||
import { ConflictManagementScreen } from "./card/ConflictManagementScreen";
|
||||
import { DecisionApproachesScreen } from "./right-rail/DecisionApproachesScreen";
|
||||
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({
|
||||
screenId,
|
||||
@@ -28,7 +35,7 @@ export function CreateFlowScreenView({
|
||||
case "community-name":
|
||||
return (
|
||||
<CreateFlowTextFieldScreen
|
||||
messageNamespace="create.communityName"
|
||||
messageNamespace="create.community.communityName"
|
||||
stateField="title"
|
||||
maxLength={48}
|
||||
/>
|
||||
@@ -38,7 +45,7 @@ export function CreateFlowScreenView({
|
||||
case "community-context":
|
||||
return (
|
||||
<CreateFlowTextFieldScreen
|
||||
messageNamespace="create.communityContext"
|
||||
messageNamespace="create.community.communityContext"
|
||||
stateField="communityContext"
|
||||
maxLength={48}
|
||||
mainAlign="center"
|
||||
@@ -51,7 +58,7 @@ export function CreateFlowScreenView({
|
||||
case "community-save":
|
||||
return (
|
||||
<CreateFlowTextFieldScreen
|
||||
messageNamespace="create.communitySave"
|
||||
messageNamespace="create.community.communitySave"
|
||||
stateField="communitySaveEmail"
|
||||
maxLength={254}
|
||||
mainAlign="center"
|
||||
@@ -62,10 +69,16 @@ export function CreateFlowScreenView({
|
||||
);
|
||||
case "review":
|
||||
return <CommunityReviewScreen />;
|
||||
case "cards":
|
||||
return <CardsScreen />;
|
||||
case "right-rail":
|
||||
return <RightRailScreen />;
|
||||
case "core-values":
|
||||
return <CoreValuesSelectScreen />;
|
||||
case "communication-methods":
|
||||
return <CommunicationMethodsScreen />;
|
||||
case "membership-methods":
|
||||
return <MembershipMethodsScreen />;
|
||||
case "decision-approaches":
|
||||
return <DecisionApproachesScreen />;
|
||||
case "conflict-management":
|
||||
return <ConflictManagementScreen />;
|
||||
case "confirm-stakeholders":
|
||||
return <ConfirmStakeholdersScreen />;
|
||||
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";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import CommunityRuleDocument from "../../../components/sections/CommunityRuleDocument";
|
||||
import type { CommunityRuleDocumentSection } from "../../../components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
|
||||
import Alert from "../../../components/modals/Alert";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import { parseDocumentSectionsForDisplay } from "../../../../lib/create/buildPublishPayload";
|
||||
import { readLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
|
||||
import CommunityRuleDocument from "../../../../components/sections/CommunityRuleDocument";
|
||||
import type { CommunityRuleDocumentSection } from "../../../../components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
|
||||
import Alert from "../../../../components/modals/Alert";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { parseDocumentSectionsForDisplay } from "../../../../../lib/create/buildPublishPayload";
|
||||
import { readLastPublishedRule } from "../../../../../lib/create/lastPublishedRule";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import {
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
export function CompletedScreen() {
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const m = useMessages();
|
||||
const completed = m.create.completed;
|
||||
const completed = m.create.reviewAndComplete.completed;
|
||||
|
||||
const fallbackSections = useMemo(
|
||||
() =>
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import NumberedList from "../../../components/type/NumberedList";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import NumberedList from "../../../../components/type/NumberedList";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||
@@ -14,7 +14,7 @@ import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowL
|
||||
*/
|
||||
export function InformationalScreen() {
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const copy = useMessages().create.informational;
|
||||
const copy = useMessages().create.community.informational;
|
||||
|
||||
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";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import MultiSelect from "../../../components/controls/MultiSelect";
|
||||
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import MultiSelect from "../../../../components/controls/MultiSelect";
|
||||
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
||||
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||
|
||||
function chipRowsFromLabels(
|
||||
rows: readonly { label: string }[],
|
||||
@@ -15,20 +14,20 @@ function chipRowsFromLabels(
|
||||
return rows.map((row, i) => ({
|
||||
id: String(i + 1),
|
||||
label: row.label,
|
||||
state: "Unselected" as const,
|
||||
state: "unselected" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
function selectedIdsFromOptions(options: ChipOption[]): string[] {
|
||||
return options
|
||||
.filter((o) => o.state === "Selected")
|
||||
.filter((o) => o.state === "selected")
|
||||
.map((o) => o.id);
|
||||
}
|
||||
|
||||
/** Create Community — Figma `20094:41317`, chips only (layout tokens shared with structure select). */
|
||||
export function CommunitySizeSelectScreen() {
|
||||
const m = useMessages();
|
||||
const cs = m.create.communitySize;
|
||||
const cs = m.create.community.communitySize;
|
||||
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
|
||||
|
||||
const [communitySizeOptions, setCommunitySizeOptions] = useState<
|
||||
@@ -38,7 +37,7 @@ export function CommunitySizeSelectScreen() {
|
||||
const selected = new Set(state.selectedCommunitySizeIds ?? []);
|
||||
return base.map((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 ?? []);
|
||||
setCommunitySizeOptions((prev) =>
|
||||
prev.map((opt) =>
|
||||
opt.state === "Custom"
|
||||
opt.state === "custom"
|
||||
? opt
|
||||
: {
|
||||
...opt,
|
||||
state: selected.has(opt.id)
|
||||
? ("Selected" as const)
|
||||
: ("Unselected" as const),
|
||||
? ("selected" as const)
|
||||
: ("unselected" as const),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -72,9 +71,9 @@ export function CommunitySizeSelectScreen() {
|
||||
? {
|
||||
...opt,
|
||||
state:
|
||||
opt.state === "Selected"
|
||||
? ("Unselected" as const)
|
||||
: ("Selected" as const),
|
||||
opt.state === "selected"
|
||||
? ("unselected" as const)
|
||||
: ("selected" as const),
|
||||
}
|
||||
: opt,
|
||||
);
|
||||
@@ -84,7 +83,7 @@ export function CommunitySizeSelectScreen() {
|
||||
const multiSelectBlock = (
|
||||
<MultiSelect
|
||||
formHeader={false}
|
||||
size="M"
|
||||
size="m"
|
||||
options={communitySizeOptions}
|
||||
onChipClick={handleCommunitySizeClick}
|
||||
addButton={false}
|
||||
@@ -92,26 +91,16 @@ export function CommunitySizeSelectScreen() {
|
||||
);
|
||||
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
variant="centeredNarrow"
|
||||
contentTopBelowMd="space-1400"
|
||||
<CreateFlowTwoColumnSelectShell
|
||||
header={
|
||||
<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)]">
|
||||
<div
|
||||
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>
|
||||
{multiSelectBlock}
|
||||
</CreateFlowTwoColumnSelectShell>
|
||||
);
|
||||
}
|
||||
+137
-66
@@ -7,17 +7,17 @@ import {
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import MultiSelect from "../../../components/controls/MultiSelect";
|
||||
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import MultiSelect from "../../../../components/controls/MultiSelect";
|
||||
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import type { CommunityStructureChipSnapshotRow } from "../../types";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
||||
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||
|
||||
function createListCustomHandlers(
|
||||
setList: Dispatch<SetStateAction<ChipOption[]>>,
|
||||
confirmState: "Unselected" | "Selected",
|
||||
confirmState: "unselected" | "selected",
|
||||
onInteraction?: () => void,
|
||||
) {
|
||||
const touch = () => onInteraction?.();
|
||||
@@ -26,7 +26,7 @@ function createListCustomHandlers(
|
||||
touch();
|
||||
setList((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), label: "", state: "Custom" },
|
||||
{ id: crypto.randomUUID(), label: "", state: "custom" },
|
||||
]);
|
||||
},
|
||||
onCustomChipConfirm: (chipId: string, value: string) => {
|
||||
@@ -52,7 +52,7 @@ function chipRowsFromLabels(
|
||||
return rows.map((row, i) => ({
|
||||
id: String(i + 1),
|
||||
label: row.label,
|
||||
state: "Unselected" as const,
|
||||
state: "unselected" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -62,73 +62,139 @@ function applySavedSelection(
|
||||
): ChipOption[] {
|
||||
const selected = new Set(saved ?? []);
|
||||
return options.map((opt) =>
|
||||
opt.state === "Custom"
|
||||
opt.state === "custom"
|
||||
? opt
|
||||
: {
|
||||
...opt,
|
||||
state: selected.has(opt.id)
|
||||
? ("Selected" as const)
|
||||
: ("Unselected" as const),
|
||||
? ("selected" as const)
|
||||
: ("unselected" as const),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function selectedIdsFromOptions(options: ChipOption[]): string[] {
|
||||
return options
|
||||
.filter((o) => o.state === "Selected")
|
||||
.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 } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 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`). */
|
||||
export function CommunityStructureSelectScreen() {
|
||||
const m = useMessages();
|
||||
const cs = m.create.communityStructure;
|
||||
const cs = m.create.community.communityStructure;
|
||||
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
|
||||
|
||||
const [organizationTypeOptions, setOrganizationTypeOptions] = useState<
|
||||
ChipOption[]
|
||||
>(() =>
|
||||
applySavedSelection(
|
||||
>(() => {
|
||||
const fromSnap = snapshotRowsToChipOptions(
|
||||
state.communityStructureChipSnapshots?.organizationTypes,
|
||||
);
|
||||
if (fromSnap) return fromSnap;
|
||||
return applySavedSelection(
|
||||
chipRowsFromLabels(cs.organizationTypes),
|
||||
state.selectedOrganizationTypeIds,
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
|
||||
const [scaleOptions, setScaleOptions] = useState<ChipOption[]>(() =>
|
||||
applySavedSelection(
|
||||
const [scaleOptions, setScaleOptions] = useState<ChipOption[]>(() => {
|
||||
const fromSnap = snapshotRowsToChipOptions(
|
||||
state.communityStructureChipSnapshots?.scale,
|
||||
);
|
||||
if (fromSnap) return fromSnap;
|
||||
return applySavedSelection(
|
||||
chipRowsFromLabels(cs.scaleOptions),
|
||||
state.selectedScaleIds,
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
|
||||
const [maturityOptions, setMaturityOptions] = useState<ChipOption[]>(() =>
|
||||
applySavedSelection(
|
||||
const [maturityOptions, setMaturityOptions] = useState<ChipOption[]>(() => {
|
||||
const fromSnap = snapshotRowsToChipOptions(
|
||||
state.communityStructureChipSnapshots?.maturity,
|
||||
);
|
||||
if (fromSnap) return fromSnap;
|
||||
return applySavedSelection(
|
||||
chipRowsFromLabels(cs.maturityOptions),
|
||||
state.selectedMaturityIds,
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fromSnap = snapshotRowsToChipOptions(
|
||||
state.communityStructureChipSnapshots?.organizationTypes,
|
||||
);
|
||||
if (fromSnap) {
|
||||
setOrganizationTypeOptions(fromSnap);
|
||||
return;
|
||||
}
|
||||
setOrganizationTypeOptions((prev) =>
|
||||
applySavedSelection(prev, state.selectedOrganizationTypeIds),
|
||||
);
|
||||
}, [state.selectedOrganizationTypeIds]);
|
||||
}, [
|
||||
state.communityStructureChipSnapshots?.organizationTypes,
|
||||
state.selectedOrganizationTypeIds,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const fromSnap = snapshotRowsToChipOptions(
|
||||
state.communityStructureChipSnapshots?.scale,
|
||||
);
|
||||
if (fromSnap) {
|
||||
setScaleOptions(fromSnap);
|
||||
return;
|
||||
}
|
||||
setScaleOptions((prev) => applySavedSelection(prev, state.selectedScaleIds));
|
||||
}, [state.selectedScaleIds]);
|
||||
}, [
|
||||
state.communityStructureChipSnapshots?.scale,
|
||||
state.selectedScaleIds,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const fromSnap = snapshotRowsToChipOptions(
|
||||
state.communityStructureChipSnapshots?.maturity,
|
||||
);
|
||||
if (fromSnap) {
|
||||
setMaturityOptions(fromSnap);
|
||||
return;
|
||||
}
|
||||
setMaturityOptions((prev) =>
|
||||
applySavedSelection(prev, state.selectedMaturityIds),
|
||||
);
|
||||
}, [state.selectedMaturityIds]);
|
||||
}, [
|
||||
state.communityStructureChipSnapshots?.maturity,
|
||||
state.selectedMaturityIds,
|
||||
]);
|
||||
|
||||
const organizationCustomHandlers = useMemo(
|
||||
() =>
|
||||
createListCustomHandlers(
|
||||
setOrganizationTypeOptions,
|
||||
"Unselected",
|
||||
"unselected",
|
||||
markCreateFlowInteraction,
|
||||
),
|
||||
[markCreateFlowInteraction],
|
||||
@@ -137,7 +203,7 @@ export function CommunityStructureSelectScreen() {
|
||||
() =>
|
||||
createListCustomHandlers(
|
||||
setScaleOptions,
|
||||
"Unselected",
|
||||
"unselected",
|
||||
markCreateFlowInteraction,
|
||||
),
|
||||
[markCreateFlowInteraction],
|
||||
@@ -146,7 +212,7 @@ export function CommunityStructureSelectScreen() {
|
||||
() =>
|
||||
createListCustomHandlers(
|
||||
setMaturityOptions,
|
||||
"Unselected",
|
||||
"unselected",
|
||||
markCreateFlowInteraction,
|
||||
),
|
||||
[markCreateFlowInteraction],
|
||||
@@ -155,19 +221,34 @@ export function CommunityStructureSelectScreen() {
|
||||
const persistOrg = (next: ChipOption[]) => {
|
||||
markCreateFlowInteraction();
|
||||
setOrganizationTypeOptions(next);
|
||||
updateState({ selectedOrganizationTypeIds: selectedIdsFromOptions(next) });
|
||||
updateState({
|
||||
selectedOrganizationTypeIds: selectedIdsFromOptions(next),
|
||||
communityStructureChipSnapshots: {
|
||||
organizationTypes: chipOptionsToSnapshotRows(next),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const persistScale = (next: ChipOption[]) => {
|
||||
markCreateFlowInteraction();
|
||||
setScaleOptions(next);
|
||||
updateState({ selectedScaleIds: selectedIdsFromOptions(next) });
|
||||
updateState({
|
||||
selectedScaleIds: selectedIdsFromOptions(next),
|
||||
communityStructureChipSnapshots: {
|
||||
scale: chipOptionsToSnapshotRows(next),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const persistMaturity = (next: ChipOption[]) => {
|
||||
markCreateFlowInteraction();
|
||||
setMaturityOptions(next);
|
||||
updateState({ selectedMaturityIds: selectedIdsFromOptions(next) });
|
||||
updateState({
|
||||
selectedMaturityIds: selectedIdsFromOptions(next),
|
||||
communityStructureChipSnapshots: {
|
||||
maturity: chipOptionsToSnapshotRows(next),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleOrganizationTypeClick = (chipId: string) => {
|
||||
@@ -177,9 +258,9 @@ export function CommunityStructureSelectScreen() {
|
||||
? {
|
||||
...opt,
|
||||
state:
|
||||
opt.state === "Selected"
|
||||
? ("Unselected" as const)
|
||||
: ("Selected" as const),
|
||||
opt.state === "selected"
|
||||
? ("unselected" as const)
|
||||
: ("selected" as const),
|
||||
}
|
||||
: opt,
|
||||
),
|
||||
@@ -193,9 +274,9 @@ export function CommunityStructureSelectScreen() {
|
||||
? {
|
||||
...opt,
|
||||
state:
|
||||
opt.state === "Selected"
|
||||
? ("Unselected" as const)
|
||||
: ("Selected" as const),
|
||||
opt.state === "selected"
|
||||
? ("unselected" as const)
|
||||
: ("selected" as const),
|
||||
}
|
||||
: opt,
|
||||
),
|
||||
@@ -209,9 +290,9 @@ export function CommunityStructureSelectScreen() {
|
||||
? {
|
||||
...opt,
|
||||
state:
|
||||
opt.state === "Selected"
|
||||
? ("Unselected" as const)
|
||||
: ("Selected" as const),
|
||||
opt.state === "selected"
|
||||
? ("unselected" as const)
|
||||
: ("selected" as const),
|
||||
}
|
||||
: opt,
|
||||
),
|
||||
@@ -223,7 +304,7 @@ export function CommunityStructureSelectScreen() {
|
||||
<MultiSelect
|
||||
label={cs.organizationMultiSelect.label}
|
||||
showHelpIcon
|
||||
size="S"
|
||||
size="s"
|
||||
options={organizationTypeOptions}
|
||||
onChipClick={handleOrganizationTypeClick}
|
||||
{...organizationCustomHandlers}
|
||||
@@ -233,7 +314,7 @@ export function CommunityStructureSelectScreen() {
|
||||
<MultiSelect
|
||||
label={cs.scaleMultiSelect.label}
|
||||
showHelpIcon
|
||||
size="S"
|
||||
size="s"
|
||||
options={scaleOptions}
|
||||
onChipClick={handleScaleClick}
|
||||
{...scaleCustomHandlers}
|
||||
@@ -243,7 +324,7 @@ export function CommunityStructureSelectScreen() {
|
||||
<MultiSelect
|
||||
label={cs.maturityMultiSelect.label}
|
||||
showHelpIcon
|
||||
size="S"
|
||||
size="s"
|
||||
options={maturityOptions}
|
||||
onChipClick={handleMaturityClick}
|
||||
{...maturityCustomHandlers}
|
||||
@@ -254,26 +335,16 @@ export function CommunityStructureSelectScreen() {
|
||||
);
|
||||
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
variant="centeredNarrow"
|
||||
contentTopBelowMd="space-1400"
|
||||
<CreateFlowTwoColumnSelectShell
|
||||
header={
|
||||
<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)]">
|
||||
<div
|
||||
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>
|
||||
{multiSelectBlock}
|
||||
</CreateFlowTwoColumnSelectShell>
|
||||
);
|
||||
}
|
||||
+8
-8
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import MultiSelect from "../../../components/controls/MultiSelect";
|
||||
import Alert from "../../../components/modals/Alert";
|
||||
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import MultiSelect from "../../../../components/controls/MultiSelect";
|
||||
import Alert from "../../../../components/modals/Alert";
|
||||
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
|
||||
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||
@@ -12,7 +12,7 @@ import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowL
|
||||
|
||||
export function ConfirmStakeholdersScreen() {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const t = useTranslation("create.confirmStakeholders");
|
||||
const t = useTranslation("create.reviewAndComplete.confirmStakeholders");
|
||||
const [toastDismissed, setToastDismissed] = useState(false);
|
||||
const [stakeholderOptions, setStakeholderOptions] = useState<ChipOption[]>(
|
||||
[],
|
||||
@@ -22,7 +22,7 @@ export function ConfirmStakeholdersScreen() {
|
||||
markCreateFlowInteraction();
|
||||
setStakeholderOptions((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), label: "", state: "Custom" },
|
||||
{ id: crypto.randomUUID(), label: "", state: "custom" },
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ export function ConfirmStakeholdersScreen() {
|
||||
markCreateFlowInteraction();
|
||||
setStakeholderOptions((prev) =>
|
||||
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
|
||||
formHeader={false}
|
||||
showHelpIcon={false}
|
||||
size="S"
|
||||
size="s"
|
||||
options={stakeholderOptions}
|
||||
onChipClick={handleChipClick}
|
||||
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";
|
||||
|
||||
import { useState, useEffect, type HTMLInputTypeAttribute } from "react";
|
||||
import TextInput from "../../../components/controls/TextInput";
|
||||
import type { HeaderLockupJustificationValue } from "../../../components/type/HeaderLockup/HeaderLockup.types";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import TextInput from "../../../../components/controls/TextInput";
|
||||
import type { HeaderLockupJustificationValue } from "../../../../components/type/HeaderLockup/HeaderLockup.types";
|
||||
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Upload from "../../../components/controls/Upload";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import Upload from "../../../../components/controls/Upload";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
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`. */
|
||||
export function CommunityUploadScreen() {
|
||||
const m = useMessages();
|
||||
const u = m.create.communityUpload;
|
||||
const u = m.create.community.communityUpload;
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
|
||||
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 { migrateLegacyCreateFlowState } from "../../../lib/create/migrateLegacyCreateFlowState";
|
||||
import { migrateLegacyCreateFlowState } from "../../../../lib/create/migrateLegacyCreateFlowState";
|
||||
|
||||
/** Anonymous in-progress create flow (local only until magic-link transfer). */
|
||||
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. */
|
||||
export function clearLegacyCreateFlowKeysOnce(): void {
|
||||
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 { FLOW_STEP_ORDER, getStepIndex } from "./flowSteps";
|
||||
|
||||
@@ -15,8 +15,11 @@ const PROPORTION_BY_STEP_INDEX: readonly ProportionBarState[] = [
|
||||
"1-5", // community-upload
|
||||
"2-0", // community-save
|
||||
"2-0", // review (Figma Flow — Review `19706:12135`: same segment fill as end of Create Community)
|
||||
"2-2", // cards
|
||||
"3-0", // right-rail
|
||||
"2-0", // core-values (same segment as review / end of Create Community)
|
||||
"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-2", // final-review
|
||||
"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).
|
||||
* 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"
|
||||
| "text"
|
||||
| "select"
|
||||
@@ -14,12 +15,14 @@ export type CreateFlowLayoutKind =
|
||||
| "right-rail"
|
||||
| "completed";
|
||||
|
||||
export interface CreateFlowScreenDefinition {
|
||||
interface CreateFlowScreenDefinition {
|
||||
layoutKind: CreateFlowLayoutKind;
|
||||
/** Figma node id (file Community-Rule-System), dev mode. */
|
||||
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).
|
||||
*/
|
||||
messageNamespace: string;
|
||||
@@ -39,79 +42,97 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record<
|
||||
informational: {
|
||||
layoutKind: "informational",
|
||||
figmaNodeId: "20094-16005",
|
||||
messageNamespace: "create.informational",
|
||||
messageNamespace: "create.community.informational",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"community-name": {
|
||||
layoutKind: "text",
|
||||
figmaNodeId: "20094-18187",
|
||||
messageNamespace: "create.communityName",
|
||||
messageNamespace: "create.community.communityName",
|
||||
centeredBodyBelowMd: true,
|
||||
},
|
||||
"community-size": {
|
||||
layoutKind: "select",
|
||||
figmaNodeId: "20094-41317",
|
||||
messageNamespace: "create.communitySize",
|
||||
messageNamespace: "create.community.communitySize",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"community-context": {
|
||||
layoutKind: "text",
|
||||
figmaNodeId: "20094-41243",
|
||||
messageNamespace: "create.communityContext",
|
||||
messageNamespace: "create.community.communityContext",
|
||||
centeredBodyBelowMd: true,
|
||||
},
|
||||
"community-structure": {
|
||||
layoutKind: "select",
|
||||
figmaNodeId: "20094-18244",
|
||||
messageNamespace: "create.communityStructure",
|
||||
messageNamespace: "create.community.communityStructure",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"community-upload": {
|
||||
layoutKind: "upload",
|
||||
figmaNodeId: "20094-41524",
|
||||
messageNamespace: "create.communityUpload",
|
||||
messageNamespace: "create.community.communityUpload",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"community-save": {
|
||||
layoutKind: "text",
|
||||
figmaNodeId: "20097-14948",
|
||||
messageNamespace: "create.communitySave",
|
||||
messageNamespace: "create.community.communitySave",
|
||||
centeredBodyBelowMd: true,
|
||||
},
|
||||
review: {
|
||||
layoutKind: "review",
|
||||
figmaNodeId: "19706-12135",
|
||||
messageNamespace: "create.review",
|
||||
messageNamespace: "create.community.review",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
cards: {
|
||||
"core-values": {
|
||||
layoutKind: "select",
|
||||
figmaNodeId: "20264-68378",
|
||||
messageNamespace: "create.customRule.coreValues",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"communication-methods": {
|
||||
layoutKind: "card",
|
||||
figmaNodeId: "TBD-cards",
|
||||
messageNamespace: "create.communication",
|
||||
figmaNodeId: "20246-15828",
|
||||
messageNamespace: "create.customRule.communication",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"right-rail": {
|
||||
"membership-methods": {
|
||||
layoutKind: "card",
|
||||
figmaNodeId: "20858-13947",
|
||||
messageNamespace: "create.customRule.membership",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"decision-approaches": {
|
||||
layoutKind: "right-rail",
|
||||
figmaNodeId: "TBD-right-rail",
|
||||
messageNamespace: "create.rightRail",
|
||||
figmaNodeId: "20523-23509",
|
||||
messageNamespace: "create.customRule.decisionApproaches",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"conflict-management": {
|
||||
layoutKind: "card",
|
||||
figmaNodeId: "20879-15979",
|
||||
messageNamespace: "create.customRule.conflictManagement",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"confirm-stakeholders": {
|
||||
layoutKind: "select",
|
||||
figmaNodeId: "21104-46594",
|
||||
messageNamespace: "create.confirmStakeholders",
|
||||
messageNamespace: "create.reviewAndComplete.confirmStakeholders",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
"final-review": {
|
||||
layoutKind: "review",
|
||||
figmaNodeId: "20907-212767",
|
||||
messageNamespace: "create.finalReview",
|
||||
messageNamespace: "create.reviewAndComplete.finalReview",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
completed: {
|
||||
layoutKind: "completed",
|
||||
figmaNodeId: "20907-213286",
|
||||
messageNamespace: "create.completed",
|
||||
messageNamespace: "create.reviewAndComplete.completed",
|
||||
centeredBodyBelowMd: false,
|
||||
},
|
||||
};
|
||||
@@ -122,3 +143,11 @@ export function createFlowStepUsesCenteredTextLayout(
|
||||
if (!step) return false;
|
||||
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-save",
|
||||
"review",
|
||||
"cards",
|
||||
"right-rail",
|
||||
"core-values",
|
||||
"communication-methods",
|
||||
"membership-methods",
|
||||
"decision-approaches",
|
||||
"conflict-management",
|
||||
"confirm-stakeholders",
|
||||
"final-review",
|
||||
"completed",
|
||||
@@ -37,16 +40,26 @@ export const VALID_STEPS: readonly CreateFlowStep[] = FLOW_STEP_ORDER;
|
||||
*/
|
||||
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
|
||||
*/
|
||||
export function getNextStep(
|
||||
currentStep: CreateFlowStep | null | undefined,
|
||||
options?: CreateFlowNavigationOptions,
|
||||
): CreateFlowStep | null {
|
||||
if (!currentStep) return null;
|
||||
const index = FLOW_STEP_ORDER.indexOf(currentStep);
|
||||
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(
|
||||
currentStep: CreateFlowStep | null | undefined,
|
||||
options?: CreateFlowNavigationOptions,
|
||||
): CreateFlowStep | null {
|
||||
if (!currentStep) return null;
|
||||
const index = FLOW_STEP_ORDER.indexOf(currentStep);
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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 Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "../contexts/MessagesContext";
|
||||
import Login from "../components/modals/Login";
|
||||
import LoginForm from "../components/modals/Login/LoginForm";
|
||||
import { useTranslation } from "../../contexts/MessagesContext";
|
||||
import Login from "../../components/modals/Login";
|
||||
import LoginForm from "../../components/modals/Login/LoginForm";
|
||||
|
||||
const loginPageBgClass =
|
||||
"min-h-[100dvh] bg-[var(--color-surface-inverse-brand-primary)]";
|
||||
|
||||
function LoginLoadingFallback() {
|
||||
const t = useTranslation("pages.login");
|
||||
return (
|
||||
<div className={`${loginPageBgClass} flex items-center justify-center`}>
|
||||
<p className="font-inter text-[14px] text-[var(--color-content-default-primary)]">
|
||||
Loading…
|
||||
{t("loadingFallback")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "../contexts/MessagesContext";
|
||||
import Button from "../components/buttons/Button";
|
||||
import { fetchAuthSession, logout } from "../../lib/create/api";
|
||||
import { useTranslation } from "../../contexts/MessagesContext";
|
||||
import Button from "../../components/buttons/Button";
|
||||
import { fetchAuthSession, logout } from "../../../lib/create/api";
|
||||
|
||||
export default function ProfilePageClient() {
|
||||
const t = useTranslation("pages.profile");
|
||||
@@ -14,27 +14,26 @@ let ruleCardIdCounter = 0;
|
||||
interface ChipData {
|
||||
id: string;
|
||||
label: string;
|
||||
state: "Unselected" | "Selected" | "Custom";
|
||||
palette: "Default" | "Inverse";
|
||||
size: "S" | "M";
|
||||
state: "unselected" | "selected" | "custom";
|
||||
palette: "default" | "inverse";
|
||||
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<
|
||||
Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
state: "Unselected" | "Selected" | "Custom";
|
||||
state: "unselected" | "selected" | "custom";
|
||||
}>
|
||||
>([
|
||||
{ id: "1", label: "1 member", state: "Unselected" },
|
||||
{ id: "2", label: "2-10 members", state: "Unselected" },
|
||||
{ id: "3", label: "10-24 members", state: "Unselected" },
|
||||
{ id: "4", label: "24-64 members", state: "Unselected" },
|
||||
{ id: "5", label: "64-128 members", state: "Unselected" },
|
||||
{ id: "6", label: "125-1000 members", state: "Unselected" },
|
||||
{ id: "7", label: "1000+ members", state: "Unselected" },
|
||||
{ id: "1", label: "1 member", state: "unselected" },
|
||||
{ id: "2", label: "2-10 members", state: "unselected" },
|
||||
{ id: "3", label: "10-24 members", state: "unselected" },
|
||||
{ id: "4", label: "24-64 members", state: "unselected" },
|
||||
{ id: "5", label: "64-128 members", state: "unselected" },
|
||||
{ id: "6", label: "125-1000 members", state: "unselected" },
|
||||
{ id: "7", label: "1000+ members", state: "unselected" },
|
||||
]);
|
||||
|
||||
const handleChipClick = (chipId: string) => {
|
||||
@@ -43,7 +42,7 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) {
|
||||
opt.id === chipId
|
||||
? {
|
||||
...opt,
|
||||
state: opt.state === "Selected" ? "Unselected" : "Selected",
|
||||
state: opt.state === "selected" ? "unselected" : "selected",
|
||||
}
|
||||
: opt,
|
||||
),
|
||||
@@ -52,14 +51,14 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) {
|
||||
|
||||
const handleAddClick = () => {
|
||||
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) => {
|
||||
setOptions((prev) =>
|
||||
prev.map((opt) =>
|
||||
opt.id === chipId
|
||||
? { ...opt, label: value, state: "Selected" as const }
|
||||
? { ...opt, label: value, state: "selected" as const }
|
||||
: opt,
|
||||
),
|
||||
);
|
||||
@@ -72,7 +71,7 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) {
|
||||
return (
|
||||
<div className="space-y-[var(--spacing-scale-016)]">
|
||||
<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>
|
||||
<MultiSelect
|
||||
label="Label"
|
||||
@@ -91,12 +90,12 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) {
|
||||
|
||||
export default function ComponentsPreview() {
|
||||
const [chipStates, setChipStates] = useState<
|
||||
Record<string, "Unselected" | "Selected">
|
||||
Record<string, "unselected" | "selected">
|
||||
>({
|
||||
"default-s": "Unselected",
|
||||
"default-m": "Unselected",
|
||||
"inverse-s": "Unselected",
|
||||
"inverse-m": "Unselected",
|
||||
"default-s": "unselected",
|
||||
"default-m": "unselected",
|
||||
"inverse-s": "unselected",
|
||||
"inverse-m": "unselected",
|
||||
});
|
||||
|
||||
// Manage custom chips separately
|
||||
@@ -104,16 +103,16 @@ export default function ComponentsPreview() {
|
||||
{
|
||||
id: "custom-1",
|
||||
label: "",
|
||||
state: "Custom",
|
||||
palette: "Default",
|
||||
size: "S",
|
||||
state: "custom",
|
||||
palette: "default",
|
||||
size: "s",
|
||||
},
|
||||
{
|
||||
id: "custom-2",
|
||||
label: "",
|
||||
state: "Custom",
|
||||
palette: "Default",
|
||||
size: "M",
|
||||
state: "custom",
|
||||
palette: "default",
|
||||
size: "m",
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -124,7 +123,7 @@ export default function ComponentsPreview() {
|
||||
chipOptions: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
state: "Unselected" | "Selected" | "Custom";
|
||||
state: "unselected" | "selected" | "custom";
|
||||
}>;
|
||||
onChipClick?: (_categoryName: string, _chipId: string) => void;
|
||||
onAddClick?: (_categoryName: string) => void;
|
||||
@@ -139,11 +138,11 @@ export default function ComponentsPreview() {
|
||||
{
|
||||
name: "Values",
|
||||
chipOptions: [
|
||||
{ id: "values-1", label: "Consciousness", state: "Unselected" },
|
||||
{ id: "values-2", label: "Ecology", state: "Unselected" },
|
||||
{ id: "values-3", label: "Abundance", state: "Unselected" },
|
||||
{ id: "values-4", label: "Art", state: "Unselected" },
|
||||
{ id: "values-5", label: "Decisiveness", state: "Unselected" },
|
||||
{ id: "values-1", label: "Consciousness", state: "unselected" },
|
||||
{ id: "values-2", label: "Ecology", state: "unselected" },
|
||||
{ id: "values-3", label: "Abundance", state: "unselected" },
|
||||
{ id: "values-4", label: "Art", state: "unselected" },
|
||||
{ id: "values-5", label: "Decisiveness", state: "unselected" },
|
||||
],
|
||||
onChipClick: (categoryName: string, chipId: string) => {
|
||||
setRuleCardCategories((prev) =>
|
||||
@@ -156,9 +155,9 @@ export default function ComponentsPreview() {
|
||||
? {
|
||||
...opt,
|
||||
state:
|
||||
opt.state === "Selected"
|
||||
? "Unselected"
|
||||
: "Selected",
|
||||
opt.state === "selected"
|
||||
? "unselected"
|
||||
: "selected",
|
||||
}
|
||||
: opt,
|
||||
),
|
||||
@@ -176,7 +175,7 @@ export default function ComponentsPreview() {
|
||||
...cat,
|
||||
chipOptions: [
|
||||
...cat.chipOptions,
|
||||
{ id: newId, label: "", state: "Custom" },
|
||||
{ id: newId, label: "", state: "custom" },
|
||||
],
|
||||
}
|
||||
: cat,
|
||||
@@ -195,7 +194,7 @@ export default function ComponentsPreview() {
|
||||
...cat,
|
||||
chipOptions: cat.chipOptions.map((opt) =>
|
||||
opt.id === chipId
|
||||
? { ...opt, label: value, state: "Selected" }
|
||||
? { ...opt, label: value, state: "selected" }
|
||||
: opt,
|
||||
),
|
||||
}
|
||||
@@ -220,7 +219,7 @@ export default function ComponentsPreview() {
|
||||
},
|
||||
{
|
||||
name: "Communication",
|
||||
chipOptions: [{ id: "comm-1", label: "Signal", state: "Unselected" }],
|
||||
chipOptions: [{ id: "comm-1", label: "Signal", state: "unselected" }],
|
||||
onChipClick: (categoryName: string, chipId: string) => {
|
||||
setRuleCardCategories((prev) =>
|
||||
prev.map((cat) =>
|
||||
@@ -232,9 +231,9 @@ export default function ComponentsPreview() {
|
||||
? {
|
||||
...opt,
|
||||
state:
|
||||
opt.state === "Selected"
|
||||
? "Unselected"
|
||||
: "Selected",
|
||||
opt.state === "selected"
|
||||
? "unselected"
|
||||
: "selected",
|
||||
}
|
||||
: opt,
|
||||
),
|
||||
@@ -252,7 +251,7 @@ export default function ComponentsPreview() {
|
||||
...cat,
|
||||
chipOptions: [
|
||||
...cat.chipOptions,
|
||||
{ id: newId, label: "", state: "Custom" },
|
||||
{ id: newId, label: "", state: "custom" },
|
||||
],
|
||||
}
|
||||
: cat,
|
||||
@@ -271,7 +270,7 @@ export default function ComponentsPreview() {
|
||||
...cat,
|
||||
chipOptions: cat.chipOptions.map((opt) =>
|
||||
opt.id === chipId
|
||||
? { ...opt, label: value, state: "Selected" }
|
||||
? { ...opt, label: value, state: "selected" }
|
||||
: opt,
|
||||
),
|
||||
}
|
||||
@@ -297,7 +296,7 @@ export default function ComponentsPreview() {
|
||||
{
|
||||
name: "Membership",
|
||||
chipOptions: [
|
||||
{ id: "membership-1", label: "Open Admission", state: "Unselected" },
|
||||
{ id: "membership-1", label: "Open Admission", state: "unselected" },
|
||||
],
|
||||
onChipClick: (categoryName: string, chipId: string) => {
|
||||
setRuleCardCategories((prev) =>
|
||||
@@ -310,9 +309,9 @@ export default function ComponentsPreview() {
|
||||
? {
|
||||
...opt,
|
||||
state:
|
||||
opt.state === "Selected"
|
||||
? "Unselected"
|
||||
: "Selected",
|
||||
opt.state === "selected"
|
||||
? "unselected"
|
||||
: "selected",
|
||||
}
|
||||
: opt,
|
||||
),
|
||||
@@ -330,7 +329,7 @@ export default function ComponentsPreview() {
|
||||
...cat,
|
||||
chipOptions: [
|
||||
...cat.chipOptions,
|
||||
{ id: newId, label: "", state: "Custom" },
|
||||
{ id: newId, label: "", state: "custom" },
|
||||
],
|
||||
}
|
||||
: cat,
|
||||
@@ -349,7 +348,7 @@ export default function ComponentsPreview() {
|
||||
...cat,
|
||||
chipOptions: cat.chipOptions.map((opt) =>
|
||||
opt.id === chipId
|
||||
? { ...opt, label: value, state: "Selected" }
|
||||
? { ...opt, label: value, state: "selected" }
|
||||
: opt,
|
||||
),
|
||||
}
|
||||
@@ -375,8 +374,8 @@ export default function ComponentsPreview() {
|
||||
{
|
||||
name: "Decision-making",
|
||||
chipOptions: [
|
||||
{ id: "decision-1", label: "Lazy Consensus", state: "Unselected" },
|
||||
{ id: "decision-2", label: "Modified Consensus", state: "Unselected" },
|
||||
{ id: "decision-1", label: "Lazy Consensus", state: "unselected" },
|
||||
{ id: "decision-2", label: "Modified Consensus", state: "unselected" },
|
||||
],
|
||||
onChipClick: (categoryName: string, chipId: string) => {
|
||||
setRuleCardCategories((prev) =>
|
||||
@@ -389,9 +388,9 @@ export default function ComponentsPreview() {
|
||||
? {
|
||||
...opt,
|
||||
state:
|
||||
opt.state === "Selected"
|
||||
? "Unselected"
|
||||
: "Selected",
|
||||
opt.state === "selected"
|
||||
? "unselected"
|
||||
: "selected",
|
||||
}
|
||||
: opt,
|
||||
),
|
||||
@@ -409,7 +408,7 @@ export default function ComponentsPreview() {
|
||||
...cat,
|
||||
chipOptions: [
|
||||
...cat.chipOptions,
|
||||
{ id: newId, label: "", state: "Custom" },
|
||||
{ id: newId, label: "", state: "custom" },
|
||||
],
|
||||
}
|
||||
: cat,
|
||||
@@ -428,7 +427,7 @@ export default function ComponentsPreview() {
|
||||
...cat,
|
||||
chipOptions: cat.chipOptions.map((opt) =>
|
||||
opt.id === chipId
|
||||
? { ...opt, label: value, state: "Selected" }
|
||||
? { ...opt, label: value, state: "selected" }
|
||||
: opt,
|
||||
),
|
||||
}
|
||||
@@ -454,8 +453,8 @@ export default function ComponentsPreview() {
|
||||
{
|
||||
name: "Conflict management",
|
||||
chipOptions: [
|
||||
{ id: "conflict-1", label: "Code of Conduct", state: "Unselected" },
|
||||
{ id: "conflict-2", label: "Restorative Justice", state: "Unselected" },
|
||||
{ id: "conflict-1", label: "Code of Conduct", state: "unselected" },
|
||||
{ id: "conflict-2", label: "Restorative Justice", state: "unselected" },
|
||||
],
|
||||
onChipClick: (categoryName: string, chipId: string) => {
|
||||
setRuleCardCategories((prev) =>
|
||||
@@ -468,9 +467,9 @@ export default function ComponentsPreview() {
|
||||
? {
|
||||
...opt,
|
||||
state:
|
||||
opt.state === "Selected"
|
||||
? "Unselected"
|
||||
: "Selected",
|
||||
opt.state === "selected"
|
||||
? "unselected"
|
||||
: "selected",
|
||||
}
|
||||
: opt,
|
||||
),
|
||||
@@ -488,7 +487,7 @@ export default function ComponentsPreview() {
|
||||
...cat,
|
||||
chipOptions: [
|
||||
...cat.chipOptions,
|
||||
{ id: newId, label: "", state: "Custom" },
|
||||
{ id: newId, label: "", state: "custom" },
|
||||
],
|
||||
}
|
||||
: cat,
|
||||
@@ -507,7 +506,7 @@ export default function ComponentsPreview() {
|
||||
...cat,
|
||||
chipOptions: cat.chipOptions.map((opt) =>
|
||||
opt.id === chipId
|
||||
? { ...opt, label: value, state: "Selected" }
|
||||
? { ...opt, label: value, state: "selected" }
|
||||
: opt,
|
||||
),
|
||||
}
|
||||
@@ -560,45 +559,45 @@ export default function ComponentsPreview() {
|
||||
<Chip
|
||||
label="Small"
|
||||
state={chipStates["default-s"]}
|
||||
palette="Default"
|
||||
size="S"
|
||||
palette="default"
|
||||
size="s"
|
||||
onClick={() =>
|
||||
setChipStates((prev) => ({
|
||||
...prev,
|
||||
"default-s":
|
||||
prev["default-s"] === "Selected"
|
||||
? "Unselected"
|
||||
: "Selected",
|
||||
prev["default-s"] === "selected"
|
||||
? "unselected"
|
||||
: "selected",
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Chip
|
||||
label="Medium"
|
||||
state={chipStates["default-m"]}
|
||||
palette="Default"
|
||||
size="M"
|
||||
palette="default"
|
||||
size="m"
|
||||
onClick={() =>
|
||||
setChipStates((prev) => ({
|
||||
...prev,
|
||||
"default-m":
|
||||
prev["default-m"] === "Selected"
|
||||
? "Unselected"
|
||||
: "Selected",
|
||||
prev["default-m"] === "selected"
|
||||
? "unselected"
|
||||
: "selected",
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Chip
|
||||
label="Disabled"
|
||||
state="Disabled"
|
||||
palette="Default"
|
||||
size="S"
|
||||
state="disabled"
|
||||
palette="default"
|
||||
size="s"
|
||||
/>
|
||||
{customChips
|
||||
.filter((chip) => chip.palette === "Default")
|
||||
.filter((chip) => chip.palette === "default")
|
||||
.map((chip) => (
|
||||
<Chip
|
||||
key={chip.id}
|
||||
label={chip.state === "Custom" ? "" : chip.label}
|
||||
label={chip.state === "custom" ? "" : chip.label}
|
||||
state={chip.state}
|
||||
palette={chip.palette}
|
||||
size={chip.size}
|
||||
@@ -607,7 +606,7 @@ export default function ComponentsPreview() {
|
||||
setCustomChips((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === chip.id
|
||||
? { ...c, label: value, state: "Selected" }
|
||||
? { ...c, label: value, state: "selected" }
|
||||
: c,
|
||||
),
|
||||
);
|
||||
@@ -622,8 +621,8 @@ export default function ComponentsPreview() {
|
||||
e.stopPropagation();
|
||||
// Only toggle if the chip is in Selected or Unselected state (not Custom)
|
||||
if (
|
||||
chip.state === "Selected" ||
|
||||
chip.state === "Unselected"
|
||||
chip.state === "selected" ||
|
||||
chip.state === "unselected"
|
||||
) {
|
||||
setCustomChips((prev) =>
|
||||
prev.map((c) =>
|
||||
@@ -631,9 +630,9 @@ export default function ComponentsPreview() {
|
||||
? {
|
||||
...c,
|
||||
state:
|
||||
c.state === "Selected"
|
||||
? "Unselected"
|
||||
: "Selected",
|
||||
c.state === "selected"
|
||||
? "unselected"
|
||||
: "selected",
|
||||
}
|
||||
: c,
|
||||
),
|
||||
@@ -652,9 +651,9 @@ export default function ComponentsPreview() {
|
||||
{
|
||||
id: newId,
|
||||
label: "",
|
||||
state: "Custom",
|
||||
palette: "Default",
|
||||
size: "S",
|
||||
state: "custom",
|
||||
palette: "default",
|
||||
size: "s",
|
||||
},
|
||||
]);
|
||||
}}
|
||||
@@ -698,38 +697,38 @@ export default function ComponentsPreview() {
|
||||
<Chip
|
||||
label="Small"
|
||||
state={chipStates["inverse-s"]}
|
||||
palette="Inverse"
|
||||
size="S"
|
||||
palette="inverse"
|
||||
size="s"
|
||||
onClick={() =>
|
||||
setChipStates((prev) => ({
|
||||
...prev,
|
||||
"inverse-s":
|
||||
prev["inverse-s"] === "Selected"
|
||||
? "Unselected"
|
||||
: "Selected",
|
||||
prev["inverse-s"] === "selected"
|
||||
? "unselected"
|
||||
: "selected",
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Chip
|
||||
label="Medium"
|
||||
state={chipStates["inverse-m"]}
|
||||
palette="Inverse"
|
||||
size="M"
|
||||
palette="inverse"
|
||||
size="m"
|
||||
onClick={() =>
|
||||
setChipStates((prev) => ({
|
||||
...prev,
|
||||
"inverse-m":
|
||||
prev["inverse-m"] === "Selected"
|
||||
? "Unselected"
|
||||
: "Selected",
|
||||
prev["inverse-m"] === "selected"
|
||||
? "unselected"
|
||||
: "selected",
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Chip
|
||||
label="Disabled"
|
||||
state="Disabled"
|
||||
palette="Inverse"
|
||||
size="S"
|
||||
state="disabled"
|
||||
palette="inverse"
|
||||
size="s"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -959,10 +958,10 @@ export default function ComponentsPreview() {
|
||||
</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)]">
|
||||
{/* Small size */}
|
||||
<MultiSelectExample size="S" />
|
||||
<MultiSelectExample size="s" />
|
||||
|
||||
{/* Medium size */}
|
||||
<MultiSelectExample size="M" />
|
||||
<MultiSelectExample size="m" />
|
||||
</div>
|
||||
</section>
|
||||
</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 { listRuleTemplatesFromDb } from "../../lib/server/ruleTemplates";
|
||||
import { GOVERNANCE_TEMPLATE_HOME_SLUGS } from "../../lib/templates/governanceTemplateCatalog";
|
||||
import { gridEntriesForSlugOrderWithCatalogFallback } from "../../lib/templates/templateGridPresentation";
|
||||
import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
|
||||
import { GOVERNANCE_TEMPLATE_HOME_SLUGS } from "../../../lib/templates/governanceTemplateCatalog";
|
||||
import { gridEntriesForSlugOrderWithCatalogFallback } from "../../../lib/templates/templateGridPresentation";
|
||||
|
||||
const RuleStack = dynamic(() => import("../components/sections/RuleStack"), {
|
||||
const RuleStack = dynamic(() => import("../../components/sections/RuleStack"), {
|
||||
loading: () => (
|
||||
<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 HeroBanner from "../components/sections/HeroBanner";
|
||||
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
|
||||
const LogoWall = dynamic(() => import("../components/sections/LogoWall"), {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
|
||||
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";
|
||||
|
||||
export interface TemplatesPageClientProps {
|
||||
@@ -17,7 +20,6 @@ export interface TemplatesPageClientProps {
|
||||
export default function TemplatesPageClient({
|
||||
initialGridEntries,
|
||||
}: TemplatesPageClientProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslation("pages.templates");
|
||||
|
||||
return (
|
||||
@@ -39,16 +41,58 @@ export default function TemplatesPageClient({
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 min-[1024px]:mt-8">
|
||||
<GovernanceTemplateGrid
|
||||
entries={initialGridEntries}
|
||||
onTemplateClick={(slug) => {
|
||||
router.push(
|
||||
`/create/review-template/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{/* Suspense boundary required by `useSearchParams` below
|
||||
(Next.js 15+ static-generation contract). */}
|
||||
<Suspense
|
||||
fallback={<TemplatesGrid entries={initialGridEntries} fromFlow={false} />}
|
||||
>
|
||||
<TemplatesGridWithSearchParams entries={initialGridEntries} />
|
||||
</Suspense>
|
||||
</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 },
|
||||
});
|
||||
}
|
||||
|
||||
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 { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
|
||||
import { listRankedRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
|
||||
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()) {
|
||||
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,
|
||||
ButtonStateValue,
|
||||
} from "../../../lib/propNormalization";
|
||||
import {
|
||||
normalizeSize,
|
||||
normalizeButtonType,
|
||||
normalizeButtonPalette,
|
||||
} from "../../../lib/propNormalization";
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Button type (Figma prop). Accepts both lowercase and PascalCase (case-insensitive).
|
||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
||||
* Button type (Figma prop).
|
||||
* @default "filled"
|
||||
*/
|
||||
buttonType?: ButtonTypeValue;
|
||||
/**
|
||||
* Button palette (Figma prop). Accepts both lowercase and PascalCase (case-insensitive).
|
||||
* Figma uses "Invert", codebase uses "inverse" - both are supported.
|
||||
* Button palette (Figma prop).
|
||||
* @default "default"
|
||||
*/
|
||||
palette?: ButtonPaletteValue;
|
||||
/**
|
||||
* Button size. Accepts both lowercase and PascalCase (case-insensitive).
|
||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
||||
* Button size.
|
||||
* @default "xsmall"
|
||||
*/
|
||||
size?: SizeValue;
|
||||
/**
|
||||
* Button state (Figma prop). Accepts both lowercase and PascalCase (case-insensitive).
|
||||
* Button state (Figma prop).
|
||||
* @default "default"
|
||||
*/
|
||||
state?: ButtonStateValue;
|
||||
@@ -83,12 +75,9 @@ const Button = memo<ButtonProps>(
|
||||
ariaLabel,
|
||||
...props
|
||||
}) => {
|
||||
// Normalize props
|
||||
const buttonType = normalizeButtonType(typeProp, "filled");
|
||||
const buttonPalette = normalizeButtonPalette(paletteProp, "default");
|
||||
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)
|
||||
const buttonType = typeProp ?? "filled";
|
||||
const buttonPalette = paletteProp ?? "default";
|
||||
const size = sizeProp;
|
||||
|
||||
// Map type + palette to variant string for styling (internal use only)
|
||||
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 SectionNumber from "../sections/SectionNumber";
|
||||
|
||||
import { normalizeNumberCardSize } from "../../../lib/propNormalization";
|
||||
|
||||
export type NumberCardSizeValue =
|
||||
| "Small"
|
||||
| "Medium"
|
||||
| "Large"
|
||||
| "XLarge"
|
||||
| "small"
|
||||
| "medium"
|
||||
| "large"
|
||||
| "xlarge";
|
||||
export type NumberCardSizeValue = "small" | "medium" | "large" | "xlarge";
|
||||
|
||||
interface NumberCardProps {
|
||||
number: number;
|
||||
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;
|
||||
iconShape?: string;
|
||||
iconColor?: string;
|
||||
}
|
||||
|
||||
const NumberCard = memo<NumberCardProps>(({ number, text, size: sizeProp }) => {
|
||||
// Base classes common to all sizes
|
||||
const baseClasses =
|
||||
"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) {
|
||||
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
|
||||
const size = normalizeNumberCardSize(sizeProp);
|
||||
// Size-specific classes
|
||||
const size = sizeProp;
|
||||
const sizeClasses = {
|
||||
Small: "flex flex-col items-end justify-center gap-4 p-5 relative",
|
||||
Medium: "flex flex-row items-center gap-8 p-8 relative",
|
||||
Large:
|
||||
small: "flex flex-col items-end justify-center gap-4 p-5 relative",
|
||||
medium: "flex flex-row items-center gap-8 p-8 relative",
|
||||
large:
|
||||
"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",
|
||||
};
|
||||
|
||||
// Text size classes
|
||||
const textClasses = {
|
||||
Small:
|
||||
small:
|
||||
"font-bricolage-grotesque font-medium text-[24px] leading-[32px] text-[#141414]",
|
||||
Medium:
|
||||
medium:
|
||||
"font-bricolage-grotesque font-medium text-[24px] leading-[24px] text-[#141414]",
|
||||
Large:
|
||||
large:
|
||||
"font-bricolage-grotesque font-medium text-[24px] leading-[24px] text-[#141414]",
|
||||
XLarge:
|
||||
xlarge:
|
||||
"font-bricolage-grotesque font-medium text-[32px] leading-[32px] text-[#141414]",
|
||||
};
|
||||
|
||||
// Section number wrapper classes - Small doesn't need a wrapper
|
||||
const sectionNumberWrapperClasses = {
|
||||
Small: "relative shrink-0",
|
||||
Medium: "flex justify-start flex-shrink-0",
|
||||
Large: "absolute top-8 right-8",
|
||||
XLarge: "absolute top-8 right-8",
|
||||
small: "relative shrink-0",
|
||||
medium: "flex justify-start flex-shrink-0",
|
||||
large: "absolute top-8 right-8",
|
||||
xlarge: "absolute top-8 right-8",
|
||||
};
|
||||
|
||||
// Content container classes
|
||||
const contentClasses = {
|
||||
Small: "min-w-full relative shrink-0",
|
||||
Medium: "flex-1",
|
||||
Large: "absolute bottom-8 left-8 right-16",
|
||||
XLarge: "absolute bottom-8 left-8 right-16",
|
||||
small: "min-w-full relative shrink-0",
|
||||
medium: "flex-1",
|
||||
large: "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 (
|
||||
<div className={`${baseClasses} ${sizeClasses[size]}`}>
|
||||
{/* Section Number - Direct child for Small */}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { memo } from "react";
|
||||
import { RuleCardView } from "./RuleCard.view";
|
||||
import type { RuleCardProps } from "./RuleCard.types";
|
||||
import { normalizeRuleCardSize } from "../../../../lib/propNormalization";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -32,9 +31,9 @@ const RuleCardContainer = memo<RuleCardProps>(
|
||||
logoUrl,
|
||||
logoAlt,
|
||||
communityInitials,
|
||||
hideCategoryAddButton = false,
|
||||
}) => {
|
||||
// Normalize size prop
|
||||
const size = normalizeRuleCardSize(sizeProp, "L");
|
||||
const size = sizeProp ?? "L";
|
||||
|
||||
const handleClick = () => {
|
||||
// Basic analytics event tracking
|
||||
@@ -78,6 +77,7 @@ const RuleCardContainer = memo<RuleCardProps>(
|
||||
logoUrl={logoUrl}
|
||||
logoAlt={logoAlt}
|
||||
communityInitials={communityInitials}
|
||||
hideCategoryAddButton={hideCategoryAddButton}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -21,11 +21,13 @@ export interface RuleCardProps {
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
expanded?: boolean;
|
||||
size?: "XS" | "S" | "M" | "L" | "xs" | "s" | "m" | "l";
|
||||
size?: "XS" | "S" | "M" | "L";
|
||||
categories?: Category[];
|
||||
logoUrl?: string;
|
||||
logoAlt?: string;
|
||||
communityInitials?: string;
|
||||
/** Hide the per-category "+" add chip affordance (e.g. read-only template review). */
|
||||
hideCategoryAddButton?: boolean;
|
||||
}
|
||||
|
||||
export interface RuleCardViewProps {
|
||||
@@ -42,4 +44,5 @@ export interface RuleCardViewProps {
|
||||
logoUrl?: string;
|
||||
logoAlt?: string;
|
||||
communityInitials?: string;
|
||||
hideCategoryAddButton?: boolean;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export function RuleCardView({
|
||||
logoUrl,
|
||||
logoAlt,
|
||||
communityInitials,
|
||||
hideCategoryAddButton = false,
|
||||
}: RuleCardViewProps) {
|
||||
const t = useTranslation("ruleCard");
|
||||
const ariaLabel = t("ariaLabel")?.replace("{title}", title) || title;
|
||||
@@ -261,8 +262,8 @@ export function RuleCardView({
|
||||
key={categoryIndex}
|
||||
label={category.name}
|
||||
showHelpIcon={false}
|
||||
size="S"
|
||||
palette="Inverse"
|
||||
size="s"
|
||||
palette="inverse"
|
||||
options={category.chipOptions}
|
||||
onChipClick={(chipId) => {
|
||||
category.onChipClick?.(category.name, chipId);
|
||||
@@ -280,7 +281,7 @@ export function RuleCardView({
|
||||
onCustomChipClose={(chipId) => {
|
||||
category.onCustomChipClose?.(category.name, chipId);
|
||||
}}
|
||||
addButton={true}
|
||||
addButton={!hideCategoryAddButton}
|
||||
addButtonText="" // Empty text for icon-only circular button
|
||||
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";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Image from "next/image";
|
||||
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 type { RuleTemplateDto } from "../../../../lib/create/fetchTemplates";
|
||||
import {
|
||||
templateBodyToCategories,
|
||||
templateBodyToReviewData,
|
||||
templateSummaryFromBody,
|
||||
} from "../../../../lib/create/templateReviewMapping";
|
||||
import {
|
||||
getGovernanceTemplateCatalogEntry,
|
||||
} from "../../../../lib/templates/governanceTemplateCatalog";
|
||||
import { TEMPLATE_GRID_FALLBACK_PRESENTATION } from "../../../../lib/templates/templateGridPresentation";
|
||||
import { TemplateChipDetailModal } from "./TemplateChipDetailModal";
|
||||
|
||||
export interface TemplateReviewCardProps {
|
||||
template: RuleTemplateDto;
|
||||
@@ -24,7 +29,9 @@ export interface TemplateReviewCardProps {
|
||||
|
||||
/**
|
||||
* 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({
|
||||
template,
|
||||
@@ -33,33 +40,60 @@ export function TemplateReviewCard({
|
||||
}: TemplateReviewCardProps) {
|
||||
const catalog = getGovernanceTemplateCatalogEntry(template.slug);
|
||||
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 [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 (
|
||||
<RuleCard
|
||||
title={template.title}
|
||||
description={summary}
|
||||
expanded
|
||||
size={size}
|
||||
categories={categories}
|
||||
backgroundColor={pres.backgroundColor}
|
||||
className={ruleCardClassName}
|
||||
onClick={() => {}}
|
||||
icon={
|
||||
<Image
|
||||
src={getAssetPath(pres.iconPath)}
|
||||
alt={template.title}
|
||||
width={90}
|
||||
height={90}
|
||||
className="
|
||||
max-[639px]:w-[40px] max-[639px]:h-[40px]
|
||||
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]
|
||||
"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<RuleCard
|
||||
title={template.title}
|
||||
description={summary}
|
||||
expanded
|
||||
size={size}
|
||||
categories={categories}
|
||||
backgroundColor={pres.backgroundColor}
|
||||
className={ruleCardClassName}
|
||||
onClick={() => {}}
|
||||
hideCategoryAddButton
|
||||
icon={
|
||||
<Image
|
||||
src={getAssetPath(pres.iconPath)}
|
||||
alt={template.title}
|
||||
width={90}
|
||||
height={90}
|
||||
className="
|
||||
max-[639px]:w-[40px] max-[639px]:h-[40px]
|
||||
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 ContentContainerView from "./ContentContainer.view";
|
||||
import type { ContentContainerProps } from "./ContentContainer.types";
|
||||
import { normalizeContentContainerSize } from "../../../../lib/propNormalization";
|
||||
|
||||
const ContentContainerContainer = memo<ContentContainerProps>(
|
||||
({ post, width = "200px", size: sizeProp = "responsive" }) => {
|
||||
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
|
||||
const size = normalizeContentContainerSize(sizeProp);
|
||||
const size = sizeProp;
|
||||
// Get the corresponding icon based on the same logic as background images
|
||||
const getIconImage = (slug: string): string => {
|
||||
const icons = [
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import type { BlogPost } from "../../../../lib/content";
|
||||
|
||||
export type ContentContainerSizeValue =
|
||||
| "xs"
|
||||
| "responsive"
|
||||
| "Xs"
|
||||
| "Responsive";
|
||||
export type ContentContainerSizeValue = "xs" | "responsive";
|
||||
|
||||
export interface ContentContainerProps {
|
||||
post: BlogPost;
|
||||
width?: string;
|
||||
/**
|
||||
* Content container size. Accepts both lowercase and PascalCase (case-insensitive).
|
||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
||||
* Content container size.
|
||||
*/
|
||||
size?: ContentContainerSizeValue;
|
||||
}
|
||||
|
||||
+1
-3
@@ -4,12 +4,10 @@ import { memo } from "react";
|
||||
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
|
||||
import ContentThumbnailTemplateView from "./ContentThumbnailTemplate.view";
|
||||
import type { ContentThumbnailTemplateProps } from "./ContentThumbnailTemplate.types";
|
||||
import { normalizeContentThumbnailVariant } from "../../../../lib/propNormalization";
|
||||
|
||||
const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
|
||||
({ post, className = "", variant: variantProp = "vertical" }) => {
|
||||
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
|
||||
const variant = normalizeContentThumbnailVariant(variantProp);
|
||||
const variant = variantProp;
|
||||
// Get article-specific background image from frontmatter
|
||||
const getBackgroundImage = (
|
||||
post: ContentThumbnailTemplateProps["post"],
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import type { BlogPost } from "../../../../lib/content";
|
||||
|
||||
export type ContentThumbnailTemplateVariantValue =
|
||||
| "vertical"
|
||||
| "horizontal"
|
||||
| "Vertical"
|
||||
| "Horizontal";
|
||||
export type ContentThumbnailTemplateVariantValue = "vertical" | "horizontal";
|
||||
|
||||
export interface ContentThumbnailTemplateProps {
|
||||
post: BlogPost;
|
||||
className?: string;
|
||||
/**
|
||||
* Content thumbnail variant. Accepts both lowercase and PascalCase (case-insensitive).
|
||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
||||
* Content thumbnail variant.
|
||||
*/
|
||||
variant?: ContentThumbnailTemplateVariantValue;
|
||||
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