Compare commits

..
96 Commits
Author SHA1 Message Date
adilalloandCursor 77b0ec8ad8 Return focus to the control that opened a dialog instead of leaving it on the document body.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 17:17:09 -06:00
an.di 98945ceb7e Merge pull request 'Restore the community photo after reload and reject empty, oversized, SVG, and spoofed uploads.' (#80) from adilallo/fix/CR-195-photo-upload into main
Reviewed-on: #80
2026-09-10 21:57:52 +00:00
adilalloandCursor 234f3998ad Restore the community photo after reload and reject empty, oversized, SVG, and spoofed uploads.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 15:50:32 -06:00
an.di 6ccc1e8c8e Merge pull request 'Keep the template picker in the create flow, make catalog cards real links, and stop showing a fake community when review has no draft.' (#79) from adilallo/fix/CR-183-builder-routing into main
Reviewed-on: #79
2026-09-10 21:30:10 +00:00
adilalloandCursor 90db213413 Keep the template picker in the create flow, make catalog cards real links, and stop showing a fake community when review has no draft.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 15:22:46 -06:00
an.di 3f9a8395be Merge pull request 'Unify create-flow gutters, viewport height, and action-bar width so tablet and wide layouts stay aligned with step content.' (#78) from adilallo/fix/CR-182-builder-layout into main
Reviewed-on: #78
2026-09-10 20:03:44 +00:00
adilalloandCursor 0e272f0c1e Unify create-flow gutters, viewport height, and action-bar width so tablet and wide layouts stay aligned with step content.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 12:02:06 -06:00
an.di 4055278628 Merge pull request 'Give builder fields persistent labels, treat description as optional, and validate save-progress email with a real form.' (#77) from adilallo/fix/CR-185-builder-inputs into main
Reviewed-on: #77
2026-09-10 17:40:55 +00:00
adilalloandCursor 42d83c8b62 Give builder fields persistent labels, treat description as optional, and validate save-progress email with a real form.
Name stays required; description is a labelled optional textarea. Email uses native validity and form submit. Drop the dead workshop link and fix a few copy errors.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 11:37:41 -06:00
an.di 130663f930 Merge pull request 'Expose builder chip selection beyond color, raise unselected contrast, and make the five-value limit visible' (#76) from adilallo/fix/CR-186-builder-chips into main
Reviewed-on: #76
2026-09-10 17:15:35 +00:00
adilalloandCursor 6c1d83e89d fix: paint chips inline so native button styles cannot override Figma colors
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 11:01:00 -06:00
adilalloandCursor 64a2ef5a00 Expose builder chip selection beyond color, raise unselected contrast, and make the five-value limit visible.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 09:04:06 -06:00
an.di 95e1937419 Merge pull request 'Fix case-study share chrome, hero lockup width, and copy confirmation in the share dialog.' (#75) from adilallo/fix/CR-212-case-studies-share into main
Reviewed-on: #75
2026-09-10 14:48:49 +00:00
adilalloandCursor ce581a42c8 Fix case-study share chrome, hero lockup width, and copy confirmation in the share dialog.
Marketing pages omitted create-flow nav copy, so Share rendered as a translation key; the case-study banner squeezed hero text to the thumbnail width; Copy link had no hover or copied state.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 22:00:19 -06:00
an.di ddf7b0ef7a Merge pull request 'Match the marketing homepage, About page, and folder header to the latest Figma' (#74) from adilallo/fix/CR-173-home-about-feature-grid into main
Reviewed-on: #74
2026-09-10 03:41:43 +00:00
adilalloandCursor 4bd9fa6dc6 Restore the folder header to Figma layout without cloning the nav, and page-center inner-page links with the home cluster.
Park the skip link in the Tailwind sheet so it cannot sit in document flow and push the yellow tab down.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 21:39:39 -06:00
adilalloandCursor b42716c262 Treat the feature-grid toolkit control as underlined text and keep its card glyphs with the other URL-served icons.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 18:37:51 -06:00
adilalloandCursor 40225e2845 Match the marketing homepage and About page to the latest Figma: black hero CTA, spaces in word-split headings, redesigned feature grid, and cited About statistics.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 17:40:11 -06:00
an.di 2d351987cf Merge pull request 'Give the shell one header, a skip link, and a cookie-aware first paint.' (#73) from adilallo/fix/CR-174-header-nav-session-chrome into main
Reviewed-on: #73
2026-09-09 23:23:48 +00:00
adilalloandCursor 5242f8c6bb Give the shell one header, a skip link, and a cookie-aware first paint.
Breakpoint clones were still in the tab order, marketing always painted Log in, and at 430px the logo overlay covered Use cases and Learn.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 17:12:59 -06:00
an.di aecb890cc8 Merge pull request 'Give each route its own title and canonical, and add robots.txt plus a sitemap.' (#72) from adilallo/fix/CR-177-site-metadata into main
Reviewed-on: #72
2026-09-09 22:48:12 +00:00
adilalloandCursor b2b272d2cc Give each route its own title and canonical, and add robots.txt plus a sitemap.
The root layout was stamping the homepage title and production root onto every page. Routes now supply a page segment through one em-dash template, canonicals follow the request path, and non-production hosts disallow crawlers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 16:45:19 -06:00
adilallo e4e61c0c9b Update CloudronManifest.json 2026-09-02 17:21:32 -06:00
adilalloandCursor b6afdb6e32 Let Use without changes from a template collect identity instead of the full questionnaire.
Direct catalog picks walk name, description, and photo, then stakeholders; leftover drafts no longer skip that path. In-flow template picks keep community identity. Exit on a direct template preview leaves immediately because nothing has been collected yet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 17:00:17 -06:00
adilalloandCursor f91ac7a893 Call the last create-flow action Publish so it reads as putting a rule on the web, not locking it forever.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 16:27:57 -06:00
adilalloandCursor db34a1f0df Let guests keep a published rule via Save & Exit and a post-finalize sign-in prompt.
Email is optional so they can continue without saving; skip from Save & Exit leaves the flow instead of returning to completed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 16:18:41 -06:00
adilalloandCursor 84ef943df4 Show a footer Remove on selected create-flow modules so removal is not kebab-only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 14:45:56 -06:00
an.di df7051f33a Merge pull request 'Encode sign-in mail as base64 so webmail cannot mangle the verify token.' (#71) from adilallo/fix/CR-172-magic-link-base64 into main
Reviewed-on: #71
2026-09-01 23:23:44 +00:00
adilalloandCursor f780eac1fa Encode sign-in mail as base64 so webmail cannot mangle the verify token.
Quoted-printable still rewrote token= as token=3D in the raw MIME, which some clients never decode. Distinguish a missing token from a real expiry, and keep links valid for 60 minutes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 17:07:00 -06:00
an.di fa928d1b2c Merge pull request 'Fix magic-link verify URLs in email and related create-flow QA' (#70) from adilallo/fix/QaFeedback into main
Reviewed-on: #70
2026-09-01 16:09:31 +00:00
adilalloandCursor d920e39f09 Send sign-in mail as HTML so quoted-printable wrapping cannot break the verify URL.
Staging was delivering magic links that looked expired because the token query string was encoded and wrapped in plaintext MIME. Also read rate-limit retry from the API error body, mention spam in the success copy, and document SES relay DNS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:01:25 -06:00
adilalloandCursor 511c3efb4c Stop seeding 75% consensus on custom decision cards so wizard copy shows on the published rule.
Wire Back on the final-review chip editor to the same dismiss path as close, instead of a no-op.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 11:28:32 -06:00
adilalloandCursor 7f67cc6271 Warn on tab close when create-flow module or wizard edits have not been saved.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 11:00:40 -06:00
adilalloandCursor a820739d07 Open create-flow method modals with editable fields and tertiary default copy.
Seeded section text should look like the form default state, not locked primary, and Customize stays on the kebab instead of unlocking the body.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 12:59:53 -06:00
adilalloandCursor 744a85a44e Bump the Cloudron manifest to 0.1.11 and fix typecheck errors that blocked the production image build.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 14:05:15 -06:00
adilalloandCursor 4c4cf99572 Give Avatar initials, person-mark, and load-error fallbacks so missing photos still look like the design-system atom.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 13:18:33 -06:00
adilalloandCursor 95781452bd Fill the stakeholders step progress bar to 3-2 so it matches final-review while that step stays before review.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 13:18:33 -06:00
adilalloandCursor bdda522971 Make the selected-card dashed outline thicker so the ants box reads more clearly in the create flow.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 12:33:32 -06:00
an.di 2e995cb5f7 Merge pull request 'Allow publishing a CommunityRule without signing in' (#69) from adilallo/feature/CR-125-guest-publish-without-email into main
Reviewed-on: #69
2026-08-26 17:02:20 +00:00
adilalloandCursor 950b3224ac feat: let guests publish a CommunityRule and claim it on this browser later
Finalize no longer requires a magic link. Guest rows stay off the catalog until sign-in on the same browser attaches ownership, and the login modal kebab no longer acts as a second close.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 11:01:10 -06:00
an.di bf4020005a Merge pull request 'QA pass: layout, create-flow, and About books' (#68) from adilallo/fix/CR-129-create-rule-button into main
Reviewed-on: #68
2026-08-26 15:46:55 +00:00
adilallo d9751d7964 Send FeatureGrid Learn more to the Learn page instead of a dead hash link. 2026-08-25 18:54:54 -06:00
adilalloandCursor 63e0f77998 Show both books on About with matching covers and downloads.
The section mixed Structure Before Crisis art with the Community Rules booklet; pairing each title to its own cover and PDF, and cropping the Figma cover padding, keeps the two cards consistent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 16:18:40 -06:00
adilalloandCursor 8bdd5040c6 Stop treating login/save-progress as a 48-character email limit.
The overlay was failing Zod on preset method-card support text attached with the draft; raise that cap, allow RFC-length emails, and drop Back to home from the overlay.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 16:17:36 -06:00
adilalloandCursor 712437edc7 Align custom policy details with the Figma create modal so the add-field control, copy, and labels match the intended styling.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 09:49:10 -06:00
adilalloandCursor 963fa03c5c Close method-card Save like first add, keep key-resource chips on the approach they were chosen for, and leave expanded See-all stacks in catalog order.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 09:21:07 -06:00
adilalloandCursor b1ca1a748e Let decision-approaches wrap at narrow widths and sync key-resource scopes.
The info-box checkboxes overflowed in a squeezed column; wrapping them and offering those scopes as chips on every approach keeps the sidebar and Applicable Scope selection in sync without publishing unchecked keys as defaults.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 16:27:06 -06:00
adilalloandCursor 38fe0e7e9b Start Add value in the empty custom-policy wizard so Finalize can save a selected chip named from the title.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 16:58:52 -06:00
adilalloandCursor e416f37940 Treat review-page value modals as unchanged when field blocks match the open snapshot so close does not ask to discard a view-only visit.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 16:41:03 -06:00
adilalloandCursor 440b6a9657 Hide unused helper marks on selection cards and modal section labels until tooltip behavior ships.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 16:28:38 -06:00
adilalloandCursor ef60175805 Keep See-all method cards at the compact 142px tile height so expanded rows do not stretch with badges and wrapping copy.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 16:14:27 -06:00
adilalloandCursor 75ba0a214f Keep catalog method section editors after a Customize title override so Core Principle and related copy are not replaced by an empty custom-policy body.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 14:11:21 -06:00
adilalloandCursor 4006fe5c6c Route kebab Customize through the prefilled policy wizard so existing methods and values can be renamed, reordered, and kept in that field order on the card after Finalize.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 13:51:37 -06:00
adilalloandCursor e9951f260b Let core-value meaning and signals be edited on first open with Add Value, confirm discard when closing without adding, and pin Keep editing to the footer back slot.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 21:56:52 -06:00
adilalloandCursor d6d6a5e055 Center lockup-plus-card and card-stack create-flow steps in the nav–footer band so they are not stuck under the header when the content is shorter than the viewport.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 21:10:58 -06:00
adilalloandCursor 3c7fca83b2 Scale About Header inline marks to the 320, 640, and 1440 variants so the 1440 sizes do not apply from tablet up.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 19:01:37 -06:00
adilalloandCursor 75c5ae61b0 Point the About book download at the current Structure Before Crisis PDF instead of the 2021 booklet.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 18:45:22 -06:00
adilalloandCursor 9e698e7ab6 Apply the FAQ Small, Medium, and Large variants at the matching breakpoints so the About accordion is not using desktop inset and type on tablet.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 17:21:47 -06:00
adilalloandCursor 846af91240 Let the About Triple Text row fill the padded width so the columns are not capped at 1440px on wide viewports.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 17:02:55 -06:00
adilalloandCursor c2188015e3 Use each catalog article’s Figma body ornaments instead of one shared pair.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 16:54:53 -06:00
adilalloandCursor 0f16c559c4 Take the article page color from the section banner SVG so the body cannot drift from the hero on current or future posts.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 16:31:10 -06:00
adilalloandCursor 1a014f30f0 Let the content-page article column fill the 1440+ template gutters so it does not stay capped at 904px on wider viewports.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 15:27:16 -06:00
adilalloandCursor f35889f966 Center leftover Learn cards on a short last row and keep thumbnail copy in the top half so it does not cover the artwork.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 14:52:54 -06:00
adilalloandCursor aa4fe47554 Limit the Use Cases tools row to the three catalog articles, make it mouse-draggable instead of auto-advancing, and put card titles on Bricolage Grotesque so they match the design.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 14:29:03 -06:00
adilalloandCursor 55915fd364 Match Triple Text padding and title width to the Figma frames so the section does not over-inset or cap too early.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 13:43:03 -06:00
adilalloandCursor d427d96a83 Cap every page at 1920px so layouts stop stretching on ultrawide viewports.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 13:34:00 -06:00
adilalloandCursor 99c5c28a51 Cap Rule stack section titles at the Figma 3-line width so they wrap instead of stretching on wide viewports.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 13:27:08 -06:00
adilalloandCursor a525b12b5c Keep Triple Step art flush to the section’s right edge.
Drop the mid-width side gutter so the 1:2 ornament column meets the viewport from md up, while copy keeps its left inset.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 11:48:51 -06:00
adilalloandCursor 91f1d1569a Keep the templates button on the Rule stack’s Figma gap.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 09:58:48 -06:00
adilalloandCursor 7ea0a52bf1 Hug the standard header to Create rule with Figma vertical padding.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 09:29:40 -06:00
adilalloandCursor 6820315aaa Clip content-template ornaments at the frame so they do not overlap copy.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 22:11:02 -06:00
adilalloandCursor 63842b31c8 Keep space under the guide logo and align header and body.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 21:36:12 -06:00
adilalloandCursor 73cb73a5af Match Rule card type and section gaps to the template grid.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 20:56:02 -06:00
adilalloandCursor b6cf980d23 Keep process-card numerals upright and match Display type and section gaps.
Rotate only the numbered shapes, use 24/32 and 32/1.1 card copy, and tighten the header-to-CTA stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 20:46:28 -06:00
adilalloandCursor 9769bcc76d Paint hero decoration as a solid darker-accent fill.
Figma uses a normal fill, not opacity, stacked paths, or multiply grain.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 19:09:35 -06:00
adilalloandCursor c0342a3b2d Align hero lockup type with Display and Paragraph styles.
Use 110% heading line-height, 14px subhead under 430, and pin Bricolage optical size so Medium stays Medium.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 18:59:15 -06:00
adilalloandCursor fc5e3e8d6c Keep ticket IDs out of comments, commits, and PR copy.
Branch names still carry the ticket; the code and PR prose should not.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 18:42:22 -06:00
adilalloandCursor 65f7287639 Use filled invert for the top-nav Create rule button.
Matches the Figma Filled Invert variant so the label stays white on the dark pill.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 18:41:50 -06:00
adilalloandCursor 3cdf4174d0 Match partner logo wall spacing and sizes to Figma on every breakpoint (CR-130).
Restore asset/Logo import casing so Turbopack can resolve the module, and align the site lockup gaps with the Figma Asset/Logo instances.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 18:24:54 -06:00
adilalloandCursor 7b50c92ad3 Fix Logo import case so the Linux Docker build can resolve the module.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 19:37:21 -06:00
adilalloandCursor 5be76913ef Bump Cloudron manifest to 0.1.10 for staging deploy.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 19:37:21 -06:00
an.di 0aceb58c13 Merge pull request 'Catch up Storybook preview, coverage, and story hygiene' (#67) from adilallo/maintenance/StorybookCatchup into main
Reviewed-on: #67
2026-08-18 01:32:09 +00:00
adilallo db1581337c chore: catch up Storybook preview, coverage, and story hygiene
Stories were throwing without AuthModal/CreateFlow providers, autodocs was a no-op, and several DS/create-flow screens had no stories.
2026-08-17 19:25:46 -06:00
an.di 1983a67ffd Merge pull request 'Replace remaining hardcoded hex colors with color tokens' (#66) from adilallo/fix/CR-27-color-tokens into main
Reviewed-on: #66
2026-08-17 20:33:55 +00:00
adilallo da453f6108 Replace remaining hardcoded hex colors with color tokens (CR-27)
Map leftover component hex values onto the existing color scale, and leave third-party share brands and the Avatar white-alpha border documented as intentional exceptions.
2026-08-17 14:31:28 -06:00
an.di 705fa428ce Merge pull request 'Adopt remaining typography token classes' (#65) from adilallo/fix/CR-24-typography-tokens into main
Reviewed-on: #65
2026-08-17 20:09:53 +00:00
adilalloandCursor 2321a1e761 Adopt remaining typography token classes (CR-24)
Map exact size/weight/line-height combos onto existing text-* utilities across cards, sections, navigation, modals, controls, and create-flow. Leave unmappable combos hardcoded for design review.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 11:40:10 -06:00
an.di cb0adf2049 Merge pull request 'Add privacy, terms, and cookies pages' (#64) from feature/legal-footer-pages into main
Reviewed-on: #64
2026-08-17 17:16:27 +00:00
adilallo 0167b03231 feat: add privacy, terms, and cookies pages
Footer and login legal links were stubs. Point them at real marketing
pages that follow the Figma content-page template.
2026-08-17 11:15:04 -06:00
an.di 99419915d7 Merge pull request 'Adopt typography token classes in type' (#63) from vinod/cr-24-type-typography-tokens into main
Reviewed-on: #63
2026-08-16 03:22:13 +00:00
adilallo fad06d9e23 Typography fix 2026-08-15 21:21:17 -06:00
an.di 2bc190e0c5 Merge pull request 'Footer fix' (#62) from adilallo/fix/CR-121-footer into main
Reviewed-on: #62
2026-08-15 20:45:10 +00:00
adilallo 4b0feb0811 Footer fix 2026-08-15 14:39:23 -06:00
an.di f6752dd7f3 Merge pull request 'Adjust button hover' (#61) from adilallo/fix/CR-123-button-hover into main
Reviewed-on: #61
2026-08-15 17:15:31 +00:00
adilallo 79bdb97c69 Adjust button hover 2026-08-15 11:12:41 -06:00
adilallo 79b5166fa3 Update CloudronManifest.json 2026-05-26 09:15:19 -06:00
524 changed files with 16309 additions and 5602 deletions
+9 -1
View File
@@ -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.**
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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";
+5
View File
@@ -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"
+6
View File
@@ -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
+7 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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>
), ),
], ],
-7
View File
@@ -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]);
+2
View File
@@ -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
+1 -1
View File
@@ -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.8", "version": "0.1.14",
"httpPort": 3000, "httpPort": 3000,
"healthCheckPath": "/api/health", "healthCheckPath": "/api/health",
"memoryLimit": 805306368, "memoryLimit": 805306368,
+16 -1
View File
@@ -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>
); );
+7
View File
@@ -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 />;
} }
+174 -59
View File
@@ -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
} }
+18 -1
View File
@@ -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"
+11
View File
@@ -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 navfooter 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}
@@ -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}
@@ -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);
} }
@@ -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}
/> />
@@ -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
@@ -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}
/> />
); );
} }
@@ -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;
} }
@@ -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>
); );
})} })}
@@ -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` (320639 / 6401023 / 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 navfooter 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,
+7
View File
@@ -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>;
+1 -1
View File
@@ -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>
);
}
+18
View File
@@ -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
View File
@@ -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;
+128 -19
View File
@@ -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
View File
@@ -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>
); );
+6 -4
View File
@@ -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,
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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}
+6 -4
View File
@@ -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 />;
+13 -13
View File
@@ -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
View File
@@ -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>
</>
);
}
+27 -14
View File
@@ -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
+30 -57
View File
@@ -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}
+16 -16
View File
@@ -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>
+31
View File
@@ -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 -9
View File
@@ -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>
+15 -6
View File
@@ -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>
+12 -4
View File
@@ -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}
+4
View File
@@ -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: () => (
+31
View File
@@ -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}
/>
);
}
+5 -4
View File
@@ -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 }),
);
}} }}
/> />
); );
+7
View File
@@ -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,
+31
View File
@@ -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}
/>
);
}
+4 -3
View File
@@ -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) {
+12 -7
View File
@@ -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