Compare commits
95
Commits
79b5166fa3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77b0ec8ad8 | ||
|
|
98945ceb7e | ||
|
|
234f3998ad | ||
|
|
6ccc1e8c8e | ||
|
|
90db213413 | ||
|
|
3f9a8395be | ||
|
|
0e272f0c1e | ||
|
|
4055278628 | ||
|
|
42d83c8b62 | ||
|
|
130663f930 | ||
|
|
6c1d83e89d | ||
|
|
64a2ef5a00 | ||
|
|
95e1937419 | ||
|
|
ce581a42c8 | ||
|
|
ddf7b0ef7a | ||
|
|
4bd9fa6dc6 | ||
|
|
b42716c262 | ||
|
|
40225e2845 | ||
|
|
2d351987cf | ||
|
|
5242f8c6bb | ||
|
|
aecb890cc8 | ||
|
|
b2b272d2cc | ||
|
|
e4e61c0c9b | ||
|
|
b6afdb6e32 | ||
|
|
f91ac7a893 | ||
|
|
db34a1f0df | ||
|
|
84ef943df4 | ||
|
|
df7051f33a | ||
|
|
f780eac1fa | ||
|
|
fa928d1b2c | ||
|
|
d920e39f09 | ||
|
|
511c3efb4c | ||
|
|
7f67cc6271 | ||
|
|
a820739d07 | ||
|
|
744a85a44e | ||
|
|
4c4cf99572 | ||
|
|
95781452bd | ||
|
|
bdda522971 | ||
|
|
2e995cb5f7 | ||
|
|
950b3224ac | ||
|
|
bf4020005a | ||
|
|
d9751d7964 | ||
|
|
63e0f77998 | ||
|
|
8bdd5040c6 | ||
|
|
712437edc7 | ||
|
|
963fa03c5c | ||
|
|
b1ca1a748e | ||
|
|
38fe0e7e9b | ||
|
|
e416f37940 | ||
|
|
440b6a9657 | ||
|
|
ef60175805 | ||
|
|
75ba0a214f | ||
|
|
4006fe5c6c | ||
|
|
e9951f260b | ||
|
|
d6d6a5e055 | ||
|
|
3c7fca83b2 | ||
|
|
75c5ae61b0 | ||
|
|
9e698e7ab6 | ||
|
|
846af91240 | ||
|
|
c2188015e3 | ||
|
|
0f16c559c4 | ||
|
|
1a014f30f0 | ||
|
|
f35889f966 | ||
|
|
aa4fe47554 | ||
|
|
55915fd364 | ||
|
|
d427d96a83 | ||
|
|
99c5c28a51 | ||
|
|
a525b12b5c | ||
|
|
91f1d1569a | ||
|
|
7ea0a52bf1 | ||
|
|
6820315aaa | ||
|
|
63842b31c8 | ||
|
|
73cb73a5af | ||
|
|
b6cf980d23 | ||
|
|
9769bcc76d | ||
|
|
c0342a3b2d | ||
|
|
fc5e3e8d6c | ||
|
|
65f7287639 | ||
|
|
3cdf4174d0 | ||
|
|
7b50c92ad3 | ||
|
|
5be76913ef | ||
|
|
0aceb58c13 | ||
|
|
db1581337c | ||
|
|
1983a67ffd | ||
|
|
da453f6108 | ||
|
|
705fa428ce | ||
|
|
2321a1e761 | ||
|
|
cb0adf2049 | ||
|
|
0167b03231 | ||
|
|
99419915d7 | ||
|
|
fad06d9e23 | ||
|
|
2bc190e0c5 | ||
|
|
4b0feb0811 | ||
|
|
f6752dd7f3 | ||
|
|
79bdb97c69 |
@@ -47,7 +47,15 @@ When your changes create orphans:
|
|||||||
|
|
||||||
The test: Every changed line should trace directly to the user's request.
|
The test: Every changed line should trace directly to the user's request.
|
||||||
|
|
||||||
## 4. Goal-Driven Execution
|
## 4. Comments, commits, and PRs
|
||||||
|
|
||||||
|
**Let the code talk. Keep tickets out of prose.**
|
||||||
|
|
||||||
|
- No comment when the names already say it.
|
||||||
|
- No Linear IDs (`CR-123`) in comments, commit messages, or PR titles/bodies. Branch names still use `adilallo/<type>/CR-<n>-…` (see `branch-naming.mdc`). Link the issue from Linear, not from the PR copy.
|
||||||
|
- Don't narrate Figma→code mappings the prop already encodes (`palette="inverse"` needs no "this is Invert" comment).
|
||||||
|
|
||||||
|
## 5. Goal-Driven Execution
|
||||||
|
|
||||||
**Define success criteria. Loop until verified.**
|
**Define success criteria. Loop until verified.**
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ Reach for these before writing new markup:
|
|||||||
| `[– value +]` numeric stepper (± label) | `app/components/controls/Incrementer` / `IncrementerBlock` |
|
| `[– value +]` numeric stepper (± label) | `app/components/controls/Incrementer` / `IncrementerBlock` |
|
||||||
| Mid-paragraph "expand / see all" link button | `app/components/buttons/InlineTextButton` |
|
| Mid-paragraph "expand / see all" link button | `app/components/buttons/InlineTextButton` |
|
||||||
| Help-icon + label above a control | `app/components/type/InputLabel` (`helpIcon` prop) |
|
| Help-icon + label above a control | `app/components/type/InputLabel` (`helpIcon` prop) |
|
||||||
| Toggle chip (dim-but-clickable) | `Chip` with `state="Disabled" disabled={false}` |
|
| Toggle chip | `Chip` with `state="selected"` / `"unselected"` (Chip sets `aria-pressed`; selected includes a check mark) |
|
||||||
| Card-click → structured creation modal | `Create` with `backdropVariant="blurredYellow"` |
|
| Card-click → structured creation modal | `Create` with `backdropVariant="blurredYellow"` |
|
||||||
|
|
||||||
If a screen grows a 2nd inline copy of any pattern above, **extract a shared
|
If a screen grows a 2nd inline copy of any pattern above, **extract a shared
|
||||||
|
|||||||
+12
-10
@@ -14,10 +14,10 @@ the file tree without affecting URLs.
|
|||||||
|
|
||||||
| Group | URL surface | Audience | Chrome |
|
| Group | URL surface | Audience | Chrome |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `app/(marketing)/` | `/`, `/learn`, `/blog`, `/templates`, future public pages | Public, indexable | `Top` (via root) + marketing `<Footer />` |
|
| `app/(marketing)/` | `/`, `/learn`, `/blog`, `/templates`, future public pages | Public, indexable | `SkipToContent` + `ConditionalNavigation` + marketing `<Footer />` |
|
||||||
| `app/(app)/` | `/create/*`, `/login`, `/profile`, future signed-in surfaces | Authenticated product | `Top` (via root) — no footer except **`/profile`** (see `profile/layout.tsx`) |
|
| `app/(app)/` | `/create/*`, `/login`, `/profile`, future signed-in surfaces | Authenticated product | `SkipToContent` + `ConditionalNavigation` — no footer except **`/profile`** (see `profile/layout.tsx`) |
|
||||||
| `app/(admin)/` | `/monitor`, future ops dashboards | Operators | `Top` (via root) — no footer |
|
| `app/(admin)/` | `/monitor`, future ops dashboards | Operators | `SkipToContent` + `ConditionalNavigation` — no footer |
|
||||||
| `app/(dev)/` | `/components-preview`, future dev previews | Local dev (NODE_ENV gated) | `Top` (via root) — no footer |
|
| `app/(dev)/` | `/components-preview`, future dev previews | Local dev (NODE_ENV gated) | `Top` omitted — no footer |
|
||||||
| `app/(marketing-case-study)/` | `/use-cases/[slug]/rule` | Public case-study demos | Chromeless (no global `Top`; see `navigationChromelessPath.ts`) |
|
| `app/(marketing-case-study)/` | `/use-cases/[slug]/rule` | Public case-study demos | Chromeless (no global `Top`; see `navigationChromelessPath.ts`) |
|
||||||
| `app/api/` | API routes | n/a | n/a |
|
| `app/api/` | API routes | n/a | n/a |
|
||||||
|
|
||||||
@@ -28,14 +28,16 @@ the folder next to `(marketing)/`.
|
|||||||
## Layout responsibilities
|
## Layout responsibilities
|
||||||
|
|
||||||
- **`app/layout.tsx`** — `<html>`, `<body>`, providers (`MessagesProvider`,
|
- **`app/layout.tsx`** — `<html>`, `<body>`, providers (`MessagesProvider`,
|
||||||
`AuthModalProvider`), fonts, and `ConditionalNavigation`. Renders
|
`AuthModalProvider` are per group), fonts. Renders
|
||||||
`{children}` directly inside the flex column. **Does not** render
|
`{children}` directly inside the flex column. **Does not** render
|
||||||
`<main>` — each group layout owns that.
|
`<main>` or the header — each group layout owns that.
|
||||||
- **`app/(marketing)/layout.tsx`** — wraps with `<main className="flex-1">`
|
- **`app/(marketing)/layout.tsx`** — skip-to-content, `ConditionalNavigation`
|
||||||
and appends the public `<Footer />`.
|
(SSR session), `<main id="main-content">`, public `<Footer />`.
|
||||||
- **`app/(app)/layout.tsx`** / **`(admin)/layout.tsx`** / **`(dev)/layout.tsx`** —
|
- **`app/(app)/layout.tsx`** / **`(admin)/layout.tsx`** —
|
||||||
wrap with `<main className="flex-1">`. No footer by default; **`app/(app)/profile/layout.tsx`**
|
skip-to-content, `ConditionalNavigation`, `<main id="main-content">`.
|
||||||
|
No footer by default; **`app/(app)/profile/layout.tsx`**
|
||||||
appends the marketing `<Footer />` for `/profile` only.
|
appends the marketing `<Footer />` for `/profile` only.
|
||||||
|
- **`app/(dev)/layout.tsx`** — `<main id="main-content">` only.
|
||||||
- **Nested layouts** (e.g. `(app)/create/layout.tsx`) compose feature-specific
|
- **Nested layouts** (e.g. `(app)/create/layout.tsx`) compose feature-specific
|
||||||
chrome inside the group's `<main>` — never render `<html>`, `<body>`,
|
chrome inside the group's `<main>` — never render `<html>`, `<body>`,
|
||||||
`<main>`, or providers.
|
`<main>`, or providers.
|
||||||
|
|||||||
+22
-16
@@ -28,16 +28,17 @@ Do **not** colocate `*.stories.*` next to components. The Storybook config
|
|||||||
|
|
||||||
# File naming
|
# File naming
|
||||||
|
|
||||||
- `<ComponentName>.stories.js` — matches 69/70 existing files.
|
- `<ComponentName>.stories.js` — default; keep `.js` unless the story
|
||||||
- Use `.tsx` only when the story genuinely needs types (rare; prefer JS to
|
genuinely needs types.
|
||||||
match the codebase convention).
|
- Use `.tsx` only when the story uses `Meta` / `StoryObj` (rare).
|
||||||
- Variants get a suffix: `Button.visual.stories.js`,
|
- Variants get a suffix: `Button.visual.stories.js`,
|
||||||
`Footer.responsive.stories.js`.
|
`Footer.responsive.stories.js`.
|
||||||
|
|
||||||
# Default export shape (CSF2)
|
# Default export shape (CSF3)
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import MyComponent from "../../app/components/<area>/MyComponent";
|
import MyComponent from "../../app/components/<area>/MyComponent";
|
||||||
|
import { CHIP_PALETTE_OPTIONS } from "../../lib/propNormalization";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "Components/<SubFolder>/MyComponent",
|
title: "Components/<SubFolder>/MyComponent",
|
||||||
@@ -51,31 +52,32 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
argTypes: {
|
argTypes: {
|
||||||
variant: {
|
palette: {
|
||||||
control: { type: "select" },
|
control: { type: "select" },
|
||||||
options: ["filled", "outline"],
|
options: [...CHIP_PALETTE_OPTIONS],
|
||||||
description: "The variant (Figma prop)",
|
description: "The palette (Figma prop)",
|
||||||
},
|
},
|
||||||
onClick: { action: "clicked" },
|
onClick: { action: "clicked" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Default = { args: { variant: "filled" } };
|
export const Default = { args: { palette: "default" } };
|
||||||
```
|
```
|
||||||
|
|
||||||
## Title hierarchy
|
## Title hierarchy
|
||||||
|
|
||||||
- Design-system components → `Components/<SubFolder>/<Name>` (e.g.
|
- Design-system components → `Components/<SubFolder>/<Name>` (e.g.
|
||||||
`Components/Controls/Checkbox`).
|
`Components/Controls/Checkbox`).
|
||||||
- Pages → `Pages/<PageName>` (folder: `stories/pages/`).
|
- Create-flow screens → `Pages/Create Flow/<Step>` (folder: `stories/pages/`).
|
||||||
- Create flow shared pieces → `Create Flow/<Name>`.
|
- Create flow shared pieces → `Create Flow/<Name>`.
|
||||||
|
|
||||||
## `argTypes`
|
## `argTypes`
|
||||||
|
|
||||||
For every Figma enum prop (`variant`, `size`, `state`, `mode`, `palette`,
|
For every Figma enum prop (`variant`, `size`, `state`, `mode`, `palette`,
|
||||||
…) expose a `select` control listing the **lowercase** option set, sourced
|
…) expose a `select` control listing the option set, sourced from the matching
|
||||||
from the matching `*_OPTIONS` const in `lib/propNormalization.ts`. See
|
`*_OPTIONS` const in `lib/propNormalization.ts`. See
|
||||||
`.cursor/rules/component-props.mdc`.
|
`.cursor/rules/component-props.mdc`. Spread with `[...FOO_OPTIONS]` so the
|
||||||
|
control receives a mutable array.
|
||||||
|
|
||||||
# Rely on the global preview — don't re-wrap
|
# Rely on the global preview — don't re-wrap
|
||||||
|
|
||||||
@@ -83,8 +85,12 @@ from the matching `*_OPTIONS` const in `lib/propNormalization.ts`. See
|
|||||||
|
|
||||||
- `MessagesProvider` with `messages/en` → access copy via `useMessages()`
|
- `MessagesProvider` with `messages/en` → access copy via `useMessages()`
|
||||||
inside stories exactly like app code. Never hard-code user-facing strings.
|
inside stories exactly like app code. Never hard-code user-facing strings.
|
||||||
- `app/globals.css` + `.font-inter` wrapper → design tokens and fonts are
|
- `AuthModalProvider` and `CreateFlowProvider` (same stack as
|
||||||
already present.
|
`tests/utils/test-utils.tsx`) so `Top` and create-flow screens can mount.
|
||||||
|
- `app/globals.css` + Inter / Bricolage Grotesque / Space Grotesk CSS
|
||||||
|
variables + `.font-inter` wrapper.
|
||||||
|
- Dark canvas default and Figma breakpoints (`sm` 430, `md` 640, `lg` 1024,
|
||||||
|
`xl` 1440).
|
||||||
|
|
||||||
Do **not** add your own `MessagesProvider`, font wrapper, or token setup in a
|
Do **not** add your own `MessagesProvider`, font wrapper, or token setup in a
|
||||||
story. If you need a new global, update `preview.js`.
|
story. If you need a new global, update `preview.js`.
|
||||||
@@ -92,8 +98,8 @@ story. If you need a new global, update `preview.js`.
|
|||||||
# Interaction tests (`play`)
|
# Interaction tests (`play`)
|
||||||
|
|
||||||
Use `storybook/test` for interaction assertions — not `@testing-library/*`
|
Use `storybook/test` for interaction assertions — not `@testing-library/*`
|
||||||
directly. This matches `Checkbox.stories.js` and stays compatible with the
|
directly. This matches `Checkbox.stories.js`. Storybook is documentation;
|
||||||
Vitest portable-stories runner in `.storybook/vitest.setup.js`.
|
Vitest component tests remain the source of truth.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import { within, userEvent, expect } from "storybook/test";
|
import { within, userEvent, expect } from "storybook/test";
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ NEXT_PUBLIC_ENABLE_BACKEND_SYNC=
|
|||||||
# Optional: URL shown on /monitor when using external storage (Grafana, Kibana, vendor RUM, etc.).
|
# Optional: URL shown on /monitor when using external storage (Grafana, Kibana, vendor RUM, etc.).
|
||||||
# NEXT_PUBLIC_RUM_DASHBOARD_URL=
|
# NEXT_PUBLIC_RUM_DASHBOARD_URL=
|
||||||
|
|
||||||
|
# Search indexing. Unset: only communityrule.com / communityrule.info are crawlable
|
||||||
|
# (staging and local are disallowed in robots.txt). Set true/false to override.
|
||||||
|
# SITE_INDEXING=true
|
||||||
|
# SITE_INDEXING=false
|
||||||
|
|
||||||
# Writable directory for `POST /api/uploads` (community photo + custom-method attachments).
|
# Writable directory for `POST /api/uploads` (community photo + custom-method attachments).
|
||||||
# In production (e.g. Cloudron localstorage mount), set to the mounted path. Local dev example:
|
# In production (e.g. Cloudron localstorage mount), set to the mounted path. Local dev example:
|
||||||
# UPLOAD_ROOT="/absolute/path/to/community-rule/var/uploads"
|
# UPLOAD_ROOT="/absolute/path/to/community-rule/var/uploads"
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ npm-cache/
|
|||||||
!public/favicon-32x32.png
|
!public/favicon-32x32.png
|
||||||
!public/apple-touch-icon.png
|
!public/apple-touch-icon.png
|
||||||
|
|
||||||
|
# Per-article body ornaments (Figma Content page Template image fills)
|
||||||
|
!public/content/blog/*-ornament-*.png
|
||||||
|
|
||||||
|
# Marketing book cover (raster; see docs/guides/static-assets.md)
|
||||||
|
!public/assets/marketing/community-rules-cover.png
|
||||||
|
|
||||||
# Visual regression snapshots (allow these)
|
# Visual regression snapshots (allow these)
|
||||||
!tests/e2e/visual-regression.spec.ts-snapshots/
|
!tests/e2e/visual-regression.spec.ts-snapshots/
|
||||||
!tests/e2e/visual-regression.spec.ts-snapshots/*.png
|
!tests/e2e/visual-regression.spec.ts-snapshots/*.png
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
|
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Bricolage+Grotesque:wght@400;500;700;800&family=Space+Grotesk:wght@400;500;700&display=swap");
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--font-inter:
|
--font-inter:
|
||||||
"Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
"Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
||||||
"Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
|
"Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
|
||||||
|
--font-bricolage-grotesque:
|
||||||
|
"Bricolage Grotesque", ui-sans-serif, system-ui, -apple-system, "Segoe UI",
|
||||||
|
Roboto, "Helvetica Neue", Arial;
|
||||||
|
--font-space-grotesk:
|
||||||
|
"Space Grotesk", ui-sans-serif, system-ui, -apple-system, "Segoe UI",
|
||||||
|
Roboto, "Helvetica Neue", Arial;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-23
@@ -4,32 +4,10 @@ module.exports = {
|
|||||||
"../stories/**/*.mdx",
|
"../stories/**/*.mdx",
|
||||||
"../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)",
|
"../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)",
|
||||||
],
|
],
|
||||||
addons: [
|
addons: ["@storybook/addon-docs", "@storybook/addon-a11y"],
|
||||||
// Removed @storybook/addon-essentials due to version mismatch with Storybook 10.x
|
|
||||||
// Using individual addons instead. Interaction helpers import from storybook/test
|
|
||||||
// (bundled with storybook@10); @storybook/addon-interactions was merged into SB 8 core.
|
|
||||||
"@storybook/addon-a11y",
|
|
||||||
],
|
|
||||||
framework: {
|
framework: {
|
||||||
name: "@storybook/nextjs",
|
name: "@storybook/nextjs",
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
staticDirs: ["../public"],
|
staticDirs: ["../public"],
|
||||||
|
|
||||||
// Webpack configuration to resolve Next.js modules for Next.js 16 compatibility
|
|
||||||
async webpackFinal(config) {
|
|
||||||
// Ensure Next.js modules are resolved correctly
|
|
||||||
config.resolve = config.resolve || {};
|
|
||||||
config.resolve.alias = {
|
|
||||||
...(config.resolve.alias || {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ensure node_modules are resolved
|
|
||||||
config.resolve.modules = [
|
|
||||||
...(config.resolve.modules || []),
|
|
||||||
"node_modules",
|
|
||||||
];
|
|
||||||
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
+55
-3
@@ -1,10 +1,43 @@
|
|||||||
import "../app/globals.css";
|
import "../app/globals.css";
|
||||||
import "./fonts.css";
|
import "./fonts.css";
|
||||||
|
import { MINIMAL_VIEWPORTS } from "storybook/viewport";
|
||||||
|
import { AuthModalProvider } from "../app/contexts/AuthModalContext";
|
||||||
import { MessagesProvider } from "../app/contexts/MessagesContext";
|
import { MessagesProvider } from "../app/contexts/MessagesContext";
|
||||||
|
import { CreateFlowProvider } from "../app/(app)/create/context/CreateFlowContext";
|
||||||
import messages from "../messages/en/index";
|
import messages from "../messages/en/index";
|
||||||
|
|
||||||
|
/** Figma / Tailwind breakpoints from `app/tailwind.css`. */
|
||||||
|
const dsViewports = {
|
||||||
|
sm: {
|
||||||
|
name: "sm 430",
|
||||||
|
styles: { width: "430px", height: "932px" },
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
name: "md 640",
|
||||||
|
styles: { width: "640px", height: "1024px" },
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
name: "lg 1024",
|
||||||
|
styles: { width: "1024px", height: "768px" },
|
||||||
|
},
|
||||||
|
xl: {
|
||||||
|
name: "xl 1440",
|
||||||
|
styles: { width: "1440px", height: "900px" },
|
||||||
|
},
|
||||||
|
// Aliases used by existing page stories
|
||||||
|
mobile1: {
|
||||||
|
name: "sm 430",
|
||||||
|
styles: { width: "430px", height: "932px" },
|
||||||
|
},
|
||||||
|
desktop: {
|
||||||
|
name: "xl 1440",
|
||||||
|
styles: { width: "1440px", height: "900px" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/** @type { import('@storybook/react').Preview } */
|
/** @type { import('@storybook/react').Preview } */
|
||||||
const preview = {
|
const preview = {
|
||||||
|
tags: ["autodocs"],
|
||||||
parameters: {
|
parameters: {
|
||||||
controls: {
|
controls: {
|
||||||
matchers: {
|
matchers: {
|
||||||
@@ -12,13 +45,32 @@ const preview = {
|
|||||||
date: /Date$/i,
|
date: /Date$/i,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
backgrounds: {
|
||||||
|
options: {
|
||||||
|
dark: { name: "Dark", value: "#000000" },
|
||||||
|
light: { name: "Light", value: "#ffffff" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
viewport: {
|
||||||
|
options: {
|
||||||
|
...MINIMAL_VIEWPORTS,
|
||||||
|
...dsViewports,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
initialGlobals: {
|
||||||
|
backgrounds: { value: "dark" },
|
||||||
},
|
},
|
||||||
decorators: [
|
decorators: [
|
||||||
(Story) => (
|
(Story) => (
|
||||||
<MessagesProvider messages={messages}>
|
<MessagesProvider messages={messages}>
|
||||||
<div className="font-inter">
|
<AuthModalProvider>
|
||||||
<Story />
|
<CreateFlowProvider>
|
||||||
</div>
|
<div className="font-inter">
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
</CreateFlowProvider>
|
||||||
|
</AuthModalProvider>
|
||||||
</MessagesProvider>
|
</MessagesProvider>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
|
|
||||||
import { setProjectAnnotations } from "@storybook/nextjs-vite";
|
|
||||||
import * as projectAnnotations from "./preview";
|
|
||||||
|
|
||||||
// This is an important step to apply the right configuration when testing your stories.
|
|
||||||
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
|
|
||||||
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
|
|
||||||
@@ -46,6 +46,8 @@ priors — they reflect deliberate decisions.
|
|||||||
6. **No new pathname-sniffing chrome.** Compose chrome via group/nested
|
6. **No new pathname-sniffing chrome.** Compose chrome via group/nested
|
||||||
layouts, not `usePathname()` checks. (`ConditionalNavigation` is the
|
layouts, not `usePathname()` checks. (`ConditionalNavigation` is the
|
||||||
sole tolerated exception — it carries SSR session state.)
|
sole tolerated exception — it carries SSR session state.)
|
||||||
|
7. **No Linear ticket IDs in comments, commits, or PR copy.** Branch names
|
||||||
|
still carry `CR-<n>`. See `coding-guidelines.mdc`.
|
||||||
|
|
||||||
## Legacy / scaffolding
|
## Legacy / scaffolding
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"title": "Community Rule",
|
"title": "Community Rule",
|
||||||
"author": "MEDLab",
|
"author": "MEDLab",
|
||||||
"description": "Community governance and rule-building app",
|
"description": "Community governance and rule-building app",
|
||||||
"version": "0.1.9",
|
"version": "0.1.14",
|
||||||
"httpPort": 3000,
|
"httpPort": 3000,
|
||||||
"healthCheckPath": "/api/health",
|
"healthCheckPath": "/api/health",
|
||||||
"memoryLimit": 805306368,
|
"memoryLimit": 805306368,
|
||||||
|
|||||||
+16
-1
@@ -1,8 +1,16 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import { Suspense, type ReactNode } from "react";
|
import { Suspense, type ReactNode } from "react";
|
||||||
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
|
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
|
||||||
|
import SkipToContent from "../components/navigation/SkipToContent";
|
||||||
import { MessagesProvider } from "../contexts/MessagesContext";
|
import { MessagesProvider } from "../contexts/MessagesContext";
|
||||||
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
||||||
import messages from "../../messages/en/index";
|
import messages from "../../messages/en/index";
|
||||||
|
import { MAIN_CONTENT_ID } from "../../lib/mainContent";
|
||||||
|
import { NO_INDEX_ROBOTS } from "../../lib/siteMetadata";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
robots: NO_INDEX_ROBOTS,
|
||||||
|
};
|
||||||
|
|
||||||
// `force-dynamic` removed in favor of `experimental.cacheComponents` (Next 16).
|
// `force-dynamic` removed in favor of `experimental.cacheComponents` (Next 16).
|
||||||
// See `(app)/layout.tsx` for the matching `<Suspense fallback={null}>` rationale
|
// See `(app)/layout.tsx` for the matching `<Suspense fallback={null}>` rationale
|
||||||
@@ -14,10 +22,17 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<MessagesProvider messages={messages}>
|
<MessagesProvider messages={messages}>
|
||||||
<AuthModalProvider>
|
<AuthModalProvider>
|
||||||
|
<SkipToContent />
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<ConditionalNavigation />
|
<ConditionalNavigation />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<main className="flex-1">{children}</main>
|
<main
|
||||||
|
id={MAIN_CONTENT_ID}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="flex-1 outline-none"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
</AuthModalProvider>
|
</AuthModalProvider>
|
||||||
</MessagesProvider>
|
</MessagesProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import messages from "../../../messages/en/index";
|
||||||
|
import { routeMetadata } from "../../../lib/siteMetadata";
|
||||||
import MonitorPageContent from "./MonitorPageContent";
|
import MonitorPageContent from "./MonitorPageContent";
|
||||||
|
|
||||||
|
export const metadata: Metadata = routeMetadata("/monitor", {
|
||||||
|
title: messages.metadata.monitor.title,
|
||||||
|
});
|
||||||
|
|
||||||
export default function MonitorPage() {
|
export default function MonitorPage() {
|
||||||
return <MonitorPageContent />;
|
return <MonitorPageContent />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,15 +23,16 @@ import {
|
|||||||
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
|
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
|
||||||
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
|
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
|
||||||
getNextStep,
|
getNextStep,
|
||||||
getStepIndex,
|
|
||||||
parseReviewReturnSearchParam,
|
parseReviewReturnSearchParam,
|
||||||
|
shouldOfferCreateFlowSaveAndExit,
|
||||||
|
isDirectTemplateReviewEntry,
|
||||||
createFlowStepUsesSelectSplitScroll,
|
createFlowStepUsesSelectSplitScroll,
|
||||||
TEMPLATES_FACET_RECOMMEND_QUERY,
|
isCreateFlowTemplatesPickerPath,
|
||||||
TEMPLATES_FACET_RECOMMEND_VALUE,
|
|
||||||
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
|
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
|
||||||
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
|
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
|
||||||
} from "./utils/flowSteps";
|
} from "./utils/flowSteps";
|
||||||
import {
|
import {
|
||||||
|
CREATE_FLOW_COMMUNITY_SAVE_FORM_ID,
|
||||||
CREATE_FLOW_SYNC_DRAFT_QUERY,
|
CREATE_FLOW_SYNC_DRAFT_QUERY,
|
||||||
CREATE_FLOW_SYNC_DRAFT_VALUE,
|
CREATE_FLOW_SYNC_DRAFT_VALUE,
|
||||||
CREATE_ROUTES,
|
CREATE_ROUTES,
|
||||||
@@ -64,12 +65,23 @@ import {
|
|||||||
} from "../../../lib/create/publishedDocumentToCreateFlowState";
|
} from "../../../lib/create/publishedDocumentToCreateFlowState";
|
||||||
import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets";
|
import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets";
|
||||||
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
||||||
|
import {
|
||||||
|
clearPendingKeepRuleLogin,
|
||||||
|
hasPendingKeepRuleLogin,
|
||||||
|
} from "../../../lib/create/pendingKeepRuleLogin";
|
||||||
import { runCompletedStepExit } from "./utils/runCompletedStepExit";
|
import { runCompletedStepExit } from "./utils/runCompletedStepExit";
|
||||||
import messages from "../../../messages/en/index";
|
import messages from "../../../messages/en/index";
|
||||||
import {
|
import {
|
||||||
CREATE_FLOW_FOOTER_BUTTON_CLASS,
|
CREATE_FLOW_FOOTER_BUTTON_CLASS,
|
||||||
CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS,
|
CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS,
|
||||||
} from "./utils/createFlowFooterClassNames";
|
} from "./utils/createFlowFooterClassNames";
|
||||||
|
import {
|
||||||
|
CREATE_FLOW_MD_CENTERED_MAIN_CLASS,
|
||||||
|
CREATE_FLOW_PAGE_GUTTER_CLASS,
|
||||||
|
CREATE_FLOW_SCROLL_REGION_CLASS,
|
||||||
|
CREATE_FLOW_VIEWPORT_FRAME_CLASS,
|
||||||
|
getCreateFlowContentMaxClass,
|
||||||
|
} from "./components/createFlowLayoutTokens";
|
||||||
import {
|
import {
|
||||||
CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP,
|
CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP,
|
||||||
methodCardFacetSectionForConfirmStep,
|
methodCardFacetSectionForConfirmStep,
|
||||||
@@ -83,15 +95,13 @@ import { SignedInDraftHydration } from "./SignedInDraftHydration";
|
|||||||
import { CreateFlowPendingAvatarFlush } from "./components/CreateFlowPendingAvatarFlush";
|
import { CreateFlowPendingAvatarFlush } from "./components/CreateFlowPendingAvatarFlush";
|
||||||
import Alert from "../../components/modals/Alert";
|
import Alert from "../../components/modals/Alert";
|
||||||
import Create from "../../components/modals/Create";
|
import Create from "../../components/modals/Create";
|
||||||
|
import { MODAL_FOOTER_START_SLOT_CLASS } from "../../components/modals/ModalFooter";
|
||||||
import Share from "../../components/modals/Share";
|
import Share from "../../components/modals/Share";
|
||||||
import {
|
import {
|
||||||
CreateFlowDraftSaveBannerProvider,
|
CreateFlowDraftSaveBannerProvider,
|
||||||
useCreateFlowDraftSaveBanner,
|
useCreateFlowDraftSaveBanner,
|
||||||
} from "./context/CreateFlowDraftSaveBannerContext";
|
} from "./context/CreateFlowDraftSaveBannerContext";
|
||||||
|
|
||||||
/** First step where Save & Exit is offered (first Create Community select per Figma). */
|
|
||||||
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-structure");
|
|
||||||
|
|
||||||
function CreateFlowSessionShell({ children }: { children: ReactNode }) {
|
function CreateFlowSessionShell({ children }: { children: ReactNode }) {
|
||||||
const [sessionUser, setSessionUser] = useState<
|
const [sessionUser, setSessionUser] = useState<
|
||||||
{ id: string; email: string } | null | undefined
|
{ id: string; email: string } | null | undefined
|
||||||
@@ -109,9 +119,9 @@ function CreateFlowSessionShell({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const sessionResolved = sessionUser !== undefined;
|
const sessionResolved = sessionUser !== undefined;
|
||||||
// Mirror in-progress draft to localStorage for ALL visitors once we know who
|
// 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;
|
// 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
|
// Save & Exit: guests open the save-progress login modal; signed-in users
|
||||||
// the server (handled in `useCreateFlowExit`).
|
// PUT the server draft (`useCreateFlowExit`).
|
||||||
const enableLocalDraftMirroring = sessionResolved;
|
const enableLocalDraftMirroring = sessionResolved;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -152,9 +162,9 @@ function CreateFlowLayoutContent({
|
|||||||
const {
|
const {
|
||||||
currentStep,
|
currentStep,
|
||||||
nextStep,
|
nextStep,
|
||||||
previousStep,
|
|
||||||
goToNextStep,
|
goToNextStep,
|
||||||
goToPreviousStep,
|
goToPreviousStep,
|
||||||
|
canGoBack,
|
||||||
templateReviewFooterBackToCreateReview,
|
templateReviewFooterBackToCreateReview,
|
||||||
} = useCreateFlowNavigation(
|
} = useCreateFlowNavigation(
|
||||||
skipCommunitySave ? { skipCommunitySave: true } : undefined,
|
skipCommunitySave ? { skipCommunitySave: true } : undefined,
|
||||||
@@ -189,12 +199,14 @@ function CreateFlowLayoutContent({
|
|||||||
useState(false);
|
useState(false);
|
||||||
const [completedFlowBanner, setCompletedFlowBanner] = useState<{
|
const [completedFlowBanner, setCompletedFlowBanner] = useState<{
|
||||||
key: string;
|
key: string;
|
||||||
status: "positive" | "danger";
|
status: "positive" | "danger" | "warning";
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||||
const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false);
|
const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false);
|
||||||
|
const [completedHasPublishedRule, setCompletedHasPublishedRule] =
|
||||||
|
useState(false);
|
||||||
const leaveConfirmResolverRef = useRef<((proceed: boolean) => void) | null>(
|
const leaveConfirmResolverRef = useRef<((proceed: boolean) => void) | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -255,8 +267,30 @@ function CreateFlowLayoutContent({
|
|||||||
openLogin,
|
openLogin,
|
||||||
updateState,
|
updateState,
|
||||||
loginReturnPath,
|
loginReturnPath,
|
||||||
|
sessionUser,
|
||||||
|
onGuestPublished: ({ skippedInvites }) => {
|
||||||
|
const completedCopy = create.reviewAndComplete.completed;
|
||||||
|
if (skippedInvites) {
|
||||||
|
setCompletedFlowBanner({
|
||||||
|
key: "guestInvitesSkipped",
|
||||||
|
status: "warning",
|
||||||
|
title: completedCopy.guestInvitesSkippedTitle,
|
||||||
|
description: completedCopy.guestInvitesSkippedDescription,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setCompletedFlowBanner({
|
||||||
|
key: "guestClaimHint",
|
||||||
|
status: "warning",
|
||||||
|
title: completedCopy.guestClaimHintTitle,
|
||||||
|
description: completedCopy.guestClaimHintDescription,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fromCreateWizard =
|
||||||
|
searchParams.get(TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY) ===
|
||||||
|
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE;
|
||||||
const {
|
const {
|
||||||
isTemplateReviewRoute,
|
isTemplateReviewRoute,
|
||||||
templateReviewSlug,
|
templateReviewSlug,
|
||||||
@@ -271,7 +305,11 @@ function CreateFlowLayoutContent({
|
|||||||
updateState,
|
updateState,
|
||||||
replaceState,
|
replaceState,
|
||||||
router,
|
router,
|
||||||
|
fromCreateWizard,
|
||||||
|
markCreateFlowInteraction,
|
||||||
});
|
});
|
||||||
|
const isCreateTemplatesPickerRoute =
|
||||||
|
isCreateFlowTemplatesPickerPath(pathname);
|
||||||
|
|
||||||
const runAuthenticatedExit = useCreateFlowExit({
|
const runAuthenticatedExit = useCreateFlowExit({
|
||||||
state,
|
state,
|
||||||
@@ -284,14 +322,34 @@ function CreateFlowLayoutContent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleExit = async (opts?: { saveDraft?: boolean }) => {
|
const handleExit = async (opts?: { saveDraft?: boolean }) => {
|
||||||
const saveDraft = opts?.saveDraft ?? false;
|
// Direct template preview: browsing a catalog card, nothing collected yet.
|
||||||
|
// In-flow (`?fromFlow=1`) still has community progress — keep save / confirm.
|
||||||
|
if (isDirectTemplateReviewEntry(pathname, searchParams)) {
|
||||||
|
clearState();
|
||||||
|
router.push(CREATE_ROUTES.root);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!sessionResolved) return;
|
if (!sessionResolved) return;
|
||||||
|
|
||||||
// Exit from `/create/completed` is post-publish: the rule is saved, so we
|
// Completed is post-publish. Guests still need Save & Exit → keep-this-rule
|
||||||
// skip the leave-confirm + login prompt and just wipe the in-flight draft.
|
// login (the document is live but unclaimed). Signed-in users just leave.
|
||||||
// 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") {
|
if (currentStep === "completed") {
|
||||||
|
if (sessionUser === null) {
|
||||||
|
openLogin({
|
||||||
|
variant: "keepRule",
|
||||||
|
nextPath: CREATE_ROUTES.completed,
|
||||||
|
backdropVariant: "blurredYellow",
|
||||||
|
onDismiss: () => {
|
||||||
|
runCompletedStepExit({
|
||||||
|
clearState,
|
||||||
|
clearAnonymousCreateFlowStorage,
|
||||||
|
router,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
runCompletedStepExit({
|
runCompletedStepExit({
|
||||||
clearState,
|
clearState,
|
||||||
clearAnonymousCreateFlowStorage,
|
clearAnonymousCreateFlowStorage,
|
||||||
@@ -301,7 +359,6 @@ function CreateFlowLayoutContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sessionUser === null) {
|
if (sessionUser === null) {
|
||||||
if (saveDraft) return;
|
|
||||||
const returnToTemplateReview =
|
const returnToTemplateReview =
|
||||||
templateReviewSlug != null
|
templateReviewSlug != null
|
||||||
? `/create/review-template/${encodeURIComponent(templateReviewSlug)}?syncDraft=1`
|
? `/create/review-template/${encodeURIComponent(templateReviewSlug)}?syncDraft=1`
|
||||||
@@ -407,15 +464,54 @@ function CreateFlowLayoutContent({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentStep !== "completed") {
|
if (currentStep !== "completed") {
|
||||||
setCompletedFlowBanner(null);
|
setCompletedFlowBanner(null);
|
||||||
|
setCompletedHasPublishedRule(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setCompletedHasPublishedRule(Boolean(readLastPublishedRule()));
|
||||||
}, [currentStep]);
|
}, [currentStep]);
|
||||||
|
|
||||||
const handleCommunitySaveMagicLinkSubmit = useCallback(async () => {
|
useEffect(() => {
|
||||||
|
if (currentStep !== "completed") return;
|
||||||
|
if (!sessionResolved) return;
|
||||||
|
if (sessionUser !== null) {
|
||||||
|
clearPendingKeepRuleLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasPendingKeepRuleLogin()) return;
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
if (!hasPendingKeepRuleLogin()) return;
|
||||||
|
clearPendingKeepRuleLogin();
|
||||||
|
openLogin({
|
||||||
|
variant: "keepRule",
|
||||||
|
nextPath: CREATE_ROUTES.completed,
|
||||||
|
backdropVariant: "blurredYellow",
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [currentStep, sessionResolved, sessionUser, openLogin]);
|
||||||
|
|
||||||
|
const handleCommunitySaveMagicLinkSubmit = useCallback(async (
|
||||||
|
formEl?: HTMLFormElement | null,
|
||||||
|
) => {
|
||||||
setCommunitySaveMagicLinkError(null);
|
setCommunitySaveMagicLinkError(null);
|
||||||
setCommunitySaveMagicLinkSuccess(false);
|
setCommunitySaveMagicLinkSuccess(false);
|
||||||
const raw = state.communitySaveEmail;
|
const namedEmail = formEl?.elements.namedItem("email");
|
||||||
const trimmed = typeof raw === "string" ? raw.trim().toLowerCase() : "";
|
const emailField =
|
||||||
if (!isValidCreateFlowSaveEmail(trimmed)) return;
|
namedEmail instanceof HTMLInputElement
|
||||||
|
? namedEmail
|
||||||
|
: typeof document === "undefined"
|
||||||
|
? null
|
||||||
|
: document.querySelector<HTMLInputElement>(
|
||||||
|
`input[name="email"][form="${CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}"]`,
|
||||||
|
);
|
||||||
|
if (emailField != null && !emailField.checkValidity()) {
|
||||||
|
emailField.reportValidity();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw =
|
||||||
|
emailField != null ? emailField.value : state.communitySaveEmail;
|
||||||
|
if (typeof raw !== "string" || !isValidCreateFlowSaveEmail(raw)) return;
|
||||||
|
const trimmed = raw.trim().toLowerCase();
|
||||||
|
|
||||||
setCommunitySaveMagicLinkSubmitting(true);
|
setCommunitySaveMagicLinkSubmitting(true);
|
||||||
try {
|
try {
|
||||||
@@ -465,16 +561,17 @@ function CreateFlowLayoutContent({
|
|||||||
const isSelectSplitScrollStep = createFlowStepUsesSelectSplitScroll(
|
const isSelectSplitScrollStep = createFlowStepUsesSelectSplitScroll(
|
||||||
currentStep,
|
currentStep,
|
||||||
);
|
);
|
||||||
const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
|
|
||||||
|
|
||||||
/** At `md+`, main cross-axis: center by default; exceptions stay top-aligned (see product spec). */
|
/** Lockup+card / card-stack: `items-start` + shell `my-auto` so overflow scrolls from the top. */
|
||||||
const mainContentClass = isCompletedStep
|
const mainContentClass = isCompletedStep
|
||||||
? "items-stretch overflow-y-auto md:overflow-hidden"
|
? "items-stretch overflow-y-auto md:overflow-hidden"
|
||||||
: isSelectSplitScrollStep
|
: isSelectSplitScrollStep
|
||||||
? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden"
|
? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden"
|
||||||
: isFinalReviewLike || isCardLayoutStep || isTemplateReviewRoute
|
: isCreateTemplatesPickerRoute
|
||||||
? "items-start justify-center overflow-y-auto"
|
? "items-start justify-start overflow-y-auto"
|
||||||
: "items-start justify-center overflow-y-auto md:items-center";
|
: isTemplateReviewRoute || isFinalReviewLike || isCardLayoutStep
|
||||||
|
? CREATE_FLOW_MD_CENTERED_MAIN_CLASS
|
||||||
|
: "items-start justify-center overflow-y-auto md:items-center";
|
||||||
|
|
||||||
const isTextStep = createFlowStepUsesCenteredTextLayout(currentStep);
|
const isTextStep = createFlowStepUsesCenteredTextLayout(currentStep);
|
||||||
const mainMaxMdJustify =
|
const mainMaxMdJustify =
|
||||||
@@ -486,8 +583,8 @@ function CreateFlowLayoutContent({
|
|||||||
: "max-md:flex-col max-md:items-center";
|
: "max-md:flex-col max-md:items-center";
|
||||||
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
|
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
|
||||||
const saveDraftOnExit =
|
const saveDraftOnExit =
|
||||||
Boolean(sessionUser) &&
|
shouldOfferCreateFlowSaveAndExit(currentStep, sessionUser) ||
|
||||||
(stepIdx >= SAVE_EXIT_FROM_STEP_INDEX || currentStep === "edit-rule");
|
isCreateTemplatesPickerRoute;
|
||||||
|
|
||||||
const proportionBarProgress = getProportionBarProgressForCreateFlowStep(
|
const proportionBarProgress = getProportionBarProgressForCreateFlowStep(
|
||||||
currentStep,
|
currentStep,
|
||||||
@@ -516,7 +613,7 @@ function CreateFlowLayoutContent({
|
|||||||
*/
|
*/
|
||||||
const topBanners: Array<{
|
const topBanners: Array<{
|
||||||
key: string;
|
key: string;
|
||||||
status: "danger" | "positive";
|
status: "danger" | "positive" | "warning";
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -535,7 +632,7 @@ function CreateFlowLayoutContent({
|
|||||||
key: "publish",
|
key: "publish",
|
||||||
status: "danger" as const,
|
status: "danger" as const,
|
||||||
title:
|
title:
|
||||||
messages.create.reviewAndComplete.publish.finalizeBannerTitle,
|
messages.create.reviewAndComplete.publish.publishBannerTitle,
|
||||||
description: publishBannerMessage,
|
description: publishBannerMessage,
|
||||||
onClose: () => setPublishBannerMessage(null),
|
onClose: () => setPublishBannerMessage(null),
|
||||||
}
|
}
|
||||||
@@ -579,10 +676,10 @@ function CreateFlowLayoutContent({
|
|||||||
].filter((b): b is NonNullable<typeof b> => b !== null);
|
].filter((b): b is NonNullable<typeof b> => b !== null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-screen min-h-0 flex-col overflow-hidden bg-black">
|
<div className={`${CREATE_FLOW_VIEWPORT_FRAME_CLASS} bg-black`}>
|
||||||
{topBanners.length > 0 ? (
|
{topBanners.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none fixed left-0 right-0 top-0 z-[200] flex flex-col gap-2 px-[var(--spacing-measures-spacing-500,20px)] pt-[var(--spacing-measures-spacing-300,12px)] md:px-[var(--measures-spacing-1800,64px)]"
|
className={`pointer-events-none fixed left-0 right-0 top-0 z-[200] flex flex-col gap-2 ${CREATE_FLOW_PAGE_GUTTER_CLASS} pt-[var(--spacing-measures-spacing-300,12px)]`}
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
{topBanners.map((b) => (
|
{topBanners.map((b) => (
|
||||||
@@ -636,14 +733,16 @@ function CreateFlowLayoutContent({
|
|||||||
nextButtonText={messages.create.topNav.leaveConfirmProceed}
|
nextButtonText={messages.create.topNav.leaveConfirmProceed}
|
||||||
onNext={() => closeLeaveConfirm(true)}
|
onNext={() => closeLeaveConfirm(true)}
|
||||||
footerContent={
|
footerContent={
|
||||||
<Button
|
<div className={MODAL_FOOTER_START_SLOT_CLASS}>
|
||||||
buttonType="ghost"
|
<Button
|
||||||
palette="default"
|
buttonType="ghost"
|
||||||
size="xsmall"
|
palette="default"
|
||||||
onClick={() => closeLeaveConfirm(false)}
|
size="xsmall"
|
||||||
>
|
onClick={() => closeLeaveConfirm(false)}
|
||||||
{messages.create.topNav.leaveConfirmCancel}
|
>
|
||||||
</Button>
|
{messages.create.topNav.leaveConfirmCancel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
backdropVariant="blurredYellow"
|
backdropVariant="blurredYellow"
|
||||||
ariaLabel={messages.create.topNav.leaveConfirmTitle}
|
ariaLabel={messages.create.topNav.leaveConfirmTitle}
|
||||||
@@ -651,7 +750,10 @@ function CreateFlowLayoutContent({
|
|||||||
<CreateFlowTopNav
|
<CreateFlowTopNav
|
||||||
hasShare={isCompletedStep}
|
hasShare={isCompletedStep}
|
||||||
hasExport={isCompletedStep}
|
hasExport={isCompletedStep}
|
||||||
hasEdit={isCompletedStep}
|
hasEdit={
|
||||||
|
isCompletedStep &&
|
||||||
|
(Boolean(sessionUser) || completedHasPublishedRule)
|
||||||
|
}
|
||||||
hasManageStakeholders={isEditRuleStep}
|
hasManageStakeholders={isEditRuleStep}
|
||||||
saveDraftOnExit={saveDraftOnExit}
|
saveDraftOnExit={saveDraftOnExit}
|
||||||
onShare={
|
onShare={
|
||||||
@@ -693,16 +795,32 @@ function CreateFlowLayoutContent({
|
|||||||
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : ""
|
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : ""
|
||||||
}`.trim()}
|
}`.trim()}
|
||||||
/>
|
/>
|
||||||
|
{currentStep === "community-save" ? (
|
||||||
|
<form
|
||||||
|
id={CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}
|
||||||
|
className="hidden"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleCommunitySaveMagicLinkSubmit(e.currentTarget);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<main
|
<main
|
||||||
className={`flex min-h-0 min-w-0 flex-1 w-full max-w-full overflow-x-hidden ${mainContentClass} ${mainResponsiveLayout}`}
|
className={`flex min-h-0 min-w-0 flex-1 w-full max-w-full overflow-x-hidden ${CREATE_FLOW_SCROLL_REGION_CLASS} ${mainContentClass} ${mainResponsiveLayout}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
{!isCompletedStep && (
|
{!isCompletedStep && (
|
||||||
<CreateFlowFooter
|
<CreateFlowFooter
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
|
contentMaxClass={getCreateFlowContentMaxClass({
|
||||||
|
step: currentStep,
|
||||||
|
isTemplateReview: isTemplateReviewRoute,
|
||||||
|
isTemplatesPicker: isCreateTemplatesPickerRoute,
|
||||||
|
})}
|
||||||
progressBar={
|
progressBar={
|
||||||
!isTemplateReviewRoute &&
|
!isTemplateReviewRoute &&
|
||||||
|
!isCreateTemplatesPickerRoute &&
|
||||||
!isFinalReviewLike &&
|
!isFinalReviewLike &&
|
||||||
reviewReturnTarget !== "edit-rule"
|
reviewReturnTarget !== "edit-rule"
|
||||||
}
|
}
|
||||||
@@ -764,6 +882,8 @@ function CreateFlowLayoutContent({
|
|||||||
{footer.saveLater}
|
{footer.saveLater}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form={CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}
|
||||||
buttonType="filled"
|
buttonType="filled"
|
||||||
palette="default"
|
palette="default"
|
||||||
size="xsmall"
|
size="xsmall"
|
||||||
@@ -774,9 +894,6 @@ function CreateFlowLayoutContent({
|
|||||||
!isValidCreateFlowSaveEmail(state.communitySaveEmail)
|
!isValidCreateFlowSaveEmail(state.communitySaveEmail)
|
||||||
}
|
}
|
||||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
onClick={() => {
|
|
||||||
void handleCommunitySaveMagicLinkSubmit();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{communitySaveMagicLinkSubmitting
|
{communitySaveMagicLinkSubmitting
|
||||||
? footer.submitEmailSending
|
? footer.submitEmailSending
|
||||||
@@ -807,15 +924,7 @@ function CreateFlowLayoutContent({
|
|||||||
disabled={isPublishing}
|
disabled={isPublishing}
|
||||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// `fromFlow=1` tells `/templates` to skip the fresh-slate
|
router.push(CREATE_ROUTES.templatesPicker);
|
||||||
// draft clear it normally runs on template click, so the
|
|
||||||
// user's in-progress Create Community stage survives this
|
|
||||||
// detour. Direct entries to `/templates` (no marker) and
|
|
||||||
// home "Popular templates" clicks always start fresh by
|
|
||||||
// wiping anonymous draft storage at click time.
|
|
||||||
router.push(
|
|
||||||
`/templates?${TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY}=${TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE}&${TEMPLATES_FACET_RECOMMEND_QUERY}=${TEMPLATES_FACET_RECOMMEND_VALUE}`,
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{footer.createFromTemplate}
|
{footer.createFromTemplate}
|
||||||
@@ -878,7 +987,9 @@ function CreateFlowLayoutContent({
|
|||||||
buttonType="filled"
|
buttonType="filled"
|
||||||
palette="default"
|
palette="default"
|
||||||
size="xsmall"
|
size="xsmall"
|
||||||
disabled={isPublishing}
|
disabled={
|
||||||
|
isPublishing || (isFinalReviewLike && !sessionResolved)
|
||||||
|
}
|
||||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isFinalReviewLike) {
|
if (isFinalReviewLike) {
|
||||||
@@ -891,14 +1002,18 @@ function CreateFlowLayoutContent({
|
|||||||
{isFinalReviewLike
|
{isFinalReviewLike
|
||||||
? isPublishing
|
? isPublishing
|
||||||
? messages.create.reviewAndComplete.publish
|
? messages.create.reviewAndComplete.publish
|
||||||
.finalizeButtonPublishing
|
.publishButtonPublishing
|
||||||
: footer.finalizeCommunityRule
|
: footer.publishCommunityRule
|
||||||
: getDefaultFooterLabel(currentStep, footer)}
|
: getDefaultFooterLabel(currentStep, footer)}
|
||||||
</Button>
|
</Button>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
onBackClick={
|
onBackClick={
|
||||||
isTemplateReviewRoute
|
isCreateTemplatesPickerRoute
|
||||||
|
? () => {
|
||||||
|
router.push(CREATE_ROUTES.review);
|
||||||
|
}
|
||||||
|
: isTemplateReviewRoute
|
||||||
? () =>
|
? () =>
|
||||||
router.push(
|
router.push(
|
||||||
templateReviewFooterBackToCreateReview
|
templateReviewFooterBackToCreateReview
|
||||||
@@ -914,7 +1029,7 @@ function CreateFlowLayoutContent({
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
: previousStep
|
: canGoBack()
|
||||||
? goToPreviousStep
|
? goToPreviousStep
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import {
|
|||||||
import { useCreateFlow } from "./context/CreateFlowContext";
|
import { useCreateFlow } from "./context/CreateFlowContext";
|
||||||
import { fetchDraftFromServer } from "../../../lib/create/api";
|
import { fetchDraftFromServer } from "../../../lib/create/api";
|
||||||
import messages from "../../../messages/en/index";
|
import messages from "../../../messages/en/index";
|
||||||
|
import { CREATE_FLOW_PAGE_GUTTER_CLASS } from "./components/createFlowLayoutTokens";
|
||||||
import Alert from "../../components/modals/Alert";
|
import Alert from "../../components/modals/Alert";
|
||||||
import {
|
import {
|
||||||
isValidStep,
|
isValidStep,
|
||||||
|
isCreateFlowTemplatesPickerPath,
|
||||||
parseCreateFlowScreenFromPathname,
|
parseCreateFlowScreenFromPathname,
|
||||||
} from "./utils/flowSteps";
|
} from "./utils/flowSteps";
|
||||||
import { hasFreshEntryPending } from "./utils/prepareFreshCreateFlowEntry";
|
import { hasFreshEntryPending } from "./utils/prepareFreshCreateFlowEntry";
|
||||||
@@ -102,6 +104,21 @@ export function SignedInDraftHydration({
|
|||||||
if (urlStep === "completed") {
|
if (urlStep === "completed") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Direct template preview is a new-rule entry, not “continue draft”.
|
||||||
|
* Applying a saved `currentStep` here is what yanked Use without changes
|
||||||
|
* past name / description / photo to stakeholders.
|
||||||
|
*/
|
||||||
|
if (pathname?.includes("/create/review-template/")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isCreateFlowTemplatesPickerPath(pathname)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (touchedRef.current) {
|
||||||
|
finishedUserIdRef.current = userId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoadingHydration(true);
|
setLoadingHydration(true);
|
||||||
@@ -149,7 +166,7 @@ export function SignedInDraftHydration({
|
|||||||
if (!loadingHydration) return null;
|
if (!loadingHydration) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed left-0 right-0 top-14 z-[170] flex justify-center px-[var(--spacing-measures-spacing-500,20px)] pt-2 md:top-16 md:px-[var(--measures-spacing-1800,64px)]">
|
<div className={`pointer-events-none fixed left-0 right-0 top-14 z-[170] flex justify-center ${CREATE_FLOW_PAGE_GUTTER_CLASS} pt-2 md:top-16`}>
|
||||||
<div className="pointer-events-auto w-full max-w-[960px]">
|
<div className="pointer-events-auto w-full max-w-[960px]">
|
||||||
<Alert
|
<Alert
|
||||||
type="banner"
|
type="banner"
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { CreateFlowScreenView } from "../screens/CreateFlowScreenView";
|
import { CreateFlowScreenView } from "../screens/CreateFlowScreenView";
|
||||||
|
import { createFlowStepPath, CREATE_ROUTES } from "../utils/createFlowPaths";
|
||||||
import { isValidStep } from "../utils/flowSteps";
|
import { isValidStep } from "../utils/flowSteps";
|
||||||
import type { CreateFlowStep } from "../types";
|
import type { CreateFlowStep } from "../types";
|
||||||
|
import { routeMetadata } from "../../../../lib/siteMetadata";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single dynamic route for the whole create wizard (every step in `FLOW_STEP_ORDER`).
|
* Single dynamic route for the whole create wizard (every step in `FLOW_STEP_ORDER`).
|
||||||
@@ -14,6 +17,14 @@ interface PageProps {
|
|||||||
params: Promise<{ screenId: string }>;
|
params: Promise<{ screenId: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||||
|
const { screenId: raw } = await params;
|
||||||
|
const path = isValidStep(raw)
|
||||||
|
? createFlowStepPath(raw)
|
||||||
|
: `${CREATE_ROUTES.createRoot}/${raw}`;
|
||||||
|
return routeMetadata(path);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function CreateFlowScreenPage({ params }: PageProps) {
|
export default async function CreateFlowScreenPage({ params }: PageProps) {
|
||||||
const { screenId: raw } = await params;
|
const { screenId: raw } = await params;
|
||||||
|
|
||||||
|
|||||||
@@ -70,20 +70,26 @@ function ApplicableScopeFieldComponent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col gap-2 ${className}`.trim()}>
|
<div className={`flex flex-col gap-2 ${className}`.trim()}>
|
||||||
<InputLabel label={label} helpIcon size="s" palette="default" />
|
<InputLabel label={label} size="s" palette="default" />
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{scopes.map((scope) => {
|
{scopes.map((scope) => {
|
||||||
const isSelected = selectedScopes.includes(scope);
|
const isSelected = selectedScopes.includes(scope);
|
||||||
|
const chipState = isSelected
|
||||||
|
? "selected"
|
||||||
|
: readOnly
|
||||||
|
? "disabled"
|
||||||
|
: "unselected";
|
||||||
return (
|
return (
|
||||||
<Chip
|
<Chip
|
||||||
key={scope}
|
key={scope}
|
||||||
label={scope}
|
label={scope}
|
||||||
state={isSelected ? "selected" : "disabled"}
|
state={chipState}
|
||||||
palette="default"
|
palette="default"
|
||||||
size="s"
|
size="s"
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
onClick={() => !readOnly && onToggleScope(scope)}
|
onClick={() => !readOnly && onToggleScope(scope)}
|
||||||
ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`}
|
ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`}
|
||||||
|
className="max-w-full"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -105,13 +111,13 @@ function ApplicableScopeFieldComponent({
|
|||||||
}}
|
}}
|
||||||
placeholder={inputPlaceholder ?? addLabel}
|
placeholder={inputPlaceholder ?? addLabel}
|
||||||
aria-label={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)]"
|
className="h-[30px] rounded-[9999px] border border-[var(--color-border-default-tertiary)] bg-transparent px-3 text-x-small-label 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsAdding(true)}
|
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"
|
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)] text-x-small-label 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 />
|
<AddGlyph />
|
||||||
{addLabel}
|
{addLabel}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { CreateFlowHeaderLockup } from "./CreateFlowHeaderLockup";
|
||||||
|
import { CreateFlowStepShell } from "./CreateFlowStepShell";
|
||||||
|
import {
|
||||||
|
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
|
||||||
|
CREATE_FLOW_MD_CENTERED_SHELL_CLASS,
|
||||||
|
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
|
||||||
|
CREATE_FLOW_WIDE_MAX_CLASS,
|
||||||
|
} from "./createFlowLayoutTokens";
|
||||||
|
|
||||||
|
type CreateFlowCardStackStepShellProps = {
|
||||||
|
lockupTitle: string;
|
||||||
|
lockupDescription?: ReactNode;
|
||||||
|
/** When true, the stack region exposes `aria-busy` while recommendations load. */
|
||||||
|
stackBusy?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centered lockup + card-stack layout shared by communication, membership, and
|
||||||
|
* conflict-management steps (Figma compact card stack).
|
||||||
|
*/
|
||||||
|
export function CreateFlowCardStackStepShell({
|
||||||
|
lockupTitle,
|
||||||
|
lockupDescription,
|
||||||
|
stackBusy = false,
|
||||||
|
children,
|
||||||
|
}: CreateFlowCardStackStepShellProps) {
|
||||||
|
return (
|
||||||
|
<CreateFlowStepShell
|
||||||
|
variant="wideGridLoosePadding"
|
||||||
|
contentTopBelowMd="space-800"
|
||||||
|
className={CREATE_FLOW_MD_CENTERED_SHELL_CLASS}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex w-full min-w-0 flex-col items-center gap-6 ${CREATE_FLOW_WIDE_MAX_CLASS}`}
|
||||||
|
>
|
||||||
|
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
|
||||||
|
<CreateFlowHeaderLockup
|
||||||
|
title={lockupTitle}
|
||||||
|
description={lockupDescription}
|
||||||
|
justification="center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
|
||||||
|
aria-busy={stackBusy}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CreateFlowStepShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { ReactNode } from "react";
|
|||||||
import { CreateFlowHeaderLockup } from "./CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "./CreateFlowHeaderLockup";
|
||||||
import { CreateFlowStepShell } from "./CreateFlowStepShell";
|
import { CreateFlowStepShell } from "./CreateFlowStepShell";
|
||||||
import {
|
import {
|
||||||
|
CREATE_FLOW_MD_CENTERED_SHELL_CLASS,
|
||||||
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
|
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
|
||||||
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
||||||
} from "./createFlowLayoutTokens";
|
} from "./createFlowLayoutTokens";
|
||||||
@@ -18,14 +19,18 @@ type CreateFlowLockupCardStepShellProps = {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Final-review layout: `wideGrid`, two columns from `md:`, column widths from `createFlowLayoutTokens`. */
|
/** Lockup + card: `wideGrid`, two columns from `md:`, centered in the nav–footer band from `md`. */
|
||||||
export function CreateFlowLockupCardStepShell({
|
export function CreateFlowLockupCardStepShell({
|
||||||
lockupTitle,
|
lockupTitle,
|
||||||
lockupDescription,
|
lockupDescription,
|
||||||
children,
|
children,
|
||||||
}: CreateFlowLockupCardStepShellProps) {
|
}: CreateFlowLockupCardStepShellProps) {
|
||||||
return (
|
return (
|
||||||
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
|
<CreateFlowStepShell
|
||||||
|
variant="wideGrid"
|
||||||
|
contentTopBelowMd="space-800"
|
||||||
|
className={CREATE_FLOW_MD_CENTERED_SHELL_CLASS}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={`mx-auto flex w-full min-w-0 flex-col gap-4 md:grid md:w-full md:grid-cols-2 md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
|
className={`mx-auto flex w-full min-w-0 flex-col gap-4 md:grid md:w-full md:grid-cols-2 md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||||
import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer";
|
import {
|
||||||
|
CreateFlowUploadValidationError,
|
||||||
|
uploadCreateFlowFile,
|
||||||
|
} from "../../../../lib/create/uploadToServer";
|
||||||
import {
|
import {
|
||||||
clearPendingCommunityAvatarFile,
|
clearPendingCommunityAvatarFile,
|
||||||
readPendingCommunityAvatarFile,
|
readPendingCommunityAvatarFile,
|
||||||
@@ -19,13 +22,17 @@ export function CreateFlowPendingAvatarFlush({
|
|||||||
sessionUser: { id: string; email: string } | null | undefined;
|
sessionUser: { id: string; email: string } | null | undefined;
|
||||||
sessionResolved: boolean;
|
sessionResolved: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { updateState } = useCreateFlow();
|
const { state, updateState } = useCreateFlow();
|
||||||
/** One successful flush per signed-in user id (survives React StrictMode remounts). */
|
/** One successful flush per signed-in user id (survives React StrictMode remounts). */
|
||||||
const lastFlushedUserIdRef = useRef<string | null>(null);
|
const lastFlushedUserIdRef = useRef<string | null>(null);
|
||||||
|
const hasServerAvatar =
|
||||||
|
typeof state.communityAvatarUrl === "string" &&
|
||||||
|
state.communityAvatarUrl.trim().length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionResolved || !sessionUser) return;
|
if (!sessionResolved || !sessionUser) return;
|
||||||
if (lastFlushedUserIdRef.current === sessionUser.id) return;
|
if (lastFlushedUserIdRef.current === sessionUser.id) return;
|
||||||
|
if (hasServerAvatar) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -37,15 +44,18 @@ export function CreateFlowPendingAvatarFlush({
|
|||||||
await clearPendingCommunityAvatarFile();
|
await clearPendingCommunityAvatarFile();
|
||||||
updateState({ communityAvatarUrl: url });
|
updateState({ communityAvatarUrl: url });
|
||||||
lastFlushedUserIdRef.current = sessionUser.id;
|
lastFlushedUserIdRef.current = sessionUser.id;
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Leave pending blob in place so the user can retry after fixing auth / UPLOAD_ROOT.
|
if (err instanceof CreateFlowUploadValidationError) {
|
||||||
|
await clearPendingCommunityAvatarFile();
|
||||||
|
}
|
||||||
|
// Leave a transient (auth / UPLOAD_ROOT) failure in place to retry.
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [sessionResolved, sessionUser, updateState]);
|
}, [hasServerAvatar, sessionResolved, sessionUser, updateState]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { CREATE_FLOW_PAGE_GUTTER_CLASS } from "./createFlowLayoutTokens";
|
||||||
|
|
||||||
export type CreateFlowStepShellVariant =
|
export type CreateFlowStepShellVariant =
|
||||||
| "centeredNarrow"
|
| "centeredNarrow"
|
||||||
@@ -13,15 +14,12 @@ export type CreateFlowStepShellVariant =
|
|||||||
export type CreateFlowContentTopBelowMd = "none" | "space-1400" | "space-800";
|
export type CreateFlowContentTopBelowMd = "none" | "space-1400" | "space-800";
|
||||||
|
|
||||||
const outerByVariant: Record<CreateFlowStepShellVariant, string> = {
|
const outerByVariant: Record<CreateFlowStepShellVariant, string> = {
|
||||||
centeredNarrow:
|
centeredNarrow: `flex w-full min-w-0 flex-col items-center ${CREATE_FLOW_PAGE_GUTTER_CLASS}`,
|
||||||
"flex w-full min-w-0 flex-col items-center px-5 md:px-16",
|
centeredNarrowBottomPad: `flex w-full min-w-0 flex-col items-center ${CREATE_FLOW_PAGE_GUTTER_CLASS} pb-28 md:pb-32`,
|
||||||
centeredNarrowBottomPad:
|
/** Wide two-column steps; inner content supplies the 1328px cap. */
|
||||||
"flex w-full min-w-0 flex-col items-center px-5 pb-28 md:px-[var(--measures-spacing-1800,64px)] md:pb-32",
|
wideGrid: `flex w-full min-w-0 shrink-0 flex-col items-center ${CREATE_FLOW_PAGE_GUTTER_CLASS}`,
|
||||||
/** Wide two-column steps; 1328px = two 640px columns + 48px gutter. */
|
/** Create Community review + card grid; inner content supplies the 1440px cap. */
|
||||||
wideGrid: "w-full min-w-0 max-w-[1328px] shrink-0 px-5 md:px-12",
|
wideGridLoosePadding: `flex w-full min-w-0 shrink-0 flex-col items-center ${CREATE_FLOW_PAGE_GUTTER_CLASS}`,
|
||||||
/** Create Community review + card grid (Figma Flow — Review `19706:12135`): max width 1440. */
|
|
||||||
wideGridLoosePadding:
|
|
||||||
"w-full min-w-0 max-w-[1440px] shrink-0 px-5 md:px-16",
|
|
||||||
bare: "w-full min-w-0",
|
bare: "w-full min-w-0",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,7 +39,8 @@ interface CreateFlowStepShellProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared horizontal padding and width constraints for create-flow step pages.
|
* Shared horizontal padding and width constraints for create-flow step pages.
|
||||||
* Horizontal padding uses Tailwind `md:` so it tracks `--breakpoint-md` (640px in `app/tailwind.css`).
|
* Gutters come from {@link CREATE_FLOW_PAGE_GUTTER_CLASS} (`md` / `lg` track
|
||||||
|
* `--breakpoint-md` and `--breakpoint-lg` in `app/tailwind.css`).
|
||||||
*/
|
*/
|
||||||
export function CreateFlowStepShell({
|
export function CreateFlowStepShell({
|
||||||
children,
|
children,
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import {
|
|||||||
CreateFlowStepShell,
|
CreateFlowStepShell,
|
||||||
type CreateFlowContentTopBelowMd,
|
type CreateFlowContentTopBelowMd,
|
||||||
} from "./CreateFlowStepShell";
|
} from "./CreateFlowStepShell";
|
||||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "./createFlowLayoutTokens";
|
import {
|
||||||
|
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
|
||||||
|
CREATE_FLOW_SCROLL_REGION_CLASS,
|
||||||
|
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
||||||
|
} from "./createFlowLayoutTokens";
|
||||||
|
|
||||||
export type CreateFlowSelectShellLgVerticalAlign = "center" | "start";
|
export type CreateFlowSelectShellLgVerticalAlign = "center" | "start";
|
||||||
|
|
||||||
@@ -26,10 +30,10 @@ interface CreateFlowTwoColumnSelectShellProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Two-column layout for create-flow select steps (community size/structure, core values) and
|
* Question-and-options layout for create-flow select steps (community size/structure,
|
||||||
* {@link DecisionApproachesScreen} (decision approaches). Below `lg` (1024px), one column + main scrolls.
|
* core values, stakeholders) and {@link DecisionApproachesScreen}.
|
||||||
* At `lg+`, mirrors {@link CompletedScreen}: static header column + scrollable controls column
|
* Below `lg` (1024px), one column uses available width and main scrolls.
|
||||||
* (`min-h-0` + `overflow-y-auto` height chain; see completed page right rail).
|
* At `lg+`, static header column + scrollable options column (`min-h-0` + overflow).
|
||||||
*/
|
*/
|
||||||
export function CreateFlowTwoColumnSelectShell({
|
export function CreateFlowTwoColumnSelectShell({
|
||||||
header,
|
header,
|
||||||
@@ -56,24 +60,24 @@ export function CreateFlowTwoColumnSelectShell({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
"flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] md:max-w-[640px] " +
|
"flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] " +
|
||||||
"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 " +
|
"max-lg:flex-none lg:max-h-full 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`
|
`${rowLgCrossAlignClass} lg:justify-center lg:gap-[var(--measures-spacing-1200,48px)] lg:overflow-hidden ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
`flex w-full min-w-0 shrink-0 flex-col items-start gap-[var(--measures-spacing-200,8px)] ` +
|
`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}`
|
`lg:flex-1 ${leftLgMainJustifyClass} lg:py-[12px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{header}
|
{header}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
`scrollbar-hide relative flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-800,32px)] ` +
|
`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)] ` +
|
`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
|
`${CREATE_FLOW_SCROLL_REGION_CLASS} ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
+1
-2
@@ -60,7 +60,6 @@ function CustomMethodCardFieldBlocksSummaryViewComponent({
|
|||||||
<div key={block.id} className="flex flex-col gap-2">
|
<div key={block.id} className="flex flex-col gap-2">
|
||||||
<InputLabel
|
<InputLabel
|
||||||
label={block.blockTitle}
|
label={block.blockTitle}
|
||||||
helpIcon
|
|
||||||
size="s"
|
size="s"
|
||||||
palette="default"
|
palette="default"
|
||||||
/>
|
/>
|
||||||
@@ -123,7 +122,6 @@ function CustomMethodCardFieldBlocksSummaryViewComponent({
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<InputLabel
|
<InputLabel
|
||||||
label={block.blockTitle}
|
label={block.blockTitle}
|
||||||
helpIcon
|
|
||||||
size="s"
|
size="s"
|
||||||
palette="default"
|
palette="default"
|
||||||
/>
|
/>
|
||||||
@@ -169,6 +167,7 @@ function CustomMethodCardFieldBlocksSummaryViewComponent({
|
|||||||
<IncrementerBlock
|
<IncrementerBlock
|
||||||
key={block.id}
|
key={block.id}
|
||||||
label={block.blockTitle}
|
label={block.blockTitle}
|
||||||
|
helpIcon={false}
|
||||||
value={block.defaultPercent}
|
value={block.defaultPercent}
|
||||||
min={1}
|
min={1}
|
||||||
max={100}
|
max={100}
|
||||||
|
|||||||
+6
-3
@@ -3,7 +3,10 @@
|
|||||||
import { memo, useCallback, useRef, useState } from "react";
|
import { memo, useCallback, useRef, useState } from "react";
|
||||||
import { useTranslation } from "../../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||||
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
import {
|
||||||
|
createFlowUploadFailureMessageKey,
|
||||||
|
uploadCreateFlowFile,
|
||||||
|
} from "../../../../../lib/create/uploadToServer";
|
||||||
import { CustomMethodCardUploadBlockRowView } from "./CustomMethodCardUploadBlockRow.view";
|
import { CustomMethodCardUploadBlockRowView } from "./CustomMethodCardUploadBlockRow.view";
|
||||||
import type { CustomMethodCardUploadBlockRowProps } from "./CustomMethodCardFieldBlocksSummary.types";
|
import type { CustomMethodCardUploadBlockRowProps } from "./CustomMethodCardFieldBlocksSummary.types";
|
||||||
|
|
||||||
@@ -68,8 +71,8 @@ function CustomMethodCardUploadBlockRowContainerComponent({
|
|||||||
: b,
|
: b,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch (err) {
|
||||||
setErrorMessage(tUpload("errors.generic"));
|
setErrorMessage(tUpload(createFlowUploadFailureMessageKey(err)));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -4,6 +4,7 @@ import { memo } from "react";
|
|||||||
import Upload from "../../../../components/controls/Upload";
|
import Upload from "../../../../components/controls/Upload";
|
||||||
import InputLabel from "../../../../components/type/InputLabel";
|
import InputLabel from "../../../../components/type/InputLabel";
|
||||||
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
|
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
|
||||||
|
import { CUSTOM_ATTACHMENT_ACCEPT } from "../../../../../lib/create/createFlowUploadValidation";
|
||||||
import type { CustomMethodCardUploadBlockRowViewProps } from "./CustomMethodCardFieldBlocksSummary.types";
|
import type { CustomMethodCardUploadBlockRowViewProps } from "./CustomMethodCardFieldBlocksSummary.types";
|
||||||
|
|
||||||
function CustomMethodCardUploadBlockRowViewComponent({
|
function CustomMethodCardUploadBlockRowViewComponent({
|
||||||
@@ -30,7 +31,6 @@ function CustomMethodCardUploadBlockRowViewComponent({
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<InputLabel
|
<InputLabel
|
||||||
label={block.blockTitle}
|
label={block.blockTitle}
|
||||||
helpIcon
|
|
||||||
size="s"
|
size="s"
|
||||||
palette="default"
|
palette="default"
|
||||||
/>
|
/>
|
||||||
@@ -44,7 +44,7 @@ function CustomMethodCardUploadBlockRowViewComponent({
|
|||||||
type="file"
|
type="file"
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif,application/pdf"
|
accept={CUSTOM_ATTACHMENT_ACCEPT}
|
||||||
aria-label={uploadFileInputAriaLabel}
|
aria-label={uploadFileInputAriaLabel}
|
||||||
onChange={onFileInputChange}
|
onChange={onFileInputChange}
|
||||||
/>
|
/>
|
||||||
@@ -77,6 +77,7 @@ function CustomMethodCardUploadBlockRowViewComponent({
|
|||||||
) : (
|
) : (
|
||||||
<Upload
|
<Upload
|
||||||
active={!busy}
|
active={!busy}
|
||||||
|
showHelpIcon={false}
|
||||||
hintText={busy ? uploadingHint : uploadHint}
|
hintText={busy ? uploadingHint : uploadHint}
|
||||||
onClick={onUploadClick}
|
onClick={onUploadClick}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+235
-17
@@ -5,8 +5,14 @@ import {
|
|||||||
useMessages,
|
useMessages,
|
||||||
useTranslation,
|
useTranslation,
|
||||||
} from "../../../../contexts/MessagesContext";
|
} from "../../../../contexts/MessagesContext";
|
||||||
|
import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm";
|
||||||
|
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
|
||||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||||
import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants";
|
import { createFlowUploadFailureMessageKey } from "../../../../../lib/create/uploadToServer";
|
||||||
|
import {
|
||||||
|
CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS,
|
||||||
|
CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS,
|
||||||
|
} from "../../../../../lib/create/customMethodCardWizardConstants";
|
||||||
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
|
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
|
||||||
import type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.types";
|
import type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.types";
|
||||||
import { CustomMethodCardWizardView } from "./CustomMethodCardWizard.view";
|
import { CustomMethodCardWizardView } from "./CustomMethodCardWizard.view";
|
||||||
@@ -17,12 +23,13 @@ import type { CustomMethodCardWizardProps } from "./CustomMethodCardWizard.types
|
|||||||
* `20066:14748`, `20094:48551`, `20066:14361`).
|
* `20066:14748`, `20094:48551`, `20066:14361`).
|
||||||
*/
|
*/
|
||||||
const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||||
({ isOpen, onClose, onFinalize, onPersistCustomUploadFile }) => {
|
({ isOpen, onClose, onFinalize, onPersistCustomUploadFile, initialValues }) => {
|
||||||
const m = useMessages();
|
const m = useMessages();
|
||||||
const t = useTranslation("common");
|
const t = useTranslation("common");
|
||||||
const tUpload = useTranslation("create.upload");
|
const tUpload = useTranslation("create.upload");
|
||||||
const w = m.create.customRule.customMethodCardWizard;
|
const w = m.create.customRule.customMethodCardWizard;
|
||||||
const menuCopy = m.create.customRule.modalKebabMenu;
|
const menuCopy = m.create.customRule.modalKebabMenu;
|
||||||
|
const { requestConfirm, confirmDialog } = useAsyncConfirm();
|
||||||
|
|
||||||
const copy = useMemo(
|
const copy = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -68,6 +75,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
|||||||
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
|
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
|
||||||
CustomMethodCardFieldBlock[]
|
CustomMethodCardFieldBlock[]
|
||||||
>([]);
|
>([]);
|
||||||
|
const [editingBlockId, setEditingBlockId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [textBlockTitle, setTextBlockTitle] = useState("");
|
const [textBlockTitle, setTextBlockTitle] = useState("");
|
||||||
const [textPlaceholderBody, setTextPlaceholderBody] = useState("");
|
const [textPlaceholderBody, setTextPlaceholderBody] = useState("");
|
||||||
@@ -88,6 +96,14 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
|||||||
const [proportionDefault, setProportionDefault] = useState(50);
|
const [proportionDefault, setProportionDefault] = useState(50);
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const initialValuesRef = useRef(initialValues);
|
||||||
|
initialValuesRef.current = initialValues;
|
||||||
|
const openSnapshotRef = useRef<{
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
fieldBlocks: string;
|
||||||
|
} | null>(null);
|
||||||
|
const fieldModalSnapshotRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const resetFieldTypeDrafts = useCallback(() => {
|
const resetFieldTypeDrafts = useCallback(() => {
|
||||||
setTextBlockTitle("");
|
setTextBlockTitle("");
|
||||||
@@ -112,21 +128,114 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
|||||||
setPolicyDescription("");
|
setPolicyDescription("");
|
||||||
setAddFieldExpanded(false);
|
setAddFieldExpanded(false);
|
||||||
setFieldTypeModal(null);
|
setFieldTypeModal(null);
|
||||||
|
setEditingBlockId(null);
|
||||||
setDraftFieldBlocks([]);
|
setDraftFieldBlocks([]);
|
||||||
|
openSnapshotRef.current = null;
|
||||||
|
fieldModalSnapshotRef.current = null;
|
||||||
resetFieldTypeDrafts();
|
resetFieldTypeDrafts();
|
||||||
}, [resetFieldTypeDrafts]);
|
}, [resetFieldTypeDrafts]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
reset();
|
reset();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [isOpen, reset]);
|
const init = initialValuesRef.current;
|
||||||
|
setWizardStep(1);
|
||||||
|
setPolicyTitle(init?.title ?? "");
|
||||||
|
setPolicyDescription(init?.description ?? "");
|
||||||
|
setAddFieldExpanded(false);
|
||||||
|
setFieldTypeModal(null);
|
||||||
|
setEditingBlockId(null);
|
||||||
|
setDraftFieldBlocks(
|
||||||
|
init?.fieldBlocks ? structuredClone(init.fieldBlocks) : [],
|
||||||
|
);
|
||||||
|
openSnapshotRef.current = {
|
||||||
|
title: init?.title ?? "",
|
||||||
|
description: init?.description ?? "",
|
||||||
|
fieldBlocks: JSON.stringify(init?.fieldBlocks ?? []),
|
||||||
|
};
|
||||||
|
fieldModalSnapshotRef.current = null;
|
||||||
|
resetFieldTypeDrafts();
|
||||||
|
}, [isOpen, reset, resetFieldTypeDrafts]);
|
||||||
|
|
||||||
const dismiss = useCallback(() => {
|
const dismiss = useCallback(() => {
|
||||||
reset();
|
reset();
|
||||||
onClose();
|
onClose();
|
||||||
}, [onClose, reset]);
|
}, [onClose, reset]);
|
||||||
|
|
||||||
|
const fieldModalDraftSignature = useCallback(() => {
|
||||||
|
return JSON.stringify({
|
||||||
|
fieldTypeModal,
|
||||||
|
textBlockTitle,
|
||||||
|
textPlaceholderBody,
|
||||||
|
badgeBlockTitle,
|
||||||
|
badgeOptions,
|
||||||
|
uploadBlockTitle,
|
||||||
|
uploadFileName,
|
||||||
|
uploadAssetUrl,
|
||||||
|
proportionBlockTitle,
|
||||||
|
proportionDefault,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
badgeBlockTitle,
|
||||||
|
badgeOptions,
|
||||||
|
fieldTypeModal,
|
||||||
|
proportionBlockTitle,
|
||||||
|
proportionDefault,
|
||||||
|
textBlockTitle,
|
||||||
|
textPlaceholderBody,
|
||||||
|
uploadAssetUrl,
|
||||||
|
uploadBlockTitle,
|
||||||
|
uploadFileName,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isWizardSessionDirty = useCallback(() => {
|
||||||
|
const snap = openSnapshotRef.current;
|
||||||
|
if (!snap) return false;
|
||||||
|
if (policyTitle !== snap.title || policyDescription !== snap.description) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (JSON.stringify(draftFieldBlocks) !== snap.fieldBlocks) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
fieldTypeModal &&
|
||||||
|
fieldModalSnapshotRef.current != null &&
|
||||||
|
fieldModalDraftSignature() !== fieldModalSnapshotRef.current
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [
|
||||||
|
draftFieldBlocks,
|
||||||
|
fieldModalDraftSignature,
|
||||||
|
fieldTypeModal,
|
||||||
|
policyDescription,
|
||||||
|
policyTitle,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useBeforeUnloadGuard(isOpen && isWizardSessionDirty());
|
||||||
|
|
||||||
|
const confirmAbandonWizardEdits = useCallback(async () => {
|
||||||
|
if (!isWizardSessionDirty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return requestConfirm({
|
||||||
|
title: menuCopy.discardUnsavedCustomizeChangesTitle,
|
||||||
|
description: menuCopy.discardUnsavedCustomizeChangesDescription,
|
||||||
|
proceedText: menuCopy.discardUnsavedCustomizeChangesProceed,
|
||||||
|
cancelText: menuCopy.discardUnsavedCustomizeChangesCancel,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
isWizardSessionDirty,
|
||||||
|
menuCopy.discardUnsavedCustomizeChangesCancel,
|
||||||
|
menuCopy.discardUnsavedCustomizeChangesDescription,
|
||||||
|
menuCopy.discardUnsavedCustomizeChangesProceed,
|
||||||
|
menuCopy.discardUnsavedCustomizeChangesTitle,
|
||||||
|
requestConfirm,
|
||||||
|
]);
|
||||||
|
|
||||||
const titleTrim = policyTitle.trim();
|
const titleTrim = policyTitle.trim();
|
||||||
const descriptionTrim = policyDescription.trim();
|
const descriptionTrim = policyDescription.trim();
|
||||||
|
|
||||||
@@ -136,7 +245,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
|||||||
titleTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS;
|
titleTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS;
|
||||||
const descriptionOk =
|
const descriptionOk =
|
||||||
descriptionTrim.length > 0 &&
|
descriptionTrim.length > 0 &&
|
||||||
descriptionTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS;
|
descriptionTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS;
|
||||||
if (wizardStep === 1) return titleOk;
|
if (wizardStep === 1) return titleOk;
|
||||||
if (wizardStep === 2) return descriptionOk;
|
if (wizardStep === 2) return descriptionOk;
|
||||||
return titleOk && descriptionOk;
|
return titleOk && descriptionOk;
|
||||||
@@ -213,7 +322,9 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
|||||||
const shellDescription = fieldModalHeader?.description ?? headerDescription;
|
const shellDescription = fieldModalHeader?.description ?? headerDescription;
|
||||||
|
|
||||||
const nextLabel = fieldTypeModal
|
const nextLabel = fieldTypeModal
|
||||||
? copy.fieldModals.addField
|
? editingBlockId
|
||||||
|
? copy.fieldModals.saveField
|
||||||
|
: copy.fieldModals.addField
|
||||||
: wizardStep === 3
|
: wizardStep === 3
|
||||||
? copy.footerFinalize
|
? copy.footerFinalize
|
||||||
: t("buttons.next");
|
: t("buttons.next");
|
||||||
@@ -222,33 +333,125 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
|||||||
? !fieldModalStepValid
|
? !fieldModalStepValid
|
||||||
: !stepValid;
|
: !stepValid;
|
||||||
|
|
||||||
const handleShellClose = useCallback(() => {
|
const handleShellClose = useCallback(async () => {
|
||||||
if (fieldTypeModal) {
|
if (fieldTypeModal) {
|
||||||
|
if (
|
||||||
|
fieldModalSnapshotRef.current != null &&
|
||||||
|
fieldModalDraftSignature() !== fieldModalSnapshotRef.current &&
|
||||||
|
!(await confirmAbandonWizardEdits())
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setFieldTypeModal(null);
|
setFieldTypeModal(null);
|
||||||
|
setEditingBlockId(null);
|
||||||
|
fieldModalSnapshotRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(await confirmAbandonWizardEdits())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dismiss();
|
dismiss();
|
||||||
}, [dismiss, fieldTypeModal]);
|
}, [
|
||||||
|
confirmAbandonWizardEdits,
|
||||||
|
dismiss,
|
||||||
|
fieldModalDraftSignature,
|
||||||
|
fieldTypeModal,
|
||||||
|
]);
|
||||||
|
|
||||||
const kebabMenuItems = useMemo<ModalHeaderMenuItem[]>(() => [], []);
|
const kebabMenuItems = useMemo<ModalHeaderMenuItem[]>(() => [], []);
|
||||||
|
|
||||||
const handleBack = useCallback(() => {
|
const handleBack = useCallback(async () => {
|
||||||
if (fieldTypeModal) {
|
if (fieldTypeModal) {
|
||||||
|
if (
|
||||||
|
fieldModalSnapshotRef.current != null &&
|
||||||
|
fieldModalDraftSignature() !== fieldModalSnapshotRef.current &&
|
||||||
|
!(await confirmAbandonWizardEdits())
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setFieldTypeModal(null);
|
setFieldTypeModal(null);
|
||||||
|
setEditingBlockId(null);
|
||||||
|
fieldModalSnapshotRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (wizardStep === 1) {
|
if (wizardStep === 1) {
|
||||||
|
if (!(await confirmAbandonWizardEdits())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
dismiss();
|
dismiss();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setWizardStep((s) => (s === 2 ? 1 : 2));
|
setWizardStep((s) => (s === 2 ? 1 : 2));
|
||||||
}, [dismiss, fieldTypeModal, wizardStep]);
|
}, [
|
||||||
|
confirmAbandonWizardEdits,
|
||||||
|
dismiss,
|
||||||
|
fieldModalDraftSignature,
|
||||||
|
fieldTypeModal,
|
||||||
|
wizardStep,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSelectFieldType = useCallback((ft: AddCustomFieldType) => {
|
const handleSelectFieldType = useCallback((ft: AddCustomFieldType) => {
|
||||||
|
setEditingBlockId(null);
|
||||||
|
setAddFieldExpanded(false);
|
||||||
resetFieldTypeDrafts();
|
resetFieldTypeDrafts();
|
||||||
setFieldTypeModal(ft);
|
setFieldTypeModal(ft);
|
||||||
|
fieldModalSnapshotRef.current = JSON.stringify({
|
||||||
|
fieldTypeModal: ft,
|
||||||
|
textBlockTitle: "",
|
||||||
|
textPlaceholderBody: "",
|
||||||
|
badgeBlockTitle: "",
|
||||||
|
badgeOptions: [],
|
||||||
|
uploadBlockTitle: "",
|
||||||
|
uploadFileName: undefined,
|
||||||
|
uploadAssetUrl: undefined,
|
||||||
|
proportionBlockTitle: "",
|
||||||
|
proportionDefault: 50,
|
||||||
|
});
|
||||||
}, [resetFieldTypeDrafts]);
|
}, [resetFieldTypeDrafts]);
|
||||||
|
|
||||||
|
const handleEditFieldBlock = useCallback(
|
||||||
|
(block: CustomMethodCardFieldBlock) => {
|
||||||
|
resetFieldTypeDrafts();
|
||||||
|
setEditingBlockId(block.id);
|
||||||
|
setAddFieldExpanded(false);
|
||||||
|
switch (block.kind) {
|
||||||
|
case "text":
|
||||||
|
setTextBlockTitle(block.blockTitle);
|
||||||
|
setTextPlaceholderBody(block.placeholderText);
|
||||||
|
break;
|
||||||
|
case "badges":
|
||||||
|
setBadgeBlockTitle(block.blockTitle);
|
||||||
|
setBadgeOptions([...block.options]);
|
||||||
|
break;
|
||||||
|
case "upload":
|
||||||
|
setUploadBlockTitle(block.blockTitle);
|
||||||
|
setUploadFileName(block.fileName);
|
||||||
|
setUploadAssetUrl(block.assetUrl);
|
||||||
|
break;
|
||||||
|
case "proportion":
|
||||||
|
setProportionBlockTitle(block.blockTitle);
|
||||||
|
setProportionDefault(block.defaultPercent);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
setFieldTypeModal(block.kind);
|
||||||
|
fieldModalSnapshotRef.current = JSON.stringify({
|
||||||
|
fieldTypeModal: block.kind,
|
||||||
|
textBlockTitle: block.kind === "text" ? block.blockTitle : "",
|
||||||
|
textPlaceholderBody:
|
||||||
|
block.kind === "text" ? block.placeholderText : "",
|
||||||
|
badgeBlockTitle: block.kind === "badges" ? block.blockTitle : "",
|
||||||
|
badgeOptions: block.kind === "badges" ? [...block.options] : [],
|
||||||
|
uploadBlockTitle: block.kind === "upload" ? block.blockTitle : "",
|
||||||
|
uploadFileName: block.kind === "upload" ? block.fileName : undefined,
|
||||||
|
uploadAssetUrl: block.kind === "upload" ? block.assetUrl : undefined,
|
||||||
|
proportionBlockTitle:
|
||||||
|
block.kind === "proportion" ? block.blockTitle : "",
|
||||||
|
proportionDefault: block.kind === "proportion" ? block.defaultPercent : 50,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[resetFieldTypeDrafts],
|
||||||
|
);
|
||||||
|
|
||||||
const handleFileChosen = useCallback(
|
const handleFileChosen = useCallback(
|
||||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -260,8 +463,9 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
|||||||
try {
|
try {
|
||||||
const { url } = await onPersistCustomUploadFile(file);
|
const { url } = await onPersistCustomUploadFile(file);
|
||||||
setUploadAssetUrl(url);
|
setUploadAssetUrl(url);
|
||||||
} catch {
|
} catch (err) {
|
||||||
setUploadFieldError(tUpload("errors.generic"));
|
setUploadFileName(undefined);
|
||||||
|
setUploadFieldError(tUpload(createFlowUploadFailureMessageKey(err)));
|
||||||
} finally {
|
} finally {
|
||||||
setUploadFieldBusy(false);
|
setUploadFieldBusy(false);
|
||||||
}
|
}
|
||||||
@@ -285,9 +489,9 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const appendFieldBlock = useCallback(() => {
|
const commitFieldBlock = useCallback(() => {
|
||||||
if (!fieldTypeModal || !fieldModalStepValid) return;
|
if (!fieldTypeModal || !fieldModalStepValid) return;
|
||||||
const id = crypto.randomUUID();
|
const id = editingBlockId ?? crypto.randomUUID();
|
||||||
let block: CustomMethodCardFieldBlock;
|
let block: CustomMethodCardFieldBlock;
|
||||||
switch (fieldTypeModal) {
|
switch (fieldTypeModal) {
|
||||||
case "text":
|
case "text":
|
||||||
@@ -325,11 +529,20 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
|||||||
defaultPercent: proportionDefault,
|
defaultPercent: proportionDefault,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
setDraftFieldBlocks((prev) => [...prev, block]);
|
setDraftFieldBlocks((prev) => {
|
||||||
|
if (editingBlockId) {
|
||||||
|
return prev.map((row) => (row.id === editingBlockId ? block : row));
|
||||||
|
}
|
||||||
|
return [...prev, block];
|
||||||
|
});
|
||||||
setFieldTypeModal(null);
|
setFieldTypeModal(null);
|
||||||
|
setEditingBlockId(null);
|
||||||
|
setAddFieldExpanded(false);
|
||||||
|
fieldModalSnapshotRef.current = null;
|
||||||
}, [
|
}, [
|
||||||
badgeBlockTitle,
|
badgeBlockTitle,
|
||||||
badgeOptions,
|
badgeOptions,
|
||||||
|
editingBlockId,
|
||||||
fieldModalStepValid,
|
fieldModalStepValid,
|
||||||
fieldTypeModal,
|
fieldTypeModal,
|
||||||
proportionBlockTitle,
|
proportionBlockTitle,
|
||||||
@@ -343,7 +556,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
|||||||
|
|
||||||
const handleNext = useCallback(() => {
|
const handleNext = useCallback(() => {
|
||||||
if (fieldTypeModal) {
|
if (fieldTypeModal) {
|
||||||
appendFieldBlock();
|
commitFieldBlock();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!stepValid) return;
|
if (!stepValid) return;
|
||||||
@@ -358,7 +571,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
|||||||
}
|
}
|
||||||
setWizardStep((s) => (s === 1 ? 2 : 3));
|
setWizardStep((s) => (s === 1 ? 2 : 3));
|
||||||
}, [
|
}, [
|
||||||
appendFieldBlock,
|
commitFieldBlock,
|
||||||
descriptionTrim,
|
descriptionTrim,
|
||||||
dismiss,
|
dismiss,
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
@@ -370,6 +583,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<CustomMethodCardWizardView
|
<CustomMethodCardWizardView
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onDismiss={handleShellClose}
|
onDismiss={handleShellClose}
|
||||||
@@ -380,7 +594,8 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
|||||||
policyDescription={policyDescription}
|
policyDescription={policyDescription}
|
||||||
addFieldExpanded={addFieldExpanded}
|
addFieldExpanded={addFieldExpanded}
|
||||||
copy={copy}
|
copy={copy}
|
||||||
maxChars={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
maxTitleChars={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
||||||
|
maxDescriptionChars={CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS}
|
||||||
onPolicyTitleChange={setPolicyTitle}
|
onPolicyTitleChange={setPolicyTitle}
|
||||||
onPolicyDescriptionChange={setPolicyDescription}
|
onPolicyDescriptionChange={setPolicyDescription}
|
||||||
onPressAddCustomField={() => setAddFieldExpanded(true)}
|
onPressAddCustomField={() => setAddFieldExpanded(true)}
|
||||||
@@ -420,10 +635,13 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
|||||||
stepper={!fieldTypeModal}
|
stepper={!fieldTypeModal}
|
||||||
draftFieldBlocks={draftFieldBlocks}
|
draftFieldBlocks={draftFieldBlocks}
|
||||||
onDraftFieldBlocksReorder={setDraftFieldBlocks}
|
onDraftFieldBlocksReorder={setDraftFieldBlocks}
|
||||||
|
onEditFieldBlock={handleEditFieldBlock}
|
||||||
kebabMoreOptionsAriaLabel={menuCopy.triggerAriaLabel}
|
kebabMoreOptionsAriaLabel={menuCopy.triggerAriaLabel}
|
||||||
kebabMenuAriaLabel={menuCopy.menuAriaLabel}
|
kebabMenuAriaLabel={menuCopy.menuAriaLabel}
|
||||||
kebabMenuItems={kebabMenuItems}
|
kebabMenuItems={kebabMenuItems}
|
||||||
/>
|
/>
|
||||||
|
{confirmDialog}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { RefObject } from "react";
|
|||||||
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
|
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
|
||||||
import type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.types";
|
import type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.types";
|
||||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||||
|
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
|
||||||
|
|
||||||
export interface CustomMethodCardWizardFieldBodiesCopy {
|
export interface CustomMethodCardWizardFieldBodiesCopy {
|
||||||
requiredHint: string;
|
requiredHint: string;
|
||||||
@@ -47,6 +48,7 @@ export interface CustomMethodCardWizardCopy {
|
|||||||
footerFinalize: string;
|
footerFinalize: string;
|
||||||
fieldModals: {
|
fieldModals: {
|
||||||
addField: string;
|
addField: string;
|
||||||
|
saveField: string;
|
||||||
requiredHint: string;
|
requiredHint: string;
|
||||||
text: CustomMethodCardWizardFieldBodiesCopy["text"] & {
|
text: CustomMethodCardWizardFieldBodiesCopy["text"] & {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -70,6 +72,8 @@ export interface CustomMethodCardWizardCopy {
|
|||||||
export interface CustomMethodCardWizardProps {
|
export interface CustomMethodCardWizardProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
/** When set, seeds title, description, and field blocks; wizard still starts at step 1. */
|
||||||
|
initialValues?: MethodCardWizardInitialValues | null;
|
||||||
/** Called when the user completes step 3; parent assigns id and persists state. */
|
/** Called when the user completes step 3; parent assigns id and persists state. */
|
||||||
onFinalize: (payload: {
|
onFinalize: (payload: {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -123,7 +127,8 @@ export interface CustomMethodCardWizardViewProps {
|
|||||||
policyDescription: string;
|
policyDescription: string;
|
||||||
addFieldExpanded: boolean;
|
addFieldExpanded: boolean;
|
||||||
copy: CustomMethodCardWizardCopy;
|
copy: CustomMethodCardWizardCopy;
|
||||||
maxChars: number;
|
maxTitleChars: number;
|
||||||
|
maxDescriptionChars: number;
|
||||||
onPolicyTitleChange: (v: string) => void;
|
onPolicyTitleChange: (v: string) => void;
|
||||||
onPolicyDescriptionChange: (v: string) => void;
|
onPolicyDescriptionChange: (v: string) => void;
|
||||||
onPressAddCustomField: () => void;
|
onPressAddCustomField: () => void;
|
||||||
@@ -136,6 +141,7 @@ export interface CustomMethodCardWizardViewProps {
|
|||||||
>;
|
>;
|
||||||
draftFieldBlocks: CustomMethodCardFieldBlock[];
|
draftFieldBlocks: CustomMethodCardFieldBlock[];
|
||||||
onDraftFieldBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void;
|
onDraftFieldBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void;
|
||||||
|
onEditFieldBlock: (_block: CustomMethodCardFieldBlock) => void;
|
||||||
nextDisabled: boolean;
|
nextDisabled: boolean;
|
||||||
nextLabel: string;
|
nextLabel: string;
|
||||||
showBackButton: boolean;
|
showBackButton: boolean;
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ function CustomMethodCardWizardViewComponent({
|
|||||||
policyDescription,
|
policyDescription,
|
||||||
addFieldExpanded,
|
addFieldExpanded,
|
||||||
copy,
|
copy,
|
||||||
maxChars,
|
maxTitleChars,
|
||||||
|
maxDescriptionChars,
|
||||||
onPolicyTitleChange,
|
onPolicyTitleChange,
|
||||||
onPolicyDescriptionChange,
|
onPolicyDescriptionChange,
|
||||||
onPressAddCustomField,
|
onPressAddCustomField,
|
||||||
@@ -35,6 +36,7 @@ function CustomMethodCardWizardViewComponent({
|
|||||||
stepper,
|
stepper,
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
onDraftFieldBlocksReorder,
|
onDraftFieldBlocksReorder,
|
||||||
|
onEditFieldBlock,
|
||||||
kebabMoreOptionsAriaLabel,
|
kebabMoreOptionsAriaLabel,
|
||||||
kebabMenuAriaLabel,
|
kebabMenuAriaLabel,
|
||||||
kebabMenuItems,
|
kebabMenuItems,
|
||||||
@@ -71,24 +73,26 @@ function CustomMethodCardWizardViewComponent({
|
|||||||
placeholder={copy.step1.fieldPlaceholder}
|
placeholder={copy.step1.fieldPlaceholder}
|
||||||
value={policyTitle}
|
value={policyTitle}
|
||||||
onChange={onPolicyTitleChange}
|
onChange={onPolicyTitleChange}
|
||||||
maxLength={maxChars}
|
maxLength={maxTitleChars}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!fieldTypeModal && wizardStep === 2 ? (
|
{!fieldTypeModal && wizardStep === 2 ? (
|
||||||
<TextArea
|
<TextArea
|
||||||
appearance="default"
|
appearance="default"
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
|
showHelpIcon={false}
|
||||||
|
label={copy.step2.title}
|
||||||
placeholder={copy.step2.fieldPlaceholder}
|
placeholder={copy.step2.fieldPlaceholder}
|
||||||
value={policyDescription}
|
value={policyDescription}
|
||||||
maxLength={maxChars}
|
maxLength={maxDescriptionChars}
|
||||||
onChange={(e) => onPolicyDescriptionChange(e.target.value)}
|
onChange={(e) => onPolicyDescriptionChange(e.target.value)}
|
||||||
textHint={`${policyDescription.length}/${maxChars}`}
|
textHint={`${policyDescription.length}/${maxDescriptionChars}`}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!fieldTypeModal && wizardStep === 3 ? (
|
{!fieldTypeModal && wizardStep === 3 ? (
|
||||||
<div className="flex w-full flex-col gap-4">
|
<div className="flex w-full flex-col gap-6">
|
||||||
{draftFieldBlocks.length > 0 ? (
|
{draftFieldBlocks.length > 0 ? (
|
||||||
<CustomMethodCardWizardBlocksList
|
<CustomMethodCardWizardBlocksList
|
||||||
blocks={draftFieldBlocks}
|
blocks={draftFieldBlocks}
|
||||||
@@ -96,6 +100,7 @@ function CustomMethodCardWizardViewComponent({
|
|||||||
dragHandleAriaLabel={copy.step3BlocksList.dragHandleAriaLabel}
|
dragHandleAriaLabel={copy.step3BlocksList.dragHandleAriaLabel}
|
||||||
listLabel={copy.step3BlocksList.listLabel}
|
listLabel={copy.step3BlocksList.listLabel}
|
||||||
onBlocksReorder={onDraftFieldBlocksReorder}
|
onBlocksReorder={onDraftFieldBlocksReorder}
|
||||||
|
onEditBlock={onEditFieldBlock}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<AddCustomField
|
<AddCustomField
|
||||||
|
|||||||
+22
-3
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo, useCallback, useState, type DragEvent } from "react";
|
import { memo, useCallback, useRef, useState, type DragEvent } from "react";
|
||||||
import { reorderCustomMethodCardFieldBlocks } from "../../../../../lib/create/reorderCustomMethodCardFieldBlocks";
|
import { reorderCustomMethodCardFieldBlocks } from "../../../../../lib/create/reorderCustomMethodCardFieldBlocks";
|
||||||
import { CustomMethodCardWizardBlocksListView } from "./CustomMethodCardWizardBlocksList.view";
|
import { CustomMethodCardWizardBlocksListView } from "./CustomMethodCardWizardBlocksList.view";
|
||||||
import type { CustomMethodCardWizardBlocksListProps } from "./CustomMethodCardWizardBlocksList.types";
|
import type { CustomMethodCardWizardBlocksListProps } from "./CustomMethodCardWizardBlocksList.types";
|
||||||
@@ -11,19 +11,33 @@ function CustomMethodCardWizardBlocksListContainerComponent({
|
|||||||
dragHandleAriaLabel,
|
dragHandleAriaLabel,
|
||||||
listLabel,
|
listLabel,
|
||||||
onBlocksReorder,
|
onBlocksReorder,
|
||||||
|
onEditBlock,
|
||||||
}: CustomMethodCardWizardBlocksListProps) {
|
}: CustomMethodCardWizardBlocksListProps) {
|
||||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
||||||
const [overIndex, setOverIndex] = useState<number | null>(null);
|
const [overIndex, setOverIndex] = useState<number | null>(null);
|
||||||
|
const draggingIndexRef = useRef<number | null>(null);
|
||||||
|
const dragFromHandleRef = useRef(false);
|
||||||
|
|
||||||
const clearDragUi = useCallback(() => {
|
const clearDragUi = useCallback(() => {
|
||||||
|
draggingIndexRef.current = null;
|
||||||
|
dragFromHandleRef.current = false;
|
||||||
setDraggingIndex(null);
|
setDraggingIndex(null);
|
||||||
setOverIndex(null);
|
setOverIndex(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleHandlePointerDown = useCallback(() => {
|
||||||
|
dragFromHandleRef.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleDragStart = useCallback(
|
const handleDragStart = useCallback(
|
||||||
(index: number) => (e: DragEvent) => {
|
(index: number) => (e: DragEvent) => {
|
||||||
|
if (!dragFromHandleRef.current) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
e.dataTransfer.effectAllowed = "move";
|
e.dataTransfer.effectAllowed = "move";
|
||||||
e.dataTransfer.setData("text/plain", String(index));
|
e.dataTransfer.setData("text/plain", String(index));
|
||||||
|
draggingIndexRef.current = index;
|
||||||
setDraggingIndex(index);
|
setDraggingIndex(index);
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
@@ -40,8 +54,11 @@ function CustomMethodCardWizardBlocksListContainerComponent({
|
|||||||
const handleDrop = useCallback(
|
const handleDrop = useCallback(
|
||||||
(index: number) => (e: DragEvent) => {
|
(index: number) => (e: DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const from = Number.parseInt(e.dataTransfer.getData("text/plain"), 10);
|
const fromData = Number.parseInt(e.dataTransfer.getData("text/plain"), 10);
|
||||||
if (Number.isNaN(from)) {
|
const from = Number.isNaN(fromData)
|
||||||
|
? draggingIndexRef.current
|
||||||
|
: fromData;
|
||||||
|
if (from == null || Number.isNaN(from)) {
|
||||||
clearDragUi();
|
clearDragUi();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -66,6 +83,8 @@ function CustomMethodCardWizardBlocksListContainerComponent({
|
|||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onDragEnd={clearDragUi}
|
onDragEnd={clearDragUi}
|
||||||
|
onHandlePointerDown={handleHandlePointerDown}
|
||||||
|
onEditBlock={onEditBlock}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -8,6 +8,7 @@ export interface CustomMethodCardWizardBlocksListProps {
|
|||||||
dragHandleAriaLabel: string;
|
dragHandleAriaLabel: string;
|
||||||
listLabel: string;
|
listLabel: string;
|
||||||
onBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void;
|
onBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void;
|
||||||
|
onEditBlock: (_block: CustomMethodCardFieldBlock) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomMethodCardWizardBlocksListViewProps
|
export interface CustomMethodCardWizardBlocksListViewProps
|
||||||
@@ -18,4 +19,5 @@ export interface CustomMethodCardWizardBlocksListViewProps
|
|||||||
onDragOver: (_index: number) => (_e: DragEvent) => void;
|
onDragOver: (_index: number) => (_e: DragEvent) => void;
|
||||||
onDrop: (_index: number) => (_e: DragEvent) => void;
|
onDrop: (_index: number) => (_e: DragEvent) => void;
|
||||||
onDragEnd: () => void;
|
onDragEnd: () => void;
|
||||||
|
onHandlePointerDown: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-17
@@ -38,34 +38,52 @@ function CustomMethodCardWizardBlocksListViewComponent({
|
|||||||
onDragOver,
|
onDragOver,
|
||||||
onDrop,
|
onDrop,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
|
onHandlePointerDown,
|
||||||
|
onEditBlock,
|
||||||
}: CustomMethodCardWizardBlocksListViewProps) {
|
}: CustomMethodCardWizardBlocksListViewProps) {
|
||||||
return (
|
return (
|
||||||
<ul className="flex list-none flex-col gap-2 p-0" aria-label={listLabel}>
|
<ul
|
||||||
|
className="flex list-none flex-col gap-2 p-0"
|
||||||
|
aria-label={listLabel}
|
||||||
|
>
|
||||||
{blocks.map((block, index) => {
|
{blocks.map((block, index) => {
|
||||||
const kind = block.kind as AddCustomFieldType;
|
const kind = block.kind as AddCustomFieldType;
|
||||||
const typeLabel = fieldTypeLabels[kind];
|
const typeLabel = fieldTypeLabels[kind];
|
||||||
const isOver = overIndex === index && draggingIndex !== index;
|
const title = block.blockTitle.trim() || typeLabel;
|
||||||
|
const isDragging = draggingIndex === index;
|
||||||
|
const isDropTarget = overIndex === index && draggingIndex !== index;
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={block.id}
|
key={block.id}
|
||||||
className={`flex min-h-[52px] items-stretch gap-2 rounded-[var(--measures-radius-medium,8px)] border border-[var(--color-border-default-primary)] bg-[var(--color-surface-default-secondary)] pl-1 pr-3 py-2 transition-shadow ${
|
draggable
|
||||||
isOver
|
aria-grabbed={isDragging ? true : undefined}
|
||||||
? "ring-2 ring-[var(--color-border-invert-primary)] ring-offset-2 ring-offset-[var(--color-surface-default-primary)]"
|
className={`group flex min-h-[52px] items-stretch gap-2 rounded-[var(--measures-radius-medium,8px)] border-2 bg-[var(--color-surface-default-secondary)] py-2 pl-1 pr-3 transition-[border-color,box-shadow,opacity] ${
|
||||||
: ""
|
isDropTarget
|
||||||
} ${draggingIndex === index ? "opacity-60" : ""}`}
|
? "relative z-10 border-[var(--color-border-invert-primary)]"
|
||||||
|
: isDragging
|
||||||
|
? "relative z-20 border-[var(--color-border-default-primary)] opacity-60 shadow-[0_4px_12px_rgba(0,0,0,0.12)]"
|
||||||
|
: "border-[var(--color-border-default-primary)] hover:border-[var(--color-content-default-secondary)]"
|
||||||
|
}`}
|
||||||
|
onDragStart={onDragStart(index)}
|
||||||
onDragOver={onDragOver(index)}
|
onDragOver={onDragOver(index)}
|
||||||
onDrop={onDrop(index)}
|
onDrop={onDrop(index)}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
>
|
>
|
||||||
<button
|
<span
|
||||||
type="button"
|
role="button"
|
||||||
draggable
|
tabIndex={0}
|
||||||
onDragStart={onDragStart(index)}
|
|
||||||
onDragEnd={onDragEnd}
|
|
||||||
className="flex shrink-0 cursor-grab touch-manipulation items-center justify-center rounded-[var(--measures-radius-200,8px)] border-0 bg-transparent px-1 text-[var(--color-content-default-secondary)] active:cursor-grabbing focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)]"
|
|
||||||
aria-label={dragHandleAriaLabel}
|
aria-label={dragHandleAriaLabel}
|
||||||
|
onPointerDown={onHandlePointerDown}
|
||||||
|
className="flex shrink-0 cursor-grab touch-manipulation items-center self-stretch rounded-[var(--measures-radius-200,8px)] px-1 text-[var(--color-content-default-secondary)] transition-colors active:cursor-grabbing focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-invert-primary)] group-hover:text-[var(--color-content-default-primary)]"
|
||||||
>
|
>
|
||||||
<DragHandleGlyph />
|
<DragHandleGlyph />
|
||||||
</button>
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={title}
|
||||||
|
onClick={() => onEditBlock(block)}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2 self-stretch rounded-[var(--measures-radius-200,8px)] border-0 bg-transparent px-0 text-left cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-invert-primary)]"
|
||||||
|
>
|
||||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center self-center">
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center self-center">
|
||||||
<Icon
|
<Icon
|
||||||
name={ADD_CUSTOM_FIELD_TYPE_ICONS[kind]}
|
name={ADD_CUSTOM_FIELD_TYPE_ICONS[kind]}
|
||||||
@@ -74,13 +92,14 @@ function CustomMethodCardWizardBlocksListViewComponent({
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
|
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
|
||||||
<span className="truncate font-inter text-[14px] font-medium leading-[18px] text-[var(--color-content-default-primary)]">
|
<span className="truncate text-[14px] font-medium leading-[18px] text-[var(--color-content-default-primary)]">
|
||||||
{block.blockTitle.trim() || typeLabel}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-inter text-[12px] leading-4 text-[var(--color-content-default-secondary)]">
|
<span className="text-x-small-paragraph text-[var(--color-content-default-secondary)]">
|
||||||
{typeLabel}
|
{typeLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+14
-11
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo, useId } from "react";
|
||||||
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
|
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
|
||||||
import InputWithCounter from "../../../../components/controls/InputWithCounter";
|
import InputWithCounter from "../../../../components/controls/InputWithCounter";
|
||||||
import TextArea from "../../../../components/controls/TextArea";
|
import TextArea from "../../../../components/controls/TextArea";
|
||||||
@@ -10,6 +10,7 @@ import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
|
|||||||
import InputLabel from "../../../../components/type/InputLabel";
|
import InputLabel from "../../../../components/type/InputLabel";
|
||||||
import ApplicableScopeField from "../ApplicableScopeField";
|
import ApplicableScopeField from "../ApplicableScopeField";
|
||||||
import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants";
|
import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants";
|
||||||
|
import { CUSTOM_ATTACHMENT_ACCEPT } from "../../../../../lib/create/createFlowUploadValidation";
|
||||||
import type { CustomMethodCardWizardFieldBodiesViewProps } from "./CustomMethodCardWizard.types";
|
import type { CustomMethodCardWizardFieldBodiesViewProps } from "./CustomMethodCardWizard.types";
|
||||||
|
|
||||||
const TEXT_PLACEHOLDER_MAX = 8000;
|
const TEXT_PLACEHOLDER_MAX = 8000;
|
||||||
@@ -39,6 +40,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
|
|||||||
onProportionBlockTitleChange,
|
onProportionBlockTitleChange,
|
||||||
onProportionDefaultChange,
|
onProportionDefaultChange,
|
||||||
}: CustomMethodCardWizardFieldBodiesViewProps) {
|
}: CustomMethodCardWizardFieldBodiesViewProps) {
|
||||||
|
const placeholderFieldId = useId();
|
||||||
const uploadPreviewTrimmed = uploadAssetPreviewUrl?.trim() ?? "";
|
const uploadPreviewTrimmed = uploadAssetPreviewUrl?.trim() ?? "";
|
||||||
const hasUploadPreview = uploadPreviewTrimmed.length > 0;
|
const hasUploadPreview = uploadPreviewTrimmed.length > 0;
|
||||||
|
|
||||||
@@ -51,16 +53,16 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
|
|||||||
value={textBlockTitle}
|
value={textBlockTitle}
|
||||||
onChange={onTextBlockTitleChange}
|
onChange={onTextBlockTitleChange}
|
||||||
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
||||||
showHelpIcon
|
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<InputLabel
|
<label
|
||||||
label={copy.text.placeholderLabel}
|
htmlFor={placeholderFieldId}
|
||||||
helpIcon
|
className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]"
|
||||||
size="s"
|
>
|
||||||
palette="default"
|
{copy.text.placeholderLabel}
|
||||||
/>
|
</label>
|
||||||
<TextArea
|
<TextArea
|
||||||
|
id={placeholderFieldId}
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
appearance="embedded"
|
appearance="embedded"
|
||||||
value={textPlaceholderBody}
|
value={textPlaceholderBody}
|
||||||
@@ -82,13 +84,13 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<InputLabel
|
<InputLabel
|
||||||
label={copy.badges.blockTitleLabel}
|
label={copy.badges.blockTitleLabel}
|
||||||
helpIcon
|
|
||||||
helperText={copy.requiredHint}
|
helperText={copy.requiredHint}
|
||||||
size="s"
|
size="s"
|
||||||
palette="default"
|
palette="default"
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
|
label={copy.badges.blockTitleLabel}
|
||||||
placeholder={copy.badges.blockTitlePlaceholder}
|
placeholder={copy.badges.blockTitlePlaceholder}
|
||||||
value={badgeBlockTitle}
|
value={badgeBlockTitle}
|
||||||
onChange={(e) => onBadgeBlockTitleChange(e.target.value)}
|
onChange={(e) => onBadgeBlockTitleChange(e.target.value)}
|
||||||
@@ -118,6 +120,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
|
|||||||
type="file"
|
type="file"
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
accept={CUSTOM_ATTACHMENT_ACCEPT}
|
||||||
aria-label={copy.upload.uploadFileInputAriaLabel}
|
aria-label={copy.upload.uploadFileInputAriaLabel}
|
||||||
onChange={onFileChosen}
|
onChange={onFileChosen}
|
||||||
/>
|
/>
|
||||||
@@ -127,7 +130,6 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
|
|||||||
value={uploadBlockTitle}
|
value={uploadBlockTitle}
|
||||||
onChange={onUploadBlockTitleChange}
|
onChange={onUploadBlockTitleChange}
|
||||||
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
||||||
showHelpIcon
|
|
||||||
/>
|
/>
|
||||||
{hasUploadPreview ? (
|
{hasUploadPreview ? (
|
||||||
<div className="relative inline-block max-w-full">
|
<div className="relative inline-block max-w-full">
|
||||||
@@ -158,6 +160,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
|
|||||||
) : (
|
) : (
|
||||||
<Upload
|
<Upload
|
||||||
active={!uploadPersisting}
|
active={!uploadPersisting}
|
||||||
|
showHelpIcon={false}
|
||||||
hintText={
|
hintText={
|
||||||
uploadPersisting && uploadBusyHint
|
uploadPersisting && uploadBusyHint
|
||||||
? uploadBusyHint
|
? uploadBusyHint
|
||||||
@@ -188,10 +191,10 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
|
|||||||
value={proportionBlockTitle}
|
value={proportionBlockTitle}
|
||||||
onChange={onProportionBlockTitleChange}
|
onChange={onProportionBlockTitleChange}
|
||||||
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
||||||
showHelpIcon
|
|
||||||
/>
|
/>
|
||||||
<IncrementerBlock
|
<IncrementerBlock
|
||||||
label={copy.proportion.defaultLabel}
|
label={copy.proportion.defaultLabel}
|
||||||
|
helpIcon={false}
|
||||||
value={proportionDefault}
|
value={proportionDefault}
|
||||||
min={1}
|
min={1}
|
||||||
max={100}
|
max={100}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useBeforeUnloadGuard } from "../../../hooks/useBeforeUnloadGuard";
|
||||||
import Create from "../../../components/modals/Create";
|
import Create from "../../../components/modals/Create";
|
||||||
import TextInput from "../../../components/controls/TextInput";
|
import TextArea from "../../../components/controls/TextArea";
|
||||||
import ContentLockup from "../../../components/type/ContentLockup";
|
import ContentLockup from "../../../components/type/ContentLockup";
|
||||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
|
|
||||||
@@ -57,6 +58,8 @@ export function FinalReviewCommunityContextEditModal({
|
|||||||
[draft],
|
[draft],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useBeforeUnloadGuard(isOpen && isDirty);
|
||||||
|
|
||||||
const characterHint = tField("characterCountTemplate")
|
const characterHint = tField("characterCountTemplate")
|
||||||
.replace("{current}", String(draft.length))
|
.replace("{current}", String(draft.length))
|
||||||
.replace("{max}", String(COMMUNITY_CONTEXT_FIELD_MAX_LENGTH));
|
.replace("{max}", String(COMMUNITY_CONTEXT_FIELD_MAX_LENGTH));
|
||||||
@@ -92,18 +95,20 @@ export function FinalReviewCommunityContextEditModal({
|
|||||||
ariaLabel={tModal("title")}
|
ariaLabel={tModal("title")}
|
||||||
>
|
>
|
||||||
<div className="pb-2">
|
<div className="pb-2">
|
||||||
<TextInput
|
<TextArea
|
||||||
className="!transition-none"
|
className="!transition-none"
|
||||||
type="text"
|
label={tModal("title")}
|
||||||
placeholder={tField("placeholder")}
|
placeholder={tField("placeholder")}
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setDraft(e.target.value);
|
setDraft(e.target.value);
|
||||||
}}
|
}}
|
||||||
inputSize="medium"
|
size="medium"
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
|
showHelpIcon={false}
|
||||||
textHint={characterHint}
|
textHint={characterHint}
|
||||||
maxLength={COMMUNITY_CONTEXT_FIELD_MAX_LENGTH}
|
maxLength={COMMUNITY_CONTEXT_FIELD_MAX_LENGTH}
|
||||||
|
rows={4}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Create>
|
</Create>
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export function FinalReviewTitleEditModal({
|
|||||||
<TextInput
|
<TextInput
|
||||||
className="!transition-none"
|
className="!transition-none"
|
||||||
type="text"
|
type="text"
|
||||||
|
label={tModal("title")}
|
||||||
placeholder={tField("placeholder")}
|
placeholder={tField("placeholder")}
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -100,8 +101,10 @@ export function FinalReviewTitleEditModal({
|
|||||||
}}
|
}}
|
||||||
inputSize="medium"
|
inputSize="medium"
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
|
showHelpIcon={false}
|
||||||
textHint={characterHint}
|
textHint={characterHint}
|
||||||
maxLength={COMMUNITY_TITLE_FIELD_MAX_LENGTH}
|
maxLength={COMMUNITY_TITLE_FIELD_MAX_LENGTH}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Create>
|
</Create>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export default function MethodCardCustomizeModalHeader({
|
|||||||
value={titleValue}
|
value={titleValue}
|
||||||
onChange={(e) => onTitleChange(e.target.value)}
|
onChange={(e) => onTitleChange(e.target.value)}
|
||||||
inputSize="medium"
|
inputSize="medium"
|
||||||
|
showHelpIcon={false}
|
||||||
/>
|
/>
|
||||||
{showDescription ? (
|
{showDescription ? (
|
||||||
<ModalTextAreaField
|
<ModalTextAreaField
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared "labelled text area" field used by every create flow modal section.
|
* 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
|
* Pairs an `InputLabel` with a `TextArea` set to the embedded appearance —
|
||||||
* appearance — matching the Figma "Control / Text Area" pattern.
|
* matching the Figma "Control / Text Area" pattern. Section help marks stay
|
||||||
|
* off until tooltip behavior ships.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { memo, useId } from "react";
|
import { memo, useId } from "react";
|
||||||
@@ -13,7 +14,7 @@ import InputLabel from "../../../components/type/InputLabel";
|
|||||||
export interface ModalTextAreaFieldProps {
|
export interface ModalTextAreaFieldProps {
|
||||||
/** Label rendered above the text area. */
|
/** Label rendered above the text area. */
|
||||||
label: string;
|
label: string;
|
||||||
/** Show the help "?" icon next to the label (default `true`). */
|
/** Show the help "?" icon next to the label. Off until tooltip behavior ships. */
|
||||||
helpIcon?: boolean;
|
helpIcon?: boolean;
|
||||||
/** Current text value. */
|
/** Current text value. */
|
||||||
value: string;
|
value: string;
|
||||||
@@ -30,7 +31,7 @@ export interface ModalTextAreaFieldProps {
|
|||||||
|
|
||||||
function ModalTextAreaFieldComponent({
|
function ModalTextAreaFieldComponent({
|
||||||
label,
|
label,
|
||||||
helpIcon = true,
|
helpIcon = false,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
rows = 4,
|
rows = 4,
|
||||||
|
|||||||
@@ -1,13 +1,60 @@
|
|||||||
/** Single column/section: full width under `md`, max 640px from `--breakpoint-md` up. */
|
import type { CreateFlowStep } from "../types";
|
||||||
|
import { CREATE_FLOW_SCREEN_REGISTRY } from "../utils/createFlowScreenRegistry";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared page gutters for create-flow chrome and step shells.
|
||||||
|
* Six viewport bands collapse to three values: 20px below `md`, 48px from `md`,
|
||||||
|
* 64px from `lg` (320–639 / 640–1023 / 1024+).
|
||||||
|
*/
|
||||||
|
export const CREATE_FLOW_PAGE_GUTTER_CLASS = "px-5 md:px-12 lg:px-16";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wizard viewport frame. `dvh` tracks mobile browser chrome so the action bar
|
||||||
|
* stays on-screen; `max-h-dvh` keeps the flex column from overflowing it.
|
||||||
|
*/
|
||||||
|
export const CREATE_FLOW_VIEWPORT_FRAME_CLASS =
|
||||||
|
"relative flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden";
|
||||||
|
|
||||||
|
/** Design-system scrollbar for create-flow scroll regions (not `scrollbar-hide`). */
|
||||||
|
export const CREATE_FLOW_SCROLL_REGION_CLASS = "scrollbar-design";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fade above the fixed action bar so content that continues below the fold is
|
||||||
|
* visible as a continuation cue (Figma Scrim / footer overlay).
|
||||||
|
*/
|
||||||
|
export const CREATE_FLOW_FOOTER_SCRIM_CLASS =
|
||||||
|
"pointer-events-none absolute inset-x-0 top-0 z-[1] h-8 -translate-y-full bg-gradient-to-t from-[var(--color-surface-default-primary,#000)] to-transparent";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single column: full width below `lg` so tablet widths use available space;
|
||||||
|
* 640px from `--breakpoint-lg` up (two-column layouts start at `lg`).
|
||||||
|
*/
|
||||||
export const CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS =
|
export const CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS =
|
||||||
"w-full min-w-0 md:max-w-[640px]";
|
"w-full min-w-0 lg:max-w-[640px]";
|
||||||
|
|
||||||
/** Grid cell: same cap as column max, centered when the track is wider than 640px. */
|
/** Grid cell: same cap as column max, centered when the track is wider than 640px. */
|
||||||
export const CREATE_FLOW_MD_UP_GRID_CELL_CLASS =
|
export const CREATE_FLOW_MD_UP_GRID_CELL_CLASS =
|
||||||
"w-full min-w-0 md:mx-auto md:max-w-[640px]";
|
"w-full min-w-0 lg:mx-auto lg:max-w-[640px]";
|
||||||
|
|
||||||
/** Two 640px columns + `--measures-spacing-1200` (48px) gutter. */
|
/** Two 640px columns + `--measures-spacing-1200` (48px) gutter. */
|
||||||
export const CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS = "md:max-w-[1328px]";
|
export const CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS =
|
||||||
|
"w-full min-w-0 lg:max-w-[1328px]";
|
||||||
|
|
||||||
|
/** Review / card-stack frames (Figma Flow — Review max 1440). */
|
||||||
|
export const CREATE_FLOW_WIDE_MAX_CLASS = "w-full min-w-0 lg:max-w-[1440px]";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lockup+card and card-stack `<main>`: keep `items-start` so a tall card can
|
||||||
|
* scroll from the top. Pair with {@link CREATE_FLOW_MD_CENTERED_SHELL_CLASS}.
|
||||||
|
*/
|
||||||
|
export const CREATE_FLOW_MD_CENTERED_MAIN_CLASS =
|
||||||
|
"items-start justify-center overflow-y-auto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Center the step in the nav–footer band from `md` when it fits (`my-auto`),
|
||||||
|
* and drop the mobile top inset so that centering is not biased downward.
|
||||||
|
*/
|
||||||
|
export const CREATE_FLOW_MD_CENTERED_SHELL_CLASS = "md:my-auto md:pt-0";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Card-stack steps only (Figma compact card stack): wider than header lockup so the card grid /
|
* Card-stack steps only (Figma compact card stack): wider than header lockup so the card grid /
|
||||||
@@ -16,3 +63,31 @@ export const CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS = "md:max-w-[1328px]";
|
|||||||
*/
|
*/
|
||||||
export const CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS =
|
export const CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS =
|
||||||
"w-full min-w-0 md:max-w-[min(100%,860px)]";
|
"w-full min-w-0 md:max-w-[min(100%,860px)]";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inner max-width for step content and the action bar, so the primary button
|
||||||
|
* lines up with the nearest content column above 1440.
|
||||||
|
*/
|
||||||
|
export function getCreateFlowContentMaxClass(options: {
|
||||||
|
step: CreateFlowStep | null | undefined;
|
||||||
|
isTemplateReview?: boolean;
|
||||||
|
isTemplatesPicker?: boolean;
|
||||||
|
}): string {
|
||||||
|
if (options.isTemplateReview || options.isTemplatesPicker) {
|
||||||
|
return CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS;
|
||||||
|
}
|
||||||
|
if (!options.step) {
|
||||||
|
return CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS;
|
||||||
|
}
|
||||||
|
switch (CREATE_FLOW_SCREEN_REGISTRY[options.step].layoutKind) {
|
||||||
|
case "select":
|
||||||
|
case "right-rail":
|
||||||
|
case "completed":
|
||||||
|
case "review":
|
||||||
|
return CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS;
|
||||||
|
case "card":
|
||||||
|
return CREATE_FLOW_WIDE_MAX_CLASS;
|
||||||
|
default:
|
||||||
|
return CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import type { CoreValueDetailEntry } from "../../types";
|
|||||||
export interface CoreValueEditFieldsProps {
|
export interface CoreValueEditFieldsProps {
|
||||||
value: CoreValueDetailEntry;
|
value: CoreValueDetailEntry;
|
||||||
onChange: (_next: CoreValueDetailEntry) => void;
|
onChange: (_next: CoreValueDetailEntry) => void;
|
||||||
/** View mode until the user taps **Customize**. */
|
/** Disable meaning/signals. Facet and final-review callers omit this so fields stay editable on open. */
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,13 @@
|
|||||||
* `markCreateFlowInteraction` live in the parent.
|
* `markCreateFlowInteraction` live in the parent.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { memo, useCallback } from "react";
|
import { memo, useCallback, useMemo } from "react";
|
||||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import ModalTextAreaField from "../ModalTextAreaField";
|
import ModalTextAreaField from "../ModalTextAreaField";
|
||||||
import ApplicableScopeField from "../ApplicableScopeField";
|
import ApplicableScopeField from "../ApplicableScopeField";
|
||||||
import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
|
import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
|
||||||
import type { DecisionApproachDetailEntry } from "../../types";
|
import type { DecisionApproachDetailEntry } from "../../types";
|
||||||
|
import { withDecisionApproachKeyResourceScopes } from "../../../../../lib/create/decisionApproachKeyResources";
|
||||||
|
|
||||||
export interface DecisionApproachEditFieldsProps {
|
export interface DecisionApproachEditFieldsProps {
|
||||||
value: DecisionApproachDetailEntry;
|
value: DecisionApproachDetailEntry;
|
||||||
@@ -32,6 +33,11 @@ function DecisionApproachEditFieldsComponent({
|
|||||||
const m = useMessages();
|
const m = useMessages();
|
||||||
const t = m.create.customRule.decisionApproaches;
|
const t = m.create.customRule.decisionApproaches;
|
||||||
|
|
||||||
|
const scopes = useMemo(
|
||||||
|
() => withDecisionApproachKeyResourceScopes(value.applicableScope),
|
||||||
|
[value.applicableScope],
|
||||||
|
);
|
||||||
|
|
||||||
const patch = useCallback(
|
const patch = useCallback(
|
||||||
<K extends keyof DecisionApproachDetailEntry>(
|
<K extends keyof DecisionApproachDetailEntry>(
|
||||||
key: K,
|
key: K,
|
||||||
@@ -53,7 +59,7 @@ function DecisionApproachEditFieldsComponent({
|
|||||||
<ApplicableScopeField
|
<ApplicableScopeField
|
||||||
label={t.sectionHeadings.applicableScope}
|
label={t.sectionHeadings.applicableScope}
|
||||||
addLabel={t.scopeAddButtonLabel}
|
addLabel={t.scopeAddButtonLabel}
|
||||||
scopes={value.applicableScope}
|
scopes={scopes}
|
||||||
selectedScopes={value.selectedApplicableScope}
|
selectedScopes={value.selectedApplicableScope}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
onToggleScope={(scope) =>
|
onToggleScope={(scope) =>
|
||||||
@@ -74,18 +80,21 @@ function DecisionApproachEditFieldsComponent({
|
|||||||
onChange={(v) => patch("stepByStepInstructions", v)}
|
onChange={(v) => patch("stepByStepInstructions", v)}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
<IncrementerBlock
|
{value.consensusLevel !== undefined ? (
|
||||||
label={t.sectionHeadings.consensusLevel}
|
<IncrementerBlock
|
||||||
value={value.consensusLevel}
|
label={t.sectionHeadings.consensusLevel}
|
||||||
min={CONSENSUS_LEVEL_MIN}
|
helpIcon={false}
|
||||||
max={CONSENSUS_LEVEL_MAX}
|
value={value.consensusLevel}
|
||||||
step={CONSENSUS_LEVEL_STEP}
|
min={CONSENSUS_LEVEL_MIN}
|
||||||
onChange={(next) => patch("consensusLevel", next)}
|
max={CONSENSUS_LEVEL_MAX}
|
||||||
formatValue={(v) => `${v}%`}
|
step={CONSENSUS_LEVEL_STEP}
|
||||||
decrementAriaLabel="Decrease consensus level"
|
onChange={(next) => patch("consensusLevel", next)}
|
||||||
incrementAriaLabel="Increase consensus level"
|
formatValue={(v) => `${v}%`}
|
||||||
disabled={readOnly}
|
decrementAriaLabel="Decrease consensus level"
|
||||||
/>
|
incrementAriaLabel="Increase consensus level"
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<ModalTextAreaField
|
<ModalTextAreaField
|
||||||
label={t.sectionHeadings.objectionsDeadlocks}
|
label={t.sectionHeadings.objectionsDeadlocks}
|
||||||
value={value.objectionsDeadlocks}
|
value={value.objectionsDeadlocks}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
|
|
||||||
export type CompletedFlowActionBanner = {
|
export type CompletedFlowActionBanner = {
|
||||||
key: string;
|
key: string;
|
||||||
status: "positive" | "danger";
|
status: "positive" | "danger" | "warning";
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
@@ -96,7 +96,7 @@ export function useCompletedRuleShareExport({
|
|||||||
}: {
|
}: {
|
||||||
setActionBanner: (_: CompletedFlowActionBanner | null) => void;
|
setActionBanner: (_: CompletedFlowActionBanner | null) => void;
|
||||||
}): {
|
}): {
|
||||||
copyPublishedRuleLink: () => Promise<void>;
|
copyPublishedRuleLink: () => Promise<boolean>;
|
||||||
mailtoPublishedRule: () => void;
|
mailtoPublishedRule: () => void;
|
||||||
sharePublishedRuleViaSignal: () => Promise<void>;
|
sharePublishedRuleViaSignal: () => Promise<void>;
|
||||||
sharePublishedRuleViaSlack: () => Promise<void>;
|
sharePublishedRuleViaSlack: () => Promise<void>;
|
||||||
@@ -137,32 +137,34 @@ export function useCompletedRuleShareExport({
|
|||||||
url: string,
|
url: string,
|
||||||
banner?: () => void,
|
banner?: () => void,
|
||||||
options?: { suppressFailureWhenDocumentNotFocused?: boolean },
|
options?: { suppressFailureWhenDocumentNotFocused?: boolean },
|
||||||
) => {
|
): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(url);
|
await navigator.clipboard.writeText(url);
|
||||||
(banner ?? bannerCopied)();
|
(banner ?? bannerCopied)();
|
||||||
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
if (
|
if (
|
||||||
options?.suppressFailureWhenDocumentNotFocused === true &&
|
options?.suppressFailureWhenDocumentNotFocused === true &&
|
||||||
typeof window !== "undefined" &&
|
typeof window !== "undefined" &&
|
||||||
shouldSkipShareClipboardFallback(window)
|
shouldSkipShareClipboardFallback(window)
|
||||||
) {
|
) {
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
bannerCopyFailed();
|
bannerCopyFailed();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[bannerCopied, bannerCopyFailed],
|
[bannerCopied, bannerCopyFailed],
|
||||||
);
|
);
|
||||||
|
|
||||||
const copyPublishedRuleLink = useCallback(async () => {
|
const copyPublishedRuleLink = useCallback(async () => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return false;
|
||||||
const ctx = resolvePublishedRuleShareContext(window);
|
const ctx = resolvePublishedRuleShareContext(window);
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
bannerNoRule();
|
bannerNoRule();
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
await copyUrlToClipboard(ctx.url);
|
return copyUrlToClipboard(ctx.url);
|
||||||
}, [bannerNoRule, copyUrlToClipboard]);
|
}, [bannerNoRule, copyUrlToClipboard]);
|
||||||
|
|
||||||
const mailtoPublishedRule = useCallback(() => {
|
const mailtoPublishedRule = useCallback(() => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
|
|||||||
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
|
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
|
||||||
import { publishRule, updatePublishedRule } from "../../../../lib/create/api";
|
import { publishRule, updatePublishedRule } from "../../../../lib/create/api";
|
||||||
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
|
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
|
||||||
|
import { writePendingKeepRuleLogin } from "../../../../lib/create/pendingKeepRuleLogin";
|
||||||
import messages from "../../../../messages/en/index";
|
import messages from "../../../../messages/en/index";
|
||||||
import type { CreateFlowState } from "../types";
|
import type { CreateFlowState } from "../types";
|
||||||
import {
|
import {
|
||||||
@@ -15,11 +16,13 @@ import { createFlowStepPath } from "../utils/createFlowPaths";
|
|||||||
type AppRouterLike = { push: (_href: string) => void };
|
type AppRouterLike = { push: (_href: string) => void };
|
||||||
|
|
||||||
type OpenLogin = (args: {
|
type OpenLogin = (args: {
|
||||||
variant: "default" | "saveProgress";
|
variant: "default" | "saveProgress" | "keepRule";
|
||||||
nextPath: string;
|
nextPath: string;
|
||||||
backdropVariant: "blurredYellow";
|
backdropVariant: "blurredYellow";
|
||||||
}) => void;
|
}) => void;
|
||||||
|
|
||||||
|
type SessionUser = { id: string; email: string };
|
||||||
|
|
||||||
export type UseCreateFlowFinalizeResult = {
|
export type UseCreateFlowFinalizeResult = {
|
||||||
publishBannerMessage: string | null;
|
publishBannerMessage: string | null;
|
||||||
setPublishBannerMessage: (_message: string | null) => void;
|
setPublishBannerMessage: (_message: string | null) => void;
|
||||||
@@ -34,6 +37,8 @@ export function useCreateFlowFinalize({
|
|||||||
openLogin,
|
openLogin,
|
||||||
updateState,
|
updateState,
|
||||||
loginReturnPath,
|
loginReturnPath,
|
||||||
|
sessionUser,
|
||||||
|
onGuestPublished,
|
||||||
}: {
|
}: {
|
||||||
state: CreateFlowState;
|
state: CreateFlowState;
|
||||||
router: AppRouterLike;
|
router: AppRouterLike;
|
||||||
@@ -41,6 +46,13 @@ export function useCreateFlowFinalize({
|
|||||||
updateState: (_patch: Partial<CreateFlowState>) => void;
|
updateState: (_patch: Partial<CreateFlowState>) => void;
|
||||||
/** Session gate return path (`?syncDraft=1`) — differs for `/create/edit-rule` vs `/create/final-review`. */
|
/** Session gate return path (`?syncDraft=1`) — differs for `/create/edit-rule` vs `/create/final-review`. */
|
||||||
loginReturnPath: string;
|
loginReturnPath: string;
|
||||||
|
/**
|
||||||
|
* `undefined` while `/api/auth/session` is in flight — publish is a no-op
|
||||||
|
* until it resolves. `null` is a guest (anonymous publish).
|
||||||
|
*/
|
||||||
|
sessionUser: SessionUser | null | undefined;
|
||||||
|
/** Guest publish succeeded; `skippedInvites` when stakeholder emails were not sent. */
|
||||||
|
onGuestPublished?: (_info: { skippedInvites: boolean }) => void;
|
||||||
}): UseCreateFlowFinalizeResult {
|
}): UseCreateFlowFinalizeResult {
|
||||||
const [publishBannerMessage, setPublishBannerMessage] = useState<
|
const [publishBannerMessage, setPublishBannerMessage] = useState<
|
||||||
string | null
|
string | null
|
||||||
@@ -48,6 +60,8 @@ export function useCreateFlowFinalize({
|
|||||||
const [isPublishing, setIsPublishing] = useState(false);
|
const [isPublishing, setIsPublishing] = useState(false);
|
||||||
|
|
||||||
const finalize = useCallback(async () => {
|
const finalize = useCallback(async () => {
|
||||||
|
if (sessionUser === undefined) return;
|
||||||
|
|
||||||
setPublishBannerMessage(null);
|
setPublishBannerMessage(null);
|
||||||
const payloadResult = buildPublishPayload(state);
|
const payloadResult = buildPublishPayload(state);
|
||||||
if (payloadResult.ok === false) {
|
if (payloadResult.ok === false) {
|
||||||
@@ -66,6 +80,21 @@ export function useCreateFlowFinalize({
|
|||||||
? state.editingPublishedRuleId.trim()
|
? state.editingPublishedRuleId.trim()
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
if (editingId.length > 0 && sessionUser === null) {
|
||||||
|
setIsPublishing(false);
|
||||||
|
writeLastPublishedRule({
|
||||||
|
id: editingId,
|
||||||
|
title,
|
||||||
|
summary: summary ?? null,
|
||||||
|
document: ruleDocument,
|
||||||
|
});
|
||||||
|
updateState({ editingPublishedRuleId: undefined });
|
||||||
|
writePendingKeepRuleLogin();
|
||||||
|
onGuestPublished?.({ skippedInvites: false });
|
||||||
|
router.push(createFlowStepPath("completed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (editingId.length > 0) {
|
if (editingId.length > 0) {
|
||||||
const updateResult = await updatePublishedRule(editingId, {
|
const updateResult = await updatePublishedRule(editingId, {
|
||||||
title,
|
title,
|
||||||
@@ -86,8 +115,11 @@ export function useCreateFlowFinalize({
|
|||||||
}
|
}
|
||||||
if (updateResult.status === 401) {
|
if (updateResult.status === 401) {
|
||||||
openLogin({
|
openLogin({
|
||||||
variant: "default",
|
variant: sessionUser === null ? "keepRule" : "default",
|
||||||
nextPath: loginReturnPath,
|
nextPath:
|
||||||
|
sessionUser === null
|
||||||
|
? createFlowStepPath("completed")
|
||||||
|
: loginReturnPath,
|
||||||
backdropVariant: "blurredYellow",
|
backdropVariant: "blurredYellow",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -100,9 +132,13 @@ export function useCreateFlowFinalize({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stakeholderEmails = (state.stakeholderEmails ?? []).filter(
|
const isGuest = sessionUser === null;
|
||||||
|
const rawStakeholderEmails = (state.stakeholderEmails ?? []).filter(
|
||||||
(e) => typeof e === "string" && e.trim() !== "",
|
(e) => typeof e === "string" && e.trim() !== "",
|
||||||
);
|
);
|
||||||
|
const stakeholderEmails = isGuest ? [] : rawStakeholderEmails;
|
||||||
|
const skippedGuestInvites = isGuest && rawStakeholderEmails.length > 0;
|
||||||
|
|
||||||
const publishResult = await publishRule({
|
const publishResult = await publishRule({
|
||||||
title,
|
title,
|
||||||
summary,
|
summary,
|
||||||
@@ -117,6 +153,10 @@ export function useCreateFlowFinalize({
|
|||||||
summary: summary ?? null,
|
summary: summary ?? null,
|
||||||
document: ruleDocument,
|
document: ruleDocument,
|
||||||
});
|
});
|
||||||
|
if (isGuest) {
|
||||||
|
writePendingKeepRuleLogin();
|
||||||
|
onGuestPublished?.({ skippedInvites: skippedGuestInvites });
|
||||||
|
}
|
||||||
router.push(
|
router.push(
|
||||||
createFlowStepPath("completed", {
|
createFlowStepPath("completed", {
|
||||||
[CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]:
|
[CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]:
|
||||||
@@ -127,8 +167,11 @@ export function useCreateFlowFinalize({
|
|||||||
}
|
}
|
||||||
if (publishResult.status === 401) {
|
if (publishResult.status === 401) {
|
||||||
openLogin({
|
openLogin({
|
||||||
variant: "default",
|
variant: sessionUser === null ? "keepRule" : "default",
|
||||||
nextPath: loginReturnPath,
|
nextPath:
|
||||||
|
sessionUser === null
|
||||||
|
? createFlowStepPath("completed")
|
||||||
|
: loginReturnPath,
|
||||||
backdropVariant: "blurredYellow",
|
backdropVariant: "blurredYellow",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -138,7 +181,15 @@ export function useCreateFlowFinalize({
|
|||||||
? publishResult.error
|
? publishResult.error
|
||||||
: messages.create.reviewAndComplete.publish.genericPublishFailed,
|
: messages.create.reviewAndComplete.publish.genericPublishFailed,
|
||||||
);
|
);
|
||||||
}, [state, router, openLogin, updateState, loginReturnPath]);
|
}, [
|
||||||
|
state,
|
||||||
|
router,
|
||||||
|
openLogin,
|
||||||
|
updateState,
|
||||||
|
loginReturnPath,
|
||||||
|
sessionUser,
|
||||||
|
onGuestPublished,
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
publishBannerMessage,
|
publishBannerMessage,
|
||||||
|
|||||||
@@ -34,13 +34,17 @@ const blurActiveElement = (): void => {
|
|||||||
*
|
*
|
||||||
* Resolves the active step from `/create/{screenId}` via
|
* Resolves the active step from `/create/{screenId}` via
|
||||||
* {@link parseCreateFlowScreenFromPathname} (flowSteps). Footer Back uses
|
* {@link parseCreateFlowScreenFromPathname} (flowSteps). Footer Back uses
|
||||||
* {@link resolveCreateFlowBackTarget} so template **Use without changes**
|
* {@link resolveCreateFlowBackTarget}: template **Use without changes** that
|
||||||
* (which skips the custom-rule segment) returns to `/create/review-template/{slug}`
|
* still needs identity (direct from a template) walks name / description /
|
||||||
* from `confirm-stakeholders` instead of `conflict-management`.
|
* photo, then Back from `community-name` returns to
|
||||||
|
* `/create/review-template/{slug}`. In-flow picks (`?fromFlow=1`) skip
|
||||||
|
* identity and land on `confirm-stakeholders`; Back from that step returns
|
||||||
|
* to the template instead of `conflict-management`.
|
||||||
*
|
*
|
||||||
* Template review footer Back uses {@link buildTemplateReviewHref}’s
|
* Template review footer Back uses {@link buildTemplateReviewHref}’s
|
||||||
* `?fromFlow=1` marker (and persisted `templateReviewEntryFromCreateFlow`) so
|
* `?fromFlow=1` marker (and persisted `templateReviewEntryFromCreateFlow`) so
|
||||||
* users who came from `/create/review` return there instead of `/`.
|
* users who came from `/create/review` via `/create/templates` return there
|
||||||
|
* instead of `/`.
|
||||||
*/
|
*/
|
||||||
export function useCreateFlowNavigation(
|
export function useCreateFlowNavigation(
|
||||||
options?: CreateFlowNavigationOptions,
|
options?: CreateFlowNavigationOptions,
|
||||||
@@ -83,17 +87,27 @@ export function useCreateFlowNavigation(
|
|||||||
updateState,
|
updateState,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const nextStep = getNextStep(validStep, options);
|
const identityOnly =
|
||||||
const previousStep = getPreviousStep(validStep, options);
|
state.pendingTemplateAction?.mode === "useWithoutChanges";
|
||||||
|
const navigationOptions = useMemo(
|
||||||
|
(): CreateFlowNavigationOptions => ({
|
||||||
|
skipCommunitySave: options?.skipCommunitySave,
|
||||||
|
useWithoutChangesIdentityOnly: identityOnly,
|
||||||
|
}),
|
||||||
|
[options?.skipCommunitySave, identityOnly],
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextStep = getNextStep(validStep, navigationOptions);
|
||||||
|
const previousStep = getPreviousStep(validStep, navigationOptions);
|
||||||
|
|
||||||
const backTarget = useMemo(
|
const backTarget = useMemo(
|
||||||
() =>
|
() =>
|
||||||
resolveCreateFlowBackTarget(
|
resolveCreateFlowBackTarget(
|
||||||
validStep,
|
validStep,
|
||||||
options,
|
navigationOptions,
|
||||||
state.templateReviewBackSlug,
|
state.templateReviewBackSlug,
|
||||||
),
|
),
|
||||||
[validStep, options?.skipCommunitySave, state.templateReviewBackSlug],
|
[validStep, navigationOptions, state.templateReviewBackSlug],
|
||||||
);
|
);
|
||||||
|
|
||||||
const goToNextStep = useCallback(() => {
|
const goToNextStep = useCallback(() => {
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import {
|
import { mergeCompactCardIdsWithPinnedSelected } from "../../../../lib/create/methodCardDisplayOrder";
|
||||||
mergeCompactCardIdsWithPinnedSelected,
|
|
||||||
orderRankedMethodsWithPinnedSelection,
|
|
||||||
} from "../../../../lib/create/methodCardDisplayOrder";
|
|
||||||
import {
|
import {
|
||||||
deriveCompactCards,
|
deriveCompactCards,
|
||||||
rankMethodsByScore,
|
rankMethodsByScore,
|
||||||
@@ -15,10 +12,10 @@ import {
|
|||||||
type MethodEntry = { id: string; label: string; supportText: string };
|
type MethodEntry = { id: string; label: string; supportText: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies score ranking, compact-slot rules, then surfaces selected ids first in
|
* Applies score ranking and compact-slot rules. Expanded CardStack order stays
|
||||||
* `selected*Ids` order (most-recent add at index 0 via
|
* the ranked catalog (selected cards keep their place). Compact slots still
|
||||||
* {@link moveFacetSelectionIdToFront}). Selection-first applies whenever the facet
|
* pin selected ids first so a pick outside the unpinned top-N remains visible
|
||||||
* has any selection — not only after footer Confirm (`methodSectionsPinCommitted`).
|
* when the stack is collapsed.
|
||||||
*/
|
*/
|
||||||
export function useMethodCardDeckOrdering(
|
export function useMethodCardDeckOrdering(
|
||||||
section: RecommendationSection,
|
section: RecommendationSection,
|
||||||
@@ -35,16 +32,7 @@ export function useMethodCardDeckOrdering(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const selectionShowcaseActive = selectedIds.length > 0;
|
const selectionShowcaseActive = selectedIds.length > 0;
|
||||||
|
const displayMethods = rankedMethods;
|
||||||
const displayMethods = useMemo(
|
|
||||||
() =>
|
|
||||||
orderRankedMethodsWithPinnedSelection(
|
|
||||||
rankedMethods,
|
|
||||||
selectedIds,
|
|
||||||
selectionShowcaseActive,
|
|
||||||
),
|
|
||||||
[rankedMethods, selectedIds, selectionShowcaseActive],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { compactCardIds: baseCompactCardIds, recommendedIds } = useMemo(
|
const { compactCardIds: baseCompactCardIds, recommendedIds } = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -60,13 +48,13 @@ export function useMethodCardDeckOrdering(
|
|||||||
const compactCardIds = useMemo(
|
const compactCardIds = useMemo(
|
||||||
() =>
|
() =>
|
||||||
mergeCompactCardIdsWithPinnedSelected(
|
mergeCompactCardIdsWithPinnedSelected(
|
||||||
displayMethods.map((m) => m.id),
|
rankedMethods.map((m) => m.id),
|
||||||
baseCompactCardIds,
|
baseCompactCardIds,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
selectionShowcaseActive,
|
selectionShowcaseActive,
|
||||||
5,
|
5,
|
||||||
),
|
),
|
||||||
[displayMethods, baseCompactCardIds, selectedIds, selectionShowcaseActive],
|
[rankedMethods, baseCompactCardIds, selectedIds, selectionShowcaseActive],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sampleCards = useMemo(
|
const sampleCards = useMemo(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
CreateFlowContextValue,
|
CreateFlowContextValue,
|
||||||
CreateFlowState,
|
CreateFlowState,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import { createFlowStepPath } from "../utils/createFlowPaths";
|
||||||
|
|
||||||
type AppRouterLike = { push: (_href: string) => void };
|
type AppRouterLike = { push: (_href: string) => void };
|
||||||
type UpdateState = CreateFlowContextValue["updateState"];
|
type UpdateState = CreateFlowContextValue["updateState"];
|
||||||
@@ -36,9 +37,10 @@ export type UseTemplateReviewActionsResult = {
|
|||||||
* Use without changes: scrub any prior customize picks, seed core values +
|
* Use without changes: scrub any prior customize picks, seed core values +
|
||||||
* method-card selections from the template body (same id mapping as
|
* method-card selections from the template body (same id mapping as
|
||||||
* Customize) so drilling from final-review via + shows selected cards, drop
|
* Customize) so drilling from final-review via + shows selected cards, drop
|
||||||
* the Values row from `state.sections`, and route to
|
* the Values row from `state.sections`. Direct template entry routes to
|
||||||
* `/create/confirm-stakeholders` (or `/create/informational` with a pin to
|
* `/create/community-name` with a pin so Next/Back collect only name →
|
||||||
* skip past `/create/review` to `/create/confirm-stakeholders` later).
|
* description → photo. In-flow (`?fromFlow=1`, community steps already done)
|
||||||
|
* routes to `/create/confirm-stakeholders`.
|
||||||
*/
|
*/
|
||||||
handleUseWithoutChanges: () => Promise<void>;
|
handleUseWithoutChanges: () => Promise<void>;
|
||||||
};
|
};
|
||||||
@@ -59,7 +61,10 @@ export type UseTemplateReviewActionsResult = {
|
|||||||
* setTemplateReviewApplyError,
|
* setTemplateReviewApplyError,
|
||||||
* handleCustomize,
|
* handleCustomize,
|
||||||
* handleUseWithoutChanges,
|
* handleUseWithoutChanges,
|
||||||
* } = useTemplateReviewActions({ pathname, state, updateState, replaceState, router });
|
* } = useTemplateReviewActions({
|
||||||
|
* pathname, state, updateState, replaceState, router,
|
||||||
|
* fromCreateWizard, markCreateFlowInteraction,
|
||||||
|
* });
|
||||||
*/
|
*/
|
||||||
export function useTemplateReviewActions({
|
export function useTemplateReviewActions({
|
||||||
pathname,
|
pathname,
|
||||||
@@ -67,12 +72,21 @@ export function useTemplateReviewActions({
|
|||||||
updateState,
|
updateState,
|
||||||
replaceState,
|
replaceState,
|
||||||
router,
|
router,
|
||||||
|
fromCreateWizard = false,
|
||||||
|
markCreateFlowInteraction,
|
||||||
}: {
|
}: {
|
||||||
pathname: string | null | undefined;
|
pathname: string | null | undefined;
|
||||||
state: CreateFlowState;
|
state: CreateFlowState;
|
||||||
updateState: UpdateState;
|
updateState: UpdateState;
|
||||||
replaceState: ReplaceStateFn;
|
replaceState: ReplaceStateFn;
|
||||||
router: AppRouterLike;
|
router: AppRouterLike;
|
||||||
|
/**
|
||||||
|
* True when this template-review URL was opened from the create wizard
|
||||||
|
* (`?fromFlow=1`). Direct catalog / marketing entry is false — a leftover
|
||||||
|
* draft title must not count as “community steps already done”.
|
||||||
|
*/
|
||||||
|
fromCreateWizard?: boolean;
|
||||||
|
markCreateFlowInteraction: () => void;
|
||||||
}): UseTemplateReviewActionsResult {
|
}): UseTemplateReviewActionsResult {
|
||||||
const [isApplyingTemplate, setIsApplyingTemplate] = useState(false);
|
const [isApplyingTemplate, setIsApplyingTemplate] = useState(false);
|
||||||
const [templateReviewApplyError, setTemplateReviewApplyError] = useState<
|
const [templateReviewApplyError, setTemplateReviewApplyError] = useState<
|
||||||
@@ -90,6 +104,7 @@ export function useTemplateReviewActions({
|
|||||||
|
|
||||||
const handleCustomize = useCallback(async () => {
|
const handleCustomize = useCallback(async () => {
|
||||||
if (!templateReviewSlug) return;
|
if (!templateReviewSlug) return;
|
||||||
|
markCreateFlowInteraction();
|
||||||
setTemplateReviewApplyError(null);
|
setTemplateReviewApplyError(null);
|
||||||
setIsApplyingTemplate(true);
|
setIsApplyingTemplate(true);
|
||||||
const loaded = await loadTemplateReviewBySlug(templateReviewSlug);
|
const loaded = await loadTemplateReviewBySlug(templateReviewSlug);
|
||||||
@@ -119,7 +134,9 @@ export function useTemplateReviewActions({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
router.push(
|
router.push(
|
||||||
hasCommunityName ? "/create/core-values" : "/create/informational",
|
hasCommunityName
|
||||||
|
? createFlowStepPath("core-values")
|
||||||
|
: createFlowStepPath("informational"),
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
router,
|
router,
|
||||||
@@ -127,10 +144,12 @@ export function useTemplateReviewActions({
|
|||||||
state.title,
|
state.title,
|
||||||
templateReviewSlug,
|
templateReviewSlug,
|
||||||
updateState,
|
updateState,
|
||||||
|
markCreateFlowInteraction,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleUseWithoutChanges = useCallback(async () => {
|
const handleUseWithoutChanges = useCallback(async () => {
|
||||||
if (!templateReviewSlug) return;
|
if (!templateReviewSlug) return;
|
||||||
|
markCreateFlowInteraction();
|
||||||
setTemplateReviewApplyError(null);
|
setTemplateReviewApplyError(null);
|
||||||
setIsApplyingTemplate(true);
|
setIsApplyingTemplate(true);
|
||||||
const loaded = await loadTemplateReviewBySlug(templateReviewSlug);
|
const loaded = await loadTemplateReviewBySlug(templateReviewSlug);
|
||||||
@@ -158,9 +177,6 @@ export function useTemplateReviewActions({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasCommunityName =
|
|
||||||
typeof state.title === "string" && state.title.trim().length > 0;
|
|
||||||
|
|
||||||
// Atomic read-modify-write: strip prior custom-rule picks and merge template
|
// Atomic read-modify-write: strip prior custom-rule picks and merge template
|
||||||
// body in one replaceState so method ids are never lost across React batching
|
// body in one replaceState so method ids are never lost across React batching
|
||||||
// (reset + update separately could leave selections undefined in Strict Mode).
|
// (reset + update separately could leave selections undefined in Strict Mode).
|
||||||
@@ -179,13 +195,10 @@ export function useTemplateReviewActions({
|
|||||||
})
|
})
|
||||||
: sections;
|
: sections;
|
||||||
|
|
||||||
const hasCommunityName =
|
|
||||||
typeof prev.title === "string" && prev.title.trim().length > 0;
|
|
||||||
|
|
||||||
const pinPatch =
|
const pinPatch =
|
||||||
methodSectionsPinsForHydratedSelections(customizePrefill);
|
methodSectionsPinsForHydratedSelections(customizePrefill);
|
||||||
|
|
||||||
return {
|
const seeded: CreateFlowState = {
|
||||||
...base,
|
...base,
|
||||||
...(hasValuesSeed
|
...(hasValuesSeed
|
||||||
? {
|
? {
|
||||||
@@ -221,22 +234,40 @@ export function useTemplateReviewActions({
|
|||||||
sections: sectionsWithoutValues,
|
sections: sectionsWithoutValues,
|
||||||
methodSectionsPinCommitted: pinPatch,
|
methodSectionsPinCommitted: pinPatch,
|
||||||
templateReviewBackSlug: templateReviewSlug,
|
templateReviewBackSlug: templateReviewSlug,
|
||||||
...(hasCommunityName
|
};
|
||||||
? { pendingTemplateAction: undefined }
|
|
||||||
: {
|
if (fromCreateWizard) {
|
||||||
pendingTemplateAction: {
|
return {
|
||||||
slug: templateReviewSlug,
|
...seeded,
|
||||||
mode: "useWithoutChanges",
|
pendingTemplateAction: undefined,
|
||||||
},
|
};
|
||||||
}),
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...seeded,
|
||||||
|
title: undefined,
|
||||||
|
communityContext: undefined,
|
||||||
|
communityAvatarUrl: undefined,
|
||||||
|
currentStep: undefined,
|
||||||
|
templateReviewEntryFromCreateFlow: undefined,
|
||||||
|
pendingTemplateAction: {
|
||||||
|
slug: templateReviewSlug,
|
||||||
|
mode: "useWithoutChanges",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
router.push(
|
router.push(
|
||||||
hasCommunityName
|
fromCreateWizard
|
||||||
? "/create/confirm-stakeholders"
|
? createFlowStepPath("confirm-stakeholders")
|
||||||
: "/create/informational",
|
: createFlowStepPath("community-name"),
|
||||||
);
|
);
|
||||||
}, [replaceState, router, state.title, templateReviewSlug]);
|
}, [
|
||||||
|
fromCreateWizard,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
replaceState,
|
||||||
|
router,
|
||||||
|
templateReviewSlug,
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isTemplateReviewRoute,
|
isTemplateReviewRoute,
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import CreateFlowLayoutGate from "./CreateFlowLayoutGate";
|
import CreateFlowLayoutGate from "./CreateFlowLayoutGate";
|
||||||
|
import messages from "../../../messages/en/index";
|
||||||
|
import { routeMetadata } from "../../../lib/siteMetadata";
|
||||||
|
|
||||||
|
export const metadata: Metadata = routeMetadata("/create", {
|
||||||
|
title: messages.metadata.create.title,
|
||||||
|
});
|
||||||
|
|
||||||
export default function CreateFlowLayout({ children }: { children: ReactNode }) {
|
export default function CreateFlowLayout({ children }: { children: ReactNode }) {
|
||||||
return <CreateFlowLayoutGate>{children}</CreateFlowLayoutGate>;
|
return <CreateFlowLayoutGate>{children}</CreateFlowLayoutGate>;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
export default function CreateFlowRouteLoading() {
|
export default function CreateFlowRouteLoading() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex h-screen min-h-0 flex-col overflow-hidden bg-[var(--color-surface-default-primary)]"
|
className="flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-[var(--color-surface-default-primary)]"
|
||||||
aria-busy="true"
|
aria-busy="true"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -15,14 +15,17 @@ import {
|
|||||||
CreateFlowLockupCardStepShell,
|
CreateFlowLockupCardStepShell,
|
||||||
} from "../../components/CreateFlowLockupCardStepShell";
|
} from "../../components/CreateFlowLockupCardStepShell";
|
||||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
import {
|
||||||
|
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
|
||||||
|
CREATE_FLOW_MD_CENTERED_SHELL_CLASS,
|
||||||
|
} from "../../components/createFlowLayoutTokens";
|
||||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Template review route — same shell/grid as final-review; Figma `22142-898702`. */
|
/** Template review — lockup + card, vertically centered (Figma `22142-898702`). */
|
||||||
export default function ReviewTemplatePage({ params }: PageProps) {
|
export default function ReviewTemplatePage({ params }: PageProps) {
|
||||||
const { slug: rawSlug } = use(params);
|
const { slug: rawSlug } = use(params);
|
||||||
const slug = decodeURIComponent(rawSlug);
|
const slug = decodeURIComponent(rawSlug);
|
||||||
@@ -72,7 +75,11 @@ export default function ReviewTemplatePage({ params }: PageProps) {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
|
<CreateFlowStepShell
|
||||||
|
variant="wideGrid"
|
||||||
|
contentTopBelowMd="space-800"
|
||||||
|
className={CREATE_FLOW_MD_CENTERED_SHELL_CLASS}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={`flex shrink-0 items-center justify-start pb-16 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
className={`flex shrink-0 items-center justify-start pb-16 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
||||||
>
|
>
|
||||||
@@ -101,7 +108,11 @@ export default function ReviewTemplatePage({ params }: PageProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
|
<CreateFlowStepShell
|
||||||
|
variant="wideGrid"
|
||||||
|
contentTopBelowMd="space-800"
|
||||||
|
className={CREATE_FLOW_MD_CENTERED_SHELL_CLASS}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={`min-h-[40vh] shrink-0 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
className={`min-h-[40vh] shrink-0 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
|
|||||||
@@ -6,30 +6,26 @@
|
|||||||
*
|
*
|
||||||
* Lives under `screens/card/` (not `select/`): Figma **card stack** layout is a distinct shell from
|
* 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
|
* two-column chip **select** frames. Future card-stack steps get their own `*Screen.tsx` here and
|
||||||
* reuse `CardStack` / `CreateFlowStepShell` as needed.
|
* reuse `CardStack` / `CreateFlowCardStackStepShell` as needed.
|
||||||
*
|
*
|
||||||
* Card click opens the Figma create modal (node `20246-15829`) with three
|
* Card click opens the Figma create modal (node `20246-15829`) with three
|
||||||
* editable sections rendered by {@link CommunicationMethodEditFields}. The primary
|
* editable sections rendered by {@link CommunicationMethodEditFields}. The
|
||||||
* action is **Add Platform** for an unselected card; a selected card in view mode has
|
* primary action is **Add Platform** for an unselected card and **Save** for a
|
||||||
* no footer primary — **Remove** is available from the kebab (same behavior as legacy
|
* selected card. **Remove** is a danger footer button on selected cards (and
|
||||||
* footer remove via {@link removeMethodCardFromFacetSelection}).
|
* remains in the kebab) via {@link removeMethodCardFromFacetSelection}.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useMemo, useRef } from "react";
|
import { useState, useCallback, useMemo, useRef } from "react";
|
||||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
|
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
|
||||||
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
||||||
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowCardStackStepShell } from "../../components/CreateFlowCardStackStepShell";
|
||||||
import CardStack from "../../../../components/cards/CardStack";
|
import CardStack from "../../../../components/cards/CardStack";
|
||||||
import Create from "../../../../components/modals/Create";
|
import Create from "../../../../components/modals/Create";
|
||||||
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
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 { CommunicationMethodEditFields } from "../../components/methodEditFields";
|
||||||
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
||||||
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
||||||
@@ -52,20 +48,21 @@ import type { CommunicationMethodDetailEntry } from "../../types";
|
|||||||
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
|
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
|
||||||
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
|
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
|
||||||
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
||||||
|
import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
|
||||||
|
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
|
||||||
import {
|
import {
|
||||||
captureMethodCardCustomizeSnapshot,
|
captureMethodCardCustomizeSnapshot,
|
||||||
|
isMethodCardCustomizeUnloadBlocked,
|
||||||
type MethodCardCustomizeSnapshot,
|
type MethodCardCustomizeSnapshot,
|
||||||
type MethodCardHeaderDraft,
|
type MethodCardHeaderDraft,
|
||||||
} from "../../../../../lib/create/methodCardCustomizeSession";
|
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||||
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
|
|
||||||
|
|
||||||
export function CommunicationMethodsScreen() {
|
export function CommunicationMethodsScreen() {
|
||||||
const m = useMessages();
|
const m = useMessages();
|
||||||
const comm = m.create.customRule.communication;
|
const comm = m.create.customRule.communication;
|
||||||
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
||||||
const mdUp = useCreateFlowMdUp();
|
const mdUp = useCreateFlowMdUp();
|
||||||
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
|
const { confirmDiscard, confirmDialog } = useDiscardCustomizeConfirm();
|
||||||
useDiscardCustomizeConfirm();
|
|
||||||
const { state, updateState, replaceState, markCreateFlowInteraction } =
|
const { state, updateState, replaceState, markCreateFlowInteraction } =
|
||||||
useCreateFlow();
|
useCreateFlow();
|
||||||
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
|
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
|
||||||
@@ -78,12 +75,24 @@ export function CommunicationMethodsScreen() {
|
|||||||
const [pendingDraft, setPendingDraft] =
|
const [pendingDraft, setPendingDraft] =
|
||||||
useState<CommunicationMethodDetailEntry | null>(null);
|
useState<CommunicationMethodDetailEntry | null>(null);
|
||||||
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
|
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
|
||||||
const [modalEditUnlocked, setModalEditUnlocked] = useState(false);
|
const [wizardCustomizeCardId, setWizardCustomizeCardId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [wizardInitialValues, setWizardInitialValues] =
|
||||||
|
useState<MethodCardWizardInitialValues | null>(null);
|
||||||
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
|
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
|
||||||
CustomMethodCardFieldBlock[] | null
|
CustomMethodCardFieldBlock[] | null
|
||||||
>(null);
|
>(null);
|
||||||
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
|
|
||||||
useState<MethodCardHeaderDraft | null>(null);
|
useBeforeUnloadGuard(
|
||||||
|
isMethodCardCustomizeUnloadBlocked(
|
||||||
|
createModalOpen,
|
||||||
|
customizeSnapshotRef.current,
|
||||||
|
pendingDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
customizeSnapshotRef.current?.headerDraft ?? null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const selectedIds = state.selectedCommunicationMethodIds ?? [];
|
const selectedIds = state.selectedCommunicationMethodIds ?? [];
|
||||||
|
|
||||||
@@ -106,6 +115,8 @@ export function CommunicationMethodsScreen() {
|
|||||||
|
|
||||||
const handleOpenAddWizard = useCallback(() => {
|
const handleOpenAddWizard = useCallback(() => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
|
setWizardCustomizeCardId(null);
|
||||||
|
setWizardInitialValues(null);
|
||||||
setAddCustomWizardOpen(true);
|
setAddCustomWizardOpen(true);
|
||||||
}, [markCreateFlowInteraction]);
|
}, [markCreateFlowInteraction]);
|
||||||
|
|
||||||
@@ -143,15 +154,40 @@ export function CommunicationMethodsScreen() {
|
|||||||
const handleCardClick = useCallback(
|
const handleCardClick = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
customizeSnapshotRef.current = null;
|
const draft = seedDraft(id);
|
||||||
setModalEditUnlocked(false);
|
const persistedBlocks = state.customMethodCardFieldBlocksById?.[id];
|
||||||
setDraftFieldBlocks(null);
|
const initialBlocks =
|
||||||
setCustomizeHeaderDraft(null);
|
Array.isArray(persistedBlocks) && persistedBlocks.length > 0
|
||||||
|
? structuredClone(persistedBlocks)
|
||||||
|
: null;
|
||||||
|
const method = methodById.get(id);
|
||||||
|
const meta = state.customMethodCardMetaById?.[id];
|
||||||
|
const headerDraft: MethodCardHeaderDraft = {
|
||||||
|
title: meta?.label ?? method?.label ?? comm.confirmModal.title,
|
||||||
|
description:
|
||||||
|
meta?.supportText ??
|
||||||
|
method?.supportText ??
|
||||||
|
comm.confirmModal.description,
|
||||||
|
};
|
||||||
|
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
||||||
|
draft,
|
||||||
|
initialBlocks,
|
||||||
|
headerDraft,
|
||||||
|
);
|
||||||
setPendingCardId(id);
|
setPendingCardId(id);
|
||||||
setPendingDraft(seedDraft(id));
|
setPendingDraft(draft);
|
||||||
|
setDraftFieldBlocks(initialBlocks);
|
||||||
setCreateModalOpen(true);
|
setCreateModalOpen(true);
|
||||||
},
|
},
|
||||||
[markCreateFlowInteraction, seedDraft],
|
[
|
||||||
|
comm.confirmModal.description,
|
||||||
|
comm.confirmModal.title,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
methodById,
|
||||||
|
seedDraft,
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDraftChange = useCallback(
|
const handleDraftChange = useCallback(
|
||||||
@@ -164,9 +200,7 @@ export function CommunicationMethodsScreen() {
|
|||||||
|
|
||||||
const isSelectedCardModal =
|
const isSelectedCardModal =
|
||||||
pendingCardId !== null && selectedIds.includes(pendingCardId);
|
pendingCardId !== null && selectedIds.includes(pendingCardId);
|
||||||
const fieldsLocked = !modalEditUnlocked;
|
const showMethodModalPrimary = true;
|
||||||
|
|
||||||
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
|
|
||||||
|
|
||||||
const customFacetDetailsMatchPreset = useMemo(() => {
|
const customFacetDetailsMatchPreset = useMemo(() => {
|
||||||
if (!pendingCardId || !pendingDraft) return false;
|
if (!pendingCardId || !pendingDraft) return false;
|
||||||
@@ -188,7 +222,7 @@ export function CommunicationMethodsScreen() {
|
|||||||
methodId: pendingCardId,
|
methodId: pendingCardId,
|
||||||
meta: state.customMethodCardMetaById,
|
meta: state.customMethodCardMetaById,
|
||||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||||
modalEditUnlocked,
|
modalEditUnlocked: true,
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
customFacetDetailsMatchPreset,
|
customFacetDetailsMatchPreset,
|
||||||
}),
|
}),
|
||||||
@@ -196,7 +230,6 @@ export function CommunicationMethodsScreen() {
|
|||||||
[
|
[
|
||||||
customFacetDetailsMatchPreset,
|
customFacetDetailsMatchPreset,
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
modalEditUnlocked,
|
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
state.customMethodCardFieldBlocksById,
|
state.customMethodCardFieldBlocksById,
|
||||||
state.customMethodCardMetaById,
|
state.customMethodCardMetaById,
|
||||||
@@ -206,11 +239,11 @@ export function CommunicationMethodsScreen() {
|
|||||||
const handleCreateModalClose = useCallback(async () => {
|
const handleCreateModalClose = useCallback(async () => {
|
||||||
if (
|
if (
|
||||||
!(await confirmDiscard(
|
!(await confirmDiscard(
|
||||||
modalEditUnlocked,
|
true,
|
||||||
customizeSnapshotRef.current,
|
customizeSnapshotRef.current,
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
customizeHeaderDraft,
|
customizeSnapshotRef.current?.headerDraft ?? null,
|
||||||
))
|
))
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -238,53 +271,14 @@ export function CommunicationMethodsScreen() {
|
|||||||
setCreateModalOpen(false);
|
setCreateModalOpen(false);
|
||||||
setPendingCardId(null);
|
setPendingCardId(null);
|
||||||
setPendingDraft(null);
|
setPendingDraft(null);
|
||||||
setModalEditUnlocked(false);
|
|
||||||
setDraftFieldBlocks(null);
|
setDraftFieldBlocks(null);
|
||||||
setCustomizeHeaderDraft(null);
|
|
||||||
}, [
|
}, [
|
||||||
confirmDiscard,
|
confirmDiscard,
|
||||||
customizeHeaderDraft,
|
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
modalEditUnlocked,
|
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
replaceState,
|
replaceState,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleCancelCustomize = useCallback(async () => {
|
|
||||||
if (!modalEditUnlocked) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const snap = customizeSnapshotRef.current;
|
|
||||||
if (!snap) {
|
|
||||||
customizeSnapshotRef.current = null;
|
|
||||||
setModalEditUnlocked(false);
|
|
||||||
setDraftFieldBlocks(null);
|
|
||||||
setCustomizeHeaderDraft(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!(await confirmDirtyCustomizeCancel(
|
|
||||||
snap,
|
|
||||||
pendingDraft,
|
|
||||||
draftFieldBlocks,
|
|
||||||
customizeHeaderDraft,
|
|
||||||
))
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPendingDraft(structuredClone(snap.pendingDraft));
|
|
||||||
setDraftFieldBlocks(null);
|
|
||||||
setModalEditUnlocked(false);
|
|
||||||
customizeSnapshotRef.current = null;
|
|
||||||
setCustomizeHeaderDraft(null);
|
|
||||||
}, [
|
|
||||||
confirmDirtyCustomizeCancel,
|
|
||||||
customizeHeaderDraft,
|
|
||||||
draftFieldBlocks,
|
|
||||||
modalEditUnlocked,
|
|
||||||
pendingDraft,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleRemoveSelectedFromModal = useCallback(async () => {
|
const handleRemoveSelectedFromModal = useCallback(async () => {
|
||||||
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
|
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
|
||||||
return;
|
return;
|
||||||
@@ -292,11 +286,11 @@ export function CommunicationMethodsScreen() {
|
|||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
if (
|
if (
|
||||||
!(await confirmDiscard(
|
!(await confirmDiscard(
|
||||||
modalEditUnlocked,
|
true,
|
||||||
customizeSnapshotRef.current,
|
customizeSnapshotRef.current,
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
customizeHeaderDraft,
|
customizeSnapshotRef.current?.headerDraft ?? null,
|
||||||
))
|
))
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -312,11 +306,9 @@ export function CommunicationMethodsScreen() {
|
|||||||
await handleCreateModalClose();
|
await handleCreateModalClose();
|
||||||
}, [
|
}, [
|
||||||
confirmDiscard,
|
confirmDiscard,
|
||||||
customizeHeaderDraft,
|
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
handleCreateModalClose,
|
handleCreateModalClose,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
modalEditUnlocked,
|
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
@@ -326,37 +318,36 @@ export function CommunicationMethodsScreen() {
|
|||||||
|
|
||||||
const handleCustomize = useCallback(() => {
|
const handleCustomize = useCallback(() => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
if (!pendingDraft || !pendingCardId) {
|
if (!pendingCardId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const persistedBlocks =
|
|
||||||
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [];
|
|
||||||
const initialFieldBlocks =
|
|
||||||
persistedBlocks.length > 0
|
|
||||||
? structuredClone(persistedBlocks)
|
|
||||||
: isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
|
||||||
? []
|
|
||||||
: null;
|
|
||||||
const method = methodById.get(pendingCardId);
|
const method = methodById.get(pendingCardId);
|
||||||
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
setWizardInitialValues(
|
||||||
const headerDraft: MethodCardHeaderDraft = {
|
buildMethodCardWizardInitialValues({
|
||||||
title: meta?.label ?? method?.label ?? comm.confirmModal.title,
|
cardId: pendingCardId,
|
||||||
description:
|
fallbackTitle: method?.label ?? comm.confirmModal.title,
|
||||||
meta?.supportText ??
|
fallbackDescription:
|
||||||
method?.supportText ??
|
method?.supportText ?? comm.confirmModal.description,
|
||||||
comm.confirmModal.description,
|
meta: state.customMethodCardMetaById,
|
||||||
};
|
persistedBlocks: state.customMethodCardFieldBlocksById,
|
||||||
setCustomizeHeaderDraft(headerDraft);
|
draftFieldBlocks,
|
||||||
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
facetPrefill: pendingDraft
|
||||||
pendingDraft,
|
? {
|
||||||
initialFieldBlocks,
|
group: "communication",
|
||||||
headerDraft,
|
draft: pendingDraft,
|
||||||
|
headings: comm.sectionHeadings,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
setDraftFieldBlocks(initialFieldBlocks);
|
setWizardCustomizeCardId(pendingCardId);
|
||||||
setModalEditUnlocked(true);
|
setCreateModalOpen(false);
|
||||||
|
setAddCustomWizardOpen(true);
|
||||||
}, [
|
}, [
|
||||||
comm.confirmModal.description,
|
comm.confirmModal.description,
|
||||||
comm.confirmModal.title,
|
comm.confirmModal.title,
|
||||||
|
comm.sectionHeadings,
|
||||||
|
draftFieldBlocks,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
methodById,
|
methodById,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
@@ -381,9 +372,7 @@ export function CommunicationMethodsScreen() {
|
|||||||
() => communicationPresetFor(newId),
|
() => communicationPresetFor(newId),
|
||||||
);
|
);
|
||||||
const blocksClone = structuredClone(
|
const blocksClone = structuredClone(
|
||||||
modalEditUnlocked &&
|
draftFieldBlocks !== null
|
||||||
draftFieldBlocks !== null &&
|
|
||||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
|
||||||
? draftFieldBlocks
|
? draftFieldBlocks
|
||||||
: cloneMethodCardBlocksForDuplicate(
|
: cloneMethodCardBlocksForDuplicate(
|
||||||
state.customMethodCardFieldBlocksById,
|
state.customMethodCardFieldBlocksById,
|
||||||
@@ -413,16 +402,15 @@ export function CommunicationMethodsScreen() {
|
|||||||
customizeSnapshotRef.current = null;
|
customizeSnapshotRef.current = null;
|
||||||
setPendingCardId(newId);
|
setPendingCardId(newId);
|
||||||
setPendingDraft(structuredClone(detailsClone));
|
setPendingDraft(structuredClone(detailsClone));
|
||||||
setModalEditUnlocked(false);
|
setDraftFieldBlocks(
|
||||||
setDraftFieldBlocks(null);
|
blocksClone.length > 0 ? structuredClone(blocksClone) : null,
|
||||||
setCustomizeHeaderDraft(null);
|
);
|
||||||
}, [
|
}, [
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
modalKebabMenu.duplicateTitleSuffix,
|
modalKebabMenu.duplicateTitleSuffix,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
modalEditUnlocked,
|
|
||||||
state.communicationMethodDetailsById,
|
state.communicationMethodDetailsById,
|
||||||
state.customMethodCardFieldBlocksById,
|
state.customMethodCardFieldBlocksById,
|
||||||
state.customMethodCardMetaById,
|
state.customMethodCardMetaById,
|
||||||
@@ -448,9 +436,7 @@ export function CommunicationMethodsScreen() {
|
|||||||
() => communicationPresetFor(newId),
|
() => communicationPresetFor(newId),
|
||||||
);
|
);
|
||||||
const blocksClone = structuredClone(
|
const blocksClone = structuredClone(
|
||||||
modalEditUnlocked &&
|
draftFieldBlocks !== null
|
||||||
draftFieldBlocks !== null &&
|
|
||||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
|
||||||
? draftFieldBlocks
|
? draftFieldBlocks
|
||||||
: cloneMethodCardBlocksForDuplicate(
|
: cloneMethodCardBlocksForDuplicate(
|
||||||
state.customMethodCardFieldBlocksById,
|
state.customMethodCardFieldBlocksById,
|
||||||
@@ -480,14 +466,13 @@ export function CommunicationMethodsScreen() {
|
|||||||
customizeSnapshotRef.current = null;
|
customizeSnapshotRef.current = null;
|
||||||
setPendingCardId(newId);
|
setPendingCardId(newId);
|
||||||
setPendingDraft(structuredClone(detailsClone));
|
setPendingDraft(structuredClone(detailsClone));
|
||||||
setModalEditUnlocked(false);
|
setDraftFieldBlocks(
|
||||||
setDraftFieldBlocks(null);
|
blocksClone.length > 0 ? structuredClone(blocksClone) : null,
|
||||||
setCustomizeHeaderDraft(null);
|
);
|
||||||
}, [
|
}, [
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
methodById,
|
methodById,
|
||||||
modalEditUnlocked,
|
|
||||||
modalKebabMenu.duplicateTitleSuffix,
|
modalKebabMenu.duplicateTitleSuffix,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
@@ -500,7 +485,7 @@ export function CommunicationMethodsScreen() {
|
|||||||
const kebabMenuItems = useMemo(
|
const kebabMenuItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
||||||
showCustomize: !modalEditUnlocked,
|
showCustomize: true,
|
||||||
onCustomize: handleCustomize,
|
onCustomize: handleCustomize,
|
||||||
onDuplicate:
|
onDuplicate:
|
||||||
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
|
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
|
||||||
@@ -520,7 +505,6 @@ export function CommunicationMethodsScreen() {
|
|||||||
handleDuplicatePrefabCard,
|
handleDuplicatePrefabCard,
|
||||||
handleRemoveSelectedFromModal,
|
handleRemoveSelectedFromModal,
|
||||||
isSelectedCardModal,
|
isSelectedCardModal,
|
||||||
modalEditUnlocked,
|
|
||||||
modalKebabMenu,
|
modalKebabMenu,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
state.customMethodCardMetaById,
|
state.customMethodCardMetaById,
|
||||||
@@ -539,7 +523,7 @@ export function CommunicationMethodsScreen() {
|
|||||||
meta?.supportText ??
|
meta?.supportText ??
|
||||||
method?.supportText ??
|
method?.supportText ??
|
||||||
comm.confirmModal.description,
|
comm.confirmModal.description,
|
||||||
nextButtonText: modalEditUnlocked
|
nextButtonText: isSelectedCardModal
|
||||||
? saveLabel
|
? saveLabel
|
||||||
: comm.addPlatform.nextButtonText,
|
: comm.addPlatform.nextButtonText,
|
||||||
};
|
};
|
||||||
@@ -551,8 +535,14 @@ export function CommunicationMethodsScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseAddWizard = useCallback(() => {
|
const handleCloseAddWizard = useCallback(() => {
|
||||||
|
const resumeCardId = wizardCustomizeCardId;
|
||||||
setAddCustomWizardOpen(false);
|
setAddCustomWizardOpen(false);
|
||||||
}, []);
|
setWizardCustomizeCardId(null);
|
||||||
|
setWizardInitialValues(null);
|
||||||
|
if (resumeCardId && pendingCardId === resumeCardId) {
|
||||||
|
setCreateModalOpen(true);
|
||||||
|
}
|
||||||
|
}, [pendingCardId, wizardCustomizeCardId]);
|
||||||
|
|
||||||
const handleFinalizeCustomCard = useCallback(
|
const handleFinalizeCustomCard = useCallback(
|
||||||
({
|
({
|
||||||
@@ -565,6 +555,42 @@ export function CommunicationMethodsScreen() {
|
|||||||
fieldBlocks: CustomMethodCardFieldBlock[];
|
fieldBlocks: CustomMethodCardFieldBlock[];
|
||||||
}) => {
|
}) => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
|
const existingId = wizardCustomizeCardId;
|
||||||
|
if (existingId) {
|
||||||
|
updateState({
|
||||||
|
selectedCommunicationMethodIds: moveFacetSelectionIdToFront(
|
||||||
|
selectedIds,
|
||||||
|
existingId,
|
||||||
|
),
|
||||||
|
customMethodCardMetaById: methodCardMetaWithCustomizeHeader(
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
existingId,
|
||||||
|
{ title, description },
|
||||||
|
),
|
||||||
|
...(pendingDraft
|
||||||
|
? {
|
||||||
|
communicationMethodDetailsById: {
|
||||||
|
...(state.communicationMethodDetailsById ?? {}),
|
||||||
|
[existingId]: pendingDraft,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
customMethodCardFieldBlocksById: {
|
||||||
|
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||||
|
[existingId]: fieldBlocks,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (pendingDraft) {
|
||||||
|
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
||||||
|
pendingDraft,
|
||||||
|
fieldBlocks,
|
||||||
|
{ title, description },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setDraftFieldBlocks(structuredClone(fieldBlocks));
|
||||||
|
setAddCustomWizardOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
updateState({
|
updateState({
|
||||||
selectedCommunicationMethodIds: moveFacetSelectionIdToFront(
|
selectedCommunicationMethodIds: moveFacetSelectionIdToFront(
|
||||||
@@ -587,91 +613,29 @@ export function CommunicationMethodsScreen() {
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
|
pendingDraft,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
state.communicationMethodDetailsById,
|
state.communicationMethodDetailsById,
|
||||||
state.customMethodCardFieldBlocksById,
|
state.customMethodCardFieldBlocksById,
|
||||||
state.customMethodCardMetaById,
|
state.customMethodCardMetaById,
|
||||||
updateState,
|
updateState,
|
||||||
|
wizardCustomizeCardId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreateModalPrimary = useCallback(() => {
|
const handleCreateModalPrimary = useCallback(() => {
|
||||||
if (!pendingCardId) {
|
if (!pendingCardId) {
|
||||||
handleCreateModalClose();
|
void handleCreateModalClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
|
|
||||||
if (selectedIds.includes(pendingCardId)) {
|
const persistWizardBlocks =
|
||||||
if (modalEditUnlocked) {
|
modalUsesWizardFieldBlocksBody && draftFieldBlocks !== null;
|
||||||
if (!customizeHeaderDraft) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextMeta = methodCardMetaWithCustomizeHeader(
|
|
||||||
state.customMethodCardMetaById,
|
|
||||||
pendingCardId,
|
|
||||||
customizeHeaderDraft,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
pendingCardId &&
|
|
||||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
|
||||||
usesWizardFieldBlocksModalBody({
|
|
||||||
methodId: pendingCardId,
|
|
||||||
meta: state.customMethodCardMetaById,
|
|
||||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
|
||||||
modalEditUnlocked,
|
|
||||||
draftFieldBlocks,
|
|
||||||
customFacetDetailsMatchPreset,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
updateState({
|
|
||||||
customMethodCardMetaById: nextMeta,
|
|
||||||
customMethodCardFieldBlocksById: {
|
|
||||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
|
||||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else if (pendingDraft) {
|
|
||||||
updateState({
|
|
||||||
customMethodCardMetaById: nextMeta,
|
|
||||||
communicationMethodDetailsById: {
|
|
||||||
...(state.communicationMethodDetailsById ?? {}),
|
|
||||||
[pendingCardId]: pendingDraft,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
customizeSnapshotRef.current = null;
|
|
||||||
setModalEditUnlocked(false);
|
|
||||||
setDraftFieldBlocks(null);
|
|
||||||
setCustomizeHeaderDraft(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (modalEditUnlocked) {
|
if (selectedIds.includes(pendingCardId)) {
|
||||||
if (!customizeHeaderDraft) {
|
if (persistWizardBlocks) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextMeta = methodCardMetaWithCustomizeHeader(
|
|
||||||
state.customMethodCardMetaById,
|
|
||||||
pendingCardId,
|
|
||||||
customizeHeaderDraft,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
pendingCardId &&
|
|
||||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
|
||||||
usesWizardFieldBlocksModalBody({
|
|
||||||
methodId: pendingCardId,
|
|
||||||
meta: state.customMethodCardMetaById,
|
|
||||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
|
||||||
modalEditUnlocked,
|
|
||||||
draftFieldBlocks,
|
|
||||||
customFacetDetailsMatchPreset,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
updateState({
|
updateState({
|
||||||
customMethodCardMetaById: nextMeta,
|
|
||||||
customMethodCardFieldBlocksById: {
|
customMethodCardFieldBlocksById: {
|
||||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||||
@@ -679,22 +643,20 @@ export function CommunicationMethodsScreen() {
|
|||||||
});
|
});
|
||||||
} else if (pendingDraft) {
|
} else if (pendingDraft) {
|
||||||
updateState({
|
updateState({
|
||||||
customMethodCardMetaById: nextMeta,
|
|
||||||
communicationMethodDetailsById: {
|
communicationMethodDetailsById: {
|
||||||
...(state.communicationMethodDetailsById ?? {}),
|
...(state.communicationMethodDetailsById ?? {}),
|
||||||
[pendingCardId]: pendingDraft,
|
[pendingCardId]: pendingDraft,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
pendingEphemeralDuplicateIdRef.current = null;
|
||||||
customizeSnapshotRef.current = null;
|
customizeSnapshotRef.current = null;
|
||||||
setModalEditUnlocked(false);
|
void handleCreateModalClose();
|
||||||
setDraftFieldBlocks(null);
|
|
||||||
setCustomizeHeaderDraft(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pendingDraft) {
|
if (!pendingDraft) {
|
||||||
handleCreateModalClose();
|
void handleCreateModalClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateState({
|
updateState({
|
||||||
@@ -706,15 +668,23 @@ export function CommunicationMethodsScreen() {
|
|||||||
...(state.communicationMethodDetailsById ?? {}),
|
...(state.communicationMethodDetailsById ?? {}),
|
||||||
[pendingCardId]: pendingDraft,
|
[pendingCardId]: pendingDraft,
|
||||||
},
|
},
|
||||||
|
...(persistWizardBlocks
|
||||||
|
? {
|
||||||
|
customMethodCardFieldBlocksById: {
|
||||||
|
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||||
|
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
pendingEphemeralDuplicateIdRef.current = null;
|
pendingEphemeralDuplicateIdRef.current = null;
|
||||||
handleCreateModalClose();
|
customizeSnapshotRef.current = null;
|
||||||
|
void handleCreateModalClose();
|
||||||
}, [
|
}, [
|
||||||
customizeHeaderDraft,
|
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
handleCreateModalClose,
|
handleCreateModalClose,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
modalEditUnlocked,
|
modalUsesWizardFieldBlocksBody,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
@@ -724,74 +694,41 @@ export function CommunicationMethodsScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CreateFlowStepShell
|
<CreateFlowCardStackStepShell
|
||||||
variant="wideGridLoosePadding"
|
lockupTitle={title}
|
||||||
contentTopBelowMd="space-800"
|
lockupDescription={description}
|
||||||
|
stackBusy={!recommendationsReady}
|
||||||
>
|
>
|
||||||
<div className="flex w-full min-w-0 flex-col items-center gap-6">
|
{recommendationsReady && (
|
||||||
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
|
<CardStack
|
||||||
<CreateFlowHeaderLockup
|
cards={sampleCards}
|
||||||
title={title}
|
selectedIds={selectedIds}
|
||||||
description={description}
|
onCardSelect={handleCardClick}
|
||||||
justification="center"
|
expanded={expanded}
|
||||||
/>
|
onToggleExpand={() => {
|
||||||
</div>
|
markCreateFlowInteraction();
|
||||||
<div
|
setExpanded((prev) => !prev);
|
||||||
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
|
}}
|
||||||
aria-busy={!recommendationsReady}
|
hasMore={true}
|
||||||
>
|
toggleLabel={comm.page.seeAllLink}
|
||||||
{recommendationsReady && (
|
compactRecommendedLimit={5}
|
||||||
<CardStack
|
compactCardIds={compactCardIds}
|
||||||
cards={sampleCards}
|
compactDesktopLayout="flexWrap"
|
||||||
selectedIds={selectedIds}
|
headerLockupSize={mdUp ? "L" : "M"}
|
||||||
onCardSelect={handleCardClick}
|
/>
|
||||||
expanded={expanded}
|
)}
|
||||||
onToggleExpand={() => {
|
</CreateFlowCardStackStepShell>
|
||||||
markCreateFlowInteraction();
|
|
||||||
setExpanded((prev) => !prev);
|
|
||||||
}}
|
|
||||||
hasMore={true}
|
|
||||||
toggleLabel={comm.page.seeAllLink}
|
|
||||||
compactRecommendedLimit={5}
|
|
||||||
compactCardIds={compactCardIds}
|
|
||||||
compactDesktopLayout="flexWrap"
|
|
||||||
headerLockupSize={mdUp ? "L" : "M"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Create
|
<Create
|
||||||
isOpen={createModalOpen}
|
isOpen={createModalOpen}
|
||||||
onClose={handleCreateModalClose}
|
onClose={handleCreateModalClose}
|
||||||
headerContent={
|
|
||||||
modalEditUnlocked && customizeHeaderDraft ? (
|
|
||||||
<MethodCardCustomizeModalHeader
|
|
||||||
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
|
|
||||||
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
|
|
||||||
titleValue={customizeHeaderDraft.title}
|
|
||||||
descriptionValue={customizeHeaderDraft.description}
|
|
||||||
onTitleChange={(title) =>
|
|
||||||
setCustomizeHeaderDraft((prev) =>
|
|
||||||
prev ? { ...prev, title } : null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onDescriptionChange={(description) =>
|
|
||||||
setCustomizeHeaderDraft((prev) =>
|
|
||||||
prev ? { ...prev, description } : null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
onNext={handleCreateModalPrimary}
|
onNext={handleCreateModalPrimary}
|
||||||
title={modalConfig.title}
|
title={modalConfig.title}
|
||||||
description={modalConfig.description}
|
description={modalConfig.description}
|
||||||
nextButtonText={modalConfig.nextButtonText}
|
nextButtonText={modalConfig.nextButtonText}
|
||||||
showBackButton={modalEditUnlocked}
|
showBackButton={false}
|
||||||
onBack={handleCancelCustomize}
|
|
||||||
backButtonText={modalKebabMenu.cancelCustomize}
|
|
||||||
showNextButton={showMethodModalPrimary}
|
showNextButton={showMethodModalPrimary}
|
||||||
|
showRemoveButton={isSelectedCardModal}
|
||||||
|
onRemove={handleRemoveSelectedFromModal}
|
||||||
backdropVariant="blurredYellow"
|
backdropVariant="blurredYellow"
|
||||||
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
||||||
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
|
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
|
||||||
@@ -803,14 +740,14 @@ export function CommunicationMethodsScreen() {
|
|||||||
cardId={pendingCardId}
|
cardId={pendingCardId}
|
||||||
blocksById={state.customMethodCardFieldBlocksById}
|
blocksById={state.customMethodCardFieldBlocksById}
|
||||||
blocksOverride={
|
blocksOverride={
|
||||||
modalEditUnlocked && draftFieldBlocks !== null
|
draftFieldBlocks !== null ? draftFieldBlocks : undefined
|
||||||
? draftFieldBlocks
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
|
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
|
||||||
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked}
|
showPolicyContentLockupWhenNoBlocks={
|
||||||
|
draftFieldBlocks === null || draftFieldBlocks.length === 0
|
||||||
|
}
|
||||||
onFieldBlocksChange={
|
onFieldBlocksChange={
|
||||||
fieldsLocked
|
draftFieldBlocks === null
|
||||||
? undefined
|
? undefined
|
||||||
: (next) => setDraftFieldBlocks(next)
|
: (next) => setDraftFieldBlocks(next)
|
||||||
}
|
}
|
||||||
@@ -819,15 +756,14 @@ export function CommunicationMethodsScreen() {
|
|||||||
<CommunicationMethodEditFields
|
<CommunicationMethodEditFields
|
||||||
value={pendingDraft}
|
value={pendingDraft}
|
||||||
onChange={handleDraftChange}
|
onChange={handleDraftChange}
|
||||||
readOnly={fieldsLocked}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</Create>
|
</Create>
|
||||||
</CreateFlowStepShell>
|
|
||||||
<CustomMethodCardWizard
|
<CustomMethodCardWizard
|
||||||
isOpen={addCustomWizardOpen}
|
isOpen={addCustomWizardOpen}
|
||||||
onClose={handleCloseAddWizard}
|
onClose={handleCloseAddWizard}
|
||||||
|
initialValues={wizardInitialValues}
|
||||||
onFinalize={handleFinalizeCustomCard}
|
onFinalize={handleFinalizeCustomCard}
|
||||||
onPersistCustomUploadFile={(file) =>
|
onPersistCustomUploadFile={(file) =>
|
||||||
uploadCreateFlowFile(file, "customMethodAttachment")
|
uploadCreateFlowFile(file, "customMethodAttachment")
|
||||||
|
|||||||
@@ -16,17 +16,13 @@ import { useState, useCallback, useMemo, useRef } from "react";
|
|||||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
|
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
|
||||||
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
||||||
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowCardStackStepShell } from "../../components/CreateFlowCardStackStepShell";
|
||||||
import CardStack from "../../../../components/cards/CardStack";
|
import CardStack from "../../../../components/cards/CardStack";
|
||||||
import Create from "../../../../components/modals/Create";
|
import Create from "../../../../components/modals/Create";
|
||||||
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
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 { ConflictManagementEditFields } from "../../components/methodEditFields";
|
||||||
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
||||||
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
||||||
@@ -49,20 +45,21 @@ import type { ConflictManagementDetailEntry } from "../../types";
|
|||||||
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
|
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
|
||||||
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
|
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
|
||||||
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
||||||
|
import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
|
||||||
|
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
|
||||||
import {
|
import {
|
||||||
captureMethodCardCustomizeSnapshot,
|
captureMethodCardCustomizeSnapshot,
|
||||||
|
isMethodCardCustomizeUnloadBlocked,
|
||||||
type MethodCardCustomizeSnapshot,
|
type MethodCardCustomizeSnapshot,
|
||||||
type MethodCardHeaderDraft,
|
type MethodCardHeaderDraft,
|
||||||
} from "../../../../../lib/create/methodCardCustomizeSession";
|
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||||
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
|
|
||||||
|
|
||||||
export function ConflictManagementScreen() {
|
export function ConflictManagementScreen() {
|
||||||
const m = useMessages();
|
const m = useMessages();
|
||||||
const cm = m.create.customRule.conflictManagement;
|
const cm = m.create.customRule.conflictManagement;
|
||||||
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
||||||
const mdUp = useCreateFlowMdUp();
|
const mdUp = useCreateFlowMdUp();
|
||||||
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
|
const { confirmDiscard, confirmDialog } = useDiscardCustomizeConfirm();
|
||||||
useDiscardCustomizeConfirm();
|
|
||||||
const { state, updateState, replaceState, markCreateFlowInteraction } =
|
const { state, updateState, replaceState, markCreateFlowInteraction } =
|
||||||
useCreateFlow();
|
useCreateFlow();
|
||||||
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
|
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
|
||||||
@@ -75,12 +72,24 @@ export function ConflictManagementScreen() {
|
|||||||
const [pendingDraft, setPendingDraft] =
|
const [pendingDraft, setPendingDraft] =
|
||||||
useState<ConflictManagementDetailEntry | null>(null);
|
useState<ConflictManagementDetailEntry | null>(null);
|
||||||
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
|
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
|
||||||
const [modalEditUnlocked, setModalEditUnlocked] = useState(false);
|
const [wizardCustomizeCardId, setWizardCustomizeCardId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [wizardInitialValues, setWizardInitialValues] =
|
||||||
|
useState<MethodCardWizardInitialValues | null>(null);
|
||||||
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
|
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
|
||||||
CustomMethodCardFieldBlock[] | null
|
CustomMethodCardFieldBlock[] | null
|
||||||
>(null);
|
>(null);
|
||||||
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
|
|
||||||
useState<MethodCardHeaderDraft | null>(null);
|
useBeforeUnloadGuard(
|
||||||
|
isMethodCardCustomizeUnloadBlocked(
|
||||||
|
createModalOpen,
|
||||||
|
customizeSnapshotRef.current,
|
||||||
|
pendingDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
customizeSnapshotRef.current?.headerDraft ?? null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const selectedIds = state.selectedConflictManagementIds ?? [];
|
const selectedIds = state.selectedConflictManagementIds ?? [];
|
||||||
|
|
||||||
@@ -103,6 +112,8 @@ export function ConflictManagementScreen() {
|
|||||||
|
|
||||||
const handleOpenAddWizard = useCallback(() => {
|
const handleOpenAddWizard = useCallback(() => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
|
setWizardCustomizeCardId(null);
|
||||||
|
setWizardInitialValues(null);
|
||||||
setAddCustomWizardOpen(true);
|
setAddCustomWizardOpen(true);
|
||||||
}, [markCreateFlowInteraction]);
|
}, [markCreateFlowInteraction]);
|
||||||
|
|
||||||
@@ -144,15 +155,40 @@ export function ConflictManagementScreen() {
|
|||||||
const handleCardClick = useCallback(
|
const handleCardClick = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
customizeSnapshotRef.current = null;
|
const draft = seedDraft(id);
|
||||||
setModalEditUnlocked(false);
|
const persistedBlocks = state.customMethodCardFieldBlocksById?.[id];
|
||||||
setDraftFieldBlocks(null);
|
const initialBlocks =
|
||||||
setCustomizeHeaderDraft(null);
|
Array.isArray(persistedBlocks) && persistedBlocks.length > 0
|
||||||
|
? structuredClone(persistedBlocks)
|
||||||
|
: null;
|
||||||
|
const method = methodById.get(id);
|
||||||
|
const meta = state.customMethodCardMetaById?.[id];
|
||||||
|
const headerDraft: MethodCardHeaderDraft = {
|
||||||
|
title: meta?.label ?? method?.label ?? cm.confirmModal.title,
|
||||||
|
description:
|
||||||
|
meta?.supportText ??
|
||||||
|
method?.supportText ??
|
||||||
|
cm.confirmModal.description,
|
||||||
|
};
|
||||||
|
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
||||||
|
draft,
|
||||||
|
initialBlocks,
|
||||||
|
headerDraft,
|
||||||
|
);
|
||||||
setPendingCardId(id);
|
setPendingCardId(id);
|
||||||
setPendingDraft(seedDraft(id));
|
setPendingDraft(draft);
|
||||||
|
setDraftFieldBlocks(initialBlocks);
|
||||||
setCreateModalOpen(true);
|
setCreateModalOpen(true);
|
||||||
},
|
},
|
||||||
[markCreateFlowInteraction, seedDraft],
|
[
|
||||||
|
cm.confirmModal.description,
|
||||||
|
cm.confirmModal.title,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
methodById,
|
||||||
|
seedDraft,
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDraftChange = useCallback(
|
const handleDraftChange = useCallback(
|
||||||
@@ -165,9 +201,7 @@ export function ConflictManagementScreen() {
|
|||||||
|
|
||||||
const isSelectedCardModal =
|
const isSelectedCardModal =
|
||||||
pendingCardId !== null && selectedIds.includes(pendingCardId);
|
pendingCardId !== null && selectedIds.includes(pendingCardId);
|
||||||
const fieldsLocked = !modalEditUnlocked;
|
const showMethodModalPrimary = true;
|
||||||
|
|
||||||
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
|
|
||||||
|
|
||||||
const customFacetDetailsMatchPreset = useMemo(() => {
|
const customFacetDetailsMatchPreset = useMemo(() => {
|
||||||
if (!pendingCardId || !pendingDraft) return false;
|
if (!pendingCardId || !pendingDraft) return false;
|
||||||
@@ -189,7 +223,7 @@ export function ConflictManagementScreen() {
|
|||||||
methodId: pendingCardId,
|
methodId: pendingCardId,
|
||||||
meta: state.customMethodCardMetaById,
|
meta: state.customMethodCardMetaById,
|
||||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||||
modalEditUnlocked,
|
modalEditUnlocked: true,
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
customFacetDetailsMatchPreset,
|
customFacetDetailsMatchPreset,
|
||||||
}),
|
}),
|
||||||
@@ -197,7 +231,6 @@ export function ConflictManagementScreen() {
|
|||||||
[
|
[
|
||||||
customFacetDetailsMatchPreset,
|
customFacetDetailsMatchPreset,
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
modalEditUnlocked,
|
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
state.customMethodCardFieldBlocksById,
|
state.customMethodCardFieldBlocksById,
|
||||||
state.customMethodCardMetaById,
|
state.customMethodCardMetaById,
|
||||||
@@ -207,11 +240,11 @@ export function ConflictManagementScreen() {
|
|||||||
const handleCreateModalClose = useCallback(async () => {
|
const handleCreateModalClose = useCallback(async () => {
|
||||||
if (
|
if (
|
||||||
!(await confirmDiscard(
|
!(await confirmDiscard(
|
||||||
modalEditUnlocked,
|
true,
|
||||||
customizeSnapshotRef.current,
|
customizeSnapshotRef.current,
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
customizeHeaderDraft,
|
customizeSnapshotRef.current?.headerDraft ?? null,
|
||||||
))
|
))
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -239,53 +272,14 @@ export function ConflictManagementScreen() {
|
|||||||
setCreateModalOpen(false);
|
setCreateModalOpen(false);
|
||||||
setPendingCardId(null);
|
setPendingCardId(null);
|
||||||
setPendingDraft(null);
|
setPendingDraft(null);
|
||||||
setModalEditUnlocked(false);
|
|
||||||
setDraftFieldBlocks(null);
|
setDraftFieldBlocks(null);
|
||||||
setCustomizeHeaderDraft(null);
|
|
||||||
}, [
|
}, [
|
||||||
confirmDiscard,
|
confirmDiscard,
|
||||||
customizeHeaderDraft,
|
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
modalEditUnlocked,
|
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
replaceState,
|
replaceState,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleCancelCustomize = useCallback(async () => {
|
|
||||||
if (!modalEditUnlocked) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const snap = customizeSnapshotRef.current;
|
|
||||||
if (!snap) {
|
|
||||||
customizeSnapshotRef.current = null;
|
|
||||||
setModalEditUnlocked(false);
|
|
||||||
setDraftFieldBlocks(null);
|
|
||||||
setCustomizeHeaderDraft(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!(await confirmDirtyCustomizeCancel(
|
|
||||||
snap,
|
|
||||||
pendingDraft,
|
|
||||||
draftFieldBlocks,
|
|
||||||
customizeHeaderDraft,
|
|
||||||
))
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPendingDraft(structuredClone(snap.pendingDraft));
|
|
||||||
setDraftFieldBlocks(null);
|
|
||||||
setModalEditUnlocked(false);
|
|
||||||
customizeSnapshotRef.current = null;
|
|
||||||
setCustomizeHeaderDraft(null);
|
|
||||||
}, [
|
|
||||||
confirmDirtyCustomizeCancel,
|
|
||||||
customizeHeaderDraft,
|
|
||||||
draftFieldBlocks,
|
|
||||||
modalEditUnlocked,
|
|
||||||
pendingDraft,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleRemoveSelectedFromModal = useCallback(async () => {
|
const handleRemoveSelectedFromModal = useCallback(async () => {
|
||||||
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
|
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
|
||||||
return;
|
return;
|
||||||
@@ -293,11 +287,11 @@ export function ConflictManagementScreen() {
|
|||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
if (
|
if (
|
||||||
!(await confirmDiscard(
|
!(await confirmDiscard(
|
||||||
modalEditUnlocked,
|
true,
|
||||||
customizeSnapshotRef.current,
|
customizeSnapshotRef.current,
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
customizeHeaderDraft,
|
customizeSnapshotRef.current?.headerDraft ?? null,
|
||||||
))
|
))
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -313,11 +307,9 @@ export function ConflictManagementScreen() {
|
|||||||
await handleCreateModalClose();
|
await handleCreateModalClose();
|
||||||
}, [
|
}, [
|
||||||
confirmDiscard,
|
confirmDiscard,
|
||||||
customizeHeaderDraft,
|
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
handleCreateModalClose,
|
handleCreateModalClose,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
modalEditUnlocked,
|
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
@@ -327,35 +319,36 @@ export function ConflictManagementScreen() {
|
|||||||
|
|
||||||
const handleCustomize = useCallback(() => {
|
const handleCustomize = useCallback(() => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
if (!pendingDraft || !pendingCardId) {
|
if (!pendingCardId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const initialFieldBlocks =
|
|
||||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
|
||||||
? structuredClone(
|
|
||||||
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [],
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const method = methodById.get(pendingCardId);
|
const method = methodById.get(pendingCardId);
|
||||||
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
setWizardInitialValues(
|
||||||
const headerDraft: MethodCardHeaderDraft = {
|
buildMethodCardWizardInitialValues({
|
||||||
title: meta?.label ?? method?.label ?? cm.confirmModal.title,
|
cardId: pendingCardId,
|
||||||
description:
|
fallbackTitle: method?.label ?? cm.confirmModal.title,
|
||||||
meta?.supportText ??
|
fallbackDescription:
|
||||||
method?.supportText ??
|
method?.supportText ?? cm.confirmModal.description,
|
||||||
cm.confirmModal.description,
|
meta: state.customMethodCardMetaById,
|
||||||
};
|
persistedBlocks: state.customMethodCardFieldBlocksById,
|
||||||
setCustomizeHeaderDraft(headerDraft);
|
draftFieldBlocks,
|
||||||
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
facetPrefill: pendingDraft
|
||||||
pendingDraft,
|
? {
|
||||||
initialFieldBlocks,
|
group: "conflictManagement",
|
||||||
headerDraft,
|
draft: pendingDraft,
|
||||||
|
headings: cm.sectionHeadings,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
setDraftFieldBlocks(initialFieldBlocks);
|
setWizardCustomizeCardId(pendingCardId);
|
||||||
setModalEditUnlocked(true);
|
setCreateModalOpen(false);
|
||||||
|
setAddCustomWizardOpen(true);
|
||||||
}, [
|
}, [
|
||||||
cm.confirmModal.description,
|
cm.confirmModal.description,
|
||||||
cm.confirmModal.title,
|
cm.confirmModal.title,
|
||||||
|
cm.sectionHeadings,
|
||||||
|
draftFieldBlocks,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
methodById,
|
methodById,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
@@ -380,9 +373,7 @@ export function ConflictManagementScreen() {
|
|||||||
() => conflictManagementPresetFor(newId),
|
() => conflictManagementPresetFor(newId),
|
||||||
);
|
);
|
||||||
const blocksClone = structuredClone(
|
const blocksClone = structuredClone(
|
||||||
modalEditUnlocked &&
|
draftFieldBlocks !== null
|
||||||
draftFieldBlocks !== null &&
|
|
||||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
|
||||||
? draftFieldBlocks
|
? draftFieldBlocks
|
||||||
: cloneMethodCardBlocksForDuplicate(
|
: cloneMethodCardBlocksForDuplicate(
|
||||||
state.customMethodCardFieldBlocksById,
|
state.customMethodCardFieldBlocksById,
|
||||||
@@ -412,13 +403,12 @@ export function ConflictManagementScreen() {
|
|||||||
customizeSnapshotRef.current = null;
|
customizeSnapshotRef.current = null;
|
||||||
setPendingCardId(newId);
|
setPendingCardId(newId);
|
||||||
setPendingDraft(structuredClone(detailsClone));
|
setPendingDraft(structuredClone(detailsClone));
|
||||||
setModalEditUnlocked(false);
|
setDraftFieldBlocks(
|
||||||
setDraftFieldBlocks(null);
|
blocksClone.length > 0 ? structuredClone(blocksClone) : null,
|
||||||
setCustomizeHeaderDraft(null);
|
);
|
||||||
}, [
|
}, [
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
modalEditUnlocked,
|
|
||||||
modalKebabMenu.duplicateTitleSuffix,
|
modalKebabMenu.duplicateTitleSuffix,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
@@ -447,9 +437,7 @@ export function ConflictManagementScreen() {
|
|||||||
() => conflictManagementPresetFor(newId),
|
() => conflictManagementPresetFor(newId),
|
||||||
);
|
);
|
||||||
const blocksClone = structuredClone(
|
const blocksClone = structuredClone(
|
||||||
modalEditUnlocked &&
|
draftFieldBlocks !== null
|
||||||
draftFieldBlocks !== null &&
|
|
||||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
|
||||||
? draftFieldBlocks
|
? draftFieldBlocks
|
||||||
: cloneMethodCardBlocksForDuplicate(
|
: cloneMethodCardBlocksForDuplicate(
|
||||||
state.customMethodCardFieldBlocksById,
|
state.customMethodCardFieldBlocksById,
|
||||||
@@ -479,14 +467,13 @@ export function ConflictManagementScreen() {
|
|||||||
customizeSnapshotRef.current = null;
|
customizeSnapshotRef.current = null;
|
||||||
setPendingCardId(newId);
|
setPendingCardId(newId);
|
||||||
setPendingDraft(structuredClone(detailsClone));
|
setPendingDraft(structuredClone(detailsClone));
|
||||||
setModalEditUnlocked(false);
|
setDraftFieldBlocks(
|
||||||
setDraftFieldBlocks(null);
|
blocksClone.length > 0 ? structuredClone(blocksClone) : null,
|
||||||
setCustomizeHeaderDraft(null);
|
);
|
||||||
}, [
|
}, [
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
methodById,
|
methodById,
|
||||||
modalEditUnlocked,
|
|
||||||
modalKebabMenu.duplicateTitleSuffix,
|
modalKebabMenu.duplicateTitleSuffix,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
@@ -499,7 +486,7 @@ export function ConflictManagementScreen() {
|
|||||||
const kebabMenuItems = useMemo(
|
const kebabMenuItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
||||||
showCustomize: !modalEditUnlocked,
|
showCustomize: true,
|
||||||
onCustomize: handleCustomize,
|
onCustomize: handleCustomize,
|
||||||
onDuplicate:
|
onDuplicate:
|
||||||
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
|
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
|
||||||
@@ -519,7 +506,6 @@ export function ConflictManagementScreen() {
|
|||||||
handleDuplicatePrefabCard,
|
handleDuplicatePrefabCard,
|
||||||
handleRemoveSelectedFromModal,
|
handleRemoveSelectedFromModal,
|
||||||
isSelectedCardModal,
|
isSelectedCardModal,
|
||||||
modalEditUnlocked,
|
|
||||||
modalKebabMenu,
|
modalKebabMenu,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
state.customMethodCardMetaById,
|
state.customMethodCardMetaById,
|
||||||
@@ -538,7 +524,7 @@ export function ConflictManagementScreen() {
|
|||||||
meta?.supportText ??
|
meta?.supportText ??
|
||||||
method?.supportText ??
|
method?.supportText ??
|
||||||
cm.confirmModal.description,
|
cm.confirmModal.description,
|
||||||
nextButtonText: modalEditUnlocked
|
nextButtonText: isSelectedCardModal
|
||||||
? saveLabel
|
? saveLabel
|
||||||
: cm.addApproach.nextButtonText,
|
: cm.addApproach.nextButtonText,
|
||||||
};
|
};
|
||||||
@@ -550,8 +536,14 @@ export function ConflictManagementScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseAddWizard = useCallback(() => {
|
const handleCloseAddWizard = useCallback(() => {
|
||||||
|
const resumeCardId = wizardCustomizeCardId;
|
||||||
setAddCustomWizardOpen(false);
|
setAddCustomWizardOpen(false);
|
||||||
}, []);
|
setWizardCustomizeCardId(null);
|
||||||
|
setWizardInitialValues(null);
|
||||||
|
if (resumeCardId && pendingCardId === resumeCardId) {
|
||||||
|
setCreateModalOpen(true);
|
||||||
|
}
|
||||||
|
}, [pendingCardId, wizardCustomizeCardId]);
|
||||||
|
|
||||||
const handleFinalizeCustomCard = useCallback(
|
const handleFinalizeCustomCard = useCallback(
|
||||||
({
|
({
|
||||||
@@ -564,6 +556,42 @@ export function ConflictManagementScreen() {
|
|||||||
fieldBlocks: CustomMethodCardFieldBlock[];
|
fieldBlocks: CustomMethodCardFieldBlock[];
|
||||||
}) => {
|
}) => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
|
const existingId = wizardCustomizeCardId;
|
||||||
|
if (existingId) {
|
||||||
|
updateState({
|
||||||
|
selectedConflictManagementIds: moveFacetSelectionIdToFront(
|
||||||
|
selectedIds,
|
||||||
|
existingId,
|
||||||
|
),
|
||||||
|
customMethodCardMetaById: methodCardMetaWithCustomizeHeader(
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
existingId,
|
||||||
|
{ title, description },
|
||||||
|
),
|
||||||
|
...(pendingDraft
|
||||||
|
? {
|
||||||
|
conflictManagementDetailsById: {
|
||||||
|
...(state.conflictManagementDetailsById ?? {}),
|
||||||
|
[existingId]: pendingDraft,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
customMethodCardFieldBlocksById: {
|
||||||
|
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||||
|
[existingId]: fieldBlocks,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (pendingDraft) {
|
||||||
|
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
||||||
|
pendingDraft,
|
||||||
|
fieldBlocks,
|
||||||
|
{ title, description },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setDraftFieldBlocks(structuredClone(fieldBlocks));
|
||||||
|
setAddCustomWizardOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
updateState({
|
updateState({
|
||||||
selectedConflictManagementIds: moveFacetSelectionIdToFront(
|
selectedConflictManagementIds: moveFacetSelectionIdToFront(
|
||||||
@@ -586,91 +614,29 @@ export function ConflictManagementScreen() {
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
|
pendingDraft,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
state.conflictManagementDetailsById,
|
state.conflictManagementDetailsById,
|
||||||
state.customMethodCardFieldBlocksById,
|
state.customMethodCardFieldBlocksById,
|
||||||
state.customMethodCardMetaById,
|
state.customMethodCardMetaById,
|
||||||
updateState,
|
updateState,
|
||||||
|
wizardCustomizeCardId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreateModalPrimary = useCallback(() => {
|
const handleCreateModalPrimary = useCallback(() => {
|
||||||
if (!pendingCardId) {
|
if (!pendingCardId) {
|
||||||
handleCreateModalClose();
|
void handleCreateModalClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
|
|
||||||
if (selectedIds.includes(pendingCardId)) {
|
const persistWizardBlocks =
|
||||||
if (modalEditUnlocked) {
|
modalUsesWizardFieldBlocksBody && draftFieldBlocks !== null;
|
||||||
if (!customizeHeaderDraft) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextMeta = methodCardMetaWithCustomizeHeader(
|
|
||||||
state.customMethodCardMetaById,
|
|
||||||
pendingCardId,
|
|
||||||
customizeHeaderDraft,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
pendingCardId &&
|
|
||||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
|
||||||
usesWizardFieldBlocksModalBody({
|
|
||||||
methodId: pendingCardId,
|
|
||||||
meta: state.customMethodCardMetaById,
|
|
||||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
|
||||||
modalEditUnlocked,
|
|
||||||
draftFieldBlocks,
|
|
||||||
customFacetDetailsMatchPreset,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
updateState({
|
|
||||||
customMethodCardMetaById: nextMeta,
|
|
||||||
customMethodCardFieldBlocksById: {
|
|
||||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
|
||||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else if (pendingDraft) {
|
|
||||||
updateState({
|
|
||||||
customMethodCardMetaById: nextMeta,
|
|
||||||
conflictManagementDetailsById: {
|
|
||||||
...(state.conflictManagementDetailsById ?? {}),
|
|
||||||
[pendingCardId]: pendingDraft,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
customizeSnapshotRef.current = null;
|
|
||||||
setModalEditUnlocked(false);
|
|
||||||
setDraftFieldBlocks(null);
|
|
||||||
setCustomizeHeaderDraft(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (modalEditUnlocked) {
|
if (selectedIds.includes(pendingCardId)) {
|
||||||
if (!customizeHeaderDraft) {
|
if (persistWizardBlocks) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextMeta = methodCardMetaWithCustomizeHeader(
|
|
||||||
state.customMethodCardMetaById,
|
|
||||||
pendingCardId,
|
|
||||||
customizeHeaderDraft,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
pendingCardId &&
|
|
||||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
|
||||||
usesWizardFieldBlocksModalBody({
|
|
||||||
methodId: pendingCardId,
|
|
||||||
meta: state.customMethodCardMetaById,
|
|
||||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
|
||||||
modalEditUnlocked,
|
|
||||||
draftFieldBlocks,
|
|
||||||
customFacetDetailsMatchPreset,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
updateState({
|
updateState({
|
||||||
customMethodCardMetaById: nextMeta,
|
|
||||||
customMethodCardFieldBlocksById: {
|
customMethodCardFieldBlocksById: {
|
||||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||||
@@ -678,22 +644,20 @@ export function ConflictManagementScreen() {
|
|||||||
});
|
});
|
||||||
} else if (pendingDraft) {
|
} else if (pendingDraft) {
|
||||||
updateState({
|
updateState({
|
||||||
customMethodCardMetaById: nextMeta,
|
|
||||||
conflictManagementDetailsById: {
|
conflictManagementDetailsById: {
|
||||||
...(state.conflictManagementDetailsById ?? {}),
|
...(state.conflictManagementDetailsById ?? {}),
|
||||||
[pendingCardId]: pendingDraft,
|
[pendingCardId]: pendingDraft,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
pendingEphemeralDuplicateIdRef.current = null;
|
||||||
customizeSnapshotRef.current = null;
|
customizeSnapshotRef.current = null;
|
||||||
setModalEditUnlocked(false);
|
void handleCreateModalClose();
|
||||||
setDraftFieldBlocks(null);
|
|
||||||
setCustomizeHeaderDraft(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pendingDraft) {
|
if (!pendingDraft) {
|
||||||
handleCreateModalClose();
|
void handleCreateModalClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateState({
|
updateState({
|
||||||
@@ -705,15 +669,23 @@ export function ConflictManagementScreen() {
|
|||||||
...(state.conflictManagementDetailsById ?? {}),
|
...(state.conflictManagementDetailsById ?? {}),
|
||||||
[pendingCardId]: pendingDraft,
|
[pendingCardId]: pendingDraft,
|
||||||
},
|
},
|
||||||
|
...(persistWizardBlocks
|
||||||
|
? {
|
||||||
|
customMethodCardFieldBlocksById: {
|
||||||
|
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||||
|
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
pendingEphemeralDuplicateIdRef.current = null;
|
pendingEphemeralDuplicateIdRef.current = null;
|
||||||
handleCreateModalClose();
|
customizeSnapshotRef.current = null;
|
||||||
|
void handleCreateModalClose();
|
||||||
}, [
|
}, [
|
||||||
customizeHeaderDraft,
|
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
handleCreateModalClose,
|
handleCreateModalClose,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
modalEditUnlocked,
|
modalUsesWizardFieldBlocksBody,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
@@ -723,74 +695,41 @@ export function ConflictManagementScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CreateFlowStepShell
|
<CreateFlowCardStackStepShell
|
||||||
variant="wideGridLoosePadding"
|
lockupTitle={title}
|
||||||
contentTopBelowMd="space-800"
|
lockupDescription={description}
|
||||||
|
stackBusy={!recommendationsReady}
|
||||||
>
|
>
|
||||||
<div className="flex w-full min-w-0 flex-col items-center gap-6">
|
{recommendationsReady && (
|
||||||
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
|
<CardStack
|
||||||
<CreateFlowHeaderLockup
|
cards={sampleCards}
|
||||||
title={title}
|
selectedIds={selectedIds}
|
||||||
description={description}
|
onCardSelect={handleCardClick}
|
||||||
justification="center"
|
expanded={expanded}
|
||||||
/>
|
onToggleExpand={() => {
|
||||||
</div>
|
markCreateFlowInteraction();
|
||||||
<div
|
setExpanded((prev) => !prev);
|
||||||
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
|
}}
|
||||||
aria-busy={!recommendationsReady}
|
hasMore={true}
|
||||||
>
|
toggleLabel={cm.page.seeAllLink}
|
||||||
{recommendationsReady && (
|
compactRecommendedLimit={5}
|
||||||
<CardStack
|
compactCardIds={compactCardIds}
|
||||||
cards={sampleCards}
|
compactDesktopLayout="pyramidFive"
|
||||||
selectedIds={selectedIds}
|
headerLockupSize={mdUp ? "L" : "M"}
|
||||||
onCardSelect={handleCardClick}
|
/>
|
||||||
expanded={expanded}
|
)}
|
||||||
onToggleExpand={() => {
|
</CreateFlowCardStackStepShell>
|
||||||
markCreateFlowInteraction();
|
|
||||||
setExpanded((prev) => !prev);
|
|
||||||
}}
|
|
||||||
hasMore={true}
|
|
||||||
toggleLabel={cm.page.seeAllLink}
|
|
||||||
compactRecommendedLimit={5}
|
|
||||||
compactCardIds={compactCardIds}
|
|
||||||
compactDesktopLayout="pyramidFive"
|
|
||||||
headerLockupSize={mdUp ? "L" : "M"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Create
|
<Create
|
||||||
isOpen={createModalOpen}
|
isOpen={createModalOpen}
|
||||||
onClose={handleCreateModalClose}
|
onClose={handleCreateModalClose}
|
||||||
headerContent={
|
|
||||||
modalEditUnlocked && customizeHeaderDraft ? (
|
|
||||||
<MethodCardCustomizeModalHeader
|
|
||||||
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
|
|
||||||
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
|
|
||||||
titleValue={customizeHeaderDraft.title}
|
|
||||||
descriptionValue={customizeHeaderDraft.description}
|
|
||||||
onTitleChange={(title) =>
|
|
||||||
setCustomizeHeaderDraft((prev) =>
|
|
||||||
prev ? { ...prev, title } : null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onDescriptionChange={(description) =>
|
|
||||||
setCustomizeHeaderDraft((prev) =>
|
|
||||||
prev ? { ...prev, description } : null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
onNext={handleCreateModalPrimary}
|
onNext={handleCreateModalPrimary}
|
||||||
title={modalConfig.title}
|
title={modalConfig.title}
|
||||||
description={modalConfig.description}
|
description={modalConfig.description}
|
||||||
nextButtonText={modalConfig.nextButtonText}
|
nextButtonText={modalConfig.nextButtonText}
|
||||||
showBackButton={modalEditUnlocked}
|
showBackButton={false}
|
||||||
onBack={handleCancelCustomize}
|
|
||||||
backButtonText={modalKebabMenu.cancelCustomize}
|
|
||||||
showNextButton={showMethodModalPrimary}
|
showNextButton={showMethodModalPrimary}
|
||||||
|
showRemoveButton={isSelectedCardModal}
|
||||||
|
onRemove={handleRemoveSelectedFromModal}
|
||||||
backdropVariant="blurredYellow"
|
backdropVariant="blurredYellow"
|
||||||
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
||||||
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
|
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
|
||||||
@@ -802,14 +741,14 @@ export function ConflictManagementScreen() {
|
|||||||
cardId={pendingCardId}
|
cardId={pendingCardId}
|
||||||
blocksById={state.customMethodCardFieldBlocksById}
|
blocksById={state.customMethodCardFieldBlocksById}
|
||||||
blocksOverride={
|
blocksOverride={
|
||||||
modalEditUnlocked && draftFieldBlocks !== null
|
draftFieldBlocks !== null ? draftFieldBlocks : undefined
|
||||||
? draftFieldBlocks
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
|
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
|
||||||
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked}
|
showPolicyContentLockupWhenNoBlocks={
|
||||||
|
draftFieldBlocks === null || draftFieldBlocks.length === 0
|
||||||
|
}
|
||||||
onFieldBlocksChange={
|
onFieldBlocksChange={
|
||||||
fieldsLocked
|
draftFieldBlocks === null
|
||||||
? undefined
|
? undefined
|
||||||
: (next) => setDraftFieldBlocks(next)
|
: (next) => setDraftFieldBlocks(next)
|
||||||
}
|
}
|
||||||
@@ -818,15 +757,14 @@ export function ConflictManagementScreen() {
|
|||||||
<ConflictManagementEditFields
|
<ConflictManagementEditFields
|
||||||
value={pendingDraft}
|
value={pendingDraft}
|
||||||
onChange={handleDraftChange}
|
onChange={handleDraftChange}
|
||||||
readOnly={fieldsLocked}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</Create>
|
</Create>
|
||||||
</CreateFlowStepShell>
|
|
||||||
<CustomMethodCardWizard
|
<CustomMethodCardWizard
|
||||||
isOpen={addCustomWizardOpen}
|
isOpen={addCustomWizardOpen}
|
||||||
onClose={handleCloseAddWizard}
|
onClose={handleCloseAddWizard}
|
||||||
|
initialValues={wizardInitialValues}
|
||||||
onFinalize={handleFinalizeCustomCard}
|
onFinalize={handleFinalizeCustomCard}
|
||||||
onPersistCustomUploadFile={(file) =>
|
onPersistCustomUploadFile={(file) =>
|
||||||
uploadCreateFlowFile(file, "customMethodAttachment")
|
uploadCreateFlowFile(file, "customMethodAttachment")
|
||||||
|
|||||||
@@ -17,17 +17,13 @@ import { useState, useCallback, useMemo, useRef } from "react";
|
|||||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
|
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
|
||||||
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
||||||
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowCardStackStepShell } from "../../components/CreateFlowCardStackStepShell";
|
||||||
import CardStack from "../../../../components/cards/CardStack";
|
import CardStack from "../../../../components/cards/CardStack";
|
||||||
import Create from "../../../../components/modals/Create";
|
import Create from "../../../../components/modals/Create";
|
||||||
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
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 { MembershipMethodEditFields } from "../../components/methodEditFields";
|
||||||
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
||||||
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
||||||
@@ -50,20 +46,21 @@ import type { MembershipMethodDetailEntry } from "../../types";
|
|||||||
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
|
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
|
||||||
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
|
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
|
||||||
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
||||||
|
import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
|
||||||
|
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
|
||||||
import {
|
import {
|
||||||
captureMethodCardCustomizeSnapshot,
|
captureMethodCardCustomizeSnapshot,
|
||||||
|
isMethodCardCustomizeUnloadBlocked,
|
||||||
type MethodCardCustomizeSnapshot,
|
type MethodCardCustomizeSnapshot,
|
||||||
type MethodCardHeaderDraft,
|
type MethodCardHeaderDraft,
|
||||||
} from "../../../../../lib/create/methodCardCustomizeSession";
|
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||||
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
|
|
||||||
|
|
||||||
export function MembershipMethodsScreen() {
|
export function MembershipMethodsScreen() {
|
||||||
const m = useMessages();
|
const m = useMessages();
|
||||||
const mem = m.create.customRule.membership;
|
const mem = m.create.customRule.membership;
|
||||||
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
||||||
const mdUp = useCreateFlowMdUp();
|
const mdUp = useCreateFlowMdUp();
|
||||||
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
|
const { confirmDiscard, confirmDialog } = useDiscardCustomizeConfirm();
|
||||||
useDiscardCustomizeConfirm();
|
|
||||||
const { state, updateState, replaceState, markCreateFlowInteraction } =
|
const { state, updateState, replaceState, markCreateFlowInteraction } =
|
||||||
useCreateFlow();
|
useCreateFlow();
|
||||||
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
|
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
|
||||||
@@ -76,12 +73,24 @@ export function MembershipMethodsScreen() {
|
|||||||
const [pendingDraft, setPendingDraft] =
|
const [pendingDraft, setPendingDraft] =
|
||||||
useState<MembershipMethodDetailEntry | null>(null);
|
useState<MembershipMethodDetailEntry | null>(null);
|
||||||
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
|
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
|
||||||
const [modalEditUnlocked, setModalEditUnlocked] = useState(false);
|
const [wizardCustomizeCardId, setWizardCustomizeCardId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [wizardInitialValues, setWizardInitialValues] =
|
||||||
|
useState<MethodCardWizardInitialValues | null>(null);
|
||||||
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
|
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
|
||||||
CustomMethodCardFieldBlock[] | null
|
CustomMethodCardFieldBlock[] | null
|
||||||
>(null);
|
>(null);
|
||||||
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
|
|
||||||
useState<MethodCardHeaderDraft | null>(null);
|
useBeforeUnloadGuard(
|
||||||
|
isMethodCardCustomizeUnloadBlocked(
|
||||||
|
createModalOpen,
|
||||||
|
customizeSnapshotRef.current,
|
||||||
|
pendingDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
customizeSnapshotRef.current?.headerDraft ?? null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const selectedIds = state.selectedMembershipMethodIds ?? [];
|
const selectedIds = state.selectedMembershipMethodIds ?? [];
|
||||||
|
|
||||||
@@ -104,6 +113,8 @@ export function MembershipMethodsScreen() {
|
|||||||
|
|
||||||
const handleOpenAddWizard = useCallback(() => {
|
const handleOpenAddWizard = useCallback(() => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
|
setWizardCustomizeCardId(null);
|
||||||
|
setWizardInitialValues(null);
|
||||||
setAddCustomWizardOpen(true);
|
setAddCustomWizardOpen(true);
|
||||||
}, [markCreateFlowInteraction]);
|
}, [markCreateFlowInteraction]);
|
||||||
|
|
||||||
@@ -141,15 +152,40 @@ export function MembershipMethodsScreen() {
|
|||||||
const handleCardClick = useCallback(
|
const handleCardClick = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
customizeSnapshotRef.current = null;
|
const draft = seedDraft(id);
|
||||||
setModalEditUnlocked(false);
|
const persistedBlocks = state.customMethodCardFieldBlocksById?.[id];
|
||||||
setDraftFieldBlocks(null);
|
const initialBlocks =
|
||||||
setCustomizeHeaderDraft(null);
|
Array.isArray(persistedBlocks) && persistedBlocks.length > 0
|
||||||
|
? structuredClone(persistedBlocks)
|
||||||
|
: null;
|
||||||
|
const method = methodById.get(id);
|
||||||
|
const meta = state.customMethodCardMetaById?.[id];
|
||||||
|
const headerDraft: MethodCardHeaderDraft = {
|
||||||
|
title: meta?.label ?? method?.label ?? mem.confirmModal.title,
|
||||||
|
description:
|
||||||
|
meta?.supportText ??
|
||||||
|
method?.supportText ??
|
||||||
|
mem.confirmModal.description,
|
||||||
|
};
|
||||||
|
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
||||||
|
draft,
|
||||||
|
initialBlocks,
|
||||||
|
headerDraft,
|
||||||
|
);
|
||||||
setPendingCardId(id);
|
setPendingCardId(id);
|
||||||
setPendingDraft(seedDraft(id));
|
setPendingDraft(draft);
|
||||||
|
setDraftFieldBlocks(initialBlocks);
|
||||||
setCreateModalOpen(true);
|
setCreateModalOpen(true);
|
||||||
},
|
},
|
||||||
[markCreateFlowInteraction, seedDraft],
|
[
|
||||||
|
mem.confirmModal.description,
|
||||||
|
mem.confirmModal.title,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
methodById,
|
||||||
|
seedDraft,
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDraftChange = useCallback(
|
const handleDraftChange = useCallback(
|
||||||
@@ -162,9 +198,7 @@ export function MembershipMethodsScreen() {
|
|||||||
|
|
||||||
const isSelectedCardModal =
|
const isSelectedCardModal =
|
||||||
pendingCardId !== null && selectedIds.includes(pendingCardId);
|
pendingCardId !== null && selectedIds.includes(pendingCardId);
|
||||||
const fieldsLocked = !modalEditUnlocked;
|
const showMethodModalPrimary = true;
|
||||||
|
|
||||||
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
|
|
||||||
|
|
||||||
const customFacetDetailsMatchPreset = useMemo(() => {
|
const customFacetDetailsMatchPreset = useMemo(() => {
|
||||||
if (!pendingCardId || !pendingDraft) return false;
|
if (!pendingCardId || !pendingDraft) return false;
|
||||||
@@ -186,7 +220,7 @@ export function MembershipMethodsScreen() {
|
|||||||
methodId: pendingCardId,
|
methodId: pendingCardId,
|
||||||
meta: state.customMethodCardMetaById,
|
meta: state.customMethodCardMetaById,
|
||||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||||
modalEditUnlocked,
|
modalEditUnlocked: true,
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
customFacetDetailsMatchPreset,
|
customFacetDetailsMatchPreset,
|
||||||
}),
|
}),
|
||||||
@@ -194,7 +228,6 @@ export function MembershipMethodsScreen() {
|
|||||||
[
|
[
|
||||||
customFacetDetailsMatchPreset,
|
customFacetDetailsMatchPreset,
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
modalEditUnlocked,
|
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
state.customMethodCardFieldBlocksById,
|
state.customMethodCardFieldBlocksById,
|
||||||
state.customMethodCardMetaById,
|
state.customMethodCardMetaById,
|
||||||
@@ -204,11 +237,11 @@ export function MembershipMethodsScreen() {
|
|||||||
const handleCreateModalClose = useCallback(async () => {
|
const handleCreateModalClose = useCallback(async () => {
|
||||||
if (
|
if (
|
||||||
!(await confirmDiscard(
|
!(await confirmDiscard(
|
||||||
modalEditUnlocked,
|
true,
|
||||||
customizeSnapshotRef.current,
|
customizeSnapshotRef.current,
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
customizeHeaderDraft,
|
customizeSnapshotRef.current?.headerDraft ?? null,
|
||||||
))
|
))
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -236,53 +269,14 @@ export function MembershipMethodsScreen() {
|
|||||||
setCreateModalOpen(false);
|
setCreateModalOpen(false);
|
||||||
setPendingCardId(null);
|
setPendingCardId(null);
|
||||||
setPendingDraft(null);
|
setPendingDraft(null);
|
||||||
setModalEditUnlocked(false);
|
|
||||||
setDraftFieldBlocks(null);
|
setDraftFieldBlocks(null);
|
||||||
setCustomizeHeaderDraft(null);
|
|
||||||
}, [
|
}, [
|
||||||
confirmDiscard,
|
confirmDiscard,
|
||||||
customizeHeaderDraft,
|
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
modalEditUnlocked,
|
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
replaceState,
|
replaceState,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleCancelCustomize = useCallback(async () => {
|
|
||||||
if (!modalEditUnlocked) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const snap = customizeSnapshotRef.current;
|
|
||||||
if (!snap) {
|
|
||||||
customizeSnapshotRef.current = null;
|
|
||||||
setModalEditUnlocked(false);
|
|
||||||
setDraftFieldBlocks(null);
|
|
||||||
setCustomizeHeaderDraft(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!(await confirmDirtyCustomizeCancel(
|
|
||||||
snap,
|
|
||||||
pendingDraft,
|
|
||||||
draftFieldBlocks,
|
|
||||||
customizeHeaderDraft,
|
|
||||||
))
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPendingDraft(structuredClone(snap.pendingDraft));
|
|
||||||
setDraftFieldBlocks(null);
|
|
||||||
setModalEditUnlocked(false);
|
|
||||||
customizeSnapshotRef.current = null;
|
|
||||||
setCustomizeHeaderDraft(null);
|
|
||||||
}, [
|
|
||||||
confirmDirtyCustomizeCancel,
|
|
||||||
customizeHeaderDraft,
|
|
||||||
draftFieldBlocks,
|
|
||||||
modalEditUnlocked,
|
|
||||||
pendingDraft,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleRemoveSelectedFromModal = useCallback(async () => {
|
const handleRemoveSelectedFromModal = useCallback(async () => {
|
||||||
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
|
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
|
||||||
return;
|
return;
|
||||||
@@ -290,11 +284,11 @@ export function MembershipMethodsScreen() {
|
|||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
if (
|
if (
|
||||||
!(await confirmDiscard(
|
!(await confirmDiscard(
|
||||||
modalEditUnlocked,
|
true,
|
||||||
customizeSnapshotRef.current,
|
customizeSnapshotRef.current,
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
customizeHeaderDraft,
|
customizeSnapshotRef.current?.headerDraft ?? null,
|
||||||
))
|
))
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -306,11 +300,9 @@ export function MembershipMethodsScreen() {
|
|||||||
await handleCreateModalClose();
|
await handleCreateModalClose();
|
||||||
}, [
|
}, [
|
||||||
confirmDiscard,
|
confirmDiscard,
|
||||||
customizeHeaderDraft,
|
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
handleCreateModalClose,
|
handleCreateModalClose,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
modalEditUnlocked,
|
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
@@ -320,35 +312,36 @@ export function MembershipMethodsScreen() {
|
|||||||
|
|
||||||
const handleCustomize = useCallback(() => {
|
const handleCustomize = useCallback(() => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
if (!pendingDraft || !pendingCardId) {
|
if (!pendingCardId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const initialFieldBlocks =
|
|
||||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
|
||||||
? structuredClone(
|
|
||||||
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [],
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const method = methodById.get(pendingCardId);
|
const method = methodById.get(pendingCardId);
|
||||||
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
setWizardInitialValues(
|
||||||
const headerDraft: MethodCardHeaderDraft = {
|
buildMethodCardWizardInitialValues({
|
||||||
title: meta?.label ?? method?.label ?? mem.confirmModal.title,
|
cardId: pendingCardId,
|
||||||
description:
|
fallbackTitle: method?.label ?? mem.confirmModal.title,
|
||||||
meta?.supportText ??
|
fallbackDescription:
|
||||||
method?.supportText ??
|
method?.supportText ?? mem.confirmModal.description,
|
||||||
mem.confirmModal.description,
|
meta: state.customMethodCardMetaById,
|
||||||
};
|
persistedBlocks: state.customMethodCardFieldBlocksById,
|
||||||
setCustomizeHeaderDraft(headerDraft);
|
draftFieldBlocks,
|
||||||
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
facetPrefill: pendingDraft
|
||||||
pendingDraft,
|
? {
|
||||||
initialFieldBlocks,
|
group: "membership",
|
||||||
headerDraft,
|
draft: pendingDraft,
|
||||||
|
headings: mem.sectionHeadings,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
setDraftFieldBlocks(initialFieldBlocks);
|
setWizardCustomizeCardId(pendingCardId);
|
||||||
setModalEditUnlocked(true);
|
setCreateModalOpen(false);
|
||||||
|
setAddCustomWizardOpen(true);
|
||||||
}, [
|
}, [
|
||||||
mem.confirmModal.description,
|
mem.confirmModal.description,
|
||||||
mem.confirmModal.title,
|
mem.confirmModal.title,
|
||||||
|
mem.sectionHeadings,
|
||||||
|
draftFieldBlocks,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
methodById,
|
methodById,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
@@ -373,9 +366,7 @@ export function MembershipMethodsScreen() {
|
|||||||
() => membershipPresetFor(newId),
|
() => membershipPresetFor(newId),
|
||||||
);
|
);
|
||||||
const blocksClone = structuredClone(
|
const blocksClone = structuredClone(
|
||||||
modalEditUnlocked &&
|
draftFieldBlocks !== null
|
||||||
draftFieldBlocks !== null &&
|
|
||||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
|
||||||
? draftFieldBlocks
|
? draftFieldBlocks
|
||||||
: cloneMethodCardBlocksForDuplicate(
|
: cloneMethodCardBlocksForDuplicate(
|
||||||
state.customMethodCardFieldBlocksById,
|
state.customMethodCardFieldBlocksById,
|
||||||
@@ -405,13 +396,12 @@ export function MembershipMethodsScreen() {
|
|||||||
customizeSnapshotRef.current = null;
|
customizeSnapshotRef.current = null;
|
||||||
setPendingCardId(newId);
|
setPendingCardId(newId);
|
||||||
setPendingDraft(structuredClone(detailsClone));
|
setPendingDraft(structuredClone(detailsClone));
|
||||||
setModalEditUnlocked(false);
|
setDraftFieldBlocks(
|
||||||
setDraftFieldBlocks(null);
|
blocksClone.length > 0 ? structuredClone(blocksClone) : null,
|
||||||
setCustomizeHeaderDraft(null);
|
);
|
||||||
}, [
|
}, [
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
modalEditUnlocked,
|
|
||||||
modalKebabMenu.duplicateTitleSuffix,
|
modalKebabMenu.duplicateTitleSuffix,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
@@ -440,9 +430,7 @@ export function MembershipMethodsScreen() {
|
|||||||
() => membershipPresetFor(newId),
|
() => membershipPresetFor(newId),
|
||||||
);
|
);
|
||||||
const blocksClone = structuredClone(
|
const blocksClone = structuredClone(
|
||||||
modalEditUnlocked &&
|
draftFieldBlocks !== null
|
||||||
draftFieldBlocks !== null &&
|
|
||||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
|
||||||
? draftFieldBlocks
|
? draftFieldBlocks
|
||||||
: cloneMethodCardBlocksForDuplicate(
|
: cloneMethodCardBlocksForDuplicate(
|
||||||
state.customMethodCardFieldBlocksById,
|
state.customMethodCardFieldBlocksById,
|
||||||
@@ -472,14 +460,13 @@ export function MembershipMethodsScreen() {
|
|||||||
customizeSnapshotRef.current = null;
|
customizeSnapshotRef.current = null;
|
||||||
setPendingCardId(newId);
|
setPendingCardId(newId);
|
||||||
setPendingDraft(structuredClone(detailsClone));
|
setPendingDraft(structuredClone(detailsClone));
|
||||||
setModalEditUnlocked(false);
|
setDraftFieldBlocks(
|
||||||
setDraftFieldBlocks(null);
|
blocksClone.length > 0 ? structuredClone(blocksClone) : null,
|
||||||
setCustomizeHeaderDraft(null);
|
);
|
||||||
}, [
|
}, [
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
methodById,
|
methodById,
|
||||||
modalEditUnlocked,
|
|
||||||
modalKebabMenu.duplicateTitleSuffix,
|
modalKebabMenu.duplicateTitleSuffix,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
@@ -492,7 +479,7 @@ export function MembershipMethodsScreen() {
|
|||||||
const kebabMenuItems = useMemo(
|
const kebabMenuItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
||||||
showCustomize: !modalEditUnlocked,
|
showCustomize: true,
|
||||||
onCustomize: handleCustomize,
|
onCustomize: handleCustomize,
|
||||||
onDuplicate:
|
onDuplicate:
|
||||||
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
|
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
|
||||||
@@ -512,7 +499,6 @@ export function MembershipMethodsScreen() {
|
|||||||
handleDuplicatePrefabCard,
|
handleDuplicatePrefabCard,
|
||||||
handleRemoveSelectedFromModal,
|
handleRemoveSelectedFromModal,
|
||||||
isSelectedCardModal,
|
isSelectedCardModal,
|
||||||
modalEditUnlocked,
|
|
||||||
modalKebabMenu,
|
modalKebabMenu,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
state.customMethodCardMetaById,
|
state.customMethodCardMetaById,
|
||||||
@@ -531,7 +517,7 @@ export function MembershipMethodsScreen() {
|
|||||||
meta?.supportText ??
|
meta?.supportText ??
|
||||||
method?.supportText ??
|
method?.supportText ??
|
||||||
mem.confirmModal.description,
|
mem.confirmModal.description,
|
||||||
nextButtonText: modalEditUnlocked
|
nextButtonText: isSelectedCardModal
|
||||||
? saveLabel
|
? saveLabel
|
||||||
: mem.addPlatform.nextButtonText,
|
: mem.addPlatform.nextButtonText,
|
||||||
};
|
};
|
||||||
@@ -543,8 +529,14 @@ export function MembershipMethodsScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseAddWizard = useCallback(() => {
|
const handleCloseAddWizard = useCallback(() => {
|
||||||
|
const resumeCardId = wizardCustomizeCardId;
|
||||||
setAddCustomWizardOpen(false);
|
setAddCustomWizardOpen(false);
|
||||||
}, []);
|
setWizardCustomizeCardId(null);
|
||||||
|
setWizardInitialValues(null);
|
||||||
|
if (resumeCardId && pendingCardId === resumeCardId) {
|
||||||
|
setCreateModalOpen(true);
|
||||||
|
}
|
||||||
|
}, [pendingCardId, wizardCustomizeCardId]);
|
||||||
|
|
||||||
const handleFinalizeCustomCard = useCallback(
|
const handleFinalizeCustomCard = useCallback(
|
||||||
({
|
({
|
||||||
@@ -557,6 +549,42 @@ export function MembershipMethodsScreen() {
|
|||||||
fieldBlocks: CustomMethodCardFieldBlock[];
|
fieldBlocks: CustomMethodCardFieldBlock[];
|
||||||
}) => {
|
}) => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
|
const existingId = wizardCustomizeCardId;
|
||||||
|
if (existingId) {
|
||||||
|
updateState({
|
||||||
|
selectedMembershipMethodIds: moveFacetSelectionIdToFront(
|
||||||
|
selectedIds,
|
||||||
|
existingId,
|
||||||
|
),
|
||||||
|
customMethodCardMetaById: methodCardMetaWithCustomizeHeader(
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
existingId,
|
||||||
|
{ title, description },
|
||||||
|
),
|
||||||
|
...(pendingDraft
|
||||||
|
? {
|
||||||
|
membershipMethodDetailsById: {
|
||||||
|
...(state.membershipMethodDetailsById ?? {}),
|
||||||
|
[existingId]: pendingDraft,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
customMethodCardFieldBlocksById: {
|
||||||
|
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||||
|
[existingId]: fieldBlocks,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (pendingDraft) {
|
||||||
|
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
||||||
|
pendingDraft,
|
||||||
|
fieldBlocks,
|
||||||
|
{ title, description },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setDraftFieldBlocks(structuredClone(fieldBlocks));
|
||||||
|
setAddCustomWizardOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
updateState({
|
updateState({
|
||||||
selectedMembershipMethodIds: moveFacetSelectionIdToFront(
|
selectedMembershipMethodIds: moveFacetSelectionIdToFront(
|
||||||
@@ -579,91 +607,29 @@ export function MembershipMethodsScreen() {
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
|
pendingDraft,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
state.customMethodCardFieldBlocksById,
|
state.customMethodCardFieldBlocksById,
|
||||||
state.customMethodCardMetaById,
|
state.customMethodCardMetaById,
|
||||||
state.membershipMethodDetailsById,
|
state.membershipMethodDetailsById,
|
||||||
updateState,
|
updateState,
|
||||||
|
wizardCustomizeCardId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreateModalPrimary = useCallback(() => {
|
const handleCreateModalPrimary = useCallback(() => {
|
||||||
if (!pendingCardId) {
|
if (!pendingCardId) {
|
||||||
handleCreateModalClose();
|
void handleCreateModalClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
|
|
||||||
if (selectedIds.includes(pendingCardId)) {
|
const persistWizardBlocks =
|
||||||
if (modalEditUnlocked) {
|
modalUsesWizardFieldBlocksBody && draftFieldBlocks !== null;
|
||||||
if (!customizeHeaderDraft) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextMeta = methodCardMetaWithCustomizeHeader(
|
|
||||||
state.customMethodCardMetaById,
|
|
||||||
pendingCardId,
|
|
||||||
customizeHeaderDraft,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
pendingCardId &&
|
|
||||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
|
||||||
usesWizardFieldBlocksModalBody({
|
|
||||||
methodId: pendingCardId,
|
|
||||||
meta: state.customMethodCardMetaById,
|
|
||||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
|
||||||
modalEditUnlocked,
|
|
||||||
draftFieldBlocks,
|
|
||||||
customFacetDetailsMatchPreset,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
updateState({
|
|
||||||
customMethodCardMetaById: nextMeta,
|
|
||||||
customMethodCardFieldBlocksById: {
|
|
||||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
|
||||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else if (pendingDraft) {
|
|
||||||
updateState({
|
|
||||||
customMethodCardMetaById: nextMeta,
|
|
||||||
membershipMethodDetailsById: {
|
|
||||||
...(state.membershipMethodDetailsById ?? {}),
|
|
||||||
[pendingCardId]: pendingDraft,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
customizeSnapshotRef.current = null;
|
|
||||||
setModalEditUnlocked(false);
|
|
||||||
setDraftFieldBlocks(null);
|
|
||||||
setCustomizeHeaderDraft(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (modalEditUnlocked) {
|
if (selectedIds.includes(pendingCardId)) {
|
||||||
if (!customizeHeaderDraft) {
|
if (persistWizardBlocks) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextMeta = methodCardMetaWithCustomizeHeader(
|
|
||||||
state.customMethodCardMetaById,
|
|
||||||
pendingCardId,
|
|
||||||
customizeHeaderDraft,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
pendingCardId &&
|
|
||||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
|
||||||
usesWizardFieldBlocksModalBody({
|
|
||||||
methodId: pendingCardId,
|
|
||||||
meta: state.customMethodCardMetaById,
|
|
||||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
|
||||||
modalEditUnlocked,
|
|
||||||
draftFieldBlocks,
|
|
||||||
customFacetDetailsMatchPreset,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
updateState({
|
updateState({
|
||||||
customMethodCardMetaById: nextMeta,
|
|
||||||
customMethodCardFieldBlocksById: {
|
customMethodCardFieldBlocksById: {
|
||||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||||
@@ -671,22 +637,20 @@ export function MembershipMethodsScreen() {
|
|||||||
});
|
});
|
||||||
} else if (pendingDraft) {
|
} else if (pendingDraft) {
|
||||||
updateState({
|
updateState({
|
||||||
customMethodCardMetaById: nextMeta,
|
|
||||||
membershipMethodDetailsById: {
|
membershipMethodDetailsById: {
|
||||||
...(state.membershipMethodDetailsById ?? {}),
|
...(state.membershipMethodDetailsById ?? {}),
|
||||||
[pendingCardId]: pendingDraft,
|
[pendingCardId]: pendingDraft,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
pendingEphemeralDuplicateIdRef.current = null;
|
||||||
customizeSnapshotRef.current = null;
|
customizeSnapshotRef.current = null;
|
||||||
setModalEditUnlocked(false);
|
void handleCreateModalClose();
|
||||||
setDraftFieldBlocks(null);
|
|
||||||
setCustomizeHeaderDraft(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pendingDraft) {
|
if (!pendingDraft) {
|
||||||
handleCreateModalClose();
|
void handleCreateModalClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateState({
|
updateState({
|
||||||
@@ -698,15 +662,23 @@ export function MembershipMethodsScreen() {
|
|||||||
...(state.membershipMethodDetailsById ?? {}),
|
...(state.membershipMethodDetailsById ?? {}),
|
||||||
[pendingCardId]: pendingDraft,
|
[pendingCardId]: pendingDraft,
|
||||||
},
|
},
|
||||||
|
...(persistWizardBlocks
|
||||||
|
? {
|
||||||
|
customMethodCardFieldBlocksById: {
|
||||||
|
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||||
|
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
pendingEphemeralDuplicateIdRef.current = null;
|
pendingEphemeralDuplicateIdRef.current = null;
|
||||||
handleCreateModalClose();
|
customizeSnapshotRef.current = null;
|
||||||
|
void handleCreateModalClose();
|
||||||
}, [
|
}, [
|
||||||
customizeHeaderDraft,
|
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
handleCreateModalClose,
|
handleCreateModalClose,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
modalEditUnlocked,
|
modalUsesWizardFieldBlocksBody,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
@@ -716,74 +688,41 @@ export function MembershipMethodsScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CreateFlowStepShell
|
<CreateFlowCardStackStepShell
|
||||||
variant="wideGridLoosePadding"
|
lockupTitle={title}
|
||||||
contentTopBelowMd="space-800"
|
lockupDescription={description}
|
||||||
|
stackBusy={!recommendationsReady}
|
||||||
>
|
>
|
||||||
<div className="flex w-full min-w-0 flex-col items-center gap-6">
|
{recommendationsReady && (
|
||||||
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
|
<CardStack
|
||||||
<CreateFlowHeaderLockup
|
cards={sampleCards}
|
||||||
title={title}
|
selectedIds={selectedIds}
|
||||||
description={description}
|
onCardSelect={handleCardClick}
|
||||||
justification="center"
|
expanded={expanded}
|
||||||
/>
|
onToggleExpand={() => {
|
||||||
</div>
|
markCreateFlowInteraction();
|
||||||
<div
|
setExpanded((prev) => !prev);
|
||||||
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
|
}}
|
||||||
aria-busy={!recommendationsReady}
|
hasMore={true}
|
||||||
>
|
toggleLabel={mem.page.seeAllLink}
|
||||||
{recommendationsReady && (
|
compactRecommendedLimit={5}
|
||||||
<CardStack
|
compactCardIds={compactCardIds}
|
||||||
cards={sampleCards}
|
compactDesktopLayout="pyramidFive"
|
||||||
selectedIds={selectedIds}
|
headerLockupSize={mdUp ? "L" : "M"}
|
||||||
onCardSelect={handleCardClick}
|
/>
|
||||||
expanded={expanded}
|
)}
|
||||||
onToggleExpand={() => {
|
</CreateFlowCardStackStepShell>
|
||||||
markCreateFlowInteraction();
|
|
||||||
setExpanded((prev) => !prev);
|
|
||||||
}}
|
|
||||||
hasMore={true}
|
|
||||||
toggleLabel={mem.page.seeAllLink}
|
|
||||||
compactRecommendedLimit={5}
|
|
||||||
compactCardIds={compactCardIds}
|
|
||||||
compactDesktopLayout="pyramidFive"
|
|
||||||
headerLockupSize={mdUp ? "L" : "M"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Create
|
<Create
|
||||||
isOpen={createModalOpen}
|
isOpen={createModalOpen}
|
||||||
onClose={handleCreateModalClose}
|
onClose={handleCreateModalClose}
|
||||||
headerContent={
|
|
||||||
modalEditUnlocked && customizeHeaderDraft ? (
|
|
||||||
<MethodCardCustomizeModalHeader
|
|
||||||
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
|
|
||||||
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
|
|
||||||
titleValue={customizeHeaderDraft.title}
|
|
||||||
descriptionValue={customizeHeaderDraft.description}
|
|
||||||
onTitleChange={(title) =>
|
|
||||||
setCustomizeHeaderDraft((prev) =>
|
|
||||||
prev ? { ...prev, title } : null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onDescriptionChange={(description) =>
|
|
||||||
setCustomizeHeaderDraft((prev) =>
|
|
||||||
prev ? { ...prev, description } : null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
onNext={handleCreateModalPrimary}
|
onNext={handleCreateModalPrimary}
|
||||||
title={modalConfig.title}
|
title={modalConfig.title}
|
||||||
description={modalConfig.description}
|
description={modalConfig.description}
|
||||||
nextButtonText={modalConfig.nextButtonText}
|
nextButtonText={modalConfig.nextButtonText}
|
||||||
showBackButton={modalEditUnlocked}
|
showBackButton={false}
|
||||||
onBack={handleCancelCustomize}
|
|
||||||
backButtonText={modalKebabMenu.cancelCustomize}
|
|
||||||
showNextButton={showMethodModalPrimary}
|
showNextButton={showMethodModalPrimary}
|
||||||
|
showRemoveButton={isSelectedCardModal}
|
||||||
|
onRemove={handleRemoveSelectedFromModal}
|
||||||
backdropVariant="blurredYellow"
|
backdropVariant="blurredYellow"
|
||||||
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
||||||
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
|
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
|
||||||
@@ -795,14 +734,14 @@ export function MembershipMethodsScreen() {
|
|||||||
cardId={pendingCardId}
|
cardId={pendingCardId}
|
||||||
blocksById={state.customMethodCardFieldBlocksById}
|
blocksById={state.customMethodCardFieldBlocksById}
|
||||||
blocksOverride={
|
blocksOverride={
|
||||||
modalEditUnlocked && draftFieldBlocks !== null
|
draftFieldBlocks !== null ? draftFieldBlocks : undefined
|
||||||
? draftFieldBlocks
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
|
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
|
||||||
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked}
|
showPolicyContentLockupWhenNoBlocks={
|
||||||
|
draftFieldBlocks === null || draftFieldBlocks.length === 0
|
||||||
|
}
|
||||||
onFieldBlocksChange={
|
onFieldBlocksChange={
|
||||||
fieldsLocked
|
draftFieldBlocks === null
|
||||||
? undefined
|
? undefined
|
||||||
: (next) => setDraftFieldBlocks(next)
|
: (next) => setDraftFieldBlocks(next)
|
||||||
}
|
}
|
||||||
@@ -811,15 +750,14 @@ export function MembershipMethodsScreen() {
|
|||||||
<MembershipMethodEditFields
|
<MembershipMethodEditFields
|
||||||
value={pendingDraft}
|
value={pendingDraft}
|
||||||
onChange={handleDraftChange}
|
onChange={handleDraftChange}
|
||||||
readOnly={fieldsLocked}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</Create>
|
</Create>
|
||||||
</CreateFlowStepShell>
|
|
||||||
<CustomMethodCardWizard
|
<CustomMethodCardWizard
|
||||||
isOpen={addCustomWizardOpen}
|
isOpen={addCustomWizardOpen}
|
||||||
onClose={handleCloseAddWizard}
|
onClose={handleCloseAddWizard}
|
||||||
|
initialValues={wizardInitialValues}
|
||||||
onFinalize={handleFinalizeCustomCard}
|
onFinalize={handleFinalizeCustomCard}
|
||||||
onPersistCustomUploadFile={(file) =>
|
onPersistCustomUploadFile={(file) =>
|
||||||
uploadCreateFlowFile(file, "customMethodAttachment")
|
uploadCreateFlowFile(file, "customMethodAttachment")
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
|||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import {
|
import {
|
||||||
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
|
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
|
||||||
|
CREATE_FLOW_PAGE_GUTTER_CLASS,
|
||||||
|
CREATE_FLOW_SCROLL_REGION_CLASS,
|
||||||
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
||||||
} from "../../components/createFlowLayoutTokens";
|
} from "../../components/createFlowLayoutTokens";
|
||||||
import {
|
import {
|
||||||
@@ -163,7 +165,7 @@ export function CompletedScreen() {
|
|||||||
<>
|
<>
|
||||||
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[var(--color-teal-teal50,#c9fef9)] md:h-full">
|
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[var(--color-teal-teal50,#c9fef9)] md:h-full">
|
||||||
<div
|
<div
|
||||||
className={`mx-auto grid min-h-0 w-full grid-cols-1 gap-4 px-5 max-md:max-w-[639px] max-md:overflow-y-auto max-md:overscroll-y-contain max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:grid-rows-1 md:items-stretch md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] md:overflow-hidden md:px-12 md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
|
className={`mx-auto grid min-h-0 w-full grid-cols-1 gap-4 ${CREATE_FLOW_PAGE_GUTTER_CLASS} max-md:overflow-y-auto max-md:overscroll-y-contain max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:grid-rows-1 md:items-stretch md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] md:overflow-hidden md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col justify-start max-md:min-h-min max-md:overflow-visible min-h-0 overflow-hidden md:justify-center md:pb-8 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
className={`flex flex-col justify-start max-md:min-h-min max-md:overflow-visible min-h-0 overflow-hidden md:justify-center md:pb-8 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||||
@@ -177,7 +179,7 @@ export function CompletedScreen() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`scrollbar-hide relative flex min-h-0 flex-col self-stretch overflow-x-hidden md:max-h-full md:overflow-y-auto ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
className={`relative flex min-h-0 flex-col self-stretch overflow-x-hidden md:max-h-full md:overflow-y-auto ${CREATE_FLOW_SCROLL_REGION_CLASS} ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 bg-gradient-to-b from-[var(--color-teal-teal50,#c9fef9)]/55 from-0% via-[var(--color-teal-teal50,#c9fef9)]/20 via-50% to-transparent md:block"
|
className="pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 bg-gradient-to-b from-[var(--color-teal-teal50,#c9fef9)]/55 from-0% via-[var(--color-teal-teal50,#c9fef9)]/20 via-50% to-transparent md:block"
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
|
|||||||
messageNamespace="create.community.communityName"
|
messageNamespace="create.community.communityName"
|
||||||
stateField="title"
|
stateField="title"
|
||||||
maxLength={48}
|
maxLength={48}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "community-structure":
|
case "community-structure":
|
||||||
@@ -126,6 +127,7 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
|
|||||||
stateField="communityContext"
|
stateField="communityContext"
|
||||||
maxLength={200}
|
maxLength={200}
|
||||||
mainAlign="center"
|
mainAlign="center"
|
||||||
|
multiline
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "community-size":
|
case "community-size":
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import NumberedList from "../../../../components/type/NumberedList";
|
import NumberedList from "../../../../components/type/NumberedList";
|
||||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
@@ -31,22 +30,6 @@ export function InformationalScreen() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const description: ReactNode = (
|
|
||||||
<>
|
|
||||||
{copy.descriptionLead}{" "}
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="font-inter font-normal text-[var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] cursor-pointer"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{copy.workshopLabel}
|
|
||||||
</a>{" "}
|
|
||||||
{copy.descriptionTrail}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CreateFlowStepShell
|
<CreateFlowStepShell
|
||||||
variant="centeredNarrow"
|
variant="centeredNarrow"
|
||||||
@@ -57,7 +40,7 @@ export function InformationalScreen() {
|
|||||||
>
|
>
|
||||||
<CreateFlowHeaderLockup
|
<CreateFlowHeaderLockup
|
||||||
title={copy.title}
|
title={copy.title}
|
||||||
description={description}
|
description={copy.description}
|
||||||
justification="left"
|
justification="left"
|
||||||
/>
|
/>
|
||||||
<NumberedList items={items} size={mdUp ? "M" : "S"} />
|
<NumberedList items={items} size={mdUp ? "M" : "S"} />
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Rule from "../../../../components/cards/Rule";
|
import Rule from "../../../../components/cards/Rule";
|
||||||
|
import Button from "../../../../components/buttons/Button";
|
||||||
import { useTranslation } from "../../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
@@ -17,7 +18,7 @@ import {
|
|||||||
vectorMarkPath,
|
vectorMarkPath,
|
||||||
} from "../../../../../lib/assetUtils";
|
} from "../../../../../lib/assetUtils";
|
||||||
import { methodSectionsPinsForHydratedSelections } from "../../../../../lib/create/publishedDocumentToCreateFlowState";
|
import { methodSectionsPinsForHydratedSelections } from "../../../../../lib/create/publishedDocumentToCreateFlowState";
|
||||||
import { createFlowStepPath } from "../../utils/createFlowPaths";
|
import { CREATE_ROUTES, createFlowStepPath } from "../../utils/createFlowPaths";
|
||||||
|
|
||||||
/** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */
|
/** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */
|
||||||
export function CommunityReviewScreen() {
|
export function CommunityReviewScreen() {
|
||||||
@@ -25,16 +26,22 @@ export function CommunityReviewScreen() {
|
|||||||
const lgUp = useCreateFlowLgUp();
|
const lgUp = useCreateFlowLgUp();
|
||||||
const t = useTranslation("create.community.review");
|
const t = useTranslation("create.community.review");
|
||||||
const { state, updateState } = useCreateFlow();
|
const { state, updateState } = useCreateFlow();
|
||||||
|
/**
|
||||||
|
* Server layout has an empty context; the client layout may already hold a
|
||||||
|
* named draft. Defer empty-vs-congrats until after mount so the first paint
|
||||||
|
* matches SSR (`null`) instead of swapping HeaderLockup titles.
|
||||||
|
*/
|
||||||
|
const [reviewReady, setReviewReady] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
setReviewReady(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If the user picked "Customize" or "Use without changes" from a template
|
* If the user picked **Customize** from a template before finishing community
|
||||||
* before entering community stage, we pinned `pendingTemplateAction` so
|
* stage, we pinned `pendingTemplateAction` so this screen can skip itself
|
||||||
* this screen can skip itself — they already expressed their intent, no
|
* and `replace` to `core-values`. **Use without changes** does not skip
|
||||||
* reason to make them re-pick from the review footer. We `replace` (not
|
* identity: if they land here with that pin, send them to `community-name`
|
||||||
* `push`) so Back from the destination goes to `community-save` instead of
|
* and leave the pin so name → description → photo stay in the skip list.
|
||||||
* 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
|
* Ref guard covers React 18 StrictMode's double-mount in dev so we don't
|
||||||
* fire `router.replace` twice on the same transition.
|
* fire `router.replace` twice on the same transition.
|
||||||
@@ -44,20 +51,19 @@ export function CommunityReviewScreen() {
|
|||||||
if (firedRedirectRef.current) return;
|
if (firedRedirectRef.current) return;
|
||||||
const pending = state.pendingTemplateAction;
|
const pending = state.pendingTemplateAction;
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
const target =
|
if (pending.mode !== "customize") {
|
||||||
pending.mode === "customize"
|
firedRedirectRef.current = true;
|
||||||
? createFlowStepPath("core-values")
|
router.replace(createFlowStepPath("community-name"));
|
||||||
: createFlowStepPath("confirm-stakeholders");
|
return;
|
||||||
|
}
|
||||||
|
const target = createFlowStepPath("core-values");
|
||||||
firedRedirectRef.current = true;
|
firedRedirectRef.current = true;
|
||||||
const pinMerge =
|
const pinMerge = {
|
||||||
pending.mode === "customize"
|
methodSectionsPinCommitted: {
|
||||||
? {
|
...state.methodSectionsPinCommitted,
|
||||||
methodSectionsPinCommitted: {
|
...methodSectionsPinsForHydratedSelections(state),
|
||||||
...state.methodSectionsPinCommitted,
|
},
|
||||||
...methodSectionsPinsForHydratedSelections(state),
|
};
|
||||||
},
|
|
||||||
}
|
|
||||||
: {};
|
|
||||||
updateState({ pendingTemplateAction: undefined, ...pinMerge });
|
updateState({ pendingTemplateAction: undefined, ...pinMerge });
|
||||||
router.replace(target);
|
router.replace(target);
|
||||||
}, [
|
}, [
|
||||||
@@ -74,11 +80,10 @@ export function CommunityReviewScreen() {
|
|||||||
const cardTitle =
|
const cardTitle =
|
||||||
typeof state.title === "string" && state.title.trim().length > 0
|
typeof state.title === "string" && state.title.trim().length > 0
|
||||||
? state.title.trim()
|
? state.title.trim()
|
||||||
: t("ruleCard.title");
|
: "";
|
||||||
/**
|
/**
|
||||||
* No placeholder fallback: if the user skipped `community-context`, leave
|
* No placeholder fallback: if the user skipped `community-context`, leave
|
||||||
* the card description off rather than render the old "Mutual Aid Monday
|
* the card description off rather than render sample copy as real user data.
|
||||||
* is a grassroots community…" sample, which read as real user copy.
|
|
||||||
*/
|
*/
|
||||||
const cardDescription =
|
const cardDescription =
|
||||||
typeof state.communityContext === "string" &&
|
typeof state.communityContext === "string" &&
|
||||||
@@ -92,6 +97,36 @@ export function CommunityReviewScreen() {
|
|||||||
? state.communityAvatarUrl.trim()
|
? state.communityAvatarUrl.trim()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
if (state.pendingTemplateAction || !reviewReady) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cardTitle) {
|
||||||
|
return (
|
||||||
|
<CreateFlowStepShell
|
||||||
|
variant="centeredNarrow"
|
||||||
|
contentTopBelowMd="space-1400"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex flex-col items-start gap-6 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
|
||||||
|
>
|
||||||
|
<CreateFlowHeaderLockup
|
||||||
|
title={t("empty.title")}
|
||||||
|
description={t("empty.description")}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
buttonType="filled"
|
||||||
|
palette="default"
|
||||||
|
size="xsmall"
|
||||||
|
href={CREATE_ROUTES.createFirstStep}
|
||||||
|
>
|
||||||
|
{t("empty.startLabel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CreateFlowStepShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CreateFlowStepShell
|
<CreateFlowStepShell
|
||||||
variant="wideGridLoosePadding"
|
variant="wideGridLoosePadding"
|
||||||
|
|||||||
@@ -97,11 +97,12 @@ export function FinalReviewScreen({
|
|||||||
/**
|
/**
|
||||||
* Two modals coexist on this screen:
|
* Two modals coexist on this screen:
|
||||||
*
|
*
|
||||||
* - {@link FinalReviewChipEditModal} — core values + method chips: kebab
|
* - {@link FinalReviewChipEditModal} — core values + method chips: footer
|
||||||
* Customize / Remove; values also offer Duplicate under the five-chip cap.
|
* **Remove** when the chip is already selected, plus kebab **Remove**;
|
||||||
* Save respects the same unlock/dirty rules as the facet create modals;
|
* values also offer Duplicate under the five-chip cap; method chips
|
||||||
* writes `{group}DetailsById`, snapshot label (values), `customMethodCardMetaById`,
|
* offer Customize (prefilled custom-policy wizard). Fields are editable on
|
||||||
* and field blocks on Save.
|
* open. Save writes `{group}DetailsById` and field blocks; wizard Finalize
|
||||||
|
* also writes `customMethodCardMetaById`.
|
||||||
* - {@link TemplateChipDetailModal} — read-only fallback for chips we
|
* - {@link TemplateChipDetailModal} — read-only fallback for chips we
|
||||||
* can't map to an override key (e.g. template body entries on the
|
* can't map to an override key (e.g. template body entries on the
|
||||||
* "Use without changes" path where no preset matches the title).
|
* "Use without changes" path where no preset matches the title).
|
||||||
@@ -270,7 +271,7 @@ export function FinalReviewScreen({
|
|||||||
}
|
}
|
||||||
size={mdUp ? "L" : "M"}
|
size={mdUp ? "L" : "M"}
|
||||||
expanded={true}
|
expanded={true}
|
||||||
backgroundColor="bg-[#c9fef9]"
|
backgroundColor="bg-[var(--color-surface-invert-brand-teal)]"
|
||||||
logoUrl={getAssetPath(vectorMarkPath("mutual-aid"))}
|
logoUrl={getAssetPath(vectorMarkPath("mutual-aid"))}
|
||||||
logoAlt={ruleCardTitle}
|
logoAlt={ruleCardTitle}
|
||||||
categories={finalReviewCategories}
|
categories={finalReviewCategories}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type { InfoMessageBoxItem } from "../../../../components/controls/InfoMes
|
|||||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
|
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
|
||||||
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
||||||
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||||
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||||
@@ -33,6 +34,11 @@ import { DecisionApproachEditFields } from "../../components/methodEditFields";
|
|||||||
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
||||||
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
||||||
import { decisionApproachPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
|
import { decisionApproachPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
|
||||||
|
import {
|
||||||
|
applyDecisionApproachKeyResources,
|
||||||
|
decisionApproachKeyResourceCheckboxIds,
|
||||||
|
selectedKeyResourceLabelsFromCheckedIds,
|
||||||
|
} from "../../../../../lib/create/decisionApproachKeyResources";
|
||||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||||
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
|
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
|
||||||
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
|
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
|
||||||
@@ -51,43 +57,59 @@ import type { DecisionApproachDetailEntry } from "../../types";
|
|||||||
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
|
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
|
||||||
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
|
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
|
||||||
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
||||||
|
import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
|
||||||
|
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
|
||||||
import {
|
import {
|
||||||
captureMethodCardCustomizeSnapshot,
|
captureMethodCardCustomizeSnapshot,
|
||||||
|
isMethodCardCustomizeUnloadBlocked,
|
||||||
type MethodCardCustomizeSnapshot,
|
type MethodCardCustomizeSnapshot,
|
||||||
type MethodCardHeaderDraft,
|
type MethodCardHeaderDraft,
|
||||||
} from "../../../../../lib/create/methodCardCustomizeSession";
|
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||||
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
|
|
||||||
|
|
||||||
export function DecisionApproachesScreen() {
|
export function DecisionApproachesScreen() {
|
||||||
const m = useMessages();
|
const m = useMessages();
|
||||||
const da = m.create.customRule.decisionApproaches;
|
const da = m.create.customRule.decisionApproaches;
|
||||||
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
||||||
const mdUp = useCreateFlowMdUp();
|
const mdUp = useCreateFlowMdUp();
|
||||||
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
|
const { confirmDiscard, confirmDialog } = useDiscardCustomizeConfirm();
|
||||||
useDiscardCustomizeConfirm();
|
|
||||||
const { state, updateState, replaceState, markCreateFlowInteraction } =
|
const { state, updateState, replaceState, markCreateFlowInteraction } =
|
||||||
useCreateFlow();
|
useCreateFlow();
|
||||||
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
|
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
|
||||||
const customizeSnapshotRef = useRef<
|
const customizeSnapshotRef = useRef<
|
||||||
MethodCardCustomizeSnapshot<DecisionApproachDetailEntry> | null
|
MethodCardCustomizeSnapshot<DecisionApproachDetailEntry> | null
|
||||||
>(null);
|
>(null);
|
||||||
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
|
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
|
||||||
const [pendingDraft, setPendingDraft] =
|
const [pendingDraft, setPendingDraft] =
|
||||||
useState<DecisionApproachDetailEntry | null>(null);
|
useState<DecisionApproachDetailEntry | null>(null);
|
||||||
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
|
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
|
||||||
const [modalEditUnlocked, setModalEditUnlocked] = useState(false);
|
const [wizardCustomizeCardId, setWizardCustomizeCardId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [wizardInitialValues, setWizardInitialValues] =
|
||||||
|
useState<MethodCardWizardInitialValues | null>(null);
|
||||||
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
|
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
|
||||||
CustomMethodCardFieldBlock[] | null
|
CustomMethodCardFieldBlock[] | null
|
||||||
>(null);
|
>(null);
|
||||||
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
|
|
||||||
useState<MethodCardHeaderDraft | null>(null);
|
useBeforeUnloadGuard(
|
||||||
|
isMethodCardCustomizeUnloadBlocked(
|
||||||
|
createModalOpen,
|
||||||
|
customizeSnapshotRef.current,
|
||||||
|
pendingDraft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
customizeSnapshotRef.current?.headerDraft ?? null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const selectedIds = state.selectedDecisionApproachIds ?? [];
|
const selectedIds = state.selectedDecisionApproachIds ?? [];
|
||||||
|
const messageBoxCheckedIds = decisionApproachKeyResourceCheckboxIds({
|
||||||
|
detailsById: state.decisionApproachDetailsById,
|
||||||
|
selectedApproachIds: selectedIds,
|
||||||
|
reminderIds: state.selectedDecisionKeyResourceIds,
|
||||||
|
openDraft: pendingDraft,
|
||||||
|
});
|
||||||
|
|
||||||
const messageBoxItems: InfoMessageBoxItem[] = useMemo(
|
const messageBoxItems: InfoMessageBoxItem[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -117,6 +139,8 @@ export function DecisionApproachesScreen() {
|
|||||||
|
|
||||||
const handleOpenAddWizard = useCallback(() => {
|
const handleOpenAddWizard = useCallback(() => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
|
setWizardCustomizeCardId(null);
|
||||||
|
setWizardInitialValues(null);
|
||||||
setAddCustomWizardOpen(true);
|
setAddCustomWizardOpen(true);
|
||||||
}, [markCreateFlowInteraction]);
|
}, [markCreateFlowInteraction]);
|
||||||
|
|
||||||
@@ -133,22 +157,34 @@ export function DecisionApproachesScreen() {
|
|||||||
const handleMessageBoxCheckboxChange = useCallback(
|
const handleMessageBoxCheckboxChange = useCallback(
|
||||||
(id: string, checked: boolean) => {
|
(id: string, checked: boolean) => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
setMessageBoxCheckedIds((prev) =>
|
const nextCheckedIds = checked
|
||||||
checked ? [...prev, id] : prev.filter((x) => x !== id),
|
? [...messageBoxCheckedIds, id]
|
||||||
);
|
: messageBoxCheckedIds.filter((x) => x !== id);
|
||||||
|
const nextLabels =
|
||||||
|
selectedKeyResourceLabelsFromCheckedIds(nextCheckedIds);
|
||||||
|
if (pendingDraft) {
|
||||||
|
setPendingDraft(
|
||||||
|
applyDecisionApproachKeyResources(pendingDraft, nextLabels),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateState({
|
||||||
|
selectedDecisionKeyResourceIds: nextCheckedIds,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[markCreateFlowInteraction],
|
[
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
messageBoxCheckedIds,
|
||||||
|
pendingDraft,
|
||||||
|
updateState,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const seedDraft = useCallback(
|
const seedDraft = useCallback(
|
||||||
(id: string): DecisionApproachDetailEntry => {
|
(id: string): DecisionApproachDetailEntry => {
|
||||||
const saved = state.decisionApproachDetailsById?.[id];
|
const saved = state.decisionApproachDetailsById?.[id];
|
||||||
if (saved) {
|
if (saved) {
|
||||||
return {
|
return structuredClone(saved);
|
||||||
...saved,
|
|
||||||
applicableScope: [...saved.applicableScope],
|
|
||||||
selectedApplicableScope: [...saved.selectedApplicableScope],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return decisionApproachPresetFor(id);
|
return decisionApproachPresetFor(id);
|
||||||
},
|
},
|
||||||
@@ -158,15 +194,40 @@ export function DecisionApproachesScreen() {
|
|||||||
const handleCardSelect = useCallback(
|
const handleCardSelect = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
customizeSnapshotRef.current = null;
|
const draft = seedDraft(id);
|
||||||
setModalEditUnlocked(false);
|
const persistedBlocks = state.customMethodCardFieldBlocksById?.[id];
|
||||||
setDraftFieldBlocks(null);
|
const initialBlocks =
|
||||||
setCustomizeHeaderDraft(null);
|
Array.isArray(persistedBlocks) && persistedBlocks.length > 0
|
||||||
|
? structuredClone(persistedBlocks)
|
||||||
|
: null;
|
||||||
|
const method = methodById.get(id);
|
||||||
|
const meta = state.customMethodCardMetaById?.[id];
|
||||||
|
const headerDraft: MethodCardHeaderDraft = {
|
||||||
|
title: meta?.label ?? method?.label ?? da.confirmModal.title,
|
||||||
|
description:
|
||||||
|
meta?.supportText ??
|
||||||
|
method?.supportText ??
|
||||||
|
da.confirmModal.description,
|
||||||
|
};
|
||||||
|
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
||||||
|
draft,
|
||||||
|
initialBlocks,
|
||||||
|
headerDraft,
|
||||||
|
);
|
||||||
setPendingCardId(id);
|
setPendingCardId(id);
|
||||||
setPendingDraft(seedDraft(id));
|
setPendingDraft(draft);
|
||||||
|
setDraftFieldBlocks(initialBlocks);
|
||||||
setCreateModalOpen(true);
|
setCreateModalOpen(true);
|
||||||
},
|
},
|
||||||
[markCreateFlowInteraction, seedDraft],
|
[
|
||||||
|
da.confirmModal.description,
|
||||||
|
da.confirmModal.title,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
methodById,
|
||||||
|
seedDraft,
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDraftChange = useCallback(
|
const handleDraftChange = useCallback(
|
||||||
@@ -179,9 +240,7 @@ export function DecisionApproachesScreen() {
|
|||||||
|
|
||||||
const isSelectedCardModal =
|
const isSelectedCardModal =
|
||||||
pendingCardId !== null && selectedIds.includes(pendingCardId);
|
pendingCardId !== null && selectedIds.includes(pendingCardId);
|
||||||
const fieldsLocked = !modalEditUnlocked;
|
const showMethodModalPrimary = true;
|
||||||
|
|
||||||
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
|
|
||||||
|
|
||||||
const customFacetDetailsMatchPreset = useMemo(() => {
|
const customFacetDetailsMatchPreset = useMemo(() => {
|
||||||
if (!pendingCardId || !pendingDraft) return false;
|
if (!pendingCardId || !pendingDraft) return false;
|
||||||
@@ -203,7 +262,7 @@ export function DecisionApproachesScreen() {
|
|||||||
methodId: pendingCardId,
|
methodId: pendingCardId,
|
||||||
meta: state.customMethodCardMetaById,
|
meta: state.customMethodCardMetaById,
|
||||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||||
modalEditUnlocked,
|
modalEditUnlocked: true,
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
customFacetDetailsMatchPreset,
|
customFacetDetailsMatchPreset,
|
||||||
}),
|
}),
|
||||||
@@ -211,7 +270,6 @@ export function DecisionApproachesScreen() {
|
|||||||
[
|
[
|
||||||
customFacetDetailsMatchPreset,
|
customFacetDetailsMatchPreset,
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
modalEditUnlocked,
|
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
state.customMethodCardFieldBlocksById,
|
state.customMethodCardFieldBlocksById,
|
||||||
state.customMethodCardMetaById,
|
state.customMethodCardMetaById,
|
||||||
@@ -221,11 +279,11 @@ export function DecisionApproachesScreen() {
|
|||||||
const handleCreateModalClose = useCallback(async () => {
|
const handleCreateModalClose = useCallback(async () => {
|
||||||
if (
|
if (
|
||||||
!(await confirmDiscard(
|
!(await confirmDiscard(
|
||||||
modalEditUnlocked,
|
true,
|
||||||
customizeSnapshotRef.current,
|
customizeSnapshotRef.current,
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
customizeHeaderDraft,
|
customizeSnapshotRef.current?.headerDraft ?? null,
|
||||||
))
|
))
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -253,53 +311,14 @@ export function DecisionApproachesScreen() {
|
|||||||
setCreateModalOpen(false);
|
setCreateModalOpen(false);
|
||||||
setPendingCardId(null);
|
setPendingCardId(null);
|
||||||
setPendingDraft(null);
|
setPendingDraft(null);
|
||||||
setModalEditUnlocked(false);
|
|
||||||
setDraftFieldBlocks(null);
|
setDraftFieldBlocks(null);
|
||||||
setCustomizeHeaderDraft(null);
|
|
||||||
}, [
|
}, [
|
||||||
confirmDiscard,
|
confirmDiscard,
|
||||||
customizeHeaderDraft,
|
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
modalEditUnlocked,
|
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
replaceState,
|
replaceState,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleCancelCustomize = useCallback(async () => {
|
|
||||||
if (!modalEditUnlocked) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const snap = customizeSnapshotRef.current;
|
|
||||||
if (!snap) {
|
|
||||||
customizeSnapshotRef.current = null;
|
|
||||||
setModalEditUnlocked(false);
|
|
||||||
setDraftFieldBlocks(null);
|
|
||||||
setCustomizeHeaderDraft(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!(await confirmDirtyCustomizeCancel(
|
|
||||||
snap,
|
|
||||||
pendingDraft,
|
|
||||||
draftFieldBlocks,
|
|
||||||
customizeHeaderDraft,
|
|
||||||
))
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPendingDraft(structuredClone(snap.pendingDraft));
|
|
||||||
setDraftFieldBlocks(null);
|
|
||||||
setModalEditUnlocked(false);
|
|
||||||
customizeSnapshotRef.current = null;
|
|
||||||
setCustomizeHeaderDraft(null);
|
|
||||||
}, [
|
|
||||||
confirmDirtyCustomizeCancel,
|
|
||||||
customizeHeaderDraft,
|
|
||||||
draftFieldBlocks,
|
|
||||||
modalEditUnlocked,
|
|
||||||
pendingDraft,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleRemoveSelectedFromModal = useCallback(async () => {
|
const handleRemoveSelectedFromModal = useCallback(async () => {
|
||||||
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
|
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
|
||||||
return;
|
return;
|
||||||
@@ -307,11 +326,11 @@ export function DecisionApproachesScreen() {
|
|||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
if (
|
if (
|
||||||
!(await confirmDiscard(
|
!(await confirmDiscard(
|
||||||
modalEditUnlocked,
|
true,
|
||||||
customizeSnapshotRef.current,
|
customizeSnapshotRef.current,
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
customizeHeaderDraft,
|
customizeSnapshotRef.current?.headerDraft ?? null,
|
||||||
))
|
))
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -327,11 +346,9 @@ export function DecisionApproachesScreen() {
|
|||||||
await handleCreateModalClose();
|
await handleCreateModalClose();
|
||||||
}, [
|
}, [
|
||||||
confirmDiscard,
|
confirmDiscard,
|
||||||
customizeHeaderDraft,
|
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
handleCreateModalClose,
|
handleCreateModalClose,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
modalEditUnlocked,
|
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
@@ -341,35 +358,36 @@ export function DecisionApproachesScreen() {
|
|||||||
|
|
||||||
const handleCustomize = useCallback(() => {
|
const handleCustomize = useCallback(() => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
if (!pendingDraft || !pendingCardId) {
|
if (!pendingCardId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const initialFieldBlocks =
|
|
||||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
|
||||||
? structuredClone(
|
|
||||||
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [],
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const method = methodById.get(pendingCardId);
|
const method = methodById.get(pendingCardId);
|
||||||
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
setWizardInitialValues(
|
||||||
const headerDraft: MethodCardHeaderDraft = {
|
buildMethodCardWizardInitialValues({
|
||||||
title: meta?.label ?? method?.label ?? da.confirmModal.title,
|
cardId: pendingCardId,
|
||||||
description:
|
fallbackTitle: method?.label ?? da.confirmModal.title,
|
||||||
meta?.supportText ??
|
fallbackDescription:
|
||||||
method?.supportText ??
|
method?.supportText ?? da.confirmModal.description,
|
||||||
da.confirmModal.description,
|
meta: state.customMethodCardMetaById,
|
||||||
};
|
persistedBlocks: state.customMethodCardFieldBlocksById,
|
||||||
setCustomizeHeaderDraft(headerDraft);
|
draftFieldBlocks,
|
||||||
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
facetPrefill: pendingDraft
|
||||||
pendingDraft,
|
? {
|
||||||
initialFieldBlocks,
|
group: "decisionApproaches",
|
||||||
headerDraft,
|
draft: pendingDraft,
|
||||||
|
headings: da.sectionHeadings,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
setDraftFieldBlocks(initialFieldBlocks);
|
setWizardCustomizeCardId(pendingCardId);
|
||||||
setModalEditUnlocked(true);
|
setCreateModalOpen(false);
|
||||||
|
setAddCustomWizardOpen(true);
|
||||||
}, [
|
}, [
|
||||||
da.confirmModal.description,
|
da.confirmModal.description,
|
||||||
da.confirmModal.title,
|
da.confirmModal.title,
|
||||||
|
da.sectionHeadings,
|
||||||
|
draftFieldBlocks,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
methodById,
|
methodById,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
@@ -394,9 +412,7 @@ export function DecisionApproachesScreen() {
|
|||||||
() => decisionApproachPresetFor(newId),
|
() => decisionApproachPresetFor(newId),
|
||||||
);
|
);
|
||||||
const blocksClone = structuredClone(
|
const blocksClone = structuredClone(
|
||||||
modalEditUnlocked &&
|
draftFieldBlocks !== null
|
||||||
draftFieldBlocks !== null &&
|
|
||||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
|
||||||
? draftFieldBlocks
|
? draftFieldBlocks
|
||||||
: cloneMethodCardBlocksForDuplicate(
|
: cloneMethodCardBlocksForDuplicate(
|
||||||
state.customMethodCardFieldBlocksById,
|
state.customMethodCardFieldBlocksById,
|
||||||
@@ -426,13 +442,12 @@ export function DecisionApproachesScreen() {
|
|||||||
customizeSnapshotRef.current = null;
|
customizeSnapshotRef.current = null;
|
||||||
setPendingCardId(newId);
|
setPendingCardId(newId);
|
||||||
setPendingDraft(structuredClone(detailsClone));
|
setPendingDraft(structuredClone(detailsClone));
|
||||||
setModalEditUnlocked(false);
|
setDraftFieldBlocks(
|
||||||
setDraftFieldBlocks(null);
|
blocksClone.length > 0 ? structuredClone(blocksClone) : null,
|
||||||
setCustomizeHeaderDraft(null);
|
);
|
||||||
}, [
|
}, [
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
modalEditUnlocked,
|
|
||||||
modalKebabMenu.duplicateTitleSuffix,
|
modalKebabMenu.duplicateTitleSuffix,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
@@ -461,9 +476,7 @@ export function DecisionApproachesScreen() {
|
|||||||
() => decisionApproachPresetFor(newId),
|
() => decisionApproachPresetFor(newId),
|
||||||
);
|
);
|
||||||
const blocksClone = structuredClone(
|
const blocksClone = structuredClone(
|
||||||
modalEditUnlocked &&
|
draftFieldBlocks !== null
|
||||||
draftFieldBlocks !== null &&
|
|
||||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
|
||||||
? draftFieldBlocks
|
? draftFieldBlocks
|
||||||
: cloneMethodCardBlocksForDuplicate(
|
: cloneMethodCardBlocksForDuplicate(
|
||||||
state.customMethodCardFieldBlocksById,
|
state.customMethodCardFieldBlocksById,
|
||||||
@@ -493,14 +506,13 @@ export function DecisionApproachesScreen() {
|
|||||||
customizeSnapshotRef.current = null;
|
customizeSnapshotRef.current = null;
|
||||||
setPendingCardId(newId);
|
setPendingCardId(newId);
|
||||||
setPendingDraft(structuredClone(detailsClone));
|
setPendingDraft(structuredClone(detailsClone));
|
||||||
setModalEditUnlocked(false);
|
setDraftFieldBlocks(
|
||||||
setDraftFieldBlocks(null);
|
blocksClone.length > 0 ? structuredClone(blocksClone) : null,
|
||||||
setCustomizeHeaderDraft(null);
|
);
|
||||||
}, [
|
}, [
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
methodById,
|
methodById,
|
||||||
modalEditUnlocked,
|
|
||||||
modalKebabMenu.duplicateTitleSuffix,
|
modalKebabMenu.duplicateTitleSuffix,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
@@ -513,7 +525,7 @@ export function DecisionApproachesScreen() {
|
|||||||
const kebabMenuItems = useMemo(
|
const kebabMenuItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
||||||
showCustomize: !modalEditUnlocked,
|
showCustomize: true,
|
||||||
onCustomize: handleCustomize,
|
onCustomize: handleCustomize,
|
||||||
onDuplicate:
|
onDuplicate:
|
||||||
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
|
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
|
||||||
@@ -533,7 +545,6 @@ export function DecisionApproachesScreen() {
|
|||||||
handleDuplicatePrefabCard,
|
handleDuplicatePrefabCard,
|
||||||
handleRemoveSelectedFromModal,
|
handleRemoveSelectedFromModal,
|
||||||
isSelectedCardModal,
|
isSelectedCardModal,
|
||||||
modalEditUnlocked,
|
|
||||||
modalKebabMenu,
|
modalKebabMenu,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
state.customMethodCardMetaById,
|
state.customMethodCardMetaById,
|
||||||
@@ -547,8 +558,14 @@ export function DecisionApproachesScreen() {
|
|||||||
}, [markCreateFlowInteraction]);
|
}, [markCreateFlowInteraction]);
|
||||||
|
|
||||||
const handleCloseAddWizard = useCallback(() => {
|
const handleCloseAddWizard = useCallback(() => {
|
||||||
|
const resumeCardId = wizardCustomizeCardId;
|
||||||
setAddCustomWizardOpen(false);
|
setAddCustomWizardOpen(false);
|
||||||
}, []);
|
setWizardCustomizeCardId(null);
|
||||||
|
setWizardInitialValues(null);
|
||||||
|
if (resumeCardId && pendingCardId === resumeCardId) {
|
||||||
|
setCreateModalOpen(true);
|
||||||
|
}
|
||||||
|
}, [pendingCardId, wizardCustomizeCardId]);
|
||||||
|
|
||||||
const handleFinalizeCustomCard = useCallback(
|
const handleFinalizeCustomCard = useCallback(
|
||||||
({
|
({
|
||||||
@@ -561,6 +578,42 @@ export function DecisionApproachesScreen() {
|
|||||||
fieldBlocks: CustomMethodCardFieldBlock[];
|
fieldBlocks: CustomMethodCardFieldBlock[];
|
||||||
}) => {
|
}) => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
|
const existingId = wizardCustomizeCardId;
|
||||||
|
if (existingId) {
|
||||||
|
updateState({
|
||||||
|
selectedDecisionApproachIds: moveFacetSelectionIdToFront(
|
||||||
|
selectedIds,
|
||||||
|
existingId,
|
||||||
|
),
|
||||||
|
customMethodCardMetaById: methodCardMetaWithCustomizeHeader(
|
||||||
|
state.customMethodCardMetaById,
|
||||||
|
existingId,
|
||||||
|
{ title, description },
|
||||||
|
),
|
||||||
|
...(pendingDraft
|
||||||
|
? {
|
||||||
|
decisionApproachDetailsById: {
|
||||||
|
...(state.decisionApproachDetailsById ?? {}),
|
||||||
|
[existingId]: pendingDraft,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
customMethodCardFieldBlocksById: {
|
||||||
|
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||||
|
[existingId]: fieldBlocks,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (pendingDraft) {
|
||||||
|
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
||||||
|
pendingDraft,
|
||||||
|
fieldBlocks,
|
||||||
|
{ title, description },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setDraftFieldBlocks(structuredClone(fieldBlocks));
|
||||||
|
setAddCustomWizardOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
updateState({
|
updateState({
|
||||||
selectedDecisionApproachIds: moveFacetSelectionIdToFront(
|
selectedDecisionApproachIds: moveFacetSelectionIdToFront(
|
||||||
@@ -583,139 +636,89 @@ export function DecisionApproachesScreen() {
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
|
pendingDraft,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
state.customMethodCardFieldBlocksById,
|
state.customMethodCardFieldBlocksById,
|
||||||
state.customMethodCardMetaById,
|
state.customMethodCardMetaById,
|
||||||
state.decisionApproachDetailsById,
|
state.decisionApproachDetailsById,
|
||||||
updateState,
|
updateState,
|
||||||
|
wizardCustomizeCardId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreateModalPrimary = useCallback(() => {
|
const handleCreateModalPrimary = useCallback(() => {
|
||||||
if (!pendingCardId) {
|
if (!pendingCardId) {
|
||||||
handleCreateModalClose();
|
void handleCreateModalClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
|
|
||||||
|
const persistWizardBlocks =
|
||||||
|
modalUsesWizardFieldBlocksBody && draftFieldBlocks !== null;
|
||||||
|
|
||||||
if (selectedIds.includes(pendingCardId)) {
|
if (selectedIds.includes(pendingCardId)) {
|
||||||
if (modalEditUnlocked) {
|
replaceState((prev) => {
|
||||||
if (!customizeHeaderDraft) {
|
if (persistWizardBlocks) {
|
||||||
return;
|
return {
|
||||||
}
|
...prev,
|
||||||
const nextMeta = methodCardMetaWithCustomizeHeader(
|
|
||||||
state.customMethodCardMetaById,
|
|
||||||
pendingCardId,
|
|
||||||
customizeHeaderDraft,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
pendingCardId &&
|
|
||||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
|
||||||
usesWizardFieldBlocksModalBody({
|
|
||||||
methodId: pendingCardId,
|
|
||||||
meta: state.customMethodCardMetaById,
|
|
||||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
|
||||||
modalEditUnlocked,
|
|
||||||
draftFieldBlocks,
|
|
||||||
customFacetDetailsMatchPreset,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
updateState({
|
|
||||||
customMethodCardMetaById: nextMeta,
|
|
||||||
customMethodCardFieldBlocksById: {
|
customMethodCardFieldBlocksById: {
|
||||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
...(prev.customMethodCardFieldBlocksById ?? {}),
|
||||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
} else if (pendingDraft) {
|
|
||||||
updateState({
|
|
||||||
customMethodCardMetaById: nextMeta,
|
|
||||||
decisionApproachDetailsById: {
|
|
||||||
...(state.decisionApproachDetailsById ?? {}),
|
|
||||||
[pendingCardId]: pendingDraft,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
customizeSnapshotRef.current = null;
|
if (!pendingDraft) {
|
||||||
setModalEditUnlocked(false);
|
return prev;
|
||||||
setDraftFieldBlocks(null);
|
}
|
||||||
setCustomizeHeaderDraft(null);
|
return {
|
||||||
return;
|
...prev,
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (modalEditUnlocked) {
|
|
||||||
if (!customizeHeaderDraft) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextMeta = methodCardMetaWithCustomizeHeader(
|
|
||||||
state.customMethodCardMetaById,
|
|
||||||
pendingCardId,
|
|
||||||
customizeHeaderDraft,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
pendingCardId &&
|
|
||||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
|
||||||
usesWizardFieldBlocksModalBody({
|
|
||||||
methodId: pendingCardId,
|
|
||||||
meta: state.customMethodCardMetaById,
|
|
||||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
|
||||||
modalEditUnlocked,
|
|
||||||
draftFieldBlocks,
|
|
||||||
customFacetDetailsMatchPreset,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
updateState({
|
|
||||||
customMethodCardMetaById: nextMeta,
|
|
||||||
customMethodCardFieldBlocksById: {
|
|
||||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
|
||||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else if (pendingDraft) {
|
|
||||||
updateState({
|
|
||||||
customMethodCardMetaById: nextMeta,
|
|
||||||
decisionApproachDetailsById: {
|
decisionApproachDetailsById: {
|
||||||
...(state.decisionApproachDetailsById ?? {}),
|
...(prev.decisionApproachDetailsById ?? {}),
|
||||||
[pendingCardId]: pendingDraft,
|
[pendingCardId]: structuredClone(pendingDraft),
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
}
|
});
|
||||||
|
pendingEphemeralDuplicateIdRef.current = null;
|
||||||
customizeSnapshotRef.current = null;
|
customizeSnapshotRef.current = null;
|
||||||
setModalEditUnlocked(false);
|
void handleCreateModalClose();
|
||||||
setDraftFieldBlocks(null);
|
|
||||||
setCustomizeHeaderDraft(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pendingDraft) {
|
if (!pendingDraft) {
|
||||||
handleCreateModalClose();
|
void handleCreateModalClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateState({
|
replaceState((prev) => ({
|
||||||
|
...prev,
|
||||||
selectedDecisionApproachIds: moveFacetSelectionIdToFront(
|
selectedDecisionApproachIds: moveFacetSelectionIdToFront(
|
||||||
selectedIds,
|
prev.selectedDecisionApproachIds ?? [],
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
),
|
),
|
||||||
decisionApproachDetailsById: {
|
decisionApproachDetailsById: {
|
||||||
...(state.decisionApproachDetailsById ?? {}),
|
...(prev.decisionApproachDetailsById ?? {}),
|
||||||
[pendingCardId]: pendingDraft,
|
[pendingCardId]: structuredClone(pendingDraft),
|
||||||
},
|
},
|
||||||
});
|
...(persistWizardBlocks
|
||||||
|
? {
|
||||||
|
customMethodCardFieldBlocksById: {
|
||||||
|
...(prev.customMethodCardFieldBlocksById ?? {}),
|
||||||
|
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}));
|
||||||
pendingEphemeralDuplicateIdRef.current = null;
|
pendingEphemeralDuplicateIdRef.current = null;
|
||||||
handleCreateModalClose();
|
customizeSnapshotRef.current = null;
|
||||||
|
void handleCreateModalClose();
|
||||||
}, [
|
}, [
|
||||||
customizeHeaderDraft,
|
|
||||||
draftFieldBlocks,
|
draftFieldBlocks,
|
||||||
handleCreateModalClose,
|
handleCreateModalClose,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
modalEditUnlocked,
|
modalUsesWizardFieldBlocksBody,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
|
replaceState,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
state,
|
|
||||||
updateState,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const modalConfig = pendingCardId
|
const modalConfig = pendingCardId
|
||||||
@@ -729,7 +732,7 @@ export function DecisionApproachesScreen() {
|
|||||||
meta?.supportText ??
|
meta?.supportText ??
|
||||||
method?.supportText ??
|
method?.supportText ??
|
||||||
da.confirmModal.description,
|
da.confirmModal.description,
|
||||||
nextButtonText: modalEditUnlocked
|
nextButtonText: isSelectedCardModal
|
||||||
? saveLabel
|
? saveLabel
|
||||||
: da.addApproach.nextButtonText,
|
: da.addApproach.nextButtonText,
|
||||||
};
|
};
|
||||||
@@ -802,34 +805,14 @@ export function DecisionApproachesScreen() {
|
|||||||
<Create
|
<Create
|
||||||
isOpen={createModalOpen}
|
isOpen={createModalOpen}
|
||||||
onClose={handleCreateModalClose}
|
onClose={handleCreateModalClose}
|
||||||
headerContent={
|
|
||||||
modalEditUnlocked && customizeHeaderDraft ? (
|
|
||||||
<MethodCardCustomizeModalHeader
|
|
||||||
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
|
|
||||||
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
|
|
||||||
titleValue={customizeHeaderDraft.title}
|
|
||||||
descriptionValue={customizeHeaderDraft.description}
|
|
||||||
onTitleChange={(title) =>
|
|
||||||
setCustomizeHeaderDraft((prev) =>
|
|
||||||
prev ? { ...prev, title } : null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onDescriptionChange={(description) =>
|
|
||||||
setCustomizeHeaderDraft((prev) =>
|
|
||||||
prev ? { ...prev, description } : null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
onNext={handleCreateModalPrimary}
|
onNext={handleCreateModalPrimary}
|
||||||
title={modalConfig.title}
|
title={modalConfig.title}
|
||||||
description={modalConfig.description}
|
description={modalConfig.description}
|
||||||
nextButtonText={modalConfig.nextButtonText}
|
nextButtonText={modalConfig.nextButtonText}
|
||||||
showBackButton={modalEditUnlocked}
|
showBackButton={false}
|
||||||
onBack={handleCancelCustomize}
|
|
||||||
backButtonText={modalKebabMenu.cancelCustomize}
|
|
||||||
showNextButton={showMethodModalPrimary}
|
showNextButton={showMethodModalPrimary}
|
||||||
|
showRemoveButton={isSelectedCardModal}
|
||||||
|
onRemove={handleRemoveSelectedFromModal}
|
||||||
backdropVariant="blurredYellow"
|
backdropVariant="blurredYellow"
|
||||||
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
||||||
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
|
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
|
||||||
@@ -841,14 +824,14 @@ export function DecisionApproachesScreen() {
|
|||||||
cardId={pendingCardId}
|
cardId={pendingCardId}
|
||||||
blocksById={state.customMethodCardFieldBlocksById}
|
blocksById={state.customMethodCardFieldBlocksById}
|
||||||
blocksOverride={
|
blocksOverride={
|
||||||
modalEditUnlocked && draftFieldBlocks !== null
|
draftFieldBlocks !== null ? draftFieldBlocks : undefined
|
||||||
? draftFieldBlocks
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
|
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
|
||||||
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked}
|
showPolicyContentLockupWhenNoBlocks={
|
||||||
|
draftFieldBlocks === null || draftFieldBlocks.length === 0
|
||||||
|
}
|
||||||
onFieldBlocksChange={
|
onFieldBlocksChange={
|
||||||
fieldsLocked
|
draftFieldBlocks === null
|
||||||
? undefined
|
? undefined
|
||||||
: (next) => setDraftFieldBlocks(next)
|
: (next) => setDraftFieldBlocks(next)
|
||||||
}
|
}
|
||||||
@@ -857,7 +840,6 @@ export function DecisionApproachesScreen() {
|
|||||||
<DecisionApproachEditFields
|
<DecisionApproachEditFields
|
||||||
value={pendingDraft}
|
value={pendingDraft}
|
||||||
onChange={handleDraftChange}
|
onChange={handleDraftChange}
|
||||||
readOnly={fieldsLocked}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
@@ -866,6 +848,7 @@ export function DecisionApproachesScreen() {
|
|||||||
<CustomMethodCardWizard
|
<CustomMethodCardWizard
|
||||||
isOpen={addCustomWizardOpen}
|
isOpen={addCustomWizardOpen}
|
||||||
onClose={handleCloseAddWizard}
|
onClose={handleCloseAddWizard}
|
||||||
|
initialValues={wizardInitialValues}
|
||||||
onFinalize={handleFinalizeCustomCard}
|
onFinalize={handleFinalizeCustomCard}
|
||||||
onPersistCustomUploadFile={(file) =>
|
onPersistCustomUploadFile={(file) =>
|
||||||
uploadCreateFlowFile(file, "customMethodAttachment")
|
uploadCreateFlowFile(file, "customMethodAttachment")
|
||||||
|
|||||||
@@ -303,7 +303,6 @@ export function CommunityStructureSelectScreen() {
|
|||||||
<>
|
<>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
label={cs.organizationMultiSelect.label}
|
label={cs.organizationMultiSelect.label}
|
||||||
showHelpIcon
|
|
||||||
size="s"
|
size="s"
|
||||||
options={organizationTypeOptions}
|
options={organizationTypeOptions}
|
||||||
onChipClick={handleOrganizationTypeClick}
|
onChipClick={handleOrganizationTypeClick}
|
||||||
@@ -313,7 +312,6 @@ export function CommunityStructureSelectScreen() {
|
|||||||
/>
|
/>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
label={cs.scaleMultiSelect.label}
|
label={cs.scaleMultiSelect.label}
|
||||||
showHelpIcon
|
|
||||||
size="s"
|
size="s"
|
||||||
options={scaleOptions}
|
options={scaleOptions}
|
||||||
onChipClick={handleScaleClick}
|
onChipClick={handleScaleClick}
|
||||||
@@ -323,7 +321,6 @@ export function CommunityStructureSelectScreen() {
|
|||||||
/>
|
/>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
label={cs.maturityMultiSelect.label}
|
label={cs.maturityMultiSelect.label}
|
||||||
showHelpIcon
|
|
||||||
size="s"
|
size="s"
|
||||||
options={maturityOptions}
|
options={maturityOptions}
|
||||||
onChipClick={handleMaturityClick}
|
onChipClick={handleMaturityClick}
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import { useTranslation } from "../../../../contexts/MessagesContext";
|
|||||||
import { MAX_STAKEHOLDER_EMAILS } from "../../../../../lib/create/stakeholderLimits";
|
import { MAX_STAKEHOLDER_EMAILS } from "../../../../../lib/create/stakeholderLimits";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
|
||||||
import {
|
import {
|
||||||
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
|
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
|
||||||
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
|
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
|
||||||
@@ -131,64 +130,52 @@ export function ConfirmStakeholdersScreen() {
|
|||||||
|
|
||||||
if (managePublishedMode) {
|
if (managePublishedMode) {
|
||||||
return (
|
return (
|
||||||
<CreateFlowStepShell
|
<CreateFlowTwoColumnSelectShell
|
||||||
variant="centeredNarrowBottomPad"
|
header={
|
||||||
contentTopBelowMd="space-1400"
|
<CreateFlowHeaderLockup
|
||||||
|
title={t("managePublished.lockupTitle")}
|
||||||
|
description={t("managePublished.lockupDescription")}
|
||||||
|
justification="left"
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<PublishedStakeholdersManagePanel ruleId={editingPublishedRuleId} />
|
||||||
className={`flex flex-col items-start gap-[var(--measures-spacing-300,12px)] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
</CreateFlowTwoColumnSelectShell>
|
||||||
>
|
|
||||||
<div className="flex w-full flex-col gap-[var(--measures-spacing-200,8px)] py-[12px]">
|
|
||||||
<CreateFlowHeaderLockup
|
|
||||||
title={t("managePublished.lockupTitle")}
|
|
||||||
description={t("managePublished.lockupDescription")}
|
|
||||||
justification="left"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<PublishedStakeholdersManagePanel ruleId={editingPublishedRuleId} />
|
|
||||||
</div>
|
|
||||||
</CreateFlowStepShell>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CreateFlowStepShell
|
<CreateFlowTwoColumnSelectShell
|
||||||
variant="centeredNarrowBottomPad"
|
header={
|
||||||
contentTopBelowMd="space-1400"
|
<CreateFlowHeaderLockup
|
||||||
>
|
title={t("title")}
|
||||||
<div
|
description={t("description")}
|
||||||
className={`flex flex-col items-start gap-[var(--measures-spacing-300,12px)] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
justification="left"
|
||||||
>
|
|
||||||
<div className="flex w-full flex-col gap-[var(--measures-spacing-200,8px)] py-[12px]">
|
|
||||||
<CreateFlowHeaderLockup
|
|
||||||
title={t("title")}
|
|
||||||
description={t("description")}
|
|
||||||
justification="left"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{chipError ? (
|
|
||||||
<p
|
|
||||||
className="font-inter text-sm text-[var(--color-border-default-utility-negative)]"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{chipError}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<MultiSelect
|
|
||||||
formHeader={false}
|
|
||||||
showHelpIcon={false}
|
|
||||||
size="s"
|
|
||||||
options={stakeholderOptions}
|
|
||||||
onChipClick={handleChipClick}
|
|
||||||
onAddClick={handleAddStakeholder}
|
|
||||||
onCustomChipConfirm={handleCustomChipConfirm}
|
|
||||||
onCustomChipClose={handleCustomChipClose}
|
|
||||||
addButton
|
|
||||||
addButtonText={t("addStakeholder")}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
</CreateFlowStepShell>
|
>
|
||||||
|
{chipError ? (
|
||||||
|
<p
|
||||||
|
className="text-small-paragraph text-[var(--color-border-default-utility-negative)]"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{chipError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<MultiSelect
|
||||||
|
formHeader={false}
|
||||||
|
showHelpIcon={false}
|
||||||
|
size="s"
|
||||||
|
options={stakeholderOptions}
|
||||||
|
onChipClick={handleChipClick}
|
||||||
|
onAddClick={handleAddStakeholder}
|
||||||
|
onCustomChipConfirm={handleCustomChipConfirm}
|
||||||
|
onCustomChipClose={handleCustomChipClose}
|
||||||
|
addButton
|
||||||
|
addButtonText={t("addStakeholder")}
|
||||||
|
/>
|
||||||
|
</CreateFlowTwoColumnSelectShell>
|
||||||
|
|
||||||
{!toastDismissed && (
|
{!toastDismissed && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import ContentLockup from "../../../../components/type/ContentLockup";
|
|||||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { buildCoreValueChipOptionsFromDraft } from "../../../../../lib/create/coreValueChipOptionsFromDraft";
|
import { buildCoreValueChipOptionsFromDraft } from "../../../../../lib/create/coreValueChipOptionsFromDraft";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
|
import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm";
|
||||||
|
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
|
||||||
import type {
|
import type {
|
||||||
CommunityStructureChipSnapshotRow,
|
CommunityStructureChipSnapshotRow,
|
||||||
CoreValueDetailEntry,
|
CoreValueDetailEntry,
|
||||||
@@ -16,20 +17,25 @@ import type {
|
|||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||||
import { CoreValueEditFields } from "../../components/methodEditFields";
|
import { CoreValueEditFields } from "../../components/methodEditFields";
|
||||||
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
|
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
|
||||||
|
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
||||||
|
import { usesWizardFieldBlocksModalBody } from "../../../../../lib/create/usesWizardFieldBlocksModalBody";
|
||||||
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
|
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
|
||||||
import {
|
|
||||||
captureMethodCardCustomizeSnapshot,
|
|
||||||
isMethodCardCustomizeSessionDirty,
|
|
||||||
type MethodCardCustomizeSnapshot,
|
|
||||||
type MethodCardHeaderDraft,
|
|
||||||
} from "../../../../../lib/create/methodCardCustomizeSession";
|
|
||||||
import {
|
import {
|
||||||
duplicateCoreValueChipInDraft,
|
duplicateCoreValueChipInDraft,
|
||||||
MAX_SELECTED_CORE_VALUES,
|
MAX_SELECTED_CORE_VALUES,
|
||||||
removeCoreValueChipFromDraft,
|
removeCoreValueChipFromDraft,
|
||||||
} from "../../../../../lib/create/coreValueChipFacet";
|
} from "../../../../../lib/create/coreValueChipFacet";
|
||||||
import { omitIdFromStringRecord } from "../../../../../lib/create/duplicateMethodCardModalDraft";
|
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
||||||
|
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
|
||||||
|
import {
|
||||||
|
buildMethodCardWizardInitialValues,
|
||||||
|
coreValueDetailsFromWizardFieldBlocks,
|
||||||
|
overlayFacetPrefillValues,
|
||||||
|
type MethodCardWizardInitialValues,
|
||||||
|
} from "../../../../../lib/create/methodCardWizardPrefill";
|
||||||
|
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||||
|
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
||||||
|
|
||||||
const MAX_CORE_VALUES = MAX_SELECTED_CORE_VALUES;
|
const MAX_CORE_VALUES = MAX_SELECTED_CORE_VALUES;
|
||||||
|
|
||||||
@@ -37,13 +43,14 @@ const MAX_CORE_VALUES = MAX_SELECTED_CORE_VALUES;
|
|||||||
* Why three sessions, not two:
|
* Why three sessions, not two:
|
||||||
*
|
*
|
||||||
* - `pending` — preset chip just selected; modal opened to capture
|
* - `pending` — preset chip just selected; modal opened to capture
|
||||||
* meaning/signals. Dismiss = unselect the chip (keep it in the
|
* meaning/signals. Close (X) confirms, then unselects the chip.
|
||||||
* preset row, just not selected).
|
* - `customPending` — leftover inline custom-chip drafts (older sessions).
|
||||||
* - `customPending` — brand-new custom chip just created via the Add
|
* Dismiss = drop the chip entirely.
|
||||||
* 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
|
* - `editing` — chip already exists & is selected; modal reopened to
|
||||||
* tweak meaning/signals. Dismiss = no-op (chip stays as-is).
|
* tweak meaning/signals. Dismiss = no-op (chip stays as-is).
|
||||||
|
*
|
||||||
|
* **Add value** (and the header "add" link) opens an empty custom-policy
|
||||||
|
* wizard. Finalize writes a selected chip named from the wizard title.
|
||||||
*/
|
*/
|
||||||
type ModalSession = "pending" | "customPending" | "editing";
|
type ModalSession = "pending" | "customPending" | "editing";
|
||||||
|
|
||||||
@@ -101,13 +108,11 @@ export function CoreValuesSelectScreen() {
|
|||||||
[cv.values],
|
[cv.values],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
|
const { requestConfirm, confirmDialog } = useAsyncConfirm();
|
||||||
useDiscardCustomizeConfirm();
|
|
||||||
const { markCreateFlowInteraction, updateState, replaceState, state } =
|
const { markCreateFlowInteraction, updateState, replaceState, state } =
|
||||||
useCreateFlow();
|
useCreateFlow();
|
||||||
|
|
||||||
const coreCustomizeSnapshotRef =
|
const initialDraftRef = useRef<CoreValueDetailEntry | null>(null);
|
||||||
useRef<MethodCardCustomizeSnapshot<CoreValueDetailEntry> | null>(null);
|
|
||||||
const pendingEphemeralCoreDuplicateRef = useRef<string | null>(null);
|
const pendingEphemeralCoreDuplicateRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const [coreValueOptions, setCoreValueOptions] = useState<ChipOption[]>(() =>
|
const [coreValueOptions, setCoreValueOptions] = useState<ChipOption[]>(() =>
|
||||||
@@ -123,9 +128,15 @@ export function CoreValuesSelectScreen() {
|
|||||||
);
|
);
|
||||||
const [modalSession, setModalSession] = useState<ModalSession | null>(null);
|
const [modalSession, setModalSession] = useState<ModalSession | null>(null);
|
||||||
const [draft, setDraft] = useState<CoreValueDetailEntry>(EMPTY_DETAIL);
|
const [draft, setDraft] = useState<CoreValueDetailEntry>(EMPTY_DETAIL);
|
||||||
const [modalEditUnlocked, setModalEditUnlocked] = useState(false);
|
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
|
||||||
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
|
const [wizardCustomizeChipId, setWizardCustomizeChipId] = useState<
|
||||||
useState<MethodCardHeaderDraft | null>(null);
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [wizardInitialValues, setWizardInitialValues] =
|
||||||
|
useState<MethodCardWizardInitialValues | null>(null);
|
||||||
|
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
|
||||||
|
CustomMethodCardFieldBlock[] | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCoreValueOptions(
|
setCoreValueOptions(
|
||||||
@@ -178,6 +189,7 @@ export function CoreValuesSelectScreen() {
|
|||||||
return {
|
return {
|
||||||
meaning: saved?.meaning ?? preset.meaning,
|
meaning: saved?.meaning ?? preset.meaning,
|
||||||
signals: saved?.signals ?? preset.signals,
|
signals: saved?.signals ?? preset.signals,
|
||||||
|
...(saved?.supportText ? { supportText: saved.supportText } : {}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[state.coreValueDetailsByChipId, getPresetTexts],
|
[state.coreValueDetailsByChipId, getPresetTexts],
|
||||||
@@ -190,15 +202,24 @@ export function CoreValuesSelectScreen() {
|
|||||||
valueLabel: string,
|
valueLabel: string,
|
||||||
seedDetail?: CoreValueDetailEntry,
|
seedDetail?: CoreValueDetailEntry,
|
||||||
) => {
|
) => {
|
||||||
setDraft(seedDetail ?? getInitialTexts(chipId, valueLabel));
|
const initial = seedDetail ?? getInitialTexts(chipId, valueLabel);
|
||||||
|
initialDraftRef.current = { ...initial };
|
||||||
|
const persisted = state.customMethodCardFieldBlocksById?.[chipId];
|
||||||
|
setDraftFieldBlocks(
|
||||||
|
Array.isArray(persisted) && persisted.length > 0
|
||||||
|
? structuredClone(persisted)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
setDraft(initial);
|
||||||
setActiveModalChipId(chipId);
|
setActiveModalChipId(chipId);
|
||||||
setModalSession(session);
|
setModalSession(session);
|
||||||
setModalEditUnlocked(false);
|
|
||||||
setCustomizeHeaderDraft(null);
|
|
||||||
coreCustomizeSnapshotRef.current = null;
|
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
},
|
},
|
||||||
[getInitialTexts, markCreateFlowInteraction],
|
[
|
||||||
|
getInitialTexts,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDraftChange = useCallback(
|
const handleDraftChange = useCallback(
|
||||||
@@ -209,87 +230,44 @@ export function CoreValuesSelectScreen() {
|
|||||||
[markCreateFlowInteraction],
|
[markCreateFlowInteraction],
|
||||||
);
|
);
|
||||||
|
|
||||||
const resetCustomizeSession = useCallback(() => {
|
|
||||||
coreCustomizeSnapshotRef.current = null;
|
|
||||||
setModalEditUnlocked(false);
|
|
||||||
setCustomizeHeaderDraft(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const finalizeModalDismiss = useCallback(() => {
|
const finalizeModalDismiss = useCallback(() => {
|
||||||
pendingEphemeralCoreDuplicateRef.current = null;
|
pendingEphemeralCoreDuplicateRef.current = null;
|
||||||
resetCustomizeSession();
|
initialDraftRef.current = null;
|
||||||
setActiveModalChipId(null);
|
setActiveModalChipId(null);
|
||||||
setModalSession(null);
|
setModalSession(null);
|
||||||
}, [resetCustomizeSession]);
|
setDraftFieldBlocks(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleCustomize = useCallback(() => {
|
const confirmLeaveWithoutSaving = useCallback(async () => {
|
||||||
if (!activeModalChipId) return;
|
const initial = initialDraftRef.current;
|
||||||
const chipLabelNow =
|
const fieldsDirty =
|
||||||
coreValueOptions.find((o) => o.id === activeModalChipId)?.label ?? "";
|
initial != null &&
|
||||||
if (!chipLabelNow) return;
|
(draft.meaning !== initial.meaning || draft.signals !== initial.signals);
|
||||||
markCreateFlowInteraction();
|
if (!fieldsDirty) {
|
||||||
const headerDraft: MethodCardHeaderDraft = {
|
return true;
|
||||||
title: chipLabelNow,
|
|
||||||
description: "",
|
|
||||||
};
|
|
||||||
coreCustomizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
|
||||||
draft,
|
|
||||||
null,
|
|
||||||
headerDraft,
|
|
||||||
);
|
|
||||||
setCustomizeHeaderDraft(headerDraft);
|
|
||||||
setModalEditUnlocked(true);
|
|
||||||
}, [activeModalChipId, coreValueOptions, draft, markCreateFlowInteraction]);
|
|
||||||
|
|
||||||
const handleCancelCustomize = useCallback(async () => {
|
|
||||||
if (!modalEditUnlocked) return;
|
|
||||||
const snap = coreCustomizeSnapshotRef.current;
|
|
||||||
if (!snap) {
|
|
||||||
resetCustomizeSession();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (
|
const isPendingAdd =
|
||||||
!(await confirmDirtyCustomizeCancel(
|
modalSession === "pending" || modalSession === "customPending";
|
||||||
snap,
|
return requestConfirm({
|
||||||
draft,
|
title: cv.detailModal.discardTitle,
|
||||||
null,
|
description: isPendingAdd
|
||||||
customizeHeaderDraft,
|
? cv.detailModal.discardPendingDescription
|
||||||
))
|
: cv.detailModal.discardEditsDescription,
|
||||||
) {
|
proceedText: cv.detailModal.discardProceed,
|
||||||
return;
|
cancelText: cv.detailModal.discardKeepEditing,
|
||||||
}
|
});
|
||||||
setDraft(structuredClone(snap.pendingDraft));
|
}, [cv.detailModal, draft, modalSession, requestConfirm]);
|
||||||
resetCustomizeSession();
|
|
||||||
}, [
|
|
||||||
confirmDirtyCustomizeCancel,
|
|
||||||
customizeHeaderDraft,
|
|
||||||
draft,
|
|
||||||
modalEditUnlocked,
|
|
||||||
resetCustomizeSession,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const syncLabelFromCustomizeHeaderToOptions = useCallback(() => {
|
useBeforeUnloadGuard(
|
||||||
if (!activeModalChipId || !customizeHeaderDraft) return coreValueOptions;
|
activeModalChipId != null &&
|
||||||
const trimmed = customizeHeaderDraft.title.trim();
|
!addCustomWizardOpen &&
|
||||||
if (!trimmed) return coreValueOptions;
|
initialDraftRef.current != null &&
|
||||||
return coreValueOptions.map((opt) =>
|
(draft.meaning !== initialDraftRef.current.meaning ||
|
||||||
opt.id === activeModalChipId ? { ...opt, label: trimmed } : opt,
|
draft.signals !== initialDraftRef.current.signals),
|
||||||
);
|
);
|
||||||
}, [activeModalChipId, customizeHeaderDraft, coreValueOptions]);
|
|
||||||
|
|
||||||
const handleDuplicateCoreChip = useCallback(async () => {
|
const handleDuplicateCoreChip = useCallback(() => {
|
||||||
if (!activeModalChipId || !modalSession) return;
|
if (!activeModalChipId || !modalSession) return;
|
||||||
if (
|
|
||||||
!(await confirmDiscard(
|
|
||||||
modalEditUnlocked,
|
|
||||||
coreCustomizeSnapshotRef.current,
|
|
||||||
draft,
|
|
||||||
null,
|
|
||||||
customizeHeaderDraft,
|
|
||||||
))
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
const priorEphemeral = pendingEphemeralCoreDuplicateRef.current;
|
const priorEphemeral = pendingEphemeralCoreDuplicateRef.current;
|
||||||
let outcome: ReturnType<typeof duplicateCoreValueChipInDraft> | null = null;
|
let outcome: ReturnType<typeof duplicateCoreValueChipInDraft> | null = null;
|
||||||
@@ -312,7 +290,6 @@ export function CoreValuesSelectScreen() {
|
|||||||
if (!outcome) {
|
if (!outcome) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resetCustomizeSession();
|
|
||||||
pendingEphemeralCoreDuplicateRef.current = outcome.newId;
|
pendingEphemeralCoreDuplicateRef.current = outcome.newId;
|
||||||
openModal(
|
openModal(
|
||||||
outcome.newId,
|
outcome.newId,
|
||||||
@@ -322,30 +299,15 @@ export function CoreValuesSelectScreen() {
|
|||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
activeModalChipId,
|
activeModalChipId,
|
||||||
confirmDiscard,
|
|
||||||
customizeHeaderDraft,
|
|
||||||
draft,
|
draft,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
modalEditUnlocked,
|
|
||||||
modalKebabMenu.duplicateTitleSuffix,
|
modalKebabMenu.duplicateTitleSuffix,
|
||||||
modalSession,
|
modalSession,
|
||||||
openModal,
|
openModal,
|
||||||
replaceState,
|
replaceState,
|
||||||
resetCustomizeSession,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleRemoveFromKebab = useCallback(async () => {
|
const handleRemoveFromKebab = useCallback(() => {
|
||||||
if (
|
|
||||||
!(await confirmDiscard(
|
|
||||||
modalEditUnlocked,
|
|
||||||
coreCustomizeSnapshotRef.current,
|
|
||||||
draft,
|
|
||||||
null,
|
|
||||||
customizeHeaderDraft,
|
|
||||||
))
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
|
|
||||||
const ep = pendingEphemeralCoreDuplicateRef.current;
|
const ep = pendingEphemeralCoreDuplicateRef.current;
|
||||||
@@ -375,39 +337,23 @@ export function CoreValuesSelectScreen() {
|
|||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
replaceState((prev) => ({
|
replaceState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
selectedCoreValueIds: selectedIdsFromOptions(nextFiltered),
|
...removeCoreValueChipFromDraft(prev, activeModalChipId),
|
||||||
coreValuesChipsSnapshot:
|
|
||||||
chipOptionsToSnapshotRows(nextFiltered),
|
|
||||||
coreValueDetailsByChipId:
|
|
||||||
omitIdFromStringRecord(prev.coreValueDetailsByChipId, activeModalChipId),
|
|
||||||
}));
|
}));
|
||||||
setCoreValueOptions(nextFiltered);
|
setCoreValueOptions(nextFiltered);
|
||||||
}
|
}
|
||||||
finalizeModalDismiss();
|
finalizeModalDismiss();
|
||||||
}, [
|
}, [
|
||||||
activeModalChipId,
|
activeModalChipId,
|
||||||
confirmDiscard,
|
|
||||||
coreValueOptions,
|
coreValueOptions,
|
||||||
customizeHeaderDraft,
|
|
||||||
draft,
|
|
||||||
finalizeModalDismiss,
|
finalizeModalDismiss,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
modalEditUnlocked,
|
|
||||||
modalSession,
|
modalSession,
|
||||||
persistCoreValues,
|
persistCoreValues,
|
||||||
replaceState,
|
replaceState,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleModalDismiss = useCallback(async () => {
|
const handleModalDismiss = useCallback(async () => {
|
||||||
if (
|
if (!(await confirmLeaveWithoutSaving())) {
|
||||||
!(await confirmDiscard(
|
|
||||||
modalEditUnlocked,
|
|
||||||
coreCustomizeSnapshotRef.current,
|
|
||||||
draft,
|
|
||||||
null,
|
|
||||||
customizeHeaderDraft,
|
|
||||||
))
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,118 +382,242 @@ export function CoreValuesSelectScreen() {
|
|||||||
finalizeModalDismiss();
|
finalizeModalDismiss();
|
||||||
}, [
|
}, [
|
||||||
activeModalChipId,
|
activeModalChipId,
|
||||||
confirmDiscard,
|
confirmLeaveWithoutSaving,
|
||||||
coreValueOptions,
|
coreValueOptions,
|
||||||
customizeHeaderDraft,
|
|
||||||
draft,
|
|
||||||
finalizeModalDismiss,
|
finalizeModalDismiss,
|
||||||
modalEditUnlocked,
|
|
||||||
modalSession,
|
modalSession,
|
||||||
persistCoreValues,
|
persistCoreValues,
|
||||||
replaceState,
|
replaceState,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const coreCustomizeSaveDisabled = useMemo(() => {
|
|
||||||
if (!modalEditUnlocked) return false;
|
|
||||||
const snap = coreCustomizeSnapshotRef.current;
|
|
||||||
if (!snap) return true;
|
|
||||||
return !isMethodCardCustomizeSessionDirty(
|
|
||||||
snap,
|
|
||||||
draft,
|
|
||||||
null,
|
|
||||||
customizeHeaderDraft,
|
|
||||||
);
|
|
||||||
}, [customizeHeaderDraft, draft, modalEditUnlocked]);
|
|
||||||
|
|
||||||
const handleModalConfirm = useCallback(() => {
|
const handleModalConfirm = useCallback(() => {
|
||||||
if (!activeModalChipId || !modalSession) return;
|
if (!activeModalChipId || !modalSession) return;
|
||||||
|
markCreateFlowInteraction();
|
||||||
if (modalEditUnlocked && customizeHeaderDraft) {
|
pendingEphemeralCoreDuplicateRef.current = null;
|
||||||
if (coreCustomizeSaveDisabled) {
|
const existingBlocks =
|
||||||
return;
|
draftFieldBlocks && draftFieldBlocks.length > 0
|
||||||
}
|
? draftFieldBlocks
|
||||||
markCreateFlowInteraction();
|
: state.customMethodCardFieldBlocksById?.[activeModalChipId];
|
||||||
pendingEphemeralCoreDuplicateRef.current = null;
|
updateState({
|
||||||
const nextOpts = syncLabelFromCustomizeHeaderToOptions();
|
coreValueDetailsByChipId: {
|
||||||
persistCoreValues(nextOpts);
|
...(state.coreValueDetailsByChipId ?? {}),
|
||||||
updateState({
|
[activeModalChipId]: draft,
|
||||||
coreValueDetailsByChipId: {
|
},
|
||||||
...(state.coreValueDetailsByChipId ?? {}),
|
...(existingBlocks && existingBlocks.length > 0
|
||||||
[activeModalChipId]: draft,
|
? {
|
||||||
},
|
customMethodCardFieldBlocksById: {
|
||||||
});
|
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||||
resetCustomizeSession();
|
[activeModalChipId]: overlayFacetPrefillValues(existingBlocks, {
|
||||||
return;
|
group: "coreValues",
|
||||||
}
|
draft,
|
||||||
|
headings: {
|
||||||
if (modalSession === "pending" || modalSession === "customPending") {
|
meaning: cv.detailModal.meaningLabel,
|
||||||
markCreateFlowInteraction();
|
signals: cv.detailModal.signalsLabel,
|
||||||
pendingEphemeralCoreDuplicateRef.current = null;
|
},
|
||||||
updateState({
|
}),
|
||||||
coreValueDetailsByChipId: {
|
},
|
||||||
...(state.coreValueDetailsByChipId ?? {}),
|
}
|
||||||
[activeModalChipId]: draft,
|
: {}),
|
||||||
},
|
});
|
||||||
});
|
finalizeModalDismiss();
|
||||||
resetCustomizeSession();
|
|
||||||
setActiveModalChipId(null);
|
|
||||||
setModalSession(null);
|
|
||||||
}
|
|
||||||
}, [
|
}, [
|
||||||
activeModalChipId,
|
activeModalChipId,
|
||||||
coreCustomizeSaveDisabled,
|
cv.detailModal.meaningLabel,
|
||||||
customizeHeaderDraft,
|
cv.detailModal.signalsLabel,
|
||||||
draft,
|
draft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
finalizeModalDismiss,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
modalEditUnlocked,
|
|
||||||
modalSession,
|
modalSession,
|
||||||
persistCoreValues,
|
|
||||||
resetCustomizeSession,
|
|
||||||
state.coreValueDetailsByChipId,
|
state.coreValueDetailsByChipId,
|
||||||
syncLabelFromCustomizeHeaderToOptions,
|
state.customMethodCardFieldBlocksById,
|
||||||
updateState,
|
updateState,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const modalChipLabel =
|
const modalChipLabel =
|
||||||
coreValueOptions.find((o) => o.id === activeModalChipId)?.label ?? "";
|
coreValueOptions.find((o) => o.id === activeModalChipId)?.label ?? "";
|
||||||
|
|
||||||
const modalFieldsLocked =
|
const modalUsesWizardFieldBlocksBody = Boolean(
|
||||||
!modalEditUnlocked &&
|
activeModalChipId &&
|
||||||
Boolean(
|
usesWizardFieldBlocksModalBody({
|
||||||
modalSession === "pending" ||
|
methodId: activeModalChipId,
|
||||||
modalSession === "customPending" ||
|
meta: {},
|
||||||
modalSession === "editing",
|
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||||
);
|
modalEditUnlocked: true,
|
||||||
|
draftFieldBlocks,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const showFooterPrimary =
|
const showFooterPrimary = Boolean(modalSession);
|
||||||
modalEditUnlocked ||
|
|
||||||
modalSession === "pending" ||
|
const handleCustomize = useCallback(() => {
|
||||||
modalSession === "customPending";
|
if (!activeModalChipId) return;
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
setWizardInitialValues(
|
||||||
|
buildMethodCardWizardInitialValues({
|
||||||
|
cardId: activeModalChipId,
|
||||||
|
fallbackTitle: modalChipLabel,
|
||||||
|
fallbackDescription:
|
||||||
|
draft.supportText?.trim() || cv.detailModal.subtitle,
|
||||||
|
meta: {},
|
||||||
|
persistedBlocks: state.customMethodCardFieldBlocksById,
|
||||||
|
draftFieldBlocks,
|
||||||
|
facetPrefill: {
|
||||||
|
group: "coreValues",
|
||||||
|
draft,
|
||||||
|
headings: {
|
||||||
|
meaning: cv.detailModal.meaningLabel,
|
||||||
|
signals: cv.detailModal.signalsLabel,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setWizardCustomizeChipId(activeModalChipId);
|
||||||
|
setAddCustomWizardOpen(true);
|
||||||
|
}, [
|
||||||
|
activeModalChipId,
|
||||||
|
cv.detailModal.meaningLabel,
|
||||||
|
cv.detailModal.signalsLabel,
|
||||||
|
cv.detailModal.subtitle,
|
||||||
|
draft,
|
||||||
|
draftFieldBlocks,
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
modalChipLabel,
|
||||||
|
state.customMethodCardFieldBlocksById,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleCloseAddWizard = useCallback(() => {
|
||||||
|
setAddCustomWizardOpen(false);
|
||||||
|
setWizardCustomizeChipId(null);
|
||||||
|
setWizardInitialValues(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFinalizeCustomCard = useCallback(
|
||||||
|
({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
fieldBlocks,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
fieldBlocks: CustomMethodCardFieldBlock[];
|
||||||
|
}) => {
|
||||||
|
const trimmedTitle = title.trim();
|
||||||
|
const trimmedDescription = description.trim();
|
||||||
|
if (!trimmedTitle) return;
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
pendingEphemeralCoreDuplicateRef.current = null;
|
||||||
|
|
||||||
|
const existingId = wizardCustomizeChipId;
|
||||||
|
if (!existingId) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const nextDetails = {
|
||||||
|
...coreValueDetailsFromWizardFieldBlocks(fieldBlocks, EMPTY_DETAIL),
|
||||||
|
...(trimmedDescription.length > 0
|
||||||
|
? { supportText: trimmedDescription }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
replaceState((prev) => {
|
||||||
|
const sel = prev.selectedCoreValueIds ?? [];
|
||||||
|
if (sel.length >= MAX_CORE_VALUES) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const snap = [...(prev.coreValuesChipsSnapshot ?? [])];
|
||||||
|
snap.push({
|
||||||
|
id,
|
||||||
|
label: trimmedTitle,
|
||||||
|
state: "selected",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
selectedCoreValueIds: moveFacetSelectionIdToFront(sel, id),
|
||||||
|
coreValuesChipsSnapshot: snap,
|
||||||
|
coreValueDetailsByChipId: {
|
||||||
|
...(prev.coreValueDetailsByChipId ?? {}),
|
||||||
|
[id]: nextDetails,
|
||||||
|
},
|
||||||
|
customMethodCardFieldBlocksById: {
|
||||||
|
...(prev.customMethodCardFieldBlocksById ?? {}),
|
||||||
|
[id]: structuredClone(fieldBlocks),
|
||||||
|
},
|
||||||
|
customMethodCardMetaById: methodCardMetaWithCustomizeHeader(
|
||||||
|
prev.customMethodCardMetaById,
|
||||||
|
id,
|
||||||
|
{ title: trimmedTitle, description: trimmedDescription },
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setAddCustomWizardOpen(false);
|
||||||
|
setWizardCustomizeChipId(null);
|
||||||
|
setWizardInitialValues(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDetails = {
|
||||||
|
...coreValueDetailsFromWizardFieldBlocks(fieldBlocks, draft),
|
||||||
|
...(trimmedDescription.length > 0
|
||||||
|
? { supportText: trimmedDescription }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
replaceState((prev) => {
|
||||||
|
const snap = [...(prev.coreValuesChipsSnapshot ?? [])];
|
||||||
|
const i = snap.findIndex((r) => r.id === existingId);
|
||||||
|
if (i >= 0) {
|
||||||
|
snap[i] = { ...snap[i], label: trimmedTitle };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
coreValuesChipsSnapshot: snap,
|
||||||
|
coreValueDetailsByChipId: {
|
||||||
|
...(prev.coreValueDetailsByChipId ?? {}),
|
||||||
|
[existingId]: nextDetails,
|
||||||
|
},
|
||||||
|
customMethodCardFieldBlocksById: {
|
||||||
|
...(prev.customMethodCardFieldBlocksById ?? {}),
|
||||||
|
[existingId]: structuredClone(fieldBlocks),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setDraft(nextDetails);
|
||||||
|
setDraftFieldBlocks(structuredClone(fieldBlocks));
|
||||||
|
setAddCustomWizardOpen(false);
|
||||||
|
setWizardCustomizeChipId(null);
|
||||||
|
setWizardInitialValues(null);
|
||||||
|
},
|
||||||
|
[draft, markCreateFlowInteraction, replaceState, wizardCustomizeChipId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedCount = useMemo(
|
||||||
|
() => coreValueOptions.filter((o) => o.state === "selected").length,
|
||||||
|
[coreValueOptions],
|
||||||
|
);
|
||||||
|
const atSelectionLimit = selectedCount >= MAX_CORE_VALUES;
|
||||||
|
const selectionCountText = cv.multiSelect.selectionCount
|
||||||
|
.replace("{count}", String(selectedCount))
|
||||||
|
.replace("{max}", String(MAX_CORE_VALUES));
|
||||||
|
|
||||||
const kebabMenuItems = useMemo(() => {
|
const kebabMenuItems = useMemo(() => {
|
||||||
if (!modalSession || !activeModalChipId) return [];
|
if (!modalSession || !activeModalChipId) return [];
|
||||||
const selectedCount = coreValueOptions.filter(
|
|
||||||
(o) => o.state === "selected",
|
|
||||||
).length;
|
|
||||||
return buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
return buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
||||||
showCustomize: !modalEditUnlocked,
|
showCustomize: true,
|
||||||
onCustomize: handleCustomize,
|
onCustomize: handleCustomize,
|
||||||
onDuplicate:
|
onDuplicate:
|
||||||
modalSession !== "editing" || selectedCount >= MAX_CORE_VALUES
|
(state.editingPublishedRuleId?.trim() ?? "") !== "" ||
|
||||||
|
atSelectionLimit
|
||||||
? undefined
|
? undefined
|
||||||
: handleDuplicateCoreChip,
|
: handleDuplicateCoreChip,
|
||||||
showRemove: true,
|
showRemove: modalSession === "editing",
|
||||||
onRemove: handleRemoveFromKebab,
|
onRemove: handleRemoveFromKebab,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
activeModalChipId,
|
activeModalChipId,
|
||||||
coreValueOptions,
|
atSelectionLimit,
|
||||||
handleCustomize,
|
handleCustomize,
|
||||||
handleDuplicateCoreChip,
|
handleDuplicateCoreChip,
|
||||||
handleRemoveFromKebab,
|
handleRemoveFromKebab,
|
||||||
modalEditUnlocked,
|
|
||||||
modalKebabMenu,
|
modalKebabMenu,
|
||||||
modalSession,
|
modalSession,
|
||||||
|
state.editingPublishedRuleId,
|
||||||
]);
|
]);
|
||||||
const handleChipClick = (chipId: string) => {
|
const handleChipClick = (chipId: string) => {
|
||||||
const target = coreValueOptions.find((o) => o.id === chipId);
|
const target = coreValueOptions.find((o) => o.id === chipId);
|
||||||
@@ -575,15 +645,14 @@ export function CoreValuesSelectScreen() {
|
|||||||
|
|
||||||
const addHandlers = {
|
const addHandlers = {
|
||||||
onAddClick: () => {
|
onAddClick: () => {
|
||||||
|
const selectedCount = coreValueOptions.filter(
|
||||||
|
(o) => o.state === "selected",
|
||||||
|
).length;
|
||||||
|
if (selectedCount >= MAX_CORE_VALUES) return;
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
setCoreValueOptions((prev) => {
|
setWizardCustomizeChipId(null);
|
||||||
const next: ChipOption[] = [
|
setWizardInitialValues(null);
|
||||||
...prev,
|
setAddCustomWizardOpen(true);
|
||||||
{ id: crypto.randomUUID(), label: "", state: "custom" },
|
|
||||||
];
|
|
||||||
queueMicrotask(() => syncCoreValuesToDraft(next));
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onCustomChipConfirm: (chipId: string, value: string) => {
|
onCustomChipConfirm: (chipId: string, value: string) => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
@@ -632,7 +701,8 @@ export function CoreValuesSelectScreen() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addHandlers.onAddClick}
|
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"
|
disabled={atSelectionLimit}
|
||||||
|
className="cursor-pointer font-normal leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] hover:opacity-90 disabled:cursor-not-allowed disabled:no-underline disabled:opacity-60 disabled:hover:opacity-60"
|
||||||
>
|
>
|
||||||
{cv.header.addLink}
|
{cv.header.addLink}
|
||||||
</button>
|
</button>
|
||||||
@@ -667,49 +737,37 @@ export function CoreValuesSelectScreen() {
|
|||||||
onCustomChipClose={addHandlers.onCustomChipClose}
|
onCustomChipClose={addHandlers.onCustomChipClose}
|
||||||
addButton
|
addButton
|
||||||
addButtonText={cv.multiSelect.addButtonText}
|
addButtonText={cv.multiSelect.addButtonText}
|
||||||
|
maxSelections={MAX_CORE_VALUES}
|
||||||
|
selectionCountText={selectionCountText}
|
||||||
|
limitReachedAnnouncement={cv.multiSelect.limitReached}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{detailModal && (
|
{detailModal && (
|
||||||
<Create
|
<Create
|
||||||
isOpen={activeModalChipId !== null}
|
isOpen={activeModalChipId !== null && !addCustomWizardOpen}
|
||||||
onClose={handleModalDismiss}
|
onClose={handleModalDismiss}
|
||||||
backdropVariant="blurredYellow"
|
backdropVariant="blurredYellow"
|
||||||
headerContent={
|
headerContent={
|
||||||
modalEditUnlocked && customizeHeaderDraft ? (
|
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
|
||||||
<MethodCardCustomizeModalHeader
|
<ContentLockup
|
||||||
titleLabel={detailModal.customizeValueNameLabel}
|
title={modalChipLabel}
|
||||||
descriptionLabel=""
|
description={
|
||||||
titleValue={customizeHeaderDraft.title}
|
draft.supportText?.trim() || detailModal.subtitle
|
||||||
descriptionValue=""
|
|
||||||
onTitleChange={(title) =>
|
|
||||||
setCustomizeHeaderDraft((prev) =>
|
|
||||||
prev ? { ...prev, title } : null,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
onDescriptionChange={() => {}}
|
variant="modal"
|
||||||
showDescription={false}
|
alignment="left"
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<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={modalEditUnlocked}
|
showBackButton={false}
|
||||||
onBack={handleCancelCustomize}
|
|
||||||
backButtonText={modalKebabMenu.cancelCustomize}
|
|
||||||
showNextButton={showFooterPrimary}
|
showNextButton={showFooterPrimary}
|
||||||
nextButtonDisabled={
|
showRemoveButton={modalSession === "editing"}
|
||||||
modalEditUnlocked && coreCustomizeSaveDisabled
|
onRemove={handleRemoveFromKebab}
|
||||||
}
|
|
||||||
onNext={handleModalConfirm}
|
onNext={handleModalConfirm}
|
||||||
nextButtonText={
|
nextButtonText={
|
||||||
modalEditUnlocked ? modalKebabMenu.saveEdits : detailModal.addValueButton
|
modalSession === "editing"
|
||||||
|
? modalKebabMenu.saveEdits
|
||||||
|
: detailModal.addValueButton
|
||||||
}
|
}
|
||||||
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
||||||
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
|
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
|
||||||
@@ -718,14 +776,43 @@ export function CoreValuesSelectScreen() {
|
|||||||
}
|
}
|
||||||
ariaLabel={modalChipLabel || "Core value details"}
|
ariaLabel={modalChipLabel || "Core value details"}
|
||||||
>
|
>
|
||||||
<CoreValueEditFields
|
{modalUsesWizardFieldBlocksBody && activeModalChipId ? (
|
||||||
readOnly={modalFieldsLocked}
|
<CustomMethodCardModalBody
|
||||||
value={draft}
|
cardId={activeModalChipId}
|
||||||
onChange={handleDraftChange}
|
blocksById={state.customMethodCardFieldBlocksById}
|
||||||
/>
|
blocksOverride={
|
||||||
|
draftFieldBlocks !== null ? draftFieldBlocks : undefined
|
||||||
|
}
|
||||||
|
showPolicyContentLockupWhenNoBlocks={false}
|
||||||
|
onFieldBlocksChange={
|
||||||
|
draftFieldBlocks === null
|
||||||
|
? undefined
|
||||||
|
: (next) => {
|
||||||
|
setDraftFieldBlocks(next);
|
||||||
|
setDraft(
|
||||||
|
coreValueDetailsFromWizardFieldBlocks(next, draft),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CoreValueEditFields
|
||||||
|
value={draft}
|
||||||
|
onChange={handleDraftChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Create>
|
</Create>
|
||||||
)}
|
)}
|
||||||
</CreateFlowTwoColumnSelectShell>
|
</CreateFlowTwoColumnSelectShell>
|
||||||
|
<CustomMethodCardWizard
|
||||||
|
isOpen={addCustomWizardOpen}
|
||||||
|
onClose={handleCloseAddWizard}
|
||||||
|
initialValues={wizardInitialValues}
|
||||||
|
onFinalize={handleFinalizeCustomCard}
|
||||||
|
onPersistCustomUploadFile={(file) =>
|
||||||
|
uploadCreateFlowFile(file, "customMethodAttachment")
|
||||||
|
}
|
||||||
|
/>
|
||||||
{confirmDialog}
|
{confirmDialog}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export function PublishedStakeholdersManagePanel({
|
|||||||
<section className="flex w-full flex-col gap-4 pt-1 pb-2">
|
<section className="flex w-full flex-col gap-4 pt-1 pb-2">
|
||||||
{bannerError ? (
|
{bannerError ? (
|
||||||
<p
|
<p
|
||||||
className="font-inter text-sm text-[var(--color-border-default-utility-negative)]"
|
className="text-small-paragraph text-[var(--color-border-default-utility-negative)]"
|
||||||
role="alert"
|
role="alert"
|
||||||
>
|
>
|
||||||
{bannerError}
|
{bannerError}
|
||||||
@@ -117,15 +117,15 @@ export function PublishedStakeholdersManagePanel({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{loadError ? (
|
{loadError ? (
|
||||||
<p className="font-inter text-sm text-[var(--color-border-default-utility-negative)]">
|
<p className="text-small-paragraph text-[var(--color-border-default-utility-negative)]">
|
||||||
{t("managePublished.loadFailed")}
|
{t("managePublished.loadFailed")}
|
||||||
</p>
|
</p>
|
||||||
) : items === null ? (
|
) : items === null ? (
|
||||||
<p className="font-inter text-sm text-[var(--color-content-default-secondary)]">
|
<p className="text-small-paragraph text-[var(--color-content-default-secondary)]">
|
||||||
{t("managePublished.loading")}
|
{t("managePublished.loading")}
|
||||||
</p>
|
</p>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<p className="font-inter text-sm text-[var(--color-content-default-tertiary)]">
|
<p className="text-small-paragraph text-[var(--color-content-default-tertiary)]">
|
||||||
{t("managePublished.empty")}
|
{t("managePublished.empty")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -136,10 +136,10 @@ export function PublishedStakeholdersManagePanel({
|
|||||||
className="flex flex-col gap-2 rounded-lg bg-black/5 px-3 py-3 md:flex-row md:items-center md:justify-between"
|
className="flex flex-col gap-2 rounded-lg bg-black/5 px-3 py-3 md:flex-row md:items-center md:justify-between"
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
<span className="truncate font-inter text-sm font-medium text-[var(--color-content-default-primary)] md:text-base">
|
<span className="truncate text-sm font-medium text-[var(--color-content-default-primary)] md:text-base">
|
||||||
{row.email}
|
{row.email}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-inter text-xs text-[var(--color-content-default-tertiary)] md:text-sm">
|
<span className="text-x-small-paragraph text-[var(--color-content-default-tertiary)] md:text-sm">
|
||||||
{row.status === "pending"
|
{row.status === "pending"
|
||||||
? t("managePublished.pending")
|
? t("managePublished.pending")
|
||||||
: t("managePublished.accepted")}
|
: t("managePublished.accepted")}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, type HTMLInputTypeAttribute } from "react";
|
import { useState, useEffect, type HTMLInputTypeAttribute } from "react";
|
||||||
import TextInput from "../../../../components/controls/TextInput";
|
import TextInput from "../../../../components/controls/TextInput";
|
||||||
|
import TextArea from "../../../../components/controls/TextArea";
|
||||||
import type { HeaderLockupJustificationValue } from "../../../../components/type/HeaderLockup/HeaderLockup.types";
|
import type { HeaderLockupJustificationValue } from "../../../../components/type/HeaderLockup/HeaderLockup.types";
|
||||||
import { useTranslation } from "../../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
type CreateFlowContentTopBelowMd,
|
type CreateFlowContentTopBelowMd,
|
||||||
} from "../../components/CreateFlowStepShell";
|
} from "../../components/CreateFlowStepShell";
|
||||||
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
||||||
|
import { CREATE_FLOW_COMMUNITY_SAVE_FORM_ID } from "../../utils/createFlowPaths";
|
||||||
import type { CreateFlowTextStateField } from "../../types";
|
import type { CreateFlowTextStateField } from "../../types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -21,6 +23,10 @@ type Props = {
|
|||||||
/** Figma Flow — Text (`20094:41243`): main column `items-center` + horizontal padding token. */
|
/** Figma Flow — Text (`20094:41243`): main column `items-center` + horizontal padding token. */
|
||||||
mainAlign?: "start" | "center";
|
mainAlign?: "start" | "center";
|
||||||
inputType?: HTMLInputTypeAttribute;
|
inputType?: HTMLInputTypeAttribute;
|
||||||
|
/** Multi-line control (community description). */
|
||||||
|
multiline?: boolean;
|
||||||
|
/** Native `required` (community name and save-progress email). */
|
||||||
|
required?: boolean;
|
||||||
showCharacterCount?: boolean;
|
showCharacterCount?: boolean;
|
||||||
headerJustification?: HeaderLockupJustificationValue;
|
headerJustification?: HeaderLockupJustificationValue;
|
||||||
/** Top spacing under top chrome (`CreateFlowStepShell` / `CreateFlowContentTopBelowMd`). */
|
/** Top spacing under top chrome (`CreateFlowStepShell` / `CreateFlowContentTopBelowMd`). */
|
||||||
@@ -28,7 +34,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared narrow-column + TextInput pattern for Create Community text frames.
|
* Shared narrow-column + labelled field pattern for Create Community text frames.
|
||||||
*/
|
*/
|
||||||
export function CreateFlowTextFieldScreen({
|
export function CreateFlowTextFieldScreen({
|
||||||
messageNamespace,
|
messageNamespace,
|
||||||
@@ -36,6 +42,8 @@ export function CreateFlowTextFieldScreen({
|
|||||||
maxLength,
|
maxLength,
|
||||||
mainAlign = "start",
|
mainAlign = "start",
|
||||||
inputType = "text",
|
inputType = "text",
|
||||||
|
multiline = false,
|
||||||
|
required = false,
|
||||||
showCharacterCount = true,
|
showCharacterCount = true,
|
||||||
headerJustification = "left",
|
headerJustification = "left",
|
||||||
contentTopBelowMd = "space-1400",
|
contentTopBelowMd = "space-1400",
|
||||||
@@ -69,6 +77,15 @@ export function CreateFlowTextFieldScreen({
|
|||||||
const mainItems =
|
const mainItems =
|
||||||
mainAlign === "center" ? "items-center" : "items-start";
|
mainAlign === "center" ? "items-center" : "items-start";
|
||||||
|
|
||||||
|
const isEmail = inputType === "email";
|
||||||
|
const persistValue = (next: string) => {
|
||||||
|
setValue(next);
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
updateState({ [stateField]: next } as Record<string, string>);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldLabel = t("inputLabel");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CreateFlowStepShell
|
<CreateFlowStepShell
|
||||||
variant="centeredNarrow"
|
variant="centeredNarrow"
|
||||||
@@ -85,22 +102,60 @@ export function CreateFlowTextFieldScreen({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<TextInput
|
{multiline ? (
|
||||||
className="!transition-none"
|
<TextArea
|
||||||
type={inputType}
|
className="!transition-none"
|
||||||
placeholder={t("placeholder")}
|
label={fieldLabel}
|
||||||
value={value}
|
placeholder={t("placeholder")}
|
||||||
onChange={(e) => {
|
value={value}
|
||||||
const v = e.target.value;
|
onChange={(e) => persistValue(e.target.value)}
|
||||||
setValue(v);
|
onBlur={() => {
|
||||||
markCreateFlowInteraction();
|
const trimmed = value.trim();
|
||||||
updateState({ [stateField]: v } as Record<string, string>);
|
if (trimmed !== value) persistValue(trimmed);
|
||||||
}}
|
}}
|
||||||
inputSize={mdUp ? "medium" : "small"}
|
size={mdUp ? "medium" : "small"}
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
textHint={hint}
|
showHelpIcon={false}
|
||||||
maxLength={maxLength}
|
textHint={hint}
|
||||||
/>
|
maxLength={maxLength}
|
||||||
|
required={required}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TextInput
|
||||||
|
className="!transition-none"
|
||||||
|
type={inputType}
|
||||||
|
name={isEmail ? "email" : undefined}
|
||||||
|
label={fieldLabel}
|
||||||
|
placeholder={t("placeholder")}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => persistValue(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
const trimmed = isEmail
|
||||||
|
? value.trim().toLowerCase()
|
||||||
|
: value.trim();
|
||||||
|
if (trimmed !== value) persistValue(trimmed);
|
||||||
|
}}
|
||||||
|
onKeyDown={
|
||||||
|
isEmail
|
||||||
|
? (e) => {
|
||||||
|
if (e.key !== "Enter") return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.form?.requestSubmit();
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
inputSize={mdUp ? "medium" : "small"}
|
||||||
|
formHeader={false}
|
||||||
|
showHelpIcon={false}
|
||||||
|
textHint={hint}
|
||||||
|
maxLength={maxLength}
|
||||||
|
required={required || isEmail}
|
||||||
|
autoComplete={isEmail ? "email" : undefined}
|
||||||
|
inputMode={isEmail ? "email" : undefined}
|
||||||
|
form={isEmail ? CREATE_FLOW_COMMUNITY_SAVE_FORM_ID : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CreateFlowStepShell>
|
</CreateFlowStepShell>
|
||||||
|
|||||||
@@ -16,14 +16,24 @@ import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowL
|
|||||||
import { fetchAuthSession } from "../../../../../lib/create/api";
|
import { fetchAuthSession } from "../../../../../lib/create/api";
|
||||||
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
|
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
|
||||||
import {
|
import {
|
||||||
UploadToServerError,
|
createFlowUploadFailureMessageKey,
|
||||||
uploadCreateFlowFile,
|
uploadCreateFlowFile,
|
||||||
} from "../../../../../lib/create/uploadToServer";
|
} from "../../../../../lib/create/uploadToServer";
|
||||||
|
import {
|
||||||
|
COMMUNITY_AVATAR_ACCEPT,
|
||||||
|
messageKeyForCreateFlowUploadReason,
|
||||||
|
validateCreateFlowUploadFile,
|
||||||
|
} from "../../../../../lib/create/createFlowUploadValidation";
|
||||||
import {
|
import {
|
||||||
clearPendingCommunityAvatarFile,
|
clearPendingCommunityAvatarFile,
|
||||||
|
readPendingCommunityAvatarFile,
|
||||||
storePendingCommunityAvatarFile,
|
storePendingCommunityAvatarFile,
|
||||||
} from "../../../../../lib/create/pendingCommunityAvatarUpload";
|
} from "../../../../../lib/create/pendingCommunityAvatarUpload";
|
||||||
|
|
||||||
|
function hasCommunityAvatarUrl(url: string | undefined): boolean {
|
||||||
|
return typeof url === "string" && url.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** Create Community — Figma Flow — Upload `20094:41524`. */
|
/** Create Community — Figma Flow — Upload `20094:41524`. */
|
||||||
export function CommunityUploadScreen() {
|
export function CommunityUploadScreen() {
|
||||||
const m = useMessages();
|
const m = useMessages();
|
||||||
@@ -54,17 +64,57 @@ export function CommunityUploadScreen() {
|
|||||||
[localPreviewUrl],
|
[localPreviewUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
const resolveUploadError = useCallback(
|
const serverAvatarUrl = hasCommunityAvatarUrl(state.communityAvatarUrl)
|
||||||
(err: unknown) => {
|
? state.communityAvatarUrl!.trim()
|
||||||
if (err instanceof UploadToServerError) {
|
: null;
|
||||||
if (err.status === 413) return tUpload("errors.tooLarge");
|
const serverAvatarUrlRef = useRef(serverAvatarUrl);
|
||||||
if (err.status === 401) return tUpload("errors.unauthorized");
|
serverAvatarUrlRef.current = serverAvatarUrl;
|
||||||
if (err.code === "server_misconfigured") {
|
|
||||||
return tUpload("errors.misconfigured");
|
useEffect(() => {
|
||||||
|
if (serverAvatarUrl) {
|
||||||
|
setLocalPreviewUrl((prev) => {
|
||||||
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [serverAvatarUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const file = await readPendingCommunityAvatarFile();
|
||||||
|
if (cancelled || !file) return;
|
||||||
|
const validated = await validateCreateFlowUploadFile(
|
||||||
|
file,
|
||||||
|
"communityAvatar",
|
||||||
|
);
|
||||||
|
if (validated.ok === false) {
|
||||||
|
await clearPendingCommunityAvatarFile();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
if (serverAvatarUrlRef.current) return;
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
if (cancelled) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLocalPreviewUrl((prev) => {
|
||||||
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
|
return objectUrl;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Missing IndexedDB / quota: leave the picker empty.
|
||||||
}
|
}
|
||||||
return tUpload("errors.generic");
|
})();
|
||||||
},
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resolveUploadError = useCallback(
|
||||||
|
(err: unknown) => tUpload(createFlowUploadFailureMessageKey(err)),
|
||||||
[tUpload],
|
[tUpload],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -94,6 +144,16 @@ export function CommunityUploadScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (signedIn === false) {
|
if (signedIn === false) {
|
||||||
|
const validated = await validateCreateFlowUploadFile(
|
||||||
|
file,
|
||||||
|
"communityAvatar",
|
||||||
|
);
|
||||||
|
if (validated.ok === false) {
|
||||||
|
setErrorMessage(
|
||||||
|
tUpload(messageKeyForCreateFlowUploadReason(validated.reason)),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await storePendingCommunityAvatarFile(file);
|
await storePendingCommunityAvatarFile(file);
|
||||||
setLocalPreviewUrl((prev) => {
|
setLocalPreviewUrl((prev) => {
|
||||||
@@ -121,24 +181,16 @@ export function CommunityUploadScreen() {
|
|||||||
if (prev) URL.revokeObjectURL(prev);
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
if (
|
if (hasCommunityAvatarUrl(state.communityAvatarUrl)) {
|
||||||
typeof state.communityAvatarUrl === "string" &&
|
|
||||||
state.communityAvatarUrl.trim().length > 0
|
|
||||||
) {
|
|
||||||
updateState({ communityAvatarUrl: undefined });
|
updateState({ communityAvatarUrl: undefined });
|
||||||
}
|
}
|
||||||
// Clear any anonymous staged blob so the post-sign-in flush won't resurrect it.
|
|
||||||
void clearPendingCommunityAvatarFile();
|
void clearPendingCommunityAvatarFile();
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = "";
|
fileInputRef.current.value = "";
|
||||||
}
|
}
|
||||||
}, [markCreateFlowInteraction, state.communityAvatarUrl, updateState]);
|
}, [markCreateFlowInteraction, state.communityAvatarUrl, updateState]);
|
||||||
|
|
||||||
const displaySrc =
|
const displaySrc = serverAvatarUrl ?? localPreviewUrl;
|
||||||
typeof state.communityAvatarUrl === "string" &&
|
|
||||||
state.communityAvatarUrl.trim().length > 0
|
|
||||||
? state.communityAvatarUrl.trim()
|
|
||||||
: localPreviewUrl;
|
|
||||||
const hasPreview = typeof displaySrc === "string" && displaySrc.length > 0;
|
const hasPreview = typeof displaySrc === "string" && displaySrc.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -161,7 +213,7 @@ export function CommunityUploadScreen() {
|
|||||||
type="file"
|
type="file"
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
accept={COMMUNITY_AVATAR_ACCEPT}
|
||||||
aria-label={u.hintText}
|
aria-label={u.hintText}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { GovernanceTemplateGrid } from "../../../components/sections/GovernanceTemplateGrid";
|
||||||
|
import type { TemplateGridCardEntry } from "../../../../lib/templates/templateGridPresentation";
|
||||||
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
|
import { useTemplatesFacetGridEntries } from "../../../(marketing)/templates/useTemplatesFacetGridEntries";
|
||||||
|
import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup";
|
||||||
|
import { CreateFlowStepShell } from "../components/CreateFlowStepShell";
|
||||||
|
import { CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS } from "../components/createFlowLayoutTokens";
|
||||||
|
import { buildTemplateReviewHref } from "../utils/flowSteps";
|
||||||
|
|
||||||
|
export function CreateFlowTemplatesPageClient({
|
||||||
|
initialGridEntries,
|
||||||
|
}: {
|
||||||
|
initialGridEntries: TemplateGridCardEntry[];
|
||||||
|
}) {
|
||||||
|
const t = useTranslation("pages.templates");
|
||||||
|
const entries = useTemplatesFacetGridEntries({
|
||||||
|
initialGridEntries,
|
||||||
|
enableFacetRecommendations: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreateFlowStepShell
|
||||||
|
variant="wideGridLoosePadding"
|
||||||
|
contentTopBelowMd="space-1400"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`mx-auto flex w-full min-w-0 flex-col gap-6 pb-8 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
|
||||||
|
>
|
||||||
|
<CreateFlowHeaderLockup
|
||||||
|
title={t("title")}
|
||||||
|
description={t("subtitle")}
|
||||||
|
justification="left"
|
||||||
|
/>
|
||||||
|
<GovernanceTemplateGrid
|
||||||
|
entries={entries}
|
||||||
|
hrefForTemplate={(slug) =>
|
||||||
|
buildTemplateReviewHref(slug, { fromCreateWizard: true })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CreateFlowStepShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import messages from "../../../../messages/en/index";
|
||||||
|
import { listRuleTemplatesFromDb } from "../../../../lib/server/ruleTemplates";
|
||||||
|
import { routeMetadata } from "../../../../lib/siteMetadata";
|
||||||
|
import { gridEntriesForFullCatalogWithFallback } from "../../../../lib/templates/templateGridPresentation";
|
||||||
|
import { CREATE_ROUTES } from "../utils/createFlowPaths";
|
||||||
|
import { CreateFlowTemplatesPageClient } from "./CreateFlowTemplatesPageClient";
|
||||||
|
|
||||||
|
export const metadata: Metadata = routeMetadata(CREATE_ROUTES.templatesPicker, {
|
||||||
|
title: messages.metadata.templates.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** In-flow template catalog after review “Create from template”. */
|
||||||
|
export default async function CreateFlowTemplatesPage() {
|
||||||
|
const rows = await listRuleTemplatesFromDb();
|
||||||
|
const initialGridEntries = gridEntriesForFullCatalogWithFallback(rows);
|
||||||
|
return <CreateFlowTemplatesPageClient initialGridEntries={initialGridEntries} />;
|
||||||
|
}
|
||||||
+26
-17
@@ -59,6 +59,7 @@ export type CommunityStructureChipSnapshotRow = {
|
|||||||
export type CoreValueDetailEntry = {
|
export type CoreValueDetailEntry = {
|
||||||
meaning: string;
|
meaning: string;
|
||||||
signals: string;
|
signals: string;
|
||||||
|
supportText?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,7 +85,11 @@ export type DecisionApproachDetailEntry = {
|
|||||||
applicableScope: string[];
|
applicableScope: string[];
|
||||||
selectedApplicableScope: string[];
|
selectedApplicableScope: string[];
|
||||||
stepByStepInstructions: string;
|
stepByStepInstructions: string;
|
||||||
consensusLevel: number;
|
/**
|
||||||
|
* Catalog presets always set this. User-authored custom cards omit it until
|
||||||
|
* the author adds a proportion field or edits consensus in the facet form.
|
||||||
|
*/
|
||||||
|
consensusLevel?: number;
|
||||||
objectionsDeadlocks: string;
|
objectionsDeadlocks: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -164,6 +169,12 @@ export interface CreateFlowState {
|
|||||||
>;
|
>;
|
||||||
membershipMethodDetailsById?: Record<string, MembershipMethodDetailEntry>;
|
membershipMethodDetailsById?: Record<string, MembershipMethodDetailEntry>;
|
||||||
decisionApproachDetailsById?: Record<string, DecisionApproachDetailEntry>;
|
decisionApproachDetailsById?: Record<string, DecisionApproachDetailEntry>;
|
||||||
|
/**
|
||||||
|
* Checked “key resource” ids from the decision-approaches InfoMessageBox
|
||||||
|
* (`amend`, `finances`, `project`, `discipline`). Selecting one also selects
|
||||||
|
* the matching Applicable Scope chip on every decision approach.
|
||||||
|
*/
|
||||||
|
selectedDecisionKeyResourceIds?: string[];
|
||||||
conflictManagementDetailsById?: Record<
|
conflictManagementDetailsById?: Record<
|
||||||
string,
|
string,
|
||||||
ConflictManagementDetailEntry
|
ConflictManagementDetailEntry
|
||||||
@@ -183,11 +194,13 @@ export interface CreateFlowState {
|
|||||||
customMethodCardFieldBlocksById?: Record<string, CustomMethodCardFieldBlock[]>;
|
customMethodCardFieldBlocksById?: Record<string, CustomMethodCardFieldBlock[]>;
|
||||||
/**
|
/**
|
||||||
* Set when a user picks a template (Customize or Use without changes) before
|
* Set when a user picks a template (Customize or Use without changes) before
|
||||||
* completing the community stage. The community-review screen consumes this
|
* completing the community stage. Customize: the community-review screen
|
||||||
* to `router.replace` past `/create/review` to the correct downstream step
|
* `router.replace`s past `/create/review` to `core-values`. Use without
|
||||||
* (`core-values` for customize; `confirm-stakeholders` for use-without-changes)
|
* changes: navigation skips to name → description → optional photo, then
|
||||||
* once community data is captured. Cleared the moment the redirect fires, so
|
* `confirm-stakeholders` (the pin keeps that skip list active). Set only for
|
||||||
* later visits to `/create/review` render normally.
|
* **direct** template entry, not when community identity was already collected
|
||||||
|
* in-flow. Cleared when community-review consumes a customize pin, or when
|
||||||
|
* the flow is reset.
|
||||||
*/
|
*/
|
||||||
pendingTemplateAction?: {
|
pendingTemplateAction?: {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -195,23 +208,20 @@ export interface CreateFlowState {
|
|||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Set when the user chooses **Use without changes** on a template-review
|
* Set when the user chooses **Use without changes** on a template-review
|
||||||
* page. The custom-rule segment (`core-values` … `conflict-management`) is
|
* page. Used so Back from `community-name` (identity-only path) returns to
|
||||||
* skipped, so linear `getPreviousStep("confirm-stakeholders")` would wrongly
|
* `/create/review-template/{slug}` instead of the intro. Cleared when the user
|
||||||
* point at `conflict-management`. Navigation uses this slug so Back from
|
* picks **Customize** from template review or when the flow state is cleared.
|
||||||
* `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;
|
templateReviewBackSlug?: string;
|
||||||
/**
|
/**
|
||||||
* True when the user opened `/create/review-template/{slug}` from the create
|
* True when the user opened `/create/review-template/{slug}` from the create
|
||||||
* wizard (`/templates?fromFlow=1` after `/create/review`). Persisted so Back
|
* wizard (`/create/templates` after `/create/review`). Persisted so Back
|
||||||
* from template review targets `/create/review` and so returning from
|
* from template review targets `/create/review` and so returning from
|
||||||
* `confirm-stakeholders` can re-apply `?fromFlow=1` on the template URL.
|
* `confirm-stakeholders` can re-apply `?fromFlow=1` on the template URL.
|
||||||
*/
|
*/
|
||||||
templateReviewEntryFromCreateFlow?: boolean;
|
templateReviewEntryFromCreateFlow?: boolean;
|
||||||
/**
|
/**
|
||||||
* When set, **Finalize** and signed-in **Save & Exit** update this published
|
* When set, **Publish** and signed-in **Save & Exit** update this published
|
||||||
* rule (PATCH) instead of POSTing a new rule or only saving a draft.
|
* rule (PATCH) instead of POSTing a new rule or only saving a draft.
|
||||||
*/
|
*/
|
||||||
editingPublishedRuleId?: string;
|
editingPublishedRuleId?: string;
|
||||||
@@ -268,9 +278,8 @@ export interface CreateFlowContextValue {
|
|||||||
*
|
*
|
||||||
* Current consumer: {@link SignedInDraftHydration} — when a signed-in user
|
* Current consumer: {@link SignedInDraftHydration} — when a signed-in user
|
||||||
* has already started editing, we skip replaying their server draft on top
|
* has already started editing, we skip replaying their server draft on top
|
||||||
* of in-progress local state. Save & Exit visibility is driven by step
|
* of in-progress local state. Save & Exit visibility is
|
||||||
* index (`SAVE_EXIT_FROM_STEP_INDEX` in `CreateFlowLayoutClient`), not this
|
* `shouldOfferCreateFlowSaveAndExit` in `utils/flowSteps.ts`, not this flag.
|
||||||
* flag.
|
|
||||||
*/
|
*/
|
||||||
interactionTouched: boolean;
|
interactionTouched: boolean;
|
||||||
markCreateFlowInteraction: () => void;
|
markCreateFlowInteraction: () => void;
|
||||||
|
|||||||
@@ -21,7 +21,13 @@ export function readCoreValueDetailsFromLocalStorage(): Record<
|
|||||||
if (typeof o.meaning !== "string" || typeof o.signals !== "string") {
|
if (typeof o.meaning !== "string" || typeof o.signals !== "string") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
out[k] = { meaning: o.meaning, signals: o.signals };
|
out[k] = {
|
||||||
|
meaning: o.meaning,
|
||||||
|
signals: o.signals,
|
||||||
|
...(typeof o.supportText === "string"
|
||||||
|
? { supportText: o.supportText }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* exposes a native size that matches, this module should collapse.
|
* exposes a native size that matches, this module should collapse.
|
||||||
*/
|
*/
|
||||||
export const CREATE_FLOW_FOOTER_BUTTON_CLASS =
|
export const CREATE_FLOW_FOOTER_BUTTON_CLASS =
|
||||||
"md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] " +
|
"!text-x-small-label md:!text-small-label " +
|
||||||
"!px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] " +
|
"!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)]";
|
"!py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]";
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ type FooterMessages = typeof footerMessages;
|
|||||||
* from this map fall back to `footer.next`.
|
* from this map fall back to `footer.next`.
|
||||||
*
|
*
|
||||||
* `final-review` is handled separately by the caller because its label
|
* `final-review` is handled separately by the caller because its label
|
||||||
* also depends on the in-flight publish flag (`finalizeButtonPublishing`
|
* also depends on the in-flight publish flag (`publishButtonPublishing`
|
||||||
* vs `finalizeCommunityRule`).
|
* vs `publishCommunityRule`).
|
||||||
*/
|
*/
|
||||||
const DEFAULT_FOOTER_LABEL_BY_STEP: ReadonlyMap<
|
const DEFAULT_FOOTER_LABEL_BY_STEP: ReadonlyMap<
|
||||||
CreateFlowStep,
|
CreateFlowStep,
|
||||||
|
|||||||
@@ -10,12 +10,18 @@ import {
|
|||||||
FIRST_STEP,
|
FIRST_STEP,
|
||||||
} from "./flowSteps";
|
} from "./flowSteps";
|
||||||
|
|
||||||
|
/** Associates the save-progress email field with the layout footer submit. */
|
||||||
|
export const CREATE_FLOW_COMMUNITY_SAVE_FORM_ID =
|
||||||
|
"create-flow-community-save";
|
||||||
|
|
||||||
export const CREATE_ROUTES = {
|
export const CREATE_ROUTES = {
|
||||||
root: "/",
|
root: "/",
|
||||||
createRoot: "/create",
|
createRoot: "/create",
|
||||||
/** Direct path to the first wizard step so client navigations skip the redirect hop. */
|
/** Direct path to the first wizard step so client navigations skip the redirect hop. */
|
||||||
createFirstStep: `/create/${FIRST_STEP}`,
|
createFirstStep: `/create/${FIRST_STEP}`,
|
||||||
review: "/create/review",
|
review: "/create/review",
|
||||||
|
/** In-flow template catalog (wizard chrome). Marketing catalog remains `/templates`. */
|
||||||
|
templatesPicker: "/create/templates",
|
||||||
finalReview: "/create/final-review",
|
finalReview: "/create/final-review",
|
||||||
completed: "/create/completed",
|
completed: "/create/completed",
|
||||||
editRule: "/create/edit-rule",
|
editRule: "/create/edit-rule",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const PROPORTION_BY_STEP_INDEX: readonly ProportionBarState[] = [
|
|||||||
"2-2", // membership-methods (Figma — Compact Card Stack `20858:13947`)
|
"2-2", // membership-methods (Figma — Compact Card Stack `20858:13947`)
|
||||||
"2-3", // decision-approaches (Figma Flow — Right Rail `20523:23509`)
|
"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-0", // conflict-management (Figma Flow — Compact Card Stack `20879:15979`; start of Review segment)
|
||||||
"3-1", // confirm-stakeholders
|
"3-2", // confirm-stakeholders (same fill as final-review)
|
||||||
"3-2", // final-review
|
"3-2", // final-review
|
||||||
"3-2", // completed
|
"3-2", // completed
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -47,8 +47,44 @@ 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). */
|
/** Options for navigation when the email / magic-link save step is not shown (signed-in users). */
|
||||||
export type CreateFlowNavigationOptions = {
|
export type CreateFlowNavigationOptions = {
|
||||||
skipCommunitySave?: boolean;
|
skipCommunitySave?: boolean;
|
||||||
|
/**
|
||||||
|
* Template **Use without changes**: collect community name, description, and
|
||||||
|
* optional photo only. Skips intro, structure, size, save, community review,
|
||||||
|
* and the custom-rule authoring segment.
|
||||||
|
*/
|
||||||
|
useWithoutChangesIdentityOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Community + custom-rule steps skipped when
|
||||||
|
* {@link CreateFlowNavigationOptions.useWithoutChangesIdentityOnly} is set.
|
||||||
|
* Remaining identity steps: `community-name` → `community-context` →
|
||||||
|
* `community-upload`, then `confirm-stakeholders`.
|
||||||
|
*/
|
||||||
|
const USE_WITHOUT_CHANGES_IDENTITY_SKIP = new Set<CreateFlowStep>([
|
||||||
|
"informational",
|
||||||
|
"community-structure",
|
||||||
|
"community-size",
|
||||||
|
"community-save",
|
||||||
|
"review",
|
||||||
|
"core-values",
|
||||||
|
"communication-methods",
|
||||||
|
"membership-methods",
|
||||||
|
"decision-approaches",
|
||||||
|
"conflict-management",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function shouldSkipStep(
|
||||||
|
step: CreateFlowStep,
|
||||||
|
options?: CreateFlowNavigationOptions,
|
||||||
|
): boolean {
|
||||||
|
if (options?.skipCommunitySave && step === "community-save") return true;
|
||||||
|
return Boolean(
|
||||||
|
options?.useWithoutChangesIdentityOnly &&
|
||||||
|
USE_WITHOUT_CHANGES_IDENTITY_SKIP.has(step),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the next step in the flow, or null if current is last/invalid
|
* Returns the next step in the flow, or null if current is last/invalid
|
||||||
*/
|
*/
|
||||||
@@ -57,13 +93,14 @@ export function getNextStep(
|
|||||||
options?: CreateFlowNavigationOptions,
|
options?: CreateFlowNavigationOptions,
|
||||||
): CreateFlowStep | null {
|
): CreateFlowStep | null {
|
||||||
if (!currentStep) return null;
|
if (!currentStep) return null;
|
||||||
const index = FLOW_STEP_ORDER.indexOf(currentStep);
|
let index = FLOW_STEP_ORDER.indexOf(currentStep);
|
||||||
if (index === -1 || index === FLOW_STEP_ORDER.length - 1) return null;
|
if (index === -1) return null;
|
||||||
const next = FLOW_STEP_ORDER[index + 1] as CreateFlowStep;
|
while (index < FLOW_STEP_ORDER.length - 1) {
|
||||||
if (options?.skipCommunitySave && next === "community-save") {
|
index += 1;
|
||||||
return getNextStep("community-save", options);
|
const next = FLOW_STEP_ORDER[index] as CreateFlowStep;
|
||||||
|
if (!shouldSkipStep(next, options)) return next;
|
||||||
}
|
}
|
||||||
return next;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,20 +111,27 @@ export function getPreviousStep(
|
|||||||
options?: CreateFlowNavigationOptions,
|
options?: CreateFlowNavigationOptions,
|
||||||
): CreateFlowStep | null {
|
): CreateFlowStep | null {
|
||||||
if (!currentStep) return null;
|
if (!currentStep) return null;
|
||||||
const index = FLOW_STEP_ORDER.indexOf(currentStep);
|
let index = FLOW_STEP_ORDER.indexOf(currentStep);
|
||||||
if (index <= 0) return null;
|
if (index <= 0) return null;
|
||||||
const prev = FLOW_STEP_ORDER[index - 1] as CreateFlowStep;
|
while (index > 0) {
|
||||||
if (options?.skipCommunitySave && prev === "community-save") {
|
index -= 1;
|
||||||
return getPreviousStep("community-save", options);
|
const prev = FLOW_STEP_ORDER[index] as CreateFlowStep;
|
||||||
|
if (!shouldSkipStep(prev, options)) return prev;
|
||||||
}
|
}
|
||||||
return prev;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the create-flow footer Back action should go. Usually the previous
|
* Where the create-flow footer Back action should go. Usually the previous
|
||||||
* step in {@link FLOW_STEP_ORDER}; when the user reached `confirm-stakeholders`
|
* step in {@link FLOW_STEP_ORDER}.
|
||||||
* via template **Use without changes**, Back returns to template review instead
|
*
|
||||||
* of `conflict-management` (that segment was skipped).
|
* Template **Use without changes** exceptions:
|
||||||
|
* - Direct from a template (`useWithoutChangesIdentityOnly`): Back from
|
||||||
|
* `community-name` returns to template review; Back from
|
||||||
|
* `confirm-stakeholders` is `community-upload`.
|
||||||
|
* - In-flow (`?fromFlow=1`, community identity already collected): Back
|
||||||
|
* from `confirm-stakeholders` returns to template review instead of
|
||||||
|
* `conflict-management`.
|
||||||
*/
|
*/
|
||||||
export type CreateFlowBackTarget =
|
export type CreateFlowBackTarget =
|
||||||
| { kind: "step"; step: CreateFlowStep }
|
| { kind: "step"; step: CreateFlowStep }
|
||||||
@@ -102,7 +146,18 @@ export function resolveCreateFlowBackTarget(
|
|||||||
typeof templateReviewBackSlug === "string"
|
typeof templateReviewBackSlug === "string"
|
||||||
? templateReviewBackSlug.trim()
|
? templateReviewBackSlug.trim()
|
||||||
: "";
|
: "";
|
||||||
if (currentStep === "confirm-stakeholders" && slug.length > 0) {
|
if (
|
||||||
|
currentStep === "community-name" &&
|
||||||
|
options?.useWithoutChangesIdentityOnly &&
|
||||||
|
slug.length > 0
|
||||||
|
) {
|
||||||
|
return { kind: "templateReview", slug };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
currentStep === "confirm-stakeholders" &&
|
||||||
|
slug.length > 0 &&
|
||||||
|
!options?.useWithoutChangesIdentityOnly
|
||||||
|
) {
|
||||||
return { kind: "templateReview", slug };
|
return { kind: "templateReview", slug };
|
||||||
}
|
}
|
||||||
const prev = getPreviousStep(currentStep, options);
|
const prev = getPreviousStep(currentStep, options);
|
||||||
@@ -117,15 +172,37 @@ export function getStepIndex(step: CreateFlowStep | null | undefined): number {
|
|||||||
return FLOW_STEP_ORDER.indexOf(step);
|
return FLOW_STEP_ORDER.indexOf(step);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** First wizard step that offers Save & Exit (first Create Community select). */
|
||||||
|
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-structure");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-nav Save & Exit (vs Exit). Guests and signed-in users share this from
|
||||||
|
* `community-structure` onward and on `edit-rule`. On completed, guests still
|
||||||
|
* get Save & Exit (claim/login); signed-in users get Exit (already owned).
|
||||||
|
*
|
||||||
|
* @param sessionUser `null` is a guest. `undefined` (session in flight) is
|
||||||
|
* not a guest — completed stays Exit until the session resolves.
|
||||||
|
*/
|
||||||
|
export function shouldOfferCreateFlowSaveAndExit(
|
||||||
|
currentStep: CreateFlowStep | null | undefined,
|
||||||
|
sessionUser?: { id: string } | null,
|
||||||
|
): boolean {
|
||||||
|
if (currentStep == null) return false;
|
||||||
|
if (currentStep === "completed") return sessionUser === null;
|
||||||
|
if (currentStep === "edit-rule") return true;
|
||||||
|
return getStepIndex(currentStep) >= SAVE_EXIT_FROM_STEP_INDEX;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Steps where below `lg` the main column scrolls with split layout
|
* Steps where below `lg` the main column scrolls with split layout
|
||||||
* (`CreateFlowLayoutClient` — Linear CR-92 §4).
|
* (`CreateFlowLayoutClient`).
|
||||||
*/
|
*/
|
||||||
export const CREATE_FLOW_SELECT_SPLIT_SCROLL_STEPS: readonly CreateFlowStep[] = [
|
export const CREATE_FLOW_SELECT_SPLIT_SCROLL_STEPS: readonly CreateFlowStep[] = [
|
||||||
"community-size",
|
"community-size",
|
||||||
"community-structure",
|
"community-structure",
|
||||||
"core-values",
|
"core-values",
|
||||||
"decision-approaches",
|
"decision-approaches",
|
||||||
|
"confirm-stakeholders",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function createFlowStepUsesSelectSplitScroll(
|
export function createFlowStepUsesSelectSplitScroll(
|
||||||
@@ -158,21 +235,53 @@ export function parseCreateFlowScreenFromPathname(
|
|||||||
): CreateFlowStep | null {
|
): CreateFlowStep | null {
|
||||||
if (!pathname || pathname.length === 0) return null;
|
if (!pathname || pathname.length === 0) return null;
|
||||||
if (pathname.includes("/create/review-template/")) return null;
|
if (pathname.includes("/create/review-template/")) return null;
|
||||||
|
if (isCreateFlowTemplatesPickerPath(pathname)) return null;
|
||||||
|
|
||||||
const parts = pathname.split("/").filter(Boolean);
|
const parts = pathname.split("/").filter(Boolean);
|
||||||
const createIdx = parts.indexOf("create");
|
const createIdx = parts.indexOf("create");
|
||||||
if (createIdx === -1 || createIdx >= parts.length - 1) return null;
|
if (createIdx === -1 || createIdx >= parts.length - 1) return null;
|
||||||
|
|
||||||
const segment = parts[createIdx + 1];
|
const segment = parts[createIdx + 1];
|
||||||
if (segment === "review-template") return null;
|
if (segment === "review-template" || segment === "templates") return null;
|
||||||
|
|
||||||
return isValidStep(segment) ? segment : null;
|
return isValidStep(segment) ? segment : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same query as `/templates?fromFlow=1` — template was picked after `/create/review`. */
|
/**
|
||||||
|
* `/create/templates` — in-flow catalog after review “Create from template”.
|
||||||
|
* Not a wizard step; layout keeps Back / Save & Exit and does not use
|
||||||
|
* marketing `/templates` chrome.
|
||||||
|
*/
|
||||||
|
export function isCreateFlowTemplatesPickerPath(
|
||||||
|
pathname: string | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (!pathname) return false;
|
||||||
|
const parts = pathname.split("/").filter(Boolean);
|
||||||
|
const createIdx = parts.indexOf("create");
|
||||||
|
if (createIdx === -1 || createIdx !== parts.length - 2) return false;
|
||||||
|
return parts[createIdx + 1] === "templates";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same query as `/create/review-template/…?fromFlow=1` — template was picked in-flow. */
|
||||||
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY = "fromFlow" as const;
|
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY = "fromFlow" as const;
|
||||||
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
|
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catalog / marketing template preview (`/create/review-template/[slug]`
|
||||||
|
* without `?fromFlow=1`). There is nothing to save yet; top-nav Exit should
|
||||||
|
* leave immediately. In-flow picks keep the usual save / leave-confirm path.
|
||||||
|
*/
|
||||||
|
export function isDirectTemplateReviewEntry(
|
||||||
|
pathname: string | null | undefined,
|
||||||
|
searchParams?: { get: (name: string) => string | null } | null,
|
||||||
|
): boolean {
|
||||||
|
if (!pathname?.includes("/create/review-template/")) return false;
|
||||||
|
return (
|
||||||
|
searchParams?.get(TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY) !==
|
||||||
|
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Only set from `/create/review` “Create from template” with `fromFlow=1`.
|
* Only set from `/create/review` “Create from template” with `fromFlow=1`.
|
||||||
* Enables facet-ranked `GET /api/templates` + “RECOMMENDED” on the grid; omit
|
* Enables facet-ranked `GET /api/templates` + “RECOMMENDED” on the grid; omit
|
||||||
@@ -181,7 +290,7 @@ export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
|
|||||||
export const TEMPLATES_FACET_RECOMMEND_QUERY = "recommendTemplates" as const;
|
export const TEMPLATES_FACET_RECOMMEND_QUERY = "recommendTemplates" as const;
|
||||||
export const TEMPLATES_FACET_RECOMMEND_VALUE = "1" as const;
|
export const TEMPLATES_FACET_RECOMMEND_VALUE = "1" as const;
|
||||||
|
|
||||||
/** `/create/completed?celebrate=1` — post-finalize toast; set only after **initial** POST publish, not PATCH updates. */
|
/** `/create/completed?celebrate=1` — post-publish toast; set only after **initial** POST publish, not PATCH updates. */
|
||||||
export const CREATE_FLOW_COMPLETED_CELEBRATE_QUERY = "celebrate" as const;
|
export const CREATE_FLOW_COMPLETED_CELEBRATE_QUERY = "celebrate" as const;
|
||||||
export const CREATE_FLOW_COMPLETED_CELEBRATE_VALUE = "1" as const;
|
export const CREATE_FLOW_COMPLETED_CELEBRATE_VALUE = "1" as const;
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -1,8 +1,16 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import { Suspense, type ReactNode } from "react";
|
import { Suspense, type ReactNode } from "react";
|
||||||
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
|
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
|
||||||
|
import SkipToContent from "../components/navigation/SkipToContent";
|
||||||
import { MessagesProvider } from "../contexts/MessagesContext";
|
import { MessagesProvider } from "../contexts/MessagesContext";
|
||||||
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
||||||
import messages from "../../messages/en/index";
|
import messages from "../../messages/en/index";
|
||||||
|
import { MAIN_CONTENT_ID } from "../../lib/mainContent";
|
||||||
|
import { NO_INDEX_ROBOTS } from "../../lib/siteMetadata";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
robots: NO_INDEX_ROBOTS,
|
||||||
|
};
|
||||||
|
|
||||||
// `force-dynamic` removed in favor of `experimental.cacheComponents` (Next 16).
|
// `force-dynamic` removed in favor of `experimental.cacheComponents` (Next 16).
|
||||||
// `ConditionalNavigation` reads `cr_session` server-side (and `usePathname()`
|
// `ConditionalNavigation` reads `cr_session` server-side (and `usePathname()`
|
||||||
@@ -21,10 +29,17 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<MessagesProvider messages={messages}>
|
<MessagesProvider messages={messages}>
|
||||||
<AuthModalProvider>
|
<AuthModalProvider>
|
||||||
|
<SkipToContent />
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<ConditionalNavigation />
|
<ConditionalNavigation />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<main className="flex-1">{children}</main>
|
<main
|
||||||
|
id={MAIN_CONTENT_ID}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="flex-1 outline-none"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
</AuthModalProvider>
|
</AuthModalProvider>
|
||||||
</MessagesProvider>
|
</MessagesProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import messages from "../../../messages/en/index";
|
||||||
|
import { NO_INDEX_ROBOTS, routeMetadata } from "../../../lib/siteMetadata";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = routeMetadata("/login", {
|
||||||
title: "Log in · CommunityRule",
|
title: messages.metadata.login.title,
|
||||||
robots: { index: false, follow: false },
|
robots: NO_INDEX_ROBOTS,
|
||||||
};
|
});
|
||||||
|
|
||||||
export default function LoginLayout({
|
export default function LoginLayout({
|
||||||
children,
|
children,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function LoginLoadingFallback() {
|
|||||||
const t = useTranslation("pages.login");
|
const t = useTranslation("pages.login");
|
||||||
return (
|
return (
|
||||||
<div className={`${loginPageBgClass} flex items-center justify-center`}>
|
<div className={`${loginPageBgClass} flex items-center justify-center`}>
|
||||||
<p className="font-inter text-[14px] text-[var(--color-content-default-primary)]">
|
<p className="text-[14px] text-[var(--color-content-default-primary)]">
|
||||||
{t("loadingFallback")}
|
{t("loadingFallback")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,7 +42,7 @@ function LoginWithSearchParams() {
|
|||||||
belowCard={
|
belowCard={
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
|
className="text-small-paragraph text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
|
||||||
>
|
>
|
||||||
{t("backToHome")}
|
{t("backToHome")}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ export default function ProfilePageClient() {
|
|||||||
if (!sessionLoaded) {
|
if (!sessionLoaded) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl px-4 py-16">
|
<div className="mx-auto max-w-3xl px-4 py-16">
|
||||||
<p className="font-inter text-sm text-[var(--color-content-default-secondary)]">
|
<p className="text-small-paragraph text-[var(--color-content-default-secondary)]">
|
||||||
{t("loading")}
|
{t("loading")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export type ProfilePageViewProps = {
|
|||||||
* mobile: smaller Bricolage.
|
* mobile: smaller Bricolage.
|
||||||
*/
|
*/
|
||||||
const profileSectionHeadingClass =
|
const profileSectionHeadingClass =
|
||||||
"font-bricolage text-base font-bold leading-[22px] text-[var(--color-content-default-primary)] md:font-inter md:text-xl md:font-bold md:leading-7 xl:font-bricolage-grotesque xl:font-bold xl:text-[28px] xl:leading-9";
|
"font-bricolage text-xx-small-heading text-[var(--color-content-default-primary)] md:font-sans md:text-x-small-heading xl:font-bricolage-grotesque xl:text-medium-heading";
|
||||||
|
|
||||||
export type ProfilePageSignedOutViewProps = {
|
export type ProfilePageSignedOutViewProps = {
|
||||||
onSignIn: () => void;
|
onSignIn: () => void;
|
||||||
@@ -122,11 +122,11 @@ export function ProfilePageSignedOutView({
|
|||||||
<>
|
<>
|
||||||
<h1
|
<h1
|
||||||
id={titleId}
|
id={titleId}
|
||||||
className="font-inter text-xl font-bold leading-7 text-[var(--color-content-default-primary)] md:font-bricolage-grotesque md:text-[28px] md:font-bold md:leading-[36px]"
|
className="text-x-small-heading text-[var(--color-content-default-primary)] md:font-bricolage-grotesque md:text-medium-heading"
|
||||||
>
|
>
|
||||||
{t("pageTitle")}
|
{t("pageTitle")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-[640px] font-inter text-sm font-normal leading-5 text-[var(--color-content-default-tertiary)] md:text-base md:leading-6">
|
<p className="max-w-[640px] text-small-paragraph text-[var(--color-content-default-tertiary)] md:text-medium-paragraph">
|
||||||
{t("signInPrompt")}
|
{t("signInPrompt")}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
@@ -275,11 +275,11 @@ export function ProfilePageView({
|
|||||||
<>
|
<>
|
||||||
<h1
|
<h1
|
||||||
id={titleId}
|
id={titleId}
|
||||||
className="font-inter text-xl font-bold leading-7 text-[var(--color-content-default-primary)] md:font-bricolage-grotesque md:text-[28px] md:font-bold md:leading-[36px]"
|
className="text-x-small-heading text-[var(--color-content-default-primary)] md:font-bricolage-grotesque md:text-medium-heading"
|
||||||
>
|
>
|
||||||
{welcomeTitle}
|
{welcomeTitle}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-[640px] font-inter text-sm font-normal leading-5 text-[var(--color-content-default-tertiary)] md:text-base md:leading-6">
|
<p className="max-w-[640px] text-small-paragraph text-[var(--color-content-default-tertiary)] md:text-medium-paragraph">
|
||||||
{welcomeBody}
|
{welcomeBody}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
@@ -381,7 +381,7 @@ export function ProfilePageView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{rules.length === 0 && !rulesError && !showDraftCard ? (
|
{rules.length === 0 && !rulesError && !showDraftCard ? (
|
||||||
<p className="font-inter text-sm text-[var(--color-content-default-tertiary)]">
|
<p className="text-small-paragraph text-[var(--color-content-default-tertiary)]">
|
||||||
{t("yourRulesEmpty")}
|
{t("yourRulesEmpty")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import messages from "../../../messages/en/index";
|
||||||
|
import { NO_INDEX_ROBOTS, routeMetadata } from "../../../lib/siteMetadata";
|
||||||
import ProfilePageClient from "./ProfilePageClient";
|
import ProfilePageClient from "./ProfilePageClient";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = routeMetadata("/profile", {
|
||||||
title: "Profile · CommunityRule",
|
title: messages.metadata.profile.title,
|
||||||
robots: { index: false, follow: false },
|
robots: NO_INDEX_ROBOTS,
|
||||||
};
|
});
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
return <ProfilePageClient />;
|
return <ProfilePageClient />;
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default function ComponentsPreview() {
|
|||||||
<h1 className="font-bricolage-grotesque text-[48px] font-bold leading-[56px] text-[var(--color-content-default-primary)]">
|
<h1 className="font-bricolage-grotesque text-[48px] font-bold leading-[56px] text-[var(--color-content-default-primary)]">
|
||||||
Component Preview
|
Component Preview
|
||||||
</h1>
|
</h1>
|
||||||
<p className="font-inter text-[18px] leading-[24px] text-[var(--color-content-default-secondary)]">
|
<p className="text-[18px] leading-[24px] text-[var(--color-content-default-secondary)]">
|
||||||
Navigation Link (Figma 21861:21428) and List (21863:45631 / 45493 /
|
Navigation Link (Figma 21861:21428) and List (21863:45631 / 45493 /
|
||||||
4405) with ListEntry (21844:4118). Use Tab and hover to review
|
4405) with ListEntry (21844:4118). Use Tab and hover to review
|
||||||
states.
|
states.
|
||||||
@@ -53,12 +53,12 @@ export default function ComponentsPreview() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="space-y-[var(--spacing-scale-016)]">
|
<section className="space-y-[var(--spacing-scale-016)]">
|
||||||
<h2 className="font-bricolage-grotesque text-[32px] font-bold leading-[40px] text-[var(--color-content-default-primary)]">
|
<h2 className="font-bricolage-grotesque text-large-heading text-[var(--color-content-default-primary)]">
|
||||||
Link
|
Link
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex flex-col gap-8 rounded-[var(--radius-300,12px)] bg-[var(--color-surface-default-secondary)] p-[var(--spacing-scale-032)]">
|
<div className="flex flex-col gap-8 rounded-[var(--radius-300,12px)] bg-[var(--color-surface-default-secondary)] p-[var(--spacing-scale-032)]">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-4 font-inter text-sm text-[var(--color-content-default-tertiary)]">
|
<p className="mb-4 text-small-paragraph text-[var(--color-content-default-tertiary)]">
|
||||||
Light surface
|
Light surface
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col gap-4 rounded-lg bg-white p-6">
|
<div className="flex flex-col gap-4 rounded-lg bg-white p-6">
|
||||||
@@ -105,7 +105,7 @@ export default function ComponentsPreview() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-4 font-inter text-sm text-[var(--color-content-default-tertiary)]">
|
<p className="mb-4 text-small-paragraph text-[var(--color-content-default-tertiary)]">
|
||||||
Dark surface
|
Dark surface
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col gap-4 rounded-lg bg-[var(--color-gray-800)] p-6">
|
<div className="flex flex-col gap-4 rounded-lg bg-[var(--color-gray-800)] p-6">
|
||||||
@@ -154,22 +154,22 @@ export default function ComponentsPreview() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-[var(--spacing-scale-016)]">
|
<section className="space-y-[var(--spacing-scale-016)]">
|
||||||
<h2 className="font-bricolage-grotesque text-[32px] font-bold leading-[40px] text-[var(--color-content-default-primary)]">
|
<h2 className="font-bricolage-grotesque text-large-heading text-[var(--color-content-default-primary)]">
|
||||||
Divider
|
Divider
|
||||||
</h2>
|
</h2>
|
||||||
<p className="font-inter text-sm text-[var(--color-content-default-tertiary)]">
|
<p className="text-small-paragraph text-[var(--color-content-default-tertiary)]">
|
||||||
Utility / Divider (450:1941). List rows use the Content line; Menu is
|
Utility / Divider (450:1941). List rows use the Content line; Menu is
|
||||||
slightly higher-contrast.
|
slightly higher-contrast.
|
||||||
</p>
|
</p>
|
||||||
<div className="max-w-md space-y-4">
|
<div className="max-w-md space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 font-inter text-xs text-[var(--color-content-default-tertiary)]">
|
<p className="mb-1 text-x-small-paragraph text-[var(--color-content-default-tertiary)]">
|
||||||
Content
|
Content
|
||||||
</p>
|
</p>
|
||||||
<Divider type="content" orientation="horizontal" />
|
<Divider type="content" orientation="horizontal" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 font-inter text-xs text-[var(--color-content-default-tertiary)]">
|
<p className="mb-1 text-x-small-paragraph text-[var(--color-content-default-tertiary)]">
|
||||||
Menu
|
Menu
|
||||||
</p>
|
</p>
|
||||||
<Divider type="menu" orientation="horizontal" />
|
<Divider type="menu" orientation="horizontal" />
|
||||||
@@ -178,16 +178,16 @@ export default function ComponentsPreview() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-[var(--spacing-scale-024)]">
|
<section className="space-y-[var(--spacing-scale-024)]">
|
||||||
<h2 className="font-bricolage-grotesque text-[32px] font-bold leading-[40px] text-[var(--color-content-default-primary)]">
|
<h2 className="font-bricolage-grotesque text-large-heading text-[var(--color-content-default-primary)]">
|
||||||
List
|
List
|
||||||
</h2>
|
</h2>
|
||||||
<p className="font-inter text-sm text-[var(--color-content-default-tertiary)]">
|
<p className="text-small-paragraph text-[var(--color-content-default-tertiary)]">
|
||||||
List frame: S (21863:45631), M (21863:45493), L (21844:4405). Row:
|
List frame: S (21863:45631), M (21863:45493), L (21844:4405). Row:
|
||||||
ListEntry (21844:4118).
|
ListEntry (21844:4118).
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-[var(--spacing-scale-024)]">
|
<div className="space-y-[var(--spacing-scale-024)]">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-2 font-inter text-sm font-medium text-[var(--color-content-default-secondary)]">
|
<h3 className="mb-2 text-sm font-medium text-[var(--color-content-default-secondary)]">
|
||||||
Small
|
Small
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mx-auto max-w-[1044px]">
|
<div className="mx-auto max-w-[1044px]">
|
||||||
@@ -195,7 +195,7 @@ export default function ComponentsPreview() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-2 font-inter text-sm font-medium text-[var(--color-content-default-secondary)]">
|
<h3 className="mb-2 text-sm font-medium text-[var(--color-content-default-secondary)]">
|
||||||
Medium
|
Medium
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mx-auto max-w-[1044px]">
|
<div className="mx-auto max-w-[1044px]">
|
||||||
@@ -203,7 +203,7 @@ export default function ComponentsPreview() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-2 font-inter text-sm font-medium text-[var(--color-content-default-secondary)]">
|
<h3 className="mb-2 text-sm font-medium text-[var(--color-content-default-secondary)]">
|
||||||
Large
|
Large
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mx-auto max-w-[1590px]">
|
<div className="mx-auto max-w-[1590px]">
|
||||||
|
|||||||
+14
-1
@@ -1,8 +1,15 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { MessagesProvider } from "../contexts/MessagesContext";
|
import { MessagesProvider } from "../contexts/MessagesContext";
|
||||||
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
||||||
import messages from "../../messages/en/index";
|
import messages from "../../messages/en/index";
|
||||||
|
import { MAIN_CONTENT_ID } from "../../lib/mainContent";
|
||||||
|
import { NO_INDEX_ROBOTS } from "../../lib/siteMetadata";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
robots: NO_INDEX_ROBOTS,
|
||||||
|
};
|
||||||
|
|
||||||
// Development-only previews (e.g. `/components-preview`) — no public chrome.
|
// Development-only previews (e.g. `/components-preview`) — no public chrome.
|
||||||
export default function DevLayout({ children }: { children: ReactNode }) {
|
export default function DevLayout({ children }: { children: ReactNode }) {
|
||||||
@@ -12,7 +19,13 @@ export default function DevLayout({ children }: { children: ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<MessagesProvider messages={messages}>
|
<MessagesProvider messages={messages}>
|
||||||
<AuthModalProvider>
|
<AuthModalProvider>
|
||||||
<main className="flex-1">{children}</main>
|
<main
|
||||||
|
id={MAIN_CONTENT_ID}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="flex-1 outline-none"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
</AuthModalProvider>
|
</AuthModalProvider>
|
||||||
</MessagesProvider>
|
</MessagesProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Figma: Content page Template body ornaments (19003:23305).
|
||||||
|
* Hidden below md (640). From md, shapes sit in the side gutters and the
|
||||||
|
* page frame clips anything that bleeds past the viewport.
|
||||||
|
*
|
||||||
|
* - 19003:23575 / 19000:23008 — right
|
||||||
|
* - 19003:23576 / 19000:23009 — left
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
getAssetPath,
|
||||||
|
howItWorksOrnamentLeftPath,
|
||||||
|
howItWorksOrnamentRightPath,
|
||||||
|
resolveArticleOrnamentPath,
|
||||||
|
} from "../../../lib/assetUtils";
|
||||||
|
|
||||||
|
type ContentTemplateDecorativeShapesVariant = "guide" | "article";
|
||||||
|
|
||||||
|
type ContentTemplateDecorativeShapesProps = {
|
||||||
|
variant: ContentTemplateDecorativeShapesVariant;
|
||||||
|
slug?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RIGHT_GUIDE_CLASS =
|
||||||
|
"pointer-events-none absolute z-0 hidden aspect-[155/250] md:block left-[87.19%] right-0 top-[266px] lg:left-[84.86%] lg:top-[255px]";
|
||||||
|
const LEFT_GUIDE_CLASS =
|
||||||
|
"pointer-events-none absolute z-0 hidden aspect-[119/136] md:block left-0 right-[88.38%] top-[811px]";
|
||||||
|
const RIGHT_ARTICLE_CLASS =
|
||||||
|
"pointer-events-none absolute z-0 hidden aspect-square md:block left-[87.19%] right-[-8.13%] top-[266px] lg:left-[84.86%] lg:right-[-9.28%] lg:top-[255px]";
|
||||||
|
const LEFT_ARTICLE_CLASS =
|
||||||
|
"pointer-events-none absolute z-0 hidden aspect-square md:block left-[-1.66%] right-[88.38%] top-[811px]";
|
||||||
|
|
||||||
|
export default function ContentTemplateDecorativeShapes({
|
||||||
|
variant,
|
||||||
|
slug,
|
||||||
|
}: ContentTemplateDecorativeShapesProps) {
|
||||||
|
const isArticle = variant === "article";
|
||||||
|
const rightSrc = isArticle
|
||||||
|
? resolveArticleOrnamentPath(slug, "right")
|
||||||
|
: getAssetPath(howItWorksOrnamentRightPath());
|
||||||
|
const leftSrc = isArticle
|
||||||
|
? resolveArticleOrnamentPath(slug, "left")
|
||||||
|
: getAssetPath(howItWorksOrnamentLeftPath());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className={isArticle ? RIGHT_ARTICLE_CLASS : RIGHT_GUIDE_CLASS}
|
||||||
|
data-node-id="19003:23575"
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={rightSrc}
|
||||||
|
alt=""
|
||||||
|
className="pointer-events-none size-full max-w-none object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className={isArticle ? LEFT_ARTICLE_CLASS : LEFT_GUIDE_CLASS}
|
||||||
|
data-node-id="19003:23576"
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={leftSrc}
|
||||||
|
alt=""
|
||||||
|
className="pointer-events-none size-full max-w-none object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
buildLegalSyntheticPost,
|
||||||
|
type LegalDocumentMessages,
|
||||||
|
type LegalDocumentSlug,
|
||||||
|
} from "../../../lib/legalSyntheticPost";
|
||||||
|
import ContentBanner from "../../components/sections/ContentBanner";
|
||||||
|
|
||||||
|
type LegalDocumentPageProps = {
|
||||||
|
slug: LegalDocumentSlug;
|
||||||
|
path: string;
|
||||||
|
page: LegalDocumentMessages;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INLINE_LINK = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||||
|
|
||||||
|
const ARTICLE_COLUMN_CLASS =
|
||||||
|
"mx-auto flex w-full flex-col gap-[var(--space-800,32px)] text-[var(--color-content-default-primary)] text-medium-paragraph sm:max-w-[390px] sm:text-large-paragraph md:max-w-[472px] lg:max-w-[700px] lg:text-x-large-paragraph xl:max-w-none xl:text-xx-large-paragraph";
|
||||||
|
|
||||||
|
const LINK_CLASS = "underline decoration-solid underline-offset-2";
|
||||||
|
|
||||||
|
function isSafeHref(href: string): boolean {
|
||||||
|
return (
|
||||||
|
(href.startsWith("/") && !href.startsWith("//")) ||
|
||||||
|
href.startsWith("https://") ||
|
||||||
|
href.startsWith("http://") ||
|
||||||
|
href.startsWith("mailto:")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegalInlineText({ text }: { text: string }) {
|
||||||
|
const nodes: ReactNode[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let key = 0;
|
||||||
|
|
||||||
|
for (const match of text.matchAll(INLINE_LINK)) {
|
||||||
|
const matchIndex = match.index ?? 0;
|
||||||
|
if (matchIndex > lastIndex) {
|
||||||
|
nodes.push(text.slice(lastIndex, matchIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = match[1];
|
||||||
|
const href = match[2];
|
||||||
|
if (isSafeHref(href)) {
|
||||||
|
const isInternal = href.startsWith("/") && !href.startsWith("//");
|
||||||
|
nodes.push(
|
||||||
|
isInternal ? (
|
||||||
|
<Link key={key} href={href} className={LINK_CLASS}>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<a key={key} href={href} className={LINK_CLASS}>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
key += 1;
|
||||||
|
} else {
|
||||||
|
nodes.push(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = matchIndex + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
nodes.push(text.slice(lastIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function legalPageMetadata(
|
||||||
|
meta: { title: string; description: string; keywords: string[] },
|
||||||
|
page: { banner: { title: string; description: string } },
|
||||||
|
): Metadata {
|
||||||
|
return {
|
||||||
|
title: meta.title,
|
||||||
|
description: meta.description,
|
||||||
|
keywords: meta.keywords,
|
||||||
|
openGraph: {
|
||||||
|
title: page.banner.title,
|
||||||
|
description: page.banner.description,
|
||||||
|
type: "website",
|
||||||
|
siteName: "CommunityRule",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public legal documents (`/privacy`, `/terms`, `/cookies`).
|
||||||
|
*
|
||||||
|
* Figma: "Content page Template" (19003:23305) — ContentBanner guide
|
||||||
|
* (22078:806964) + article column (19003:23574). Body stacks follow
|
||||||
|
* Type / Text Block (22001:29793): heading + paragraphs. Omits Related
|
||||||
|
* articles, Ask Organizer, and decorative shapes.
|
||||||
|
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=19003-23305
|
||||||
|
*/
|
||||||
|
export default function LegalDocumentPage({
|
||||||
|
slug,
|
||||||
|
path,
|
||||||
|
page,
|
||||||
|
}: LegalDocumentPageProps) {
|
||||||
|
const post = buildLegalSyntheticPost(slug, page);
|
||||||
|
|
||||||
|
const structuredData = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebPage",
|
||||||
|
name: page.banner.title,
|
||||||
|
description: page.banner.description,
|
||||||
|
url: `https://communityrule.com${path}`,
|
||||||
|
dateModified: page.banner.date,
|
||||||
|
publisher: {
|
||||||
|
"@type": "Organization",
|
||||||
|
name: "CommunityRule",
|
||||||
|
url: "https://communityrule.com",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadcrumbData = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
itemListElement: [
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: 1,
|
||||||
|
name: "Home",
|
||||||
|
item: "https://communityrule.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: 2,
|
||||||
|
name: page.banner.title,
|
||||||
|
item: `https://communityrule.com${path}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: JSON.stringify(structuredData),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: JSON.stringify(breadcrumbData),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative min-h-screen overflow-x-hidden bg-transparent">
|
||||||
|
<ContentBanner
|
||||||
|
post={post}
|
||||||
|
variant="guide"
|
||||||
|
updatedLabel={page.updated}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<article
|
||||||
|
className="relative z-10 flex w-full justify-center p-[var(--spacing-scale-024)] sm:px-0 sm:py-[var(--spacing-scale-032)] xl:px-[calc(268/1440*100%)]"
|
||||||
|
data-node-id="19003:23574"
|
||||||
|
>
|
||||||
|
<div className={ARTICLE_COLUMN_CLASS}>
|
||||||
|
{page.intro.length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-[1em]">
|
||||||
|
{page.intro.map((paragraph) => (
|
||||||
|
<p key={paragraph}>
|
||||||
|
<LegalInlineText text={paragraph} />
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{page.sections.map((section) => (
|
||||||
|
<section
|
||||||
|
key={section.heading}
|
||||||
|
className="flex w-full flex-col gap-[var(--space-200,8px)]"
|
||||||
|
data-name="TextBlock"
|
||||||
|
>
|
||||||
|
<h2 className="w-full font-bold">{section.heading}</h2>
|
||||||
|
<div className="flex flex-col gap-[1em]">
|
||||||
|
{section.paragraphs.map((paragraph) => (
|
||||||
|
<p key={paragraph}>
|
||||||
|
<LegalInlineText text={paragraph} />
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import messages from "../../../messages/en/index";
|
import messages from "../../../messages/en/index";
|
||||||
import { getAssetPath, governanceBookletPath } from "../../../lib/assetUtils";
|
import {
|
||||||
|
ASSETS,
|
||||||
|
getAssetPath,
|
||||||
|
governanceBookletPath,
|
||||||
|
structureBeforeCrisisPath,
|
||||||
|
} from "../../../lib/assetUtils";
|
||||||
import { getTranslation } from "../../../lib/i18n/getTranslation";
|
import { getTranslation } from "../../../lib/i18n/getTranslation";
|
||||||
|
import { routeMetadata } from "../../../lib/siteMetadata";
|
||||||
import AboutHeader from "../../components/type/AboutHeader";
|
import AboutHeader from "../../components/type/AboutHeader";
|
||||||
import type { AboutHeaderSegment } from "../../components/type/AboutHeader";
|
import type { AboutHeaderSegment } from "../../components/type/AboutHeader";
|
||||||
import Stats from "../../components/sections/Stats";
|
import Stats from "../../components/sections/Stats";
|
||||||
@@ -13,6 +20,10 @@ import type { FaqAccordionItem } from "../../components/sections/Accordion";
|
|||||||
import QuoteBlock from "../../components/sections/QuoteBlock";
|
import QuoteBlock from "../../components/sections/QuoteBlock";
|
||||||
import AskOrganizer from "../../components/sections/AskOrganizer";
|
import AskOrganizer from "../../components/sections/AskOrganizer";
|
||||||
|
|
||||||
|
export const metadata: Metadata = routeMetadata("/about", {
|
||||||
|
title: messages.metadata.about.title,
|
||||||
|
});
|
||||||
|
|
||||||
function asArray<T>(value: unknown): T[] {
|
function asArray<T>(value: unknown): T[] {
|
||||||
return Array.isArray(value) ? value : [];
|
return Array.isArray(value) ? value : [];
|
||||||
}
|
}
|
||||||
@@ -25,11 +36,6 @@ export default function AboutPage() {
|
|||||||
const headerSegments = asArray<AboutHeaderSegment>(page.aboutHeader.segments);
|
const headerSegments = asArray<AboutHeaderSegment>(page.aboutHeader.segments);
|
||||||
const statsItems = asArray<StatItem>(page.stats.items);
|
const statsItems = asArray<StatItem>(page.stats.items);
|
||||||
|
|
||||||
const statsAsOf =
|
|
||||||
typeof page.stats.asOf === "string"
|
|
||||||
? page.stats.asOf
|
|
||||||
: String(page.stats.asOf ?? "");
|
|
||||||
|
|
||||||
const faqItems = asArray<FaqAccordionItem>(page.faq.items);
|
const faqItems = asArray<FaqAccordionItem>(page.faq.items);
|
||||||
const tripleColumns = asArray<TripleTextBlockColumn>(page.tripleTextBlock.columns);
|
const tripleColumns = asArray<TripleTextBlockColumn>(page.tripleTextBlock.columns);
|
||||||
|
|
||||||
@@ -46,18 +52,25 @@ export default function AboutPage() {
|
|||||||
titlePrefix={page.stats.titlePrefix}
|
titlePrefix={page.stats.titlePrefix}
|
||||||
titleEmphasis={page.stats.titleEmphasis}
|
titleEmphasis={page.stats.titleEmphasis}
|
||||||
titleSuffix={page.stats.titleSuffix}
|
titleSuffix={page.stats.titleSuffix}
|
||||||
items={statsItems.map((item) => ({
|
asOfPrefix={page.stats.asOfPrefix}
|
||||||
...item,
|
items={statsItems}
|
||||||
asOf: statsAsOf,
|
|
||||||
}))}
|
|
||||||
/>
|
/>
|
||||||
<TripleTextBlock columns={tripleColumns} />
|
<TripleTextBlock columns={tripleColumns} />
|
||||||
<Book
|
<Book
|
||||||
title={page.book.title}
|
title={page.structureBeforeCrisis.title}
|
||||||
description={page.book.description}
|
description={page.structureBeforeCrisis.description}
|
||||||
buttonText={page.book.buttonText}
|
buttonText={page.structureBeforeCrisis.buttonText}
|
||||||
|
buttonHref={getAssetPath(structureBeforeCrisisPath())}
|
||||||
|
imageSrc={getAssetPath(ASSETS.STRUCTURE_BEFORE_CRISIS_COVER)}
|
||||||
|
imageAlt={page.structureBeforeCrisis.imageAlt}
|
||||||
|
/>
|
||||||
|
<Book
|
||||||
|
title={page.communityRules.title}
|
||||||
|
description={page.communityRules.description}
|
||||||
|
buttonText={page.communityRules.buttonText}
|
||||||
buttonHref={getAssetPath(governanceBookletPath())}
|
buttonHref={getAssetPath(governanceBookletPath())}
|
||||||
imageAlt={page.book.imageAlt}
|
imageSrc={getAssetPath(ASSETS.COMMUNITY_RULES_COVER)}
|
||||||
|
imageAlt={page.communityRules.imageAlt}
|
||||||
/>
|
/>
|
||||||
<FaqAccordion title={page.faq.title} items={faqItems} />
|
<FaqAccordion title={page.faq.title} items={faqItems} />
|
||||||
<QuoteBlock
|
<QuoteBlock
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import {
|
|||||||
getRelatedBlogPosts,
|
getRelatedBlogPosts,
|
||||||
} from "../../../../lib/content";
|
} from "../../../../lib/content";
|
||||||
import { logger } from "../../../../lib/logger";
|
import { logger } from "../../../../lib/logger";
|
||||||
|
import { routeMetadata } from "../../../../lib/siteMetadata";
|
||||||
|
import messages from "../../../../messages/en/index";
|
||||||
import ContentBanner from "../../../components/sections/ContentBanner";
|
import ContentBanner from "../../../components/sections/ContentBanner";
|
||||||
import AskOrganizer from "../../../components/sections/AskOrganizer";
|
import AskOrganizer from "../../../components/sections/AskOrganizer";
|
||||||
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
|
import ContentTemplateDecorativeShapes from "../../_components/ContentTemplateDecorativeShapes";
|
||||||
import "../blog.css";
|
import "../blog.css";
|
||||||
|
|
||||||
// Code split RelatedArticles - blog-specific, below the fold
|
// Code split RelatedArticles - blog-specific, below the fold
|
||||||
@@ -62,13 +64,13 @@ export async function generateMetadata({
|
|||||||
const post = getBlogPostBySlug(slug);
|
const post = getBlogPostBySlug(slug);
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
return {
|
return routeMetadata(`/blog/${slug}`, {
|
||||||
title: "Post Not Found",
|
title: messages.metadata.blog.notFoundTitle,
|
||||||
description: "The requested blog post could not be found.",
|
description: messages.metadata.blog.fallbackDescription,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return routeMetadata(`/blog/${slug}`, {
|
||||||
title: post.frontmatter.title,
|
title: post.frontmatter.title,
|
||||||
description: post.frontmatter.description,
|
description: post.frontmatter.description,
|
||||||
authors: [{ name: post.frontmatter.author }],
|
authors: [{ name: post.frontmatter.author }],
|
||||||
@@ -78,8 +80,8 @@ export async function generateMetadata({
|
|||||||
type: "article",
|
type: "article",
|
||||||
publishedTime: post.frontmatter.date,
|
publishedTime: post.frontmatter.date,
|
||||||
authors: [post.frontmatter.author],
|
authors: [post.frontmatter.author],
|
||||||
url: `https://communityrule.com/blog/${slug}`,
|
url: `/blog/${slug}`,
|
||||||
siteName: "CommunityRule",
|
siteName: messages.metadata.siteName,
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
@@ -87,12 +89,12 @@ export async function generateMetadata({
|
|||||||
description: post.frontmatter.description,
|
description: post.frontmatter.description,
|
||||||
creator: "@communityrule",
|
creator: "@communityrule",
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error generating metadata:", error);
|
logger.error("Error generating metadata:", error);
|
||||||
return {
|
return {
|
||||||
title: "Blog Post",
|
title: messages.metadata.blog.fallbackTitle,
|
||||||
description: "A blog post from our community.",
|
description: messages.metadata.blog.fallbackDescription,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,58 +207,29 @@ export default async function BlogPostPage({ params }: PageProps) {
|
|||||||
className="relative min-h-screen overflow-x-clip"
|
className="relative min-h-screen overflow-x-clip"
|
||||||
style={{ backgroundColor }}
|
style={{ backgroundColor }}
|
||||||
>
|
>
|
||||||
{/* Content Banner */}
|
|
||||||
<ContentBanner post={post} />
|
<ContentBanner post={post} />
|
||||||
|
|
||||||
{/* Decorative Shapes */}
|
<div className="relative w-full overflow-x-clip">
|
||||||
{/* Right Side Shape (3/4 up the page) */}
|
<ContentTemplateDecorativeShapes
|
||||||
<div
|
variant="article"
|
||||||
className="hidden md:block absolute top-1/4 right-0 pointer-events-none z-10"
|
slug={post.slug}
|
||||||
style={{ transform: "translateX(40%)" }}
|
|
||||||
>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={getAssetPath(ASSETS.CONTENT_SHAPE_1)}
|
|
||||||
alt=""
|
|
||||||
className="w-auto h-auto max-w-none"
|
|
||||||
style={{
|
|
||||||
width: "clamp(120px, 15vw, 200px)",
|
|
||||||
height: "auto",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Left Side Shape (3/4 down the page) */}
|
<article
|
||||||
<div
|
data-node-id="19031:10426"
|
||||||
className="hidden md:block absolute top-1/2 left-0 pointer-events-none z-10"
|
className="
|
||||||
style={{ transform: "translateX(-10%)" }}
|
relative z-10 flex w-full justify-center
|
||||||
>
|
p-[var(--spacing-scale-024)]
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
sm:px-0 sm:py-[var(--spacing-scale-032)]
|
||||||
<img
|
xl:px-[calc(268/1440*100%)]
|
||||||
src={getAssetPath(ASSETS.CONTENT_SHAPE_2)}
|
"
|
||||||
alt=""
|
>
|
||||||
className="w-auto h-auto max-w-none"
|
<div className="post-body w-full text-[var(--color-content-inverse-primary)] text-medium-paragraph sm:text-large-paragraph lg:text-x-large-paragraph xl:text-xx-large-paragraph sm:max-w-[390px] md:max-w-[472px] lg:max-w-[700px] xl:max-w-none">
|
||||||
style={{
|
<div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
|
||||||
width: "clamp(100px, 12vw, 180px)",
|
</div>
|
||||||
height: "auto",
|
</article>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content — Figma Content page Template (19003:23305) article body instances */}
|
|
||||||
<article
|
|
||||||
data-node-id="19031:10426"
|
|
||||||
className="
|
|
||||||
relative z-[2] flex w-full justify-center
|
|
||||||
p-[var(--spacing-scale-024)]
|
|
||||||
sm:px-0 sm:py-[var(--spacing-scale-032)]
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div className="post-body w-full text-[var(--color-content-inverse-primary)] text-[16px] leading-[24px] sm:text-[18px] sm:leading-[130%] lg:text-[24px] lg:leading-[32px] xl:text-[32px] xl:leading-[40px] sm:max-w-[390px] md:max-w-[472px] lg:max-w-[700px] xl:max-w-[904px]">
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
{/* Related Articles Section */}
|
{/* Related Articles Section */}
|
||||||
<RelatedArticles
|
<RelatedArticles
|
||||||
relatedPosts={relatedArticles}
|
relatedPosts={relatedArticles}
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
import { getAllBlogPosts } from "../../../lib/content";
|
import { getAllBlogPosts } from "../../../lib/content";
|
||||||
import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate";
|
import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import messages from "../../../messages/en/index";
|
||||||
|
import { routeMetadata } from "../../../lib/siteMetadata";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
const blogMeta = messages.metadata.blog;
|
||||||
title: "Blog - CommunityRule",
|
|
||||||
description:
|
export const metadata: Metadata = routeMetadata("/blog", {
|
||||||
"Learn about community governance, decision-making, and building successful organizations.",
|
title: blogMeta.title,
|
||||||
|
description: blogMeta.description,
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "Blog - CommunityRule",
|
title: blogMeta.title,
|
||||||
description:
|
description: blogMeta.description,
|
||||||
"Learn about community governance, decision-making, and building successful organizations.",
|
url: "/blog",
|
||||||
url: "https://communityrule.com/blog",
|
siteName: messages.metadata.siteName,
|
||||||
siteName: "CommunityRule",
|
|
||||||
type: "website",
|
type: "website",
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: "Blog - CommunityRule",
|
title: blogMeta.title,
|
||||||
description:
|
description: blogMeta.description,
|
||||||
"Learn about community governance, decision-making, and building successful organizations.",
|
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
export default function BlogPage() {
|
export default function BlogPage() {
|
||||||
const posts = getAllBlogPosts();
|
const posts = getAllBlogPosts();
|
||||||
@@ -34,11 +35,10 @@ export default function BlogPage() {
|
|||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-content-default-primary)] mb-4">
|
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-content-default-primary)] mb-4">
|
||||||
Blog
|
{blogMeta.title}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-[var(--color-content-default-secondary)] max-w-2xl mx-auto">
|
<p className="text-lg text-[var(--color-content-default-secondary)] max-w-2xl mx-auto">
|
||||||
Learn about community governance, decision-making, and building
|
{blogMeta.description}
|
||||||
successful organizations.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Figma: "Content page Template" (19003:23305)
|
||||||
|
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=19003-23305
|
||||||
|
*/
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import messages from "../../../messages/en/index";
|
||||||
|
import { legalPathForSlug } from "../../../lib/legalSyntheticPost";
|
||||||
|
import { routeMetadata } from "../../../lib/siteMetadata";
|
||||||
|
import LegalDocumentPage, {
|
||||||
|
legalPageMetadata,
|
||||||
|
} from "../_components/LegalDocumentPage";
|
||||||
|
|
||||||
|
const SLUG = "cookies" as const;
|
||||||
|
const PATH = legalPathForSlug(SLUG);
|
||||||
|
|
||||||
|
export function generateMetadata(): Metadata {
|
||||||
|
return routeMetadata(
|
||||||
|
PATH,
|
||||||
|
legalPageMetadata(messages.metadata.cookies, messages.pages.cookies),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CookiesPage() {
|
||||||
|
return (
|
||||||
|
<LegalDocumentPage
|
||||||
|
slug={SLUG}
|
||||||
|
path={PATH}
|
||||||
|
page={messages.pages.cookies}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
/**
|
|
||||||
* Figma: "A Guide to CommunityRule" body ornaments (22078:791901)
|
|
||||||
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22078-791901
|
|
||||||
*
|
|
||||||
* - 19003:23575 — concentric circles, right (`how-shape-2.svg`)
|
|
||||||
* - 19003:23576 — loop mark, left (`how-shape-1.svg`)
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
getAssetPath,
|
|
||||||
howItWorksOrnamentLeftPath,
|
|
||||||
howItWorksOrnamentRightPath,
|
|
||||||
} from "../../../../lib/assetUtils";
|
|
||||||
|
|
||||||
export default function HowItWorksDecorativeShapes() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="pointer-events-none absolute z-0 hidden aspect-square md:block left-[84.86%] right-[-9.28%] top-[clamp(200px,20vw,255px)]"
|
|
||||||
data-node-id="19003:23575"
|
|
||||||
>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={getAssetPath(howItWorksOrnamentRightPath())}
|
|
||||||
alt=""
|
|
||||||
className="pointer-events-none size-full max-w-none object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="pointer-events-none absolute z-0 hidden aspect-square md:block left-[-1.66%] right-[88.38%] top-[clamp(520px,55vw,811px)]"
|
|
||||||
data-node-id="19003:23576"
|
|
||||||
>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={getAssetPath(howItWorksOrnamentLeftPath())}
|
|
||||||
alt=""
|
|
||||||
className="pointer-events-none size-full max-w-none object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,8 +10,9 @@ import {
|
|||||||
buildHowItWorksSyntheticPost,
|
buildHowItWorksSyntheticPost,
|
||||||
HOW_IT_WORKS_SENTINEL_SLUG,
|
HOW_IT_WORKS_SENTINEL_SLUG,
|
||||||
} from "../../../lib/howItWorksSyntheticPost";
|
} from "../../../lib/howItWorksSyntheticPost";
|
||||||
|
import { routeMetadata } from "../../../lib/siteMetadata";
|
||||||
import ContentBanner from "../../components/sections/ContentBanner";
|
import ContentBanner from "../../components/sections/ContentBanner";
|
||||||
import HowItWorksDecorativeShapes from "./_components/HowItWorksDecorativeShapes";
|
import ContentTemplateDecorativeShapes from "../_components/ContentTemplateDecorativeShapes";
|
||||||
import AskOrganizer from "../../components/sections/AskOrganizer";
|
import AskOrganizer from "../../components/sections/AskOrganizer";
|
||||||
import "../blog/blog.css";
|
import "../blog/blog.css";
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
const meta = messages.metadata.howItWorks;
|
const meta = messages.metadata.howItWorks;
|
||||||
const page = messages.pages.howItWorks;
|
const page = messages.pages.howItWorks;
|
||||||
|
|
||||||
return {
|
return routeMetadata("/how-it-works", {
|
||||||
title: meta.title,
|
title: meta.title,
|
||||||
description: meta.description,
|
description: meta.description,
|
||||||
keywords: meta.keywords,
|
keywords: meta.keywords,
|
||||||
@@ -37,9 +38,9 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
title: page.banner.title,
|
title: page.banner.title,
|
||||||
description: page.banner.description,
|
description: page.banner.description,
|
||||||
type: "website",
|
type: "website",
|
||||||
siteName: "CommunityRule",
|
siteName: messages.metadata.siteName,
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HowItWorksPage() {
|
export default function HowItWorksPage() {
|
||||||
@@ -103,15 +104,15 @@ export default function HowItWorksPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative min-h-screen overflow-x-hidden bg-transparent">
|
<div className="relative min-h-screen overflow-x-clip bg-transparent">
|
||||||
<ContentBanner post={syntheticPost} variant="guide" />
|
<ContentBanner post={syntheticPost} variant="guide" />
|
||||||
|
|
||||||
<div className="relative w-full">
|
<div className="relative w-full overflow-x-clip">
|
||||||
<HowItWorksDecorativeShapes />
|
<ContentTemplateDecorativeShapes variant="guide" />
|
||||||
|
|
||||||
<article className="relative z-10 p-[var(--spacing-scale-024)] sm:py-[var(--spacing-scale-032)]">
|
<article className="relative z-10 p-[var(--spacing-scale-024)] sm:py-[var(--spacing-scale-032)] xl:px-[calc(268/1440*100%)]">
|
||||||
<div
|
<div
|
||||||
className="post-body -mt-[var(--spacing-scale-048)] text-[var(--color-content-default-primary)] text-[16px] leading-[24px] sm:text-[18px] sm:leading-[130%] lg:text-[24px] lg:leading-[32px] xl:text-[32px] xl:leading-[40px] sm:mx-auto sm:max-w-[390px] md:max-w-[472px] lg:max-w-[700px] xl:max-w-[904px]"
|
className="post-body text-[var(--color-content-default-primary)] text-medium-paragraph sm:text-large-paragraph lg:text-x-large-paragraph xl:text-xx-large-paragraph sm:mx-auto sm:max-w-[390px] md:max-w-[472px] lg:max-w-[700px] xl:max-w-none"
|
||||||
dangerouslySetInnerHTML={{ __html: syntheticPost.htmlContent }}
|
dangerouslySetInnerHTML={{ __html: syntheticPost.htmlContent }}
|
||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Suspense, type ReactNode } from "react";
|
import { Suspense, type ReactNode } from "react";
|
||||||
import MarketingNavigation from "../components/navigation/MarketingNavigation";
|
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
|
||||||
|
import SkipToContent from "../components/navigation/SkipToContent";
|
||||||
import { MessagesProvider } from "../contexts/MessagesContext";
|
import { MessagesProvider } from "../contexts/MessagesContext";
|
||||||
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
||||||
|
import { MAIN_CONTENT_ID } from "../../lib/mainContent";
|
||||||
import marketingMessages from "../../messages/en/marketing";
|
import marketingMessages from "../../messages/en/marketing";
|
||||||
|
|
||||||
// Site footer is part of the public marketing chrome only — not rendered for
|
// Site footer is part of the public marketing chrome only — not rendered for
|
||||||
@@ -20,14 +22,21 @@ export default function MarketingLayout({ children }: { children: ReactNode }) {
|
|||||||
<MessagesProvider messages={marketingMessages}>
|
<MessagesProvider messages={marketingMessages}>
|
||||||
<AuthModalProvider>
|
<AuthModalProvider>
|
||||||
{/*
|
{/*
|
||||||
* MarketingNavigation reads `usePathname()` to decide chromeless paths
|
* Same session-aware shell as `(app)` / `(admin)`: `ConditionalNavigation`
|
||||||
* (uncached data under `cacheComponents`). Suspense lets the static
|
* reads the cookie behind Suspense so the static page shell can prerender
|
||||||
* shell prerender; the nav streams in with the correct visibility.
|
* while the header streams signed-in vs signed-out correctly.
|
||||||
*/}
|
*/}
|
||||||
|
<SkipToContent />
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<MarketingNavigation />
|
<ConditionalNavigation />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<main className="flex-1">{children}</main>
|
<main
|
||||||
|
id={MAIN_CONTENT_ID}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="flex-1 outline-none"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</AuthModalProvider>
|
</AuthModalProvider>
|
||||||
</MessagesProvider>
|
</MessagesProvider>
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import messages from "../../../messages/en/index";
|
import messages from "../../../messages/en/index";
|
||||||
import { getTranslation } from "../../../lib/i18n/getTranslation";
|
import { getTranslation } from "../../../lib/i18n/getTranslation";
|
||||||
|
import { routeMetadata } from "../../../lib/siteMetadata";
|
||||||
import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate";
|
import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate";
|
||||||
import ContentLockup from "../../components/type/ContentLockup";
|
import ContentLockup from "../../components/type/ContentLockup";
|
||||||
import AskOrganizer from "../../components/sections/AskOrganizer";
|
import AskOrganizer from "../../components/sections/AskOrganizer";
|
||||||
import { getAllBlogPosts } from "../../../lib/content";
|
import { getAllBlogPosts } from "../../../lib/content";
|
||||||
|
|
||||||
|
export const metadata: Metadata = routeMetadata("/learn", {
|
||||||
|
title: messages.metadata.learn.title,
|
||||||
|
});
|
||||||
|
|
||||||
export default function LearnPage() {
|
export default function LearnPage() {
|
||||||
const allPosts = getAllBlogPosts();
|
const allPosts = getAllBlogPosts();
|
||||||
|
|
||||||
@@ -31,11 +37,13 @@ export default function LearnPage() {
|
|||||||
|
|
||||||
{/*
|
{/*
|
||||||
* Single responsive render: ContentThumbnailTemplate variant="responsive"
|
* Single responsive render: ContentThumbnailTemplate variant="responsive"
|
||||||
* uses <picture> to swap horizontal/vertical art at smd (530px). The
|
* uses <picture> to swap horizontal/vertical art at smd (530px). Wrap and
|
||||||
* container switches from a vertical flex stack (<smd) to a grid (≥smd),
|
* center leftover cards so a short last row is not left-aligned.
|
||||||
* matching the prior twin-region layout without doubling the DOM.
|
|
||||||
*/}
|
*/}
|
||||||
<div className="flex flex-col space-y-[var(--spacing-scale-002)] sm:space-y-[var(--spacing-scale-008)] sm:px-[var(--spacing-scale-020)] sm:pt-[var(--spacing-scale-024)] sm:pb-[var(--spacing-scale-024)] smd:grid smd:grid-cols-2 smd:gap-[var(--spacing-scale-008)] smd:space-y-0 smd:px-[var(--spacing-scale-020)] smd:pt-[var(--spacing-scale-024)] smd:pb-[var(--spacing-scale-024)] md:gap-[var(--spacing-scale-016)] md:px-[var(--spacing-scale-032)] xmd:grid-cols-3 xmd:gap-[var(--spacing-scale-012)] lg:grid-cols-3 lg:gap-[var(--spacing-scale-016)] lg:px-[var(--spacing-scale-064)] lg:pt-[var(--spacing-scale-032)] lg:pb-[var(--spacing-scale-064)] lg2:grid-cols-4 lg2:gap-x-[var(--spacing-scale-016)] lg2:gap-y-[var(--spacing-scale-024)] xl:grid-cols-5 xl:gap-x-[var(--spacing-scale-016)] xl:gap-y-[var(--spacing-scale-016)] [&>*]:min-w-0">
|
<div
|
||||||
|
data-testid="learn-article-grid"
|
||||||
|
className="flex flex-col space-y-[var(--spacing-scale-002)] sm:space-y-[var(--spacing-scale-008)] sm:px-[var(--spacing-scale-020)] sm:pt-[var(--spacing-scale-024)] sm:pb-[var(--spacing-scale-024)] smd:flex-row smd:flex-wrap smd:justify-center smd:gap-[var(--spacing-scale-008)] smd:space-y-0 smd:px-[var(--spacing-scale-020)] smd:pt-[var(--spacing-scale-024)] smd:pb-[var(--spacing-scale-024)] md:gap-[var(--spacing-scale-016)] md:px-[var(--spacing-scale-032)] xmd:gap-[var(--spacing-scale-012)] lg:gap-[var(--spacing-scale-016)] lg:px-[var(--spacing-scale-064)] lg:pt-[var(--spacing-scale-032)] lg:pb-[var(--spacing-scale-064)] lg2:gap-x-[var(--spacing-scale-016)] lg2:gap-y-[var(--spacing-scale-024)] xl:gap-x-[var(--spacing-scale-016)] xl:gap-y-[var(--spacing-scale-016)] [&>*]:min-w-0 [&>*]:w-full smd:[&>*]:w-[calc((100%-var(--spacing-scale-008))/2)] md:[&>*]:w-[calc((100%-var(--spacing-scale-016))/2)] xmd:[&>*]:w-[calc((100%-2*var(--spacing-scale-012))/3)] lg:[&>*]:w-[calc((100%-2*var(--spacing-scale-016))/3)] lg2:[&>*]:w-[calc((100%-3*var(--spacing-scale-016))/4)] xl:[&>*]:w-[calc((100%-4*var(--spacing-scale-016))/5)]"
|
||||||
|
>
|
||||||
{allPosts.map((post) => (
|
{allPosts.map((post) => (
|
||||||
<ContentThumbnailTemplate
|
<ContentThumbnailTemplate
|
||||||
key={post.slug}
|
key={post.slug}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import messages from "../../messages/en/index";
|
import messages from "../../messages/en/index";
|
||||||
import { getTranslation } from "../../lib/i18n/getTranslation";
|
import { getTranslation } from "../../lib/i18n/getTranslation";
|
||||||
|
import { routeMetadata } from "../../lib/siteMetadata";
|
||||||
import HeroBanner from "../components/sections/HeroBanner";
|
import HeroBanner from "../components/sections/HeroBanner";
|
||||||
import AskOrganizer from "../components/sections/AskOrganizer";
|
import AskOrganizer from "../components/sections/AskOrganizer";
|
||||||
import { MarketingRuleStackSection } from "./_components/MarketingRuleStackSection";
|
import { MarketingRuleStackSection } from "./_components/MarketingRuleStackSection";
|
||||||
|
|
||||||
|
export const metadata: Metadata = routeMetadata("/");
|
||||||
|
|
||||||
// Code split below-the-fold components to reduce initial bundle size
|
// Code split below-the-fold components to reduce initial bundle size
|
||||||
const LogoWall = dynamic(() => import("../components/sections/LogoWall"), {
|
const LogoWall = dynamic(() => import("../components/sections/LogoWall"), {
|
||||||
loading: () => (
|
loading: () => (
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Figma: "Content page Template" (19003:23305)
|
||||||
|
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=19003-23305
|
||||||
|
*/
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import messages from "../../../messages/en/index";
|
||||||
|
import { legalPathForSlug } from "../../../lib/legalSyntheticPost";
|
||||||
|
import { routeMetadata } from "../../../lib/siteMetadata";
|
||||||
|
import LegalDocumentPage, {
|
||||||
|
legalPageMetadata,
|
||||||
|
} from "../_components/LegalDocumentPage";
|
||||||
|
|
||||||
|
const SLUG = "privacy" as const;
|
||||||
|
const PATH = legalPathForSlug(SLUG);
|
||||||
|
|
||||||
|
export function generateMetadata(): Metadata {
|
||||||
|
return routeMetadata(
|
||||||
|
PATH,
|
||||||
|
legalPageMetadata(messages.metadata.privacy, messages.pages.privacy),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PrivacyPage() {
|
||||||
|
return (
|
||||||
|
<LegalDocumentPage
|
||||||
|
slug={SLUG}
|
||||||
|
path={PATH}
|
||||||
|
page={messages.pages.privacy}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
|
|||||||
import messages from "../../../../messages/en/index";
|
import messages from "../../../../messages/en/index";
|
||||||
import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules";
|
import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules";
|
||||||
import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../lib/create/publishedDocumentToDisplaySections";
|
import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../lib/create/publishedDocumentToDisplaySections";
|
||||||
|
import { routeMetadata } from "../../../../lib/siteMetadata";
|
||||||
import CommunityRule from "../../../components/type/CommunityRule";
|
import CommunityRule from "../../../components/type/CommunityRule";
|
||||||
import HeaderLockup from "../../../components/type/HeaderLockup";
|
import HeaderLockup from "../../../components/type/HeaderLockup";
|
||||||
|
|
||||||
@@ -25,22 +26,22 @@ export async function generateMetadata({
|
|||||||
typeof rule.summary === "string" && rule.summary.trim().length > 0
|
typeof rule.summary === "string" && rule.summary.trim().length > 0
|
||||||
? rule.summary
|
? rule.summary
|
||||||
: undefined;
|
: undefined;
|
||||||
return {
|
return routeMetadata(`/rules/${rule.id}`, {
|
||||||
title: rule.title,
|
title: rule.title,
|
||||||
description,
|
description,
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: rule.title,
|
title: rule.title,
|
||||||
description,
|
description,
|
||||||
type: "article",
|
type: "article",
|
||||||
url: `https://communityrule.com/rules/${rule.id}`,
|
url: `/rules/${rule.id}`,
|
||||||
siteName: "CommunityRule",
|
siteName: messages.metadata.siteName,
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: rule.title,
|
title: rule.title,
|
||||||
description,
|
description,
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function PublicRuleDetailPage({ params }: PageProps) {
|
export default async function PublicRuleDetailPage({ params }: PageProps) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
import HeaderLockup from "../../components/type/HeaderLockup";
|
||||||
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
|
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
|
||||||
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
|
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
|
||||||
@@ -68,8 +68,8 @@ export default function TemplatesPageClient({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* - `fromFlow=1` — skip `prepareFreshCreateFlowEntry` on template click
|
* - `fromFlow=1` — skip `prepareFreshCreateFlowEntry` on template click
|
||||||
* (draft preserved). Used by review “Create from template” and profile.
|
* (draft preserved). Used by profile “Create from template”.
|
||||||
* - `recommendTemplates=1` (with review only) — rank templates + “RECOMMENDED”
|
* - `recommendTemplates=1` — rank templates + “RECOMMENDED”
|
||||||
* from `GET /api/templates?facet.*` using the persisted community draft.
|
* from `GET /api/templates?facet.*` using the persisted community draft.
|
||||||
*/
|
*/
|
||||||
function TemplatesGridWithSearchParams({
|
function TemplatesGridWithSearchParams({
|
||||||
@@ -96,17 +96,18 @@ function TemplatesGrid({
|
|||||||
entries: TemplateGridCardEntry[];
|
entries: TemplateGridCardEntry[];
|
||||||
fromFlow: boolean;
|
fromFlow: boolean;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
|
||||||
return (
|
return (
|
||||||
<GovernanceTemplateGrid
|
<GovernanceTemplateGrid
|
||||||
entries={entries}
|
entries={entries}
|
||||||
onTemplateClick={(slug) => {
|
hrefForTemplate={(slug) =>
|
||||||
|
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow })
|
||||||
|
}
|
||||||
|
onTemplateClick={() => {
|
||||||
if (!fromFlow) {
|
if (!fromFlow) {
|
||||||
prepareFreshCreateFlowEntrySync();
|
// Marketing /templates has no session in hand; DELETE is
|
||||||
|
// best-effort and the sentinel blocks stale-draft hydration.
|
||||||
|
prepareFreshCreateFlowEntrySync({ signedIn: true });
|
||||||
}
|
}
|
||||||
router.push(
|
|
||||||
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }),
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import messages from "../../../messages/en/index";
|
||||||
import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
|
import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
|
||||||
|
import { routeMetadata } from "../../../lib/siteMetadata";
|
||||||
import { gridEntriesForFullCatalogWithFallback } from "../../../lib/templates/templateGridPresentation";
|
import { gridEntriesForFullCatalogWithFallback } from "../../../lib/templates/templateGridPresentation";
|
||||||
import TemplatesPageClient from "./TemplatesPageClient";
|
import TemplatesPageClient from "./TemplatesPageClient";
|
||||||
|
|
||||||
|
export const metadata: Metadata = routeMetadata("/templates", {
|
||||||
|
title: messages.metadata.templates.title,
|
||||||
|
});
|
||||||
|
|
||||||
export default async function TemplatesPage() {
|
export default async function TemplatesPage() {
|
||||||
const rows = await listRuleTemplatesFromDb();
|
const rows = await listRuleTemplatesFromDb();
|
||||||
const initialGridEntries = gridEntriesForFullCatalogWithFallback(rows);
|
const initialGridEntries = gridEntriesForFullCatalogWithFallback(rows);
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ type UseTemplatesFacetGridEntriesArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When `enableFacetRecommendations` (review → “Create from template” only),
|
* When `enableFacetRecommendations` (in-flow `/create/templates`, or marketing
|
||||||
* re-fetch ranked templates from `GET /api/templates?facet.*` using the
|
* `/templates?recommendTemplates=1`), re-fetch ranked templates from
|
||||||
* persisted create-flow draft. Otherwise returns `initialGridEntries` from SSR.
|
* `GET /api/templates?facet.*` using the persisted create-flow draft.
|
||||||
|
* Otherwise returns `initialGridEntries` from SSR.
|
||||||
*/
|
*/
|
||||||
export function useTemplatesFacetGridEntries({
|
export function useTemplatesFacetGridEntries({
|
||||||
initialGridEntries,
|
initialGridEntries,
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Figma: "Content page Template" (19003:23305)
|
||||||
|
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=19003-23305
|
||||||
|
*/
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import messages from "../../../messages/en/index";
|
||||||
|
import { legalPathForSlug } from "../../../lib/legalSyntheticPost";
|
||||||
|
import { routeMetadata } from "../../../lib/siteMetadata";
|
||||||
|
import LegalDocumentPage, {
|
||||||
|
legalPageMetadata,
|
||||||
|
} from "../_components/LegalDocumentPage";
|
||||||
|
|
||||||
|
const SLUG = "terms" as const;
|
||||||
|
const PATH = legalPathForSlug(SLUG);
|
||||||
|
|
||||||
|
export function generateMetadata(): Metadata {
|
||||||
|
return routeMetadata(
|
||||||
|
PATH,
|
||||||
|
legalPageMetadata(messages.metadata.terms, messages.pages.terms),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TermsPage() {
|
||||||
|
return (
|
||||||
|
<LegalDocumentPage
|
||||||
|
slug={SLUG}
|
||||||
|
path={PATH}
|
||||||
|
page={messages.pages.terms}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
USE_CASE_DETAIL_SLUGS,
|
USE_CASE_DETAIL_SLUGS,
|
||||||
useCaseContentKeyForSlug,
|
useCaseContentKeyForSlug,
|
||||||
} from "../../../../lib/useCaseSyntheticPost";
|
} from "../../../../lib/useCaseSyntheticPost";
|
||||||
|
import { routeMetadata } from "../../../../lib/siteMetadata";
|
||||||
import ContentBanner from "../../../components/sections/ContentBanner";
|
import ContentBanner from "../../../components/sections/ContentBanner";
|
||||||
import AskOrganizer from "../../../components/sections/AskOrganizer";
|
import AskOrganizer from "../../../components/sections/AskOrganizer";
|
||||||
import type { AskOrganizerVariant } from "../../../components/sections/AskOrganizer/AskOrganizer.types";
|
import type { AskOrganizerVariant } from "../../../components/sections/AskOrganizer/AskOrganizer.types";
|
||||||
@@ -34,7 +35,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|||||||
const contentKey = useCaseContentKeyForSlug(slug);
|
const contentKey = useCaseContentKeyForSlug(slug);
|
||||||
const meta = messages.metadata.useCasesDetail[contentKey];
|
const meta = messages.metadata.useCasesDetail[contentKey];
|
||||||
|
|
||||||
return {
|
return routeMetadata(`/use-cases/${slug}`, {
|
||||||
title: meta.title,
|
title: meta.title,
|
||||||
description: meta.description,
|
description: meta.description,
|
||||||
keywords: meta.keywords,
|
keywords: meta.keywords,
|
||||||
@@ -42,9 +43,9 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|||||||
title: meta.title,
|
title: meta.title,
|
||||||
description: meta.description,
|
description: meta.description,
|
||||||
type: "website",
|
type: "website",
|
||||||
siteName: "CommunityRule",
|
siteName: messages.metadata.siteName,
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function UseCaseDetailPage({ params }: PageProps) {
|
export default async function UseCaseDetailPage({ params }: PageProps) {
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import dynamic from "next/dynamic";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import messages from "../../../messages/en/index";
|
import messages from "../../../messages/en/index";
|
||||||
import { getAllBlogPosts } from "../../../lib/content";
|
import { CONTENT_CATALOG_SLUG_ORDER } from "../../../lib/assetUtils";
|
||||||
|
import { getAllBlogPosts, getRelatedBlogPosts } from "../../../lib/content";
|
||||||
|
import { routeMetadata } from "../../../lib/siteMetadata";
|
||||||
import PageHeader from "../../components/type/PageHeader";
|
import PageHeader from "../../components/type/PageHeader";
|
||||||
import CaseStudy from "../../components/cards/CaseStudy";
|
import CaseStudy from "../../components/cards/CaseStudy";
|
||||||
import UseCasesOrgs from "../../components/sections/UseCasesOrgs";
|
import UseCasesOrgs from "../../components/sections/UseCasesOrgs";
|
||||||
@@ -38,8 +40,8 @@ const CASE_STUDY_TILE_RADIUS_CLASS = "rounded-[23.093px]";
|
|||||||
|
|
||||||
const CASE_STUDY_LINK_CLASS = [
|
const CASE_STUDY_LINK_CLASS = [
|
||||||
CASE_STUDY_TILE_RADIUS_CLASS,
|
CASE_STUDY_TILE_RADIUS_CLASS,
|
||||||
"block shrink-0 cursor-pointer outline-none transition-transform duration-200",
|
"block shrink-0 cursor-pointer outline-none transition-[opacity,transform] duration-200",
|
||||||
"hover:scale-[1.02] hover:opacity-95",
|
"hover:opacity-95",
|
||||||
"focus-visible:ring-2 focus-visible:ring-[var(--color-border-default-brand-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]",
|
"focus-visible:ring-2 focus-visible:ring-[var(--color-border-default-brand-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]",
|
||||||
"active:scale-[0.98]",
|
"active:scale-[0.98]",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
@@ -59,7 +61,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
const description = messages.metadata.useCases.description;
|
const description = messages.metadata.useCases.description;
|
||||||
const keywords = messages.metadata.useCases.keywords;
|
const keywords = messages.metadata.useCases.keywords;
|
||||||
|
|
||||||
return {
|
return routeMetadata("/use-cases", {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
keywords,
|
keywords,
|
||||||
@@ -67,9 +69,9 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
type: "website",
|
type: "website",
|
||||||
siteName: "CommunityRule",
|
siteName: messages.metadata.siteName,
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UseCasesPage() {
|
export default function UseCasesPage() {
|
||||||
@@ -102,7 +104,10 @@ export default function UseCasesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const allPosts = getAllBlogPosts();
|
const allPosts = getAllBlogPosts();
|
||||||
const relatedPosts = allPosts.slice(0, 8);
|
const relatedPosts = getRelatedBlogPosts(
|
||||||
|
USE_CASES_RELATED_SENTINEL_SLUG,
|
||||||
|
CONTENT_CATALOG_SLUG_ORDER.slice(0, 3),
|
||||||
|
);
|
||||||
const slugOrder = allPosts.map((p) => p.slug);
|
const slugOrder = allPosts.map((p) => p.slug);
|
||||||
|
|
||||||
const tripleStepSteps = asArray<{ title: string; body: string }>(
|
const tripleStepSteps = asArray<{ title: string; body: string }>(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user