Compare commits

..
98 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
an.di 3de8ffc4ef Merge pull request 'Backend / staging cleanup, performance substrate, and create-flow polish' (#60) from adilallo/Backend/StagingCleanup into main
Reviewed-on: #60
2026-05-26 15:11:46 +00:00
adilallo 62efb6a0cc Fix draft error when logged out 2026-05-26 09:09:11 -06:00
526 changed files with 16458 additions and 5643 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.
## 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.**
+1 -1
View File
@@ -33,7 +33,7 @@ Reach for these before writing new markup:
| `[ value +]` numeric stepper (± label) | `app/components/controls/Incrementer` / `IncrementerBlock` |
| Mid-paragraph "expand / see all" link button | `app/components/buttons/InlineTextButton` |
| Help-icon + label above a control | `app/components/type/InputLabel` (`helpIcon` prop) |
| Toggle chip (dim-but-clickable) | `Chip` with `state="Disabled" disabled={false}` |
| 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"` |
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 |
|---|---|---|---|
| `app/(marketing)/` | `/`, `/learn`, `/blog`, `/templates`, future public pages | Public, indexable | `Top` (via root) + marketing `<Footer />` |
| `app/(app)/` | `/create/*`, `/login`, `/profile`, future signed-in surfaces | Authenticated product | `Top` (via root) — no footer except **`/profile`** (see `profile/layout.tsx`) |
| `app/(admin)/` | `/monitor`, future ops dashboards | Operators | `Top` (via root) — no footer |
| `app/(dev)/` | `/components-preview`, future dev previews | Local dev (NODE_ENV gated) | `Top` (via root) — no footer |
| `app/(marketing)/` | `/`, `/learn`, `/blog`, `/templates`, future public pages | Public, indexable | `SkipToContent` + `ConditionalNavigation` + marketing `<Footer />` |
| `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 | `SkipToContent` + `ConditionalNavigation` — 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/api/` | API routes | n/a | n/a |
@@ -28,14 +28,16 @@ the folder next to `(marketing)/`.
## Layout responsibilities
- **`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
`<main>` — each group layout owns that.
- **`app/(marketing)/layout.tsx`** — wraps with `<main className="flex-1">`
and appends the public `<Footer />`.
- **`app/(app)/layout.tsx`** / **`(admin)/layout.tsx`** / **`(dev)/layout.tsx`**
wrap with `<main className="flex-1">`. No footer by default; **`app/(app)/profile/layout.tsx`**
`<main>` or the header — each group layout owns that.
- **`app/(marketing)/layout.tsx`** — skip-to-content, `ConditionalNavigation`
(SSR session), `<main id="main-content">`, public `<Footer />`.
- **`app/(app)/layout.tsx`** / **`(admin)/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.
- **`app/(dev)/layout.tsx`** — `<main id="main-content">` only.
- **Nested layouts** (e.g. `(app)/create/layout.tsx`) compose feature-specific
chrome inside the group's `<main>` — never render `<html>`, `<body>`,
`<main>`, or providers.
+22 -16
View File
@@ -28,16 +28,17 @@ Do **not** colocate `*.stories.*` next to components. The Storybook config
# File naming
- `<ComponentName>.stories.js` — matches 69/70 existing files.
- Use `.tsx` only when the story genuinely needs types (rare; prefer JS to
match the codebase convention).
- `<ComponentName>.stories.js` — default; keep `.js` unless the story
genuinely needs types.
- Use `.tsx` only when the story uses `Meta` / `StoryObj` (rare).
- Variants get a suffix: `Button.visual.stories.js`,
`Footer.responsive.stories.js`.
# Default export shape (CSF2)
# Default export shape (CSF3)
```javascript
import MyComponent from "../../app/components/<area>/MyComponent";
import { CHIP_PALETTE_OPTIONS } from "../../lib/propNormalization";
export default {
title: "Components/<SubFolder>/MyComponent",
@@ -51,31 +52,32 @@ export default {
},
},
argTypes: {
variant: {
palette: {
control: { type: "select" },
options: ["filled", "outline"],
description: "The variant (Figma prop)",
options: [...CHIP_PALETTE_OPTIONS],
description: "The palette (Figma prop)",
},
onClick: { action: "clicked" },
},
};
export const Default = { args: { variant: "filled" } };
export const Default = { args: { palette: "default" } };
```
## Title hierarchy
- Design-system components → `Components/<SubFolder>/<Name>` (e.g.
`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>`.
## `argTypes`
For every Figma enum prop (`variant`, `size`, `state`, `mode`, `palette`,
…) expose a `select` control listing the **lowercase** option set, sourced
from the matching `*_OPTIONS` const in `lib/propNormalization.ts`. See
`.cursor/rules/component-props.mdc`.
…) expose a `select` control listing the option set, sourced from the matching
`*_OPTIONS` const in `lib/propNormalization.ts`. See
`.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
@@ -83,8 +85,12 @@ from the matching `*_OPTIONS` const in `lib/propNormalization.ts`. See
- `MessagesProvider` with `messages/en` → access copy via `useMessages()`
inside stories exactly like app code. Never hard-code user-facing strings.
- `app/globals.css` + `.font-inter` wrapper → design tokens and fonts are
already present.
- `AuthModalProvider` and `CreateFlowProvider` (same stack as
`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
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`)
Use `storybook/test` for interaction assertions — not `@testing-library/*`
directly. This matches `Checkbox.stories.js` and stays compatible with the
Vitest portable-stories runner in `.storybook/vitest.setup.js`.
directly. This matches `Checkbox.stories.js`. Storybook is documentation;
Vitest component tests remain the source of truth.
```javascript
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.).
# 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).
# In production (e.g. Cloudron localstorage mount), set to the mounted path. Local dev example:
# UPLOAD_ROOT="/absolute/path/to/community-rule/var/uploads"
+6
View File
@@ -44,6 +44,12 @@ npm-cache/
!public/favicon-32x32.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)
!tests/e2e/visual-regression.spec.ts-snapshots/
!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 {
--font-inter:
"Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"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/**/*.stories.@(js|jsx|mjs|ts|tsx)",
],
addons: [
// Removed @storybook/addon-essentials due to version mismatch with Storybook 10.x
// Using individual addons instead. Interaction helpers import from storybook/test
// (bundled with storybook@10); @storybook/addon-interactions was merged into SB 8 core.
"@storybook/addon-a11y",
],
addons: ["@storybook/addon-docs", "@storybook/addon-a11y"],
framework: {
name: "@storybook/nextjs",
options: {},
},
staticDirs: ["../public"],
// Webpack configuration to resolve Next.js modules for Next.js 16 compatibility
async webpackFinal(config) {
// Ensure Next.js modules are resolved correctly
config.resolve = config.resolve || {};
config.resolve.alias = {
...(config.resolve.alias || {}),
};
// 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 "./fonts.css";
import { MINIMAL_VIEWPORTS } from "storybook/viewport";
import { AuthModalProvider } from "../app/contexts/AuthModalContext";
import { MessagesProvider } from "../app/contexts/MessagesContext";
import { CreateFlowProvider } from "../app/(app)/create/context/CreateFlowContext";
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 } */
const preview = {
tags: ["autodocs"],
parameters: {
controls: {
matchers: {
@@ -12,13 +45,32 @@ const preview = {
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: [
(Story) => (
<MessagesProvider messages={messages}>
<div className="font-inter">
<Story />
</div>
<AuthModalProvider>
<CreateFlowProvider>
<div className="font-inter">
<Story />
</div>
</CreateFlowProvider>
</AuthModalProvider>
</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
layouts, not `usePathname()` checks. (`ConditionalNavigation` is the
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
+1 -1
View File
@@ -4,7 +4,7 @@
"title": "Community Rule",
"author": "MEDLab",
"description": "Community governance and rule-building app",
"version": "0.1.8",
"version": "0.1.14",
"httpPort": 3000,
"healthCheckPath": "/api/health",
"memoryLimit": 805306368,
+16 -1
View File
@@ -1,8 +1,16 @@
import type { Metadata } from "next";
import { Suspense, type ReactNode } from "react";
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
import SkipToContent from "../components/navigation/SkipToContent";
import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext";
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).
// See `(app)/layout.tsx` for the matching `<Suspense fallback={null}>` rationale
@@ -14,10 +22,17 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
return (
<MessagesProvider messages={messages}>
<AuthModalProvider>
<SkipToContent />
<Suspense fallback={null}>
<ConditionalNavigation />
</Suspense>
<main className="flex-1">{children}</main>
<main
id={MAIN_CONTENT_ID}
tabIndex={-1}
className="flex-1 outline-none"
>
{children}
</main>
</AuthModalProvider>
</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";
export const metadata: Metadata = routeMetadata("/monitor", {
title: messages.metadata.monitor.title,
});
export default function MonitorPage() {
return <MonitorPageContent />;
}
+174 -59
View File
@@ -23,15 +23,16 @@ import {
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
getNextStep,
getStepIndex,
parseReviewReturnSearchParam,
shouldOfferCreateFlowSaveAndExit,
isDirectTemplateReviewEntry,
createFlowStepUsesSelectSplitScroll,
TEMPLATES_FACET_RECOMMEND_QUERY,
TEMPLATES_FACET_RECOMMEND_VALUE,
isCreateFlowTemplatesPickerPath,
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
} from "./utils/flowSteps";
import {
CREATE_FLOW_COMMUNITY_SAVE_FORM_ID,
CREATE_FLOW_SYNC_DRAFT_QUERY,
CREATE_FLOW_SYNC_DRAFT_VALUE,
CREATE_ROUTES,
@@ -64,12 +65,23 @@ import {
} from "../../../lib/create/publishedDocumentToCreateFlowState";
import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets";
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
import {
clearPendingKeepRuleLogin,
hasPendingKeepRuleLogin,
} from "../../../lib/create/pendingKeepRuleLogin";
import { runCompletedStepExit } from "./utils/runCompletedStepExit";
import messages from "../../../messages/en/index";
import {
CREATE_FLOW_FOOTER_BUTTON_CLASS,
CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS,
} from "./utils/createFlowFooterClassNames";
import {
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 {
CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP,
methodCardFacetSectionForConfirmStep,
@@ -83,15 +95,13 @@ import { SignedInDraftHydration } from "./SignedInDraftHydration";
import { CreateFlowPendingAvatarFlush } from "./components/CreateFlowPendingAvatarFlush";
import Alert from "../../components/modals/Alert";
import Create from "../../components/modals/Create";
import { MODAL_FOOTER_START_SLOT_CLASS } from "../../components/modals/ModalFooter";
import Share from "../../components/modals/Share";
import {
CreateFlowDraftSaveBannerProvider,
useCreateFlowDraftSaveBanner,
} from "./context/CreateFlowDraftSaveBannerContext";
/** First step where Save & Exit is offered (first Create Community select per Figma). */
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-structure");
function CreateFlowSessionShell({ children }: { children: ReactNode }) {
const [sessionUser, setSessionUser] = useState<
{ id: string; email: string } | null | undefined
@@ -109,9 +119,9 @@ function CreateFlowSessionShell({ children }: { children: ReactNode }) {
const sessionResolved = sessionUser !== undefined;
// Mirror in-progress draft to localStorage for ALL visitors once we know who
// they are. Refresh-survival is the same UX for guest and signed-in users;
// signed-in users additionally get an explicit "Save & Exit" that PUTs to
// the server (handled in `useCreateFlowExit`).
// they are. Refresh-survival is the same UX for guest and signed-in users.
// Save & Exit: guests open the save-progress login modal; signed-in users
// PUT the server draft (`useCreateFlowExit`).
const enableLocalDraftMirroring = sessionResolved;
return (
@@ -152,9 +162,9 @@ function CreateFlowLayoutContent({
const {
currentStep,
nextStep,
previousStep,
goToNextStep,
goToPreviousStep,
canGoBack,
templateReviewFooterBackToCreateReview,
} = useCreateFlowNavigation(
skipCommunitySave ? { skipCommunitySave: true } : undefined,
@@ -189,12 +199,14 @@ function CreateFlowLayoutContent({
useState(false);
const [completedFlowBanner, setCompletedFlowBanner] = useState<{
key: string;
status: "positive" | "danger";
status: "positive" | "danger" | "warning";
title: string;
description?: string;
} | null>(null);
const [shareModalOpen, setShareModalOpen] = useState(false);
const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false);
const [completedHasPublishedRule, setCompletedHasPublishedRule] =
useState(false);
const leaveConfirmResolverRef = useRef<((proceed: boolean) => void) | null>(
null,
);
@@ -255,8 +267,30 @@ function CreateFlowLayoutContent({
openLogin,
updateState,
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 {
isTemplateReviewRoute,
templateReviewSlug,
@@ -271,7 +305,11 @@ function CreateFlowLayoutContent({
updateState,
replaceState,
router,
fromCreateWizard,
markCreateFlowInteraction,
});
const isCreateTemplatesPickerRoute =
isCreateFlowTemplatesPickerPath(pathname);
const runAuthenticatedExit = useCreateFlowExit({
state,
@@ -284,14 +322,34 @@ function CreateFlowLayoutContent({
});
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;
// Exit from `/create/completed` is post-publish: the rule is saved, so we
// skip the leave-confirm + login prompt and just wipe the in-flight draft.
// For signed-in users we also DELETE the server draft so a future visit to
// /create starts fresh instead of rehydrating yesterday's work.
// Completed is post-publish. Guests still need Save & Exit → keep-this-rule
// login (the document is live but unclaimed). Signed-in users just leave.
if (currentStep === "completed") {
if (sessionUser === null) {
openLogin({
variant: "keepRule",
nextPath: CREATE_ROUTES.completed,
backdropVariant: "blurredYellow",
onDismiss: () => {
runCompletedStepExit({
clearState,
clearAnonymousCreateFlowStorage,
router,
});
},
});
return;
}
runCompletedStepExit({
clearState,
clearAnonymousCreateFlowStorage,
@@ -301,7 +359,6 @@ function CreateFlowLayoutContent({
}
if (sessionUser === null) {
if (saveDraft) return;
const returnToTemplateReview =
templateReviewSlug != null
? `/create/review-template/${encodeURIComponent(templateReviewSlug)}?syncDraft=1`
@@ -407,15 +464,54 @@ function CreateFlowLayoutContent({
useEffect(() => {
if (currentStep !== "completed") {
setCompletedFlowBanner(null);
setCompletedHasPublishedRule(false);
return;
}
setCompletedHasPublishedRule(Boolean(readLastPublishedRule()));
}, [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);
setCommunitySaveMagicLinkSuccess(false);
const raw = state.communitySaveEmail;
const trimmed = typeof raw === "string" ? raw.trim().toLowerCase() : "";
if (!isValidCreateFlowSaveEmail(trimmed)) return;
const namedEmail = formEl?.elements.namedItem("email");
const emailField =
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);
try {
@@ -465,16 +561,17 @@ function CreateFlowLayoutContent({
const isSelectSplitScrollStep = createFlowStepUsesSelectSplitScroll(
currentStep,
);
const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
/** At `md+`, main cross-axis: center by default; exceptions stay top-aligned (see product spec). */
/** Lockup+card / card-stack: `items-start` + shell `my-auto` so overflow scrolls from the top. */
const mainContentClass = isCompletedStep
? "items-stretch overflow-y-auto md:overflow-hidden"
: isSelectSplitScrollStep
? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden"
: isFinalReviewLike || isCardLayoutStep || isTemplateReviewRoute
? "items-start justify-center overflow-y-auto"
: "items-start justify-center overflow-y-auto md:items-center";
: isCreateTemplatesPickerRoute
? "items-start justify-start overflow-y-auto"
: isTemplateReviewRoute || isFinalReviewLike || isCardLayoutStep
? CREATE_FLOW_MD_CENTERED_MAIN_CLASS
: "items-start justify-center overflow-y-auto md:items-center";
const isTextStep = createFlowStepUsesCenteredTextLayout(currentStep);
const mainMaxMdJustify =
@@ -486,8 +583,8 @@ function CreateFlowLayoutContent({
: "max-md:flex-col max-md:items-center";
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
const saveDraftOnExit =
Boolean(sessionUser) &&
(stepIdx >= SAVE_EXIT_FROM_STEP_INDEX || currentStep === "edit-rule");
shouldOfferCreateFlowSaveAndExit(currentStep, sessionUser) ||
isCreateTemplatesPickerRoute;
const proportionBarProgress = getProportionBarProgressForCreateFlowStep(
currentStep,
@@ -516,7 +613,7 @@ function CreateFlowLayoutContent({
*/
const topBanners: Array<{
key: string;
status: "danger" | "positive";
status: "danger" | "positive" | "warning";
title: string;
description?: string;
onClose: () => void;
@@ -535,7 +632,7 @@ function CreateFlowLayoutContent({
key: "publish",
status: "danger" as const,
title:
messages.create.reviewAndComplete.publish.finalizeBannerTitle,
messages.create.reviewAndComplete.publish.publishBannerTitle,
description: publishBannerMessage,
onClose: () => setPublishBannerMessage(null),
}
@@ -579,10 +676,10 @@ function CreateFlowLayoutContent({
].filter((b): b is NonNullable<typeof b> => b !== null);
return (
<div className="relative flex h-screen min-h-0 flex-col overflow-hidden bg-black">
<div className={`${CREATE_FLOW_VIEWPORT_FRAME_CLASS} bg-black`}>
{topBanners.length > 0 ? (
<div
className="pointer-events-none fixed left-0 right-0 top-0 z-[200] flex flex-col gap-2 px-[var(--spacing-measures-spacing-500,20px)] pt-[var(--spacing-measures-spacing-300,12px)] md:px-[var(--measures-spacing-1800,64px)]"
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"
>
{topBanners.map((b) => (
@@ -636,14 +733,16 @@ function CreateFlowLayoutContent({
nextButtonText={messages.create.topNav.leaveConfirmProceed}
onNext={() => closeLeaveConfirm(true)}
footerContent={
<Button
buttonType="ghost"
palette="default"
size="xsmall"
onClick={() => closeLeaveConfirm(false)}
>
{messages.create.topNav.leaveConfirmCancel}
</Button>
<div className={MODAL_FOOTER_START_SLOT_CLASS}>
<Button
buttonType="ghost"
palette="default"
size="xsmall"
onClick={() => closeLeaveConfirm(false)}
>
{messages.create.topNav.leaveConfirmCancel}
</Button>
</div>
}
backdropVariant="blurredYellow"
ariaLabel={messages.create.topNav.leaveConfirmTitle}
@@ -651,7 +750,10 @@ function CreateFlowLayoutContent({
<CreateFlowTopNav
hasShare={isCompletedStep}
hasExport={isCompletedStep}
hasEdit={isCompletedStep}
hasEdit={
isCompletedStep &&
(Boolean(sessionUser) || completedHasPublishedRule)
}
hasManageStakeholders={isEditRuleStep}
saveDraftOnExit={saveDraftOnExit}
onShare={
@@ -693,16 +795,32 @@ function CreateFlowLayoutContent({
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : ""
}`.trim()}
/>
{currentStep === "community-save" ? (
<form
id={CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}
className="hidden"
onSubmit={(e) => {
e.preventDefault();
void handleCommunitySaveMagicLinkSubmit(e.currentTarget);
}}
/>
) : null}
<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}
</main>
{!isCompletedStep && (
<CreateFlowFooter
className="shrink-0"
contentMaxClass={getCreateFlowContentMaxClass({
step: currentStep,
isTemplateReview: isTemplateReviewRoute,
isTemplatesPicker: isCreateTemplatesPickerRoute,
})}
progressBar={
!isTemplateReviewRoute &&
!isCreateTemplatesPickerRoute &&
!isFinalReviewLike &&
reviewReturnTarget !== "edit-rule"
}
@@ -764,6 +882,8 @@ function CreateFlowLayoutContent({
{footer.saveLater}
</Button>
<Button
type="submit"
form={CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}
buttonType="filled"
palette="default"
size="xsmall"
@@ -774,9 +894,6 @@ function CreateFlowLayoutContent({
!isValidCreateFlowSaveEmail(state.communitySaveEmail)
}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
void handleCommunitySaveMagicLinkSubmit();
}}
>
{communitySaveMagicLinkSubmitting
? footer.submitEmailSending
@@ -807,15 +924,7 @@ function CreateFlowLayoutContent({
disabled={isPublishing}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
// `fromFlow=1` tells `/templates` to skip the fresh-slate
// draft clear it normally runs on template click, so the
// user's in-progress Create Community stage survives this
// detour. Direct entries to `/templates` (no marker) and
// home "Popular templates" clicks always start fresh by
// wiping anonymous draft storage at click time.
router.push(
`/templates?${TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY}=${TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE}&${TEMPLATES_FACET_RECOMMEND_QUERY}=${TEMPLATES_FACET_RECOMMEND_VALUE}`,
);
router.push(CREATE_ROUTES.templatesPicker);
}}
>
{footer.createFromTemplate}
@@ -878,7 +987,9 @@ function CreateFlowLayoutContent({
buttonType="filled"
palette="default"
size="xsmall"
disabled={isPublishing}
disabled={
isPublishing || (isFinalReviewLike && !sessionResolved)
}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
if (isFinalReviewLike) {
@@ -891,14 +1002,18 @@ function CreateFlowLayoutContent({
{isFinalReviewLike
? isPublishing
? messages.create.reviewAndComplete.publish
.finalizeButtonPublishing
: footer.finalizeCommunityRule
.publishButtonPublishing
: footer.publishCommunityRule
: getDefaultFooterLabel(currentStep, footer)}
</Button>
) : null
}
onBackClick={
isTemplateReviewRoute
isCreateTemplatesPickerRoute
? () => {
router.push(CREATE_ROUTES.review);
}
: isTemplateReviewRoute
? () =>
router.push(
templateReviewFooterBackToCreateReview
@@ -914,7 +1029,7 @@ function CreateFlowLayoutContent({
),
);
}
: previousStep
: canGoBack()
? goToPreviousStep
: undefined
}
+18 -1
View File
@@ -11,9 +11,11 @@ import {
import { useCreateFlow } from "./context/CreateFlowContext";
import { fetchDraftFromServer } from "../../../lib/create/api";
import messages from "../../../messages/en/index";
import { CREATE_FLOW_PAGE_GUTTER_CLASS } from "./components/createFlowLayoutTokens";
import Alert from "../../components/modals/Alert";
import {
isValidStep,
isCreateFlowTemplatesPickerPath,
parseCreateFlowScreenFromPathname,
} from "./utils/flowSteps";
import { hasFreshEntryPending } from "./utils/prepareFreshCreateFlowEntry";
@@ -102,6 +104,21 @@ export function SignedInDraftHydration({
if (urlStep === "completed") {
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;
setLoadingHydration(true);
@@ -149,7 +166,7 @@ export function SignedInDraftHydration({
if (!loadingHydration) return null;
return (
<div className="pointer-events-none fixed left-0 right-0 top-14 z-[170] flex justify-center px-[var(--spacing-measures-spacing-500,20px)] pt-2 md:top-16 md:px-[var(--measures-spacing-1800,64px)]">
<div className={`pointer-events-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]">
<Alert
type="banner"
+11
View File
@@ -1,7 +1,10 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { CreateFlowScreenView } from "../screens/CreateFlowScreenView";
import { createFlowStepPath, CREATE_ROUTES } from "../utils/createFlowPaths";
import { isValidStep } from "../utils/flowSteps";
import type { CreateFlowStep } from "../types";
import { routeMetadata } from "../../../../lib/siteMetadata";
/**
* Single dynamic route for the whole create wizard (every step in `FLOW_STEP_ORDER`).
@@ -14,6 +17,14 @@ interface PageProps {
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) {
const { screenId: raw } = await params;
@@ -70,20 +70,26 @@ function ApplicableScopeFieldComponent({
return (
<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">
{scopes.map((scope) => {
const isSelected = selectedScopes.includes(scope);
const chipState = isSelected
? "selected"
: readOnly
? "disabled"
: "unselected";
return (
<Chip
key={scope}
label={scope}
state={isSelected ? "selected" : "disabled"}
state={chipState}
palette="default"
size="s"
disabled={readOnly}
onClick={() => !readOnly && onToggleScope(scope)}
ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`}
className="max-w-full"
/>
);
})}
@@ -105,13 +111,13 @@ function ApplicableScopeFieldComponent({
}}
placeholder={inputPlaceholder ?? addLabel}
aria-label={inputPlaceholder ?? addLabel}
className="h-[30px] rounded-[9999px] border border-[var(--color-border-default-tertiary)] bg-transparent px-3 font-inter text-[length:var(--sizing-300,12px)] font-medium leading-[14px] text-[color:var(--color-content-default-primary)] outline-none placeholder:text-[color:var(--color-content-default-tertiary)] focus-visible:border-[var(--color-border-default-brand-primary)]"
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
type="button"
onClick={() => setIsAdding(true)}
className="inline-flex items-center gap-[var(--measures-spacing-050,2px)] rounded-[var(--measures-radius-full,9999px)] px-[var(--space-250,10px)] py-[var(--measures-spacing-200,8px)] font-inter text-[length:var(--sizing-300,12px)] font-medium leading-[14px] text-[color:var(--color-content-default-primary)] hover:bg-[var(--color-surface-default-secondary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-transparent"
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 />
{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 { CreateFlowStepShell } from "./CreateFlowStepShell";
import {
CREATE_FLOW_MD_CENTERED_SHELL_CLASS,
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "./createFlowLayoutTokens";
@@ -18,14 +19,18 @@ type CreateFlowLockupCardStepShellProps = {
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({
lockupTitle,
lockupDescription,
children,
}: CreateFlowLockupCardStepShellProps) {
return (
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
<CreateFlowStepShell
variant="wideGrid"
contentTopBelowMd="space-800"
className={CREATE_FLOW_MD_CENTERED_SHELL_CLASS}
>
<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}`}
>
@@ -2,7 +2,10 @@
import { useEffect, useRef } from "react";
import { useCreateFlow } from "../context/CreateFlowContext";
import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer";
import {
CreateFlowUploadValidationError,
uploadCreateFlowFile,
} from "../../../../lib/create/uploadToServer";
import {
clearPendingCommunityAvatarFile,
readPendingCommunityAvatarFile,
@@ -19,13 +22,17 @@ export function CreateFlowPendingAvatarFlush({
sessionUser: { id: string; email: string } | null | undefined;
sessionResolved: boolean;
}) {
const { updateState } = useCreateFlow();
const { state, updateState } = useCreateFlow();
/** One successful flush per signed-in user id (survives React StrictMode remounts). */
const lastFlushedUserIdRef = useRef<string | null>(null);
const hasServerAvatar =
typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0;
useEffect(() => {
if (!sessionResolved || !sessionUser) return;
if (lastFlushedUserIdRef.current === sessionUser.id) return;
if (hasServerAvatar) return;
let cancelled = false;
void (async () => {
@@ -37,15 +44,18 @@ export function CreateFlowPendingAvatarFlush({
await clearPendingCommunityAvatarFile();
updateState({ communityAvatarUrl: url });
lastFlushedUserIdRef.current = sessionUser.id;
} catch {
// Leave pending blob in place so the user can retry after fixing auth / UPLOAD_ROOT.
} catch (err) {
if (err instanceof CreateFlowUploadValidationError) {
await clearPendingCommunityAvatarFile();
}
// Leave a transient (auth / UPLOAD_ROOT) failure in place to retry.
}
})();
return () => {
cancelled = true;
};
}, [sessionResolved, sessionUser, updateState]);
}, [hasServerAvatar, sessionResolved, sessionUser, updateState]);
return null;
}
@@ -1,6 +1,7 @@
"use client";
import type { ReactNode } from "react";
import { CREATE_FLOW_PAGE_GUTTER_CLASS } from "./createFlowLayoutTokens";
export type CreateFlowStepShellVariant =
| "centeredNarrow"
@@ -13,15 +14,12 @@ export type CreateFlowStepShellVariant =
export type CreateFlowContentTopBelowMd = "none" | "space-1400" | "space-800";
const outerByVariant: Record<CreateFlowStepShellVariant, string> = {
centeredNarrow:
"flex w-full min-w-0 flex-col items-center px-5 md:px-16",
centeredNarrowBottomPad:
"flex w-full min-w-0 flex-col items-center px-5 pb-28 md:px-[var(--measures-spacing-1800,64px)] md:pb-32",
/** Wide two-column steps; 1328px = two 640px columns + 48px gutter. */
wideGrid: "w-full min-w-0 max-w-[1328px] shrink-0 px-5 md:px-12",
/** Create Community review + card grid (Figma Flow — Review `19706:12135`): max width 1440. */
wideGridLoosePadding:
"w-full min-w-0 max-w-[1440px] shrink-0 px-5 md:px-16",
centeredNarrow: `flex w-full min-w-0 flex-col items-center ${CREATE_FLOW_PAGE_GUTTER_CLASS}`,
centeredNarrowBottomPad: `flex w-full min-w-0 flex-col items-center ${CREATE_FLOW_PAGE_GUTTER_CLASS} pb-28 md:pb-32`,
/** Wide two-column steps; inner content supplies the 1328px cap. */
wideGrid: `flex w-full min-w-0 shrink-0 flex-col items-center ${CREATE_FLOW_PAGE_GUTTER_CLASS}`,
/** Create Community review + card grid; inner content supplies the 1440px cap. */
wideGridLoosePadding: `flex w-full min-w-0 shrink-0 flex-col items-center ${CREATE_FLOW_PAGE_GUTTER_CLASS}`,
bare: "w-full min-w-0",
};
@@ -41,7 +39,8 @@ interface CreateFlowStepShellProps {
/**
* 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({
children,
@@ -5,7 +5,11 @@ import {
CreateFlowStepShell,
type CreateFlowContentTopBelowMd,
} 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";
@@ -26,10 +30,10 @@ interface CreateFlowTwoColumnSelectShellProps {
}
/**
* Two-column layout for create-flow select steps (community size/structure, core values) and
* {@link DecisionApproachesScreen} (decision approaches). Below `lg` (1024px), one column + main scrolls.
* At `lg+`, mirrors {@link CompletedScreen}: static header column + scrollable controls column
* (`min-h-0` + `overflow-y-auto` height chain; see completed page right rail).
* Question-and-options layout for create-flow select steps (community size/structure,
* core values, stakeholders) and {@link DecisionApproachesScreen}.
* Below `lg` (1024px), one column uses available width and main scrolls.
* At `lg+`, static header column + scrollable options column (`min-h-0` + overflow).
*/
export function CreateFlowTwoColumnSelectShell({
header,
@@ -56,24 +60,24 @@ export function CreateFlowTwoColumnSelectShell({
>
<div
className={
"flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] md:max-w-[640px] " +
"max-lg:flex-none lg:max-h-full lg:max-w-[1328px] lg:min-h-0 lg:flex-1 lg:flex-row lg:flex-nowrap " +
`${rowLgCrossAlignClass} lg:justify-center lg:gap-[var(--measures-spacing-1200,48px)] lg:overflow-hidden`
"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: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 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`
}
>
<div
className={
`flex w-full min-w-0 shrink-0 flex-col items-start gap-[var(--measures-spacing-200,8px)] ` +
`lg:flex-1 ${leftLgMainJustifyClass} lg:py-[12px] lg:max-w-[640px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`
`lg:flex-1 ${leftLgMainJustifyClass} lg:py-[12px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`
}
>
{header}
</div>
<div
className={
`scrollbar-hide relative flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-800,32px)] ` +
`relative flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-800,32px)] ` +
`overflow-x-hidden lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pb-[var(--measures-spacing-300,12px)] ` +
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS
`${CREATE_FLOW_SCROLL_REGION_CLASS} ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`
}
>
{children}
@@ -60,7 +60,6 @@ function CustomMethodCardFieldBlocksSummaryViewComponent({
<div key={block.id} className="flex flex-col gap-2">
<InputLabel
label={block.blockTitle}
helpIcon
size="s"
palette="default"
/>
@@ -123,7 +122,6 @@ function CustomMethodCardFieldBlocksSummaryViewComponent({
<div className="flex flex-col gap-2">
<InputLabel
label={block.blockTitle}
helpIcon
size="s"
palette="default"
/>
@@ -169,6 +167,7 @@ function CustomMethodCardFieldBlocksSummaryViewComponent({
<IncrementerBlock
key={block.id}
label={block.blockTitle}
helpIcon={false}
value={block.defaultPercent}
min={1}
max={100}
@@ -3,7 +3,10 @@
import { memo, useCallback, useRef, useState } from "react";
import { useTranslation } from "../../../../contexts/MessagesContext";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import {
createFlowUploadFailureMessageKey,
uploadCreateFlowFile,
} from "../../../../../lib/create/uploadToServer";
import { CustomMethodCardUploadBlockRowView } from "./CustomMethodCardUploadBlockRow.view";
import type { CustomMethodCardUploadBlockRowProps } from "./CustomMethodCardFieldBlocksSummary.types";
@@ -68,8 +71,8 @@ function CustomMethodCardUploadBlockRowContainerComponent({
: b,
),
);
} catch {
setErrorMessage(tUpload("errors.generic"));
} catch (err) {
setErrorMessage(tUpload(createFlowUploadFailureMessageKey(err)));
} finally {
setBusy(false);
}
@@ -4,6 +4,7 @@ import { memo } from "react";
import Upload from "../../../../components/controls/Upload";
import InputLabel from "../../../../components/type/InputLabel";
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
import { CUSTOM_ATTACHMENT_ACCEPT } from "../../../../../lib/create/createFlowUploadValidation";
import type { CustomMethodCardUploadBlockRowViewProps } from "./CustomMethodCardFieldBlocksSummary.types";
function CustomMethodCardUploadBlockRowViewComponent({
@@ -30,7 +31,6 @@ function CustomMethodCardUploadBlockRowViewComponent({
<div className="flex flex-col gap-2">
<InputLabel
label={block.blockTitle}
helpIcon
size="s"
palette="default"
/>
@@ -44,7 +44,7 @@ function CustomMethodCardUploadBlockRowViewComponent({
type="file"
className="sr-only"
tabIndex={-1}
accept="image/jpeg,image/png,image/webp,image/gif,application/pdf"
accept={CUSTOM_ATTACHMENT_ACCEPT}
aria-label={uploadFileInputAriaLabel}
onChange={onFileInputChange}
/>
@@ -77,6 +77,7 @@ function CustomMethodCardUploadBlockRowViewComponent({
) : (
<Upload
active={!busy}
showHelpIcon={false}
hintText={busy ? uploadingHint : uploadHint}
onClick={onUploadClick}
/>
@@ -5,8 +5,14 @@ import {
useMessages,
useTranslation,
} from "../../../../contexts/MessagesContext";
import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
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 { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.types";
import { CustomMethodCardWizardView } from "./CustomMethodCardWizard.view";
@@ -17,12 +23,13 @@ import type { CustomMethodCardWizardProps } from "./CustomMethodCardWizard.types
* `20066:14748`, `20094:48551`, `20066:14361`).
*/
const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
({ isOpen, onClose, onFinalize, onPersistCustomUploadFile }) => {
({ isOpen, onClose, onFinalize, onPersistCustomUploadFile, initialValues }) => {
const m = useMessages();
const t = useTranslation("common");
const tUpload = useTranslation("create.upload");
const w = m.create.customRule.customMethodCardWizard;
const menuCopy = m.create.customRule.modalKebabMenu;
const { requestConfirm, confirmDialog } = useAsyncConfirm();
const copy = useMemo(
() => ({
@@ -68,6 +75,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
CustomMethodCardFieldBlock[]
>([]);
const [editingBlockId, setEditingBlockId] = useState<string | null>(null);
const [textBlockTitle, setTextBlockTitle] = useState("");
const [textPlaceholderBody, setTextPlaceholderBody] = useState("");
@@ -88,6 +96,14 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
const [proportionDefault, setProportionDefault] = useState(50);
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(() => {
setTextBlockTitle("");
@@ -112,21 +128,114 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
setPolicyDescription("");
setAddFieldExpanded(false);
setFieldTypeModal(null);
setEditingBlockId(null);
setDraftFieldBlocks([]);
openSnapshotRef.current = null;
fieldModalSnapshotRef.current = null;
resetFieldTypeDrafts();
}, [resetFieldTypeDrafts]);
useEffect(() => {
if (!isOpen) {
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(() => {
reset();
onClose();
}, [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 descriptionTrim = policyDescription.trim();
@@ -136,7 +245,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
titleTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS;
const descriptionOk =
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 === 2) return descriptionOk;
return titleOk && descriptionOk;
@@ -213,7 +322,9 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
const shellDescription = fieldModalHeader?.description ?? headerDescription;
const nextLabel = fieldTypeModal
? copy.fieldModals.addField
? editingBlockId
? copy.fieldModals.saveField
: copy.fieldModals.addField
: wizardStep === 3
? copy.footerFinalize
: t("buttons.next");
@@ -222,33 +333,125 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
? !fieldModalStepValid
: !stepValid;
const handleShellClose = useCallback(() => {
const handleShellClose = useCallback(async () => {
if (fieldTypeModal) {
if (
fieldModalSnapshotRef.current != null &&
fieldModalDraftSignature() !== fieldModalSnapshotRef.current &&
!(await confirmAbandonWizardEdits())
) {
return;
}
setFieldTypeModal(null);
setEditingBlockId(null);
fieldModalSnapshotRef.current = null;
return;
}
if (!(await confirmAbandonWizardEdits())) {
return;
}
dismiss();
}, [dismiss, fieldTypeModal]);
}, [
confirmAbandonWizardEdits,
dismiss,
fieldModalDraftSignature,
fieldTypeModal,
]);
const kebabMenuItems = useMemo<ModalHeaderMenuItem[]>(() => [], []);
const handleBack = useCallback(() => {
const handleBack = useCallback(async () => {
if (fieldTypeModal) {
if (
fieldModalSnapshotRef.current != null &&
fieldModalDraftSignature() !== fieldModalSnapshotRef.current &&
!(await confirmAbandonWizardEdits())
) {
return;
}
setFieldTypeModal(null);
setEditingBlockId(null);
fieldModalSnapshotRef.current = null;
return;
}
if (wizardStep === 1) {
if (!(await confirmAbandonWizardEdits())) {
return;
}
dismiss();
return;
}
setWizardStep((s) => (s === 2 ? 1 : 2));
}, [dismiss, fieldTypeModal, wizardStep]);
}, [
confirmAbandonWizardEdits,
dismiss,
fieldModalDraftSignature,
fieldTypeModal,
wizardStep,
]);
const handleSelectFieldType = useCallback((ft: AddCustomFieldType) => {
setEditingBlockId(null);
setAddFieldExpanded(false);
resetFieldTypeDrafts();
setFieldTypeModal(ft);
fieldModalSnapshotRef.current = JSON.stringify({
fieldTypeModal: ft,
textBlockTitle: "",
textPlaceholderBody: "",
badgeBlockTitle: "",
badgeOptions: [],
uploadBlockTitle: "",
uploadFileName: undefined,
uploadAssetUrl: undefined,
proportionBlockTitle: "",
proportionDefault: 50,
});
}, [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(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -260,8 +463,9 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
try {
const { url } = await onPersistCustomUploadFile(file);
setUploadAssetUrl(url);
} catch {
setUploadFieldError(tUpload("errors.generic"));
} catch (err) {
setUploadFileName(undefined);
setUploadFieldError(tUpload(createFlowUploadFailureMessageKey(err)));
} finally {
setUploadFieldBusy(false);
}
@@ -285,9 +489,9 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
);
}, []);
const appendFieldBlock = useCallback(() => {
const commitFieldBlock = useCallback(() => {
if (!fieldTypeModal || !fieldModalStepValid) return;
const id = crypto.randomUUID();
const id = editingBlockId ?? crypto.randomUUID();
let block: CustomMethodCardFieldBlock;
switch (fieldTypeModal) {
case "text":
@@ -325,11 +529,20 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
defaultPercent: proportionDefault,
};
}
setDraftFieldBlocks((prev) => [...prev, block]);
setDraftFieldBlocks((prev) => {
if (editingBlockId) {
return prev.map((row) => (row.id === editingBlockId ? block : row));
}
return [...prev, block];
});
setFieldTypeModal(null);
setEditingBlockId(null);
setAddFieldExpanded(false);
fieldModalSnapshotRef.current = null;
}, [
badgeBlockTitle,
badgeOptions,
editingBlockId,
fieldModalStepValid,
fieldTypeModal,
proportionBlockTitle,
@@ -343,7 +556,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
const handleNext = useCallback(() => {
if (fieldTypeModal) {
appendFieldBlock();
commitFieldBlock();
return;
}
if (!stepValid) return;
@@ -358,7 +571,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
}
setWizardStep((s) => (s === 1 ? 2 : 3));
}, [
appendFieldBlock,
commitFieldBlock,
descriptionTrim,
dismiss,
draftFieldBlocks,
@@ -370,6 +583,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
]);
return (
<>
<CustomMethodCardWizardView
isOpen={isOpen}
onDismiss={handleShellClose}
@@ -380,7 +594,8 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
policyDescription={policyDescription}
addFieldExpanded={addFieldExpanded}
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}
onPolicyDescriptionChange={setPolicyDescription}
onPressAddCustomField={() => setAddFieldExpanded(true)}
@@ -420,10 +635,13 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
stepper={!fieldTypeModal}
draftFieldBlocks={draftFieldBlocks}
onDraftFieldBlocksReorder={setDraftFieldBlocks}
onEditFieldBlock={handleEditFieldBlock}
kebabMoreOptionsAriaLabel={menuCopy.triggerAriaLabel}
kebabMenuAriaLabel={menuCopy.menuAriaLabel}
kebabMenuItems={kebabMenuItems}
/>
{confirmDialog}
</>
);
},
);
@@ -2,6 +2,7 @@ import type { RefObject } from "react";
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
import type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.types";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
export interface CustomMethodCardWizardFieldBodiesCopy {
requiredHint: string;
@@ -47,6 +48,7 @@ export interface CustomMethodCardWizardCopy {
footerFinalize: string;
fieldModals: {
addField: string;
saveField: string;
requiredHint: string;
text: CustomMethodCardWizardFieldBodiesCopy["text"] & {
title: string;
@@ -70,6 +72,8 @@ export interface CustomMethodCardWizardCopy {
export interface CustomMethodCardWizardProps {
isOpen: boolean;
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. */
onFinalize: (payload: {
title: string;
@@ -123,7 +127,8 @@ export interface CustomMethodCardWizardViewProps {
policyDescription: string;
addFieldExpanded: boolean;
copy: CustomMethodCardWizardCopy;
maxChars: number;
maxTitleChars: number;
maxDescriptionChars: number;
onPolicyTitleChange: (v: string) => void;
onPolicyDescriptionChange: (v: string) => void;
onPressAddCustomField: () => void;
@@ -136,6 +141,7 @@ export interface CustomMethodCardWizardViewProps {
>;
draftFieldBlocks: CustomMethodCardFieldBlock[];
onDraftFieldBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void;
onEditFieldBlock: (_block: CustomMethodCardFieldBlock) => void;
nextDisabled: boolean;
nextLabel: string;
showBackButton: boolean;
@@ -19,7 +19,8 @@ function CustomMethodCardWizardViewComponent({
policyDescription,
addFieldExpanded,
copy,
maxChars,
maxTitleChars,
maxDescriptionChars,
onPolicyTitleChange,
onPolicyDescriptionChange,
onPressAddCustomField,
@@ -35,6 +36,7 @@ function CustomMethodCardWizardViewComponent({
stepper,
draftFieldBlocks,
onDraftFieldBlocksReorder,
onEditFieldBlock,
kebabMoreOptionsAriaLabel,
kebabMenuAriaLabel,
kebabMenuItems,
@@ -71,24 +73,26 @@ function CustomMethodCardWizardViewComponent({
placeholder={copy.step1.fieldPlaceholder}
value={policyTitle}
onChange={onPolicyTitleChange}
maxLength={maxChars}
maxLength={maxTitleChars}
/>
) : null}
{!fieldTypeModal && wizardStep === 2 ? (
<TextArea
appearance="default"
formHeader={false}
showHelpIcon={false}
label={copy.step2.title}
placeholder={copy.step2.fieldPlaceholder}
value={policyDescription}
maxLength={maxChars}
maxLength={maxDescriptionChars}
onChange={(e) => onPolicyDescriptionChange(e.target.value)}
textHint={`${policyDescription.length}/${maxChars}`}
textHint={`${policyDescription.length}/${maxDescriptionChars}`}
className="w-full"
rows={4}
/>
) : null}
{!fieldTypeModal && wizardStep === 3 ? (
<div className="flex w-full flex-col gap-4">
<div className="flex w-full flex-col gap-6">
{draftFieldBlocks.length > 0 ? (
<CustomMethodCardWizardBlocksList
blocks={draftFieldBlocks}
@@ -96,6 +100,7 @@ function CustomMethodCardWizardViewComponent({
dragHandleAriaLabel={copy.step3BlocksList.dragHandleAriaLabel}
listLabel={copy.step3BlocksList.listLabel}
onBlocksReorder={onDraftFieldBlocksReorder}
onEditBlock={onEditFieldBlock}
/>
) : null}
<AddCustomField
@@ -1,6 +1,6 @@
"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 { CustomMethodCardWizardBlocksListView } from "./CustomMethodCardWizardBlocksList.view";
import type { CustomMethodCardWizardBlocksListProps } from "./CustomMethodCardWizardBlocksList.types";
@@ -11,19 +11,33 @@ function CustomMethodCardWizardBlocksListContainerComponent({
dragHandleAriaLabel,
listLabel,
onBlocksReorder,
onEditBlock,
}: CustomMethodCardWizardBlocksListProps) {
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
const [overIndex, setOverIndex] = useState<number | null>(null);
const draggingIndexRef = useRef<number | null>(null);
const dragFromHandleRef = useRef(false);
const clearDragUi = useCallback(() => {
draggingIndexRef.current = null;
dragFromHandleRef.current = false;
setDraggingIndex(null);
setOverIndex(null);
}, []);
const handleHandlePointerDown = useCallback(() => {
dragFromHandleRef.current = true;
}, []);
const handleDragStart = useCallback(
(index: number) => (e: DragEvent) => {
if (!dragFromHandleRef.current) {
e.preventDefault();
return;
}
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(index));
draggingIndexRef.current = index;
setDraggingIndex(index);
},
[],
@@ -40,8 +54,11 @@ function CustomMethodCardWizardBlocksListContainerComponent({
const handleDrop = useCallback(
(index: number) => (e: DragEvent) => {
e.preventDefault();
const from = Number.parseInt(e.dataTransfer.getData("text/plain"), 10);
if (Number.isNaN(from)) {
const fromData = Number.parseInt(e.dataTransfer.getData("text/plain"), 10);
const from = Number.isNaN(fromData)
? draggingIndexRef.current
: fromData;
if (from == null || Number.isNaN(from)) {
clearDragUi();
return;
}
@@ -66,6 +83,8 @@ function CustomMethodCardWizardBlocksListContainerComponent({
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnd={clearDragUi}
onHandlePointerDown={handleHandlePointerDown}
onEditBlock={onEditBlock}
/>
);
}
@@ -8,6 +8,7 @@ export interface CustomMethodCardWizardBlocksListProps {
dragHandleAriaLabel: string;
listLabel: string;
onBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void;
onEditBlock: (_block: CustomMethodCardFieldBlock) => void;
}
export interface CustomMethodCardWizardBlocksListViewProps
@@ -18,4 +19,5 @@ export interface CustomMethodCardWizardBlocksListViewProps
onDragOver: (_index: number) => (_e: DragEvent) => void;
onDrop: (_index: number) => (_e: DragEvent) => void;
onDragEnd: () => void;
onHandlePointerDown: () => void;
}
@@ -38,34 +38,52 @@ function CustomMethodCardWizardBlocksListViewComponent({
onDragOver,
onDrop,
onDragEnd,
onHandlePointerDown,
onEditBlock,
}: CustomMethodCardWizardBlocksListViewProps) {
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) => {
const kind = block.kind as AddCustomFieldType;
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 (
<li
key={block.id}
className={`flex min-h-[52px] items-stretch gap-2 rounded-[var(--measures-radius-medium,8px)] border border-[var(--color-border-default-primary)] bg-[var(--color-surface-default-secondary)] pl-1 pr-3 py-2 transition-shadow ${
isOver
? "ring-2 ring-[var(--color-border-invert-primary)] ring-offset-2 ring-offset-[var(--color-surface-default-primary)]"
: ""
} ${draggingIndex === index ? "opacity-60" : ""}`}
draggable
aria-grabbed={isDragging ? true : undefined}
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
? "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)}
onDrop={onDrop(index)}
onDragEnd={onDragEnd}
>
<button
type="button"
draggable
onDragStart={onDragStart(index)}
onDragEnd={onDragEnd}
className="flex shrink-0 cursor-grab touch-manipulation items-center justify-center rounded-[var(--measures-radius-200,8px)] border-0 bg-transparent px-1 text-[var(--color-content-default-secondary)] active:cursor-grabbing focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)]"
<span
role="button"
tabIndex={0}
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 />
</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">
<Icon
name={ADD_CUSTOM_FIELD_TYPE_ICONS[kind]}
@@ -74,13 +92,14 @@ function CustomMethodCardWizardBlocksListViewComponent({
/>
</span>
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
<span className="truncate font-inter text-[14px] font-medium leading-[18px] text-[var(--color-content-default-primary)]">
{block.blockTitle.trim() || typeLabel}
<span className="truncate text-[14px] font-medium leading-[18px] text-[var(--color-content-default-primary)]">
{title}
</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}
</span>
</div>
</button>
</li>
);
})}
@@ -1,6 +1,6 @@
"use client";
import { memo } from "react";
import { memo, useId } from "react";
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
import InputWithCounter from "../../../../components/controls/InputWithCounter";
import TextArea from "../../../../components/controls/TextArea";
@@ -10,6 +10,7 @@ import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
import InputLabel from "../../../../components/type/InputLabel";
import ApplicableScopeField from "../ApplicableScopeField";
import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants";
import { CUSTOM_ATTACHMENT_ACCEPT } from "../../../../../lib/create/createFlowUploadValidation";
import type { CustomMethodCardWizardFieldBodiesViewProps } from "./CustomMethodCardWizard.types";
const TEXT_PLACEHOLDER_MAX = 8000;
@@ -39,6 +40,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
onProportionBlockTitleChange,
onProportionDefaultChange,
}: CustomMethodCardWizardFieldBodiesViewProps) {
const placeholderFieldId = useId();
const uploadPreviewTrimmed = uploadAssetPreviewUrl?.trim() ?? "";
const hasUploadPreview = uploadPreviewTrimmed.length > 0;
@@ -51,16 +53,16 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
value={textBlockTitle}
onChange={onTextBlockTitleChange}
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
showHelpIcon
/>
<div className="flex flex-col gap-2">
<InputLabel
label={copy.text.placeholderLabel}
helpIcon
size="s"
palette="default"
/>
<label
htmlFor={placeholderFieldId}
className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]"
>
{copy.text.placeholderLabel}
</label>
<TextArea
id={placeholderFieldId}
formHeader={false}
appearance="embedded"
value={textPlaceholderBody}
@@ -82,13 +84,13 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
<div className="flex flex-col gap-2">
<InputLabel
label={copy.badges.blockTitleLabel}
helpIcon
helperText={copy.requiredHint}
size="s"
palette="default"
/>
<TextInput
formHeader={false}
label={copy.badges.blockTitleLabel}
placeholder={copy.badges.blockTitlePlaceholder}
value={badgeBlockTitle}
onChange={(e) => onBadgeBlockTitleChange(e.target.value)}
@@ -118,6 +120,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
type="file"
className="sr-only"
tabIndex={-1}
accept={CUSTOM_ATTACHMENT_ACCEPT}
aria-label={copy.upload.uploadFileInputAriaLabel}
onChange={onFileChosen}
/>
@@ -127,7 +130,6 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
value={uploadBlockTitle}
onChange={onUploadBlockTitleChange}
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
showHelpIcon
/>
{hasUploadPreview ? (
<div className="relative inline-block max-w-full">
@@ -158,6 +160,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
) : (
<Upload
active={!uploadPersisting}
showHelpIcon={false}
hintText={
uploadPersisting && uploadBusyHint
? uploadBusyHint
@@ -188,10 +191,10 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
value={proportionBlockTitle}
onChange={onProportionBlockTitleChange}
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
showHelpIcon
/>
<IncrementerBlock
label={copy.proportion.defaultLabel}
helpIcon={false}
value={proportionDefault}
min={1}
max={100}
File diff suppressed because it is too large Load Diff
@@ -6,8 +6,9 @@
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useBeforeUnloadGuard } from "../../../hooks/useBeforeUnloadGuard";
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 { useTranslation } from "../../../contexts/MessagesContext";
@@ -57,6 +58,8 @@ export function FinalReviewCommunityContextEditModal({
[draft],
);
useBeforeUnloadGuard(isOpen && isDirty);
const characterHint = tField("characterCountTemplate")
.replace("{current}", String(draft.length))
.replace("{max}", String(COMMUNITY_CONTEXT_FIELD_MAX_LENGTH));
@@ -92,18 +95,20 @@ export function FinalReviewCommunityContextEditModal({
ariaLabel={tModal("title")}
>
<div className="pb-2">
<TextInput
<TextArea
className="!transition-none"
type="text"
label={tModal("title")}
placeholder={tField("placeholder")}
value={draft}
onChange={(e) => {
setDraft(e.target.value);
}}
inputSize="medium"
size="medium"
formHeader={false}
showHelpIcon={false}
textHint={characterHint}
maxLength={COMMUNITY_CONTEXT_FIELD_MAX_LENGTH}
rows={4}
/>
</div>
</Create>
@@ -93,6 +93,7 @@ export function FinalReviewTitleEditModal({
<TextInput
className="!transition-none"
type="text"
label={tModal("title")}
placeholder={tField("placeholder")}
value={draft}
onChange={(e) => {
@@ -100,8 +101,10 @@ export function FinalReviewTitleEditModal({
}}
inputSize="medium"
formHeader={false}
showHelpIcon={false}
textHint={characterHint}
maxLength={COMMUNITY_TITLE_FIELD_MAX_LENGTH}
required
/>
</div>
</Create>
@@ -38,6 +38,7 @@ export default function MethodCardCustomizeModalHeader({
value={titleValue}
onChange={(e) => onTitleChange(e.target.value)}
inputSize="medium"
showHelpIcon={false}
/>
{showDescription ? (
<ModalTextAreaField
@@ -2,8 +2,9 @@
/**
* Shared "labelled text area" field used by every create flow modal section.
* Pairs an `InputLabel` (with help icon) with a `TextArea` set to the embedded
* appearance matching the Figma "Control / Text Area" pattern.
* Pairs an `InputLabel` with a `TextArea` set to the embedded appearance
* matching the Figma "Control / Text Area" pattern. Section help marks stay
* off until tooltip behavior ships.
*/
import { memo, useId } from "react";
@@ -13,7 +14,7 @@ import InputLabel from "../../../components/type/InputLabel";
export interface ModalTextAreaFieldProps {
/** Label rendered above the text area. */
label: string;
/** Show the help "?" icon next to the label (default `true`). */
/** Show the help "?" icon next to the label. Off until tooltip behavior ships. */
helpIcon?: boolean;
/** Current text value. */
value: string;
@@ -30,7 +31,7 @@ export interface ModalTextAreaFieldProps {
function ModalTextAreaFieldComponent({
label,
helpIcon = true,
helpIcon = false,
value,
onChange,
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 =
"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. */
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. */
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 /
@@ -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 =
"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 {
value: CoreValueDetailEntry;
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;
}
@@ -7,12 +7,13 @@
* `markCreateFlowInteraction` live in the parent.
*/
import { memo, useCallback } from "react";
import { memo, useCallback, useMemo } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import ModalTextAreaField from "../ModalTextAreaField";
import ApplicableScopeField from "../ApplicableScopeField";
import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
import type { DecisionApproachDetailEntry } from "../../types";
import { withDecisionApproachKeyResourceScopes } from "../../../../../lib/create/decisionApproachKeyResources";
export interface DecisionApproachEditFieldsProps {
value: DecisionApproachDetailEntry;
@@ -32,6 +33,11 @@ function DecisionApproachEditFieldsComponent({
const m = useMessages();
const t = m.create.customRule.decisionApproaches;
const scopes = useMemo(
() => withDecisionApproachKeyResourceScopes(value.applicableScope),
[value.applicableScope],
);
const patch = useCallback(
<K extends keyof DecisionApproachDetailEntry>(
key: K,
@@ -53,7 +59,7 @@ function DecisionApproachEditFieldsComponent({
<ApplicableScopeField
label={t.sectionHeadings.applicableScope}
addLabel={t.scopeAddButtonLabel}
scopes={value.applicableScope}
scopes={scopes}
selectedScopes={value.selectedApplicableScope}
readOnly={readOnly}
onToggleScope={(scope) =>
@@ -74,18 +80,21 @@ function DecisionApproachEditFieldsComponent({
onChange={(v) => patch("stepByStepInstructions", v)}
disabled={readOnly}
/>
<IncrementerBlock
label={t.sectionHeadings.consensusLevel}
value={value.consensusLevel}
min={CONSENSUS_LEVEL_MIN}
max={CONSENSUS_LEVEL_MAX}
step={CONSENSUS_LEVEL_STEP}
onChange={(next) => patch("consensusLevel", next)}
formatValue={(v) => `${v}%`}
decrementAriaLabel="Decrease consensus level"
incrementAriaLabel="Increase consensus level"
disabled={readOnly}
/>
{value.consensusLevel !== undefined ? (
<IncrementerBlock
label={t.sectionHeadings.consensusLevel}
helpIcon={false}
value={value.consensusLevel}
min={CONSENSUS_LEVEL_MIN}
max={CONSENSUS_LEVEL_MAX}
step={CONSENSUS_LEVEL_STEP}
onChange={(next) => patch("consensusLevel", next)}
formatValue={(v) => `${v}%`}
decrementAriaLabel="Decrease consensus level"
incrementAriaLabel="Increase consensus level"
disabled={readOnly}
/>
) : null}
<ModalTextAreaField
label={t.sectionHeadings.objectionsDeadlocks}
value={value.objectionsDeadlocks}
@@ -24,7 +24,7 @@ import {
export type CompletedFlowActionBanner = {
key: string;
status: "positive" | "danger";
status: "positive" | "danger" | "warning";
title: string;
description?: string;
};
@@ -96,7 +96,7 @@ export function useCompletedRuleShareExport({
}: {
setActionBanner: (_: CompletedFlowActionBanner | null) => void;
}): {
copyPublishedRuleLink: () => Promise<void>;
copyPublishedRuleLink: () => Promise<boolean>;
mailtoPublishedRule: () => void;
sharePublishedRuleViaSignal: () => Promise<void>;
sharePublishedRuleViaSlack: () => Promise<void>;
@@ -137,32 +137,34 @@ export function useCompletedRuleShareExport({
url: string,
banner?: () => void,
options?: { suppressFailureWhenDocumentNotFocused?: boolean },
) => {
): Promise<boolean> => {
try {
await navigator.clipboard.writeText(url);
(banner ?? bannerCopied)();
return true;
} catch {
if (
options?.suppressFailureWhenDocumentNotFocused === true &&
typeof window !== "undefined" &&
shouldSkipShareClipboardFallback(window)
) {
return;
return true;
}
bannerCopyFailed();
return false;
}
},
[bannerCopied, bannerCopyFailed],
);
const copyPublishedRuleLink = useCallback(async () => {
if (typeof window === "undefined") return;
if (typeof window === "undefined") return false;
const ctx = resolvePublishedRuleShareContext(window);
if (!ctx) {
bannerNoRule();
return;
return false;
}
await copyUrlToClipboard(ctx.url);
return copyUrlToClipboard(ctx.url);
}, [bannerNoRule, copyUrlToClipboard]);
const mailtoPublishedRule = useCallback(() => {
@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
import { publishRule, updatePublishedRule } from "../../../../lib/create/api";
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
import { writePendingKeepRuleLogin } from "../../../../lib/create/pendingKeepRuleLogin";
import messages from "../../../../messages/en/index";
import type { CreateFlowState } from "../types";
import {
@@ -15,11 +16,13 @@ import { createFlowStepPath } from "../utils/createFlowPaths";
type AppRouterLike = { push: (_href: string) => void };
type OpenLogin = (args: {
variant: "default" | "saveProgress";
variant: "default" | "saveProgress" | "keepRule";
nextPath: string;
backdropVariant: "blurredYellow";
}) => void;
type SessionUser = { id: string; email: string };
export type UseCreateFlowFinalizeResult = {
publishBannerMessage: string | null;
setPublishBannerMessage: (_message: string | null) => void;
@@ -34,6 +37,8 @@ export function useCreateFlowFinalize({
openLogin,
updateState,
loginReturnPath,
sessionUser,
onGuestPublished,
}: {
state: CreateFlowState;
router: AppRouterLike;
@@ -41,6 +46,13 @@ export function useCreateFlowFinalize({
updateState: (_patch: Partial<CreateFlowState>) => void;
/** Session gate return path (`?syncDraft=1`) — differs for `/create/edit-rule` vs `/create/final-review`. */
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 {
const [publishBannerMessage, setPublishBannerMessage] = useState<
string | null
@@ -48,6 +60,8 @@ export function useCreateFlowFinalize({
const [isPublishing, setIsPublishing] = useState(false);
const finalize = useCallback(async () => {
if (sessionUser === undefined) return;
setPublishBannerMessage(null);
const payloadResult = buildPublishPayload(state);
if (payloadResult.ok === false) {
@@ -66,6 +80,21 @@ export function useCreateFlowFinalize({
? 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) {
const updateResult = await updatePublishedRule(editingId, {
title,
@@ -86,8 +115,11 @@ export function useCreateFlowFinalize({
}
if (updateResult.status === 401) {
openLogin({
variant: "default",
nextPath: loginReturnPath,
variant: sessionUser === null ? "keepRule" : "default",
nextPath:
sessionUser === null
? createFlowStepPath("completed")
: loginReturnPath,
backdropVariant: "blurredYellow",
});
return;
@@ -100,9 +132,13 @@ export function useCreateFlowFinalize({
return;
}
const stakeholderEmails = (state.stakeholderEmails ?? []).filter(
const isGuest = sessionUser === null;
const rawStakeholderEmails = (state.stakeholderEmails ?? []).filter(
(e) => typeof e === "string" && e.trim() !== "",
);
const stakeholderEmails = isGuest ? [] : rawStakeholderEmails;
const skippedGuestInvites = isGuest && rawStakeholderEmails.length > 0;
const publishResult = await publishRule({
title,
summary,
@@ -117,6 +153,10 @@ export function useCreateFlowFinalize({
summary: summary ?? null,
document: ruleDocument,
});
if (isGuest) {
writePendingKeepRuleLogin();
onGuestPublished?.({ skippedInvites: skippedGuestInvites });
}
router.push(
createFlowStepPath("completed", {
[CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]:
@@ -127,8 +167,11 @@ export function useCreateFlowFinalize({
}
if (publishResult.status === 401) {
openLogin({
variant: "default",
nextPath: loginReturnPath,
variant: sessionUser === null ? "keepRule" : "default",
nextPath:
sessionUser === null
? createFlowStepPath("completed")
: loginReturnPath,
backdropVariant: "blurredYellow",
});
return;
@@ -138,7 +181,15 @@ export function useCreateFlowFinalize({
? publishResult.error
: messages.create.reviewAndComplete.publish.genericPublishFailed,
);
}, [state, router, openLogin, updateState, loginReturnPath]);
}, [
state,
router,
openLogin,
updateState,
loginReturnPath,
sessionUser,
onGuestPublished,
]);
return {
publishBannerMessage,
@@ -34,13 +34,17 @@ const blurActiveElement = (): void => {
*
* Resolves the active step from `/create/{screenId}` via
* {@link parseCreateFlowScreenFromPathname} (flowSteps). Footer Back uses
* {@link resolveCreateFlowBackTarget} so template **Use without changes**
* (which skips the custom-rule segment) returns to `/create/review-template/{slug}`
* from `confirm-stakeholders` instead of `conflict-management`.
* {@link resolveCreateFlowBackTarget}: template **Use without changes** that
* still needs identity (direct from a template) walks name / description /
* 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
* `?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(
options?: CreateFlowNavigationOptions,
@@ -83,17 +87,27 @@ export function useCreateFlowNavigation(
updateState,
]);
const nextStep = getNextStep(validStep, options);
const previousStep = getPreviousStep(validStep, options);
const identityOnly =
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(
() =>
resolveCreateFlowBackTarget(
validStep,
options,
navigationOptions,
state.templateReviewBackSlug,
),
[validStep, options?.skipCommunitySave, state.templateReviewBackSlug],
[validStep, navigationOptions, state.templateReviewBackSlug],
);
const goToNextStep = useCallback(() => {
@@ -1,10 +1,7 @@
"use client";
import { useMemo } from "react";
import {
mergeCompactCardIdsWithPinnedSelected,
orderRankedMethodsWithPinnedSelection,
} from "../../../../lib/create/methodCardDisplayOrder";
import { mergeCompactCardIdsWithPinnedSelected } from "../../../../lib/create/methodCardDisplayOrder";
import {
deriveCompactCards,
rankMethodsByScore,
@@ -15,10 +12,10 @@ import {
type MethodEntry = { id: string; label: string; supportText: string };
/**
* Applies score ranking, compact-slot rules, then surfaces selected ids first in
* `selected*Ids` order (most-recent add at index 0 via
* {@link moveFacetSelectionIdToFront}). Selection-first applies whenever the facet
* has any selection not only after footer Confirm (`methodSectionsPinCommitted`).
* Applies score ranking and compact-slot rules. Expanded CardStack order stays
* the ranked catalog (selected cards keep their place). Compact slots still
* pin selected ids first so a pick outside the unpinned top-N remains visible
* when the stack is collapsed.
*/
export function useMethodCardDeckOrdering(
section: RecommendationSection,
@@ -35,16 +32,7 @@ export function useMethodCardDeckOrdering(
);
const selectionShowcaseActive = selectedIds.length > 0;
const displayMethods = useMemo(
() =>
orderRankedMethodsWithPinnedSelection(
rankedMethods,
selectedIds,
selectionShowcaseActive,
),
[rankedMethods, selectedIds, selectionShowcaseActive],
);
const displayMethods = rankedMethods;
const { compactCardIds: baseCompactCardIds, recommendedIds } = useMemo(
() =>
@@ -60,13 +48,13 @@ export function useMethodCardDeckOrdering(
const compactCardIds = useMemo(
() =>
mergeCompactCardIdsWithPinnedSelected(
displayMethods.map((m) => m.id),
rankedMethods.map((m) => m.id),
baseCompactCardIds,
selectedIds,
selectionShowcaseActive,
5,
),
[displayMethods, baseCompactCardIds, selectedIds, selectionShowcaseActive],
[rankedMethods, baseCompactCardIds, selectedIds, selectionShowcaseActive],
);
const sampleCards = useMemo(
@@ -10,6 +10,7 @@ import type {
CreateFlowContextValue,
CreateFlowState,
} from "../types";
import { createFlowStepPath } from "../utils/createFlowPaths";
type AppRouterLike = { push: (_href: string) => void };
type UpdateState = CreateFlowContextValue["updateState"];
@@ -36,9 +37,10 @@ export type UseTemplateReviewActionsResult = {
* Use without changes: scrub any prior customize picks, seed core values +
* method-card selections from the template body (same id mapping as
* Customize) so drilling from final-review via + shows selected cards, drop
* the Values row from `state.sections`, and route to
* `/create/confirm-stakeholders` (or `/create/informational` with a pin to
* skip past `/create/review` to `/create/confirm-stakeholders` later).
* the Values row from `state.sections`. Direct template entry routes to
* `/create/community-name` with a pin so Next/Back collect only name
* description photo. In-flow (`?fromFlow=1`, community steps already done)
* routes to `/create/confirm-stakeholders`.
*/
handleUseWithoutChanges: () => Promise<void>;
};
@@ -59,7 +61,10 @@ export type UseTemplateReviewActionsResult = {
* setTemplateReviewApplyError,
* handleCustomize,
* handleUseWithoutChanges,
* } = useTemplateReviewActions({ pathname, state, updateState, replaceState, router });
* } = useTemplateReviewActions({
* pathname, state, updateState, replaceState, router,
* fromCreateWizard, markCreateFlowInteraction,
* });
*/
export function useTemplateReviewActions({
pathname,
@@ -67,12 +72,21 @@ export function useTemplateReviewActions({
updateState,
replaceState,
router,
fromCreateWizard = false,
markCreateFlowInteraction,
}: {
pathname: string | null | undefined;
state: CreateFlowState;
updateState: UpdateState;
replaceState: ReplaceStateFn;
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 {
const [isApplyingTemplate, setIsApplyingTemplate] = useState(false);
const [templateReviewApplyError, setTemplateReviewApplyError] = useState<
@@ -90,6 +104,7 @@ export function useTemplateReviewActions({
const handleCustomize = useCallback(async () => {
if (!templateReviewSlug) return;
markCreateFlowInteraction();
setTemplateReviewApplyError(null);
setIsApplyingTemplate(true);
const loaded = await loadTemplateReviewBySlug(templateReviewSlug);
@@ -119,7 +134,9 @@ export function useTemplateReviewActions({
}),
});
router.push(
hasCommunityName ? "/create/core-values" : "/create/informational",
hasCommunityName
? createFlowStepPath("core-values")
: createFlowStepPath("informational"),
);
}, [
router,
@@ -127,10 +144,12 @@ export function useTemplateReviewActions({
state.title,
templateReviewSlug,
updateState,
markCreateFlowInteraction,
]);
const handleUseWithoutChanges = useCallback(async () => {
if (!templateReviewSlug) return;
markCreateFlowInteraction();
setTemplateReviewApplyError(null);
setIsApplyingTemplate(true);
const loaded = await loadTemplateReviewBySlug(templateReviewSlug);
@@ -158,9 +177,6 @@ export function useTemplateReviewActions({
return;
}
const hasCommunityName =
typeof state.title === "string" && state.title.trim().length > 0;
// Atomic read-modify-write: strip prior custom-rule picks and merge template
// body in one replaceState so method ids are never lost across React batching
// (reset + update separately could leave selections undefined in Strict Mode).
@@ -179,13 +195,10 @@ export function useTemplateReviewActions({
})
: sections;
const hasCommunityName =
typeof prev.title === "string" && prev.title.trim().length > 0;
const pinPatch =
methodSectionsPinsForHydratedSelections(customizePrefill);
return {
const seeded: CreateFlowState = {
...base,
...(hasValuesSeed
? {
@@ -221,22 +234,40 @@ export function useTemplateReviewActions({
sections: sectionsWithoutValues,
methodSectionsPinCommitted: pinPatch,
templateReviewBackSlug: templateReviewSlug,
...(hasCommunityName
? { pendingTemplateAction: undefined }
: {
pendingTemplateAction: {
slug: templateReviewSlug,
mode: "useWithoutChanges",
},
}),
};
if (fromCreateWizard) {
return {
...seeded,
pendingTemplateAction: undefined,
};
}
return {
...seeded,
title: undefined,
communityContext: undefined,
communityAvatarUrl: undefined,
currentStep: undefined,
templateReviewEntryFromCreateFlow: undefined,
pendingTemplateAction: {
slug: templateReviewSlug,
mode: "useWithoutChanges",
},
};
});
router.push(
hasCommunityName
? "/create/confirm-stakeholders"
: "/create/informational",
fromCreateWizard
? createFlowStepPath("confirm-stakeholders")
: createFlowStepPath("community-name"),
);
}, [replaceState, router, state.title, templateReviewSlug]);
}, [
fromCreateWizard,
markCreateFlowInteraction,
replaceState,
router,
templateReviewSlug,
]);
return {
isTemplateReviewRoute,
+7
View File
@@ -1,5 +1,12 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
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 }) {
return <CreateFlowLayoutGate>{children}</CreateFlowLayoutGate>;
+1 -1
View File
@@ -6,7 +6,7 @@
export default function CreateFlowRouteLoading() {
return (
<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-live="polite"
>
@@ -15,14 +15,17 @@ import {
CreateFlowLockupCardStepShell,
} from "../../components/CreateFlowLockupCardStepShell";
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";
interface PageProps {
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) {
const { slug: rawSlug } = use(params);
const slug = decodeURIComponent(rawSlug);
@@ -72,7 +75,11 @@ export default function ReviewTemplatePage({ params }: PageProps) {
if (loading) {
return (
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
<CreateFlowStepShell
variant="wideGrid"
contentTopBelowMd="space-800"
className={CREATE_FLOW_MD_CENTERED_SHELL_CLASS}
>
<div
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>
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
<CreateFlowStepShell
variant="wideGrid"
contentTopBelowMd="space-800"
className={CREATE_FLOW_MD_CENTERED_SHELL_CLASS}
>
<div
className={`min-h-[40vh] shrink-0 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
aria-hidden
@@ -6,30 +6,26 @@
*
* Lives under `screens/card/` (not `select/`): Figma **card stack** layout is a distinct shell from
* two-column chip **select** frames. Future card-stack steps get their own `*Screen.tsx` here and
* reuse `CardStack` / `CreateFlowStepShell` as needed.
* reuse `CardStack` / `CreateFlowCardStackStepShell` as needed.
*
* Card click opens the Figma create modal (node `20246-15829`) with three
* editable sections rendered by {@link CommunicationMethodEditFields}. The primary
* action is **Add Platform** for an unselected card; a selected card in view mode has
* no footer primary **Remove** is available from the kebab (same behavior as legacy
* footer remove via {@link removeMethodCardFromFacetSelection}).
* editable sections rendered by {@link CommunicationMethodEditFields}. The
* primary action is **Add Platform** for an unselected card and **Save** for a
* selected card. **Remove** is a danger footer button on selected cards (and
* remains in the kebab) via {@link removeMethodCardFromFacetSelection}.
*/
import { useState, useCallback, useMemo, useRef } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowCardStackStepShell } from "../../components/CreateFlowCardStackStepShell";
import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import { CommunicationMethodEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
@@ -52,20 +48,21 @@ import type { CommunicationMethodDetailEntry } from "../../types";
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import {
captureMethodCardCustomizeSnapshot,
isMethodCardCustomizeUnloadBlocked,
type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession";
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
export function CommunicationMethodsScreen() {
const m = useMessages();
const comm = m.create.customRule.communication;
const modalKebabMenu = m.create.customRule.modalKebabMenu;
const mdUp = useCreateFlowMdUp();
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
useDiscardCustomizeConfirm();
const { confirmDiscard, confirmDialog } = useDiscardCustomizeConfirm();
const { state, updateState, replaceState, markCreateFlowInteraction } =
useCreateFlow();
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
@@ -78,12 +75,24 @@ export function CommunicationMethodsScreen() {
const [pendingDraft, setPendingDraft] =
useState<CommunicationMethodDetailEntry | null>(null);
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<
CustomMethodCardFieldBlock[] | null
>(null);
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
useState<MethodCardHeaderDraft | null>(null);
useBeforeUnloadGuard(
isMethodCardCustomizeUnloadBlocked(
createModalOpen,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeSnapshotRef.current?.headerDraft ?? null,
),
);
const selectedIds = state.selectedCommunicationMethodIds ?? [];
@@ -106,6 +115,8 @@ export function CommunicationMethodsScreen() {
const handleOpenAddWizard = useCallback(() => {
markCreateFlowInteraction();
setWizardCustomizeCardId(null);
setWizardInitialValues(null);
setAddCustomWizardOpen(true);
}, [markCreateFlowInteraction]);
@@ -143,15 +154,40 @@ export function CommunicationMethodsScreen() {
const handleCardClick = useCallback(
(id: string) => {
markCreateFlowInteraction();
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
const draft = seedDraft(id);
const persistedBlocks = state.customMethodCardFieldBlocksById?.[id];
const initialBlocks =
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);
setPendingDraft(seedDraft(id));
setPendingDraft(draft);
setDraftFieldBlocks(initialBlocks);
setCreateModalOpen(true);
},
[markCreateFlowInteraction, seedDraft],
[
comm.confirmModal.description,
comm.confirmModal.title,
markCreateFlowInteraction,
methodById,
seedDraft,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
],
);
const handleDraftChange = useCallback(
@@ -164,9 +200,7 @@ export function CommunicationMethodsScreen() {
const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId);
const fieldsLocked = !modalEditUnlocked;
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
const showMethodModalPrimary = true;
const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false;
@@ -188,7 +222,7 @@ export function CommunicationMethodsScreen() {
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
modalEditUnlocked: true,
draftFieldBlocks,
customFacetDetailsMatchPreset,
}),
@@ -196,7 +230,6 @@ export function CommunicationMethodsScreen() {
[
customFacetDetailsMatchPreset,
draftFieldBlocks,
modalEditUnlocked,
pendingCardId,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
@@ -206,11 +239,11 @@ export function CommunicationMethodsScreen() {
const handleCreateModalClose = useCallback(async () => {
if (
!(await confirmDiscard(
modalEditUnlocked,
true,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
customizeSnapshotRef.current?.headerDraft ?? null,
))
) {
return;
@@ -238,53 +271,14 @@ export function CommunicationMethodsScreen() {
setCreateModalOpen(false);
setPendingCardId(null);
setPendingDraft(null);
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
}, [
confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks,
modalEditUnlocked,
pendingDraft,
replaceState,
]);
const handleCancelCustomize = useCallback(async () => {
if (!modalEditUnlocked) {
return;
}
const snap = customizeSnapshotRef.current;
if (!snap) {
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
if (
!(await confirmDirtyCustomizeCancel(
snap,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
))
) {
return;
}
setPendingDraft(structuredClone(snap.pendingDraft));
setDraftFieldBlocks(null);
setModalEditUnlocked(false);
customizeSnapshotRef.current = null;
setCustomizeHeaderDraft(null);
}, [
confirmDirtyCustomizeCancel,
customizeHeaderDraft,
draftFieldBlocks,
modalEditUnlocked,
pendingDraft,
]);
const handleRemoveSelectedFromModal = useCallback(async () => {
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
return;
@@ -292,11 +286,11 @@ export function CommunicationMethodsScreen() {
markCreateFlowInteraction();
if (
!(await confirmDiscard(
modalEditUnlocked,
true,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
customizeSnapshotRef.current?.headerDraft ?? null,
))
) {
return;
@@ -312,11 +306,9 @@ export function CommunicationMethodsScreen() {
await handleCreateModalClose();
}, [
confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks,
handleCreateModalClose,
markCreateFlowInteraction,
modalEditUnlocked,
pendingDraft,
pendingCardId,
selectedIds,
@@ -326,37 +318,36 @@ export function CommunicationMethodsScreen() {
const handleCustomize = useCallback(() => {
markCreateFlowInteraction();
if (!pendingDraft || !pendingCardId) {
if (!pendingCardId) {
return;
}
const persistedBlocks =
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [];
const initialFieldBlocks =
persistedBlocks.length > 0
? structuredClone(persistedBlocks)
: isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? []
: null;
const method = methodById.get(pendingCardId);
const meta = state.customMethodCardMetaById?.[pendingCardId];
const headerDraft: MethodCardHeaderDraft = {
title: meta?.label ?? method?.label ?? comm.confirmModal.title,
description:
meta?.supportText ??
method?.supportText ??
comm.confirmModal.description,
};
setCustomizeHeaderDraft(headerDraft);
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
pendingDraft,
initialFieldBlocks,
headerDraft,
setWizardInitialValues(
buildMethodCardWizardInitialValues({
cardId: pendingCardId,
fallbackTitle: method?.label ?? comm.confirmModal.title,
fallbackDescription:
method?.supportText ?? comm.confirmModal.description,
meta: state.customMethodCardMetaById,
persistedBlocks: state.customMethodCardFieldBlocksById,
draftFieldBlocks,
facetPrefill: pendingDraft
? {
group: "communication",
draft: pendingDraft,
headings: comm.sectionHeadings,
}
: undefined,
}),
);
setDraftFieldBlocks(initialFieldBlocks);
setModalEditUnlocked(true);
setWizardCustomizeCardId(pendingCardId);
setCreateModalOpen(false);
setAddCustomWizardOpen(true);
}, [
comm.confirmModal.description,
comm.confirmModal.title,
comm.sectionHeadings,
draftFieldBlocks,
markCreateFlowInteraction,
methodById,
pendingCardId,
@@ -381,9 +372,7 @@ export function CommunicationMethodsScreen() {
() => communicationPresetFor(newId),
);
const blocksClone = structuredClone(
modalEditUnlocked &&
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
draftFieldBlocks !== null
? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById,
@@ -413,16 +402,15 @@ export function CommunicationMethodsScreen() {
customizeSnapshotRef.current = null;
setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
setDraftFieldBlocks(
blocksClone.length > 0 ? structuredClone(blocksClone) : null,
);
}, [
markCreateFlowInteraction,
modalKebabMenu.duplicateTitleSuffix,
pendingCardId,
pendingDraft,
draftFieldBlocks,
modalEditUnlocked,
state.communicationMethodDetailsById,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
@@ -448,9 +436,7 @@ export function CommunicationMethodsScreen() {
() => communicationPresetFor(newId),
);
const blocksClone = structuredClone(
modalEditUnlocked &&
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
draftFieldBlocks !== null
? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById,
@@ -480,14 +466,13 @@ export function CommunicationMethodsScreen() {
customizeSnapshotRef.current = null;
setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
setDraftFieldBlocks(
blocksClone.length > 0 ? structuredClone(blocksClone) : null,
);
}, [
draftFieldBlocks,
markCreateFlowInteraction,
methodById,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix,
pendingCardId,
pendingDraft,
@@ -500,7 +485,7 @@ export function CommunicationMethodsScreen() {
const kebabMenuItems = useMemo(
() =>
buildCustomRuleModalKebabMenu(modalKebabMenu, {
showCustomize: !modalEditUnlocked,
showCustomize: true,
onCustomize: handleCustomize,
onDuplicate:
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
@@ -520,7 +505,6 @@ export function CommunicationMethodsScreen() {
handleDuplicatePrefabCard,
handleRemoveSelectedFromModal,
isSelectedCardModal,
modalEditUnlocked,
modalKebabMenu,
pendingCardId,
state.customMethodCardMetaById,
@@ -539,7 +523,7 @@ export function CommunicationMethodsScreen() {
meta?.supportText ??
method?.supportText ??
comm.confirmModal.description,
nextButtonText: modalEditUnlocked
nextButtonText: isSelectedCardModal
? saveLabel
: comm.addPlatform.nextButtonText,
};
@@ -551,8 +535,14 @@ export function CommunicationMethodsScreen() {
};
const handleCloseAddWizard = useCallback(() => {
const resumeCardId = wizardCustomizeCardId;
setAddCustomWizardOpen(false);
}, []);
setWizardCustomizeCardId(null);
setWizardInitialValues(null);
if (resumeCardId && pendingCardId === resumeCardId) {
setCreateModalOpen(true);
}
}, [pendingCardId, wizardCustomizeCardId]);
const handleFinalizeCustomCard = useCallback(
({
@@ -565,6 +555,42 @@ export function CommunicationMethodsScreen() {
fieldBlocks: CustomMethodCardFieldBlock[];
}) => {
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();
updateState({
selectedCommunicationMethodIds: moveFacetSelectionIdToFront(
@@ -587,91 +613,29 @@ export function CommunicationMethodsScreen() {
},
[
markCreateFlowInteraction,
pendingDraft,
selectedIds,
state.communicationMethodDetailsById,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
updateState,
wizardCustomizeCardId,
],
);
const handleCreateModalPrimary = useCallback(() => {
if (!pendingCardId) {
handleCreateModalClose();
void handleCreateModalClose();
return;
}
markCreateFlowInteraction();
if (selectedIds.includes(pendingCardId)) {
if (modalEditUnlocked) {
if (!customizeHeaderDraft) {
return;
}
const nextMeta = methodCardMetaWithCustomizeHeader(
state.customMethodCardMetaById,
pendingCardId,
customizeHeaderDraft,
);
if (
pendingCardId &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
usesWizardFieldBlocksModalBody({
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
draftFieldBlocks,
customFacetDetailsMatchPreset,
})
) {
updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
});
} else if (pendingDraft) {
updateState({
customMethodCardMetaById: nextMeta,
communicationMethodDetailsById: {
...(state.communicationMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
});
}
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
return;
}
const persistWizardBlocks =
modalUsesWizardFieldBlocksBody && draftFieldBlocks !== null;
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,
})
) {
if (selectedIds.includes(pendingCardId)) {
if (persistWizardBlocks) {
updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
@@ -679,22 +643,20 @@ export function CommunicationMethodsScreen() {
});
} else if (pendingDraft) {
updateState({
customMethodCardMetaById: nextMeta,
communicationMethodDetailsById: {
...(state.communicationMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
});
}
pendingEphemeralDuplicateIdRef.current = null;
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
void handleCreateModalClose();
return;
}
if (!pendingDraft) {
handleCreateModalClose();
void handleCreateModalClose();
return;
}
updateState({
@@ -706,15 +668,23 @@ export function CommunicationMethodsScreen() {
...(state.communicationMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
...(persistWizardBlocks
? {
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
}
: {}),
});
pendingEphemeralDuplicateIdRef.current = null;
handleCreateModalClose();
customizeSnapshotRef.current = null;
void handleCreateModalClose();
}, [
customizeHeaderDraft,
draftFieldBlocks,
handleCreateModalClose,
markCreateFlowInteraction,
modalEditUnlocked,
modalUsesWizardFieldBlocksBody,
pendingCardId,
pendingDraft,
selectedIds,
@@ -724,74 +694,41 @@ export function CommunicationMethodsScreen() {
return (
<>
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-800"
<CreateFlowCardStackStepShell
lockupTitle={title}
lockupDescription={description}
stackBusy={!recommendationsReady}
>
<div className="flex w-full min-w-0 flex-col items-center gap-6">
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={title}
description={description}
justification="center"
/>
</div>
<div
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
aria-busy={!recommendationsReady}
>
{recommendationsReady ? (
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={comm.page.seeAllLink}
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
compactDesktopLayout="flexWrap"
headerLockupSize={mdUp ? "L" : "M"}
/>
) : null}
</div>
</div>
{recommendationsReady && (
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={comm.page.seeAllLink}
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
compactDesktopLayout="flexWrap"
headerLockupSize={mdUp ? "L" : "M"}
/>
)}
</CreateFlowCardStackStepShell>
<Create
isOpen={createModalOpen}
onClose={handleCreateModalClose}
headerContent={
modalEditUnlocked && customizeHeaderDraft ? (
<MethodCardCustomizeModalHeader
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
titleValue={customizeHeaderDraft.title}
descriptionValue={customizeHeaderDraft.description}
onTitleChange={(title) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, title } : null,
)
}
onDescriptionChange={(description) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, description } : null,
)
}
/>
) : undefined
}
onNext={handleCreateModalPrimary}
title={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={modalEditUnlocked}
onBack={handleCancelCustomize}
backButtonText={modalKebabMenu.cancelCustomize}
showBackButton={false}
showNextButton={showMethodModalPrimary}
showRemoveButton={isSelectedCardModal}
onRemove={handleRemoveSelectedFromModal}
backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
@@ -803,14 +740,14 @@ export function CommunicationMethodsScreen() {
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={
modalEditUnlocked && draftFieldBlocks !== null
? draftFieldBlocks
: undefined
draftFieldBlocks !== null ? draftFieldBlocks : undefined
}
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked}
showPolicyContentLockupWhenNoBlocks={
draftFieldBlocks === null || draftFieldBlocks.length === 0
}
onFieldBlocksChange={
fieldsLocked
draftFieldBlocks === null
? undefined
: (next) => setDraftFieldBlocks(next)
}
@@ -819,15 +756,14 @@ export function CommunicationMethodsScreen() {
<CommunicationMethodEditFields
value={pendingDraft}
onChange={handleDraftChange}
readOnly={fieldsLocked}
/>
)
) : null}
</Create>
</CreateFlowStepShell>
<CustomMethodCardWizard
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
initialValues={wizardInitialValues}
onFinalize={handleFinalizeCustomCard}
onPersistCustomUploadFile={(file) =>
uploadCreateFlowFile(file, "customMethodAttachment")
@@ -16,17 +16,13 @@ import { useState, useCallback, useMemo, useRef } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowCardStackStepShell } from "../../components/CreateFlowCardStackStepShell";
import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import { ConflictManagementEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
@@ -49,20 +45,21 @@ import type { ConflictManagementDetailEntry } from "../../types";
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import {
captureMethodCardCustomizeSnapshot,
isMethodCardCustomizeUnloadBlocked,
type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession";
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
export function ConflictManagementScreen() {
const m = useMessages();
const cm = m.create.customRule.conflictManagement;
const modalKebabMenu = m.create.customRule.modalKebabMenu;
const mdUp = useCreateFlowMdUp();
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
useDiscardCustomizeConfirm();
const { confirmDiscard, confirmDialog } = useDiscardCustomizeConfirm();
const { state, updateState, replaceState, markCreateFlowInteraction } =
useCreateFlow();
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
@@ -75,12 +72,24 @@ export function ConflictManagementScreen() {
const [pendingDraft, setPendingDraft] =
useState<ConflictManagementDetailEntry | null>(null);
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<
CustomMethodCardFieldBlock[] | null
>(null);
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
useState<MethodCardHeaderDraft | null>(null);
useBeforeUnloadGuard(
isMethodCardCustomizeUnloadBlocked(
createModalOpen,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeSnapshotRef.current?.headerDraft ?? null,
),
);
const selectedIds = state.selectedConflictManagementIds ?? [];
@@ -103,6 +112,8 @@ export function ConflictManagementScreen() {
const handleOpenAddWizard = useCallback(() => {
markCreateFlowInteraction();
setWizardCustomizeCardId(null);
setWizardInitialValues(null);
setAddCustomWizardOpen(true);
}, [markCreateFlowInteraction]);
@@ -144,15 +155,40 @@ export function ConflictManagementScreen() {
const handleCardClick = useCallback(
(id: string) => {
markCreateFlowInteraction();
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
const draft = seedDraft(id);
const persistedBlocks = state.customMethodCardFieldBlocksById?.[id];
const initialBlocks =
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);
setPendingDraft(seedDraft(id));
setPendingDraft(draft);
setDraftFieldBlocks(initialBlocks);
setCreateModalOpen(true);
},
[markCreateFlowInteraction, seedDraft],
[
cm.confirmModal.description,
cm.confirmModal.title,
markCreateFlowInteraction,
methodById,
seedDraft,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
],
);
const handleDraftChange = useCallback(
@@ -165,9 +201,7 @@ export function ConflictManagementScreen() {
const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId);
const fieldsLocked = !modalEditUnlocked;
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
const showMethodModalPrimary = true;
const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false;
@@ -189,7 +223,7 @@ export function ConflictManagementScreen() {
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
modalEditUnlocked: true,
draftFieldBlocks,
customFacetDetailsMatchPreset,
}),
@@ -197,7 +231,6 @@ export function ConflictManagementScreen() {
[
customFacetDetailsMatchPreset,
draftFieldBlocks,
modalEditUnlocked,
pendingCardId,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
@@ -207,11 +240,11 @@ export function ConflictManagementScreen() {
const handleCreateModalClose = useCallback(async () => {
if (
!(await confirmDiscard(
modalEditUnlocked,
true,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
customizeSnapshotRef.current?.headerDraft ?? null,
))
) {
return;
@@ -239,53 +272,14 @@ export function ConflictManagementScreen() {
setCreateModalOpen(false);
setPendingCardId(null);
setPendingDraft(null);
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
}, [
confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks,
modalEditUnlocked,
pendingDraft,
replaceState,
]);
const handleCancelCustomize = useCallback(async () => {
if (!modalEditUnlocked) {
return;
}
const snap = customizeSnapshotRef.current;
if (!snap) {
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
if (
!(await confirmDirtyCustomizeCancel(
snap,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
))
) {
return;
}
setPendingDraft(structuredClone(snap.pendingDraft));
setDraftFieldBlocks(null);
setModalEditUnlocked(false);
customizeSnapshotRef.current = null;
setCustomizeHeaderDraft(null);
}, [
confirmDirtyCustomizeCancel,
customizeHeaderDraft,
draftFieldBlocks,
modalEditUnlocked,
pendingDraft,
]);
const handleRemoveSelectedFromModal = useCallback(async () => {
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
return;
@@ -293,11 +287,11 @@ export function ConflictManagementScreen() {
markCreateFlowInteraction();
if (
!(await confirmDiscard(
modalEditUnlocked,
true,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
customizeSnapshotRef.current?.headerDraft ?? null,
))
) {
return;
@@ -313,11 +307,9 @@ export function ConflictManagementScreen() {
await handleCreateModalClose();
}, [
confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks,
handleCreateModalClose,
markCreateFlowInteraction,
modalEditUnlocked,
pendingDraft,
pendingCardId,
selectedIds,
@@ -327,35 +319,36 @@ export function ConflictManagementScreen() {
const handleCustomize = useCallback(() => {
markCreateFlowInteraction();
if (!pendingDraft || !pendingCardId) {
if (!pendingCardId) {
return;
}
const initialFieldBlocks =
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? structuredClone(
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [],
)
: null;
const method = methodById.get(pendingCardId);
const meta = state.customMethodCardMetaById?.[pendingCardId];
const headerDraft: MethodCardHeaderDraft = {
title: meta?.label ?? method?.label ?? cm.confirmModal.title,
description:
meta?.supportText ??
method?.supportText ??
cm.confirmModal.description,
};
setCustomizeHeaderDraft(headerDraft);
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
pendingDraft,
initialFieldBlocks,
headerDraft,
setWizardInitialValues(
buildMethodCardWizardInitialValues({
cardId: pendingCardId,
fallbackTitle: method?.label ?? cm.confirmModal.title,
fallbackDescription:
method?.supportText ?? cm.confirmModal.description,
meta: state.customMethodCardMetaById,
persistedBlocks: state.customMethodCardFieldBlocksById,
draftFieldBlocks,
facetPrefill: pendingDraft
? {
group: "conflictManagement",
draft: pendingDraft,
headings: cm.sectionHeadings,
}
: undefined,
}),
);
setDraftFieldBlocks(initialFieldBlocks);
setModalEditUnlocked(true);
setWizardCustomizeCardId(pendingCardId);
setCreateModalOpen(false);
setAddCustomWizardOpen(true);
}, [
cm.confirmModal.description,
cm.confirmModal.title,
cm.sectionHeadings,
draftFieldBlocks,
markCreateFlowInteraction,
methodById,
pendingCardId,
@@ -380,9 +373,7 @@ export function ConflictManagementScreen() {
() => conflictManagementPresetFor(newId),
);
const blocksClone = structuredClone(
modalEditUnlocked &&
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
draftFieldBlocks !== null
? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById,
@@ -412,13 +403,12 @@ export function ConflictManagementScreen() {
customizeSnapshotRef.current = null;
setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
setDraftFieldBlocks(
blocksClone.length > 0 ? structuredClone(blocksClone) : null,
);
}, [
draftFieldBlocks,
markCreateFlowInteraction,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix,
pendingCardId,
pendingDraft,
@@ -447,9 +437,7 @@ export function ConflictManagementScreen() {
() => conflictManagementPresetFor(newId),
);
const blocksClone = structuredClone(
modalEditUnlocked &&
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
draftFieldBlocks !== null
? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById,
@@ -479,14 +467,13 @@ export function ConflictManagementScreen() {
customizeSnapshotRef.current = null;
setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
setDraftFieldBlocks(
blocksClone.length > 0 ? structuredClone(blocksClone) : null,
);
}, [
draftFieldBlocks,
markCreateFlowInteraction,
methodById,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix,
pendingCardId,
pendingDraft,
@@ -499,7 +486,7 @@ export function ConflictManagementScreen() {
const kebabMenuItems = useMemo(
() =>
buildCustomRuleModalKebabMenu(modalKebabMenu, {
showCustomize: !modalEditUnlocked,
showCustomize: true,
onCustomize: handleCustomize,
onDuplicate:
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
@@ -519,7 +506,6 @@ export function ConflictManagementScreen() {
handleDuplicatePrefabCard,
handleRemoveSelectedFromModal,
isSelectedCardModal,
modalEditUnlocked,
modalKebabMenu,
pendingCardId,
state.customMethodCardMetaById,
@@ -538,7 +524,7 @@ export function ConflictManagementScreen() {
meta?.supportText ??
method?.supportText ??
cm.confirmModal.description,
nextButtonText: modalEditUnlocked
nextButtonText: isSelectedCardModal
? saveLabel
: cm.addApproach.nextButtonText,
};
@@ -550,8 +536,14 @@ export function ConflictManagementScreen() {
};
const handleCloseAddWizard = useCallback(() => {
const resumeCardId = wizardCustomizeCardId;
setAddCustomWizardOpen(false);
}, []);
setWizardCustomizeCardId(null);
setWizardInitialValues(null);
if (resumeCardId && pendingCardId === resumeCardId) {
setCreateModalOpen(true);
}
}, [pendingCardId, wizardCustomizeCardId]);
const handleFinalizeCustomCard = useCallback(
({
@@ -564,6 +556,42 @@ export function ConflictManagementScreen() {
fieldBlocks: CustomMethodCardFieldBlock[];
}) => {
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();
updateState({
selectedConflictManagementIds: moveFacetSelectionIdToFront(
@@ -586,91 +614,29 @@ export function ConflictManagementScreen() {
},
[
markCreateFlowInteraction,
pendingDraft,
selectedIds,
state.conflictManagementDetailsById,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
updateState,
wizardCustomizeCardId,
],
);
const handleCreateModalPrimary = useCallback(() => {
if (!pendingCardId) {
handleCreateModalClose();
void handleCreateModalClose();
return;
}
markCreateFlowInteraction();
if (selectedIds.includes(pendingCardId)) {
if (modalEditUnlocked) {
if (!customizeHeaderDraft) {
return;
}
const nextMeta = methodCardMetaWithCustomizeHeader(
state.customMethodCardMetaById,
pendingCardId,
customizeHeaderDraft,
);
if (
pendingCardId &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
usesWizardFieldBlocksModalBody({
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
draftFieldBlocks,
customFacetDetailsMatchPreset,
})
) {
updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
});
} else if (pendingDraft) {
updateState({
customMethodCardMetaById: nextMeta,
conflictManagementDetailsById: {
...(state.conflictManagementDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
});
}
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
return;
}
const persistWizardBlocks =
modalUsesWizardFieldBlocksBody && draftFieldBlocks !== null;
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,
})
) {
if (selectedIds.includes(pendingCardId)) {
if (persistWizardBlocks) {
updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
@@ -678,22 +644,20 @@ export function ConflictManagementScreen() {
});
} else if (pendingDraft) {
updateState({
customMethodCardMetaById: nextMeta,
conflictManagementDetailsById: {
...(state.conflictManagementDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
});
}
pendingEphemeralDuplicateIdRef.current = null;
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
void handleCreateModalClose();
return;
}
if (!pendingDraft) {
handleCreateModalClose();
void handleCreateModalClose();
return;
}
updateState({
@@ -705,15 +669,23 @@ export function ConflictManagementScreen() {
...(state.conflictManagementDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
...(persistWizardBlocks
? {
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
}
: {}),
});
pendingEphemeralDuplicateIdRef.current = null;
handleCreateModalClose();
customizeSnapshotRef.current = null;
void handleCreateModalClose();
}, [
customizeHeaderDraft,
draftFieldBlocks,
handleCreateModalClose,
markCreateFlowInteraction,
modalEditUnlocked,
modalUsesWizardFieldBlocksBody,
pendingCardId,
pendingDraft,
selectedIds,
@@ -723,74 +695,41 @@ export function ConflictManagementScreen() {
return (
<>
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-800"
<CreateFlowCardStackStepShell
lockupTitle={title}
lockupDescription={description}
stackBusy={!recommendationsReady}
>
<div className="flex w-full min-w-0 flex-col items-center gap-6">
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={title}
description={description}
justification="center"
/>
</div>
<div
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
aria-busy={!recommendationsReady}
>
{recommendationsReady ? (
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={cm.page.seeAllLink}
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"}
/>
) : null}
</div>
</div>
{recommendationsReady && (
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={cm.page.seeAllLink}
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"}
/>
)}
</CreateFlowCardStackStepShell>
<Create
isOpen={createModalOpen}
onClose={handleCreateModalClose}
headerContent={
modalEditUnlocked && customizeHeaderDraft ? (
<MethodCardCustomizeModalHeader
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
titleValue={customizeHeaderDraft.title}
descriptionValue={customizeHeaderDraft.description}
onTitleChange={(title) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, title } : null,
)
}
onDescriptionChange={(description) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, description } : null,
)
}
/>
) : undefined
}
onNext={handleCreateModalPrimary}
title={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={modalEditUnlocked}
onBack={handleCancelCustomize}
backButtonText={modalKebabMenu.cancelCustomize}
showBackButton={false}
showNextButton={showMethodModalPrimary}
showRemoveButton={isSelectedCardModal}
onRemove={handleRemoveSelectedFromModal}
backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
@@ -802,14 +741,14 @@ export function ConflictManagementScreen() {
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={
modalEditUnlocked && draftFieldBlocks !== null
? draftFieldBlocks
: undefined
draftFieldBlocks !== null ? draftFieldBlocks : undefined
}
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked}
showPolicyContentLockupWhenNoBlocks={
draftFieldBlocks === null || draftFieldBlocks.length === 0
}
onFieldBlocksChange={
fieldsLocked
draftFieldBlocks === null
? undefined
: (next) => setDraftFieldBlocks(next)
}
@@ -818,15 +757,14 @@ export function ConflictManagementScreen() {
<ConflictManagementEditFields
value={pendingDraft}
onChange={handleDraftChange}
readOnly={fieldsLocked}
/>
)
) : null}
</Create>
</CreateFlowStepShell>
<CustomMethodCardWizard
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
initialValues={wizardInitialValues}
onFinalize={handleFinalizeCustomCard}
onPersistCustomUploadFile={(file) =>
uploadCreateFlowFile(file, "customMethodAttachment")
@@ -17,17 +17,13 @@ import { useState, useCallback, useMemo, useRef } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowCardStackStepShell } from "../../components/CreateFlowCardStackStepShell";
import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import { MembershipMethodEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
@@ -50,20 +46,21 @@ import type { MembershipMethodDetailEntry } from "../../types";
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import {
captureMethodCardCustomizeSnapshot,
isMethodCardCustomizeUnloadBlocked,
type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession";
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
export function MembershipMethodsScreen() {
const m = useMessages();
const mem = m.create.customRule.membership;
const modalKebabMenu = m.create.customRule.modalKebabMenu;
const mdUp = useCreateFlowMdUp();
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
useDiscardCustomizeConfirm();
const { confirmDiscard, confirmDialog } = useDiscardCustomizeConfirm();
const { state, updateState, replaceState, markCreateFlowInteraction } =
useCreateFlow();
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
@@ -76,12 +73,24 @@ export function MembershipMethodsScreen() {
const [pendingDraft, setPendingDraft] =
useState<MembershipMethodDetailEntry | null>(null);
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<
CustomMethodCardFieldBlock[] | null
>(null);
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
useState<MethodCardHeaderDraft | null>(null);
useBeforeUnloadGuard(
isMethodCardCustomizeUnloadBlocked(
createModalOpen,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeSnapshotRef.current?.headerDraft ?? null,
),
);
const selectedIds = state.selectedMembershipMethodIds ?? [];
@@ -104,6 +113,8 @@ export function MembershipMethodsScreen() {
const handleOpenAddWizard = useCallback(() => {
markCreateFlowInteraction();
setWizardCustomizeCardId(null);
setWizardInitialValues(null);
setAddCustomWizardOpen(true);
}, [markCreateFlowInteraction]);
@@ -141,15 +152,40 @@ export function MembershipMethodsScreen() {
const handleCardClick = useCallback(
(id: string) => {
markCreateFlowInteraction();
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
const draft = seedDraft(id);
const persistedBlocks = state.customMethodCardFieldBlocksById?.[id];
const initialBlocks =
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);
setPendingDraft(seedDraft(id));
setPendingDraft(draft);
setDraftFieldBlocks(initialBlocks);
setCreateModalOpen(true);
},
[markCreateFlowInteraction, seedDraft],
[
mem.confirmModal.description,
mem.confirmModal.title,
markCreateFlowInteraction,
methodById,
seedDraft,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
],
);
const handleDraftChange = useCallback(
@@ -162,9 +198,7 @@ export function MembershipMethodsScreen() {
const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId);
const fieldsLocked = !modalEditUnlocked;
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
const showMethodModalPrimary = true;
const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false;
@@ -186,7 +220,7 @@ export function MembershipMethodsScreen() {
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
modalEditUnlocked: true,
draftFieldBlocks,
customFacetDetailsMatchPreset,
}),
@@ -194,7 +228,6 @@ export function MembershipMethodsScreen() {
[
customFacetDetailsMatchPreset,
draftFieldBlocks,
modalEditUnlocked,
pendingCardId,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
@@ -204,11 +237,11 @@ export function MembershipMethodsScreen() {
const handleCreateModalClose = useCallback(async () => {
if (
!(await confirmDiscard(
modalEditUnlocked,
true,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
customizeSnapshotRef.current?.headerDraft ?? null,
))
) {
return;
@@ -236,53 +269,14 @@ export function MembershipMethodsScreen() {
setCreateModalOpen(false);
setPendingCardId(null);
setPendingDraft(null);
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
}, [
confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks,
modalEditUnlocked,
pendingDraft,
replaceState,
]);
const handleCancelCustomize = useCallback(async () => {
if (!modalEditUnlocked) {
return;
}
const snap = customizeSnapshotRef.current;
if (!snap) {
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
if (
!(await confirmDirtyCustomizeCancel(
snap,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
))
) {
return;
}
setPendingDraft(structuredClone(snap.pendingDraft));
setDraftFieldBlocks(null);
setModalEditUnlocked(false);
customizeSnapshotRef.current = null;
setCustomizeHeaderDraft(null);
}, [
confirmDirtyCustomizeCancel,
customizeHeaderDraft,
draftFieldBlocks,
modalEditUnlocked,
pendingDraft,
]);
const handleRemoveSelectedFromModal = useCallback(async () => {
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
return;
@@ -290,11 +284,11 @@ export function MembershipMethodsScreen() {
markCreateFlowInteraction();
if (
!(await confirmDiscard(
modalEditUnlocked,
true,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
customizeSnapshotRef.current?.headerDraft ?? null,
))
) {
return;
@@ -306,11 +300,9 @@ export function MembershipMethodsScreen() {
await handleCreateModalClose();
}, [
confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks,
handleCreateModalClose,
markCreateFlowInteraction,
modalEditUnlocked,
pendingDraft,
pendingCardId,
selectedIds,
@@ -320,35 +312,36 @@ export function MembershipMethodsScreen() {
const handleCustomize = useCallback(() => {
markCreateFlowInteraction();
if (!pendingDraft || !pendingCardId) {
if (!pendingCardId) {
return;
}
const initialFieldBlocks =
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? structuredClone(
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [],
)
: null;
const method = methodById.get(pendingCardId);
const meta = state.customMethodCardMetaById?.[pendingCardId];
const headerDraft: MethodCardHeaderDraft = {
title: meta?.label ?? method?.label ?? mem.confirmModal.title,
description:
meta?.supportText ??
method?.supportText ??
mem.confirmModal.description,
};
setCustomizeHeaderDraft(headerDraft);
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
pendingDraft,
initialFieldBlocks,
headerDraft,
setWizardInitialValues(
buildMethodCardWizardInitialValues({
cardId: pendingCardId,
fallbackTitle: method?.label ?? mem.confirmModal.title,
fallbackDescription:
method?.supportText ?? mem.confirmModal.description,
meta: state.customMethodCardMetaById,
persistedBlocks: state.customMethodCardFieldBlocksById,
draftFieldBlocks,
facetPrefill: pendingDraft
? {
group: "membership",
draft: pendingDraft,
headings: mem.sectionHeadings,
}
: undefined,
}),
);
setDraftFieldBlocks(initialFieldBlocks);
setModalEditUnlocked(true);
setWizardCustomizeCardId(pendingCardId);
setCreateModalOpen(false);
setAddCustomWizardOpen(true);
}, [
mem.confirmModal.description,
mem.confirmModal.title,
mem.sectionHeadings,
draftFieldBlocks,
markCreateFlowInteraction,
methodById,
pendingCardId,
@@ -373,9 +366,7 @@ export function MembershipMethodsScreen() {
() => membershipPresetFor(newId),
);
const blocksClone = structuredClone(
modalEditUnlocked &&
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
draftFieldBlocks !== null
? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById,
@@ -405,13 +396,12 @@ export function MembershipMethodsScreen() {
customizeSnapshotRef.current = null;
setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
setDraftFieldBlocks(
blocksClone.length > 0 ? structuredClone(blocksClone) : null,
);
}, [
draftFieldBlocks,
markCreateFlowInteraction,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix,
pendingCardId,
pendingDraft,
@@ -440,9 +430,7 @@ export function MembershipMethodsScreen() {
() => membershipPresetFor(newId),
);
const blocksClone = structuredClone(
modalEditUnlocked &&
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
draftFieldBlocks !== null
? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById,
@@ -472,14 +460,13 @@ export function MembershipMethodsScreen() {
customizeSnapshotRef.current = null;
setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
setDraftFieldBlocks(
blocksClone.length > 0 ? structuredClone(blocksClone) : null,
);
}, [
draftFieldBlocks,
markCreateFlowInteraction,
methodById,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix,
pendingCardId,
pendingDraft,
@@ -492,7 +479,7 @@ export function MembershipMethodsScreen() {
const kebabMenuItems = useMemo(
() =>
buildCustomRuleModalKebabMenu(modalKebabMenu, {
showCustomize: !modalEditUnlocked,
showCustomize: true,
onCustomize: handleCustomize,
onDuplicate:
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
@@ -512,7 +499,6 @@ export function MembershipMethodsScreen() {
handleDuplicatePrefabCard,
handleRemoveSelectedFromModal,
isSelectedCardModal,
modalEditUnlocked,
modalKebabMenu,
pendingCardId,
state.customMethodCardMetaById,
@@ -531,7 +517,7 @@ export function MembershipMethodsScreen() {
meta?.supportText ??
method?.supportText ??
mem.confirmModal.description,
nextButtonText: modalEditUnlocked
nextButtonText: isSelectedCardModal
? saveLabel
: mem.addPlatform.nextButtonText,
};
@@ -543,8 +529,14 @@ export function MembershipMethodsScreen() {
};
const handleCloseAddWizard = useCallback(() => {
const resumeCardId = wizardCustomizeCardId;
setAddCustomWizardOpen(false);
}, []);
setWizardCustomizeCardId(null);
setWizardInitialValues(null);
if (resumeCardId && pendingCardId === resumeCardId) {
setCreateModalOpen(true);
}
}, [pendingCardId, wizardCustomizeCardId]);
const handleFinalizeCustomCard = useCallback(
({
@@ -557,6 +549,42 @@ export function MembershipMethodsScreen() {
fieldBlocks: CustomMethodCardFieldBlock[];
}) => {
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();
updateState({
selectedMembershipMethodIds: moveFacetSelectionIdToFront(
@@ -579,91 +607,29 @@ export function MembershipMethodsScreen() {
},
[
markCreateFlowInteraction,
pendingDraft,
selectedIds,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
state.membershipMethodDetailsById,
updateState,
wizardCustomizeCardId,
],
);
const handleCreateModalPrimary = useCallback(() => {
if (!pendingCardId) {
handleCreateModalClose();
void handleCreateModalClose();
return;
}
markCreateFlowInteraction();
if (selectedIds.includes(pendingCardId)) {
if (modalEditUnlocked) {
if (!customizeHeaderDraft) {
return;
}
const nextMeta = methodCardMetaWithCustomizeHeader(
state.customMethodCardMetaById,
pendingCardId,
customizeHeaderDraft,
);
if (
pendingCardId &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
usesWizardFieldBlocksModalBody({
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
draftFieldBlocks,
customFacetDetailsMatchPreset,
})
) {
updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
});
} else if (pendingDraft) {
updateState({
customMethodCardMetaById: nextMeta,
membershipMethodDetailsById: {
...(state.membershipMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
});
}
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
return;
}
const persistWizardBlocks =
modalUsesWizardFieldBlocksBody && draftFieldBlocks !== null;
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,
})
) {
if (selectedIds.includes(pendingCardId)) {
if (persistWizardBlocks) {
updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
@@ -671,22 +637,20 @@ export function MembershipMethodsScreen() {
});
} else if (pendingDraft) {
updateState({
customMethodCardMetaById: nextMeta,
membershipMethodDetailsById: {
...(state.membershipMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
});
}
pendingEphemeralDuplicateIdRef.current = null;
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
void handleCreateModalClose();
return;
}
if (!pendingDraft) {
handleCreateModalClose();
void handleCreateModalClose();
return;
}
updateState({
@@ -698,15 +662,23 @@ export function MembershipMethodsScreen() {
...(state.membershipMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
...(persistWizardBlocks
? {
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
}
: {}),
});
pendingEphemeralDuplicateIdRef.current = null;
handleCreateModalClose();
customizeSnapshotRef.current = null;
void handleCreateModalClose();
}, [
customizeHeaderDraft,
draftFieldBlocks,
handleCreateModalClose,
markCreateFlowInteraction,
modalEditUnlocked,
modalUsesWizardFieldBlocksBody,
pendingCardId,
pendingDraft,
selectedIds,
@@ -716,74 +688,41 @@ export function MembershipMethodsScreen() {
return (
<>
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-800"
<CreateFlowCardStackStepShell
lockupTitle={title}
lockupDescription={description}
stackBusy={!recommendationsReady}
>
<div className="flex w-full min-w-0 flex-col items-center gap-6">
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={title}
description={description}
justification="center"
/>
</div>
<div
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
aria-busy={!recommendationsReady}
>
{recommendationsReady ? (
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={mem.page.seeAllLink}
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"}
/>
) : null}
</div>
</div>
{recommendationsReady && (
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={mem.page.seeAllLink}
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"}
/>
)}
</CreateFlowCardStackStepShell>
<Create
isOpen={createModalOpen}
onClose={handleCreateModalClose}
headerContent={
modalEditUnlocked && customizeHeaderDraft ? (
<MethodCardCustomizeModalHeader
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
titleValue={customizeHeaderDraft.title}
descriptionValue={customizeHeaderDraft.description}
onTitleChange={(title) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, title } : null,
)
}
onDescriptionChange={(description) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, description } : null,
)
}
/>
) : undefined
}
onNext={handleCreateModalPrimary}
title={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={modalEditUnlocked}
onBack={handleCancelCustomize}
backButtonText={modalKebabMenu.cancelCustomize}
showBackButton={false}
showNextButton={showMethodModalPrimary}
showRemoveButton={isSelectedCardModal}
onRemove={handleRemoveSelectedFromModal}
backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
@@ -795,14 +734,14 @@ export function MembershipMethodsScreen() {
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={
modalEditUnlocked && draftFieldBlocks !== null
? draftFieldBlocks
: undefined
draftFieldBlocks !== null ? draftFieldBlocks : undefined
}
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked}
showPolicyContentLockupWhenNoBlocks={
draftFieldBlocks === null || draftFieldBlocks.length === 0
}
onFieldBlocksChange={
fieldsLocked
draftFieldBlocks === null
? undefined
: (next) => setDraftFieldBlocks(next)
}
@@ -811,15 +750,14 @@ export function MembershipMethodsScreen() {
<MembershipMethodEditFields
value={pendingDraft}
onChange={handleDraftChange}
readOnly={fieldsLocked}
/>
)
) : null}
</Create>
</CreateFlowStepShell>
<CustomMethodCardWizard
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
initialValues={wizardInitialValues}
onFinalize={handleFinalizeCustomCard}
onPersistCustomUploadFile={(file) =>
uploadCreateFlowFile(file, "customMethodAttachment")
@@ -16,6 +16,8 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import {
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
CREATE_FLOW_PAGE_GUTTER_CLASS,
CREATE_FLOW_SCROLL_REGION_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "../../components/createFlowLayoutTokens";
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={`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
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
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
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"
stateField="title"
maxLength={48}
required
/>
);
case "community-structure":
@@ -126,6 +127,7 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
stateField="communityContext"
maxLength={200}
mainAlign="center"
multiline
/>
);
case "community-size":
@@ -1,6 +1,5 @@
"use client";
import type { ReactNode } from "react";
import NumberedList from "../../../../components/type/NumberedList";
import { useMessages } from "../../../../contexts/MessagesContext";
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 (
<CreateFlowStepShell
variant="centeredNarrow"
@@ -57,7 +40,7 @@ export function InformationalScreen() {
>
<CreateFlowHeaderLockup
title={copy.title}
description={description}
description={copy.description}
justification="left"
/>
<NumberedList items={items} size={mdUp ? "M" : "S"} />
@@ -1,8 +1,9 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Rule from "../../../../components/cards/Rule";
import Button from "../../../../components/buttons/Button";
import { useTranslation } from "../../../../contexts/MessagesContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { useCreateFlow } from "../../context/CreateFlowContext";
@@ -17,7 +18,7 @@ import {
vectorMarkPath,
} from "../../../../../lib/assetUtils";
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`). */
export function CommunityReviewScreen() {
@@ -25,16 +26,22 @@ export function CommunityReviewScreen() {
const lgUp = useCreateFlowLgUp();
const t = useTranslation("create.community.review");
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
* before entering community stage, we pinned `pendingTemplateAction` so
* this screen can skip itself they already expressed their intent, no
* reason to make them re-pick from the review footer. We `replace` (not
* `push`) so Back from the destination goes to `community-save` instead of
* bouncing through here again. The action is cleared synchronously via
* `updateState` to guarantee the redirect only fires once: later visits to
* `/create/review` (e.g. navigating here directly) render normally.
* If the user picked **Customize** from a template before finishing community
* stage, we pinned `pendingTemplateAction` so this screen can skip itself
* and `replace` to `core-values`. **Use without changes** does not skip
* identity: if they land here with that pin, send them to `community-name`
* and leave the pin so name description photo stay in the skip list.
*
* Ref guard covers React 18 StrictMode's double-mount in dev so we don't
* fire `router.replace` twice on the same transition.
@@ -44,20 +51,19 @@ export function CommunityReviewScreen() {
if (firedRedirectRef.current) return;
const pending = state.pendingTemplateAction;
if (!pending) return;
const target =
pending.mode === "customize"
? createFlowStepPath("core-values")
: createFlowStepPath("confirm-stakeholders");
if (pending.mode !== "customize") {
firedRedirectRef.current = true;
router.replace(createFlowStepPath("community-name"));
return;
}
const target = createFlowStepPath("core-values");
firedRedirectRef.current = true;
const pinMerge =
pending.mode === "customize"
? {
methodSectionsPinCommitted: {
...state.methodSectionsPinCommitted,
...methodSectionsPinsForHydratedSelections(state),
},
}
: {};
const pinMerge = {
methodSectionsPinCommitted: {
...state.methodSectionsPinCommitted,
...methodSectionsPinsForHydratedSelections(state),
},
};
updateState({ pendingTemplateAction: undefined, ...pinMerge });
router.replace(target);
}, [
@@ -74,11 +80,10 @@ export function CommunityReviewScreen() {
const cardTitle =
typeof state.title === "string" && state.title.trim().length > 0
? state.title.trim()
: t("ruleCard.title");
: "";
/**
* No placeholder fallback: if the user skipped `community-context`, leave
* the card description off rather than render the old "Mutual Aid Monday
* is a grassroots community" sample, which read as real user copy.
* the card description off rather than render sample copy as real user data.
*/
const cardDescription =
typeof state.communityContext === "string" &&
@@ -92,6 +97,36 @@ export function CommunityReviewScreen() {
? state.communityAvatarUrl.trim()
: 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 (
<CreateFlowStepShell
variant="wideGridLoosePadding"
@@ -97,11 +97,12 @@ export function FinalReviewScreen({
/**
* Two modals coexist on this screen:
*
* - {@link FinalReviewChipEditModal} core values + method chips: kebab
* Customize / Remove; values also offer Duplicate under the five-chip cap.
* Save respects the same unlock/dirty rules as the facet create modals;
* writes `{group}DetailsById`, snapshot label (values), `customMethodCardMetaById`,
* and field blocks on Save.
* - {@link FinalReviewChipEditModal} core values + method chips: footer
* **Remove** when the chip is already selected, plus kebab **Remove**;
* values also offer Duplicate under the five-chip cap; method chips
* offer Customize (prefilled custom-policy wizard). Fields are editable on
* open. Save writes `{group}DetailsById` and field blocks; wizard Finalize
* also writes `customMethodCardMetaById`.
* - {@link TemplateChipDetailModal} read-only fallback for chips we
* can't map to an override key (e.g. template body entries on the
* "Use without changes" path where no preset matches the title).
@@ -270,7 +271,7 @@ export function FinalReviewScreen({
}
size={mdUp ? "L" : "M"}
expanded={true}
backgroundColor="bg-[#c9fef9]"
backgroundColor="bg-[var(--color-surface-invert-brand-teal)]"
logoUrl={getAssetPath(vectorMarkPath("mutual-aid"))}
logoAlt={ruleCardTitle}
categories={finalReviewCategories}
@@ -26,6 +26,7 @@ import type { InfoMessageBoxItem } from "../../../../components/controls/InfoMes
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
@@ -33,6 +34,11 @@ import { DecisionApproachEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import { decisionApproachPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
import {
applyDecisionApproachKeyResources,
decisionApproachKeyResourceCheckboxIds,
selectedKeyResourceLabelsFromCheckedIds,
} from "../../../../../lib/create/decisionApproachKeyResources";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
@@ -51,43 +57,59 @@ import type { DecisionApproachDetailEntry } from "../../types";
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import {
captureMethodCardCustomizeSnapshot,
isMethodCardCustomizeUnloadBlocked,
type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession";
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
export function DecisionApproachesScreen() {
const m = useMessages();
const da = m.create.customRule.decisionApproaches;
const modalKebabMenu = m.create.customRule.modalKebabMenu;
const mdUp = useCreateFlowMdUp();
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
useDiscardCustomizeConfirm();
const { confirmDiscard, confirmDialog } = useDiscardCustomizeConfirm();
const { state, updateState, replaceState, markCreateFlowInteraction } =
useCreateFlow();
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
const customizeSnapshotRef = useRef<
MethodCardCustomizeSnapshot<DecisionApproachDetailEntry> | null
>(null);
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
[],
);
const [expanded, setExpanded] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
const [pendingDraft, setPendingDraft] =
useState<DecisionApproachDetailEntry | null>(null);
const [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<
CustomMethodCardFieldBlock[] | 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 messageBoxCheckedIds = decisionApproachKeyResourceCheckboxIds({
detailsById: state.decisionApproachDetailsById,
selectedApproachIds: selectedIds,
reminderIds: state.selectedDecisionKeyResourceIds,
openDraft: pendingDraft,
});
const messageBoxItems: InfoMessageBoxItem[] = useMemo(
() =>
@@ -117,6 +139,8 @@ export function DecisionApproachesScreen() {
const handleOpenAddWizard = useCallback(() => {
markCreateFlowInteraction();
setWizardCustomizeCardId(null);
setWizardInitialValues(null);
setAddCustomWizardOpen(true);
}, [markCreateFlowInteraction]);
@@ -133,22 +157,34 @@ export function DecisionApproachesScreen() {
const handleMessageBoxCheckboxChange = useCallback(
(id: string, checked: boolean) => {
markCreateFlowInteraction();
setMessageBoxCheckedIds((prev) =>
checked ? [...prev, id] : prev.filter((x) => x !== id),
);
const nextCheckedIds = checked
? [...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(
(id: string): DecisionApproachDetailEntry => {
const saved = state.decisionApproachDetailsById?.[id];
if (saved) {
return {
...saved,
applicableScope: [...saved.applicableScope],
selectedApplicableScope: [...saved.selectedApplicableScope],
};
return structuredClone(saved);
}
return decisionApproachPresetFor(id);
},
@@ -158,15 +194,40 @@ export function DecisionApproachesScreen() {
const handleCardSelect = useCallback(
(id: string) => {
markCreateFlowInteraction();
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
const draft = seedDraft(id);
const persistedBlocks = state.customMethodCardFieldBlocksById?.[id];
const initialBlocks =
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);
setPendingDraft(seedDraft(id));
setPendingDraft(draft);
setDraftFieldBlocks(initialBlocks);
setCreateModalOpen(true);
},
[markCreateFlowInteraction, seedDraft],
[
da.confirmModal.description,
da.confirmModal.title,
markCreateFlowInteraction,
methodById,
seedDraft,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
],
);
const handleDraftChange = useCallback(
@@ -179,9 +240,7 @@ export function DecisionApproachesScreen() {
const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId);
const fieldsLocked = !modalEditUnlocked;
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
const showMethodModalPrimary = true;
const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false;
@@ -203,7 +262,7 @@ export function DecisionApproachesScreen() {
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
modalEditUnlocked: true,
draftFieldBlocks,
customFacetDetailsMatchPreset,
}),
@@ -211,7 +270,6 @@ export function DecisionApproachesScreen() {
[
customFacetDetailsMatchPreset,
draftFieldBlocks,
modalEditUnlocked,
pendingCardId,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
@@ -221,11 +279,11 @@ export function DecisionApproachesScreen() {
const handleCreateModalClose = useCallback(async () => {
if (
!(await confirmDiscard(
modalEditUnlocked,
true,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
customizeSnapshotRef.current?.headerDraft ?? null,
))
) {
return;
@@ -253,53 +311,14 @@ export function DecisionApproachesScreen() {
setCreateModalOpen(false);
setPendingCardId(null);
setPendingDraft(null);
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
}, [
confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks,
modalEditUnlocked,
pendingDraft,
replaceState,
]);
const handleCancelCustomize = useCallback(async () => {
if (!modalEditUnlocked) {
return;
}
const snap = customizeSnapshotRef.current;
if (!snap) {
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
if (
!(await confirmDirtyCustomizeCancel(
snap,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
))
) {
return;
}
setPendingDraft(structuredClone(snap.pendingDraft));
setDraftFieldBlocks(null);
setModalEditUnlocked(false);
customizeSnapshotRef.current = null;
setCustomizeHeaderDraft(null);
}, [
confirmDirtyCustomizeCancel,
customizeHeaderDraft,
draftFieldBlocks,
modalEditUnlocked,
pendingDraft,
]);
const handleRemoveSelectedFromModal = useCallback(async () => {
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
return;
@@ -307,11 +326,11 @@ export function DecisionApproachesScreen() {
markCreateFlowInteraction();
if (
!(await confirmDiscard(
modalEditUnlocked,
true,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
customizeSnapshotRef.current?.headerDraft ?? null,
))
) {
return;
@@ -327,11 +346,9 @@ export function DecisionApproachesScreen() {
await handleCreateModalClose();
}, [
confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks,
handleCreateModalClose,
markCreateFlowInteraction,
modalEditUnlocked,
pendingDraft,
pendingCardId,
selectedIds,
@@ -341,35 +358,36 @@ export function DecisionApproachesScreen() {
const handleCustomize = useCallback(() => {
markCreateFlowInteraction();
if (!pendingDraft || !pendingCardId) {
if (!pendingCardId) {
return;
}
const initialFieldBlocks =
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? structuredClone(
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [],
)
: null;
const method = methodById.get(pendingCardId);
const meta = state.customMethodCardMetaById?.[pendingCardId];
const headerDraft: MethodCardHeaderDraft = {
title: meta?.label ?? method?.label ?? da.confirmModal.title,
description:
meta?.supportText ??
method?.supportText ??
da.confirmModal.description,
};
setCustomizeHeaderDraft(headerDraft);
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
pendingDraft,
initialFieldBlocks,
headerDraft,
setWizardInitialValues(
buildMethodCardWizardInitialValues({
cardId: pendingCardId,
fallbackTitle: method?.label ?? da.confirmModal.title,
fallbackDescription:
method?.supportText ?? da.confirmModal.description,
meta: state.customMethodCardMetaById,
persistedBlocks: state.customMethodCardFieldBlocksById,
draftFieldBlocks,
facetPrefill: pendingDraft
? {
group: "decisionApproaches",
draft: pendingDraft,
headings: da.sectionHeadings,
}
: undefined,
}),
);
setDraftFieldBlocks(initialFieldBlocks);
setModalEditUnlocked(true);
setWizardCustomizeCardId(pendingCardId);
setCreateModalOpen(false);
setAddCustomWizardOpen(true);
}, [
da.confirmModal.description,
da.confirmModal.title,
da.sectionHeadings,
draftFieldBlocks,
markCreateFlowInteraction,
methodById,
pendingCardId,
@@ -394,9 +412,7 @@ export function DecisionApproachesScreen() {
() => decisionApproachPresetFor(newId),
);
const blocksClone = structuredClone(
modalEditUnlocked &&
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
draftFieldBlocks !== null
? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById,
@@ -426,13 +442,12 @@ export function DecisionApproachesScreen() {
customizeSnapshotRef.current = null;
setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
setDraftFieldBlocks(
blocksClone.length > 0 ? structuredClone(blocksClone) : null,
);
}, [
draftFieldBlocks,
markCreateFlowInteraction,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix,
pendingCardId,
pendingDraft,
@@ -461,9 +476,7 @@ export function DecisionApproachesScreen() {
() => decisionApproachPresetFor(newId),
);
const blocksClone = structuredClone(
modalEditUnlocked &&
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
draftFieldBlocks !== null
? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById,
@@ -493,14 +506,13 @@ export function DecisionApproachesScreen() {
customizeSnapshotRef.current = null;
setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
setDraftFieldBlocks(
blocksClone.length > 0 ? structuredClone(blocksClone) : null,
);
}, [
draftFieldBlocks,
markCreateFlowInteraction,
methodById,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix,
pendingCardId,
pendingDraft,
@@ -513,7 +525,7 @@ export function DecisionApproachesScreen() {
const kebabMenuItems = useMemo(
() =>
buildCustomRuleModalKebabMenu(modalKebabMenu, {
showCustomize: !modalEditUnlocked,
showCustomize: true,
onCustomize: handleCustomize,
onDuplicate:
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
@@ -533,7 +545,6 @@ export function DecisionApproachesScreen() {
handleDuplicatePrefabCard,
handleRemoveSelectedFromModal,
isSelectedCardModal,
modalEditUnlocked,
modalKebabMenu,
pendingCardId,
state.customMethodCardMetaById,
@@ -547,8 +558,14 @@ export function DecisionApproachesScreen() {
}, [markCreateFlowInteraction]);
const handleCloseAddWizard = useCallback(() => {
const resumeCardId = wizardCustomizeCardId;
setAddCustomWizardOpen(false);
}, []);
setWizardCustomizeCardId(null);
setWizardInitialValues(null);
if (resumeCardId && pendingCardId === resumeCardId) {
setCreateModalOpen(true);
}
}, [pendingCardId, wizardCustomizeCardId]);
const handleFinalizeCustomCard = useCallback(
({
@@ -561,6 +578,42 @@ export function DecisionApproachesScreen() {
fieldBlocks: CustomMethodCardFieldBlock[];
}) => {
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();
updateState({
selectedDecisionApproachIds: moveFacetSelectionIdToFront(
@@ -583,139 +636,89 @@ export function DecisionApproachesScreen() {
},
[
markCreateFlowInteraction,
pendingDraft,
selectedIds,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
state.decisionApproachDetailsById,
updateState,
wizardCustomizeCardId,
],
);
const handleCreateModalPrimary = useCallback(() => {
if (!pendingCardId) {
handleCreateModalClose();
void handleCreateModalClose();
return;
}
markCreateFlowInteraction();
const persistWizardBlocks =
modalUsesWizardFieldBlocksBody && draftFieldBlocks !== null;
if (selectedIds.includes(pendingCardId)) {
if (modalEditUnlocked) {
if (!customizeHeaderDraft) {
return;
}
const nextMeta = methodCardMetaWithCustomizeHeader(
state.customMethodCardMetaById,
pendingCardId,
customizeHeaderDraft,
);
if (
pendingCardId &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
usesWizardFieldBlocksModalBody({
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
draftFieldBlocks,
customFacetDetailsMatchPreset,
})
) {
updateState({
customMethodCardMetaById: nextMeta,
replaceState((prev) => {
if (persistWizardBlocks) {
return {
...prev,
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
...(prev.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
});
} else if (pendingDraft) {
updateState({
customMethodCardMetaById: nextMeta,
decisionApproachDetailsById: {
...(state.decisionApproachDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
});
};
}
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
return;
}
if (modalEditUnlocked) {
if (!customizeHeaderDraft) {
return;
}
const nextMeta = methodCardMetaWithCustomizeHeader(
state.customMethodCardMetaById,
pendingCardId,
customizeHeaderDraft,
);
if (
pendingCardId &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
usesWizardFieldBlocksModalBody({
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
draftFieldBlocks,
customFacetDetailsMatchPreset,
})
) {
updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
});
} else if (pendingDraft) {
updateState({
customMethodCardMetaById: nextMeta,
if (!pendingDraft) {
return prev;
}
return {
...prev,
decisionApproachDetailsById: {
...(state.decisionApproachDetailsById ?? {}),
[pendingCardId]: pendingDraft,
...(prev.decisionApproachDetailsById ?? {}),
[pendingCardId]: structuredClone(pendingDraft),
},
});
}
};
});
pendingEphemeralDuplicateIdRef.current = null;
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
void handleCreateModalClose();
return;
}
if (!pendingDraft) {
handleCreateModalClose();
void handleCreateModalClose();
return;
}
updateState({
replaceState((prev) => ({
...prev,
selectedDecisionApproachIds: moveFacetSelectionIdToFront(
selectedIds,
prev.selectedDecisionApproachIds ?? [],
pendingCardId,
),
decisionApproachDetailsById: {
...(state.decisionApproachDetailsById ?? {}),
[pendingCardId]: pendingDraft,
...(prev.decisionApproachDetailsById ?? {}),
[pendingCardId]: structuredClone(pendingDraft),
},
});
...(persistWizardBlocks
? {
customMethodCardFieldBlocksById: {
...(prev.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
}
: {}),
}));
pendingEphemeralDuplicateIdRef.current = null;
handleCreateModalClose();
customizeSnapshotRef.current = null;
void handleCreateModalClose();
}, [
customizeHeaderDraft,
draftFieldBlocks,
handleCreateModalClose,
markCreateFlowInteraction,
modalEditUnlocked,
modalUsesWizardFieldBlocksBody,
pendingCardId,
pendingDraft,
replaceState,
selectedIds,
state,
updateState,
]);
const modalConfig = pendingCardId
@@ -729,7 +732,7 @@ export function DecisionApproachesScreen() {
meta?.supportText ??
method?.supportText ??
da.confirmModal.description,
nextButtonText: modalEditUnlocked
nextButtonText: isSelectedCardModal
? saveLabel
: da.addApproach.nextButtonText,
};
@@ -766,70 +769,50 @@ export function DecisionApproachesScreen() {
className="flex w-full min-w-0 flex-col items-stretch gap-6 py-0"
aria-busy={!recommendationsReady}
>
{recommendationsReady ? (
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardSelect}
expanded={expanded}
onToggleExpand={handleToggleExpand}
hasMore={true}
toggleLabel={da.cardStack.toggleSeeAll}
showLessLabel={da.cardStack.toggleShowLess}
title=""
description={
expanded ? (
<>
{da.cardStack.expandedStackDescriptionBefore}
<InlineTextButton onClick={handleOpenAddWizard}>
{da.sidebar.descriptionLinkLabel}
</InlineTextButton>
{da.cardStack.expandedStackDescriptionAfter}
</>
) : (
""
)
}
layout="singleStack"
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
className="w-full"
headerLockupSize={mdUp ? "L" : "M"}
/>
) : null}
{recommendationsReady && (
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardSelect}
expanded={expanded}
onToggleExpand={handleToggleExpand}
hasMore={true}
toggleLabel={da.cardStack.toggleSeeAll}
showLessLabel={da.cardStack.toggleShowLess}
title=""
description={
expanded ? (
<>
{da.cardStack.expandedStackDescriptionBefore}
<InlineTextButton onClick={handleOpenAddWizard}>
{da.sidebar.descriptionLinkLabel}
</InlineTextButton>
{da.cardStack.expandedStackDescriptionAfter}
</>
) : (
""
)
}
layout="singleStack"
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
className="w-full"
headerLockupSize={mdUp ? "L" : "M"}
/>
)}
</div>
<Create
isOpen={createModalOpen}
onClose={handleCreateModalClose}
headerContent={
modalEditUnlocked && customizeHeaderDraft ? (
<MethodCardCustomizeModalHeader
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
titleValue={customizeHeaderDraft.title}
descriptionValue={customizeHeaderDraft.description}
onTitleChange={(title) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, title } : null,
)
}
onDescriptionChange={(description) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, description } : null,
)
}
/>
) : undefined
}
onNext={handleCreateModalPrimary}
title={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={modalEditUnlocked}
onBack={handleCancelCustomize}
backButtonText={modalKebabMenu.cancelCustomize}
showBackButton={false}
showNextButton={showMethodModalPrimary}
showRemoveButton={isSelectedCardModal}
onRemove={handleRemoveSelectedFromModal}
backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
@@ -841,14 +824,14 @@ export function DecisionApproachesScreen() {
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={
modalEditUnlocked && draftFieldBlocks !== null
? draftFieldBlocks
: undefined
draftFieldBlocks !== null ? draftFieldBlocks : undefined
}
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked}
showPolicyContentLockupWhenNoBlocks={
draftFieldBlocks === null || draftFieldBlocks.length === 0
}
onFieldBlocksChange={
fieldsLocked
draftFieldBlocks === null
? undefined
: (next) => setDraftFieldBlocks(next)
}
@@ -857,7 +840,6 @@ export function DecisionApproachesScreen() {
<DecisionApproachEditFields
value={pendingDraft}
onChange={handleDraftChange}
readOnly={fieldsLocked}
/>
)
) : null}
@@ -866,6 +848,7 @@ export function DecisionApproachesScreen() {
<CustomMethodCardWizard
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
initialValues={wizardInitialValues}
onFinalize={handleFinalizeCustomCard}
onPersistCustomUploadFile={(file) =>
uploadCreateFlowFile(file, "customMethodAttachment")
@@ -303,7 +303,6 @@ export function CommunityStructureSelectScreen() {
<>
<MultiSelect
label={cs.organizationMultiSelect.label}
showHelpIcon
size="s"
options={organizationTypeOptions}
onChipClick={handleOrganizationTypeClick}
@@ -313,7 +312,6 @@ export function CommunityStructureSelectScreen() {
/>
<MultiSelect
label={cs.scaleMultiSelect.label}
showHelpIcon
size="s"
options={scaleOptions}
onChipClick={handleScaleClick}
@@ -323,7 +321,6 @@ export function CommunityStructureSelectScreen() {
/>
<MultiSelect
label={cs.maturityMultiSelect.label}
showHelpIcon
size="s"
options={maturityOptions}
onChipClick={handleMaturityClick}
@@ -9,8 +9,7 @@ import { useTranslation } from "../../../../contexts/MessagesContext";
import { MAX_STAKEHOLDER_EMAILS } from "../../../../../lib/create/stakeholderLimits";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import {
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
@@ -131,64 +130,52 @@ export function ConfirmStakeholdersScreen() {
if (managePublishedMode) {
return (
<CreateFlowStepShell
variant="centeredNarrowBottomPad"
contentTopBelowMd="space-1400"
<CreateFlowTwoColumnSelectShell
header={
<CreateFlowHeaderLockup
title={t("managePublished.lockupTitle")}
description={t("managePublished.lockupDescription")}
justification="left"
/>
}
>
<div
className={`flex flex-col items-start gap-[var(--measures-spacing-300,12px)] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
<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>
<PublishedStakeholdersManagePanel ruleId={editingPublishedRuleId} />
</CreateFlowTwoColumnSelectShell>
);
}
return (
<>
<CreateFlowStepShell
variant="centeredNarrowBottomPad"
contentTopBelowMd="space-1400"
>
<div
className={`flex flex-col items-start gap-[var(--measures-spacing-300,12px)] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
<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")}
<CreateFlowTwoColumnSelectShell
header={
<CreateFlowHeaderLockup
title={t("title")}
description={t("description")}
justification="left"
/>
</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 && (
<div
@@ -8,7 +8,8 @@ import ContentLockup from "../../../../components/type/ContentLockup";
import { useMessages } from "../../../../contexts/MessagesContext";
import { buildCoreValueChipOptionsFromDraft } from "../../../../../lib/create/coreValueChipOptionsFromDraft";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import type {
CommunityStructureChipSnapshotRow,
CoreValueDetailEntry,
@@ -16,20 +17,25 @@ import type {
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
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 {
captureMethodCardCustomizeSnapshot,
isMethodCardCustomizeSessionDirty,
type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession";
import {
duplicateCoreValueChipInDraft,
MAX_SELECTED_CORE_VALUES,
removeCoreValueChipFromDraft,
} 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;
@@ -37,13 +43,14 @@ const MAX_CORE_VALUES = MAX_SELECTED_CORE_VALUES;
* Why three sessions, not two:
*
* - `pending` preset chip just selected; modal opened to capture
* meaning/signals. Dismiss = unselect the chip (keep it in the
* preset row, just not selected).
* - `customPending` brand-new custom chip just created via the Add
* value flow; modal opened with empty fields. Dismiss = drop the
* chip entirely (it was never confirmed via the Add Value button).
* meaning/signals. Close (X) confirms, then unselects the chip.
* - `customPending` leftover inline custom-chip drafts (older sessions).
* Dismiss = drop the chip entirely.
* - `editing` chip already exists & is selected; modal reopened to
* 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";
@@ -101,13 +108,11 @@ export function CoreValuesSelectScreen() {
[cv.values],
);
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
useDiscardCustomizeConfirm();
const { requestConfirm, confirmDialog } = useAsyncConfirm();
const { markCreateFlowInteraction, updateState, replaceState, state } =
useCreateFlow();
const coreCustomizeSnapshotRef =
useRef<MethodCardCustomizeSnapshot<CoreValueDetailEntry> | null>(null);
const initialDraftRef = useRef<CoreValueDetailEntry | null>(null);
const pendingEphemeralCoreDuplicateRef = useRef<string | null>(null);
const [coreValueOptions, setCoreValueOptions] = useState<ChipOption[]>(() =>
@@ -123,9 +128,15 @@ export function CoreValuesSelectScreen() {
);
const [modalSession, setModalSession] = useState<ModalSession | null>(null);
const [draft, setDraft] = useState<CoreValueDetailEntry>(EMPTY_DETAIL);
const [modalEditUnlocked, setModalEditUnlocked] = useState(false);
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
useState<MethodCardHeaderDraft | null>(null);
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
const [wizardCustomizeChipId, setWizardCustomizeChipId] = useState<
string | null
>(null);
const [wizardInitialValues, setWizardInitialValues] =
useState<MethodCardWizardInitialValues | null>(null);
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
CustomMethodCardFieldBlock[] | null
>(null);
useEffect(() => {
setCoreValueOptions(
@@ -178,6 +189,7 @@ export function CoreValuesSelectScreen() {
return {
meaning: saved?.meaning ?? preset.meaning,
signals: saved?.signals ?? preset.signals,
...(saved?.supportText ? { supportText: saved.supportText } : {}),
};
},
[state.coreValueDetailsByChipId, getPresetTexts],
@@ -190,15 +202,24 @@ export function CoreValuesSelectScreen() {
valueLabel: string,
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);
setModalSession(session);
setModalEditUnlocked(false);
setCustomizeHeaderDraft(null);
coreCustomizeSnapshotRef.current = null;
markCreateFlowInteraction();
},
[getInitialTexts, markCreateFlowInteraction],
[
getInitialTexts,
markCreateFlowInteraction,
state.customMethodCardFieldBlocksById,
],
);
const handleDraftChange = useCallback(
@@ -209,87 +230,44 @@ export function CoreValuesSelectScreen() {
[markCreateFlowInteraction],
);
const resetCustomizeSession = useCallback(() => {
coreCustomizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setCustomizeHeaderDraft(null);
}, []);
const finalizeModalDismiss = useCallback(() => {
pendingEphemeralCoreDuplicateRef.current = null;
resetCustomizeSession();
initialDraftRef.current = null;
setActiveModalChipId(null);
setModalSession(null);
}, [resetCustomizeSession]);
setDraftFieldBlocks(null);
}, []);
const handleCustomize = useCallback(() => {
if (!activeModalChipId) return;
const chipLabelNow =
coreValueOptions.find((o) => o.id === activeModalChipId)?.label ?? "";
if (!chipLabelNow) return;
markCreateFlowInteraction();
const headerDraft: MethodCardHeaderDraft = {
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;
const confirmLeaveWithoutSaving = useCallback(async () => {
const initial = initialDraftRef.current;
const fieldsDirty =
initial != null &&
(draft.meaning !== initial.meaning || draft.signals !== initial.signals);
if (!fieldsDirty) {
return true;
}
if (
!(await confirmDirtyCustomizeCancel(
snap,
draft,
null,
customizeHeaderDraft,
))
) {
return;
}
setDraft(structuredClone(snap.pendingDraft));
resetCustomizeSession();
}, [
confirmDirtyCustomizeCancel,
customizeHeaderDraft,
draft,
modalEditUnlocked,
resetCustomizeSession,
]);
const isPendingAdd =
modalSession === "pending" || modalSession === "customPending";
return requestConfirm({
title: cv.detailModal.discardTitle,
description: isPendingAdd
? cv.detailModal.discardPendingDescription
: cv.detailModal.discardEditsDescription,
proceedText: cv.detailModal.discardProceed,
cancelText: cv.detailModal.discardKeepEditing,
});
}, [cv.detailModal, draft, modalSession, requestConfirm]);
const syncLabelFromCustomizeHeaderToOptions = useCallback(() => {
if (!activeModalChipId || !customizeHeaderDraft) return coreValueOptions;
const trimmed = customizeHeaderDraft.title.trim();
if (!trimmed) return coreValueOptions;
return coreValueOptions.map((opt) =>
opt.id === activeModalChipId ? { ...opt, label: trimmed } : opt,
);
}, [activeModalChipId, customizeHeaderDraft, coreValueOptions]);
useBeforeUnloadGuard(
activeModalChipId != null &&
!addCustomWizardOpen &&
initialDraftRef.current != null &&
(draft.meaning !== initialDraftRef.current.meaning ||
draft.signals !== initialDraftRef.current.signals),
);
const handleDuplicateCoreChip = useCallback(async () => {
const handleDuplicateCoreChip = useCallback(() => {
if (!activeModalChipId || !modalSession) return;
if (
!(await confirmDiscard(
modalEditUnlocked,
coreCustomizeSnapshotRef.current,
draft,
null,
customizeHeaderDraft,
))
) {
return;
}
markCreateFlowInteraction();
const priorEphemeral = pendingEphemeralCoreDuplicateRef.current;
let outcome: ReturnType<typeof duplicateCoreValueChipInDraft> | null = null;
@@ -312,7 +290,6 @@ export function CoreValuesSelectScreen() {
if (!outcome) {
return;
}
resetCustomizeSession();
pendingEphemeralCoreDuplicateRef.current = outcome.newId;
openModal(
outcome.newId,
@@ -322,30 +299,15 @@ export function CoreValuesSelectScreen() {
);
}, [
activeModalChipId,
confirmDiscard,
customizeHeaderDraft,
draft,
markCreateFlowInteraction,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix,
modalSession,
openModal,
replaceState,
resetCustomizeSession,
]);
const handleRemoveFromKebab = useCallback(async () => {
if (
!(await confirmDiscard(
modalEditUnlocked,
coreCustomizeSnapshotRef.current,
draft,
null,
customizeHeaderDraft,
))
) {
return;
}
const handleRemoveFromKebab = useCallback(() => {
markCreateFlowInteraction();
const ep = pendingEphemeralCoreDuplicateRef.current;
@@ -375,39 +337,23 @@ export function CoreValuesSelectScreen() {
markCreateFlowInteraction();
replaceState((prev) => ({
...prev,
selectedCoreValueIds: selectedIdsFromOptions(nextFiltered),
coreValuesChipsSnapshot:
chipOptionsToSnapshotRows(nextFiltered),
coreValueDetailsByChipId:
omitIdFromStringRecord(prev.coreValueDetailsByChipId, activeModalChipId),
...removeCoreValueChipFromDraft(prev, activeModalChipId),
}));
setCoreValueOptions(nextFiltered);
}
finalizeModalDismiss();
}, [
activeModalChipId,
confirmDiscard,
coreValueOptions,
customizeHeaderDraft,
draft,
finalizeModalDismiss,
markCreateFlowInteraction,
modalEditUnlocked,
modalSession,
persistCoreValues,
replaceState,
]);
const handleModalDismiss = useCallback(async () => {
if (
!(await confirmDiscard(
modalEditUnlocked,
coreCustomizeSnapshotRef.current,
draft,
null,
customizeHeaderDraft,
))
) {
if (!(await confirmLeaveWithoutSaving())) {
return;
}
@@ -436,118 +382,242 @@ export function CoreValuesSelectScreen() {
finalizeModalDismiss();
}, [
activeModalChipId,
confirmDiscard,
confirmLeaveWithoutSaving,
coreValueOptions,
customizeHeaderDraft,
draft,
finalizeModalDismiss,
modalEditUnlocked,
modalSession,
persistCoreValues,
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(() => {
if (!activeModalChipId || !modalSession) return;
if (modalEditUnlocked && customizeHeaderDraft) {
if (coreCustomizeSaveDisabled) {
return;
}
markCreateFlowInteraction();
pendingEphemeralCoreDuplicateRef.current = null;
const nextOpts = syncLabelFromCustomizeHeaderToOptions();
persistCoreValues(nextOpts);
updateState({
coreValueDetailsByChipId: {
...(state.coreValueDetailsByChipId ?? {}),
[activeModalChipId]: draft,
},
});
resetCustomizeSession();
return;
}
if (modalSession === "pending" || modalSession === "customPending") {
markCreateFlowInteraction();
pendingEphemeralCoreDuplicateRef.current = null;
updateState({
coreValueDetailsByChipId: {
...(state.coreValueDetailsByChipId ?? {}),
[activeModalChipId]: draft,
},
});
resetCustomizeSession();
setActiveModalChipId(null);
setModalSession(null);
}
markCreateFlowInteraction();
pendingEphemeralCoreDuplicateRef.current = null;
const existingBlocks =
draftFieldBlocks && draftFieldBlocks.length > 0
? draftFieldBlocks
: state.customMethodCardFieldBlocksById?.[activeModalChipId];
updateState({
coreValueDetailsByChipId: {
...(state.coreValueDetailsByChipId ?? {}),
[activeModalChipId]: draft,
},
...(existingBlocks && existingBlocks.length > 0
? {
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[activeModalChipId]: overlayFacetPrefillValues(existingBlocks, {
group: "coreValues",
draft,
headings: {
meaning: cv.detailModal.meaningLabel,
signals: cv.detailModal.signalsLabel,
},
}),
},
}
: {}),
});
finalizeModalDismiss();
}, [
activeModalChipId,
coreCustomizeSaveDisabled,
customizeHeaderDraft,
cv.detailModal.meaningLabel,
cv.detailModal.signalsLabel,
draft,
draftFieldBlocks,
finalizeModalDismiss,
markCreateFlowInteraction,
modalEditUnlocked,
modalSession,
persistCoreValues,
resetCustomizeSession,
state.coreValueDetailsByChipId,
syncLabelFromCustomizeHeaderToOptions,
state.customMethodCardFieldBlocksById,
updateState,
]);
const modalChipLabel =
coreValueOptions.find((o) => o.id === activeModalChipId)?.label ?? "";
const modalFieldsLocked =
!modalEditUnlocked &&
Boolean(
modalSession === "pending" ||
modalSession === "customPending" ||
modalSession === "editing",
);
const modalUsesWizardFieldBlocksBody = Boolean(
activeModalChipId &&
usesWizardFieldBlocksModalBody({
methodId: activeModalChipId,
meta: {},
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked: true,
draftFieldBlocks,
}),
);
const showFooterPrimary =
modalEditUnlocked ||
modalSession === "pending" ||
modalSession === "customPending";
const showFooterPrimary = Boolean(modalSession);
const handleCustomize = useCallback(() => {
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(() => {
if (!modalSession || !activeModalChipId) return [];
const selectedCount = coreValueOptions.filter(
(o) => o.state === "selected",
).length;
return buildCustomRuleModalKebabMenu(modalKebabMenu, {
showCustomize: !modalEditUnlocked,
showCustomize: true,
onCustomize: handleCustomize,
onDuplicate:
modalSession !== "editing" || selectedCount >= MAX_CORE_VALUES
(state.editingPublishedRuleId?.trim() ?? "") !== "" ||
atSelectionLimit
? undefined
: handleDuplicateCoreChip,
showRemove: true,
showRemove: modalSession === "editing",
onRemove: handleRemoveFromKebab,
});
}, [
activeModalChipId,
coreValueOptions,
atSelectionLimit,
handleCustomize,
handleDuplicateCoreChip,
handleRemoveFromKebab,
modalEditUnlocked,
modalKebabMenu,
modalSession,
state.editingPublishedRuleId,
]);
const handleChipClick = (chipId: string) => {
const target = coreValueOptions.find((o) => o.id === chipId);
@@ -575,15 +645,14 @@ export function CoreValuesSelectScreen() {
const addHandlers = {
onAddClick: () => {
const selectedCount = coreValueOptions.filter(
(o) => o.state === "selected",
).length;
if (selectedCount >= MAX_CORE_VALUES) return;
markCreateFlowInteraction();
setCoreValueOptions((prev) => {
const next: ChipOption[] = [
...prev,
{ id: crypto.randomUUID(), label: "", state: "custom" },
];
queueMicrotask(() => syncCoreValuesToDraft(next));
return next;
});
setWizardCustomizeChipId(null);
setWizardInitialValues(null);
setAddCustomWizardOpen(true);
},
onCustomChipConfirm: (chipId: string, value: string) => {
markCreateFlowInteraction();
@@ -632,7 +701,8 @@ export function CoreValuesSelectScreen() {
<button
type="button"
onClick={addHandlers.onAddClick}
className="cursor-pointer font-inter font-normal leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] hover:opacity-90"
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}
</button>
@@ -667,49 +737,37 @@ export function CoreValuesSelectScreen() {
onCustomChipClose={addHandlers.onCustomChipClose}
addButton
addButtonText={cv.multiSelect.addButtonText}
maxSelections={MAX_CORE_VALUES}
selectionCountText={selectionCountText}
limitReachedAnnouncement={cv.multiSelect.limitReached}
/>
{detailModal && (
<Create
isOpen={activeModalChipId !== null}
isOpen={activeModalChipId !== null && !addCustomWizardOpen}
onClose={handleModalDismiss}
backdropVariant="blurredYellow"
headerContent={
modalEditUnlocked && customizeHeaderDraft ? (
<MethodCardCustomizeModalHeader
titleLabel={detailModal.customizeValueNameLabel}
descriptionLabel=""
titleValue={customizeHeaderDraft.title}
descriptionValue=""
onTitleChange={(title) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, title } : null,
)
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup
title={modalChipLabel}
description={
draft.supportText?.trim() || detailModal.subtitle
}
onDescriptionChange={() => {}}
showDescription={false}
variant="modal"
alignment="left"
/>
) : (
<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>
)
</div>
}
showBackButton={modalEditUnlocked}
onBack={handleCancelCustomize}
backButtonText={modalKebabMenu.cancelCustomize}
showBackButton={false}
showNextButton={showFooterPrimary}
nextButtonDisabled={
modalEditUnlocked && coreCustomizeSaveDisabled
}
showRemoveButton={modalSession === "editing"}
onRemove={handleRemoveFromKebab}
onNext={handleModalConfirm}
nextButtonText={
modalEditUnlocked ? modalKebabMenu.saveEdits : detailModal.addValueButton
modalSession === "editing"
? modalKebabMenu.saveEdits
: detailModal.addValueButton
}
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
@@ -718,14 +776,43 @@ export function CoreValuesSelectScreen() {
}
ariaLabel={modalChipLabel || "Core value details"}
>
<CoreValueEditFields
readOnly={modalFieldsLocked}
value={draft}
onChange={handleDraftChange}
/>
{modalUsesWizardFieldBlocksBody && activeModalChipId ? (
<CustomMethodCardModalBody
cardId={activeModalChipId}
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>
)}
</CreateFlowTwoColumnSelectShell>
<CustomMethodCardWizard
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
initialValues={wizardInitialValues}
onFinalize={handleFinalizeCustomCard}
onPersistCustomUploadFile={(file) =>
uploadCreateFlowFile(file, "customMethodAttachment")
}
/>
{confirmDialog}
</>
);
@@ -109,7 +109,7 @@ export function PublishedStakeholdersManagePanel({
<section className="flex w-full flex-col gap-4 pt-1 pb-2">
{bannerError ? (
<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"
>
{bannerError}
@@ -117,15 +117,15 @@ export function PublishedStakeholdersManagePanel({
) : null}
{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")}
</p>
) : 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")}
</p>
) : 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")}
</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"
>
<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}
</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"
? t("managePublished.pending")
: t("managePublished.accepted")}
@@ -2,6 +2,7 @@
import { useState, useEffect, type HTMLInputTypeAttribute } from "react";
import TextInput from "../../../../components/controls/TextInput";
import TextArea from "../../../../components/controls/TextArea";
import type { HeaderLockupJustificationValue } from "../../../../components/type/HeaderLockup/HeaderLockup.types";
import { useTranslation } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
@@ -12,6 +13,7 @@ import {
type CreateFlowContentTopBelowMd,
} from "../../components/CreateFlowStepShell";
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";
type Props = {
@@ -21,6 +23,10 @@ type Props = {
/** Figma Flow — Text (`20094:41243`): main column `items-center` + horizontal padding token. */
mainAlign?: "start" | "center";
inputType?: HTMLInputTypeAttribute;
/** Multi-line control (community description). */
multiline?: boolean;
/** Native `required` (community name and save-progress email). */
required?: boolean;
showCharacterCount?: boolean;
headerJustification?: HeaderLockupJustificationValue;
/** 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({
messageNamespace,
@@ -36,6 +42,8 @@ export function CreateFlowTextFieldScreen({
maxLength,
mainAlign = "start",
inputType = "text",
multiline = false,
required = false,
showCharacterCount = true,
headerJustification = "left",
contentTopBelowMd = "space-1400",
@@ -69,6 +77,15 @@ export function CreateFlowTextFieldScreen({
const mainItems =
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 (
<CreateFlowStepShell
variant="centeredNarrow"
@@ -85,22 +102,60 @@ export function CreateFlowTextFieldScreen({
/>
</div>
<div className="w-full">
<TextInput
className="!transition-none"
type={inputType}
placeholder={t("placeholder")}
value={value}
onChange={(e) => {
const v = e.target.value;
setValue(v);
markCreateFlowInteraction();
updateState({ [stateField]: v } as Record<string, string>);
}}
inputSize={mdUp ? "medium" : "small"}
formHeader={false}
textHint={hint}
maxLength={maxLength}
/>
{multiline ? (
<TextArea
className="!transition-none"
label={fieldLabel}
placeholder={t("placeholder")}
value={value}
onChange={(e) => persistValue(e.target.value)}
onBlur={() => {
const trimmed = value.trim();
if (trimmed !== value) persistValue(trimmed);
}}
size={mdUp ? "medium" : "small"}
formHeader={false}
showHelpIcon={false}
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>
</CreateFlowStepShell>
@@ -16,14 +16,24 @@ import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowL
import { fetchAuthSession } from "../../../../../lib/create/api";
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
import {
UploadToServerError,
createFlowUploadFailureMessageKey,
uploadCreateFlowFile,
} from "../../../../../lib/create/uploadToServer";
import {
COMMUNITY_AVATAR_ACCEPT,
messageKeyForCreateFlowUploadReason,
validateCreateFlowUploadFile,
} from "../../../../../lib/create/createFlowUploadValidation";
import {
clearPendingCommunityAvatarFile,
readPendingCommunityAvatarFile,
storePendingCommunityAvatarFile,
} 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`. */
export function CommunityUploadScreen() {
const m = useMessages();
@@ -54,17 +64,57 @@ export function CommunityUploadScreen() {
[localPreviewUrl],
);
const resolveUploadError = useCallback(
(err: unknown) => {
if (err instanceof UploadToServerError) {
if (err.status === 413) return tUpload("errors.tooLarge");
if (err.status === 401) return tUpload("errors.unauthorized");
if (err.code === "server_misconfigured") {
return tUpload("errors.misconfigured");
const serverAvatarUrl = hasCommunityAvatarUrl(state.communityAvatarUrl)
? state.communityAvatarUrl!.trim()
: null;
const serverAvatarUrlRef = useRef(serverAvatarUrl);
serverAvatarUrlRef.current = serverAvatarUrl;
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],
);
@@ -94,6 +144,16 @@ export function CommunityUploadScreen() {
}
if (signedIn === false) {
const validated = await validateCreateFlowUploadFile(
file,
"communityAvatar",
);
if (validated.ok === false) {
setErrorMessage(
tUpload(messageKeyForCreateFlowUploadReason(validated.reason)),
);
return;
}
try {
await storePendingCommunityAvatarFile(file);
setLocalPreviewUrl((prev) => {
@@ -121,24 +181,16 @@ export function CommunityUploadScreen() {
if (prev) URL.revokeObjectURL(prev);
return null;
});
if (
typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0
) {
if (hasCommunityAvatarUrl(state.communityAvatarUrl)) {
updateState({ communityAvatarUrl: undefined });
}
// Clear any anonymous staged blob so the post-sign-in flush won't resurrect it.
void clearPendingCommunityAvatarFile();
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}, [markCreateFlowInteraction, state.communityAvatarUrl, updateState]);
const displaySrc =
typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0
? state.communityAvatarUrl.trim()
: localPreviewUrl;
const displaySrc = serverAvatarUrl ?? localPreviewUrl;
const hasPreview = typeof displaySrc === "string" && displaySrc.length > 0;
return (
@@ -161,7 +213,7 @@ export function CommunityUploadScreen() {
type="file"
className="sr-only"
tabIndex={-1}
accept="image/jpeg,image/png,image/webp,image/gif"
accept={COMMUNITY_AVATAR_ACCEPT}
aria-label={u.hintText}
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 = {
meaning: string;
signals: string;
supportText?: string;
};
/**
@@ -84,7 +85,11 @@ export type DecisionApproachDetailEntry = {
applicableScope: string[];
selectedApplicableScope: 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;
};
@@ -164,6 +169,12 @@ export interface CreateFlowState {
>;
membershipMethodDetailsById?: Record<string, MembershipMethodDetailEntry>;
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<
string,
ConflictManagementDetailEntry
@@ -183,11 +194,13 @@ export interface CreateFlowState {
customMethodCardFieldBlocksById?: Record<string, CustomMethodCardFieldBlock[]>;
/**
* Set when a user picks a template (Customize or Use without changes) before
* completing the community stage. The community-review screen consumes this
* to `router.replace` past `/create/review` to the correct downstream step
* (`core-values` for customize; `confirm-stakeholders` for use-without-changes)
* once community data is captured. Cleared the moment the redirect fires, so
* later visits to `/create/review` render normally.
* completing the community stage. Customize: the community-review screen
* `router.replace`s past `/create/review` to `core-values`. Use without
* changes: navigation skips to name description optional photo, then
* `confirm-stakeholders` (the pin keeps that skip list active). Set only for
* **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?: {
slug: string;
@@ -195,23 +208,20 @@ export interface CreateFlowState {
};
/**
* Set when the user chooses **Use without changes** on a template-review
* page. The custom-rule segment (`core-values` `conflict-management`) is
* skipped, so linear `getPreviousStep("confirm-stakeholders")` would wrongly
* point at `conflict-management`. Navigation uses this slug so Back from
* `confirm-stakeholders` returns to `/create/review-template/{slug}`.
* Cleared when the user picks **Customize** from template review (normal
* linear back applies) or when the flow state is cleared.
* page. Used so Back from `community-name` (identity-only path) returns to
* `/create/review-template/{slug}` instead of the intro. Cleared when the user
* picks **Customize** from template review or when the flow state is cleared.
*/
templateReviewBackSlug?: string;
/**
* True when the user opened `/create/review-template/{slug}` from the create
* wizard (`/templates?fromFlow=1` after `/create/review`). Persisted so Back
* wizard (`/create/templates` after `/create/review`). Persisted so Back
* from template review targets `/create/review` and so returning from
* `confirm-stakeholders` can re-apply `?fromFlow=1` on the template URL.
*/
templateReviewEntryFromCreateFlow?: boolean;
/**
* 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.
*/
editingPublishedRuleId?: string;
@@ -268,9 +278,8 @@ export interface CreateFlowContextValue {
*
* Current consumer: {@link SignedInDraftHydration} when a signed-in user
* has already started editing, we skip replaying their server draft on top
* of in-progress local state. Save & Exit visibility is driven by step
* index (`SAVE_EXIT_FROM_STEP_INDEX` in `CreateFlowLayoutClient`), not this
* flag.
* of in-progress local state. Save & Exit visibility is
* `shouldOfferCreateFlowSaveAndExit` in `utils/flowSteps.ts`, not this flag.
*/
interactionTouched: boolean;
markCreateFlowInteraction: () => void;
@@ -21,7 +21,13 @@ export function readCoreValueDetailsFromLocalStorage(): Record<
if (typeof o.meaning !== "string" || typeof o.signals !== "string") {
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;
} catch {
@@ -11,7 +11,7 @@
* exposes a native size that matches, this module should collapse.
*/
export const CREATE_FLOW_FOOTER_BUTTON_CLASS =
"md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] " +
"!text-x-small-label md:!text-small-label " +
"!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)]";
@@ -10,8 +10,8 @@ type FooterMessages = typeof footerMessages;
* from this map fall back to `footer.next`.
*
* `final-review` is handled separately by the caller because its label
* also depends on the in-flight publish flag (`finalizeButtonPublishing`
* vs `finalizeCommunityRule`).
* also depends on the in-flight publish flag (`publishButtonPublishing`
* vs `publishCommunityRule`).
*/
const DEFAULT_FOOTER_LABEL_BY_STEP: ReadonlyMap<
CreateFlowStep,
@@ -10,12 +10,18 @@ import {
FIRST_STEP,
} 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 = {
root: "/",
createRoot: "/create",
/** Direct path to the first wizard step so client navigations skip the redirect hop. */
createFirstStep: `/create/${FIRST_STEP}`,
review: "/create/review",
/** In-flow template catalog (wizard chrome). Marketing catalog remains `/templates`. */
templatesPicker: "/create/templates",
finalReview: "/create/final-review",
completed: "/create/completed",
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-3", // decision-approaches (Figma Flow — Right Rail `20523:23509`)
"3-0", // conflict-management (Figma Flow — Compact Card Stack `20879:15979`; start of Review segment)
"3-1", // confirm-stakeholders
"3-2", // confirm-stakeholders (same fill as final-review)
"3-2", // final-review
"3-2", // completed
] 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). */
export type CreateFlowNavigationOptions = {
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
*/
@@ -57,13 +93,14 @@ export function getNextStep(
options?: CreateFlowNavigationOptions,
): CreateFlowStep | null {
if (!currentStep) return null;
const index = FLOW_STEP_ORDER.indexOf(currentStep);
if (index === -1 || index === FLOW_STEP_ORDER.length - 1) return null;
const next = FLOW_STEP_ORDER[index + 1] as CreateFlowStep;
if (options?.skipCommunitySave && next === "community-save") {
return getNextStep("community-save", options);
let index = FLOW_STEP_ORDER.indexOf(currentStep);
if (index === -1) return null;
while (index < FLOW_STEP_ORDER.length - 1) {
index += 1;
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,
): CreateFlowStep | null {
if (!currentStep) return null;
const index = FLOW_STEP_ORDER.indexOf(currentStep);
let index = FLOW_STEP_ORDER.indexOf(currentStep);
if (index <= 0) return null;
const prev = FLOW_STEP_ORDER[index - 1] as CreateFlowStep;
if (options?.skipCommunitySave && prev === "community-save") {
return getPreviousStep("community-save", options);
while (index > 0) {
index -= 1;
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
* step in {@link FLOW_STEP_ORDER}; when the user reached `confirm-stakeholders`
* via template **Use without changes**, Back returns to template review instead
* of `conflict-management` (that segment was skipped).
* step in {@link FLOW_STEP_ORDER}.
*
* 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 =
| { kind: "step"; step: CreateFlowStep }
@@ -102,7 +146,18 @@ export function resolveCreateFlowBackTarget(
typeof templateReviewBackSlug === "string"
? 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 };
}
const prev = getPreviousStep(currentStep, options);
@@ -117,15 +172,37 @@ export function getStepIndex(step: CreateFlowStep | null | undefined): number {
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
* (`CreateFlowLayoutClient` Linear CR-92 §4).
* (`CreateFlowLayoutClient`).
*/
export const CREATE_FLOW_SELECT_SPLIT_SCROLL_STEPS: readonly CreateFlowStep[] = [
"community-size",
"community-structure",
"core-values",
"decision-approaches",
"confirm-stakeholders",
] as const;
export function createFlowStepUsesSelectSplitScroll(
@@ -158,21 +235,53 @@ export function parseCreateFlowScreenFromPathname(
): CreateFlowStep | null {
if (!pathname || pathname.length === 0) return null;
if (pathname.includes("/create/review-template/")) return null;
if (isCreateFlowTemplatesPickerPath(pathname)) return null;
const parts = pathname.split("/").filter(Boolean);
const createIdx = parts.indexOf("create");
if (createIdx === -1 || createIdx >= parts.length - 1) return null;
const segment = parts[createIdx + 1];
if (segment === "review-template") return null;
if (segment === "review-template" || segment === "templates") return 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_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`.
* 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_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_VALUE = "1" as const;
@@ -11,6 +11,14 @@ import { isBackendSyncEnabled } from "../../../../lib/create/backendSyncEnabled"
*/
export const FRESH_ENTRY_PENDING_KEY = "create:fresh-entry-pending";
export type PrepareFreshCreateFlowEntryOptions = {
/**
* When `true`, and backend sync is on, also `DELETE /api/drafts/me`.
* Omit or pass `false` for guests they have no server draft to clear.
*/
signedIn?: boolean;
};
export function hasFreshEntryPending(): boolean {
if (typeof window === "undefined") return false;
try {
@@ -38,10 +46,17 @@ function clearFreshEntryPending(): void {
}
}
function clearServerDraftWhenSignedIn(signedIn: boolean): void {
if (!signedIn || !isBackendSyncEnabled()) return;
setFreshEntryPending();
void deleteServerDraft().finally(clearFreshEntryPending);
}
/**
* Call **before** navigating into `/create` from marketing or profile new rule
* entry points so signed-in + sync matches an anonymous fresh start: wipe
* `localStorage` draft keys and, when sync is on, `DELETE /api/drafts/me`.
* `localStorage` draft keys and, when sync is on and the user is signed in,
* `DELETE /api/drafts/me`.
*
* Synchronous variant: returns immediately after clearing local state and
* scheduling the server draft delete in the background. Sets a sessionStorage
@@ -50,12 +65,13 @@ function clearFreshEntryPending(): void {
*
* Do **not** use for Continue draft that path should load the server draft.
*/
export function prepareFreshCreateFlowEntrySync(): void {
export function prepareFreshCreateFlowEntrySync(
options: PrepareFreshCreateFlowEntryOptions = {},
): void {
const signedIn = options.signedIn === true;
clearAnonymousCreateFlowStorage();
clearCoreValueDetailsLocalStorage();
if (!isBackendSyncEnabled()) return;
setFreshEntryPending();
void deleteServerDraft().finally(clearFreshEntryPending);
clearServerDraftWhenSignedIn(signedIn);
}
/**
@@ -63,10 +79,13 @@ export function prepareFreshCreateFlowEntrySync(): void {
* before continuing (e.g. tests, programmatic reset flows). Most click handlers
* should use {@link prepareFreshCreateFlowEntrySync} for instant navigation.
*/
export async function prepareFreshCreateFlowEntry(): Promise<void> {
export async function prepareFreshCreateFlowEntry(
options: PrepareFreshCreateFlowEntryOptions = {},
): Promise<void> {
const signedIn = options.signedIn === true;
clearAnonymousCreateFlowStorage();
clearCoreValueDetailsLocalStorage();
if (!isBackendSyncEnabled()) return;
if (!signedIn || !isBackendSyncEnabled()) return;
setFreshEntryPending();
try {
await deleteServerDraft();
+16 -1
View File
@@ -1,8 +1,16 @@
import type { Metadata } from "next";
import { Suspense, type ReactNode } from "react";
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
import SkipToContent from "../components/navigation/SkipToContent";
import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext";
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).
// `ConditionalNavigation` reads `cr_session` server-side (and `usePathname()`
@@ -21,10 +29,17 @@ export default function AppLayout({ children }: { children: ReactNode }) {
return (
<MessagesProvider messages={messages}>
<AuthModalProvider>
<SkipToContent />
<Suspense fallback={null}>
<ConditionalNavigation />
</Suspense>
<main className="flex-1">{children}</main>
<main
id={MAIN_CONTENT_ID}
tabIndex={-1}
className="flex-1 outline-none"
>
{children}
</main>
</AuthModalProvider>
</MessagesProvider>
);
+6 -4
View File
@@ -1,9 +1,11 @@
import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { NO_INDEX_ROBOTS, routeMetadata } from "../../../lib/siteMetadata";
export const metadata: Metadata = {
title: "Log in · CommunityRule",
robots: { index: false, follow: false },
};
export const metadata: Metadata = routeMetadata("/login", {
title: messages.metadata.login.title,
robots: NO_INDEX_ROBOTS,
});
export default function LoginLayout({
children,
+2 -2
View File
@@ -14,7 +14,7 @@ function LoginLoadingFallback() {
const t = useTranslation("pages.login");
return (
<div className={`${loginPageBgClass} flex items-center justify-center`}>
<p className="font-inter text-[14px] text-[var(--color-content-default-primary)]">
<p className="text-[14px] text-[var(--color-content-default-primary)]">
{t("loadingFallback")}
</p>
</div>
@@ -42,7 +42,7 @@ function LoginWithSearchParams() {
belowCard={
<Link
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")}
</Link>
+2 -2
View File
@@ -253,7 +253,7 @@ export default function ProfilePageClient() {
}, [draft, router]);
const handleStartNewCustomRule = useCallback(() => {
prepareFreshCreateFlowEntrySync();
prepareFreshCreateFlowEntrySync({ signedIn: true });
router.push("/create/informational");
}, [router]);
@@ -303,7 +303,7 @@ export default function ProfilePageClient() {
if (!sessionLoaded) {
return (
<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")}
</p>
</div>
@@ -81,7 +81,7 @@ export type ProfilePageViewProps = {
* mobile: smaller Bricolage.
*/
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 = {
onSignIn: () => void;
@@ -122,11 +122,11 @@ export function ProfilePageSignedOutView({
<>
<h1
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")}
</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")}
</p>
</>
@@ -275,11 +275,11 @@ export function ProfilePageView({
<>
<h1
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}
</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}
</p>
</>
@@ -381,7 +381,7 @@ export function ProfilePageView({
))}
</div>
{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")}
</p>
) : null}
+6 -4
View File
@@ -1,10 +1,12 @@
import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { NO_INDEX_ROBOTS, routeMetadata } from "../../../lib/siteMetadata";
import ProfilePageClient from "./ProfilePageClient";
export const metadata: Metadata = {
title: "Profile · CommunityRule",
robots: { index: false, follow: false },
};
export const metadata: Metadata = routeMetadata("/profile", {
title: messages.metadata.profile.title,
robots: NO_INDEX_ROBOTS,
});
export default function ProfilePage() {
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)]">
Component Preview
</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 /
4405) with ListEntry (21844:4118). Use Tab and hover to review
states.
@@ -53,12 +53,12 @@ export default function ComponentsPreview() {
</header>
<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
</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>
<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
</p>
<div className="flex flex-col gap-4 rounded-lg bg-white p-6">
@@ -105,7 +105,7 @@ export default function ComponentsPreview() {
</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
</p>
<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 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
</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
slightly higher-contrast.
</p>
<div className="max-w-md space-y-4">
<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
</p>
<Divider type="content" orientation="horizontal" />
</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
</p>
<Divider type="menu" orientation="horizontal" />
@@ -178,16 +178,16 @@ export default function ComponentsPreview() {
</section>
<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
</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:
ListEntry (21844:4118).
</p>
<div className="space-y-[var(--spacing-scale-024)]">
<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
</h3>
<div className="mx-auto max-w-[1044px]">
@@ -195,7 +195,7 @@ export default function ComponentsPreview() {
</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
</h3>
<div className="mx-auto max-w-[1044px]">
@@ -203,7 +203,7 @@ export default function ComponentsPreview() {
</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
</h3>
<div className="mx-auto max-w-[1590px]">
+14 -1
View File
@@ -1,8 +1,15 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext";
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.
export default function DevLayout({ children }: { children: ReactNode }) {
@@ -12,7 +19,13 @@ export default function DevLayout({ children }: { children: ReactNode }) {
return (
<MessagesProvider messages={messages}>
<AuthModalProvider>
<main className="flex-1">{children}</main>
<main
id={MAIN_CONTENT_ID}
tabIndex={-1}
className="flex-1 outline-none"
>
{children}
</main>
</AuthModalProvider>
</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 { getAssetPath, governanceBookletPath } from "../../../lib/assetUtils";
import {
ASSETS,
getAssetPath,
governanceBookletPath,
structureBeforeCrisisPath,
} from "../../../lib/assetUtils";
import { getTranslation } from "../../../lib/i18n/getTranslation";
import { routeMetadata } from "../../../lib/siteMetadata";
import AboutHeader from "../../components/type/AboutHeader";
import type { AboutHeaderSegment } from "../../components/type/AboutHeader";
import Stats from "../../components/sections/Stats";
@@ -13,6 +20,10 @@ import type { FaqAccordionItem } from "../../components/sections/Accordion";
import QuoteBlock from "../../components/sections/QuoteBlock";
import AskOrganizer from "../../components/sections/AskOrganizer";
export const metadata: Metadata = routeMetadata("/about", {
title: messages.metadata.about.title,
});
function asArray<T>(value: unknown): T[] {
return Array.isArray(value) ? value : [];
}
@@ -25,11 +36,6 @@ export default function AboutPage() {
const headerSegments = asArray<AboutHeaderSegment>(page.aboutHeader.segments);
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 tripleColumns = asArray<TripleTextBlockColumn>(page.tripleTextBlock.columns);
@@ -46,18 +52,25 @@ export default function AboutPage() {
titlePrefix={page.stats.titlePrefix}
titleEmphasis={page.stats.titleEmphasis}
titleSuffix={page.stats.titleSuffix}
items={statsItems.map((item) => ({
...item,
asOf: statsAsOf,
}))}
asOfPrefix={page.stats.asOfPrefix}
items={statsItems}
/>
<TripleTextBlock columns={tripleColumns} />
<Book
title={page.book.title}
description={page.book.description}
buttonText={page.book.buttonText}
title={page.structureBeforeCrisis.title}
description={page.structureBeforeCrisis.description}
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())}
imageAlt={page.book.imageAlt}
imageSrc={getAssetPath(ASSETS.COMMUNITY_RULES_COVER)}
imageAlt={page.communityRules.imageAlt}
/>
<FaqAccordion title={page.faq.title} items={faqItems} />
<QuoteBlock
+30 -57
View File
@@ -8,9 +8,11 @@ import {
getRelatedBlogPosts,
} from "../../../../lib/content";
import { logger } from "../../../../lib/logger";
import { routeMetadata } from "../../../../lib/siteMetadata";
import messages from "../../../../messages/en/index";
import ContentBanner from "../../../components/sections/ContentBanner";
import AskOrganizer from "../../../components/sections/AskOrganizer";
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
import ContentTemplateDecorativeShapes from "../../_components/ContentTemplateDecorativeShapes";
import "../blog.css";
// Code split RelatedArticles - blog-specific, below the fold
@@ -62,13 +64,13 @@ export async function generateMetadata({
const post = getBlogPostBySlug(slug);
if (!post) {
return {
title: "Post Not Found",
description: "The requested blog post could not be found.",
};
return routeMetadata(`/blog/${slug}`, {
title: messages.metadata.blog.notFoundTitle,
description: messages.metadata.blog.fallbackDescription,
});
}
return {
return routeMetadata(`/blog/${slug}`, {
title: post.frontmatter.title,
description: post.frontmatter.description,
authors: [{ name: post.frontmatter.author }],
@@ -78,8 +80,8 @@ export async function generateMetadata({
type: "article",
publishedTime: post.frontmatter.date,
authors: [post.frontmatter.author],
url: `https://communityrule.com/blog/${slug}`,
siteName: "CommunityRule",
url: `/blog/${slug}`,
siteName: messages.metadata.siteName,
},
twitter: {
card: "summary_large_image",
@@ -87,12 +89,12 @@ export async function generateMetadata({
description: post.frontmatter.description,
creator: "@communityrule",
},
};
});
} catch (error) {
logger.error("Error generating metadata:", error);
return {
title: "Blog Post",
description: "A blog post from our community.",
title: messages.metadata.blog.fallbackTitle,
description: messages.metadata.blog.fallbackDescription,
};
}
}
@@ -205,58 +207,29 @@ export default async function BlogPostPage({ params }: PageProps) {
className="relative min-h-screen overflow-x-clip"
style={{ backgroundColor }}
>
{/* Content Banner */}
<ContentBanner post={post} />
{/* Decorative Shapes */}
{/* Right Side Shape (3/4 up the page) */}
<div
className="hidden md:block absolute top-1/4 right-0 pointer-events-none z-10"
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 className="relative w-full overflow-x-clip">
<ContentTemplateDecorativeShapes
variant="article"
slug={post.slug}
/>
</div>
{/* Left Side Shape (3/4 down the page) */}
<div
className="hidden md:block absolute top-1/2 left-0 pointer-events-none z-10"
style={{ transform: "translateX(-10%)" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getAssetPath(ASSETS.CONTENT_SHAPE_2)}
alt=""
className="w-auto h-auto max-w-none"
style={{
width: "clamp(100px, 12vw, 180px)",
height: "auto",
}}
/>
<article
data-node-id="19031:10426"
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%)]
"
>
<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">
<div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
</div>
</article>
</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 */}
<RelatedArticles
relatedPosts={relatedArticles}
+16 -16
View File
@@ -1,26 +1,27 @@
import { getAllBlogPosts } from "../../../lib/content";
import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate";
import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { routeMetadata } from "../../../lib/siteMetadata";
export const metadata: Metadata = {
title: "Blog - CommunityRule",
description:
"Learn about community governance, decision-making, and building successful organizations.",
const blogMeta = messages.metadata.blog;
export const metadata: Metadata = routeMetadata("/blog", {
title: blogMeta.title,
description: blogMeta.description,
openGraph: {
title: "Blog - CommunityRule",
description:
"Learn about community governance, decision-making, and building successful organizations.",
url: "https://communityrule.com/blog",
siteName: "CommunityRule",
title: blogMeta.title,
description: blogMeta.description,
url: "/blog",
siteName: messages.metadata.siteName,
type: "website",
},
twitter: {
card: "summary_large_image",
title: "Blog - CommunityRule",
description:
"Learn about community governance, decision-making, and building successful organizations.",
title: blogMeta.title,
description: blogMeta.description,
},
};
});
export default function BlogPage() {
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="text-center mb-12">
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-content-default-primary)] mb-4">
Blog
{blogMeta.title}
</h1>
<p className="text-lg text-[var(--color-content-default-secondary)] max-w-2xl mx-auto">
Learn about community governance, decision-making, and building
successful organizations.
{blogMeta.description}
</p>
</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,
HOW_IT_WORKS_SENTINEL_SLUG,
} from "../../../lib/howItWorksSyntheticPost";
import { routeMetadata } from "../../../lib/siteMetadata";
import ContentBanner from "../../components/sections/ContentBanner";
import HowItWorksDecorativeShapes from "./_components/HowItWorksDecorativeShapes";
import ContentTemplateDecorativeShapes from "../_components/ContentTemplateDecorativeShapes";
import AskOrganizer from "../../components/sections/AskOrganizer";
import "../blog/blog.css";
@@ -29,7 +30,7 @@ export async function generateMetadata(): Promise<Metadata> {
const meta = messages.metadata.howItWorks;
const page = messages.pages.howItWorks;
return {
return routeMetadata("/how-it-works", {
title: meta.title,
description: meta.description,
keywords: meta.keywords,
@@ -37,9 +38,9 @@ export async function generateMetadata(): Promise<Metadata> {
title: page.banner.title,
description: page.banner.description,
type: "website",
siteName: "CommunityRule",
siteName: messages.metadata.siteName,
},
};
});
}
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" />
<div className="relative w-full">
<HowItWorksDecorativeShapes />
<div className="relative w-full overflow-x-clip">
<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
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 }}
/>
</article>
+15 -6
View File
@@ -1,8 +1,10 @@
import dynamic from "next/dynamic";
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 { AuthModalProvider } from "../contexts/AuthModalContext";
import { MAIN_CONTENT_ID } from "../../lib/mainContent";
import marketingMessages from "../../messages/en/marketing";
// 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}>
<AuthModalProvider>
{/*
* MarketingNavigation reads `usePathname()` to decide chromeless paths
* (uncached data under `cacheComponents`). Suspense lets the static
* shell prerender; the nav streams in with the correct visibility.
* Same session-aware shell as `(app)` / `(admin)`: `ConditionalNavigation`
* reads the cookie behind Suspense so the static page shell can prerender
* while the header streams signed-in vs signed-out correctly.
*/}
<SkipToContent />
<Suspense fallback={null}>
<MarketingNavigation />
<ConditionalNavigation />
</Suspense>
<main className="flex-1">{children}</main>
<main
id={MAIN_CONTENT_ID}
tabIndex={-1}
className="flex-1 outline-none"
>
{children}
</main>
<Footer />
</AuthModalProvider>
</MessagesProvider>
+12 -4
View File
@@ -1,10 +1,16 @@
import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { getTranslation } from "../../../lib/i18n/getTranslation";
import { routeMetadata } from "../../../lib/siteMetadata";
import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate";
import ContentLockup from "../../components/type/ContentLockup";
import AskOrganizer from "../../components/sections/AskOrganizer";
import { getAllBlogPosts } from "../../../lib/content";
export const metadata: Metadata = routeMetadata("/learn", {
title: messages.metadata.learn.title,
});
export default function LearnPage() {
const allPosts = getAllBlogPosts();
@@ -31,11 +37,13 @@ export default function LearnPage() {
{/*
* Single responsive render: ContentThumbnailTemplate variant="responsive"
* uses <picture> to swap horizontal/vertical art at smd (530px). The
* container switches from a vertical flex stack (<smd) to a grid (smd),
* matching the prior twin-region layout without doubling the DOM.
* uses <picture> to swap horizontal/vertical art at smd (530px). Wrap and
* center leftover cards so a short last row is not left-aligned.
*/}
<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) => (
<ContentThumbnailTemplate
key={post.slug}
+4
View File
@@ -1,11 +1,15 @@
import type { Metadata } from "next";
import dynamic from "next/dynamic";
import { Suspense } from "react";
import messages from "../../messages/en/index";
import { getTranslation } from "../../lib/i18n/getTranslation";
import { routeMetadata } from "../../lib/siteMetadata";
import HeroBanner from "../components/sections/HeroBanner";
import AskOrganizer from "../components/sections/AskOrganizer";
import { MarketingRuleStackSection } from "./_components/MarketingRuleStackSection";
export const metadata: Metadata = routeMetadata("/");
// Code split below-the-fold components to reduce initial bundle size
const LogoWall = dynamic(() => import("../components/sections/LogoWall"), {
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 { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules";
import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../lib/create/publishedDocumentToDisplaySections";
import { routeMetadata } from "../../../../lib/siteMetadata";
import CommunityRule from "../../../components/type/CommunityRule";
import HeaderLockup from "../../../components/type/HeaderLockup";
@@ -25,22 +26,22 @@ export async function generateMetadata({
typeof rule.summary === "string" && rule.summary.trim().length > 0
? rule.summary
: undefined;
return {
return routeMetadata(`/rules/${rule.id}`, {
title: rule.title,
description,
openGraph: {
title: rule.title,
description,
type: "article",
url: `https://communityrule.com/rules/${rule.id}`,
siteName: "CommunityRule",
url: `/rules/${rule.id}`,
siteName: messages.metadata.siteName,
},
twitter: {
card: "summary_large_image",
title: rule.title,
description,
},
};
});
}
export default async function PublicRuleDetailPage({ params }: PageProps) {
@@ -1,7 +1,7 @@
"use client";
import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import HeaderLockup from "../../components/type/HeaderLockup";
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
@@ -68,8 +68,8 @@ export default function TemplatesPageClient({
/**
* - `fromFlow=1` skip `prepareFreshCreateFlowEntry` on template click
* (draft preserved). Used by review Create from template and profile.
* - `recommendTemplates=1` (with review only) rank templates + RECOMMENDED
* (draft preserved). Used by profile Create from template.
* - `recommendTemplates=1` rank templates + RECOMMENDED
* from `GET /api/templates?facet.*` using the persisted community draft.
*/
function TemplatesGridWithSearchParams({
@@ -96,17 +96,18 @@ function TemplatesGrid({
entries: TemplateGridCardEntry[];
fromFlow: boolean;
}) {
const router = useRouter();
return (
<GovernanceTemplateGrid
entries={entries}
onTemplateClick={(slug) => {
hrefForTemplate={(slug) =>
buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow })
}
onTemplateClick={() => {
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 { routeMetadata } from "../../../lib/siteMetadata";
import { gridEntriesForFullCatalogWithFallback } from "../../../lib/templates/templateGridPresentation";
import TemplatesPageClient from "./TemplatesPageClient";
export const metadata: Metadata = routeMetadata("/templates", {
title: messages.metadata.templates.title,
});
export default async function TemplatesPage() {
const rows = await listRuleTemplatesFromDb();
const initialGridEntries = gridEntriesForFullCatalogWithFallback(rows);
@@ -18,9 +18,10 @@ type UseTemplatesFacetGridEntriesArgs = {
};
/**
* When `enableFacetRecommendations` (review Create from template only),
* re-fetch ranked templates from `GET /api/templates?facet.*` using the
* persisted create-flow draft. Otherwise returns `initialGridEntries` from SSR.
* When `enableFacetRecommendations` (in-flow `/create/templates`, or marketing
* `/templates?recommendTemplates=1`), re-fetch ranked templates from
* `GET /api/templates?facet.*` using the persisted create-flow draft.
* Otherwise returns `initialGridEntries` from SSR.
*/
export function useTemplatesFacetGridEntries({
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,
useCaseContentKeyForSlug,
} from "../../../../lib/useCaseSyntheticPost";
import { routeMetadata } from "../../../../lib/siteMetadata";
import ContentBanner from "../../../components/sections/ContentBanner";
import AskOrganizer from "../../../components/sections/AskOrganizer";
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 meta = messages.metadata.useCasesDetail[contentKey];
return {
return routeMetadata(`/use-cases/${slug}`, {
title: meta.title,
description: meta.description,
keywords: meta.keywords,
@@ -42,9 +43,9 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
title: meta.title,
description: meta.description,
type: "website",
siteName: "CommunityRule",
siteName: messages.metadata.siteName,
},
};
});
}
export default async function UseCaseDetailPage({ params }: PageProps) {

Some files were not shown because too many files have changed in this diff Show More