Compare commits

...
126 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
adilallo 8420ce42e3 Fix loading of recommended methods 2026-05-26 09:03:18 -06:00
adilallo 2871df27b2 Fix svg loading issues 2026-05-26 08:40:01 -06:00
adilallo edda28f95b Cache components and react compiler 2026-05-26 08:11:57 -06:00
adilallo eded97559d Performance follow-ups 2026-05-26 07:24:36 -06:00
adilallo 3be188a3cc Improve page load times and rendering 2026-05-26 06:59:52 -06:00
adilallo 6b45a2e5d0 Issue #59 fixes 2026-05-25 22:05:00 -06:00
adilallo bd536a407f Attempt to fix iphone input text zoom 2026-05-24 17:13:02 -06:00
adilallo e7c17b8651 Fix cross-browser favicons 2026-05-24 17:06:07 -06:00
adilallo daa5e533d6 Attempt to fix iphone button states 2026-05-24 17:00:05 -06:00
adilallo 59999b8211 Remove focus on feature grid section 2026-05-24 16:55:07 -06:00
adilallo b1ad3c6203 Fix footer buttons 2026-05-24 16:52:52 -06:00
adilallo d8e0b87bb3 Fix ask organizer modal 2026-05-24 16:44:36 -06:00
adilallo 30a7dd1c2f Fix about page triple step block 2026-05-24 16:39:48 -06:00
adilallo daba38f96f Issue template 2026-05-24 16:06:17 -06:00
adilallo d5d0bed88a Update Docs 2026-05-24 16:00:11 -06:00
adilallo f0e193746c Seed template recommendations 2026-05-23 19:35:38 -06:00
adilallo 6d0335a86a Fix verify route 2026-05-23 18:28:20 -06:00
adilallo b84d80c3a9 Fix magic link routes 2026-05-23 18:19:45 -06:00
adilallo bb26d95b32 Fix staging health-check hang: revert seed-at-boot 2026-05-23 17:53:12 -06:00
adilallo e64ba640c8 Seed files use process.cwd 2026-05-23 17:44:48 -06:00
adilallo 200b9f8c9e Fix seed db 2026-05-23 17:38:34 -06:00
adilallo c13a3f2a04 Update smoke-test 2026-05-23 17:35:13 -06:00
adilallo 4af244fa09 Fix prisma deploy issue 2026-05-23 17:26:18 -06:00
adilallo 28eb5007fc Update CloudronManifest.json 2026-05-23 16:58:38 -06:00
an.di bb4ef2ac4b Merge pull request 'Staging install prep: docs, license, and smoke script' (#58) from adilallo/Backend/StagingInstall into main
Reviewed-on: #58
2026-05-23 22:55:42 +00:00
adilallo 22ccc02b70 Add license and other docs 2026-05-23 16:52:18 -06:00
adilallo 98eed6cf22 Update public documenation 2026-05-23 16:47:47 -06:00
adilallo ca338cb017 Add staging smoke script for Cloudron install 2026-05-23 15:10:49 -06:00
585 changed files with 19729 additions and 6189 deletions
+9 -1
View File
@@ -47,7 +47,15 @@ When your changes create orphans:
The test: Every changed line should trace directly to the user's request. The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution ## 4. Comments, commits, and PRs
**Let the code talk. Keep tickets out of prose.**
- No comment when the names already say it.
- No Linear IDs (`CR-123`) in comments, commit messages, or PR titles/bodies. Branch names still use `adilallo/<type>/CR-<n>-…` (see `branch-naming.mdc`). Link the issue from Linear, not from the PR copy.
- Don't narrate Figma→code mappings the prop already encodes (`palette="inverse"` needs no "this is Invert" comment).
## 5. Goal-Driven Execution
**Define success criteria. Loop until verified.** **Define success criteria. Loop until verified.**
+1 -1
View File
@@ -33,7 +33,7 @@ Reach for these before writing new markup:
| `[ value +]` numeric stepper (± label) | `app/components/controls/Incrementer` / `IncrementerBlock` | | `[ value +]` numeric stepper (± label) | `app/components/controls/Incrementer` / `IncrementerBlock` |
| Mid-paragraph "expand / see all" link button | `app/components/buttons/InlineTextButton` | | Mid-paragraph "expand / see all" link button | `app/components/buttons/InlineTextButton` |
| Help-icon + label above a control | `app/components/type/InputLabel` (`helpIcon` prop) | | Help-icon + label above a control | `app/components/type/InputLabel` (`helpIcon` prop) |
| Toggle chip (dim-but-clickable) | `Chip` with `state="Disabled" disabled={false}` | | Toggle chip | `Chip` with `state="selected"` / `"unselected"` (Chip sets `aria-pressed`; selected includes a check mark) |
| Card-click → structured creation modal | `Create` with `backdropVariant="blurredYellow"` | | Card-click → structured creation modal | `Create` with `backdropVariant="blurredYellow"` |
If a screen grows a 2nd inline copy of any pattern above, **extract a shared If a screen grows a 2nd inline copy of any pattern above, **extract a shared
+12 -10
View File
@@ -14,10 +14,10 @@ the file tree without affecting URLs.
| Group | URL surface | Audience | Chrome | | Group | URL surface | Audience | Chrome |
|---|---|---|---| |---|---|---|---|
| `app/(marketing)/` | `/`, `/learn`, `/blog`, `/templates`, future public pages | Public, indexable | `Top` (via root) + marketing `<Footer />` | | `app/(marketing)/` | `/`, `/learn`, `/blog`, `/templates`, future public pages | Public, indexable | `SkipToContent` + `ConditionalNavigation` + marketing `<Footer />` |
| `app/(app)/` | `/create/*`, `/login`, `/profile`, future signed-in surfaces | Authenticated product | `Top` (via root) — no footer except **`/profile`** (see `profile/layout.tsx`) | | `app/(app)/` | `/create/*`, `/login`, `/profile`, future signed-in surfaces | Authenticated product | `SkipToContent` + `ConditionalNavigation` — no footer except **`/profile`** (see `profile/layout.tsx`) |
| `app/(admin)/` | `/monitor`, future ops dashboards | Operators | `Top` (via root) — no footer | | `app/(admin)/` | `/monitor`, future ops dashboards | Operators | `SkipToContent` + `ConditionalNavigation` — no footer |
| `app/(dev)/` | `/components-preview`, future dev previews | Local dev (NODE_ENV gated) | `Top` (via root) — no footer | | `app/(dev)/` | `/components-preview`, future dev previews | Local dev (NODE_ENV gated) | `Top` omitted — no footer |
| `app/(marketing-case-study)/` | `/use-cases/[slug]/rule` | Public case-study demos | Chromeless (no global `Top`; see `navigationChromelessPath.ts`) | | `app/(marketing-case-study)/` | `/use-cases/[slug]/rule` | Public case-study demos | Chromeless (no global `Top`; see `navigationChromelessPath.ts`) |
| `app/api/` | API routes | n/a | n/a | | `app/api/` | API routes | n/a | n/a |
@@ -28,14 +28,16 @@ the folder next to `(marketing)/`.
## Layout responsibilities ## Layout responsibilities
- **`app/layout.tsx`** — `<html>`, `<body>`, providers (`MessagesProvider`, - **`app/layout.tsx`** — `<html>`, `<body>`, providers (`MessagesProvider`,
`AuthModalProvider`), fonts, and `ConditionalNavigation`. Renders `AuthModalProvider` are per group), fonts. Renders
`{children}` directly inside the flex column. **Does not** render `{children}` directly inside the flex column. **Does not** render
`<main>` — each group layout owns that. `<main>` or the header — each group layout owns that.
- **`app/(marketing)/layout.tsx`** — wraps with `<main className="flex-1">` - **`app/(marketing)/layout.tsx`** — skip-to-content, `ConditionalNavigation`
and appends the public `<Footer />`. (SSR session), `<main id="main-content">`, public `<Footer />`.
- **`app/(app)/layout.tsx`** / **`(admin)/layout.tsx`** / **`(dev)/layout.tsx`** - **`app/(app)/layout.tsx`** / **`(admin)/layout.tsx`** —
wrap with `<main className="flex-1">`. No footer by default; **`app/(app)/profile/layout.tsx`** skip-to-content, `ConditionalNavigation`, `<main id="main-content">`.
No footer by default; **`app/(app)/profile/layout.tsx`**
appends the marketing `<Footer />` for `/profile` only. appends the marketing `<Footer />` for `/profile` only.
- **`app/(dev)/layout.tsx`** — `<main id="main-content">` only.
- **Nested layouts** (e.g. `(app)/create/layout.tsx`) compose feature-specific - **Nested layouts** (e.g. `(app)/create/layout.tsx`) compose feature-specific
chrome inside the group's `<main>` — never render `<html>`, `<body>`, chrome inside the group's `<main>` — never render `<html>`, `<body>`,
`<main>`, or providers. `<main>`, or providers.
+22 -16
View File
@@ -28,16 +28,17 @@ Do **not** colocate `*.stories.*` next to components. The Storybook config
# File naming # File naming
- `<ComponentName>.stories.js` — matches 69/70 existing files. - `<ComponentName>.stories.js` — default; keep `.js` unless the story
- Use `.tsx` only when the story genuinely needs types (rare; prefer JS to genuinely needs types.
match the codebase convention). - Use `.tsx` only when the story uses `Meta` / `StoryObj` (rare).
- Variants get a suffix: `Button.visual.stories.js`, - Variants get a suffix: `Button.visual.stories.js`,
`Footer.responsive.stories.js`. `Footer.responsive.stories.js`.
# Default export shape (CSF2) # Default export shape (CSF3)
```javascript ```javascript
import MyComponent from "../../app/components/<area>/MyComponent"; import MyComponent from "../../app/components/<area>/MyComponent";
import { CHIP_PALETTE_OPTIONS } from "../../lib/propNormalization";
export default { export default {
title: "Components/<SubFolder>/MyComponent", title: "Components/<SubFolder>/MyComponent",
@@ -51,31 +52,32 @@ export default {
}, },
}, },
argTypes: { argTypes: {
variant: { palette: {
control: { type: "select" }, control: { type: "select" },
options: ["filled", "outline"], options: [...CHIP_PALETTE_OPTIONS],
description: "The variant (Figma prop)", description: "The palette (Figma prop)",
}, },
onClick: { action: "clicked" }, onClick: { action: "clicked" },
}, },
}; };
export const Default = { args: { variant: "filled" } }; export const Default = { args: { palette: "default" } };
``` ```
## Title hierarchy ## Title hierarchy
- Design-system components → `Components/<SubFolder>/<Name>` (e.g. - Design-system components → `Components/<SubFolder>/<Name>` (e.g.
`Components/Controls/Checkbox`). `Components/Controls/Checkbox`).
- Pages → `Pages/<PageName>` (folder: `stories/pages/`). - Create-flow screens → `Pages/Create Flow/<Step>` (folder: `stories/pages/`).
- Create flow shared pieces → `Create Flow/<Name>`. - Create flow shared pieces → `Create Flow/<Name>`.
## `argTypes` ## `argTypes`
For every Figma enum prop (`variant`, `size`, `state`, `mode`, `palette`, For every Figma enum prop (`variant`, `size`, `state`, `mode`, `palette`,
…) expose a `select` control listing the **lowercase** option set, sourced …) expose a `select` control listing the option set, sourced from the matching
from the matching `*_OPTIONS` const in `lib/propNormalization.ts`. See `*_OPTIONS` const in `lib/propNormalization.ts`. See
`.cursor/rules/component-props.mdc`. `.cursor/rules/component-props.mdc`. Spread with `[...FOO_OPTIONS]` so the
control receives a mutable array.
# Rely on the global preview — don't re-wrap # Rely on the global preview — don't re-wrap
@@ -83,8 +85,12 @@ from the matching `*_OPTIONS` const in `lib/propNormalization.ts`. See
- `MessagesProvider` with `messages/en` → access copy via `useMessages()` - `MessagesProvider` with `messages/en` → access copy via `useMessages()`
inside stories exactly like app code. Never hard-code user-facing strings. inside stories exactly like app code. Never hard-code user-facing strings.
- `app/globals.css` + `.font-inter` wrapper → design tokens and fonts are - `AuthModalProvider` and `CreateFlowProvider` (same stack as
already present. `tests/utils/test-utils.tsx`) so `Top` and create-flow screens can mount.
- `app/globals.css` + Inter / Bricolage Grotesque / Space Grotesk CSS
variables + `.font-inter` wrapper.
- Dark canvas default and Figma breakpoints (`sm` 430, `md` 640, `lg` 1024,
`xl` 1440).
Do **not** add your own `MessagesProvider`, font wrapper, or token setup in a Do **not** add your own `MessagesProvider`, font wrapper, or token setup in a
story. If you need a new global, update `preview.js`. story. If you need a new global, update `preview.js`.
@@ -92,8 +98,8 @@ story. If you need a new global, update `preview.js`.
# Interaction tests (`play`) # Interaction tests (`play`)
Use `storybook/test` for interaction assertions — not `@testing-library/*` Use `storybook/test` for interaction assertions — not `@testing-library/*`
directly. This matches `Checkbox.stories.js` and stays compatible with the directly. This matches `Checkbox.stories.js`. Storybook is documentation;
Vitest portable-stories runner in `.storybook/vitest.setup.js`. Vitest component tests remain the source of truth.
```javascript ```javascript
import { within, userEvent, expect } from "storybook/test"; import { within, userEvent, expect } from "storybook/test";
+5
View File
@@ -29,6 +29,11 @@ NEXT_PUBLIC_ENABLE_BACKEND_SYNC=
# Optional: URL shown on /monitor when using external storage (Grafana, Kibana, vendor RUM, etc.). # Optional: URL shown on /monitor when using external storage (Grafana, Kibana, vendor RUM, etc.).
# NEXT_PUBLIC_RUM_DASHBOARD_URL= # NEXT_PUBLIC_RUM_DASHBOARD_URL=
# Search indexing. Unset: only communityrule.com / communityrule.info are crawlable
# (staging and local are disallowed in robots.txt). Set true/false to override.
# SITE_INDEXING=true
# SITE_INDEXING=false
# Writable directory for `POST /api/uploads` (community photo + custom-method attachments). # Writable directory for `POST /api/uploads` (community photo + custom-method attachments).
# In production (e.g. Cloudron localstorage mount), set to the mounted path. Local dev example: # In production (e.g. Cloudron localstorage mount), set to the mounted path. Local dev example:
# UPLOAD_ROOT="/absolute/path/to/community-rule/var/uploads" # UPLOAD_ROOT="/absolute/path/to/community-rule/var/uploads"
+9
View File
@@ -0,0 +1,9 @@
# Gitea issue template config — see https://docs.gitea.com/usage/issue-pull-request-templates
blank_issues_enabled: false
contact_links:
- name: Open the staging site
url: https://staging.communityrule.info
about: Preview version of Community Rule, test here before reporting
- name: How to sign in (read first)
url: https://staging.communityrule.info/login
about: Use your email to recieve a link to click
@@ -0,0 +1,36 @@
---
name: "Feedback or suggestion"
about: "Something worked but was confusing, or you have an idea to improve it"
title: "[Staging feedback] "
---
Thank you for sharing your thoughts on the staging preview. **Clear, everyday language is exactly what we need.**
### Before you submit
- This form is for **ideas and usability feedback**, not broken pages — if something failed or errored, use **"Something isn't working"** instead.
- **One topic per report** keeps feedback easier to act on.
---
## Where on the site?
_Copy the page address from your browser, or describe where you were — e.g. "Create rule — choose methods step"_
## What is your feedback?
_Tell us what felt confusing, missing, or could work better. What would make this easier for you or your community?_
## Why would this help? (optional)
_Who benefits — e.g. first-time organizers, small groups, non-English speakers?_
## Screenshot or example (optional)
_Drag an image here, or describe what you were looking at._
## Your browser and device (optional)
_Examples: "Safari on iPad," "Chrome on Android phone"_
## Anything else?
@@ -0,0 +1,67 @@
---
name: "Something isn't working"
about: "A page broke, a button didn't work, or you got stuck"
title: "[Staging] "
---
Thank you for helping us test Community Rule. **You do not need to be technical** — plain language is perfect. Answer what you can and skip anything you are not sure about.
### Before you submit
- **One report = one problem.** If you found several separate issues, please open a new report for each.
- **Check [existing reports](https://git.medlab.host/CommunityRule/community-rule/issues)** — someone may have already reported the same thing.
- **Never share your password** or paste the **sign-in link from your email** here (those are private to you).
---
## Where were you on the site?
_Copy the web address from your browser's address bar, or check the box that fits:_
- [ ] https://staging.communityrule.info (staging / preview site)
- [ ] https://communityrule.info (live site)
- [ ] Other:
## What were you trying to do?
_Examples: "Sign in with my email," "Create a new rule," "Upload a community photo," "Publish my rule"_
## What happened instead?
_Describe what you saw. Did an error message appear? Did a button do nothing? Did the page look wrong?_
## What did you expect to happen?
## Steps to get there (if you remember)
_Numbered steps help us reproduce the problem — e.g. "1. Clicked Log in → 2. Entered my email → 3. …"_
1.
2.
3.
## Does it happen every time?
- [ ] Yes — it happens every time I try
- [ ] Sometimes — it only happened once or occasionally
- [ ] Not sure — I have not tried again
## Your browser and device
_Examples: "Safari on iPhone," "Chrome on a Windows laptop," "Firefox on Mac"_
## Screenshot (optional, very helpful)
You can **drag an image into this text box** or use the **attachment** button below it.
## Sign-in email (only if this is about logging in)
_The email address you used. We will not ask for your password._
**Did you receive the sign-in email?**
- [ ] Yes, but the link did not work
- [ ] No, nothing arrived (I checked spam/junk)
- [ ] Not applicable — this is not about signing in
## Anything else we should know?
+12 -1
View File
@@ -28,7 +28,7 @@ npm-cache/
/lhci-results/ /lhci-results/
/.lighthouseci/ /.lighthouseci/
# Ignore other image files (but not visual regression snapshots) # Ignore other image files (but not visual regression snapshots or favicons)
*.png *.png
*.jpg *.jpg
*.jpeg *.jpeg
@@ -39,6 +39,17 @@ npm-cache/
*.avi *.avi
*.mkv *.mkv
# Root favicons (generated via `npm run generate:favicons`)
!public/favicon-16x16.png
!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) # Visual regression snapshots (allow these)
!tests/e2e/visual-regression.spec.ts-snapshots/ !tests/e2e/visual-regression.spec.ts-snapshots/
!tests/e2e/visual-regression.spec.ts-snapshots/*.png !tests/e2e/visual-regression.spec.ts-snapshots/*.png
+7 -1
View File
@@ -1,7 +1,13 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"); @import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Bricolage+Grotesque:wght@400;500;700;800&family=Space+Grotesk:wght@400;500;700&display=swap");
:root { :root {
--font-inter: --font-inter:
"Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji"; "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
--font-bricolage-grotesque:
"Bricolage Grotesque", ui-sans-serif, system-ui, -apple-system, "Segoe UI",
Roboto, "Helvetica Neue", Arial;
--font-space-grotesk:
"Space Grotesk", ui-sans-serif, system-ui, -apple-system, "Segoe UI",
Roboto, "Helvetica Neue", Arial;
} }
+1 -23
View File
@@ -4,32 +4,10 @@ module.exports = {
"../stories/**/*.mdx", "../stories/**/*.mdx",
"../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)", "../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)",
], ],
addons: [ addons: ["@storybook/addon-docs", "@storybook/addon-a11y"],
// Removed @storybook/addon-essentials due to version mismatch with Storybook 10.x
// Using individual addons instead. Interaction helpers import from storybook/test
// (bundled with storybook@10); @storybook/addon-interactions was merged into SB 8 core.
"@storybook/addon-a11y",
],
framework: { framework: {
name: "@storybook/nextjs", name: "@storybook/nextjs",
options: {}, options: {},
}, },
staticDirs: ["../public"], staticDirs: ["../public"],
// Webpack configuration to resolve Next.js modules for Next.js 16 compatibility
async webpackFinal(config) {
// Ensure Next.js modules are resolved correctly
config.resolve = config.resolve || {};
config.resolve.alias = {
...(config.resolve.alias || {}),
};
// Ensure node_modules are resolved
config.resolve.modules = [
...(config.resolve.modules || []),
"node_modules",
];
return config;
},
}; };
+52
View File
@@ -1,10 +1,43 @@
import "../app/globals.css"; import "../app/globals.css";
import "./fonts.css"; import "./fonts.css";
import { MINIMAL_VIEWPORTS } from "storybook/viewport";
import { AuthModalProvider } from "../app/contexts/AuthModalContext";
import { MessagesProvider } from "../app/contexts/MessagesContext"; import { MessagesProvider } from "../app/contexts/MessagesContext";
import { CreateFlowProvider } from "../app/(app)/create/context/CreateFlowContext";
import messages from "../messages/en/index"; import messages from "../messages/en/index";
/** Figma / Tailwind breakpoints from `app/tailwind.css`. */
const dsViewports = {
sm: {
name: "sm 430",
styles: { width: "430px", height: "932px" },
},
md: {
name: "md 640",
styles: { width: "640px", height: "1024px" },
},
lg: {
name: "lg 1024",
styles: { width: "1024px", height: "768px" },
},
xl: {
name: "xl 1440",
styles: { width: "1440px", height: "900px" },
},
// Aliases used by existing page stories
mobile1: {
name: "sm 430",
styles: { width: "430px", height: "932px" },
},
desktop: {
name: "xl 1440",
styles: { width: "1440px", height: "900px" },
},
};
/** @type { import('@storybook/react').Preview } */ /** @type { import('@storybook/react').Preview } */
const preview = { const preview = {
tags: ["autodocs"],
parameters: { parameters: {
controls: { controls: {
matchers: { matchers: {
@@ -12,13 +45,32 @@ const preview = {
date: /Date$/i, date: /Date$/i,
}, },
}, },
backgrounds: {
options: {
dark: { name: "Dark", value: "#000000" },
light: { name: "Light", value: "#ffffff" },
},
},
viewport: {
options: {
...MINIMAL_VIEWPORTS,
...dsViewports,
},
},
},
initialGlobals: {
backgrounds: { value: "dark" },
}, },
decorators: [ decorators: [
(Story) => ( (Story) => (
<MessagesProvider messages={messages}> <MessagesProvider messages={messages}>
<AuthModalProvider>
<CreateFlowProvider>
<div className="font-inter"> <div className="font-inter">
<Story /> <Story />
</div> </div>
</CreateFlowProvider>
</AuthModalProvider>
</MessagesProvider> </MessagesProvider>
), ),
], ],
-7
View File
@@ -1,7 +0,0 @@
import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
import { setProjectAnnotations } from "@storybook/nextjs-vite";
import * as projectAnnotations from "./preview";
// This is an important step to apply the right configuration when testing your stories.
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
+2
View File
@@ -46,6 +46,8 @@ priors — they reflect deliberate decisions.
6. **No new pathname-sniffing chrome.** Compose chrome via group/nested 6. **No new pathname-sniffing chrome.** Compose chrome via group/nested
layouts, not `usePathname()` checks. (`ConditionalNavigation` is the layouts, not `usePathname()` checks. (`ConditionalNavigation` is the
sole tolerated exception — it carries SSR session state.) sole tolerated exception — it carries SSR session state.)
7. **No Linear ticket IDs in comments, commits, or PR copy.** Branch names
still carry `CR-<n>`. See `coding-guidelines.mdc`.
## Legacy / scaffolding ## Legacy / scaffolding
+127 -80
View File
@@ -1,101 +1,148 @@
# Contributing # Contributing
## Local backend Thanks for working on Community Rule. This file covers local setup, the
API surface, and the pull-request workflow. Per-file implementation
conventions live in [`.cursor/rules/`](.cursor/rules/) (auto-loaded by
Cursor); high-level orientation is in [`AGENTS.md`](AGENTS.md).
1. Copy [`.env.example`](.env.example) to `.env` and set `SESSION_SECRET` ## Local setup
(at least 16 characters).
2. `docker compose up -d postgres mailhog` — omit `mailhog` if you only
need Postgres. Without `CLOUDRON_MAIL_SMTP_*`, the **magic-link verify URL** is
printed in the dev server log.
3. `npm ci`
4. `npx prisma migrate dev`
5. *(Optional)* `npx prisma db seed` — seeds curated rule templates.
Idempotent; rows upsert by `slug`.
6. `npm run dev`
Use `npx prisma studio` to inspect the database. Prerequisites: Node **20+**, npm **10+**, Docker.
Deploying to staging or production (MEDLab Cloudron) — see ```bash
[docs/guides/ops-backend-deploy.md](docs/guides/ops-backend-deploy.md) cp .env.example .env # set SESSION_SECRET (≥16 chars)
for the admin handoff and the linked Linear tickets for the actual docker compose up -d postgres mailhog # omit `mailhog` if you don't need
deployment-pipeline work. # a local inbox
npm ci
npx prisma migrate dev
npx prisma db seed # optional — seeds curated templates
npm run dev
```
### Prisma migrations Open [http://localhost:3000](http://localhost:3000). Use
`npx prisma studio` to browse the database.
- **Never edit** a migration that has already been applied to staging, Deploying to staging or production (MEDLab Cloudron at `my.medlab.host`)
production, or any shared database. Add a **new** migration that is documented in
corrects the schema instead. Full policy: [`docs/guides/ops-backend-deploy.md`](docs/guides/ops-backend-deploy.md).
[docs/guides/backend-roadmap.md](docs/guides/backend-roadmap.md) §8.
- Any change under **`prisma/`**: run **`npm run migrate:smoke`** (see
[docs/testing-guide.md](docs/testing-guide.md#running-tests), **Prisma**
under *Running tests*).
### API routes
| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/api/health` | Liveness / DB check. |
| GET | `/api/auth/session` | Current user or null. |
| POST | `/api/auth/magic-link/request` | Send sign-in link email. |
| GET | `/api/auth/magic-link/verify` | Validate token, set cookie, redirect. |
| POST | `/api/auth/logout` | Clear session. |
| GET / PUT | `/api/drafts/me` | Load or save the create-flow draft. |
| POST | `/api/uploads` | Authenticated multipart upload (create-flow images / PDFs); requires `UPLOAD_ROOT`. |
| GET | `/api/uploads/[id]` | Stream a previously uploaded file by opaque id (public read). |
| GET / POST | `/api/rules` | List or publish rules. |
| GET | `/api/templates` | List curated templates. Optional repeatable `facet.<group>=<value>` query params re-rank results (and may include `scores` in the JSON). See [docs/guides/template-recommendation-matrix.md](docs/guides/template-recommendation-matrix.md) §9.1. |
| GET | `/api/templates/[slug]` | Single curated template plus normalized `{ section, slug }` composition from `body`. Public read; 404 when unknown. §9.4 (CR-115). |
| GET | `/api/create-flow/methods` | Public catalog for built-in governance methods and core values. Required `section` (`communication` \| `membership` \| `decisionApproaches` \| `conflictManagement` \| `coreValues`; alias `values``coreValues`). Returns the **full deck** with `label`, `description`, and `sections` (methods) or `id`, `label`, `meaning`, `signals` (core values). Optional `facet.*` adds `matches` and re-ranks method rows (ignored for `coreValues`). Core value `id` is a 1-based position string (`"1"`, `"2"`, …). English v1 only. §9.2 / §9.5 (CR-115). |
| POST / GET | `/api/web-vitals` | Ingest or read web vitals. **Production default:** `external` — structured logs only (no writes under `.next`; safe for read-only FS). **Development default:** `local` — aggregates under `.next/web-vitals`. Override with `WEB_VITALS_STORAGE`. See [docs/guides/backend-roadmap.md](docs/guides/backend-roadmap.md) §7. |
| GET | `/api/rules/me` | Authenticated list of own published rules. |
| GET / PATCH / DELETE | `/api/rules/[id]` | Public read; owner update/delete. |
| POST | `/api/rules/[id]/duplicate` | Owner clone of a published rule. |
| GET / POST | `/api/rules/[id]/stakeholders` | List or invite rule stakeholders. |
| DELETE | `/api/rules/[id]/stakeholders/[stakeholderId]` | Remove a stakeholder. |
| POST | `/api/rules/[id]/stakeholders/[stakeholderId]/resend` | Resend stakeholder invite email. |
| GET | `/api/invites/rule-stakeholder/verify` | Verify stakeholder invite token; redirect. |
| DELETE | `/api/user/me` | Delete authenticated user account. |
| POST | `/api/user/email-change/request` | Request email change (magic link to new address). |
| GET | `/api/user/email-change/verify` | Verify email-change token; update `User.email`. |
| POST | `/api/organizer-inquiry` | Submit ask-organizer inquiry form. |
| POST | `/api/use-cases/[slug]/duplicate` | Duplicate a use-case demo rule. |
### Magic-link sign-in ### Magic-link sign-in
- Visit **[/login](http://localhost:3000/login)** or use **Log in** in the 1. Go to [/login](http://localhost:3000/login) or click **Log in** in
site header. the site header.
- Without `CLOUDRON_MAIL_SMTP_*`: copy the verify URL from the dev server terminal. 2. Submit your email.
- With Mailhog: set `CLOUDRON_MAIL_SMTP_SERVER=localhost` and 3. Open the verify link in the **same browser** (the session cookie is
`CLOUDRON_MAIL_SMTP_PORT=1025` (see `.env.example`) and open the message bound to that origin):
at [http://localhost:8025](http://localhost:8025). - **Without SMTP:** copy the URL from the dev-server log.
- Open the link in the **same browser** as the app (session cookie). - **With Mailhog:** open the message at
[http://localhost:8025](http://localhost:8025).
### Optional draft sync ### Prisma migrations
Postgres draft persistence via `PUT /api/drafts/me` is **on by default** for - **Never edit a migration** that has already been applied to staging,
signed-in users and post-sign-in transfer of anonymous drafts. Set production, or any shared database — add a new migration instead.
`NEXT_PUBLIC_ENABLE_BACKEND_SYNC=false` to disable server sync (anonymous Full policy: [`docs/guides/backend-roadmap.md`](docs/guides/backend-roadmap.md) §8.
progress stays in `localStorage` only). - **After any change under `prisma/`**, run `npm run migrate:smoke`
(Docker required). A throwaway Postgres on `127.0.0.1:5433` verifies
the migration applies cleanly. See
[`docs/testing-guide.md`](docs/testing-guide.md) → *Running tests*.
### Draft persistence
Signed-in create-flow drafts sync to Postgres via `PUT /api/drafts/me`
by default; anonymous progress stays in `localStorage`. Set
`NEXT_PUBLIC_ENABLE_BACKEND_SYNC=false` to disable server sync.
### Create flow ### Create flow
The custom wizard lives under `/create/…`. Step order, URLs, and Figma The custom wizard lives under `/create/…`. Step order, URLs, and Figma
stage mapping are canon in [docs/create-flow.md](docs/create-flow.md). stage mapping are canon in
Engineering tracking: Linear **CR-89** (**Done**) / [`docs/create-flow.md`](docs/create-flow.md); component conventions are
[docs/guides/backend-linear-tickets.md](docs/guides/backend-linear-tickets.md) in `.cursor/rules/create-flow.mdc`.
Ticket 17.
## Frontend & tests ## API routes
- Code conventions are enforced by `.cursor/rules/*.mdc` — Cursor surfaces All routes return JSON. Non-`GET` requests expect
the relevant rule when editing matching files. `Content-Type: application/json` unless noted (uploads are multipart).
- See [docs/testing-guide.md](docs/testing-guide.md) for testing
philosophy and `.cursor/rules/testing.mdc` for layout/helpers.
## Pull request workflow ### Auth & account
| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/api/health` | Liveness + DB connectivity. |
| GET | `/api/auth/session` | Current user or `null`. |
| POST | `/api/auth/magic-link/request` | Send sign-in link. |
| GET | `/api/auth/magic-link/verify` | Validate token, set cookie, redirect. |
| POST | `/api/auth/logout` | Clear session. |
| DELETE | `/api/user/me` | Delete authenticated account. |
| POST | `/api/user/email-change/request` | Send verify link to new address. |
| GET | `/api/user/email-change/verify` | Apply email change. |
### Drafts & uploads
| Method | Path | Purpose |
| --- | --- | --- |
| GET, PUT | `/api/drafts/me` | Load / save the signed-in create-flow draft. |
| POST | `/api/uploads` | Multipart upload (requires `UPLOAD_ROOT`). |
| GET | `/api/uploads/[id]` | Stream a previously uploaded file (public). |
### Rules
| Method | Path | Purpose |
| --- | --- | --- |
| GET, POST | `/api/rules` | List or publish rules. |
| GET | `/api/rules/me` | Owner's published rules. |
| GET, PATCH, DELETE | `/api/rules/[id]` | Public read; owner update / delete. |
| POST | `/api/rules/[id]/duplicate` | Owner clone. |
| GET, POST | `/api/rules/[id]/stakeholders` | List / invite stakeholders. |
| DELETE | `/api/rules/[id]/stakeholders/[stakeholderId]` | Remove stakeholder. |
| POST | `/api/rules/[id]/stakeholders/[stakeholderId]/resend` | Resend invite email. |
| GET | `/api/invites/rule-stakeholder/verify` | Verify stakeholder invite token. |
### Templates & create-flow catalog
| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/api/templates` | List curated templates. Repeatable `facet.<group>=<value>` query params re-rank results. |
| GET | `/api/templates/[slug]` | Single template with normalized `{ section, slug }` composition. |
| GET | `/api/create-flow/methods` | Built-in governance methods / core values for the wizard. Required `section` query param. |
Facet semantics and the recommendation matrix:
[`docs/guides/template-recommendation-matrix.md`](docs/guides/template-recommendation-matrix.md)
§9.
### Misc
| Method | Path | Purpose |
| --- | --- | --- |
| POST | `/api/organizer-inquiry` | "Ask an organizer" form submission. |
| POST | `/api/use-cases/[slug]/duplicate` | Duplicate a use-case demo rule. |
| GET, POST | `/api/web-vitals` | Read / ingest web vitals. Storage mode set by `WEB_VITALS_STORAGE` (`local` in dev, `external` in prod). |
## Testing
The full testing recipe and philosophy live in
[`docs/testing-guide.md`](docs/testing-guide.md). Component conventions
and shared helpers are in `.cursor/rules/testing.mdc`.
A typical pre-merge subset:
```bash
npx tsc --noEmit
npm run knip
npm test
npx next build
```
Add `npm run e2e` for routing, auth, or critical-flow changes, and
`npm run migrate:smoke` for anything under `prisma/`.
## Pull-request workflow
1. Branch from `main`: `git checkout -b feature/<short-name>`. 1. Branch from `main`: `git checkout -b feature/<short-name>`.
2. Make the change and add/update tests. 2. Make the change and add or update tests.
3. Before merging, run [docs/testing-guide.md](docs/testing-guide.md#running-tests) *Running tests*. 3. Run the relevant subset of the testing recipe above.
4. Commit using a clear message (`feat:`, `fix:`, `chore:`, …). 4. Commit using a conventional-commit prefix: `feat:`, `fix:`,
5. Open a pull request. `chore:`, `docs:`, `refactor:`, `test:`.
5. Open a pull request; link the Linear ticket if there is one (e.g.
`CR-123`).
+1 -2
View File
@@ -4,7 +4,7 @@
"title": "Community Rule", "title": "Community Rule",
"author": "MEDLab", "author": "MEDLab",
"description": "Community governance and rule-building app", "description": "Community governance and rule-building app",
"version": "0.1.0", "version": "0.1.14",
"httpPort": 3000, "httpPort": 3000,
"healthCheckPath": "/api/health", "healthCheckPath": "/api/health",
"memoryLimit": 805306368, "memoryLimit": 805306368,
@@ -14,7 +14,6 @@
"sendmail": {}, "sendmail": {},
"localstorage": {} "localstorage": {}
}, },
"dockerimage": "git.medlab.host/communityrule/community-rule:0.1.0",
"website": "https://communityrule.info", "website": "https://communityrule.info",
"contactEmail": "hello@communityrule.info" "contactEmail": "hello@communityrule.info"
} }
+12 -4
View File
@@ -28,6 +28,12 @@ RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
RUN npm run build RUN npm run build
# Standalone output omits TS sources the seed imports; bundle seed + JSON paths
# so `node prisma/seed.bundle.cjs` works in the slim runner (no tsx/lib/ tree).
RUN ./node_modules/.bin/esbuild prisma/seed.ts \
--bundle --platform=node --format=cjs \
--outfile=prisma/seed.bundle.cjs \
--external:@prisma/client
FROM base AS runner FROM base AS runner
# openssl: Prisma engines. gosu: privilege drop in start.sh after chown. # openssl: Prisma engines. gosu: privilege drop in start.sh after chown.
@@ -42,11 +48,13 @@ COPY --from=builder --chown=node:node /app/public ./public
COPY --from=builder --chown=node:node /app/.next/standalone ./ COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static COPY --from=builder --chown=node:node /app/.next/static ./.next/static
COPY --from=builder --chown=node:node /app/prisma ./prisma COPY --from=builder --chown=node:node /app/prisma ./prisma
# Facet/template seed JSON — NOT under /app/data (localstorage mount overlays that).
COPY --from=builder --chown=node:node /app/data ./seed-data
ENV SEED_DATA_DIR=/app/seed-data
# Prisma CLI is in devDependencies and is not included in the Next.js # Prisma CLI (devDependency) is not in the Next.js standalone trace. Install
# standalone output. Copy it explicitly so start.sh can run migrations. # globally in the runner so start.sh can run `prisma migrate deploy`.
COPY --from=builder --chown=node:node /app/node_modules/prisma ./node_modules/prisma RUN npm install -g prisma@6.19.3
COPY --from=builder --chown=node:node /app/node_modules/.bin/prisma ./node_modules/.bin/prisma
# Cloudron's runtime rootfs is read-only except /tmp, /run, /app/data. # Cloudron's runtime rootfs is read-only except /tmp, /run, /app/data.
# Three marketing routes use ISR (`revalidate`) and write to .next/cache; # Three marketing routes use ISR (`revalidate`) and write to .next/cache;
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
CommunityRule
Copyright (C) 2020 Media Enterprise Design Lab
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
CommunityRule Copyright (C) 2020 Media Enterprise Design Lab
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
+47 -20
View File
@@ -1,66 +1,93 @@
# Community Rule # Community Rule
A Next.js application for community decision-making and governance A Next.js application for community decision-making and governance
documentation. documentation — author, browse, and share governance "rules" built from
curated templates and a guided wizard.
Live at [communityrule.info](https://communityrule.info). Packaged as a
Cloudron app for MEDLab; see
[docs/guides/ops-backend-deploy.md](docs/guides/ops-backend-deploy.md)
for the deployment handoff.
## Requirements ## Requirements
- Node.js **20+** (LTS) - Node.js **20+** (LTS)
- npm **10+** - npm **10+**
- Docker (for local Postgres and Mailhog)
## Getting started ## Quick start
```bash ```bash
cp .env.example .env # then set SESSION_SECRET (≥16 chars)
docker compose up -d postgres # add `mailhog` for a local inbox
npm ci npm ci
npx prisma migrate dev
npm run dev npm run dev
``` ```
Open [http://localhost:3000](http://localhost:3000). Open [http://localhost:3000](http://localhost:3000). Without
`CLOUDRON_MAIL_SMTP_*` set, magic-link sign-in URLs are printed to the
dev-server log instead of emailed.
Backend setup (Postgres, Prisma, magic-link auth) is documented in Full local backend, API reference, and PR workflow:
[CONTRIBUTING.md](CONTRIBUTING.md). [CONTRIBUTING.md](CONTRIBUTING.md).
## Common scripts ## Scripts
| Command | What it does | | Command | What it does |
| --- | --- | | --- | --- |
| `npm run dev` | Next.js dev server (Turbopack). | | `npm run dev` | Next.js dev server (Turbopack). |
| `npm run build` / `npm start` | Production build / serve. | | `npm run build` / `npm start` | Production build / serve. |
| `npm test` | Vitest unit + component tests with coverage. | | `npm test` | Vitest unit + component tests with coverage. |
| `npm run test:component` | Faster inner loop — components only. | | `npm run test:component` | Components only — faster inner loop. |
| `npm run e2e` | Playwright E2E + visual regression. | | `npm run e2e` | Playwright E2E + visual regression. |
| `npm run migrate:smoke` | Throwaway Postgres + `prisma migrate deploy` (Docker required). |
| `npm run storybook` | Storybook on port 6006. | | `npm run storybook` | Storybook on port 6006. |
| `npm run knip` | Detect unused files / exports. |
| `npm run lhci` | Lighthouse CI performance pass. | | `npm run lhci` | Lighthouse CI performance pass. |
See [`package.json`](package.json) for the full list (visual regression,
bundle analysis, seeding, etc.).
## Project layout ## Project layout
```text ```text
app/ Next.js app router: route groups (marketing), (app), (admin), (dev); app/ Next.js app router route groups (marketing), (app),
shared components under app/components/; optional _components/ (admin), (dev); shared components under app/components/;
colocated with a route (e.g. (admin)/monitor/_components/) admin-only widgets under app/(admin)/<route>/_components/
lib/ Shared library code (i18n, validation, utilities) lib/ Shared library code (server, validation, create-flow logic)
messages/en/ Localized UI copy (see docs/guides/i18n-translation-workflow.md) prisma/ Schema, migrations, seed
prisma/ Database schema, migrations, seed messages/en/ Localized UI copy (single-locale today; English)
public/ Static assets public/ Static assets
stories/ Storybook stories stories/ Storybook stories
tests/ Vitest + Playwright suites tests/ Vitest + Playwright suites (mirror source paths)
docs/ User-facing documentation (start with docs/README.md) docs/ Human-facing documentation start at docs/README.md
.cursor/rules/ Implementation conventions enforced by Cursor .cursor/rules/ Per-file conventions (auto-loaded by Cursor)
scripts/ Build, release, and smoke-test scripts
``` ```
## Tech stack ## Tech stack
Next.js 16 · React 19 · TypeScript · Tailwind CSS 4 · Prisma · Vitest · Next.js 16 · React 19 · TypeScript · Tailwind CSS 4 · Prisma 6 ·
Playwright · Storybook 10 · Lighthouse CI. PostgreSQL · Vitest · Playwright · Storybook 10 · Lighthouse CI.
## Documentation ## Documentation
- [docs/README.md](docs/README.md) — index of guides and rules. - [docs/README.md](docs/README.md) — index of guides and rules.
- [docs/create-flow.md](docs/create-flow.md) — create-rule wizard canon. - [docs/create-flow.md](docs/create-flow.md) — create-rule wizard canon.
- [docs/testing-guide.md](docs/testing-guide.md) — testing philosophy. - [docs/testing-guide.md](docs/testing-guide.md) — testing philosophy.
- [CONTRIBUTING.md](CONTRIBUTING.md) — local backend, API routes, PR - [docs/guides/ops-backend-deploy.md](docs/guides/ops-backend-deploy.md)
workflow. — Cloudron deploy + cutover plan.
- [CONTRIBUTING.md](CONTRIBUTING.md) — local backend, API routes, PR workflow.
- [AGENTS.md](AGENTS.md) — orientation for AI coding agents.
## License ## License
[MIT](LICENSE). Application source code is licensed under the
[GNU General Public License v3.0](LICENSE), the same license as the
legacy [GitLab project](https://gitlab.com/medlabboulder/communityrule).
Copyright (C) 2020 Media Enterprise Design Lab.
User-facing content (guides, template copy, marketing text) is licensed
under [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/),
as stated on [communityrule.info/about](https://communityrule.info/about/).
+34 -2
View File
@@ -1,7 +1,39 @@
import type { ReactNode } from "react"; 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
// — the fallback can't access `usePathname()` since it sits in the static shell.
//
// Operator/admin dashboards (e.g. `/monitor`) intentionally render without the // Operator/admin dashboards (e.g. `/monitor`) intentionally render without the
// public marketing footer. Auth/access is enforced upstream. // public marketing footer. Auth/access is enforced upstream.
export default function AdminLayout({ children }: { children: ReactNode }) { export default function AdminLayout({ children }: { children: ReactNode }) {
return <main className="flex-1">{children}</main>; return (
<MessagesProvider messages={messages}>
<AuthModalProvider>
<SkipToContent />
<Suspense fallback={null}>
<ConditionalNavigation />
</Suspense>
<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"; import MonitorPageContent from "./MonitorPageContent";
export const metadata: Metadata = routeMetadata("/monitor", {
title: messages.metadata.monitor.title,
});
export default function MonitorPage() { export default function MonitorPage() {
return <MonitorPageContent />; return <MonitorPageContent />;
} }
+167 -50
View File
@@ -12,6 +12,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext"; import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation"; import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
import { useCreateFlowExit } from "./hooks/useCreateFlowExit"; import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
import { usePrefetchMethodFacetRecommendations } from "./hooks/usePrefetchMethodFacetRecommendations";
import { useCreateFlowFinalize } from "./hooks/useCreateFlowFinalize"; import { useCreateFlowFinalize } from "./hooks/useCreateFlowFinalize";
import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions"; import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions";
import { useCompletedRuleShareExport } from "./hooks/useCompletedRuleShareExport"; import { useCompletedRuleShareExport } from "./hooks/useCompletedRuleShareExport";
@@ -22,15 +23,16 @@ import {
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE, CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY, CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
getNextStep, getNextStep,
getStepIndex,
parseReviewReturnSearchParam, parseReviewReturnSearchParam,
shouldOfferCreateFlowSaveAndExit,
isDirectTemplateReviewEntry,
createFlowStepUsesSelectSplitScroll, createFlowStepUsesSelectSplitScroll,
TEMPLATES_FACET_RECOMMEND_QUERY, isCreateFlowTemplatesPickerPath,
TEMPLATES_FACET_RECOMMEND_VALUE,
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY, TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE, TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
} from "./utils/flowSteps"; } from "./utils/flowSteps";
import { import {
CREATE_FLOW_COMMUNITY_SAVE_FORM_ID,
CREATE_FLOW_SYNC_DRAFT_QUERY, CREATE_FLOW_SYNC_DRAFT_QUERY,
CREATE_FLOW_SYNC_DRAFT_VALUE, CREATE_FLOW_SYNC_DRAFT_VALUE,
CREATE_ROUTES, CREATE_ROUTES,
@@ -63,12 +65,23 @@ import {
} from "../../../lib/create/publishedDocumentToCreateFlowState"; } from "../../../lib/create/publishedDocumentToCreateFlowState";
import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets"; import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets";
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule"; import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
import {
clearPendingKeepRuleLogin,
hasPendingKeepRuleLogin,
} from "../../../lib/create/pendingKeepRuleLogin";
import { runCompletedStepExit } from "./utils/runCompletedStepExit"; import { runCompletedStepExit } from "./utils/runCompletedStepExit";
import messages from "../../../messages/en/index"; import messages from "../../../messages/en/index";
import { import {
CREATE_FLOW_FOOTER_BUTTON_CLASS, CREATE_FLOW_FOOTER_BUTTON_CLASS,
CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS, CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS,
} from "./utils/createFlowFooterClassNames"; } from "./utils/createFlowFooterClassNames";
import {
CREATE_FLOW_MD_CENTERED_MAIN_CLASS,
CREATE_FLOW_PAGE_GUTTER_CLASS,
CREATE_FLOW_SCROLL_REGION_CLASS,
CREATE_FLOW_VIEWPORT_FRAME_CLASS,
getCreateFlowContentMaxClass,
} from "./components/createFlowLayoutTokens";
import { import {
CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP, CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP,
methodCardFacetSectionForConfirmStep, methodCardFacetSectionForConfirmStep,
@@ -82,15 +95,13 @@ import { SignedInDraftHydration } from "./SignedInDraftHydration";
import { CreateFlowPendingAvatarFlush } from "./components/CreateFlowPendingAvatarFlush"; import { CreateFlowPendingAvatarFlush } from "./components/CreateFlowPendingAvatarFlush";
import Alert from "../../components/modals/Alert"; import Alert from "../../components/modals/Alert";
import Create from "../../components/modals/Create"; import Create from "../../components/modals/Create";
import { MODAL_FOOTER_START_SLOT_CLASS } from "../../components/modals/ModalFooter";
import Share from "../../components/modals/Share"; import Share from "../../components/modals/Share";
import { import {
CreateFlowDraftSaveBannerProvider, CreateFlowDraftSaveBannerProvider,
useCreateFlowDraftSaveBanner, useCreateFlowDraftSaveBanner,
} from "./context/CreateFlowDraftSaveBannerContext"; } from "./context/CreateFlowDraftSaveBannerContext";
/** First step where Save & Exit is offered (first Create Community select per Figma). */
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-structure");
function CreateFlowSessionShell({ children }: { children: ReactNode }) { function CreateFlowSessionShell({ children }: { children: ReactNode }) {
const [sessionUser, setSessionUser] = useState< const [sessionUser, setSessionUser] = useState<
{ id: string; email: string } | null | undefined { id: string; email: string } | null | undefined
@@ -108,9 +119,9 @@ function CreateFlowSessionShell({ children }: { children: ReactNode }) {
const sessionResolved = sessionUser !== undefined; const sessionResolved = sessionUser !== undefined;
// Mirror in-progress draft to localStorage for ALL visitors once we know who // Mirror in-progress draft to localStorage for ALL visitors once we know who
// they are. Refresh-survival is the same UX for guest and signed-in users; // they are. Refresh-survival is the same UX for guest and signed-in users.
// signed-in users additionally get an explicit "Save & Exit" that PUTs to // Save & Exit: guests open the save-progress login modal; signed-in users
// the server (handled in `useCreateFlowExit`). // PUT the server draft (`useCreateFlowExit`).
const enableLocalDraftMirroring = sessionResolved; const enableLocalDraftMirroring = sessionResolved;
return ( return (
@@ -151,9 +162,9 @@ function CreateFlowLayoutContent({
const { const {
currentStep, currentStep,
nextStep, nextStep,
previousStep,
goToNextStep, goToNextStep,
goToPreviousStep, goToPreviousStep,
canGoBack,
templateReviewFooterBackToCreateReview, templateReviewFooterBackToCreateReview,
} = useCreateFlowNavigation( } = useCreateFlowNavigation(
skipCommunitySave ? { skipCommunitySave: true } : undefined, skipCommunitySave ? { skipCommunitySave: true } : undefined,
@@ -167,6 +178,7 @@ function CreateFlowLayoutContent({
replaceState, replaceState,
markCreateFlowInteraction, markCreateFlowInteraction,
} = useCreateFlow(); } = useCreateFlow();
usePrefetchMethodFacetRecommendations();
const manageStakeholdersIntent = const manageStakeholdersIntent =
searchParams?.get(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY) === searchParams?.get(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY) ===
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE; CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE;
@@ -187,12 +199,14 @@ function CreateFlowLayoutContent({
useState(false); useState(false);
const [completedFlowBanner, setCompletedFlowBanner] = useState<{ const [completedFlowBanner, setCompletedFlowBanner] = useState<{
key: string; key: string;
status: "positive" | "danger"; status: "positive" | "danger" | "warning";
title: string; title: string;
description?: string; description?: string;
} | null>(null); } | null>(null);
const [shareModalOpen, setShareModalOpen] = useState(false); const [shareModalOpen, setShareModalOpen] = useState(false);
const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false); const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false);
const [completedHasPublishedRule, setCompletedHasPublishedRule] =
useState(false);
const leaveConfirmResolverRef = useRef<((proceed: boolean) => void) | null>( const leaveConfirmResolverRef = useRef<((proceed: boolean) => void) | null>(
null, null,
); );
@@ -253,8 +267,30 @@ function CreateFlowLayoutContent({
openLogin, openLogin,
updateState, updateState,
loginReturnPath, loginReturnPath,
sessionUser,
onGuestPublished: ({ skippedInvites }) => {
const completedCopy = create.reviewAndComplete.completed;
if (skippedInvites) {
setCompletedFlowBanner({
key: "guestInvitesSkipped",
status: "warning",
title: completedCopy.guestInvitesSkippedTitle,
description: completedCopy.guestInvitesSkippedDescription,
});
} else {
setCompletedFlowBanner({
key: "guestClaimHint",
status: "warning",
title: completedCopy.guestClaimHintTitle,
description: completedCopy.guestClaimHintDescription,
});
}
},
}); });
const fromCreateWizard =
searchParams.get(TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY) ===
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE;
const { const {
isTemplateReviewRoute, isTemplateReviewRoute,
templateReviewSlug, templateReviewSlug,
@@ -269,7 +305,11 @@ function CreateFlowLayoutContent({
updateState, updateState,
replaceState, replaceState,
router, router,
fromCreateWizard,
markCreateFlowInteraction,
}); });
const isCreateTemplatesPickerRoute =
isCreateFlowTemplatesPickerPath(pathname);
const runAuthenticatedExit = useCreateFlowExit({ const runAuthenticatedExit = useCreateFlowExit({
state, state,
@@ -282,14 +322,34 @@ function CreateFlowLayoutContent({
}); });
const handleExit = async (opts?: { saveDraft?: boolean }) => { const handleExit = async (opts?: { saveDraft?: boolean }) => {
const saveDraft = opts?.saveDraft ?? false; // Direct template preview: browsing a catalog card, nothing collected yet.
// In-flow (`?fromFlow=1`) still has community progress — keep save / confirm.
if (isDirectTemplateReviewEntry(pathname, searchParams)) {
clearState();
router.push(CREATE_ROUTES.root);
return;
}
if (!sessionResolved) return; if (!sessionResolved) return;
// Exit from `/create/completed` is post-publish: the rule is saved, so we // Completed is post-publish. Guests still need Save & Exit → keep-this-rule
// skip the leave-confirm + login prompt and just wipe the in-flight draft. // login (the document is live but unclaimed). Signed-in users just leave.
// For signed-in users we also DELETE the server draft so a future visit to
// /create starts fresh instead of rehydrating yesterday's work.
if (currentStep === "completed") { if (currentStep === "completed") {
if (sessionUser === null) {
openLogin({
variant: "keepRule",
nextPath: CREATE_ROUTES.completed,
backdropVariant: "blurredYellow",
onDismiss: () => {
runCompletedStepExit({
clearState,
clearAnonymousCreateFlowStorage,
router,
});
},
});
return;
}
runCompletedStepExit({ runCompletedStepExit({
clearState, clearState,
clearAnonymousCreateFlowStorage, clearAnonymousCreateFlowStorage,
@@ -299,7 +359,6 @@ function CreateFlowLayoutContent({
} }
if (sessionUser === null) { if (sessionUser === null) {
if (saveDraft) return;
const returnToTemplateReview = const returnToTemplateReview =
templateReviewSlug != null templateReviewSlug != null
? `/create/review-template/${encodeURIComponent(templateReviewSlug)}?syncDraft=1` ? `/create/review-template/${encodeURIComponent(templateReviewSlug)}?syncDraft=1`
@@ -405,15 +464,54 @@ function CreateFlowLayoutContent({
useEffect(() => { useEffect(() => {
if (currentStep !== "completed") { if (currentStep !== "completed") {
setCompletedFlowBanner(null); setCompletedFlowBanner(null);
setCompletedHasPublishedRule(false);
return;
} }
setCompletedHasPublishedRule(Boolean(readLastPublishedRule()));
}, [currentStep]); }, [currentStep]);
const handleCommunitySaveMagicLinkSubmit = useCallback(async () => { useEffect(() => {
if (currentStep !== "completed") return;
if (!sessionResolved) return;
if (sessionUser !== null) {
clearPendingKeepRuleLogin();
return;
}
if (!hasPendingKeepRuleLogin()) return;
const timeoutId = window.setTimeout(() => {
if (!hasPendingKeepRuleLogin()) return;
clearPendingKeepRuleLogin();
openLogin({
variant: "keepRule",
nextPath: CREATE_ROUTES.completed,
backdropVariant: "blurredYellow",
});
}, 0);
return () => window.clearTimeout(timeoutId);
}, [currentStep, sessionResolved, sessionUser, openLogin]);
const handleCommunitySaveMagicLinkSubmit = useCallback(async (
formEl?: HTMLFormElement | null,
) => {
setCommunitySaveMagicLinkError(null); setCommunitySaveMagicLinkError(null);
setCommunitySaveMagicLinkSuccess(false); setCommunitySaveMagicLinkSuccess(false);
const raw = state.communitySaveEmail; const namedEmail = formEl?.elements.namedItem("email");
const trimmed = typeof raw === "string" ? raw.trim().toLowerCase() : ""; const emailField =
if (!isValidCreateFlowSaveEmail(trimmed)) return; namedEmail instanceof HTMLInputElement
? namedEmail
: typeof document === "undefined"
? null
: document.querySelector<HTMLInputElement>(
`input[name="email"][form="${CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}"]`,
);
if (emailField != null && !emailField.checkValidity()) {
emailField.reportValidity();
return;
}
const raw =
emailField != null ? emailField.value : state.communitySaveEmail;
if (typeof raw !== "string" || !isValidCreateFlowSaveEmail(raw)) return;
const trimmed = raw.trim().toLowerCase();
setCommunitySaveMagicLinkSubmitting(true); setCommunitySaveMagicLinkSubmitting(true);
try { try {
@@ -463,15 +561,16 @@ function CreateFlowLayoutContent({
const isSelectSplitScrollStep = createFlowStepUsesSelectSplitScroll( const isSelectSplitScrollStep = createFlowStepUsesSelectSplitScroll(
currentStep, currentStep,
); );
const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
/** At `md+`, main cross-axis: center by default; exceptions stay top-aligned (see product spec). */ /** Lockup+card / card-stack: `items-start` + shell `my-auto` so overflow scrolls from the top. */
const mainContentClass = isCompletedStep const mainContentClass = isCompletedStep
? "items-stretch overflow-y-auto md:overflow-hidden" ? "items-stretch overflow-y-auto md:overflow-hidden"
: isSelectSplitScrollStep : isSelectSplitScrollStep
? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden" ? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden"
: isFinalReviewLike || isCardLayoutStep || isTemplateReviewRoute : isCreateTemplatesPickerRoute
? "items-start justify-center overflow-y-auto" ? "items-start justify-start overflow-y-auto"
: isTemplateReviewRoute || isFinalReviewLike || isCardLayoutStep
? CREATE_FLOW_MD_CENTERED_MAIN_CLASS
: "items-start justify-center overflow-y-auto md:items-center"; : "items-start justify-center overflow-y-auto md:items-center";
const isTextStep = createFlowStepUsesCenteredTextLayout(currentStep); const isTextStep = createFlowStepUsesCenteredTextLayout(currentStep);
@@ -484,8 +583,8 @@ function CreateFlowLayoutContent({
: "max-md:flex-col max-md:items-center"; : "max-md:flex-col max-md:items-center";
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`; const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
const saveDraftOnExit = const saveDraftOnExit =
Boolean(sessionUser) && shouldOfferCreateFlowSaveAndExit(currentStep, sessionUser) ||
(stepIdx >= SAVE_EXIT_FROM_STEP_INDEX || currentStep === "edit-rule"); isCreateTemplatesPickerRoute;
const proportionBarProgress = getProportionBarProgressForCreateFlowStep( const proportionBarProgress = getProportionBarProgressForCreateFlowStep(
currentStep, currentStep,
@@ -514,7 +613,7 @@ function CreateFlowLayoutContent({
*/ */
const topBanners: Array<{ const topBanners: Array<{
key: string; key: string;
status: "danger" | "positive"; status: "danger" | "positive" | "warning";
title: string; title: string;
description?: string; description?: string;
onClose: () => void; onClose: () => void;
@@ -533,7 +632,7 @@ function CreateFlowLayoutContent({
key: "publish", key: "publish",
status: "danger" as const, status: "danger" as const,
title: title:
messages.create.reviewAndComplete.publish.finalizeBannerTitle, messages.create.reviewAndComplete.publish.publishBannerTitle,
description: publishBannerMessage, description: publishBannerMessage,
onClose: () => setPublishBannerMessage(null), onClose: () => setPublishBannerMessage(null),
} }
@@ -577,10 +676,10 @@ function CreateFlowLayoutContent({
].filter((b): b is NonNullable<typeof b> => b !== null); ].filter((b): b is NonNullable<typeof b> => b !== null);
return ( return (
<div className="relative flex h-screen min-h-0 flex-col overflow-hidden bg-black"> <div className={`${CREATE_FLOW_VIEWPORT_FRAME_CLASS} bg-black`}>
{topBanners.length > 0 ? ( {topBanners.length > 0 ? (
<div <div
className="pointer-events-none fixed left-0 right-0 top-0 z-[200] flex flex-col gap-2 px-[var(--spacing-measures-spacing-500,20px)] pt-[var(--spacing-measures-spacing-300,12px)] md:px-[var(--measures-spacing-1800,64px)]" className={`pointer-events-none fixed left-0 right-0 top-0 z-[200] flex flex-col gap-2 ${CREATE_FLOW_PAGE_GUTTER_CLASS} pt-[var(--spacing-measures-spacing-300,12px)]`}
aria-live="polite" aria-live="polite"
> >
{topBanners.map((b) => ( {topBanners.map((b) => (
@@ -634,6 +733,7 @@ function CreateFlowLayoutContent({
nextButtonText={messages.create.topNav.leaveConfirmProceed} nextButtonText={messages.create.topNav.leaveConfirmProceed}
onNext={() => closeLeaveConfirm(true)} onNext={() => closeLeaveConfirm(true)}
footerContent={ footerContent={
<div className={MODAL_FOOTER_START_SLOT_CLASS}>
<Button <Button
buttonType="ghost" buttonType="ghost"
palette="default" palette="default"
@@ -642,6 +742,7 @@ function CreateFlowLayoutContent({
> >
{messages.create.topNav.leaveConfirmCancel} {messages.create.topNav.leaveConfirmCancel}
</Button> </Button>
</div>
} }
backdropVariant="blurredYellow" backdropVariant="blurredYellow"
ariaLabel={messages.create.topNav.leaveConfirmTitle} ariaLabel={messages.create.topNav.leaveConfirmTitle}
@@ -649,7 +750,10 @@ function CreateFlowLayoutContent({
<CreateFlowTopNav <CreateFlowTopNav
hasShare={isCompletedStep} hasShare={isCompletedStep}
hasExport={isCompletedStep} hasExport={isCompletedStep}
hasEdit={isCompletedStep} hasEdit={
isCompletedStep &&
(Boolean(sessionUser) || completedHasPublishedRule)
}
hasManageStakeholders={isEditRuleStep} hasManageStakeholders={isEditRuleStep}
saveDraftOnExit={saveDraftOnExit} saveDraftOnExit={saveDraftOnExit}
onShare={ onShare={
@@ -691,16 +795,32 @@ function CreateFlowLayoutContent({
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : "" isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : ""
}`.trim()} }`.trim()}
/> />
{currentStep === "community-save" ? (
<form
id={CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}
className="hidden"
onSubmit={(e) => {
e.preventDefault();
void handleCommunitySaveMagicLinkSubmit(e.currentTarget);
}}
/>
) : null}
<main <main
className={`flex min-h-0 flex-1 w-full ${mainContentClass} ${mainResponsiveLayout}`} className={`flex min-h-0 min-w-0 flex-1 w-full max-w-full overflow-x-hidden ${CREATE_FLOW_SCROLL_REGION_CLASS} ${mainContentClass} ${mainResponsiveLayout}`}
> >
{children} {children}
</main> </main>
{!isCompletedStep && ( {!isCompletedStep && (
<CreateFlowFooter <CreateFlowFooter
className="shrink-0" className="shrink-0"
contentMaxClass={getCreateFlowContentMaxClass({
step: currentStep,
isTemplateReview: isTemplateReviewRoute,
isTemplatesPicker: isCreateTemplatesPickerRoute,
})}
progressBar={ progressBar={
!isTemplateReviewRoute && !isTemplateReviewRoute &&
!isCreateTemplatesPickerRoute &&
!isFinalReviewLike && !isFinalReviewLike &&
reviewReturnTarget !== "edit-rule" reviewReturnTarget !== "edit-rule"
} }
@@ -762,6 +882,8 @@ function CreateFlowLayoutContent({
{footer.saveLater} {footer.saveLater}
</Button> </Button>
<Button <Button
type="submit"
form={CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}
buttonType="filled" buttonType="filled"
palette="default" palette="default"
size="xsmall" size="xsmall"
@@ -772,9 +894,6 @@ function CreateFlowLayoutContent({
!isValidCreateFlowSaveEmail(state.communitySaveEmail) !isValidCreateFlowSaveEmail(state.communitySaveEmail)
} }
className={CREATE_FLOW_FOOTER_BUTTON_CLASS} className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
void handleCommunitySaveMagicLinkSubmit();
}}
> >
{communitySaveMagicLinkSubmitting {communitySaveMagicLinkSubmitting
? footer.submitEmailSending ? footer.submitEmailSending
@@ -805,15 +924,7 @@ function CreateFlowLayoutContent({
disabled={isPublishing} disabled={isPublishing}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS} className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => { onClick={() => {
// `fromFlow=1` tells `/templates` to skip the fresh-slate router.push(CREATE_ROUTES.templatesPicker);
// draft clear it normally runs on template click, so the
// user's in-progress Create Community stage survives this
// detour. Direct entries to `/templates` (no marker) and
// home "Popular templates" clicks always start fresh by
// wiping anonymous draft storage at click time.
router.push(
`/templates?${TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY}=${TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE}&${TEMPLATES_FACET_RECOMMEND_QUERY}=${TEMPLATES_FACET_RECOMMEND_VALUE}`,
);
}} }}
> >
{footer.createFromTemplate} {footer.createFromTemplate}
@@ -876,7 +987,9 @@ function CreateFlowLayoutContent({
buttonType="filled" buttonType="filled"
palette="default" palette="default"
size="xsmall" size="xsmall"
disabled={isPublishing} disabled={
isPublishing || (isFinalReviewLike && !sessionResolved)
}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS} className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => { onClick={() => {
if (isFinalReviewLike) { if (isFinalReviewLike) {
@@ -889,14 +1002,18 @@ function CreateFlowLayoutContent({
{isFinalReviewLike {isFinalReviewLike
? isPublishing ? isPublishing
? messages.create.reviewAndComplete.publish ? messages.create.reviewAndComplete.publish
.finalizeButtonPublishing .publishButtonPublishing
: footer.finalizeCommunityRule : footer.publishCommunityRule
: getDefaultFooterLabel(currentStep, footer)} : getDefaultFooterLabel(currentStep, footer)}
</Button> </Button>
) : null ) : null
} }
onBackClick={ onBackClick={
isTemplateReviewRoute isCreateTemplatesPickerRoute
? () => {
router.push(CREATE_ROUTES.review);
}
: isTemplateReviewRoute
? () => ? () =>
router.push( router.push(
templateReviewFooterBackToCreateReview templateReviewFooterBackToCreateReview
@@ -912,7 +1029,7 @@ function CreateFlowLayoutContent({
), ),
); );
} }
: previousStep : canGoBack()
? goToPreviousStep ? goToPreviousStep
: undefined : undefined
} }
+7 -23
View File
@@ -1,28 +1,12 @@
"use client";
import dynamic from "next/dynamic";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useTranslation } from "../../contexts/MessagesContext"; import CreateFlowLayoutClient from "./CreateFlowLayoutClient";
function CreateFlowLayoutLoading() {
const t = useTranslation("controlsChrome");
return (
<div
className="flex h-screen min-h-0 flex-col overflow-hidden bg-black"
aria-busy="true"
aria-label={t("loadingCreateFlow")}
/>
);
}
const CreateFlowLayoutClient = dynamic(
() => import("./CreateFlowLayoutClient"),
{
ssr: false,
loading: () => <CreateFlowLayoutLoading />,
},
);
/**
* Server-renders the create-flow chrome shell so users see real layout instead
* of a black `aria-busy` div while the client bundle hydrates. The provider
* inside `CreateFlowLayoutClient` defers `localStorage` reads to a mount-once
* effect so SSR + first client render align.
*/
export default function CreateFlowLayoutGate({ export default function CreateFlowLayoutGate({
children, children,
}: { }: {
+40 -1
View File
@@ -11,11 +11,14 @@ import {
import { useCreateFlow } from "./context/CreateFlowContext"; import { useCreateFlow } from "./context/CreateFlowContext";
import { fetchDraftFromServer } from "../../../lib/create/api"; import { fetchDraftFromServer } from "../../../lib/create/api";
import messages from "../../../messages/en/index"; import messages from "../../../messages/en/index";
import { CREATE_FLOW_PAGE_GUTTER_CLASS } from "./components/createFlowLayoutTokens";
import Alert from "../../components/modals/Alert"; import Alert from "../../components/modals/Alert";
import { import {
isValidStep, isValidStep,
isCreateFlowTemplatesPickerPath,
parseCreateFlowScreenFromPathname, parseCreateFlowScreenFromPathname,
} from "./utils/flowSteps"; } from "./utils/flowSteps";
import { hasFreshEntryPending } from "./utils/prepareFreshCreateFlowEntry";
import { isBackendSyncEnabled } from "../../../lib/create/backendSyncEnabled"; import { isBackendSyncEnabled } from "../../../lib/create/backendSyncEnabled";
@@ -52,6 +55,22 @@ export function SignedInDraftHydration({
const [loadingHydration, setLoadingHydration] = useState(false); const [loadingHydration, setLoadingHydration] = useState(false);
const finishedUserIdRef = useRef<string | null>(null); const finishedUserIdRef = useRef<string | null>(null);
const [freshEntryPending, setFreshEntryPending] = useState(false);
// Poll the sessionStorage sentinel set by `prepareFreshCreateFlowEntrySync`.
// Cheap because the gate is open within a few hundred ms in practice; the
// poll stops as soon as the in-flight DELETE clears the flag.
useEffect(() => {
if (!hasFreshEntryPending()) return;
setFreshEntryPending(true);
const id = window.setInterval(() => {
if (!hasFreshEntryPending()) {
setFreshEntryPending(false);
window.clearInterval(id);
}
}, 50);
return () => window.clearInterval(id);
}, []);
useEffect(() => { useEffect(() => {
if (!isBackendSyncEnabled()) return; if (!isBackendSyncEnabled()) return;
@@ -68,6 +87,10 @@ export function SignedInDraftHydration({
return; return;
} }
if (freshEntryPending) {
return;
}
// Local draft wins over server: no fetch, no replaceState. The provider // Local draft wins over server: no fetch, no replaceState. The provider
// already hydrated from localStorage at mount, so the user sees their // already hydrated from localStorage at mount, so the user sees their
// unsaved keystrokes immediately. // unsaved keystrokes immediately.
@@ -81,6 +104,21 @@ export function SignedInDraftHydration({
if (urlStep === "completed") { if (urlStep === "completed") {
return; return;
} }
/**
* Direct template preview is a new-rule entry, not “continue draft”.
* Applying a saved `currentStep` here is what yanked Use without changes
* past name / description / photo to stakeholders.
*/
if (pathname?.includes("/create/review-template/")) {
return;
}
if (isCreateFlowTemplatesPickerPath(pathname)) {
return;
}
if (touchedRef.current) {
finishedUserIdRef.current = userId;
return;
}
let cancelled = false; let cancelled = false;
setLoadingHydration(true); setLoadingHydration(true);
@@ -122,12 +160,13 @@ export function SignedInDraftHydration({
replaceState, replaceState,
pathname, pathname,
router, router,
freshEntryPending,
]); ]);
if (!loadingHydration) return null; if (!loadingHydration) return null;
return ( return (
<div className="pointer-events-none fixed left-0 right-0 top-14 z-[170] flex justify-center px-[var(--spacing-measures-spacing-500,20px)] pt-2 md:top-16 md:px-[var(--measures-spacing-1800,64px)]"> <div className={`pointer-events-none fixed left-0 right-0 top-14 z-[170] flex justify-center ${CREATE_FLOW_PAGE_GUTTER_CLASS} pt-2 md:top-16`}>
<div className="pointer-events-auto w-full max-w-[960px]"> <div className="pointer-events-auto w-full max-w-[960px]">
<Alert <Alert
type="banner" type="banner"
+11
View File
@@ -1,7 +1,10 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { CreateFlowScreenView } from "../screens/CreateFlowScreenView"; import { CreateFlowScreenView } from "../screens/CreateFlowScreenView";
import { createFlowStepPath, CREATE_ROUTES } from "../utils/createFlowPaths";
import { isValidStep } from "../utils/flowSteps"; import { isValidStep } from "../utils/flowSteps";
import type { CreateFlowStep } from "../types"; import type { CreateFlowStep } from "../types";
import { routeMetadata } from "../../../../lib/siteMetadata";
/** /**
* Single dynamic route for the whole create wizard (every step in `FLOW_STEP_ORDER`). * Single dynamic route for the whole create wizard (every step in `FLOW_STEP_ORDER`).
@@ -14,6 +17,14 @@ interface PageProps {
params: Promise<{ screenId: string }>; params: Promise<{ screenId: string }>;
} }
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { screenId: raw } = await params;
const path = isValidStep(raw)
? createFlowStepPath(raw)
: `${CREATE_ROUTES.createRoot}/${raw}`;
return routeMetadata(path);
}
export default async function CreateFlowScreenPage({ params }: PageProps) { export default async function CreateFlowScreenPage({ params }: PageProps) {
const { screenId: raw } = await params; const { screenId: raw } = await params;
@@ -70,20 +70,26 @@ function ApplicableScopeFieldComponent({
return ( return (
<div className={`flex flex-col gap-2 ${className}`.trim()}> <div className={`flex flex-col gap-2 ${className}`.trim()}>
<InputLabel label={label} helpIcon size="s" palette="default" /> <InputLabel label={label} size="s" palette="default" />
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{scopes.map((scope) => { {scopes.map((scope) => {
const isSelected = selectedScopes.includes(scope); const isSelected = selectedScopes.includes(scope);
const chipState = isSelected
? "selected"
: readOnly
? "disabled"
: "unselected";
return ( return (
<Chip <Chip
key={scope} key={scope}
label={scope} label={scope}
state={isSelected ? "selected" : "disabled"} state={chipState}
palette="default" palette="default"
size="s" size="s"
disabled={readOnly} disabled={readOnly}
onClick={() => !readOnly && onToggleScope(scope)} onClick={() => !readOnly && onToggleScope(scope)}
ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`} ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`}
className="max-w-full"
/> />
); );
})} })}
@@ -105,13 +111,13 @@ function ApplicableScopeFieldComponent({
}} }}
placeholder={inputPlaceholder ?? addLabel} placeholder={inputPlaceholder ?? addLabel}
aria-label={inputPlaceholder ?? addLabel} aria-label={inputPlaceholder ?? addLabel}
className="h-[30px] rounded-[9999px] border border-[var(--color-border-default-tertiary)] bg-transparent px-3 font-inter text-[length:var(--sizing-300,12px)] font-medium leading-[14px] text-[color:var(--color-content-default-primary)] outline-none placeholder:text-[color:var(--color-content-default-tertiary)] focus-visible:border-[var(--color-border-default-brand-primary)]" className="h-[30px] rounded-[9999px] border border-[var(--color-border-default-tertiary)] bg-transparent px-3 text-x-small-label text-[color:var(--color-content-default-primary)] outline-none placeholder:text-[color:var(--color-content-default-tertiary)] focus-visible:border-[var(--color-border-default-brand-primary)]"
/> />
) : ( ) : (
<button <button
type="button" type="button"
onClick={() => setIsAdding(true)} onClick={() => setIsAdding(true)}
className="inline-flex items-center gap-[var(--measures-spacing-050,2px)] rounded-[var(--measures-radius-full,9999px)] px-[var(--space-250,10px)] py-[var(--measures-spacing-200,8px)] font-inter text-[length:var(--sizing-300,12px)] font-medium leading-[14px] text-[color:var(--color-content-default-primary)] hover:bg-[var(--color-surface-default-secondary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-transparent" className="inline-flex items-center gap-[var(--measures-spacing-050,2px)] rounded-[var(--measures-radius-full,9999px)] px-[var(--space-250,10px)] py-[var(--measures-spacing-200,8px)] text-x-small-label text-[color:var(--color-content-default-primary)] hover:bg-[var(--color-surface-default-secondary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-transparent"
> >
<AddGlyph /> <AddGlyph />
{addLabel} {addLabel}
@@ -0,0 +1,56 @@
"use client";
import type { ReactNode } from "react";
import { CreateFlowHeaderLockup } from "./CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "./CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_CENTERED_SHELL_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
CREATE_FLOW_WIDE_MAX_CLASS,
} from "./createFlowLayoutTokens";
type CreateFlowCardStackStepShellProps = {
lockupTitle: string;
lockupDescription?: ReactNode;
/** When true, the stack region exposes `aria-busy` while recommendations load. */
stackBusy?: boolean;
children: ReactNode;
};
/**
* Centered lockup + card-stack layout shared by communication, membership, and
* conflict-management steps (Figma compact card stack).
*/
export function CreateFlowCardStackStepShell({
lockupTitle,
lockupDescription,
stackBusy = false,
children,
}: CreateFlowCardStackStepShellProps) {
return (
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-800"
className={CREATE_FLOW_MD_CENTERED_SHELL_CLASS}
>
<div
className={`flex w-full min-w-0 flex-col items-center gap-6 ${CREATE_FLOW_WIDE_MAX_CLASS}`}
>
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={lockupTitle}
description={lockupDescription}
justification="center"
/>
</div>
<div
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
aria-busy={stackBusy}
>
{children}
</div>
</div>
</CreateFlowStepShell>
);
}
@@ -4,6 +4,7 @@ import type { ReactNode } from "react";
import { CreateFlowHeaderLockup } from "./CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "./CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "./CreateFlowStepShell"; import { CreateFlowStepShell } from "./CreateFlowStepShell";
import { import {
CREATE_FLOW_MD_CENTERED_SHELL_CLASS,
CREATE_FLOW_MD_UP_GRID_CELL_CLASS, CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS, CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "./createFlowLayoutTokens"; } from "./createFlowLayoutTokens";
@@ -18,14 +19,18 @@ type CreateFlowLockupCardStepShellProps = {
children: ReactNode; children: ReactNode;
}; };
/** Final-review layout: `wideGrid`, two columns from `md:`, column widths from `createFlowLayoutTokens`. */ /** Lockup + card: `wideGrid`, two columns from `md:`, centered in the navfooter band from `md`. */
export function CreateFlowLockupCardStepShell({ export function CreateFlowLockupCardStepShell({
lockupTitle, lockupTitle,
lockupDescription, lockupDescription,
children, children,
}: CreateFlowLockupCardStepShellProps) { }: CreateFlowLockupCardStepShellProps) {
return ( return (
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800"> <CreateFlowStepShell
variant="wideGrid"
contentTopBelowMd="space-800"
className={CREATE_FLOW_MD_CENTERED_SHELL_CLASS}
>
<div <div
className={`mx-auto flex w-full min-w-0 flex-col gap-4 md:grid md:w-full md:grid-cols-2 md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`} className={`mx-auto flex w-full min-w-0 flex-col gap-4 md:grid md:w-full md:grid-cols-2 md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
> >
@@ -2,7 +2,10 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useCreateFlow } from "../context/CreateFlowContext"; import { useCreateFlow } from "../context/CreateFlowContext";
import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer"; import {
CreateFlowUploadValidationError,
uploadCreateFlowFile,
} from "../../../../lib/create/uploadToServer";
import { import {
clearPendingCommunityAvatarFile, clearPendingCommunityAvatarFile,
readPendingCommunityAvatarFile, readPendingCommunityAvatarFile,
@@ -19,13 +22,17 @@ export function CreateFlowPendingAvatarFlush({
sessionUser: { id: string; email: string } | null | undefined; sessionUser: { id: string; email: string } | null | undefined;
sessionResolved: boolean; sessionResolved: boolean;
}) { }) {
const { updateState } = useCreateFlow(); const { state, updateState } = useCreateFlow();
/** One successful flush per signed-in user id (survives React StrictMode remounts). */ /** One successful flush per signed-in user id (survives React StrictMode remounts). */
const lastFlushedUserIdRef = useRef<string | null>(null); const lastFlushedUserIdRef = useRef<string | null>(null);
const hasServerAvatar =
typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0;
useEffect(() => { useEffect(() => {
if (!sessionResolved || !sessionUser) return; if (!sessionResolved || !sessionUser) return;
if (lastFlushedUserIdRef.current === sessionUser.id) return; if (lastFlushedUserIdRef.current === sessionUser.id) return;
if (hasServerAvatar) return;
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
@@ -37,15 +44,18 @@ export function CreateFlowPendingAvatarFlush({
await clearPendingCommunityAvatarFile(); await clearPendingCommunityAvatarFile();
updateState({ communityAvatarUrl: url }); updateState({ communityAvatarUrl: url });
lastFlushedUserIdRef.current = sessionUser.id; lastFlushedUserIdRef.current = sessionUser.id;
} catch { } catch (err) {
// Leave pending blob in place so the user can retry after fixing auth / UPLOAD_ROOT. if (err instanceof CreateFlowUploadValidationError) {
await clearPendingCommunityAvatarFile();
}
// Leave a transient (auth / UPLOAD_ROOT) failure in place to retry.
} }
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [sessionResolved, sessionUser, updateState]); }, [hasServerAvatar, sessionResolved, sessionUser, updateState]);
return null; return null;
} }
@@ -1,6 +1,7 @@
"use client"; "use client";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { CREATE_FLOW_PAGE_GUTTER_CLASS } from "./createFlowLayoutTokens";
export type CreateFlowStepShellVariant = export type CreateFlowStepShellVariant =
| "centeredNarrow" | "centeredNarrow"
@@ -13,15 +14,12 @@ export type CreateFlowStepShellVariant =
export type CreateFlowContentTopBelowMd = "none" | "space-1400" | "space-800"; export type CreateFlowContentTopBelowMd = "none" | "space-1400" | "space-800";
const outerByVariant: Record<CreateFlowStepShellVariant, string> = { const outerByVariant: Record<CreateFlowStepShellVariant, string> = {
centeredNarrow: centeredNarrow: `flex w-full min-w-0 flex-col items-center ${CREATE_FLOW_PAGE_GUTTER_CLASS}`,
"flex w-full min-w-0 flex-col items-center px-5 md:px-16", centeredNarrowBottomPad: `flex w-full min-w-0 flex-col items-center ${CREATE_FLOW_PAGE_GUTTER_CLASS} pb-28 md:pb-32`,
centeredNarrowBottomPad: /** Wide two-column steps; inner content supplies the 1328px cap. */
"flex w-full min-w-0 flex-col items-center px-5 pb-28 md:px-[var(--measures-spacing-1800,64px)] md:pb-32", wideGrid: `flex w-full min-w-0 shrink-0 flex-col items-center ${CREATE_FLOW_PAGE_GUTTER_CLASS}`,
/** Wide two-column steps; 1328px = two 640px columns + 48px gutter. */ /** Create Community review + card grid; inner content supplies the 1440px cap. */
wideGrid: "w-full min-w-0 max-w-[1328px] shrink-0 px-5 md:px-12", wideGridLoosePadding: `flex w-full min-w-0 shrink-0 flex-col items-center ${CREATE_FLOW_PAGE_GUTTER_CLASS}`,
/** Create Community review + card grid (Figma Flow — Review `19706:12135`): max width 1440. */
wideGridLoosePadding:
"w-full min-w-0 max-w-[1440px] shrink-0 px-5 md:px-16",
bare: "w-full min-w-0", bare: "w-full min-w-0",
}; };
@@ -41,7 +39,8 @@ interface CreateFlowStepShellProps {
/** /**
* Shared horizontal padding and width constraints for create-flow step pages. * Shared horizontal padding and width constraints for create-flow step pages.
* Horizontal padding uses Tailwind `md:` so it tracks `--breakpoint-md` (640px in `app/tailwind.css`). * Gutters come from {@link CREATE_FLOW_PAGE_GUTTER_CLASS} (`md` / `lg` track
* `--breakpoint-md` and `--breakpoint-lg` in `app/tailwind.css`).
*/ */
export function CreateFlowStepShell({ export function CreateFlowStepShell({
children, children,
@@ -5,7 +5,11 @@ import {
CreateFlowStepShell, CreateFlowStepShell,
type CreateFlowContentTopBelowMd, type CreateFlowContentTopBelowMd,
} from "./CreateFlowStepShell"; } from "./CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "./createFlowLayoutTokens"; import {
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
CREATE_FLOW_SCROLL_REGION_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "./createFlowLayoutTokens";
export type CreateFlowSelectShellLgVerticalAlign = "center" | "start"; export type CreateFlowSelectShellLgVerticalAlign = "center" | "start";
@@ -26,10 +30,10 @@ interface CreateFlowTwoColumnSelectShellProps {
} }
/** /**
* Two-column layout for create-flow select steps (community size/structure, core values) and * Question-and-options layout for create-flow select steps (community size/structure,
* {@link DecisionApproachesScreen} (decision approaches). Below `lg` (1024px), one column + main scrolls. * core values, stakeholders) and {@link DecisionApproachesScreen}.
* At `lg+`, mirrors {@link CompletedScreen}: static header column + scrollable controls column * Below `lg` (1024px), one column uses available width and main scrolls.
* (`min-h-0` + `overflow-y-auto` height chain; see completed page right rail). * At `lg+`, static header column + scrollable options column (`min-h-0` + overflow).
*/ */
export function CreateFlowTwoColumnSelectShell({ export function CreateFlowTwoColumnSelectShell({
header, header,
@@ -56,24 +60,24 @@ export function CreateFlowTwoColumnSelectShell({
> >
<div <div
className={ className={
"flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] md:max-w-[640px] " + "flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] " +
"max-lg:flex-none lg:max-h-full lg:max-w-[1328px] lg:min-h-0 lg:flex-1 lg:flex-row lg:flex-nowrap " + "max-lg:flex-none lg:max-h-full lg:min-h-0 lg:flex-1 lg:flex-row lg:flex-nowrap " +
`${rowLgCrossAlignClass} lg:justify-center lg:gap-[var(--measures-spacing-1200,48px)] lg:overflow-hidden` `${rowLgCrossAlignClass} lg:justify-center lg:gap-[var(--measures-spacing-1200,48px)] lg:overflow-hidden ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`
} }
> >
<div <div
className={ className={
`flex w-full min-w-0 shrink-0 flex-col items-start gap-[var(--measures-spacing-200,8px)] ` + `flex w-full min-w-0 shrink-0 flex-col items-start gap-[var(--measures-spacing-200,8px)] ` +
`lg:flex-1 ${leftLgMainJustifyClass} lg:py-[12px] lg:max-w-[640px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}` `lg:flex-1 ${leftLgMainJustifyClass} lg:py-[12px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`
} }
> >
{header} {header}
</div> </div>
<div <div
className={ className={
`scrollbar-hide relative flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-800,32px)] ` + `relative flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-800,32px)] ` +
`overflow-x-hidden lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pb-[var(--measures-spacing-300,12px)] ` + `overflow-x-hidden lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pb-[var(--measures-spacing-300,12px)] ` +
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS `${CREATE_FLOW_SCROLL_REGION_CLASS} ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`
} }
> >
{children} {children}
@@ -60,7 +60,6 @@ function CustomMethodCardFieldBlocksSummaryViewComponent({
<div key={block.id} className="flex flex-col gap-2"> <div key={block.id} className="flex flex-col gap-2">
<InputLabel <InputLabel
label={block.blockTitle} label={block.blockTitle}
helpIcon
size="s" size="s"
palette="default" palette="default"
/> />
@@ -123,7 +122,6 @@ function CustomMethodCardFieldBlocksSummaryViewComponent({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<InputLabel <InputLabel
label={block.blockTitle} label={block.blockTitle}
helpIcon
size="s" size="s"
palette="default" palette="default"
/> />
@@ -169,6 +167,7 @@ function CustomMethodCardFieldBlocksSummaryViewComponent({
<IncrementerBlock <IncrementerBlock
key={block.id} key={block.id}
label={block.blockTitle} label={block.blockTitle}
helpIcon={false}
value={block.defaultPercent} value={block.defaultPercent}
min={1} min={1}
max={100} max={100}
@@ -3,7 +3,10 @@
import { memo, useCallback, useRef, useState } from "react"; import { memo, useCallback, useRef, useState } from "react";
import { useTranslation } from "../../../../contexts/MessagesContext"; import { useTranslation } from "../../../../contexts/MessagesContext";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; import {
createFlowUploadFailureMessageKey,
uploadCreateFlowFile,
} from "../../../../../lib/create/uploadToServer";
import { CustomMethodCardUploadBlockRowView } from "./CustomMethodCardUploadBlockRow.view"; import { CustomMethodCardUploadBlockRowView } from "./CustomMethodCardUploadBlockRow.view";
import type { CustomMethodCardUploadBlockRowProps } from "./CustomMethodCardFieldBlocksSummary.types"; import type { CustomMethodCardUploadBlockRowProps } from "./CustomMethodCardFieldBlocksSummary.types";
@@ -68,8 +71,8 @@ function CustomMethodCardUploadBlockRowContainerComponent({
: b, : b,
), ),
); );
} catch { } catch (err) {
setErrorMessage(tUpload("errors.generic")); setErrorMessage(tUpload(createFlowUploadFailureMessageKey(err)));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -4,6 +4,7 @@ import { memo } from "react";
import Upload from "../../../../components/controls/Upload"; import Upload from "../../../../components/controls/Upload";
import InputLabel from "../../../../components/type/InputLabel"; import InputLabel from "../../../../components/type/InputLabel";
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils"; import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
import { CUSTOM_ATTACHMENT_ACCEPT } from "../../../../../lib/create/createFlowUploadValidation";
import type { CustomMethodCardUploadBlockRowViewProps } from "./CustomMethodCardFieldBlocksSummary.types"; import type { CustomMethodCardUploadBlockRowViewProps } from "./CustomMethodCardFieldBlocksSummary.types";
function CustomMethodCardUploadBlockRowViewComponent({ function CustomMethodCardUploadBlockRowViewComponent({
@@ -30,7 +31,6 @@ function CustomMethodCardUploadBlockRowViewComponent({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<InputLabel <InputLabel
label={block.blockTitle} label={block.blockTitle}
helpIcon
size="s" size="s"
palette="default" palette="default"
/> />
@@ -44,7 +44,7 @@ function CustomMethodCardUploadBlockRowViewComponent({
type="file" type="file"
className="sr-only" className="sr-only"
tabIndex={-1} tabIndex={-1}
accept="image/jpeg,image/png,image/webp,image/gif,application/pdf" accept={CUSTOM_ATTACHMENT_ACCEPT}
aria-label={uploadFileInputAriaLabel} aria-label={uploadFileInputAriaLabel}
onChange={onFileInputChange} onChange={onFileInputChange}
/> />
@@ -77,6 +77,7 @@ function CustomMethodCardUploadBlockRowViewComponent({
) : ( ) : (
<Upload <Upload
active={!busy} active={!busy}
showHelpIcon={false}
hintText={busy ? uploadingHint : uploadHint} hintText={busy ? uploadingHint : uploadHint}
onClick={onUploadClick} onClick={onUploadClick}
/> />
@@ -5,8 +5,14 @@ import {
useMessages, useMessages,
useTranslation, useTranslation,
} from "../../../../contexts/MessagesContext"; } from "../../../../contexts/MessagesContext";
import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants"; import { createFlowUploadFailureMessageKey } from "../../../../../lib/create/uploadToServer";
import {
CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS,
CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS,
} from "../../../../../lib/create/customMethodCardWizardConstants";
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types"; import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
import type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.types"; import type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.types";
import { CustomMethodCardWizardView } from "./CustomMethodCardWizard.view"; import { CustomMethodCardWizardView } from "./CustomMethodCardWizard.view";
@@ -17,12 +23,13 @@ import type { CustomMethodCardWizardProps } from "./CustomMethodCardWizard.types
* `20066:14748`, `20094:48551`, `20066:14361`). * `20066:14748`, `20094:48551`, `20066:14361`).
*/ */
const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>( const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
({ isOpen, onClose, onFinalize, onPersistCustomUploadFile }) => { ({ isOpen, onClose, onFinalize, onPersistCustomUploadFile, initialValues }) => {
const m = useMessages(); const m = useMessages();
const t = useTranslation("common"); const t = useTranslation("common");
const tUpload = useTranslation("create.upload"); const tUpload = useTranslation("create.upload");
const w = m.create.customRule.customMethodCardWizard; const w = m.create.customRule.customMethodCardWizard;
const menuCopy = m.create.customRule.modalKebabMenu; const menuCopy = m.create.customRule.modalKebabMenu;
const { requestConfirm, confirmDialog } = useAsyncConfirm();
const copy = useMemo( const copy = useMemo(
() => ({ () => ({
@@ -68,6 +75,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
const [draftFieldBlocks, setDraftFieldBlocks] = useState< const [draftFieldBlocks, setDraftFieldBlocks] = useState<
CustomMethodCardFieldBlock[] CustomMethodCardFieldBlock[]
>([]); >([]);
const [editingBlockId, setEditingBlockId] = useState<string | null>(null);
const [textBlockTitle, setTextBlockTitle] = useState(""); const [textBlockTitle, setTextBlockTitle] = useState("");
const [textPlaceholderBody, setTextPlaceholderBody] = useState(""); const [textPlaceholderBody, setTextPlaceholderBody] = useState("");
@@ -88,6 +96,14 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
const [proportionDefault, setProportionDefault] = useState(50); const [proportionDefault, setProportionDefault] = useState(50);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const initialValuesRef = useRef(initialValues);
initialValuesRef.current = initialValues;
const openSnapshotRef = useRef<{
title: string;
description: string;
fieldBlocks: string;
} | null>(null);
const fieldModalSnapshotRef = useRef<string | null>(null);
const resetFieldTypeDrafts = useCallback(() => { const resetFieldTypeDrafts = useCallback(() => {
setTextBlockTitle(""); setTextBlockTitle("");
@@ -112,21 +128,114 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
setPolicyDescription(""); setPolicyDescription("");
setAddFieldExpanded(false); setAddFieldExpanded(false);
setFieldTypeModal(null); setFieldTypeModal(null);
setEditingBlockId(null);
setDraftFieldBlocks([]); setDraftFieldBlocks([]);
openSnapshotRef.current = null;
fieldModalSnapshotRef.current = null;
resetFieldTypeDrafts(); resetFieldTypeDrafts();
}, [resetFieldTypeDrafts]); }, [resetFieldTypeDrafts]);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
reset(); reset();
return;
} }
}, [isOpen, reset]); const init = initialValuesRef.current;
setWizardStep(1);
setPolicyTitle(init?.title ?? "");
setPolicyDescription(init?.description ?? "");
setAddFieldExpanded(false);
setFieldTypeModal(null);
setEditingBlockId(null);
setDraftFieldBlocks(
init?.fieldBlocks ? structuredClone(init.fieldBlocks) : [],
);
openSnapshotRef.current = {
title: init?.title ?? "",
description: init?.description ?? "",
fieldBlocks: JSON.stringify(init?.fieldBlocks ?? []),
};
fieldModalSnapshotRef.current = null;
resetFieldTypeDrafts();
}, [isOpen, reset, resetFieldTypeDrafts]);
const dismiss = useCallback(() => { const dismiss = useCallback(() => {
reset(); reset();
onClose(); onClose();
}, [onClose, reset]); }, [onClose, reset]);
const fieldModalDraftSignature = useCallback(() => {
return JSON.stringify({
fieldTypeModal,
textBlockTitle,
textPlaceholderBody,
badgeBlockTitle,
badgeOptions,
uploadBlockTitle,
uploadFileName,
uploadAssetUrl,
proportionBlockTitle,
proportionDefault,
});
}, [
badgeBlockTitle,
badgeOptions,
fieldTypeModal,
proportionBlockTitle,
proportionDefault,
textBlockTitle,
textPlaceholderBody,
uploadAssetUrl,
uploadBlockTitle,
uploadFileName,
]);
const isWizardSessionDirty = useCallback(() => {
const snap = openSnapshotRef.current;
if (!snap) return false;
if (policyTitle !== snap.title || policyDescription !== snap.description) {
return true;
}
if (JSON.stringify(draftFieldBlocks) !== snap.fieldBlocks) {
return true;
}
if (
fieldTypeModal &&
fieldModalSnapshotRef.current != null &&
fieldModalDraftSignature() !== fieldModalSnapshotRef.current
) {
return true;
}
return false;
}, [
draftFieldBlocks,
fieldModalDraftSignature,
fieldTypeModal,
policyDescription,
policyTitle,
]);
useBeforeUnloadGuard(isOpen && isWizardSessionDirty());
const confirmAbandonWizardEdits = useCallback(async () => {
if (!isWizardSessionDirty()) {
return true;
}
return requestConfirm({
title: menuCopy.discardUnsavedCustomizeChangesTitle,
description: menuCopy.discardUnsavedCustomizeChangesDescription,
proceedText: menuCopy.discardUnsavedCustomizeChangesProceed,
cancelText: menuCopy.discardUnsavedCustomizeChangesCancel,
});
}, [
isWizardSessionDirty,
menuCopy.discardUnsavedCustomizeChangesCancel,
menuCopy.discardUnsavedCustomizeChangesDescription,
menuCopy.discardUnsavedCustomizeChangesProceed,
menuCopy.discardUnsavedCustomizeChangesTitle,
requestConfirm,
]);
const titleTrim = policyTitle.trim(); const titleTrim = policyTitle.trim();
const descriptionTrim = policyDescription.trim(); const descriptionTrim = policyDescription.trim();
@@ -136,7 +245,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
titleTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS; titleTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS;
const descriptionOk = const descriptionOk =
descriptionTrim.length > 0 && descriptionTrim.length > 0 &&
descriptionTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS; descriptionTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS;
if (wizardStep === 1) return titleOk; if (wizardStep === 1) return titleOk;
if (wizardStep === 2) return descriptionOk; if (wizardStep === 2) return descriptionOk;
return titleOk && descriptionOk; return titleOk && descriptionOk;
@@ -213,7 +322,9 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
const shellDescription = fieldModalHeader?.description ?? headerDescription; const shellDescription = fieldModalHeader?.description ?? headerDescription;
const nextLabel = fieldTypeModal const nextLabel = fieldTypeModal
? copy.fieldModals.addField ? editingBlockId
? copy.fieldModals.saveField
: copy.fieldModals.addField
: wizardStep === 3 : wizardStep === 3
? copy.footerFinalize ? copy.footerFinalize
: t("buttons.next"); : t("buttons.next");
@@ -222,33 +333,125 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
? !fieldModalStepValid ? !fieldModalStepValid
: !stepValid; : !stepValid;
const handleShellClose = useCallback(() => { const handleShellClose = useCallback(async () => {
if (fieldTypeModal) { if (fieldTypeModal) {
if (
fieldModalSnapshotRef.current != null &&
fieldModalDraftSignature() !== fieldModalSnapshotRef.current &&
!(await confirmAbandonWizardEdits())
) {
return;
}
setFieldTypeModal(null); setFieldTypeModal(null);
setEditingBlockId(null);
fieldModalSnapshotRef.current = null;
return;
}
if (!(await confirmAbandonWizardEdits())) {
return; return;
} }
dismiss(); dismiss();
}, [dismiss, fieldTypeModal]); }, [
confirmAbandonWizardEdits,
dismiss,
fieldModalDraftSignature,
fieldTypeModal,
]);
const kebabMenuItems = useMemo<ModalHeaderMenuItem[]>(() => [], []); const kebabMenuItems = useMemo<ModalHeaderMenuItem[]>(() => [], []);
const handleBack = useCallback(() => { const handleBack = useCallback(async () => {
if (fieldTypeModal) { if (fieldTypeModal) {
if (
fieldModalSnapshotRef.current != null &&
fieldModalDraftSignature() !== fieldModalSnapshotRef.current &&
!(await confirmAbandonWizardEdits())
) {
return;
}
setFieldTypeModal(null); setFieldTypeModal(null);
setEditingBlockId(null);
fieldModalSnapshotRef.current = null;
return; return;
} }
if (wizardStep === 1) { if (wizardStep === 1) {
if (!(await confirmAbandonWizardEdits())) {
return;
}
dismiss(); dismiss();
return; return;
} }
setWizardStep((s) => (s === 2 ? 1 : 2)); setWizardStep((s) => (s === 2 ? 1 : 2));
}, [dismiss, fieldTypeModal, wizardStep]); }, [
confirmAbandonWizardEdits,
dismiss,
fieldModalDraftSignature,
fieldTypeModal,
wizardStep,
]);
const handleSelectFieldType = useCallback((ft: AddCustomFieldType) => { const handleSelectFieldType = useCallback((ft: AddCustomFieldType) => {
setEditingBlockId(null);
setAddFieldExpanded(false);
resetFieldTypeDrafts(); resetFieldTypeDrafts();
setFieldTypeModal(ft); setFieldTypeModal(ft);
fieldModalSnapshotRef.current = JSON.stringify({
fieldTypeModal: ft,
textBlockTitle: "",
textPlaceholderBody: "",
badgeBlockTitle: "",
badgeOptions: [],
uploadBlockTitle: "",
uploadFileName: undefined,
uploadAssetUrl: undefined,
proportionBlockTitle: "",
proportionDefault: 50,
});
}, [resetFieldTypeDrafts]); }, [resetFieldTypeDrafts]);
const handleEditFieldBlock = useCallback(
(block: CustomMethodCardFieldBlock) => {
resetFieldTypeDrafts();
setEditingBlockId(block.id);
setAddFieldExpanded(false);
switch (block.kind) {
case "text":
setTextBlockTitle(block.blockTitle);
setTextPlaceholderBody(block.placeholderText);
break;
case "badges":
setBadgeBlockTitle(block.blockTitle);
setBadgeOptions([...block.options]);
break;
case "upload":
setUploadBlockTitle(block.blockTitle);
setUploadFileName(block.fileName);
setUploadAssetUrl(block.assetUrl);
break;
case "proportion":
setProportionBlockTitle(block.blockTitle);
setProportionDefault(block.defaultPercent);
break;
}
setFieldTypeModal(block.kind);
fieldModalSnapshotRef.current = JSON.stringify({
fieldTypeModal: block.kind,
textBlockTitle: block.kind === "text" ? block.blockTitle : "",
textPlaceholderBody:
block.kind === "text" ? block.placeholderText : "",
badgeBlockTitle: block.kind === "badges" ? block.blockTitle : "",
badgeOptions: block.kind === "badges" ? [...block.options] : [],
uploadBlockTitle: block.kind === "upload" ? block.blockTitle : "",
uploadFileName: block.kind === "upload" ? block.fileName : undefined,
uploadAssetUrl: block.kind === "upload" ? block.assetUrl : undefined,
proportionBlockTitle:
block.kind === "proportion" ? block.blockTitle : "",
proportionDefault: block.kind === "proportion" ? block.defaultPercent : 50,
});
},
[resetFieldTypeDrafts],
);
const handleFileChosen = useCallback( const handleFileChosen = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => { async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -260,8 +463,9 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
try { try {
const { url } = await onPersistCustomUploadFile(file); const { url } = await onPersistCustomUploadFile(file);
setUploadAssetUrl(url); setUploadAssetUrl(url);
} catch { } catch (err) {
setUploadFieldError(tUpload("errors.generic")); setUploadFileName(undefined);
setUploadFieldError(tUpload(createFlowUploadFailureMessageKey(err)));
} finally { } finally {
setUploadFieldBusy(false); setUploadFieldBusy(false);
} }
@@ -285,9 +489,9 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
); );
}, []); }, []);
const appendFieldBlock = useCallback(() => { const commitFieldBlock = useCallback(() => {
if (!fieldTypeModal || !fieldModalStepValid) return; if (!fieldTypeModal || !fieldModalStepValid) return;
const id = crypto.randomUUID(); const id = editingBlockId ?? crypto.randomUUID();
let block: CustomMethodCardFieldBlock; let block: CustomMethodCardFieldBlock;
switch (fieldTypeModal) { switch (fieldTypeModal) {
case "text": case "text":
@@ -325,11 +529,20 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
defaultPercent: proportionDefault, defaultPercent: proportionDefault,
}; };
} }
setDraftFieldBlocks((prev) => [...prev, block]); setDraftFieldBlocks((prev) => {
if (editingBlockId) {
return prev.map((row) => (row.id === editingBlockId ? block : row));
}
return [...prev, block];
});
setFieldTypeModal(null); setFieldTypeModal(null);
setEditingBlockId(null);
setAddFieldExpanded(false);
fieldModalSnapshotRef.current = null;
}, [ }, [
badgeBlockTitle, badgeBlockTitle,
badgeOptions, badgeOptions,
editingBlockId,
fieldModalStepValid, fieldModalStepValid,
fieldTypeModal, fieldTypeModal,
proportionBlockTitle, proportionBlockTitle,
@@ -343,7 +556,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
const handleNext = useCallback(() => { const handleNext = useCallback(() => {
if (fieldTypeModal) { if (fieldTypeModal) {
appendFieldBlock(); commitFieldBlock();
return; return;
} }
if (!stepValid) return; if (!stepValid) return;
@@ -358,7 +571,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
} }
setWizardStep((s) => (s === 1 ? 2 : 3)); setWizardStep((s) => (s === 1 ? 2 : 3));
}, [ }, [
appendFieldBlock, commitFieldBlock,
descriptionTrim, descriptionTrim,
dismiss, dismiss,
draftFieldBlocks, draftFieldBlocks,
@@ -370,6 +583,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
]); ]);
return ( return (
<>
<CustomMethodCardWizardView <CustomMethodCardWizardView
isOpen={isOpen} isOpen={isOpen}
onDismiss={handleShellClose} onDismiss={handleShellClose}
@@ -380,7 +594,8 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
policyDescription={policyDescription} policyDescription={policyDescription}
addFieldExpanded={addFieldExpanded} addFieldExpanded={addFieldExpanded}
copy={copy} copy={copy}
maxChars={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS} maxTitleChars={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
maxDescriptionChars={CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS}
onPolicyTitleChange={setPolicyTitle} onPolicyTitleChange={setPolicyTitle}
onPolicyDescriptionChange={setPolicyDescription} onPolicyDescriptionChange={setPolicyDescription}
onPressAddCustomField={() => setAddFieldExpanded(true)} onPressAddCustomField={() => setAddFieldExpanded(true)}
@@ -420,10 +635,13 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
stepper={!fieldTypeModal} stepper={!fieldTypeModal}
draftFieldBlocks={draftFieldBlocks} draftFieldBlocks={draftFieldBlocks}
onDraftFieldBlocksReorder={setDraftFieldBlocks} onDraftFieldBlocksReorder={setDraftFieldBlocks}
onEditFieldBlock={handleEditFieldBlock}
kebabMoreOptionsAriaLabel={menuCopy.triggerAriaLabel} kebabMoreOptionsAriaLabel={menuCopy.triggerAriaLabel}
kebabMenuAriaLabel={menuCopy.menuAriaLabel} kebabMenuAriaLabel={menuCopy.menuAriaLabel}
kebabMenuItems={kebabMenuItems} kebabMenuItems={kebabMenuItems}
/> />
{confirmDialog}
</>
); );
}, },
); );
@@ -2,6 +2,7 @@ import type { RefObject } from "react";
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types"; import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
import type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.types"; import type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.types";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
export interface CustomMethodCardWizardFieldBodiesCopy { export interface CustomMethodCardWizardFieldBodiesCopy {
requiredHint: string; requiredHint: string;
@@ -47,6 +48,7 @@ export interface CustomMethodCardWizardCopy {
footerFinalize: string; footerFinalize: string;
fieldModals: { fieldModals: {
addField: string; addField: string;
saveField: string;
requiredHint: string; requiredHint: string;
text: CustomMethodCardWizardFieldBodiesCopy["text"] & { text: CustomMethodCardWizardFieldBodiesCopy["text"] & {
title: string; title: string;
@@ -70,6 +72,8 @@ export interface CustomMethodCardWizardCopy {
export interface CustomMethodCardWizardProps { export interface CustomMethodCardWizardProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
/** When set, seeds title, description, and field blocks; wizard still starts at step 1. */
initialValues?: MethodCardWizardInitialValues | null;
/** Called when the user completes step 3; parent assigns id and persists state. */ /** Called when the user completes step 3; parent assigns id and persists state. */
onFinalize: (payload: { onFinalize: (payload: {
title: string; title: string;
@@ -123,7 +127,8 @@ export interface CustomMethodCardWizardViewProps {
policyDescription: string; policyDescription: string;
addFieldExpanded: boolean; addFieldExpanded: boolean;
copy: CustomMethodCardWizardCopy; copy: CustomMethodCardWizardCopy;
maxChars: number; maxTitleChars: number;
maxDescriptionChars: number;
onPolicyTitleChange: (v: string) => void; onPolicyTitleChange: (v: string) => void;
onPolicyDescriptionChange: (v: string) => void; onPolicyDescriptionChange: (v: string) => void;
onPressAddCustomField: () => void; onPressAddCustomField: () => void;
@@ -136,6 +141,7 @@ export interface CustomMethodCardWizardViewProps {
>; >;
draftFieldBlocks: CustomMethodCardFieldBlock[]; draftFieldBlocks: CustomMethodCardFieldBlock[];
onDraftFieldBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void; onDraftFieldBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void;
onEditFieldBlock: (_block: CustomMethodCardFieldBlock) => void;
nextDisabled: boolean; nextDisabled: boolean;
nextLabel: string; nextLabel: string;
showBackButton: boolean; showBackButton: boolean;
@@ -19,7 +19,8 @@ function CustomMethodCardWizardViewComponent({
policyDescription, policyDescription,
addFieldExpanded, addFieldExpanded,
copy, copy,
maxChars, maxTitleChars,
maxDescriptionChars,
onPolicyTitleChange, onPolicyTitleChange,
onPolicyDescriptionChange, onPolicyDescriptionChange,
onPressAddCustomField, onPressAddCustomField,
@@ -35,6 +36,7 @@ function CustomMethodCardWizardViewComponent({
stepper, stepper,
draftFieldBlocks, draftFieldBlocks,
onDraftFieldBlocksReorder, onDraftFieldBlocksReorder,
onEditFieldBlock,
kebabMoreOptionsAriaLabel, kebabMoreOptionsAriaLabel,
kebabMenuAriaLabel, kebabMenuAriaLabel,
kebabMenuItems, kebabMenuItems,
@@ -71,24 +73,26 @@ function CustomMethodCardWizardViewComponent({
placeholder={copy.step1.fieldPlaceholder} placeholder={copy.step1.fieldPlaceholder}
value={policyTitle} value={policyTitle}
onChange={onPolicyTitleChange} onChange={onPolicyTitleChange}
maxLength={maxChars} maxLength={maxTitleChars}
/> />
) : null} ) : null}
{!fieldTypeModal && wizardStep === 2 ? ( {!fieldTypeModal && wizardStep === 2 ? (
<TextArea <TextArea
appearance="default" appearance="default"
formHeader={false} formHeader={false}
showHelpIcon={false}
label={copy.step2.title}
placeholder={copy.step2.fieldPlaceholder} placeholder={copy.step2.fieldPlaceholder}
value={policyDescription} value={policyDescription}
maxLength={maxChars} maxLength={maxDescriptionChars}
onChange={(e) => onPolicyDescriptionChange(e.target.value)} onChange={(e) => onPolicyDescriptionChange(e.target.value)}
textHint={`${policyDescription.length}/${maxChars}`} textHint={`${policyDescription.length}/${maxDescriptionChars}`}
className="w-full" className="w-full"
rows={4} rows={4}
/> />
) : null} ) : null}
{!fieldTypeModal && wizardStep === 3 ? ( {!fieldTypeModal && wizardStep === 3 ? (
<div className="flex w-full flex-col gap-4"> <div className="flex w-full flex-col gap-6">
{draftFieldBlocks.length > 0 ? ( {draftFieldBlocks.length > 0 ? (
<CustomMethodCardWizardBlocksList <CustomMethodCardWizardBlocksList
blocks={draftFieldBlocks} blocks={draftFieldBlocks}
@@ -96,6 +100,7 @@ function CustomMethodCardWizardViewComponent({
dragHandleAriaLabel={copy.step3BlocksList.dragHandleAriaLabel} dragHandleAriaLabel={copy.step3BlocksList.dragHandleAriaLabel}
listLabel={copy.step3BlocksList.listLabel} listLabel={copy.step3BlocksList.listLabel}
onBlocksReorder={onDraftFieldBlocksReorder} onBlocksReorder={onDraftFieldBlocksReorder}
onEditBlock={onEditFieldBlock}
/> />
) : null} ) : null}
<AddCustomField <AddCustomField
@@ -1,6 +1,6 @@
"use client"; "use client";
import { memo, useCallback, useState, type DragEvent } from "react"; import { memo, useCallback, useRef, useState, type DragEvent } from "react";
import { reorderCustomMethodCardFieldBlocks } from "../../../../../lib/create/reorderCustomMethodCardFieldBlocks"; import { reorderCustomMethodCardFieldBlocks } from "../../../../../lib/create/reorderCustomMethodCardFieldBlocks";
import { CustomMethodCardWizardBlocksListView } from "./CustomMethodCardWizardBlocksList.view"; import { CustomMethodCardWizardBlocksListView } from "./CustomMethodCardWizardBlocksList.view";
import type { CustomMethodCardWizardBlocksListProps } from "./CustomMethodCardWizardBlocksList.types"; import type { CustomMethodCardWizardBlocksListProps } from "./CustomMethodCardWizardBlocksList.types";
@@ -11,19 +11,33 @@ function CustomMethodCardWizardBlocksListContainerComponent({
dragHandleAriaLabel, dragHandleAriaLabel,
listLabel, listLabel,
onBlocksReorder, onBlocksReorder,
onEditBlock,
}: CustomMethodCardWizardBlocksListProps) { }: CustomMethodCardWizardBlocksListProps) {
const [draggingIndex, setDraggingIndex] = useState<number | null>(null); const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
const [overIndex, setOverIndex] = useState<number | null>(null); const [overIndex, setOverIndex] = useState<number | null>(null);
const draggingIndexRef = useRef<number | null>(null);
const dragFromHandleRef = useRef(false);
const clearDragUi = useCallback(() => { const clearDragUi = useCallback(() => {
draggingIndexRef.current = null;
dragFromHandleRef.current = false;
setDraggingIndex(null); setDraggingIndex(null);
setOverIndex(null); setOverIndex(null);
}, []); }, []);
const handleHandlePointerDown = useCallback(() => {
dragFromHandleRef.current = true;
}, []);
const handleDragStart = useCallback( const handleDragStart = useCallback(
(index: number) => (e: DragEvent) => { (index: number) => (e: DragEvent) => {
if (!dragFromHandleRef.current) {
e.preventDefault();
return;
}
e.dataTransfer.effectAllowed = "move"; e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(index)); e.dataTransfer.setData("text/plain", String(index));
draggingIndexRef.current = index;
setDraggingIndex(index); setDraggingIndex(index);
}, },
[], [],
@@ -40,8 +54,11 @@ function CustomMethodCardWizardBlocksListContainerComponent({
const handleDrop = useCallback( const handleDrop = useCallback(
(index: number) => (e: DragEvent) => { (index: number) => (e: DragEvent) => {
e.preventDefault(); e.preventDefault();
const from = Number.parseInt(e.dataTransfer.getData("text/plain"), 10); const fromData = Number.parseInt(e.dataTransfer.getData("text/plain"), 10);
if (Number.isNaN(from)) { const from = Number.isNaN(fromData)
? draggingIndexRef.current
: fromData;
if (from == null || Number.isNaN(from)) {
clearDragUi(); clearDragUi();
return; return;
} }
@@ -66,6 +83,8 @@ function CustomMethodCardWizardBlocksListContainerComponent({
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={handleDrop} onDrop={handleDrop}
onDragEnd={clearDragUi} onDragEnd={clearDragUi}
onHandlePointerDown={handleHandlePointerDown}
onEditBlock={onEditBlock}
/> />
); );
} }
@@ -8,6 +8,7 @@ export interface CustomMethodCardWizardBlocksListProps {
dragHandleAriaLabel: string; dragHandleAriaLabel: string;
listLabel: string; listLabel: string;
onBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void; onBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void;
onEditBlock: (_block: CustomMethodCardFieldBlock) => void;
} }
export interface CustomMethodCardWizardBlocksListViewProps export interface CustomMethodCardWizardBlocksListViewProps
@@ -18,4 +19,5 @@ export interface CustomMethodCardWizardBlocksListViewProps
onDragOver: (_index: number) => (_e: DragEvent) => void; onDragOver: (_index: number) => (_e: DragEvent) => void;
onDrop: (_index: number) => (_e: DragEvent) => void; onDrop: (_index: number) => (_e: DragEvent) => void;
onDragEnd: () => void; onDragEnd: () => void;
onHandlePointerDown: () => void;
} }
@@ -38,34 +38,52 @@ function CustomMethodCardWizardBlocksListViewComponent({
onDragOver, onDragOver,
onDrop, onDrop,
onDragEnd, onDragEnd,
onHandlePointerDown,
onEditBlock,
}: CustomMethodCardWizardBlocksListViewProps) { }: CustomMethodCardWizardBlocksListViewProps) {
return ( return (
<ul className="flex list-none flex-col gap-2 p-0" aria-label={listLabel}> <ul
className="flex list-none flex-col gap-2 p-0"
aria-label={listLabel}
>
{blocks.map((block, index) => { {blocks.map((block, index) => {
const kind = block.kind as AddCustomFieldType; const kind = block.kind as AddCustomFieldType;
const typeLabel = fieldTypeLabels[kind]; const typeLabel = fieldTypeLabels[kind];
const isOver = overIndex === index && draggingIndex !== index; const title = block.blockTitle.trim() || typeLabel;
const isDragging = draggingIndex === index;
const isDropTarget = overIndex === index && draggingIndex !== index;
return ( return (
<li <li
key={block.id} key={block.id}
className={`flex min-h-[52px] items-stretch gap-2 rounded-[var(--measures-radius-medium,8px)] border border-[var(--color-border-default-primary)] bg-[var(--color-surface-default-secondary)] pl-1 pr-3 py-2 transition-shadow ${ draggable
isOver aria-grabbed={isDragging ? true : undefined}
? "ring-2 ring-[var(--color-border-invert-primary)] ring-offset-2 ring-offset-[var(--color-surface-default-primary)]" className={`group flex min-h-[52px] items-stretch gap-2 rounded-[var(--measures-radius-medium,8px)] border-2 bg-[var(--color-surface-default-secondary)] py-2 pl-1 pr-3 transition-[border-color,box-shadow,opacity] ${
: "" isDropTarget
} ${draggingIndex === index ? "opacity-60" : ""}`} ? "relative z-10 border-[var(--color-border-invert-primary)]"
: isDragging
? "relative z-20 border-[var(--color-border-default-primary)] opacity-60 shadow-[0_4px_12px_rgba(0,0,0,0.12)]"
: "border-[var(--color-border-default-primary)] hover:border-[var(--color-content-default-secondary)]"
}`}
onDragStart={onDragStart(index)}
onDragOver={onDragOver(index)} onDragOver={onDragOver(index)}
onDrop={onDrop(index)} onDrop={onDrop(index)}
>
<button
type="button"
draggable
onDragStart={onDragStart(index)}
onDragEnd={onDragEnd} 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} aria-label={dragHandleAriaLabel}
onPointerDown={onHandlePointerDown}
className="flex shrink-0 cursor-grab touch-manipulation items-center self-stretch rounded-[var(--measures-radius-200,8px)] px-1 text-[var(--color-content-default-secondary)] transition-colors active:cursor-grabbing focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-invert-primary)] group-hover:text-[var(--color-content-default-primary)]"
> >
<DragHandleGlyph /> <DragHandleGlyph />
</button> </span>
<button
type="button"
aria-label={title}
onClick={() => onEditBlock(block)}
className="flex min-w-0 flex-1 items-center gap-2 self-stretch rounded-[var(--measures-radius-200,8px)] border-0 bg-transparent px-0 text-left cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-invert-primary)]"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center self-center"> <span className="flex h-8 w-8 shrink-0 items-center justify-center self-center">
<Icon <Icon
name={ADD_CUSTOM_FIELD_TYPE_ICONS[kind]} name={ADD_CUSTOM_FIELD_TYPE_ICONS[kind]}
@@ -74,13 +92,14 @@ function CustomMethodCardWizardBlocksListViewComponent({
/> />
</span> </span>
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5"> <div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
<span className="truncate font-inter text-[14px] font-medium leading-[18px] text-[var(--color-content-default-primary)]"> <span className="truncate text-[14px] font-medium leading-[18px] text-[var(--color-content-default-primary)]">
{block.blockTitle.trim() || typeLabel} {title}
</span> </span>
<span className="font-inter text-[12px] leading-4 text-[var(--color-content-default-secondary)]"> <span className="text-x-small-paragraph text-[var(--color-content-default-secondary)]">
{typeLabel} {typeLabel}
</span> </span>
</div> </div>
</button>
</li> </li>
); );
})} })}
@@ -1,6 +1,6 @@
"use client"; "use client";
import { memo } from "react"; import { memo, useId } from "react";
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils"; import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
import InputWithCounter from "../../../../components/controls/InputWithCounter"; import InputWithCounter from "../../../../components/controls/InputWithCounter";
import TextArea from "../../../../components/controls/TextArea"; import TextArea from "../../../../components/controls/TextArea";
@@ -10,6 +10,7 @@ import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
import InputLabel from "../../../../components/type/InputLabel"; import InputLabel from "../../../../components/type/InputLabel";
import ApplicableScopeField from "../ApplicableScopeField"; import ApplicableScopeField from "../ApplicableScopeField";
import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants"; import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants";
import { CUSTOM_ATTACHMENT_ACCEPT } from "../../../../../lib/create/createFlowUploadValidation";
import type { CustomMethodCardWizardFieldBodiesViewProps } from "./CustomMethodCardWizard.types"; import type { CustomMethodCardWizardFieldBodiesViewProps } from "./CustomMethodCardWizard.types";
const TEXT_PLACEHOLDER_MAX = 8000; const TEXT_PLACEHOLDER_MAX = 8000;
@@ -39,6 +40,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
onProportionBlockTitleChange, onProportionBlockTitleChange,
onProportionDefaultChange, onProportionDefaultChange,
}: CustomMethodCardWizardFieldBodiesViewProps) { }: CustomMethodCardWizardFieldBodiesViewProps) {
const placeholderFieldId = useId();
const uploadPreviewTrimmed = uploadAssetPreviewUrl?.trim() ?? ""; const uploadPreviewTrimmed = uploadAssetPreviewUrl?.trim() ?? "";
const hasUploadPreview = uploadPreviewTrimmed.length > 0; const hasUploadPreview = uploadPreviewTrimmed.length > 0;
@@ -51,16 +53,16 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
value={textBlockTitle} value={textBlockTitle}
onChange={onTextBlockTitleChange} onChange={onTextBlockTitleChange}
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS} maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
showHelpIcon
/> />
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<InputLabel <label
label={copy.text.placeholderLabel} htmlFor={placeholderFieldId}
helpIcon className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]"
size="s" >
palette="default" {copy.text.placeholderLabel}
/> </label>
<TextArea <TextArea
id={placeholderFieldId}
formHeader={false} formHeader={false}
appearance="embedded" appearance="embedded"
value={textPlaceholderBody} value={textPlaceholderBody}
@@ -82,13 +84,13 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<InputLabel <InputLabel
label={copy.badges.blockTitleLabel} label={copy.badges.blockTitleLabel}
helpIcon
helperText={copy.requiredHint} helperText={copy.requiredHint}
size="s" size="s"
palette="default" palette="default"
/> />
<TextInput <TextInput
formHeader={false} formHeader={false}
label={copy.badges.blockTitleLabel}
placeholder={copy.badges.blockTitlePlaceholder} placeholder={copy.badges.blockTitlePlaceholder}
value={badgeBlockTitle} value={badgeBlockTitle}
onChange={(e) => onBadgeBlockTitleChange(e.target.value)} onChange={(e) => onBadgeBlockTitleChange(e.target.value)}
@@ -118,6 +120,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
type="file" type="file"
className="sr-only" className="sr-only"
tabIndex={-1} tabIndex={-1}
accept={CUSTOM_ATTACHMENT_ACCEPT}
aria-label={copy.upload.uploadFileInputAriaLabel} aria-label={copy.upload.uploadFileInputAriaLabel}
onChange={onFileChosen} onChange={onFileChosen}
/> />
@@ -127,7 +130,6 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
value={uploadBlockTitle} value={uploadBlockTitle}
onChange={onUploadBlockTitleChange} onChange={onUploadBlockTitleChange}
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS} maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
showHelpIcon
/> />
{hasUploadPreview ? ( {hasUploadPreview ? (
<div className="relative inline-block max-w-full"> <div className="relative inline-block max-w-full">
@@ -158,6 +160,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
) : ( ) : (
<Upload <Upload
active={!uploadPersisting} active={!uploadPersisting}
showHelpIcon={false}
hintText={ hintText={
uploadPersisting && uploadBusyHint uploadPersisting && uploadBusyHint
? uploadBusyHint ? uploadBusyHint
@@ -188,10 +191,10 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
value={proportionBlockTitle} value={proportionBlockTitle}
onChange={onProportionBlockTitleChange} onChange={onProportionBlockTitleChange}
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS} maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
showHelpIcon
/> />
<IncrementerBlock <IncrementerBlock
label={copy.proportion.defaultLabel} label={copy.proportion.defaultLabel}
helpIcon={false}
value={proportionDefault} value={proportionDefault}
min={1} min={1}
max={100} max={100}
File diff suppressed because it is too large Load Diff
@@ -6,8 +6,9 @@
*/ */
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useBeforeUnloadGuard } from "../../../hooks/useBeforeUnloadGuard";
import Create from "../../../components/modals/Create"; import Create from "../../../components/modals/Create";
import TextInput from "../../../components/controls/TextInput"; import TextArea from "../../../components/controls/TextArea";
import ContentLockup from "../../../components/type/ContentLockup"; import ContentLockup from "../../../components/type/ContentLockup";
import { useTranslation } from "../../../contexts/MessagesContext"; import { useTranslation } from "../../../contexts/MessagesContext";
@@ -57,6 +58,8 @@ export function FinalReviewCommunityContextEditModal({
[draft], [draft],
); );
useBeforeUnloadGuard(isOpen && isDirty);
const characterHint = tField("characterCountTemplate") const characterHint = tField("characterCountTemplate")
.replace("{current}", String(draft.length)) .replace("{current}", String(draft.length))
.replace("{max}", String(COMMUNITY_CONTEXT_FIELD_MAX_LENGTH)); .replace("{max}", String(COMMUNITY_CONTEXT_FIELD_MAX_LENGTH));
@@ -92,18 +95,20 @@ export function FinalReviewCommunityContextEditModal({
ariaLabel={tModal("title")} ariaLabel={tModal("title")}
> >
<div className="pb-2"> <div className="pb-2">
<TextInput <TextArea
className="!transition-none" className="!transition-none"
type="text" label={tModal("title")}
placeholder={tField("placeholder")} placeholder={tField("placeholder")}
value={draft} value={draft}
onChange={(e) => { onChange={(e) => {
setDraft(e.target.value); setDraft(e.target.value);
}} }}
inputSize="medium" size="medium"
formHeader={false} formHeader={false}
showHelpIcon={false}
textHint={characterHint} textHint={characterHint}
maxLength={COMMUNITY_CONTEXT_FIELD_MAX_LENGTH} maxLength={COMMUNITY_CONTEXT_FIELD_MAX_LENGTH}
rows={4}
/> />
</div> </div>
</Create> </Create>
@@ -93,6 +93,7 @@ export function FinalReviewTitleEditModal({
<TextInput <TextInput
className="!transition-none" className="!transition-none"
type="text" type="text"
label={tModal("title")}
placeholder={tField("placeholder")} placeholder={tField("placeholder")}
value={draft} value={draft}
onChange={(e) => { onChange={(e) => {
@@ -100,8 +101,10 @@ export function FinalReviewTitleEditModal({
}} }}
inputSize="medium" inputSize="medium"
formHeader={false} formHeader={false}
showHelpIcon={false}
textHint={characterHint} textHint={characterHint}
maxLength={COMMUNITY_TITLE_FIELD_MAX_LENGTH} maxLength={COMMUNITY_TITLE_FIELD_MAX_LENGTH}
required
/> />
</div> </div>
</Create> </Create>
@@ -38,6 +38,7 @@ export default function MethodCardCustomizeModalHeader({
value={titleValue} value={titleValue}
onChange={(e) => onTitleChange(e.target.value)} onChange={(e) => onTitleChange(e.target.value)}
inputSize="medium" inputSize="medium"
showHelpIcon={false}
/> />
{showDescription ? ( {showDescription ? (
<ModalTextAreaField <ModalTextAreaField
@@ -2,8 +2,9 @@
/** /**
* Shared "labelled text area" field used by every create flow modal section. * Shared "labelled text area" field used by every create flow modal section.
* Pairs an `InputLabel` (with help icon) with a `TextArea` set to the embedded * Pairs an `InputLabel` with a `TextArea` set to the embedded appearance —
* appearance — matching the Figma "Control / Text Area" pattern. * matching the Figma "Control / Text Area" pattern. Section help marks stay
* off until tooltip behavior ships.
*/ */
import { memo, useId } from "react"; import { memo, useId } from "react";
@@ -13,7 +14,7 @@ import InputLabel from "../../../components/type/InputLabel";
export interface ModalTextAreaFieldProps { export interface ModalTextAreaFieldProps {
/** Label rendered above the text area. */ /** Label rendered above the text area. */
label: string; label: string;
/** Show the help "?" icon next to the label (default `true`). */ /** Show the help "?" icon next to the label. Off until tooltip behavior ships. */
helpIcon?: boolean; helpIcon?: boolean;
/** Current text value. */ /** Current text value. */
value: string; value: string;
@@ -30,7 +31,7 @@ export interface ModalTextAreaFieldProps {
function ModalTextAreaFieldComponent({ function ModalTextAreaFieldComponent({
label, label,
helpIcon = true, helpIcon = false,
value, value,
onChange, onChange,
rows = 4, rows = 4,
@@ -1,13 +1,60 @@
/** Single column/section: full width under `md`, max 640px from `--breakpoint-md` up. */ import type { CreateFlowStep } from "../types";
import { CREATE_FLOW_SCREEN_REGISTRY } from "../utils/createFlowScreenRegistry";
/**
* Shared page gutters for create-flow chrome and step shells.
* Six viewport bands collapse to three values: 20px below `md`, 48px from `md`,
* 64px from `lg` (320639 / 6401023 / 1024+).
*/
export const CREATE_FLOW_PAGE_GUTTER_CLASS = "px-5 md:px-12 lg:px-16";
/**
* Wizard viewport frame. `dvh` tracks mobile browser chrome so the action bar
* stays on-screen; `max-h-dvh` keeps the flex column from overflowing it.
*/
export const CREATE_FLOW_VIEWPORT_FRAME_CLASS =
"relative flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden";
/** Design-system scrollbar for create-flow scroll regions (not `scrollbar-hide`). */
export const CREATE_FLOW_SCROLL_REGION_CLASS = "scrollbar-design";
/**
* Fade above the fixed action bar so content that continues below the fold is
* visible as a continuation cue (Figma Scrim / footer overlay).
*/
export const CREATE_FLOW_FOOTER_SCRIM_CLASS =
"pointer-events-none absolute inset-x-0 top-0 z-[1] h-8 -translate-y-full bg-gradient-to-t from-[var(--color-surface-default-primary,#000)] to-transparent";
/**
* Single column: full width below `lg` so tablet widths use available space;
* 640px from `--breakpoint-lg` up (two-column layouts start at `lg`).
*/
export const CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS = export const CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS =
"w-full min-w-0 md:max-w-[640px]"; "w-full min-w-0 lg:max-w-[640px]";
/** Grid cell: same cap as column max, centered when the track is wider than 640px. */ /** Grid cell: same cap as column max, centered when the track is wider than 640px. */
export const CREATE_FLOW_MD_UP_GRID_CELL_CLASS = export const CREATE_FLOW_MD_UP_GRID_CELL_CLASS =
"w-full min-w-0 md:mx-auto md:max-w-[640px]"; "w-full min-w-0 lg:mx-auto lg:max-w-[640px]";
/** Two 640px columns + `--measures-spacing-1200` (48px) gutter. */ /** Two 640px columns + `--measures-spacing-1200` (48px) gutter. */
export const CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS = "md:max-w-[1328px]"; export const CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS =
"w-full min-w-0 lg:max-w-[1328px]";
/** Review / card-stack frames (Figma Flow — Review max 1440). */
export const CREATE_FLOW_WIDE_MAX_CLASS = "w-full min-w-0 lg:max-w-[1440px]";
/**
* Lockup+card and card-stack `<main>`: keep `items-start` so a tall card can
* scroll from the top. Pair with {@link CREATE_FLOW_MD_CENTERED_SHELL_CLASS}.
*/
export const CREATE_FLOW_MD_CENTERED_MAIN_CLASS =
"items-start justify-center overflow-y-auto";
/**
* Center the step in the navfooter band from `md` when it fits (`my-auto`),
* and drop the mobile top inset so that centering is not biased downward.
*/
export const CREATE_FLOW_MD_CENTERED_SHELL_CLASS = "md:my-auto md:pt-0";
/** /**
* Card-stack steps only (Figma compact card stack): wider than header lockup so the card grid / * Card-stack steps only (Figma compact card stack): wider than header lockup so the card grid /
@@ -16,3 +63,31 @@ export const CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS = "md:max-w-[1328px]";
*/ */
export const CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS = export const CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS =
"w-full min-w-0 md:max-w-[min(100%,860px)]"; "w-full min-w-0 md:max-w-[min(100%,860px)]";
/**
* Inner max-width for step content and the action bar, so the primary button
* lines up with the nearest content column above 1440.
*/
export function getCreateFlowContentMaxClass(options: {
step: CreateFlowStep | null | undefined;
isTemplateReview?: boolean;
isTemplatesPicker?: boolean;
}): string {
if (options.isTemplateReview || options.isTemplatesPicker) {
return CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS;
}
if (!options.step) {
return CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS;
}
switch (CREATE_FLOW_SCREEN_REGISTRY[options.step].layoutKind) {
case "select":
case "right-rail":
case "completed":
case "review":
return CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS;
case "card":
return CREATE_FLOW_WIDE_MAX_CLASS;
default:
return CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS;
}
}
@@ -15,7 +15,7 @@ import type { CoreValueDetailEntry } from "../../types";
export interface CoreValueEditFieldsProps { export interface CoreValueEditFieldsProps {
value: CoreValueDetailEntry; value: CoreValueDetailEntry;
onChange: (_next: CoreValueDetailEntry) => void; onChange: (_next: CoreValueDetailEntry) => void;
/** View mode until the user taps **Customize**. */ /** Disable meaning/signals. Facet and final-review callers omit this so fields stay editable on open. */
readOnly?: boolean; readOnly?: boolean;
} }
@@ -7,12 +7,13 @@
* `markCreateFlowInteraction` live in the parent. * `markCreateFlowInteraction` live in the parent.
*/ */
import { memo, useCallback } from "react"; import { memo, useCallback, useMemo } from "react";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import ModalTextAreaField from "../ModalTextAreaField"; import ModalTextAreaField from "../ModalTextAreaField";
import ApplicableScopeField from "../ApplicableScopeField"; import ApplicableScopeField from "../ApplicableScopeField";
import IncrementerBlock from "../../../../components/controls/IncrementerBlock"; import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
import type { DecisionApproachDetailEntry } from "../../types"; import type { DecisionApproachDetailEntry } from "../../types";
import { withDecisionApproachKeyResourceScopes } from "../../../../../lib/create/decisionApproachKeyResources";
export interface DecisionApproachEditFieldsProps { export interface DecisionApproachEditFieldsProps {
value: DecisionApproachDetailEntry; value: DecisionApproachDetailEntry;
@@ -32,6 +33,11 @@ function DecisionApproachEditFieldsComponent({
const m = useMessages(); const m = useMessages();
const t = m.create.customRule.decisionApproaches; const t = m.create.customRule.decisionApproaches;
const scopes = useMemo(
() => withDecisionApproachKeyResourceScopes(value.applicableScope),
[value.applicableScope],
);
const patch = useCallback( const patch = useCallback(
<K extends keyof DecisionApproachDetailEntry>( <K extends keyof DecisionApproachDetailEntry>(
key: K, key: K,
@@ -53,7 +59,7 @@ function DecisionApproachEditFieldsComponent({
<ApplicableScopeField <ApplicableScopeField
label={t.sectionHeadings.applicableScope} label={t.sectionHeadings.applicableScope}
addLabel={t.scopeAddButtonLabel} addLabel={t.scopeAddButtonLabel}
scopes={value.applicableScope} scopes={scopes}
selectedScopes={value.selectedApplicableScope} selectedScopes={value.selectedApplicableScope}
readOnly={readOnly} readOnly={readOnly}
onToggleScope={(scope) => onToggleScope={(scope) =>
@@ -74,8 +80,10 @@ function DecisionApproachEditFieldsComponent({
onChange={(v) => patch("stepByStepInstructions", v)} onChange={(v) => patch("stepByStepInstructions", v)}
disabled={readOnly} disabled={readOnly}
/> />
{value.consensusLevel !== undefined ? (
<IncrementerBlock <IncrementerBlock
label={t.sectionHeadings.consensusLevel} label={t.sectionHeadings.consensusLevel}
helpIcon={false}
value={value.consensusLevel} value={value.consensusLevel}
min={CONSENSUS_LEVEL_MIN} min={CONSENSUS_LEVEL_MIN}
max={CONSENSUS_LEVEL_MAX} max={CONSENSUS_LEVEL_MAX}
@@ -86,6 +94,7 @@ function DecisionApproachEditFieldsComponent({
incrementAriaLabel="Increase consensus level" incrementAriaLabel="Increase consensus level"
disabled={readOnly} disabled={readOnly}
/> />
) : null}
<ModalTextAreaField <ModalTextAreaField
label={t.sectionHeadings.objectionsDeadlocks} label={t.sectionHeadings.objectionsDeadlocks}
value={value.objectionsDeadlocks} value={value.objectionsDeadlocks}
+34 -14
View File
@@ -56,29 +56,49 @@ export function CreateFlowProvider({
initialStep = null, initialStep = null,
enableLocalDraftMirroring = false, enableLocalDraftMirroring = false,
}: CreateFlowProviderProps) { }: CreateFlowProviderProps) {
const [state, setState] = useState<CreateFlowState>(() => { // Initializer must NOT touch `localStorage`: this provider runs through SSR
const base = enableLocalDraftMirroring // now (CreateFlowLayoutGate dropped `ssr: false`), and a server `{}` followed
? readAnonymousCreateFlowState() // by a client read of stored data would be a hydration mismatch. The
: {}; // `mount-once` effect below replays the read on the client.
const storedDetails = readCoreValueDetailsFromLocalStorage(); const [state, setState] = useState<CreateFlowState>({});
if (Object.keys(storedDetails).length === 0) return base;
return {
...base,
coreValueDetailsByChipId: {
...storedDetails,
...(base.coreValueDetailsByChipId ?? {}),
},
};
});
const [interactionTouched, setInteractionTouched] = useState(false); const [interactionTouched, setInteractionTouched] = useState(false);
const [currentStep] = useState<CreateFlowStep | null>(initialStep); const [currentStep] = useState<CreateFlowStep | null>(initialStep);
const prevPersistRef = useRef(enableLocalDraftMirroring); const prevPersistRef = useRef(enableLocalDraftMirroring);
const persistWriteSkipRef = useRef(true); const persistWriteSkipRef = useRef(true);
const initialHydrateDoneRef = useRef(false);
useEffect(() => { useEffect(() => {
clearLegacyCreateFlowKeysOnce(); clearLegacyCreateFlowKeysOnce();
}, []); }, []);
// Replay the previous `useState` initializer on mount (client-only). Keeps
// SSR + first client render aligned with the empty default while still
// hydrating any persisted draft / core-value details that existed before
// the user landed back on a wizard step.
useEffect(() => {
if (initialHydrateDoneRef.current) return;
initialHydrateDoneRef.current = true;
const base = enableLocalDraftMirroring
? readAnonymousCreateFlowState()
: {};
const storedDetails = readCoreValueDetailsFromLocalStorage();
const baseEmpty = Object.keys(base).length === 0;
const detailsEmpty = Object.keys(storedDetails).length === 0;
if (baseEmpty && detailsEmpty) return;
setState((prev) => {
const merged: CreateFlowState = { ...base, ...prev };
if (!detailsEmpty) {
merged.coreValueDetailsByChipId = {
...storedDetails,
...(base.coreValueDetailsByChipId ?? {}),
...(prev.coreValueDetailsByChipId ?? {}),
};
}
return merged;
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional mount-once
}, []);
// Session resolved after initial paint: hydrate from localStorage, merging // Session resolved after initial paint: hydrate from localStorage, merging
// with anything already in state. We can't bail on `prev` being non-empty: // with anything already in state. We can't bail on `prev` being non-empty:
// the initializer pre-populates `coreValueDetailsByChipId` from a separate // the initializer pre-populates `coreValueDetailsByChipId` from a separate
@@ -24,7 +24,7 @@ import {
export type CompletedFlowActionBanner = { export type CompletedFlowActionBanner = {
key: string; key: string;
status: "positive" | "danger"; status: "positive" | "danger" | "warning";
title: string; title: string;
description?: string; description?: string;
}; };
@@ -96,7 +96,7 @@ export function useCompletedRuleShareExport({
}: { }: {
setActionBanner: (_: CompletedFlowActionBanner | null) => void; setActionBanner: (_: CompletedFlowActionBanner | null) => void;
}): { }): {
copyPublishedRuleLink: () => Promise<void>; copyPublishedRuleLink: () => Promise<boolean>;
mailtoPublishedRule: () => void; mailtoPublishedRule: () => void;
sharePublishedRuleViaSignal: () => Promise<void>; sharePublishedRuleViaSignal: () => Promise<void>;
sharePublishedRuleViaSlack: () => Promise<void>; sharePublishedRuleViaSlack: () => Promise<void>;
@@ -137,32 +137,34 @@ export function useCompletedRuleShareExport({
url: string, url: string,
banner?: () => void, banner?: () => void,
options?: { suppressFailureWhenDocumentNotFocused?: boolean }, options?: { suppressFailureWhenDocumentNotFocused?: boolean },
) => { ): Promise<boolean> => {
try { try {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
(banner ?? bannerCopied)(); (banner ?? bannerCopied)();
return true;
} catch { } catch {
if ( if (
options?.suppressFailureWhenDocumentNotFocused === true && options?.suppressFailureWhenDocumentNotFocused === true &&
typeof window !== "undefined" && typeof window !== "undefined" &&
shouldSkipShareClipboardFallback(window) shouldSkipShareClipboardFallback(window)
) { ) {
return; return true;
} }
bannerCopyFailed(); bannerCopyFailed();
return false;
} }
}, },
[bannerCopied, bannerCopyFailed], [bannerCopied, bannerCopyFailed],
); );
const copyPublishedRuleLink = useCallback(async () => { const copyPublishedRuleLink = useCallback(async () => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return false;
const ctx = resolvePublishedRuleShareContext(window); const ctx = resolvePublishedRuleShareContext(window);
if (!ctx) { if (!ctx) {
bannerNoRule(); bannerNoRule();
return; return false;
} }
await copyUrlToClipboard(ctx.url); return copyUrlToClipboard(ctx.url);
}, [bannerNoRule, copyUrlToClipboard]); }, [bannerNoRule, copyUrlToClipboard]);
const mailtoPublishedRule = useCallback(() => { const mailtoPublishedRule = useCallback(() => {
@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload"; import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
import { publishRule, updatePublishedRule } from "../../../../lib/create/api"; import { publishRule, updatePublishedRule } from "../../../../lib/create/api";
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule"; import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
import { writePendingKeepRuleLogin } from "../../../../lib/create/pendingKeepRuleLogin";
import messages from "../../../../messages/en/index"; import messages from "../../../../messages/en/index";
import type { CreateFlowState } from "../types"; import type { CreateFlowState } from "../types";
import { import {
@@ -15,11 +16,13 @@ import { createFlowStepPath } from "../utils/createFlowPaths";
type AppRouterLike = { push: (_href: string) => void }; type AppRouterLike = { push: (_href: string) => void };
type OpenLogin = (args: { type OpenLogin = (args: {
variant: "default" | "saveProgress"; variant: "default" | "saveProgress" | "keepRule";
nextPath: string; nextPath: string;
backdropVariant: "blurredYellow"; backdropVariant: "blurredYellow";
}) => void; }) => void;
type SessionUser = { id: string; email: string };
export type UseCreateFlowFinalizeResult = { export type UseCreateFlowFinalizeResult = {
publishBannerMessage: string | null; publishBannerMessage: string | null;
setPublishBannerMessage: (_message: string | null) => void; setPublishBannerMessage: (_message: string | null) => void;
@@ -34,6 +37,8 @@ export function useCreateFlowFinalize({
openLogin, openLogin,
updateState, updateState,
loginReturnPath, loginReturnPath,
sessionUser,
onGuestPublished,
}: { }: {
state: CreateFlowState; state: CreateFlowState;
router: AppRouterLike; router: AppRouterLike;
@@ -41,6 +46,13 @@ export function useCreateFlowFinalize({
updateState: (_patch: Partial<CreateFlowState>) => void; updateState: (_patch: Partial<CreateFlowState>) => void;
/** Session gate return path (`?syncDraft=1`) — differs for `/create/edit-rule` vs `/create/final-review`. */ /** Session gate return path (`?syncDraft=1`) — differs for `/create/edit-rule` vs `/create/final-review`. */
loginReturnPath: string; loginReturnPath: string;
/**
* `undefined` while `/api/auth/session` is in flight — publish is a no-op
* until it resolves. `null` is a guest (anonymous publish).
*/
sessionUser: SessionUser | null | undefined;
/** Guest publish succeeded; `skippedInvites` when stakeholder emails were not sent. */
onGuestPublished?: (_info: { skippedInvites: boolean }) => void;
}): UseCreateFlowFinalizeResult { }): UseCreateFlowFinalizeResult {
const [publishBannerMessage, setPublishBannerMessage] = useState< const [publishBannerMessage, setPublishBannerMessage] = useState<
string | null string | null
@@ -48,6 +60,8 @@ export function useCreateFlowFinalize({
const [isPublishing, setIsPublishing] = useState(false); const [isPublishing, setIsPublishing] = useState(false);
const finalize = useCallback(async () => { const finalize = useCallback(async () => {
if (sessionUser === undefined) return;
setPublishBannerMessage(null); setPublishBannerMessage(null);
const payloadResult = buildPublishPayload(state); const payloadResult = buildPublishPayload(state);
if (payloadResult.ok === false) { if (payloadResult.ok === false) {
@@ -66,6 +80,21 @@ export function useCreateFlowFinalize({
? state.editingPublishedRuleId.trim() ? state.editingPublishedRuleId.trim()
: ""; : "";
if (editingId.length > 0 && sessionUser === null) {
setIsPublishing(false);
writeLastPublishedRule({
id: editingId,
title,
summary: summary ?? null,
document: ruleDocument,
});
updateState({ editingPublishedRuleId: undefined });
writePendingKeepRuleLogin();
onGuestPublished?.({ skippedInvites: false });
router.push(createFlowStepPath("completed"));
return;
}
if (editingId.length > 0) { if (editingId.length > 0) {
const updateResult = await updatePublishedRule(editingId, { const updateResult = await updatePublishedRule(editingId, {
title, title,
@@ -86,8 +115,11 @@ export function useCreateFlowFinalize({
} }
if (updateResult.status === 401) { if (updateResult.status === 401) {
openLogin({ openLogin({
variant: "default", variant: sessionUser === null ? "keepRule" : "default",
nextPath: loginReturnPath, nextPath:
sessionUser === null
? createFlowStepPath("completed")
: loginReturnPath,
backdropVariant: "blurredYellow", backdropVariant: "blurredYellow",
}); });
return; return;
@@ -100,9 +132,13 @@ export function useCreateFlowFinalize({
return; return;
} }
const stakeholderEmails = (state.stakeholderEmails ?? []).filter( const isGuest = sessionUser === null;
const rawStakeholderEmails = (state.stakeholderEmails ?? []).filter(
(e) => typeof e === "string" && e.trim() !== "", (e) => typeof e === "string" && e.trim() !== "",
); );
const stakeholderEmails = isGuest ? [] : rawStakeholderEmails;
const skippedGuestInvites = isGuest && rawStakeholderEmails.length > 0;
const publishResult = await publishRule({ const publishResult = await publishRule({
title, title,
summary, summary,
@@ -117,6 +153,10 @@ export function useCreateFlowFinalize({
summary: summary ?? null, summary: summary ?? null,
document: ruleDocument, document: ruleDocument,
}); });
if (isGuest) {
writePendingKeepRuleLogin();
onGuestPublished?.({ skippedInvites: skippedGuestInvites });
}
router.push( router.push(
createFlowStepPath("completed", { createFlowStepPath("completed", {
[CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]: [CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]:
@@ -127,8 +167,11 @@ export function useCreateFlowFinalize({
} }
if (publishResult.status === 401) { if (publishResult.status === 401) {
openLogin({ openLogin({
variant: "default", variant: sessionUser === null ? "keepRule" : "default",
nextPath: loginReturnPath, nextPath:
sessionUser === null
? createFlowStepPath("completed")
: loginReturnPath,
backdropVariant: "blurredYellow", backdropVariant: "blurredYellow",
}); });
return; return;
@@ -138,7 +181,15 @@ export function useCreateFlowFinalize({
? publishResult.error ? publishResult.error
: messages.create.reviewAndComplete.publish.genericPublishFailed, : messages.create.reviewAndComplete.publish.genericPublishFailed,
); );
}, [state, router, openLogin, updateState, loginReturnPath]); }, [
state,
router,
openLogin,
updateState,
loginReturnPath,
sessionUser,
onGuestPublished,
]);
return { return {
publishBannerMessage, publishBannerMessage,
@@ -34,13 +34,17 @@ const blurActiveElement = (): void => {
* *
* Resolves the active step from `/create/{screenId}` via * Resolves the active step from `/create/{screenId}` via
* {@link parseCreateFlowScreenFromPathname} (flowSteps). Footer Back uses * {@link parseCreateFlowScreenFromPathname} (flowSteps). Footer Back uses
* {@link resolveCreateFlowBackTarget} so template **Use without changes** * {@link resolveCreateFlowBackTarget}: template **Use without changes** that
* (which skips the custom-rule segment) returns to `/create/review-template/{slug}` * still needs identity (direct from a template) walks name / description /
* from `confirm-stakeholders` instead of `conflict-management`. * photo, then Back from `community-name` returns to
* `/create/review-template/{slug}`. In-flow picks (`?fromFlow=1`) skip
* identity and land on `confirm-stakeholders`; Back from that step returns
* to the template instead of `conflict-management`.
* *
* Template review footer Back uses {@link buildTemplateReviewHref}s * Template review footer Back uses {@link buildTemplateReviewHref}s
* `?fromFlow=1` marker (and persisted `templateReviewEntryFromCreateFlow`) so * `?fromFlow=1` marker (and persisted `templateReviewEntryFromCreateFlow`) so
* users who came from `/create/review` return there instead of `/`. * users who came from `/create/review` via `/create/templates` return there
* instead of `/`.
*/ */
export function useCreateFlowNavigation( export function useCreateFlowNavigation(
options?: CreateFlowNavigationOptions, options?: CreateFlowNavigationOptions,
@@ -83,17 +87,27 @@ export function useCreateFlowNavigation(
updateState, updateState,
]); ]);
const nextStep = getNextStep(validStep, options); const identityOnly =
const previousStep = getPreviousStep(validStep, options); state.pendingTemplateAction?.mode === "useWithoutChanges";
const navigationOptions = useMemo(
(): CreateFlowNavigationOptions => ({
skipCommunitySave: options?.skipCommunitySave,
useWithoutChangesIdentityOnly: identityOnly,
}),
[options?.skipCommunitySave, identityOnly],
);
const nextStep = getNextStep(validStep, navigationOptions);
const previousStep = getPreviousStep(validStep, navigationOptions);
const backTarget = useMemo( const backTarget = useMemo(
() => () =>
resolveCreateFlowBackTarget( resolveCreateFlowBackTarget(
validStep, validStep,
options, navigationOptions,
state.templateReviewBackSlug, state.templateReviewBackSlug,
), ),
[validStep, options?.skipCommunitySave, state.templateReviewBackSlug], [validStep, navigationOptions, state.templateReviewBackSlug],
); );
const goToNextStep = useCallback(() => { const goToNextStep = useCallback(() => {
@@ -1,8 +1,13 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { buildFacetQueryString } from "../../../../lib/create/buildFacetQueryString"; import { buildFacetQueryString } from "../../../../lib/create/buildFacetQueryString";
import type { MethodFacetApiSectionId } from "../../../../lib/create/customRuleFacets"; import type { MethodFacetApiSectionId } from "../../../../lib/create/customRuleFacets";
import {
buildFacetRecommendationRequestKey,
getCachedFacetScores,
loadFacetScores,
} from "../../../../lib/create/facetRecommendationsLoad";
import { useCreateFlow } from "../context/CreateFlowContext"; import { useCreateFlow } from "../context/CreateFlowContext";
/** /**
@@ -25,6 +30,34 @@ export type FacetRecommendationsResult = {
const EMPTY_SCORES: Record<string, number> = {}; const EMPTY_SCORES: Record<string, number> = {};
function initialFacetRecommendationsResult(
section: RecommendationSection,
queryString: string,
): FacetRecommendationsResult {
const hasAnyFacets = queryString.length > 0;
if (!hasAnyFacets) {
return {
isReady: true,
scoresBySlug: EMPTY_SCORES,
hasAnyFacets: false,
};
}
const requestKey = buildFacetRecommendationRequestKey(section, queryString);
const cached = getCachedFacetScores(requestKey);
if (cached) {
return {
isReady: true,
scoresBySlug: cached,
hasAnyFacets: true,
};
}
return {
isReady: false,
scoresBySlug: EMPTY_SCORES,
hasAnyFacets: true,
};
}
/** /**
* Calls `GET /api/create-flow/methods?section=<section>&facet.*=...` for the * Calls `GET /api/create-flow/methods?section=<section>&facet.*=...` for the
* card-deck step `section` and returns a `slug → score` map for re-ranking * card-deck step `section` and returns a `slug → score` map for re-ranking
@@ -46,14 +79,9 @@ export function useFacetRecommendations(
); );
const hasAnyFacets = queryString.length > 0; const hasAnyFacets = queryString.length > 0;
const [result, setResult] = useState<FacetRecommendationsResult>({ const [result, setResult] = useState<FacetRecommendationsResult>(() =>
isReady: !hasAnyFacets, initialFacetRecommendationsResult(section, queryString),
scoresBySlug: EMPTY_SCORES, );
hasAnyFacets,
});
// Track the last successful request input so we don't re-fetch on every state poke.
const lastQueryRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
if (!hasAnyFacets) { if (!hasAnyFacets) {
@@ -62,51 +90,34 @@ export function useFacetRecommendations(
scoresBySlug: EMPTY_SCORES, scoresBySlug: EMPTY_SCORES,
hasAnyFacets: false, hasAnyFacets: false,
}); });
lastQueryRef.current = null;
return; return;
} }
const requestKey = `${section}?${queryString}`;
if (lastQueryRef.current === requestKey) return;
lastQueryRef.current = requestKey;
const ctrl = new AbortController(); const requestKey = buildFacetRecommendationRequestKey(section, queryString);
setResult((prev) => ({ ...prev, isReady: false, hasAnyFacets: true })); const cached = getCachedFacetScores(requestKey);
fetch(`/api/create-flow/methods?section=${section}&${queryString}`, { if (cached) {
credentials: "include",
signal: ctrl.signal,
})
.then(async (res) => {
if (!res.ok) throw new Error(`status ${res.status}`);
return (await res.json()) as {
methods?: { slug: string; matches?: { score?: number } }[];
};
})
.then((json) => {
const scoresBySlug: Record<string, number> = {};
for (const m of json.methods ?? []) {
if (typeof m.slug === "string") {
scoresBySlug[m.slug] = m.matches?.score ?? 0;
}
}
setResult({ isReady: true, scoresBySlug, hasAnyFacets: true });
})
.catch((e) => {
if ((e as { name?: string }).name === "AbortError") return;
setResult({ setResult({
isReady: true, isReady: true,
scoresBySlug: EMPTY_SCORES, scoresBySlug: cached,
hasAnyFacets: true, hasAnyFacets: true,
}); });
return;
}
let cancelled = false;
setResult((prev) =>
prev.isReady && prev.hasAnyFacets
? { ...prev, isReady: false }
: { isReady: false, scoresBySlug: EMPTY_SCORES, hasAnyFacets: true },
);
void loadFacetScores(section, queryString).then((scoresBySlug) => {
if (cancelled) return;
setResult({ isReady: true, scoresBySlug, hasAnyFacets: true });
}); });
return () => { return () => {
ctrl.abort(); cancelled = true;
// Clear the dedup key so React 19 Strict Mode's mount → unmount → mount
// cycle (and any future remount) re-issues the request instead of
// returning early on the same key.
if (lastQueryRef.current === requestKey) {
lastQueryRef.current = null;
}
}; };
}, [section, queryString, hasAnyFacets]); }, [section, queryString, hasAnyFacets]);
@@ -1,10 +1,7 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo } from "react";
import { import { mergeCompactCardIdsWithPinnedSelected } from "../../../../lib/create/methodCardDisplayOrder";
mergeCompactCardIdsWithPinnedSelected,
orderRankedMethodsWithPinnedSelection,
} from "../../../../lib/create/methodCardDisplayOrder";
import { import {
deriveCompactCards, deriveCompactCards,
rankMethodsByScore, rankMethodsByScore,
@@ -15,17 +12,19 @@ import {
type MethodEntry = { id: string; label: string; supportText: string }; type MethodEntry = { id: string; label: string; supportText: string };
/** /**
* Applies score ranking, compact-slot rules, then surfaces selected ids first in * Applies score ranking and compact-slot rules. Expanded CardStack order stays
* `selected*Ids` order (most-recent add at index 0 via * the ranked catalog (selected cards keep their place). Compact slots still
* {@link moveFacetSelectionIdToFront}). Selection-first applies whenever the facet * pin selected ids first so a pick outside the unpinned top-N remains visible
* has any selection — not only after footer Confirm (`methodSectionsPinCommitted`). * when the stack is collapsed.
*/ */
export function useMethodCardDeckOrdering( export function useMethodCardDeckOrdering(
section: RecommendationSection, section: RecommendationSection,
methods: readonly MethodEntry[], methods: readonly MethodEntry[],
selectedIds: readonly string[], selectedIds: readonly string[],
) { ) {
const { scoresBySlug, hasAnyFacets } = useFacetRecommendations(section); const { scoresBySlug, hasAnyFacets, isReady } =
useFacetRecommendations(section);
const recommendationsReady = !hasAnyFacets || isReady;
const rankedMethods = useMemo( const rankedMethods = useMemo(
() => rankMethodsByScore(methods, scoresBySlug), () => rankMethodsByScore(methods, scoresBySlug),
@@ -33,16 +32,7 @@ export function useMethodCardDeckOrdering(
); );
const selectionShowcaseActive = selectedIds.length > 0; const selectionShowcaseActive = selectedIds.length > 0;
const displayMethods = rankedMethods;
const displayMethods = useMemo(
() =>
orderRankedMethodsWithPinnedSelection(
rankedMethods,
selectedIds,
selectionShowcaseActive,
),
[rankedMethods, selectedIds, selectionShowcaseActive],
);
const { compactCardIds: baseCompactCardIds, recommendedIds } = useMemo( const { compactCardIds: baseCompactCardIds, recommendedIds } = useMemo(
() => () =>
@@ -58,13 +48,13 @@ export function useMethodCardDeckOrdering(
const compactCardIds = useMemo( const compactCardIds = useMemo(
() => () =>
mergeCompactCardIdsWithPinnedSelected( mergeCompactCardIdsWithPinnedSelected(
displayMethods.map((m) => m.id), rankedMethods.map((m) => m.id),
baseCompactCardIds, baseCompactCardIds,
selectedIds, selectedIds,
selectionShowcaseActive, selectionShowcaseActive,
5, 5,
), ),
[displayMethods, baseCompactCardIds, selectedIds, selectionShowcaseActive], [rankedMethods, baseCompactCardIds, selectedIds, selectionShowcaseActive],
); );
const sampleCards = useMemo( const sampleCards = useMemo(
@@ -90,5 +80,6 @@ export function useMethodCardDeckOrdering(
recommendedIds, recommendedIds,
sampleCards, sampleCards,
methodById, methodById,
recommendationsReady,
}; };
} }
@@ -0,0 +1,27 @@
"use client";
import { useEffect, useMemo } from "react";
import { buildFacetQueryString } from "../../../../lib/create/buildFacetQueryString";
import { METHOD_FACET_API_SECTION_IDS } from "../../../../lib/create/customRuleFacets";
import { loadFacetScores } from "../../../../lib/create/facetRecommendationsLoad";
import { useCreateFlow } from "../context/CreateFlowContext";
/**
* Warms the facet recommendation cache for all method-deck sections once the
* user has community facet selections, so method screens can render ranked
* cards on first paint instead of flashing authoring order.
*/
export function usePrefetchMethodFacetRecommendations(): void {
const { state } = useCreateFlow();
const queryString = useMemo(
() => buildFacetQueryString(state),
[state],
);
useEffect(() => {
if (queryString.length === 0) return;
for (const section of METHOD_FACET_API_SECTION_IDS) {
void loadFacetScores(section, queryString);
}
}, [queryString]);
}
@@ -10,6 +10,7 @@ import type {
CreateFlowContextValue, CreateFlowContextValue,
CreateFlowState, CreateFlowState,
} from "../types"; } from "../types";
import { createFlowStepPath } from "../utils/createFlowPaths";
type AppRouterLike = { push: (_href: string) => void }; type AppRouterLike = { push: (_href: string) => void };
type UpdateState = CreateFlowContextValue["updateState"]; type UpdateState = CreateFlowContextValue["updateState"];
@@ -36,9 +37,10 @@ export type UseTemplateReviewActionsResult = {
* Use without changes: scrub any prior customize picks, seed core values + * Use without changes: scrub any prior customize picks, seed core values +
* method-card selections from the template body (same id mapping as * method-card selections from the template body (same id mapping as
* Customize) so drilling from final-review via + shows selected cards, drop * Customize) so drilling from final-review via + shows selected cards, drop
* the Values row from `state.sections`, and route to * the Values row from `state.sections`. Direct template entry routes to
* `/create/confirm-stakeholders` (or `/create/informational` with a pin to * `/create/community-name` with a pin so Next/Back collect only name →
* skip past `/create/review` to `/create/confirm-stakeholders` later). * description → photo. In-flow (`?fromFlow=1`, community steps already done)
* routes to `/create/confirm-stakeholders`.
*/ */
handleUseWithoutChanges: () => Promise<void>; handleUseWithoutChanges: () => Promise<void>;
}; };
@@ -59,7 +61,10 @@ export type UseTemplateReviewActionsResult = {
* setTemplateReviewApplyError, * setTemplateReviewApplyError,
* handleCustomize, * handleCustomize,
* handleUseWithoutChanges, * handleUseWithoutChanges,
* } = useTemplateReviewActions({ pathname, state, updateState, replaceState, router }); * } = useTemplateReviewActions({
* pathname, state, updateState, replaceState, router,
* fromCreateWizard, markCreateFlowInteraction,
* });
*/ */
export function useTemplateReviewActions({ export function useTemplateReviewActions({
pathname, pathname,
@@ -67,12 +72,21 @@ export function useTemplateReviewActions({
updateState, updateState,
replaceState, replaceState,
router, router,
fromCreateWizard = false,
markCreateFlowInteraction,
}: { }: {
pathname: string | null | undefined; pathname: string | null | undefined;
state: CreateFlowState; state: CreateFlowState;
updateState: UpdateState; updateState: UpdateState;
replaceState: ReplaceStateFn; replaceState: ReplaceStateFn;
router: AppRouterLike; router: AppRouterLike;
/**
* True when this template-review URL was opened from the create wizard
* (`?fromFlow=1`). Direct catalog / marketing entry is false — a leftover
* draft title must not count as “community steps already done”.
*/
fromCreateWizard?: boolean;
markCreateFlowInteraction: () => void;
}): UseTemplateReviewActionsResult { }): UseTemplateReviewActionsResult {
const [isApplyingTemplate, setIsApplyingTemplate] = useState(false); const [isApplyingTemplate, setIsApplyingTemplate] = useState(false);
const [templateReviewApplyError, setTemplateReviewApplyError] = useState< const [templateReviewApplyError, setTemplateReviewApplyError] = useState<
@@ -90,6 +104,7 @@ export function useTemplateReviewActions({
const handleCustomize = useCallback(async () => { const handleCustomize = useCallback(async () => {
if (!templateReviewSlug) return; if (!templateReviewSlug) return;
markCreateFlowInteraction();
setTemplateReviewApplyError(null); setTemplateReviewApplyError(null);
setIsApplyingTemplate(true); setIsApplyingTemplate(true);
const loaded = await loadTemplateReviewBySlug(templateReviewSlug); const loaded = await loadTemplateReviewBySlug(templateReviewSlug);
@@ -119,7 +134,9 @@ export function useTemplateReviewActions({
}), }),
}); });
router.push( router.push(
hasCommunityName ? "/create/core-values" : "/create/informational", hasCommunityName
? createFlowStepPath("core-values")
: createFlowStepPath("informational"),
); );
}, [ }, [
router, router,
@@ -127,10 +144,12 @@ export function useTemplateReviewActions({
state.title, state.title,
templateReviewSlug, templateReviewSlug,
updateState, updateState,
markCreateFlowInteraction,
]); ]);
const handleUseWithoutChanges = useCallback(async () => { const handleUseWithoutChanges = useCallback(async () => {
if (!templateReviewSlug) return; if (!templateReviewSlug) return;
markCreateFlowInteraction();
setTemplateReviewApplyError(null); setTemplateReviewApplyError(null);
setIsApplyingTemplate(true); setIsApplyingTemplate(true);
const loaded = await loadTemplateReviewBySlug(templateReviewSlug); const loaded = await loadTemplateReviewBySlug(templateReviewSlug);
@@ -158,9 +177,6 @@ export function useTemplateReviewActions({
return; return;
} }
const hasCommunityName =
typeof state.title === "string" && state.title.trim().length > 0;
// Atomic read-modify-write: strip prior custom-rule picks and merge template // Atomic read-modify-write: strip prior custom-rule picks and merge template
// body in one replaceState so method ids are never lost across React batching // body in one replaceState so method ids are never lost across React batching
// (reset + update separately could leave selections undefined in Strict Mode). // (reset + update separately could leave selections undefined in Strict Mode).
@@ -179,13 +195,10 @@ export function useTemplateReviewActions({
}) })
: sections; : sections;
const hasCommunityName =
typeof prev.title === "string" && prev.title.trim().length > 0;
const pinPatch = const pinPatch =
methodSectionsPinsForHydratedSelections(customizePrefill); methodSectionsPinsForHydratedSelections(customizePrefill);
return { const seeded: CreateFlowState = {
...base, ...base,
...(hasValuesSeed ...(hasValuesSeed
? { ? {
@@ -221,22 +234,40 @@ export function useTemplateReviewActions({
sections: sectionsWithoutValues, sections: sectionsWithoutValues,
methodSectionsPinCommitted: pinPatch, methodSectionsPinCommitted: pinPatch,
templateReviewBackSlug: templateReviewSlug, templateReviewBackSlug: templateReviewSlug,
...(hasCommunityName };
? { pendingTemplateAction: undefined }
: { if (fromCreateWizard) {
return {
...seeded,
pendingTemplateAction: undefined,
};
}
return {
...seeded,
title: undefined,
communityContext: undefined,
communityAvatarUrl: undefined,
currentStep: undefined,
templateReviewEntryFromCreateFlow: undefined,
pendingTemplateAction: { pendingTemplateAction: {
slug: templateReviewSlug, slug: templateReviewSlug,
mode: "useWithoutChanges", mode: "useWithoutChanges",
}, },
}),
}; };
}); });
router.push( router.push(
hasCommunityName fromCreateWizard
? "/create/confirm-stakeholders" ? createFlowStepPath("confirm-stakeholders")
: "/create/informational", : createFlowStepPath("community-name"),
); );
}, [replaceState, router, state.title, templateReviewSlug]); }, [
fromCreateWizard,
markCreateFlowInteraction,
replaceState,
router,
templateReviewSlug,
]);
return { return {
isTemplateReviewRoute, isTemplateReviewRoute,
+7
View File
@@ -1,5 +1,12 @@
import type { Metadata } from "next";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import CreateFlowLayoutGate from "./CreateFlowLayoutGate"; import CreateFlowLayoutGate from "./CreateFlowLayoutGate";
import messages from "../../../messages/en/index";
import { routeMetadata } from "../../../lib/siteMetadata";
export const metadata: Metadata = routeMetadata("/create", {
title: messages.metadata.create.title,
});
export default function CreateFlowLayout({ children }: { children: ReactNode }) { export default function CreateFlowLayout({ children }: { children: ReactNode }) {
return <CreateFlowLayoutGate>{children}</CreateFlowLayoutGate>; return <CreateFlowLayoutGate>{children}</CreateFlowLayoutGate>;
+17
View File
@@ -0,0 +1,17 @@
/**
* Route-level fallback shown while `/create/...` RSC streams in. Mirrors the
* create-flow chrome surface (top bar height + dark canvas) so the user sees
* structural feedback instead of a flash of the previous page.
*/
export default function CreateFlowRouteLoading() {
return (
<div
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"
>
<div className="h-14 w-full border-b border-[var(--color-border-default-primary)] md:h-16" />
<div className="flex-1" />
</div>
);
}
@@ -15,14 +15,17 @@ import {
CreateFlowLockupCardStepShell, CreateFlowLockupCardStepShell,
} from "../../components/CreateFlowLockupCardStepShell"; } from "../../components/CreateFlowLockupCardStepShell";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell"; import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens"; import {
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
CREATE_FLOW_MD_CENTERED_SHELL_CLASS,
} from "../../components/createFlowLayoutTokens";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
interface PageProps { interface PageProps {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
} }
/** Template review route — same shell/grid as final-review; Figma `22142-898702`. */ /** Template review — lockup + card, vertically centered (Figma `22142-898702`). */
export default function ReviewTemplatePage({ params }: PageProps) { export default function ReviewTemplatePage({ params }: PageProps) {
const { slug: rawSlug } = use(params); const { slug: rawSlug } = use(params);
const slug = decodeURIComponent(rawSlug); const slug = decodeURIComponent(rawSlug);
@@ -72,7 +75,11 @@ export default function ReviewTemplatePage({ params }: PageProps) {
if (loading) { if (loading) {
return ( return (
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800"> <CreateFlowStepShell
variant="wideGrid"
contentTopBelowMd="space-800"
className={CREATE_FLOW_MD_CENTERED_SHELL_CLASS}
>
<div <div
className={`flex shrink-0 items-center justify-start pb-16 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`} className={`flex shrink-0 items-center justify-start pb-16 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
> >
@@ -101,7 +108,11 @@ export default function ReviewTemplatePage({ params }: PageProps) {
/> />
</div> </div>
</div> </div>
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800"> <CreateFlowStepShell
variant="wideGrid"
contentTopBelowMd="space-800"
className={CREATE_FLOW_MD_CENTERED_SHELL_CLASS}
>
<div <div
className={`min-h-[40vh] shrink-0 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`} className={`min-h-[40vh] shrink-0 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
aria-hidden aria-hidden
@@ -6,30 +6,26 @@
* *
* Lives under `screens/card/` (not `select/`): Figma **card stack** layout is a distinct shell from * Lives under `screens/card/` (not `select/`): Figma **card stack** layout is a distinct shell from
* two-column chip **select** frames. Future card-stack steps get their own `*Screen.tsx` here and * two-column chip **select** frames. Future card-stack steps get their own `*Screen.tsx` here and
* reuse `CardStack` / `CreateFlowStepShell` as needed. * reuse `CardStack` / `CreateFlowCardStackStepShell` as needed.
* *
* Card click opens the Figma create modal (node `20246-15829`) with three * Card click opens the Figma create modal (node `20246-15829`) with three
* editable sections rendered by {@link CommunicationMethodEditFields}. The primary * editable sections rendered by {@link CommunicationMethodEditFields}. The
* action is **Add Platform** for an unselected card; a selected card in view mode has * primary action is **Add Platform** for an unselected card and **Save** for a
* no footer primary — **Remove** is available from the kebab (same behavior as legacy * selected card. **Remove** is a danger footer button on selected cards (and
* footer remove via {@link removeMethodCardFromFacetSelection}). * remains in the kebab) via {@link removeMethodCardFromFacetSelection}.
*/ */
import { useState, useCallback, useMemo, useRef } from "react"; import { useState, useCallback, useMemo, useRef } from "react";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowCardStackStepShell } from "../../components/CreateFlowCardStackStepShell";
import CardStack from "../../../../components/cards/CardStack"; import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create"; import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton"; import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import { CommunicationMethodEditFields } from "../../components/methodEditFields"; import { CommunicationMethodEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard"; import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
@@ -52,20 +48,21 @@ import type { CommunicationMethodDetailEntry } from "../../types";
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody"; import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu"; import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch"; import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
isMethodCardCustomizeUnloadBlocked,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession"; } from "../../../../../lib/create/methodCardCustomizeSession";
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
export function CommunicationMethodsScreen() { export function CommunicationMethodsScreen() {
const m = useMessages(); const m = useMessages();
const comm = m.create.customRule.communication; const comm = m.create.customRule.communication;
const modalKebabMenu = m.create.customRule.modalKebabMenu; const modalKebabMenu = m.create.customRule.modalKebabMenu;
const mdUp = useCreateFlowMdUp(); const mdUp = useCreateFlowMdUp();
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } = const { confirmDiscard, confirmDialog } = useDiscardCustomizeConfirm();
useDiscardCustomizeConfirm();
const { state, updateState, replaceState, markCreateFlowInteraction } = const { state, updateState, replaceState, markCreateFlowInteraction } =
useCreateFlow(); useCreateFlow();
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null); const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
@@ -78,12 +75,24 @@ export function CommunicationMethodsScreen() {
const [pendingDraft, setPendingDraft] = const [pendingDraft, setPendingDraft] =
useState<CommunicationMethodDetailEntry | null>(null); useState<CommunicationMethodDetailEntry | null>(null);
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false); const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
const [modalEditUnlocked, setModalEditUnlocked] = useState(false); const [wizardCustomizeCardId, setWizardCustomizeCardId] = useState<
string | null
>(null);
const [wizardInitialValues, setWizardInitialValues] =
useState<MethodCardWizardInitialValues | null>(null);
const [draftFieldBlocks, setDraftFieldBlocks] = useState< const [draftFieldBlocks, setDraftFieldBlocks] = useState<
CustomMethodCardFieldBlock[] | null CustomMethodCardFieldBlock[] | null
>(null); >(null);
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
useState<MethodCardHeaderDraft | null>(null); useBeforeUnloadGuard(
isMethodCardCustomizeUnloadBlocked(
createModalOpen,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeSnapshotRef.current?.headerDraft ?? null,
),
);
const selectedIds = state.selectedCommunicationMethodIds ?? []; const selectedIds = state.selectedCommunicationMethodIds ?? [];
@@ -97,7 +106,8 @@ export function CommunicationMethodsScreen() {
[comm.methods, selectedIds, state.customMethodCardMetaById], [comm.methods, selectedIds, state.customMethodCardMetaById],
); );
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering( const { sampleCards, compactCardIds, methodById, recommendationsReady } =
useMethodCardDeckOrdering(
"communication", "communication",
mergedMethods, mergedMethods,
selectedIds, selectedIds,
@@ -105,6 +115,8 @@ export function CommunicationMethodsScreen() {
const handleOpenAddWizard = useCallback(() => { const handleOpenAddWizard = useCallback(() => {
markCreateFlowInteraction(); markCreateFlowInteraction();
setWizardCustomizeCardId(null);
setWizardInitialValues(null);
setAddCustomWizardOpen(true); setAddCustomWizardOpen(true);
}, [markCreateFlowInteraction]); }, [markCreateFlowInteraction]);
@@ -142,15 +154,40 @@ export function CommunicationMethodsScreen() {
const handleCardClick = useCallback( const handleCardClick = useCallback(
(id: string) => { (id: string) => {
markCreateFlowInteraction(); markCreateFlowInteraction();
customizeSnapshotRef.current = null; const draft = seedDraft(id);
setModalEditUnlocked(false); const persistedBlocks = state.customMethodCardFieldBlocksById?.[id];
setDraftFieldBlocks(null); const initialBlocks =
setCustomizeHeaderDraft(null); Array.isArray(persistedBlocks) && persistedBlocks.length > 0
? structuredClone(persistedBlocks)
: null;
const method = methodById.get(id);
const meta = state.customMethodCardMetaById?.[id];
const headerDraft: MethodCardHeaderDraft = {
title: meta?.label ?? method?.label ?? comm.confirmModal.title,
description:
meta?.supportText ??
method?.supportText ??
comm.confirmModal.description,
};
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
draft,
initialBlocks,
headerDraft,
);
setPendingCardId(id); setPendingCardId(id);
setPendingDraft(seedDraft(id)); setPendingDraft(draft);
setDraftFieldBlocks(initialBlocks);
setCreateModalOpen(true); setCreateModalOpen(true);
}, },
[markCreateFlowInteraction, seedDraft], [
comm.confirmModal.description,
comm.confirmModal.title,
markCreateFlowInteraction,
methodById,
seedDraft,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
],
); );
const handleDraftChange = useCallback( const handleDraftChange = useCallback(
@@ -163,9 +200,7 @@ export function CommunicationMethodsScreen() {
const isSelectedCardModal = const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId); pendingCardId !== null && selectedIds.includes(pendingCardId);
const fieldsLocked = !modalEditUnlocked; const showMethodModalPrimary = true;
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
const customFacetDetailsMatchPreset = useMemo(() => { const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false; if (!pendingCardId || !pendingDraft) return false;
@@ -187,7 +222,7 @@ export function CommunicationMethodsScreen() {
methodId: pendingCardId, methodId: pendingCardId,
meta: state.customMethodCardMetaById, meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById, fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked, modalEditUnlocked: true,
draftFieldBlocks, draftFieldBlocks,
customFacetDetailsMatchPreset, customFacetDetailsMatchPreset,
}), }),
@@ -195,7 +230,6 @@ export function CommunicationMethodsScreen() {
[ [
customFacetDetailsMatchPreset, customFacetDetailsMatchPreset,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked,
pendingCardId, pendingCardId,
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById, state.customMethodCardMetaById,
@@ -205,11 +239,11 @@ export function CommunicationMethodsScreen() {
const handleCreateModalClose = useCallback(async () => { const handleCreateModalClose = useCallback(async () => {
if ( if (
!(await confirmDiscard( !(await confirmDiscard(
modalEditUnlocked, true,
customizeSnapshotRef.current, customizeSnapshotRef.current,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeSnapshotRef.current?.headerDraft ?? null,
)) ))
) { ) {
return; return;
@@ -237,53 +271,14 @@ export function CommunicationMethodsScreen() {
setCreateModalOpen(false); setCreateModalOpen(false);
setPendingCardId(null); setPendingCardId(null);
setPendingDraft(null); setPendingDraft(null);
setModalEditUnlocked(false);
setDraftFieldBlocks(null); setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
}, [ }, [
confirmDiscard, confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked,
pendingDraft, pendingDraft,
replaceState, replaceState,
]); ]);
const handleCancelCustomize = useCallback(async () => {
if (!modalEditUnlocked) {
return;
}
const snap = customizeSnapshotRef.current;
if (!snap) {
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
if (
!(await confirmDirtyCustomizeCancel(
snap,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
))
) {
return;
}
setPendingDraft(structuredClone(snap.pendingDraft));
setDraftFieldBlocks(null);
setModalEditUnlocked(false);
customizeSnapshotRef.current = null;
setCustomizeHeaderDraft(null);
}, [
confirmDirtyCustomizeCancel,
customizeHeaderDraft,
draftFieldBlocks,
modalEditUnlocked,
pendingDraft,
]);
const handleRemoveSelectedFromModal = useCallback(async () => { const handleRemoveSelectedFromModal = useCallback(async () => {
if (!pendingCardId || !selectedIds.includes(pendingCardId)) { if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
return; return;
@@ -291,11 +286,11 @@ export function CommunicationMethodsScreen() {
markCreateFlowInteraction(); markCreateFlowInteraction();
if ( if (
!(await confirmDiscard( !(await confirmDiscard(
modalEditUnlocked, true,
customizeSnapshotRef.current, customizeSnapshotRef.current,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeSnapshotRef.current?.headerDraft ?? null,
)) ))
) { ) {
return; return;
@@ -311,11 +306,9 @@ export function CommunicationMethodsScreen() {
await handleCreateModalClose(); await handleCreateModalClose();
}, [ }, [
confirmDiscard, confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
handleCreateModalClose, handleCreateModalClose,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked,
pendingDraft, pendingDraft,
pendingCardId, pendingCardId,
selectedIds, selectedIds,
@@ -325,37 +318,36 @@ export function CommunicationMethodsScreen() {
const handleCustomize = useCallback(() => { const handleCustomize = useCallback(() => {
markCreateFlowInteraction(); markCreateFlowInteraction();
if (!pendingDraft || !pendingCardId) { if (!pendingCardId) {
return; return;
} }
const persistedBlocks =
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [];
const initialFieldBlocks =
persistedBlocks.length > 0
? structuredClone(persistedBlocks)
: isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? []
: null;
const method = methodById.get(pendingCardId); const method = methodById.get(pendingCardId);
const meta = state.customMethodCardMetaById?.[pendingCardId]; setWizardInitialValues(
const headerDraft: MethodCardHeaderDraft = { buildMethodCardWizardInitialValues({
title: meta?.label ?? method?.label ?? comm.confirmModal.title, cardId: pendingCardId,
description: fallbackTitle: method?.label ?? comm.confirmModal.title,
meta?.supportText ?? fallbackDescription:
method?.supportText ?? method?.supportText ?? comm.confirmModal.description,
comm.confirmModal.description, meta: state.customMethodCardMetaById,
}; persistedBlocks: state.customMethodCardFieldBlocksById,
setCustomizeHeaderDraft(headerDraft); draftFieldBlocks,
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( facetPrefill: pendingDraft
pendingDraft, ? {
initialFieldBlocks, group: "communication",
headerDraft, draft: pendingDraft,
headings: comm.sectionHeadings,
}
: undefined,
}),
); );
setDraftFieldBlocks(initialFieldBlocks); setWizardCustomizeCardId(pendingCardId);
setModalEditUnlocked(true); setCreateModalOpen(false);
setAddCustomWizardOpen(true);
}, [ }, [
comm.confirmModal.description, comm.confirmModal.description,
comm.confirmModal.title, comm.confirmModal.title,
comm.sectionHeadings,
draftFieldBlocks,
markCreateFlowInteraction, markCreateFlowInteraction,
methodById, methodById,
pendingCardId, pendingCardId,
@@ -380,9 +372,7 @@ export function CommunicationMethodsScreen() {
() => communicationPresetFor(newId), () => communicationPresetFor(newId),
); );
const blocksClone = structuredClone( const blocksClone = structuredClone(
modalEditUnlocked && draftFieldBlocks !== null
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? draftFieldBlocks ? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate( : cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
@@ -412,16 +402,15 @@ export function CommunicationMethodsScreen() {
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setPendingCardId(newId); setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone)); setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false); setDraftFieldBlocks(
setDraftFieldBlocks(null); blocksClone.length > 0 ? structuredClone(blocksClone) : null,
setCustomizeHeaderDraft(null); );
}, [ }, [
markCreateFlowInteraction, markCreateFlowInteraction,
modalKebabMenu.duplicateTitleSuffix, modalKebabMenu.duplicateTitleSuffix,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked,
state.communicationMethodDetailsById, state.communicationMethodDetailsById,
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById, state.customMethodCardMetaById,
@@ -447,9 +436,7 @@ export function CommunicationMethodsScreen() {
() => communicationPresetFor(newId), () => communicationPresetFor(newId),
); );
const blocksClone = structuredClone( const blocksClone = structuredClone(
modalEditUnlocked && draftFieldBlocks !== null
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? draftFieldBlocks ? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate( : cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
@@ -479,14 +466,13 @@ export function CommunicationMethodsScreen() {
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setPendingCardId(newId); setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone)); setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false); setDraftFieldBlocks(
setDraftFieldBlocks(null); blocksClone.length > 0 ? structuredClone(blocksClone) : null,
setCustomizeHeaderDraft(null); );
}, [ }, [
draftFieldBlocks, draftFieldBlocks,
markCreateFlowInteraction, markCreateFlowInteraction,
methodById, methodById,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix, modalKebabMenu.duplicateTitleSuffix,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
@@ -499,7 +485,7 @@ export function CommunicationMethodsScreen() {
const kebabMenuItems = useMemo( const kebabMenuItems = useMemo(
() => () =>
buildCustomRuleModalKebabMenu(modalKebabMenu, { buildCustomRuleModalKebabMenu(modalKebabMenu, {
showCustomize: !modalEditUnlocked, showCustomize: true,
onCustomize: handleCustomize, onCustomize: handleCustomize,
onDuplicate: onDuplicate:
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId (state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
@@ -519,7 +505,6 @@ export function CommunicationMethodsScreen() {
handleDuplicatePrefabCard, handleDuplicatePrefabCard,
handleRemoveSelectedFromModal, handleRemoveSelectedFromModal,
isSelectedCardModal, isSelectedCardModal,
modalEditUnlocked,
modalKebabMenu, modalKebabMenu,
pendingCardId, pendingCardId,
state.customMethodCardMetaById, state.customMethodCardMetaById,
@@ -538,7 +523,7 @@ export function CommunicationMethodsScreen() {
meta?.supportText ?? meta?.supportText ??
method?.supportText ?? method?.supportText ??
comm.confirmModal.description, comm.confirmModal.description,
nextButtonText: modalEditUnlocked nextButtonText: isSelectedCardModal
? saveLabel ? saveLabel
: comm.addPlatform.nextButtonText, : comm.addPlatform.nextButtonText,
}; };
@@ -550,8 +535,14 @@ export function CommunicationMethodsScreen() {
}; };
const handleCloseAddWizard = useCallback(() => { const handleCloseAddWizard = useCallback(() => {
const resumeCardId = wizardCustomizeCardId;
setAddCustomWizardOpen(false); setAddCustomWizardOpen(false);
}, []); setWizardCustomizeCardId(null);
setWizardInitialValues(null);
if (resumeCardId && pendingCardId === resumeCardId) {
setCreateModalOpen(true);
}
}, [pendingCardId, wizardCustomizeCardId]);
const handleFinalizeCustomCard = useCallback( const handleFinalizeCustomCard = useCallback(
({ ({
@@ -564,6 +555,42 @@ export function CommunicationMethodsScreen() {
fieldBlocks: CustomMethodCardFieldBlock[]; fieldBlocks: CustomMethodCardFieldBlock[];
}) => { }) => {
markCreateFlowInteraction(); markCreateFlowInteraction();
const existingId = wizardCustomizeCardId;
if (existingId) {
updateState({
selectedCommunicationMethodIds: moveFacetSelectionIdToFront(
selectedIds,
existingId,
),
customMethodCardMetaById: methodCardMetaWithCustomizeHeader(
state.customMethodCardMetaById,
existingId,
{ title, description },
),
...(pendingDraft
? {
communicationMethodDetailsById: {
...(state.communicationMethodDetailsById ?? {}),
[existingId]: pendingDraft,
},
}
: {}),
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[existingId]: fieldBlocks,
},
});
if (pendingDraft) {
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
pendingDraft,
fieldBlocks,
{ title, description },
);
}
setDraftFieldBlocks(structuredClone(fieldBlocks));
setAddCustomWizardOpen(false);
return;
}
const id = crypto.randomUUID(); const id = crypto.randomUUID();
updateState({ updateState({
selectedCommunicationMethodIds: moveFacetSelectionIdToFront( selectedCommunicationMethodIds: moveFacetSelectionIdToFront(
@@ -586,91 +613,29 @@ export function CommunicationMethodsScreen() {
}, },
[ [
markCreateFlowInteraction, markCreateFlowInteraction,
pendingDraft,
selectedIds, selectedIds,
state.communicationMethodDetailsById, state.communicationMethodDetailsById,
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById, state.customMethodCardMetaById,
updateState, updateState,
wizardCustomizeCardId,
], ],
); );
const handleCreateModalPrimary = useCallback(() => { const handleCreateModalPrimary = useCallback(() => {
if (!pendingCardId) { if (!pendingCardId) {
handleCreateModalClose(); void handleCreateModalClose();
return; return;
} }
markCreateFlowInteraction(); markCreateFlowInteraction();
if (selectedIds.includes(pendingCardId)) { const persistWizardBlocks =
if (modalEditUnlocked) { modalUsesWizardFieldBlocksBody && draftFieldBlocks !== null;
if (!customizeHeaderDraft) {
return;
}
const nextMeta = methodCardMetaWithCustomizeHeader(
state.customMethodCardMetaById,
pendingCardId,
customizeHeaderDraft,
);
if (
pendingCardId &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
usesWizardFieldBlocksModalBody({
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
draftFieldBlocks,
customFacetDetailsMatchPreset,
})
) {
updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
});
} else if (pendingDraft) {
updateState({
customMethodCardMetaById: nextMeta,
communicationMethodDetailsById: {
...(state.communicationMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
});
}
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
return;
}
if (modalEditUnlocked) { if (selectedIds.includes(pendingCardId)) {
if (!customizeHeaderDraft) { if (persistWizardBlocks) {
return;
}
const nextMeta = methodCardMetaWithCustomizeHeader(
state.customMethodCardMetaById,
pendingCardId,
customizeHeaderDraft,
);
if (
pendingCardId &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
usesWizardFieldBlocksModalBody({
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
draftFieldBlocks,
customFacetDetailsMatchPreset,
})
) {
updateState({ updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: { customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}), ...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []), [pendingCardId]: structuredClone(draftFieldBlocks ?? []),
@@ -678,22 +643,20 @@ export function CommunicationMethodsScreen() {
}); });
} else if (pendingDraft) { } else if (pendingDraft) {
updateState({ updateState({
customMethodCardMetaById: nextMeta,
communicationMethodDetailsById: { communicationMethodDetailsById: {
...(state.communicationMethodDetailsById ?? {}), ...(state.communicationMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft, [pendingCardId]: pendingDraft,
}, },
}); });
} }
pendingEphemeralDuplicateIdRef.current = null;
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setModalEditUnlocked(false); void handleCreateModalClose();
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return; return;
} }
if (!pendingDraft) { if (!pendingDraft) {
handleCreateModalClose(); void handleCreateModalClose();
return; return;
} }
updateState({ updateState({
@@ -705,15 +668,23 @@ export function CommunicationMethodsScreen() {
...(state.communicationMethodDetailsById ?? {}), ...(state.communicationMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft, [pendingCardId]: pendingDraft,
}, },
...(persistWizardBlocks
? {
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
}
: {}),
}); });
pendingEphemeralDuplicateIdRef.current = null; pendingEphemeralDuplicateIdRef.current = null;
handleCreateModalClose(); customizeSnapshotRef.current = null;
void handleCreateModalClose();
}, [ }, [
customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
handleCreateModalClose, handleCreateModalClose,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked, modalUsesWizardFieldBlocksBody,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
selectedIds, selectedIds,
@@ -723,19 +694,12 @@ export function CommunicationMethodsScreen() {
return ( return (
<> <>
<CreateFlowStepShell <CreateFlowCardStackStepShell
variant="wideGridLoosePadding" lockupTitle={title}
contentTopBelowMd="space-800" lockupDescription={description}
stackBusy={!recommendationsReady}
> >
<div className="flex w-full min-w-0 flex-col items-center gap-6"> {recommendationsReady && (
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={title}
description={description}
justification="center"
/>
</div>
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
<CardStack <CardStack
cards={sampleCards} cards={sampleCards}
selectedIds={selectedIds} selectedIds={selectedIds}
@@ -752,40 +716,19 @@ export function CommunicationMethodsScreen() {
compactDesktopLayout="flexWrap" compactDesktopLayout="flexWrap"
headerLockupSize={mdUp ? "L" : "M"} headerLockupSize={mdUp ? "L" : "M"}
/> />
</div> )}
</div> </CreateFlowCardStackStepShell>
<Create <Create
isOpen={createModalOpen} isOpen={createModalOpen}
onClose={handleCreateModalClose} onClose={handleCreateModalClose}
headerContent={
modalEditUnlocked && customizeHeaderDraft ? (
<MethodCardCustomizeModalHeader
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
titleValue={customizeHeaderDraft.title}
descriptionValue={customizeHeaderDraft.description}
onTitleChange={(title) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, title } : null,
)
}
onDescriptionChange={(description) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, description } : null,
)
}
/>
) : undefined
}
onNext={handleCreateModalPrimary} onNext={handleCreateModalPrimary}
title={modalConfig.title} title={modalConfig.title}
description={modalConfig.description} description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText} nextButtonText={modalConfig.nextButtonText}
showBackButton={modalEditUnlocked} showBackButton={false}
onBack={handleCancelCustomize}
backButtonText={modalKebabMenu.cancelCustomize}
showNextButton={showMethodModalPrimary} showNextButton={showMethodModalPrimary}
showRemoveButton={isSelectedCardModal}
onRemove={handleRemoveSelectedFromModal}
backdropVariant="blurredYellow" backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel} kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
@@ -797,14 +740,14 @@ export function CommunicationMethodsScreen() {
cardId={pendingCardId} cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById} blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={ blocksOverride={
modalEditUnlocked && draftFieldBlocks !== null draftFieldBlocks !== null ? draftFieldBlocks : undefined
? draftFieldBlocks
: undefined
} }
policyMeta={state.customMethodCardMetaById?.[pendingCardId]} policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked} showPolicyContentLockupWhenNoBlocks={
draftFieldBlocks === null || draftFieldBlocks.length === 0
}
onFieldBlocksChange={ onFieldBlocksChange={
fieldsLocked draftFieldBlocks === null
? undefined ? undefined
: (next) => setDraftFieldBlocks(next) : (next) => setDraftFieldBlocks(next)
} }
@@ -813,15 +756,14 @@ export function CommunicationMethodsScreen() {
<CommunicationMethodEditFields <CommunicationMethodEditFields
value={pendingDraft} value={pendingDraft}
onChange={handleDraftChange} onChange={handleDraftChange}
readOnly={fieldsLocked}
/> />
) )
) : null} ) : null}
</Create> </Create>
</CreateFlowStepShell>
<CustomMethodCardWizard <CustomMethodCardWizard
isOpen={addCustomWizardOpen} isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard} onClose={handleCloseAddWizard}
initialValues={wizardInitialValues}
onFinalize={handleFinalizeCustomCard} onFinalize={handleFinalizeCustomCard}
onPersistCustomUploadFile={(file) => onPersistCustomUploadFile={(file) =>
uploadCreateFlowFile(file, "customMethodAttachment") uploadCreateFlowFile(file, "customMethodAttachment")
@@ -16,17 +16,13 @@ import { useState, useCallback, useMemo, useRef } from "react";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowCardStackStepShell } from "../../components/CreateFlowCardStackStepShell";
import CardStack from "../../../../components/cards/CardStack"; import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create"; import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton"; import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import { ConflictManagementEditFields } from "../../components/methodEditFields"; import { ConflictManagementEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard"; import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
@@ -49,20 +45,21 @@ import type { ConflictManagementDetailEntry } from "../../types";
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody"; import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu"; import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch"; import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
isMethodCardCustomizeUnloadBlocked,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession"; } from "../../../../../lib/create/methodCardCustomizeSession";
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
export function ConflictManagementScreen() { export function ConflictManagementScreen() {
const m = useMessages(); const m = useMessages();
const cm = m.create.customRule.conflictManagement; const cm = m.create.customRule.conflictManagement;
const modalKebabMenu = m.create.customRule.modalKebabMenu; const modalKebabMenu = m.create.customRule.modalKebabMenu;
const mdUp = useCreateFlowMdUp(); const mdUp = useCreateFlowMdUp();
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } = const { confirmDiscard, confirmDialog } = useDiscardCustomizeConfirm();
useDiscardCustomizeConfirm();
const { state, updateState, replaceState, markCreateFlowInteraction } = const { state, updateState, replaceState, markCreateFlowInteraction } =
useCreateFlow(); useCreateFlow();
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null); const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
@@ -75,12 +72,24 @@ export function ConflictManagementScreen() {
const [pendingDraft, setPendingDraft] = const [pendingDraft, setPendingDraft] =
useState<ConflictManagementDetailEntry | null>(null); useState<ConflictManagementDetailEntry | null>(null);
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false); const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
const [modalEditUnlocked, setModalEditUnlocked] = useState(false); const [wizardCustomizeCardId, setWizardCustomizeCardId] = useState<
string | null
>(null);
const [wizardInitialValues, setWizardInitialValues] =
useState<MethodCardWizardInitialValues | null>(null);
const [draftFieldBlocks, setDraftFieldBlocks] = useState< const [draftFieldBlocks, setDraftFieldBlocks] = useState<
CustomMethodCardFieldBlock[] | null CustomMethodCardFieldBlock[] | null
>(null); >(null);
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
useState<MethodCardHeaderDraft | null>(null); useBeforeUnloadGuard(
isMethodCardCustomizeUnloadBlocked(
createModalOpen,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeSnapshotRef.current?.headerDraft ?? null,
),
);
const selectedIds = state.selectedConflictManagementIds ?? []; const selectedIds = state.selectedConflictManagementIds ?? [];
@@ -94,7 +103,8 @@ export function ConflictManagementScreen() {
[cm.methods, selectedIds, state.customMethodCardMetaById], [cm.methods, selectedIds, state.customMethodCardMetaById],
); );
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering( const { sampleCards, compactCardIds, methodById, recommendationsReady } =
useMethodCardDeckOrdering(
"conflictManagement", "conflictManagement",
mergedMethods, mergedMethods,
selectedIds, selectedIds,
@@ -102,6 +112,8 @@ export function ConflictManagementScreen() {
const handleOpenAddWizard = useCallback(() => { const handleOpenAddWizard = useCallback(() => {
markCreateFlowInteraction(); markCreateFlowInteraction();
setWizardCustomizeCardId(null);
setWizardInitialValues(null);
setAddCustomWizardOpen(true); setAddCustomWizardOpen(true);
}, [markCreateFlowInteraction]); }, [markCreateFlowInteraction]);
@@ -143,15 +155,40 @@ export function ConflictManagementScreen() {
const handleCardClick = useCallback( const handleCardClick = useCallback(
(id: string) => { (id: string) => {
markCreateFlowInteraction(); markCreateFlowInteraction();
customizeSnapshotRef.current = null; const draft = seedDraft(id);
setModalEditUnlocked(false); const persistedBlocks = state.customMethodCardFieldBlocksById?.[id];
setDraftFieldBlocks(null); const initialBlocks =
setCustomizeHeaderDraft(null); Array.isArray(persistedBlocks) && persistedBlocks.length > 0
? structuredClone(persistedBlocks)
: null;
const method = methodById.get(id);
const meta = state.customMethodCardMetaById?.[id];
const headerDraft: MethodCardHeaderDraft = {
title: meta?.label ?? method?.label ?? cm.confirmModal.title,
description:
meta?.supportText ??
method?.supportText ??
cm.confirmModal.description,
};
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
draft,
initialBlocks,
headerDraft,
);
setPendingCardId(id); setPendingCardId(id);
setPendingDraft(seedDraft(id)); setPendingDraft(draft);
setDraftFieldBlocks(initialBlocks);
setCreateModalOpen(true); setCreateModalOpen(true);
}, },
[markCreateFlowInteraction, seedDraft], [
cm.confirmModal.description,
cm.confirmModal.title,
markCreateFlowInteraction,
methodById,
seedDraft,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
],
); );
const handleDraftChange = useCallback( const handleDraftChange = useCallback(
@@ -164,9 +201,7 @@ export function ConflictManagementScreen() {
const isSelectedCardModal = const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId); pendingCardId !== null && selectedIds.includes(pendingCardId);
const fieldsLocked = !modalEditUnlocked; const showMethodModalPrimary = true;
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
const customFacetDetailsMatchPreset = useMemo(() => { const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false; if (!pendingCardId || !pendingDraft) return false;
@@ -188,7 +223,7 @@ export function ConflictManagementScreen() {
methodId: pendingCardId, methodId: pendingCardId,
meta: state.customMethodCardMetaById, meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById, fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked, modalEditUnlocked: true,
draftFieldBlocks, draftFieldBlocks,
customFacetDetailsMatchPreset, customFacetDetailsMatchPreset,
}), }),
@@ -196,7 +231,6 @@ export function ConflictManagementScreen() {
[ [
customFacetDetailsMatchPreset, customFacetDetailsMatchPreset,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked,
pendingCardId, pendingCardId,
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById, state.customMethodCardMetaById,
@@ -206,11 +240,11 @@ export function ConflictManagementScreen() {
const handleCreateModalClose = useCallback(async () => { const handleCreateModalClose = useCallback(async () => {
if ( if (
!(await confirmDiscard( !(await confirmDiscard(
modalEditUnlocked, true,
customizeSnapshotRef.current, customizeSnapshotRef.current,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeSnapshotRef.current?.headerDraft ?? null,
)) ))
) { ) {
return; return;
@@ -238,53 +272,14 @@ export function ConflictManagementScreen() {
setCreateModalOpen(false); setCreateModalOpen(false);
setPendingCardId(null); setPendingCardId(null);
setPendingDraft(null); setPendingDraft(null);
setModalEditUnlocked(false);
setDraftFieldBlocks(null); setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
}, [ }, [
confirmDiscard, confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked,
pendingDraft, pendingDraft,
replaceState, replaceState,
]); ]);
const handleCancelCustomize = useCallback(async () => {
if (!modalEditUnlocked) {
return;
}
const snap = customizeSnapshotRef.current;
if (!snap) {
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
if (
!(await confirmDirtyCustomizeCancel(
snap,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
))
) {
return;
}
setPendingDraft(structuredClone(snap.pendingDraft));
setDraftFieldBlocks(null);
setModalEditUnlocked(false);
customizeSnapshotRef.current = null;
setCustomizeHeaderDraft(null);
}, [
confirmDirtyCustomizeCancel,
customizeHeaderDraft,
draftFieldBlocks,
modalEditUnlocked,
pendingDraft,
]);
const handleRemoveSelectedFromModal = useCallback(async () => { const handleRemoveSelectedFromModal = useCallback(async () => {
if (!pendingCardId || !selectedIds.includes(pendingCardId)) { if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
return; return;
@@ -292,11 +287,11 @@ export function ConflictManagementScreen() {
markCreateFlowInteraction(); markCreateFlowInteraction();
if ( if (
!(await confirmDiscard( !(await confirmDiscard(
modalEditUnlocked, true,
customizeSnapshotRef.current, customizeSnapshotRef.current,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeSnapshotRef.current?.headerDraft ?? null,
)) ))
) { ) {
return; return;
@@ -312,11 +307,9 @@ export function ConflictManagementScreen() {
await handleCreateModalClose(); await handleCreateModalClose();
}, [ }, [
confirmDiscard, confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
handleCreateModalClose, handleCreateModalClose,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked,
pendingDraft, pendingDraft,
pendingCardId, pendingCardId,
selectedIds, selectedIds,
@@ -326,35 +319,36 @@ export function ConflictManagementScreen() {
const handleCustomize = useCallback(() => { const handleCustomize = useCallback(() => {
markCreateFlowInteraction(); markCreateFlowInteraction();
if (!pendingDraft || !pendingCardId) { if (!pendingCardId) {
return; return;
} }
const initialFieldBlocks =
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? structuredClone(
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [],
)
: null;
const method = methodById.get(pendingCardId); const method = methodById.get(pendingCardId);
const meta = state.customMethodCardMetaById?.[pendingCardId]; setWizardInitialValues(
const headerDraft: MethodCardHeaderDraft = { buildMethodCardWizardInitialValues({
title: meta?.label ?? method?.label ?? cm.confirmModal.title, cardId: pendingCardId,
description: fallbackTitle: method?.label ?? cm.confirmModal.title,
meta?.supportText ?? fallbackDescription:
method?.supportText ?? method?.supportText ?? cm.confirmModal.description,
cm.confirmModal.description, meta: state.customMethodCardMetaById,
}; persistedBlocks: state.customMethodCardFieldBlocksById,
setCustomizeHeaderDraft(headerDraft); draftFieldBlocks,
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( facetPrefill: pendingDraft
pendingDraft, ? {
initialFieldBlocks, group: "conflictManagement",
headerDraft, draft: pendingDraft,
headings: cm.sectionHeadings,
}
: undefined,
}),
); );
setDraftFieldBlocks(initialFieldBlocks); setWizardCustomizeCardId(pendingCardId);
setModalEditUnlocked(true); setCreateModalOpen(false);
setAddCustomWizardOpen(true);
}, [ }, [
cm.confirmModal.description, cm.confirmModal.description,
cm.confirmModal.title, cm.confirmModal.title,
cm.sectionHeadings,
draftFieldBlocks,
markCreateFlowInteraction, markCreateFlowInteraction,
methodById, methodById,
pendingCardId, pendingCardId,
@@ -379,9 +373,7 @@ export function ConflictManagementScreen() {
() => conflictManagementPresetFor(newId), () => conflictManagementPresetFor(newId),
); );
const blocksClone = structuredClone( const blocksClone = structuredClone(
modalEditUnlocked && draftFieldBlocks !== null
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? draftFieldBlocks ? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate( : cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
@@ -411,13 +403,12 @@ export function ConflictManagementScreen() {
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setPendingCardId(newId); setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone)); setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false); setDraftFieldBlocks(
setDraftFieldBlocks(null); blocksClone.length > 0 ? structuredClone(blocksClone) : null,
setCustomizeHeaderDraft(null); );
}, [ }, [
draftFieldBlocks, draftFieldBlocks,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix, modalKebabMenu.duplicateTitleSuffix,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
@@ -446,9 +437,7 @@ export function ConflictManagementScreen() {
() => conflictManagementPresetFor(newId), () => conflictManagementPresetFor(newId),
); );
const blocksClone = structuredClone( const blocksClone = structuredClone(
modalEditUnlocked && draftFieldBlocks !== null
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? draftFieldBlocks ? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate( : cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
@@ -478,14 +467,13 @@ export function ConflictManagementScreen() {
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setPendingCardId(newId); setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone)); setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false); setDraftFieldBlocks(
setDraftFieldBlocks(null); blocksClone.length > 0 ? structuredClone(blocksClone) : null,
setCustomizeHeaderDraft(null); );
}, [ }, [
draftFieldBlocks, draftFieldBlocks,
markCreateFlowInteraction, markCreateFlowInteraction,
methodById, methodById,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix, modalKebabMenu.duplicateTitleSuffix,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
@@ -498,7 +486,7 @@ export function ConflictManagementScreen() {
const kebabMenuItems = useMemo( const kebabMenuItems = useMemo(
() => () =>
buildCustomRuleModalKebabMenu(modalKebabMenu, { buildCustomRuleModalKebabMenu(modalKebabMenu, {
showCustomize: !modalEditUnlocked, showCustomize: true,
onCustomize: handleCustomize, onCustomize: handleCustomize,
onDuplicate: onDuplicate:
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId (state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
@@ -518,7 +506,6 @@ export function ConflictManagementScreen() {
handleDuplicatePrefabCard, handleDuplicatePrefabCard,
handleRemoveSelectedFromModal, handleRemoveSelectedFromModal,
isSelectedCardModal, isSelectedCardModal,
modalEditUnlocked,
modalKebabMenu, modalKebabMenu,
pendingCardId, pendingCardId,
state.customMethodCardMetaById, state.customMethodCardMetaById,
@@ -537,7 +524,7 @@ export function ConflictManagementScreen() {
meta?.supportText ?? meta?.supportText ??
method?.supportText ?? method?.supportText ??
cm.confirmModal.description, cm.confirmModal.description,
nextButtonText: modalEditUnlocked nextButtonText: isSelectedCardModal
? saveLabel ? saveLabel
: cm.addApproach.nextButtonText, : cm.addApproach.nextButtonText,
}; };
@@ -549,8 +536,14 @@ export function ConflictManagementScreen() {
}; };
const handleCloseAddWizard = useCallback(() => { const handleCloseAddWizard = useCallback(() => {
const resumeCardId = wizardCustomizeCardId;
setAddCustomWizardOpen(false); setAddCustomWizardOpen(false);
}, []); setWizardCustomizeCardId(null);
setWizardInitialValues(null);
if (resumeCardId && pendingCardId === resumeCardId) {
setCreateModalOpen(true);
}
}, [pendingCardId, wizardCustomizeCardId]);
const handleFinalizeCustomCard = useCallback( const handleFinalizeCustomCard = useCallback(
({ ({
@@ -563,6 +556,42 @@ export function ConflictManagementScreen() {
fieldBlocks: CustomMethodCardFieldBlock[]; fieldBlocks: CustomMethodCardFieldBlock[];
}) => { }) => {
markCreateFlowInteraction(); markCreateFlowInteraction();
const existingId = wizardCustomizeCardId;
if (existingId) {
updateState({
selectedConflictManagementIds: moveFacetSelectionIdToFront(
selectedIds,
existingId,
),
customMethodCardMetaById: methodCardMetaWithCustomizeHeader(
state.customMethodCardMetaById,
existingId,
{ title, description },
),
...(pendingDraft
? {
conflictManagementDetailsById: {
...(state.conflictManagementDetailsById ?? {}),
[existingId]: pendingDraft,
},
}
: {}),
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[existingId]: fieldBlocks,
},
});
if (pendingDraft) {
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
pendingDraft,
fieldBlocks,
{ title, description },
);
}
setDraftFieldBlocks(structuredClone(fieldBlocks));
setAddCustomWizardOpen(false);
return;
}
const id = crypto.randomUUID(); const id = crypto.randomUUID();
updateState({ updateState({
selectedConflictManagementIds: moveFacetSelectionIdToFront( selectedConflictManagementIds: moveFacetSelectionIdToFront(
@@ -585,91 +614,29 @@ export function ConflictManagementScreen() {
}, },
[ [
markCreateFlowInteraction, markCreateFlowInteraction,
pendingDraft,
selectedIds, selectedIds,
state.conflictManagementDetailsById, state.conflictManagementDetailsById,
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById, state.customMethodCardMetaById,
updateState, updateState,
wizardCustomizeCardId,
], ],
); );
const handleCreateModalPrimary = useCallback(() => { const handleCreateModalPrimary = useCallback(() => {
if (!pendingCardId) { if (!pendingCardId) {
handleCreateModalClose(); void handleCreateModalClose();
return; return;
} }
markCreateFlowInteraction(); markCreateFlowInteraction();
if (selectedIds.includes(pendingCardId)) { const persistWizardBlocks =
if (modalEditUnlocked) { modalUsesWizardFieldBlocksBody && draftFieldBlocks !== null;
if (!customizeHeaderDraft) {
return;
}
const nextMeta = methodCardMetaWithCustomizeHeader(
state.customMethodCardMetaById,
pendingCardId,
customizeHeaderDraft,
);
if (
pendingCardId &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
usesWizardFieldBlocksModalBody({
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
draftFieldBlocks,
customFacetDetailsMatchPreset,
})
) {
updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
});
} else if (pendingDraft) {
updateState({
customMethodCardMetaById: nextMeta,
conflictManagementDetailsById: {
...(state.conflictManagementDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
});
}
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
return;
}
if (modalEditUnlocked) { if (selectedIds.includes(pendingCardId)) {
if (!customizeHeaderDraft) { if (persistWizardBlocks) {
return;
}
const nextMeta = methodCardMetaWithCustomizeHeader(
state.customMethodCardMetaById,
pendingCardId,
customizeHeaderDraft,
);
if (
pendingCardId &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
usesWizardFieldBlocksModalBody({
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
draftFieldBlocks,
customFacetDetailsMatchPreset,
})
) {
updateState({ updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: { customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}), ...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []), [pendingCardId]: structuredClone(draftFieldBlocks ?? []),
@@ -677,22 +644,20 @@ export function ConflictManagementScreen() {
}); });
} else if (pendingDraft) { } else if (pendingDraft) {
updateState({ updateState({
customMethodCardMetaById: nextMeta,
conflictManagementDetailsById: { conflictManagementDetailsById: {
...(state.conflictManagementDetailsById ?? {}), ...(state.conflictManagementDetailsById ?? {}),
[pendingCardId]: pendingDraft, [pendingCardId]: pendingDraft,
}, },
}); });
} }
pendingEphemeralDuplicateIdRef.current = null;
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setModalEditUnlocked(false); void handleCreateModalClose();
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return; return;
} }
if (!pendingDraft) { if (!pendingDraft) {
handleCreateModalClose(); void handleCreateModalClose();
return; return;
} }
updateState({ updateState({
@@ -704,15 +669,23 @@ export function ConflictManagementScreen() {
...(state.conflictManagementDetailsById ?? {}), ...(state.conflictManagementDetailsById ?? {}),
[pendingCardId]: pendingDraft, [pendingCardId]: pendingDraft,
}, },
...(persistWizardBlocks
? {
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
}
: {}),
}); });
pendingEphemeralDuplicateIdRef.current = null; pendingEphemeralDuplicateIdRef.current = null;
handleCreateModalClose(); customizeSnapshotRef.current = null;
void handleCreateModalClose();
}, [ }, [
customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
handleCreateModalClose, handleCreateModalClose,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked, modalUsesWizardFieldBlocksBody,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
selectedIds, selectedIds,
@@ -722,19 +695,12 @@ export function ConflictManagementScreen() {
return ( return (
<> <>
<CreateFlowStepShell <CreateFlowCardStackStepShell
variant="wideGridLoosePadding" lockupTitle={title}
contentTopBelowMd="space-800" lockupDescription={description}
stackBusy={!recommendationsReady}
> >
<div className="flex w-full min-w-0 flex-col items-center gap-6"> {recommendationsReady && (
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={title}
description={description}
justification="center"
/>
</div>
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
<CardStack <CardStack
cards={sampleCards} cards={sampleCards}
selectedIds={selectedIds} selectedIds={selectedIds}
@@ -751,40 +717,19 @@ export function ConflictManagementScreen() {
compactDesktopLayout="pyramidFive" compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"} headerLockupSize={mdUp ? "L" : "M"}
/> />
</div> )}
</div> </CreateFlowCardStackStepShell>
<Create <Create
isOpen={createModalOpen} isOpen={createModalOpen}
onClose={handleCreateModalClose} onClose={handleCreateModalClose}
headerContent={
modalEditUnlocked && customizeHeaderDraft ? (
<MethodCardCustomizeModalHeader
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
titleValue={customizeHeaderDraft.title}
descriptionValue={customizeHeaderDraft.description}
onTitleChange={(title) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, title } : null,
)
}
onDescriptionChange={(description) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, description } : null,
)
}
/>
) : undefined
}
onNext={handleCreateModalPrimary} onNext={handleCreateModalPrimary}
title={modalConfig.title} title={modalConfig.title}
description={modalConfig.description} description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText} nextButtonText={modalConfig.nextButtonText}
showBackButton={modalEditUnlocked} showBackButton={false}
onBack={handleCancelCustomize}
backButtonText={modalKebabMenu.cancelCustomize}
showNextButton={showMethodModalPrimary} showNextButton={showMethodModalPrimary}
showRemoveButton={isSelectedCardModal}
onRemove={handleRemoveSelectedFromModal}
backdropVariant="blurredYellow" backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel} kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
@@ -796,14 +741,14 @@ export function ConflictManagementScreen() {
cardId={pendingCardId} cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById} blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={ blocksOverride={
modalEditUnlocked && draftFieldBlocks !== null draftFieldBlocks !== null ? draftFieldBlocks : undefined
? draftFieldBlocks
: undefined
} }
policyMeta={state.customMethodCardMetaById?.[pendingCardId]} policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked} showPolicyContentLockupWhenNoBlocks={
draftFieldBlocks === null || draftFieldBlocks.length === 0
}
onFieldBlocksChange={ onFieldBlocksChange={
fieldsLocked draftFieldBlocks === null
? undefined ? undefined
: (next) => setDraftFieldBlocks(next) : (next) => setDraftFieldBlocks(next)
} }
@@ -812,15 +757,14 @@ export function ConflictManagementScreen() {
<ConflictManagementEditFields <ConflictManagementEditFields
value={pendingDraft} value={pendingDraft}
onChange={handleDraftChange} onChange={handleDraftChange}
readOnly={fieldsLocked}
/> />
) )
) : null} ) : null}
</Create> </Create>
</CreateFlowStepShell>
<CustomMethodCardWizard <CustomMethodCardWizard
isOpen={addCustomWizardOpen} isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard} onClose={handleCloseAddWizard}
initialValues={wizardInitialValues}
onFinalize={handleFinalizeCustomCard} onFinalize={handleFinalizeCustomCard}
onPersistCustomUploadFile={(file) => onPersistCustomUploadFile={(file) =>
uploadCreateFlowFile(file, "customMethodAttachment") uploadCreateFlowFile(file, "customMethodAttachment")
@@ -17,17 +17,13 @@ import { useState, useCallback, useMemo, useRef } from "react";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowCardStackStepShell } from "../../components/CreateFlowCardStackStepShell";
import CardStack from "../../../../components/cards/CardStack"; import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create"; import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton"; import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import { MembershipMethodEditFields } from "../../components/methodEditFields"; import { MembershipMethodEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard"; import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
@@ -50,20 +46,21 @@ import type { MembershipMethodDetailEntry } from "../../types";
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody"; import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu"; import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch"; import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
isMethodCardCustomizeUnloadBlocked,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession"; } from "../../../../../lib/create/methodCardCustomizeSession";
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
export function MembershipMethodsScreen() { export function MembershipMethodsScreen() {
const m = useMessages(); const m = useMessages();
const mem = m.create.customRule.membership; const mem = m.create.customRule.membership;
const modalKebabMenu = m.create.customRule.modalKebabMenu; const modalKebabMenu = m.create.customRule.modalKebabMenu;
const mdUp = useCreateFlowMdUp(); const mdUp = useCreateFlowMdUp();
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } = const { confirmDiscard, confirmDialog } = useDiscardCustomizeConfirm();
useDiscardCustomizeConfirm();
const { state, updateState, replaceState, markCreateFlowInteraction } = const { state, updateState, replaceState, markCreateFlowInteraction } =
useCreateFlow(); useCreateFlow();
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null); const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
@@ -76,12 +73,24 @@ export function MembershipMethodsScreen() {
const [pendingDraft, setPendingDraft] = const [pendingDraft, setPendingDraft] =
useState<MembershipMethodDetailEntry | null>(null); useState<MembershipMethodDetailEntry | null>(null);
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false); const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
const [modalEditUnlocked, setModalEditUnlocked] = useState(false); const [wizardCustomizeCardId, setWizardCustomizeCardId] = useState<
string | null
>(null);
const [wizardInitialValues, setWizardInitialValues] =
useState<MethodCardWizardInitialValues | null>(null);
const [draftFieldBlocks, setDraftFieldBlocks] = useState< const [draftFieldBlocks, setDraftFieldBlocks] = useState<
CustomMethodCardFieldBlock[] | null CustomMethodCardFieldBlock[] | null
>(null); >(null);
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
useState<MethodCardHeaderDraft | null>(null); useBeforeUnloadGuard(
isMethodCardCustomizeUnloadBlocked(
createModalOpen,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeSnapshotRef.current?.headerDraft ?? null,
),
);
const selectedIds = state.selectedMembershipMethodIds ?? []; const selectedIds = state.selectedMembershipMethodIds ?? [];
@@ -95,7 +104,8 @@ export function MembershipMethodsScreen() {
[mem.methods, selectedIds, state.customMethodCardMetaById], [mem.methods, selectedIds, state.customMethodCardMetaById],
); );
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering( const { sampleCards, compactCardIds, methodById, recommendationsReady } =
useMethodCardDeckOrdering(
"membership", "membership",
mergedMethods, mergedMethods,
selectedIds, selectedIds,
@@ -103,6 +113,8 @@ export function MembershipMethodsScreen() {
const handleOpenAddWizard = useCallback(() => { const handleOpenAddWizard = useCallback(() => {
markCreateFlowInteraction(); markCreateFlowInteraction();
setWizardCustomizeCardId(null);
setWizardInitialValues(null);
setAddCustomWizardOpen(true); setAddCustomWizardOpen(true);
}, [markCreateFlowInteraction]); }, [markCreateFlowInteraction]);
@@ -140,15 +152,40 @@ export function MembershipMethodsScreen() {
const handleCardClick = useCallback( const handleCardClick = useCallback(
(id: string) => { (id: string) => {
markCreateFlowInteraction(); markCreateFlowInteraction();
customizeSnapshotRef.current = null; const draft = seedDraft(id);
setModalEditUnlocked(false); const persistedBlocks = state.customMethodCardFieldBlocksById?.[id];
setDraftFieldBlocks(null); const initialBlocks =
setCustomizeHeaderDraft(null); Array.isArray(persistedBlocks) && persistedBlocks.length > 0
? structuredClone(persistedBlocks)
: null;
const method = methodById.get(id);
const meta = state.customMethodCardMetaById?.[id];
const headerDraft: MethodCardHeaderDraft = {
title: meta?.label ?? method?.label ?? mem.confirmModal.title,
description:
meta?.supportText ??
method?.supportText ??
mem.confirmModal.description,
};
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
draft,
initialBlocks,
headerDraft,
);
setPendingCardId(id); setPendingCardId(id);
setPendingDraft(seedDraft(id)); setPendingDraft(draft);
setDraftFieldBlocks(initialBlocks);
setCreateModalOpen(true); setCreateModalOpen(true);
}, },
[markCreateFlowInteraction, seedDraft], [
mem.confirmModal.description,
mem.confirmModal.title,
markCreateFlowInteraction,
methodById,
seedDraft,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
],
); );
const handleDraftChange = useCallback( const handleDraftChange = useCallback(
@@ -161,9 +198,7 @@ export function MembershipMethodsScreen() {
const isSelectedCardModal = const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId); pendingCardId !== null && selectedIds.includes(pendingCardId);
const fieldsLocked = !modalEditUnlocked; const showMethodModalPrimary = true;
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
const customFacetDetailsMatchPreset = useMemo(() => { const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false; if (!pendingCardId || !pendingDraft) return false;
@@ -185,7 +220,7 @@ export function MembershipMethodsScreen() {
methodId: pendingCardId, methodId: pendingCardId,
meta: state.customMethodCardMetaById, meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById, fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked, modalEditUnlocked: true,
draftFieldBlocks, draftFieldBlocks,
customFacetDetailsMatchPreset, customFacetDetailsMatchPreset,
}), }),
@@ -193,7 +228,6 @@ export function MembershipMethodsScreen() {
[ [
customFacetDetailsMatchPreset, customFacetDetailsMatchPreset,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked,
pendingCardId, pendingCardId,
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById, state.customMethodCardMetaById,
@@ -203,11 +237,11 @@ export function MembershipMethodsScreen() {
const handleCreateModalClose = useCallback(async () => { const handleCreateModalClose = useCallback(async () => {
if ( if (
!(await confirmDiscard( !(await confirmDiscard(
modalEditUnlocked, true,
customizeSnapshotRef.current, customizeSnapshotRef.current,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeSnapshotRef.current?.headerDraft ?? null,
)) ))
) { ) {
return; return;
@@ -235,53 +269,14 @@ export function MembershipMethodsScreen() {
setCreateModalOpen(false); setCreateModalOpen(false);
setPendingCardId(null); setPendingCardId(null);
setPendingDraft(null); setPendingDraft(null);
setModalEditUnlocked(false);
setDraftFieldBlocks(null); setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
}, [ }, [
confirmDiscard, confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked,
pendingDraft, pendingDraft,
replaceState, replaceState,
]); ]);
const handleCancelCustomize = useCallback(async () => {
if (!modalEditUnlocked) {
return;
}
const snap = customizeSnapshotRef.current;
if (!snap) {
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
if (
!(await confirmDirtyCustomizeCancel(
snap,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
))
) {
return;
}
setPendingDraft(structuredClone(snap.pendingDraft));
setDraftFieldBlocks(null);
setModalEditUnlocked(false);
customizeSnapshotRef.current = null;
setCustomizeHeaderDraft(null);
}, [
confirmDirtyCustomizeCancel,
customizeHeaderDraft,
draftFieldBlocks,
modalEditUnlocked,
pendingDraft,
]);
const handleRemoveSelectedFromModal = useCallback(async () => { const handleRemoveSelectedFromModal = useCallback(async () => {
if (!pendingCardId || !selectedIds.includes(pendingCardId)) { if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
return; return;
@@ -289,11 +284,11 @@ export function MembershipMethodsScreen() {
markCreateFlowInteraction(); markCreateFlowInteraction();
if ( if (
!(await confirmDiscard( !(await confirmDiscard(
modalEditUnlocked, true,
customizeSnapshotRef.current, customizeSnapshotRef.current,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeSnapshotRef.current?.headerDraft ?? null,
)) ))
) { ) {
return; return;
@@ -305,11 +300,9 @@ export function MembershipMethodsScreen() {
await handleCreateModalClose(); await handleCreateModalClose();
}, [ }, [
confirmDiscard, confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
handleCreateModalClose, handleCreateModalClose,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked,
pendingDraft, pendingDraft,
pendingCardId, pendingCardId,
selectedIds, selectedIds,
@@ -319,35 +312,36 @@ export function MembershipMethodsScreen() {
const handleCustomize = useCallback(() => { const handleCustomize = useCallback(() => {
markCreateFlowInteraction(); markCreateFlowInteraction();
if (!pendingDraft || !pendingCardId) { if (!pendingCardId) {
return; return;
} }
const initialFieldBlocks =
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? structuredClone(
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [],
)
: null;
const method = methodById.get(pendingCardId); const method = methodById.get(pendingCardId);
const meta = state.customMethodCardMetaById?.[pendingCardId]; setWizardInitialValues(
const headerDraft: MethodCardHeaderDraft = { buildMethodCardWizardInitialValues({
title: meta?.label ?? method?.label ?? mem.confirmModal.title, cardId: pendingCardId,
description: fallbackTitle: method?.label ?? mem.confirmModal.title,
meta?.supportText ?? fallbackDescription:
method?.supportText ?? method?.supportText ?? mem.confirmModal.description,
mem.confirmModal.description, meta: state.customMethodCardMetaById,
}; persistedBlocks: state.customMethodCardFieldBlocksById,
setCustomizeHeaderDraft(headerDraft); draftFieldBlocks,
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( facetPrefill: pendingDraft
pendingDraft, ? {
initialFieldBlocks, group: "membership",
headerDraft, draft: pendingDraft,
headings: mem.sectionHeadings,
}
: undefined,
}),
); );
setDraftFieldBlocks(initialFieldBlocks); setWizardCustomizeCardId(pendingCardId);
setModalEditUnlocked(true); setCreateModalOpen(false);
setAddCustomWizardOpen(true);
}, [ }, [
mem.confirmModal.description, mem.confirmModal.description,
mem.confirmModal.title, mem.confirmModal.title,
mem.sectionHeadings,
draftFieldBlocks,
markCreateFlowInteraction, markCreateFlowInteraction,
methodById, methodById,
pendingCardId, pendingCardId,
@@ -372,9 +366,7 @@ export function MembershipMethodsScreen() {
() => membershipPresetFor(newId), () => membershipPresetFor(newId),
); );
const blocksClone = structuredClone( const blocksClone = structuredClone(
modalEditUnlocked && draftFieldBlocks !== null
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? draftFieldBlocks ? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate( : cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
@@ -404,13 +396,12 @@ export function MembershipMethodsScreen() {
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setPendingCardId(newId); setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone)); setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false); setDraftFieldBlocks(
setDraftFieldBlocks(null); blocksClone.length > 0 ? structuredClone(blocksClone) : null,
setCustomizeHeaderDraft(null); );
}, [ }, [
draftFieldBlocks, draftFieldBlocks,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix, modalKebabMenu.duplicateTitleSuffix,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
@@ -439,9 +430,7 @@ export function MembershipMethodsScreen() {
() => membershipPresetFor(newId), () => membershipPresetFor(newId),
); );
const blocksClone = structuredClone( const blocksClone = structuredClone(
modalEditUnlocked && draftFieldBlocks !== null
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? draftFieldBlocks ? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate( : cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
@@ -471,14 +460,13 @@ export function MembershipMethodsScreen() {
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setPendingCardId(newId); setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone)); setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false); setDraftFieldBlocks(
setDraftFieldBlocks(null); blocksClone.length > 0 ? structuredClone(blocksClone) : null,
setCustomizeHeaderDraft(null); );
}, [ }, [
draftFieldBlocks, draftFieldBlocks,
markCreateFlowInteraction, markCreateFlowInteraction,
methodById, methodById,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix, modalKebabMenu.duplicateTitleSuffix,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
@@ -491,7 +479,7 @@ export function MembershipMethodsScreen() {
const kebabMenuItems = useMemo( const kebabMenuItems = useMemo(
() => () =>
buildCustomRuleModalKebabMenu(modalKebabMenu, { buildCustomRuleModalKebabMenu(modalKebabMenu, {
showCustomize: !modalEditUnlocked, showCustomize: true,
onCustomize: handleCustomize, onCustomize: handleCustomize,
onDuplicate: onDuplicate:
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId (state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
@@ -511,7 +499,6 @@ export function MembershipMethodsScreen() {
handleDuplicatePrefabCard, handleDuplicatePrefabCard,
handleRemoveSelectedFromModal, handleRemoveSelectedFromModal,
isSelectedCardModal, isSelectedCardModal,
modalEditUnlocked,
modalKebabMenu, modalKebabMenu,
pendingCardId, pendingCardId,
state.customMethodCardMetaById, state.customMethodCardMetaById,
@@ -530,7 +517,7 @@ export function MembershipMethodsScreen() {
meta?.supportText ?? meta?.supportText ??
method?.supportText ?? method?.supportText ??
mem.confirmModal.description, mem.confirmModal.description,
nextButtonText: modalEditUnlocked nextButtonText: isSelectedCardModal
? saveLabel ? saveLabel
: mem.addPlatform.nextButtonText, : mem.addPlatform.nextButtonText,
}; };
@@ -542,8 +529,14 @@ export function MembershipMethodsScreen() {
}; };
const handleCloseAddWizard = useCallback(() => { const handleCloseAddWizard = useCallback(() => {
const resumeCardId = wizardCustomizeCardId;
setAddCustomWizardOpen(false); setAddCustomWizardOpen(false);
}, []); setWizardCustomizeCardId(null);
setWizardInitialValues(null);
if (resumeCardId && pendingCardId === resumeCardId) {
setCreateModalOpen(true);
}
}, [pendingCardId, wizardCustomizeCardId]);
const handleFinalizeCustomCard = useCallback( const handleFinalizeCustomCard = useCallback(
({ ({
@@ -556,6 +549,42 @@ export function MembershipMethodsScreen() {
fieldBlocks: CustomMethodCardFieldBlock[]; fieldBlocks: CustomMethodCardFieldBlock[];
}) => { }) => {
markCreateFlowInteraction(); markCreateFlowInteraction();
const existingId = wizardCustomizeCardId;
if (existingId) {
updateState({
selectedMembershipMethodIds: moveFacetSelectionIdToFront(
selectedIds,
existingId,
),
customMethodCardMetaById: methodCardMetaWithCustomizeHeader(
state.customMethodCardMetaById,
existingId,
{ title, description },
),
...(pendingDraft
? {
membershipMethodDetailsById: {
...(state.membershipMethodDetailsById ?? {}),
[existingId]: pendingDraft,
},
}
: {}),
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[existingId]: fieldBlocks,
},
});
if (pendingDraft) {
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
pendingDraft,
fieldBlocks,
{ title, description },
);
}
setDraftFieldBlocks(structuredClone(fieldBlocks));
setAddCustomWizardOpen(false);
return;
}
const id = crypto.randomUUID(); const id = crypto.randomUUID();
updateState({ updateState({
selectedMembershipMethodIds: moveFacetSelectionIdToFront( selectedMembershipMethodIds: moveFacetSelectionIdToFront(
@@ -578,91 +607,29 @@ export function MembershipMethodsScreen() {
}, },
[ [
markCreateFlowInteraction, markCreateFlowInteraction,
pendingDraft,
selectedIds, selectedIds,
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById, state.customMethodCardMetaById,
state.membershipMethodDetailsById, state.membershipMethodDetailsById,
updateState, updateState,
wizardCustomizeCardId,
], ],
); );
const handleCreateModalPrimary = useCallback(() => { const handleCreateModalPrimary = useCallback(() => {
if (!pendingCardId) { if (!pendingCardId) {
handleCreateModalClose(); void handleCreateModalClose();
return; return;
} }
markCreateFlowInteraction(); markCreateFlowInteraction();
if (selectedIds.includes(pendingCardId)) { const persistWizardBlocks =
if (modalEditUnlocked) { modalUsesWizardFieldBlocksBody && draftFieldBlocks !== null;
if (!customizeHeaderDraft) {
return;
}
const nextMeta = methodCardMetaWithCustomizeHeader(
state.customMethodCardMetaById,
pendingCardId,
customizeHeaderDraft,
);
if (
pendingCardId &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
usesWizardFieldBlocksModalBody({
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
draftFieldBlocks,
customFacetDetailsMatchPreset,
})
) {
updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
});
} else if (pendingDraft) {
updateState({
customMethodCardMetaById: nextMeta,
membershipMethodDetailsById: {
...(state.membershipMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
});
}
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
return;
}
if (modalEditUnlocked) { if (selectedIds.includes(pendingCardId)) {
if (!customizeHeaderDraft) { if (persistWizardBlocks) {
return;
}
const nextMeta = methodCardMetaWithCustomizeHeader(
state.customMethodCardMetaById,
pendingCardId,
customizeHeaderDraft,
);
if (
pendingCardId &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
usesWizardFieldBlocksModalBody({
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
draftFieldBlocks,
customFacetDetailsMatchPreset,
})
) {
updateState({ updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: { customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}), ...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []), [pendingCardId]: structuredClone(draftFieldBlocks ?? []),
@@ -670,22 +637,20 @@ export function MembershipMethodsScreen() {
}); });
} else if (pendingDraft) { } else if (pendingDraft) {
updateState({ updateState({
customMethodCardMetaById: nextMeta,
membershipMethodDetailsById: { membershipMethodDetailsById: {
...(state.membershipMethodDetailsById ?? {}), ...(state.membershipMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft, [pendingCardId]: pendingDraft,
}, },
}); });
} }
pendingEphemeralDuplicateIdRef.current = null;
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setModalEditUnlocked(false); void handleCreateModalClose();
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return; return;
} }
if (!pendingDraft) { if (!pendingDraft) {
handleCreateModalClose(); void handleCreateModalClose();
return; return;
} }
updateState({ updateState({
@@ -697,15 +662,23 @@ export function MembershipMethodsScreen() {
...(state.membershipMethodDetailsById ?? {}), ...(state.membershipMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft, [pendingCardId]: pendingDraft,
}, },
...(persistWizardBlocks
? {
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
}
: {}),
}); });
pendingEphemeralDuplicateIdRef.current = null; pendingEphemeralDuplicateIdRef.current = null;
handleCreateModalClose(); customizeSnapshotRef.current = null;
void handleCreateModalClose();
}, [ }, [
customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
handleCreateModalClose, handleCreateModalClose,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked, modalUsesWizardFieldBlocksBody,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
selectedIds, selectedIds,
@@ -715,19 +688,12 @@ export function MembershipMethodsScreen() {
return ( return (
<> <>
<CreateFlowStepShell <CreateFlowCardStackStepShell
variant="wideGridLoosePadding" lockupTitle={title}
contentTopBelowMd="space-800" lockupDescription={description}
stackBusy={!recommendationsReady}
> >
<div className="flex w-full min-w-0 flex-col items-center gap-6"> {recommendationsReady && (
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={title}
description={description}
justification="center"
/>
</div>
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
<CardStack <CardStack
cards={sampleCards} cards={sampleCards}
selectedIds={selectedIds} selectedIds={selectedIds}
@@ -744,40 +710,19 @@ export function MembershipMethodsScreen() {
compactDesktopLayout="pyramidFive" compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"} headerLockupSize={mdUp ? "L" : "M"}
/> />
</div> )}
</div> </CreateFlowCardStackStepShell>
<Create <Create
isOpen={createModalOpen} isOpen={createModalOpen}
onClose={handleCreateModalClose} onClose={handleCreateModalClose}
headerContent={
modalEditUnlocked && customizeHeaderDraft ? (
<MethodCardCustomizeModalHeader
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
titleValue={customizeHeaderDraft.title}
descriptionValue={customizeHeaderDraft.description}
onTitleChange={(title) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, title } : null,
)
}
onDescriptionChange={(description) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, description } : null,
)
}
/>
) : undefined
}
onNext={handleCreateModalPrimary} onNext={handleCreateModalPrimary}
title={modalConfig.title} title={modalConfig.title}
description={modalConfig.description} description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText} nextButtonText={modalConfig.nextButtonText}
showBackButton={modalEditUnlocked} showBackButton={false}
onBack={handleCancelCustomize}
backButtonText={modalKebabMenu.cancelCustomize}
showNextButton={showMethodModalPrimary} showNextButton={showMethodModalPrimary}
showRemoveButton={isSelectedCardModal}
onRemove={handleRemoveSelectedFromModal}
backdropVariant="blurredYellow" backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel} kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
@@ -789,14 +734,14 @@ export function MembershipMethodsScreen() {
cardId={pendingCardId} cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById} blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={ blocksOverride={
modalEditUnlocked && draftFieldBlocks !== null draftFieldBlocks !== null ? draftFieldBlocks : undefined
? draftFieldBlocks
: undefined
} }
policyMeta={state.customMethodCardMetaById?.[pendingCardId]} policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked} showPolicyContentLockupWhenNoBlocks={
draftFieldBlocks === null || draftFieldBlocks.length === 0
}
onFieldBlocksChange={ onFieldBlocksChange={
fieldsLocked draftFieldBlocks === null
? undefined ? undefined
: (next) => setDraftFieldBlocks(next) : (next) => setDraftFieldBlocks(next)
} }
@@ -805,15 +750,14 @@ export function MembershipMethodsScreen() {
<MembershipMethodEditFields <MembershipMethodEditFields
value={pendingDraft} value={pendingDraft}
onChange={handleDraftChange} onChange={handleDraftChange}
readOnly={fieldsLocked}
/> />
) )
) : null} ) : null}
</Create> </Create>
</CreateFlowStepShell>
<CustomMethodCardWizard <CustomMethodCardWizard
isOpen={addCustomWizardOpen} isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard} onClose={handleCloseAddWizard}
initialValues={wizardInitialValues}
onFinalize={handleFinalizeCustomCard} onFinalize={handleFinalizeCustomCard}
onPersistCustomUploadFile={(file) => onPersistCustomUploadFile={(file) =>
uploadCreateFlowFile(file, "customMethodAttachment") uploadCreateFlowFile(file, "customMethodAttachment")
@@ -16,6 +16,8 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { import {
CREATE_FLOW_MD_UP_GRID_CELL_CLASS, CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
CREATE_FLOW_PAGE_GUTTER_CLASS,
CREATE_FLOW_SCROLL_REGION_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS, CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "../../components/createFlowLayoutTokens"; } from "../../components/createFlowLayoutTokens";
import { import {
@@ -163,7 +165,7 @@ export function CompletedScreen() {
<> <>
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[var(--color-teal-teal50,#c9fef9)] md:h-full"> <div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[var(--color-teal-teal50,#c9fef9)] md:h-full">
<div <div
className={`mx-auto grid min-h-0 w-full grid-cols-1 gap-4 px-5 max-md:max-w-[639px] max-md:overflow-y-auto max-md:overscroll-y-contain max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:grid-rows-1 md:items-stretch md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] md:overflow-hidden md:px-12 md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`} className={`mx-auto grid min-h-0 w-full grid-cols-1 gap-4 ${CREATE_FLOW_PAGE_GUTTER_CLASS} max-md:overflow-y-auto max-md:overscroll-y-contain max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:grid-rows-1 md:items-stretch md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] md:overflow-hidden md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
> >
<div <div
className={`flex flex-col justify-start max-md:min-h-min max-md:overflow-visible min-h-0 overflow-hidden md:justify-center md:pb-8 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`} className={`flex flex-col justify-start max-md:min-h-min max-md:overflow-visible min-h-0 overflow-hidden md:justify-center md:pb-8 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
@@ -177,7 +179,7 @@ export function CompletedScreen() {
/> />
</div> </div>
<div <div
className={`scrollbar-hide relative flex min-h-0 flex-col self-stretch overflow-x-hidden md:max-h-full md:overflow-y-auto ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`} className={`relative flex min-h-0 flex-col self-stretch overflow-x-hidden md:max-h-full md:overflow-y-auto ${CREATE_FLOW_SCROLL_REGION_CLASS} ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
> >
<div <div
className="pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 bg-gradient-to-b from-[var(--color-teal-teal50,#c9fef9)]/55 from-0% via-[var(--color-teal-teal50,#c9fef9)]/20 via-50% to-transparent md:block" className="pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 bg-gradient-to-b from-[var(--color-teal-teal50,#c9fef9)]/55 from-0% via-[var(--color-teal-teal50,#c9fef9)]/20 via-50% to-transparent md:block"
@@ -2,24 +2,108 @@
* Step → screen component map (Linear CR-92 §3). Keeps {@link CreateFlowScreenView} * Step → screen component map (Linear CR-92 §3). Keeps {@link CreateFlowScreenView}
* thin; pair with {@link CREATE_FLOW_SCREEN_REGISTRY} metadata in tests/docs so * thin; pair with {@link CREATE_FLOW_SCREEN_REGISTRY} metadata in tests/docs so
* new steps do not drift. * new steps do not drift.
*
* `InformationalScreen` is statically imported because it is the entry step;
* every other screen is lazy-loaded so visiting `/create/informational` does
* not pull the rest of the wizard into the initial bundle.
*/ */
import dynamic from "next/dynamic";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { CreateFlowStep } from "../types"; import type { CreateFlowStep } from "../types";
import { InformationalScreen } from "./informational/InformationalScreen"; import { InformationalScreen } from "./informational/InformationalScreen";
import { CreateFlowTextFieldScreen } from "./text/CreateFlowTextFieldScreen";
import { CommunitySizeSelectScreen } from "./select/CommunitySizeSelectScreen"; const CreateFlowTextFieldScreen = dynamic(
import { CommunityStructureSelectScreen } from "./select/CommunityStructureSelectScreen"; () =>
import { CoreValuesSelectScreen } from "./select/CoreValuesSelectScreen"; import("./text/CreateFlowTextFieldScreen").then((m) => ({
import { ConfirmStakeholdersScreen } from "./select/ConfirmStakeholdersScreen"; default: m.CreateFlowTextFieldScreen,
import { CommunityUploadScreen } from "./upload/CommunityUploadScreen"; })),
import { CommunityReviewScreen } from "./review/CommunityReviewScreen"; { loading: () => null },
import { FinalReviewScreen } from "./review/FinalReviewScreen"; );
import { CommunicationMethodsScreen } from "./card/CommunicationMethodsScreen"; const CommunitySizeSelectScreen = dynamic(
import { MembershipMethodsScreen } from "./card/MembershipMethodsScreen"; () =>
import { ConflictManagementScreen } from "./card/ConflictManagementScreen"; import("./select/CommunitySizeSelectScreen").then((m) => ({
import { DecisionApproachesScreen } from "./right-rail/DecisionApproachesScreen"; default: m.CommunitySizeSelectScreen,
import { CompletedScreen } from "./completed/CompletedScreen"; })),
{ loading: () => null },
);
const CommunityStructureSelectScreen = dynamic(
() =>
import("./select/CommunityStructureSelectScreen").then((m) => ({
default: m.CommunityStructureSelectScreen,
})),
{ loading: () => null },
);
const CoreValuesSelectScreen = dynamic(
() =>
import("./select/CoreValuesSelectScreen").then((m) => ({
default: m.CoreValuesSelectScreen,
})),
{ loading: () => null },
);
const ConfirmStakeholdersScreen = dynamic(
() =>
import("./select/ConfirmStakeholdersScreen").then((m) => ({
default: m.ConfirmStakeholdersScreen,
})),
{ loading: () => null },
);
const CommunityUploadScreen = dynamic(
() =>
import("./upload/CommunityUploadScreen").then((m) => ({
default: m.CommunityUploadScreen,
})),
{ loading: () => null },
);
const CommunityReviewScreen = dynamic(
() =>
import("./review/CommunityReviewScreen").then((m) => ({
default: m.CommunityReviewScreen,
})),
{ loading: () => null },
);
const FinalReviewScreen = dynamic(
() =>
import("./review/FinalReviewScreen").then((m) => ({
default: m.FinalReviewScreen,
})),
{ loading: () => null },
);
const CommunicationMethodsScreen = dynamic(
() =>
import("./card/CommunicationMethodsScreen").then((m) => ({
default: m.CommunicationMethodsScreen,
})),
{ loading: () => null },
);
const MembershipMethodsScreen = dynamic(
() =>
import("./card/MembershipMethodsScreen").then((m) => ({
default: m.MembershipMethodsScreen,
})),
{ loading: () => null },
);
const ConflictManagementScreen = dynamic(
() =>
import("./card/ConflictManagementScreen").then((m) => ({
default: m.ConflictManagementScreen,
})),
{ loading: () => null },
);
const DecisionApproachesScreen = dynamic(
() =>
import("./right-rail/DecisionApproachesScreen").then((m) => ({
default: m.DecisionApproachesScreen,
})),
{ loading: () => null },
);
const CompletedScreen = dynamic(
() =>
import("./completed/CompletedScreen").then((m) => ({
default: m.CompletedScreen,
})),
{ loading: () => null },
);
export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode { export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
switch (screenId) { switch (screenId) {
@@ -31,6 +115,7 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
messageNamespace="create.community.communityName" messageNamespace="create.community.communityName"
stateField="title" stateField="title"
maxLength={48} maxLength={48}
required
/> />
); );
case "community-structure": case "community-structure":
@@ -42,6 +127,7 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
stateField="communityContext" stateField="communityContext"
maxLength={200} maxLength={200}
mainAlign="center" mainAlign="center"
multiline
/> />
); );
case "community-size": case "community-size":
@@ -1,6 +1,5 @@
"use client"; "use client";
import type { ReactNode } from "react";
import NumberedList from "../../../../components/type/NumberedList"; import NumberedList from "../../../../components/type/NumberedList";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
@@ -31,22 +30,6 @@ export function InformationalScreen() {
}, },
]; ];
const description: ReactNode = (
<>
{copy.descriptionLead}{" "}
<a
href="#"
className="font-inter font-normal text-[var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] cursor-pointer"
onClick={(e) => {
e.preventDefault();
}}
>
{copy.workshopLabel}
</a>{" "}
{copy.descriptionTrail}
</>
);
return ( return (
<CreateFlowStepShell <CreateFlowStepShell
variant="centeredNarrow" variant="centeredNarrow"
@@ -57,7 +40,7 @@ export function InformationalScreen() {
> >
<CreateFlowHeaderLockup <CreateFlowHeaderLockup
title={copy.title} title={copy.title}
description={description} description={copy.description}
justification="left" justification="left"
/> />
<NumberedList items={items} size={mdUp ? "M" : "S"} /> <NumberedList items={items} size={mdUp ? "M" : "S"} />
@@ -1,8 +1,9 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Rule from "../../../../components/cards/Rule"; import Rule from "../../../../components/cards/Rule";
import Button from "../../../../components/buttons/Button";
import { useTranslation } from "../../../../contexts/MessagesContext"; import { useTranslation } from "../../../../contexts/MessagesContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
@@ -17,7 +18,7 @@ import {
vectorMarkPath, vectorMarkPath,
} from "../../../../../lib/assetUtils"; } from "../../../../../lib/assetUtils";
import { methodSectionsPinsForHydratedSelections } from "../../../../../lib/create/publishedDocumentToCreateFlowState"; import { methodSectionsPinsForHydratedSelections } from "../../../../../lib/create/publishedDocumentToCreateFlowState";
import { createFlowStepPath } from "../../utils/createFlowPaths"; import { CREATE_ROUTES, createFlowStepPath } from "../../utils/createFlowPaths";
/** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */ /** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */
export function CommunityReviewScreen() { export function CommunityReviewScreen() {
@@ -25,16 +26,22 @@ export function CommunityReviewScreen() {
const lgUp = useCreateFlowLgUp(); const lgUp = useCreateFlowLgUp();
const t = useTranslation("create.community.review"); const t = useTranslation("create.community.review");
const { state, updateState } = useCreateFlow(); const { state, updateState } = useCreateFlow();
/**
* Server layout has an empty context; the client layout may already hold a
* named draft. Defer empty-vs-congrats until after mount so the first paint
* matches SSR (`null`) instead of swapping HeaderLockup titles.
*/
const [reviewReady, setReviewReady] = useState(false);
useEffect(() => {
setReviewReady(true);
}, []);
/** /**
* If the user picked "Customize" or "Use without changes" from a template * If the user picked **Customize** from a template before finishing community
* before entering community stage, we pinned `pendingTemplateAction` so * stage, we pinned `pendingTemplateAction` so this screen can skip itself
* this screen can skip itself — they already expressed their intent, no * and `replace` to `core-values`. **Use without changes** does not skip
* reason to make them re-pick from the review footer. We `replace` (not * identity: if they land here with that pin, send them to `community-name`
* `push`) so Back from the destination goes to `community-save` instead of * and leave the pin so name → description → photo stay in the skip list.
* bouncing through here again. The action is cleared synchronously via
* `updateState` to guarantee the redirect only fires once: later visits to
* `/create/review` (e.g. navigating here directly) render normally.
* *
* Ref guard covers React 18 StrictMode's double-mount in dev so we don't * Ref guard covers React 18 StrictMode's double-mount in dev so we don't
* fire `router.replace` twice on the same transition. * fire `router.replace` twice on the same transition.
@@ -44,20 +51,19 @@ export function CommunityReviewScreen() {
if (firedRedirectRef.current) return; if (firedRedirectRef.current) return;
const pending = state.pendingTemplateAction; const pending = state.pendingTemplateAction;
if (!pending) return; if (!pending) return;
const target = if (pending.mode !== "customize") {
pending.mode === "customize"
? createFlowStepPath("core-values")
: createFlowStepPath("confirm-stakeholders");
firedRedirectRef.current = true; firedRedirectRef.current = true;
const pinMerge = router.replace(createFlowStepPath("community-name"));
pending.mode === "customize" return;
? { }
const target = createFlowStepPath("core-values");
firedRedirectRef.current = true;
const pinMerge = {
methodSectionsPinCommitted: { methodSectionsPinCommitted: {
...state.methodSectionsPinCommitted, ...state.methodSectionsPinCommitted,
...methodSectionsPinsForHydratedSelections(state), ...methodSectionsPinsForHydratedSelections(state),
}, },
} };
: {};
updateState({ pendingTemplateAction: undefined, ...pinMerge }); updateState({ pendingTemplateAction: undefined, ...pinMerge });
router.replace(target); router.replace(target);
}, [ }, [
@@ -74,11 +80,10 @@ export function CommunityReviewScreen() {
const cardTitle = const cardTitle =
typeof state.title === "string" && state.title.trim().length > 0 typeof state.title === "string" && state.title.trim().length > 0
? state.title.trim() ? state.title.trim()
: t("ruleCard.title"); : "";
/** /**
* No placeholder fallback: if the user skipped `community-context`, leave * No placeholder fallback: if the user skipped `community-context`, leave
* the card description off rather than render the old "Mutual Aid Monday * the card description off rather than render sample copy as real user data.
* is a grassroots community…" sample, which read as real user copy.
*/ */
const cardDescription = const cardDescription =
typeof state.communityContext === "string" && typeof state.communityContext === "string" &&
@@ -92,6 +97,36 @@ export function CommunityReviewScreen() {
? state.communityAvatarUrl.trim() ? state.communityAvatarUrl.trim()
: null; : null;
if (state.pendingTemplateAction || !reviewReady) {
return null;
}
if (!cardTitle) {
return (
<CreateFlowStepShell
variant="centeredNarrow"
contentTopBelowMd="space-1400"
>
<div
className={`flex flex-col items-start gap-6 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
<CreateFlowHeaderLockup
title={t("empty.title")}
description={t("empty.description")}
/>
<Button
buttonType="filled"
palette="default"
size="xsmall"
href={CREATE_ROUTES.createFirstStep}
>
{t("empty.startLabel")}
</Button>
</div>
</CreateFlowStepShell>
);
}
return ( return (
<CreateFlowStepShell <CreateFlowStepShell
variant="wideGridLoosePadding" variant="wideGridLoosePadding"
@@ -97,11 +97,12 @@ export function FinalReviewScreen({
/** /**
* Two modals coexist on this screen: * Two modals coexist on this screen:
* *
* - {@link FinalReviewChipEditModal} — core values + method chips: kebab * - {@link FinalReviewChipEditModal} — core values + method chips: footer
* Customize / Remove; values also offer Duplicate under the five-chip cap. * **Remove** when the chip is already selected, plus kebab **Remove**;
* Save respects the same unlock/dirty rules as the facet create modals; * values also offer Duplicate under the five-chip cap; method chips
* writes `{group}DetailsById`, snapshot label (values), `customMethodCardMetaById`, * offer Customize (prefilled custom-policy wizard). Fields are editable on
* and field blocks on Save. * open. Save writes `{group}DetailsById` and field blocks; wizard Finalize
* also writes `customMethodCardMetaById`.
* - {@link TemplateChipDetailModal} — read-only fallback for chips we * - {@link TemplateChipDetailModal} — read-only fallback for chips we
* can't map to an override key (e.g. template body entries on the * can't map to an override key (e.g. template body entries on the
* "Use without changes" path where no preset matches the title). * "Use without changes" path where no preset matches the title).
@@ -270,7 +271,7 @@ export function FinalReviewScreen({
} }
size={mdUp ? "L" : "M"} size={mdUp ? "L" : "M"}
expanded={true} expanded={true}
backgroundColor="bg-[#c9fef9]" backgroundColor="bg-[var(--color-surface-invert-brand-teal)]"
logoUrl={getAssetPath(vectorMarkPath("mutual-aid"))} logoUrl={getAssetPath(vectorMarkPath("mutual-aid"))}
logoAlt={ruleCardTitle} logoAlt={ruleCardTitle}
categories={finalReviewCategories} categories={finalReviewCategories}
@@ -26,6 +26,7 @@ import type { InfoMessageBoxItem } from "../../../../components/controls/InfoMes
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell"; import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
@@ -33,6 +34,11 @@ import { DecisionApproachEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard"; import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import { decisionApproachPresetFor } from "../../../../../lib/create/finalReviewChipPresets"; import { decisionApproachPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
import {
applyDecisionApproachKeyResources,
decisionApproachKeyResourceCheckboxIds,
selectedKeyResourceLabelsFromCheckedIds,
} from "../../../../../lib/create/decisionApproachKeyResources";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom"; import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder"; import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
@@ -51,43 +57,59 @@ import type { DecisionApproachDetailEntry } from "../../types";
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody"; import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu"; import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch"; import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
isMethodCardCustomizeUnloadBlocked,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession"; } from "../../../../../lib/create/methodCardCustomizeSession";
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
export function DecisionApproachesScreen() { export function DecisionApproachesScreen() {
const m = useMessages(); const m = useMessages();
const da = m.create.customRule.decisionApproaches; const da = m.create.customRule.decisionApproaches;
const modalKebabMenu = m.create.customRule.modalKebabMenu; const modalKebabMenu = m.create.customRule.modalKebabMenu;
const mdUp = useCreateFlowMdUp(); const mdUp = useCreateFlowMdUp();
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } = const { confirmDiscard, confirmDialog } = useDiscardCustomizeConfirm();
useDiscardCustomizeConfirm();
const { state, updateState, replaceState, markCreateFlowInteraction } = const { state, updateState, replaceState, markCreateFlowInteraction } =
useCreateFlow(); useCreateFlow();
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null); const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
const customizeSnapshotRef = useRef< const customizeSnapshotRef = useRef<
MethodCardCustomizeSnapshot<DecisionApproachDetailEntry> | null MethodCardCustomizeSnapshot<DecisionApproachDetailEntry> | null
>(null); >(null);
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
[],
);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false);
const [pendingCardId, setPendingCardId] = useState<string | null>(null); const [pendingCardId, setPendingCardId] = useState<string | null>(null);
const [pendingDraft, setPendingDraft] = const [pendingDraft, setPendingDraft] =
useState<DecisionApproachDetailEntry | null>(null); useState<DecisionApproachDetailEntry | null>(null);
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false); const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
const [modalEditUnlocked, setModalEditUnlocked] = useState(false); const [wizardCustomizeCardId, setWizardCustomizeCardId] = useState<
string | null
>(null);
const [wizardInitialValues, setWizardInitialValues] =
useState<MethodCardWizardInitialValues | null>(null);
const [draftFieldBlocks, setDraftFieldBlocks] = useState< const [draftFieldBlocks, setDraftFieldBlocks] = useState<
CustomMethodCardFieldBlock[] | null CustomMethodCardFieldBlock[] | null
>(null); >(null);
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
useState<MethodCardHeaderDraft | null>(null); useBeforeUnloadGuard(
isMethodCardCustomizeUnloadBlocked(
createModalOpen,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeSnapshotRef.current?.headerDraft ?? null,
),
);
const selectedIds = state.selectedDecisionApproachIds ?? []; const selectedIds = state.selectedDecisionApproachIds ?? [];
const messageBoxCheckedIds = decisionApproachKeyResourceCheckboxIds({
detailsById: state.decisionApproachDetailsById,
selectedApproachIds: selectedIds,
reminderIds: state.selectedDecisionKeyResourceIds,
openDraft: pendingDraft,
});
const messageBoxItems: InfoMessageBoxItem[] = useMemo( const messageBoxItems: InfoMessageBoxItem[] = useMemo(
() => () =>
@@ -108,7 +130,8 @@ export function DecisionApproachesScreen() {
[da.methods, selectedIds, state.customMethodCardMetaById], [da.methods, selectedIds, state.customMethodCardMetaById],
); );
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering( const { sampleCards, compactCardIds, methodById, recommendationsReady } =
useMethodCardDeckOrdering(
"decisionApproaches", "decisionApproaches",
mergedMethods, mergedMethods,
selectedIds, selectedIds,
@@ -116,6 +139,8 @@ export function DecisionApproachesScreen() {
const handleOpenAddWizard = useCallback(() => { const handleOpenAddWizard = useCallback(() => {
markCreateFlowInteraction(); markCreateFlowInteraction();
setWizardCustomizeCardId(null);
setWizardInitialValues(null);
setAddCustomWizardOpen(true); setAddCustomWizardOpen(true);
}, [markCreateFlowInteraction]); }, [markCreateFlowInteraction]);
@@ -132,22 +157,34 @@ export function DecisionApproachesScreen() {
const handleMessageBoxCheckboxChange = useCallback( const handleMessageBoxCheckboxChange = useCallback(
(id: string, checked: boolean) => { (id: string, checked: boolean) => {
markCreateFlowInteraction(); markCreateFlowInteraction();
setMessageBoxCheckedIds((prev) => const nextCheckedIds = checked
checked ? [...prev, id] : prev.filter((x) => x !== id), ? [...messageBoxCheckedIds, id]
: messageBoxCheckedIds.filter((x) => x !== id);
const nextLabels =
selectedKeyResourceLabelsFromCheckedIds(nextCheckedIds);
if (pendingDraft) {
setPendingDraft(
applyDecisionApproachKeyResources(pendingDraft, nextLabels),
); );
return;
}
updateState({
selectedDecisionKeyResourceIds: nextCheckedIds,
});
}, },
[markCreateFlowInteraction], [
markCreateFlowInteraction,
messageBoxCheckedIds,
pendingDraft,
updateState,
],
); );
const seedDraft = useCallback( const seedDraft = useCallback(
(id: string): DecisionApproachDetailEntry => { (id: string): DecisionApproachDetailEntry => {
const saved = state.decisionApproachDetailsById?.[id]; const saved = state.decisionApproachDetailsById?.[id];
if (saved) { if (saved) {
return { return structuredClone(saved);
...saved,
applicableScope: [...saved.applicableScope],
selectedApplicableScope: [...saved.selectedApplicableScope],
};
} }
return decisionApproachPresetFor(id); return decisionApproachPresetFor(id);
}, },
@@ -157,15 +194,40 @@ export function DecisionApproachesScreen() {
const handleCardSelect = useCallback( const handleCardSelect = useCallback(
(id: string) => { (id: string) => {
markCreateFlowInteraction(); markCreateFlowInteraction();
customizeSnapshotRef.current = null; const draft = seedDraft(id);
setModalEditUnlocked(false); const persistedBlocks = state.customMethodCardFieldBlocksById?.[id];
setDraftFieldBlocks(null); const initialBlocks =
setCustomizeHeaderDraft(null); Array.isArray(persistedBlocks) && persistedBlocks.length > 0
? structuredClone(persistedBlocks)
: null;
const method = methodById.get(id);
const meta = state.customMethodCardMetaById?.[id];
const headerDraft: MethodCardHeaderDraft = {
title: meta?.label ?? method?.label ?? da.confirmModal.title,
description:
meta?.supportText ??
method?.supportText ??
da.confirmModal.description,
};
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
draft,
initialBlocks,
headerDraft,
);
setPendingCardId(id); setPendingCardId(id);
setPendingDraft(seedDraft(id)); setPendingDraft(draft);
setDraftFieldBlocks(initialBlocks);
setCreateModalOpen(true); setCreateModalOpen(true);
}, },
[markCreateFlowInteraction, seedDraft], [
da.confirmModal.description,
da.confirmModal.title,
markCreateFlowInteraction,
methodById,
seedDraft,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
],
); );
const handleDraftChange = useCallback( const handleDraftChange = useCallback(
@@ -178,9 +240,7 @@ export function DecisionApproachesScreen() {
const isSelectedCardModal = const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId); pendingCardId !== null && selectedIds.includes(pendingCardId);
const fieldsLocked = !modalEditUnlocked; const showMethodModalPrimary = true;
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
const customFacetDetailsMatchPreset = useMemo(() => { const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false; if (!pendingCardId || !pendingDraft) return false;
@@ -202,7 +262,7 @@ export function DecisionApproachesScreen() {
methodId: pendingCardId, methodId: pendingCardId,
meta: state.customMethodCardMetaById, meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById, fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked, modalEditUnlocked: true,
draftFieldBlocks, draftFieldBlocks,
customFacetDetailsMatchPreset, customFacetDetailsMatchPreset,
}), }),
@@ -210,7 +270,6 @@ export function DecisionApproachesScreen() {
[ [
customFacetDetailsMatchPreset, customFacetDetailsMatchPreset,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked,
pendingCardId, pendingCardId,
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById, state.customMethodCardMetaById,
@@ -220,11 +279,11 @@ export function DecisionApproachesScreen() {
const handleCreateModalClose = useCallback(async () => { const handleCreateModalClose = useCallback(async () => {
if ( if (
!(await confirmDiscard( !(await confirmDiscard(
modalEditUnlocked, true,
customizeSnapshotRef.current, customizeSnapshotRef.current,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeSnapshotRef.current?.headerDraft ?? null,
)) ))
) { ) {
return; return;
@@ -252,53 +311,14 @@ export function DecisionApproachesScreen() {
setCreateModalOpen(false); setCreateModalOpen(false);
setPendingCardId(null); setPendingCardId(null);
setPendingDraft(null); setPendingDraft(null);
setModalEditUnlocked(false);
setDraftFieldBlocks(null); setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
}, [ }, [
confirmDiscard, confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked,
pendingDraft, pendingDraft,
replaceState, replaceState,
]); ]);
const handleCancelCustomize = useCallback(async () => {
if (!modalEditUnlocked) {
return;
}
const snap = customizeSnapshotRef.current;
if (!snap) {
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
if (
!(await confirmDirtyCustomizeCancel(
snap,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
))
) {
return;
}
setPendingDraft(structuredClone(snap.pendingDraft));
setDraftFieldBlocks(null);
setModalEditUnlocked(false);
customizeSnapshotRef.current = null;
setCustomizeHeaderDraft(null);
}, [
confirmDirtyCustomizeCancel,
customizeHeaderDraft,
draftFieldBlocks,
modalEditUnlocked,
pendingDraft,
]);
const handleRemoveSelectedFromModal = useCallback(async () => { const handleRemoveSelectedFromModal = useCallback(async () => {
if (!pendingCardId || !selectedIds.includes(pendingCardId)) { if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
return; return;
@@ -306,11 +326,11 @@ export function DecisionApproachesScreen() {
markCreateFlowInteraction(); markCreateFlowInteraction();
if ( if (
!(await confirmDiscard( !(await confirmDiscard(
modalEditUnlocked, true,
customizeSnapshotRef.current, customizeSnapshotRef.current,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeSnapshotRef.current?.headerDraft ?? null,
)) ))
) { ) {
return; return;
@@ -326,11 +346,9 @@ export function DecisionApproachesScreen() {
await handleCreateModalClose(); await handleCreateModalClose();
}, [ }, [
confirmDiscard, confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
handleCreateModalClose, handleCreateModalClose,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked,
pendingDraft, pendingDraft,
pendingCardId, pendingCardId,
selectedIds, selectedIds,
@@ -340,35 +358,36 @@ export function DecisionApproachesScreen() {
const handleCustomize = useCallback(() => { const handleCustomize = useCallback(() => {
markCreateFlowInteraction(); markCreateFlowInteraction();
if (!pendingDraft || !pendingCardId) { if (!pendingCardId) {
return; return;
} }
const initialFieldBlocks =
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? structuredClone(
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [],
)
: null;
const method = methodById.get(pendingCardId); const method = methodById.get(pendingCardId);
const meta = state.customMethodCardMetaById?.[pendingCardId]; setWizardInitialValues(
const headerDraft: MethodCardHeaderDraft = { buildMethodCardWizardInitialValues({
title: meta?.label ?? method?.label ?? da.confirmModal.title, cardId: pendingCardId,
description: fallbackTitle: method?.label ?? da.confirmModal.title,
meta?.supportText ?? fallbackDescription:
method?.supportText ?? method?.supportText ?? da.confirmModal.description,
da.confirmModal.description, meta: state.customMethodCardMetaById,
}; persistedBlocks: state.customMethodCardFieldBlocksById,
setCustomizeHeaderDraft(headerDraft); draftFieldBlocks,
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( facetPrefill: pendingDraft
pendingDraft, ? {
initialFieldBlocks, group: "decisionApproaches",
headerDraft, draft: pendingDraft,
headings: da.sectionHeadings,
}
: undefined,
}),
); );
setDraftFieldBlocks(initialFieldBlocks); setWizardCustomizeCardId(pendingCardId);
setModalEditUnlocked(true); setCreateModalOpen(false);
setAddCustomWizardOpen(true);
}, [ }, [
da.confirmModal.description, da.confirmModal.description,
da.confirmModal.title, da.confirmModal.title,
da.sectionHeadings,
draftFieldBlocks,
markCreateFlowInteraction, markCreateFlowInteraction,
methodById, methodById,
pendingCardId, pendingCardId,
@@ -393,9 +412,7 @@ export function DecisionApproachesScreen() {
() => decisionApproachPresetFor(newId), () => decisionApproachPresetFor(newId),
); );
const blocksClone = structuredClone( const blocksClone = structuredClone(
modalEditUnlocked && draftFieldBlocks !== null
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? draftFieldBlocks ? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate( : cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
@@ -425,13 +442,12 @@ export function DecisionApproachesScreen() {
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setPendingCardId(newId); setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone)); setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false); setDraftFieldBlocks(
setDraftFieldBlocks(null); blocksClone.length > 0 ? structuredClone(blocksClone) : null,
setCustomizeHeaderDraft(null); );
}, [ }, [
draftFieldBlocks, draftFieldBlocks,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix, modalKebabMenu.duplicateTitleSuffix,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
@@ -460,9 +476,7 @@ export function DecisionApproachesScreen() {
() => decisionApproachPresetFor(newId), () => decisionApproachPresetFor(newId),
); );
const blocksClone = structuredClone( const blocksClone = structuredClone(
modalEditUnlocked && draftFieldBlocks !== null
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? draftFieldBlocks ? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate( : cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
@@ -492,14 +506,13 @@ export function DecisionApproachesScreen() {
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setPendingCardId(newId); setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone)); setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false); setDraftFieldBlocks(
setDraftFieldBlocks(null); blocksClone.length > 0 ? structuredClone(blocksClone) : null,
setCustomizeHeaderDraft(null); );
}, [ }, [
draftFieldBlocks, draftFieldBlocks,
markCreateFlowInteraction, markCreateFlowInteraction,
methodById, methodById,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix, modalKebabMenu.duplicateTitleSuffix,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
@@ -512,7 +525,7 @@ export function DecisionApproachesScreen() {
const kebabMenuItems = useMemo( const kebabMenuItems = useMemo(
() => () =>
buildCustomRuleModalKebabMenu(modalKebabMenu, { buildCustomRuleModalKebabMenu(modalKebabMenu, {
showCustomize: !modalEditUnlocked, showCustomize: true,
onCustomize: handleCustomize, onCustomize: handleCustomize,
onDuplicate: onDuplicate:
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId (state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
@@ -532,7 +545,6 @@ export function DecisionApproachesScreen() {
handleDuplicatePrefabCard, handleDuplicatePrefabCard,
handleRemoveSelectedFromModal, handleRemoveSelectedFromModal,
isSelectedCardModal, isSelectedCardModal,
modalEditUnlocked,
modalKebabMenu, modalKebabMenu,
pendingCardId, pendingCardId,
state.customMethodCardMetaById, state.customMethodCardMetaById,
@@ -546,8 +558,14 @@ export function DecisionApproachesScreen() {
}, [markCreateFlowInteraction]); }, [markCreateFlowInteraction]);
const handleCloseAddWizard = useCallback(() => { const handleCloseAddWizard = useCallback(() => {
const resumeCardId = wizardCustomizeCardId;
setAddCustomWizardOpen(false); setAddCustomWizardOpen(false);
}, []); setWizardCustomizeCardId(null);
setWizardInitialValues(null);
if (resumeCardId && pendingCardId === resumeCardId) {
setCreateModalOpen(true);
}
}, [pendingCardId, wizardCustomizeCardId]);
const handleFinalizeCustomCard = useCallback( const handleFinalizeCustomCard = useCallback(
({ ({
@@ -560,6 +578,42 @@ export function DecisionApproachesScreen() {
fieldBlocks: CustomMethodCardFieldBlock[]; fieldBlocks: CustomMethodCardFieldBlock[];
}) => { }) => {
markCreateFlowInteraction(); markCreateFlowInteraction();
const existingId = wizardCustomizeCardId;
if (existingId) {
updateState({
selectedDecisionApproachIds: moveFacetSelectionIdToFront(
selectedIds,
existingId,
),
customMethodCardMetaById: methodCardMetaWithCustomizeHeader(
state.customMethodCardMetaById,
existingId,
{ title, description },
),
...(pendingDraft
? {
decisionApproachDetailsById: {
...(state.decisionApproachDetailsById ?? {}),
[existingId]: pendingDraft,
},
}
: {}),
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[existingId]: fieldBlocks,
},
});
if (pendingDraft) {
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
pendingDraft,
fieldBlocks,
{ title, description },
);
}
setDraftFieldBlocks(structuredClone(fieldBlocks));
setAddCustomWizardOpen(false);
return;
}
const id = crypto.randomUUID(); const id = crypto.randomUUID();
updateState({ updateState({
selectedDecisionApproachIds: moveFacetSelectionIdToFront( selectedDecisionApproachIds: moveFacetSelectionIdToFront(
@@ -582,139 +636,89 @@ export function DecisionApproachesScreen() {
}, },
[ [
markCreateFlowInteraction, markCreateFlowInteraction,
pendingDraft,
selectedIds, selectedIds,
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById, state.customMethodCardMetaById,
state.decisionApproachDetailsById, state.decisionApproachDetailsById,
updateState, updateState,
wizardCustomizeCardId,
], ],
); );
const handleCreateModalPrimary = useCallback(() => { const handleCreateModalPrimary = useCallback(() => {
if (!pendingCardId) { if (!pendingCardId) {
handleCreateModalClose(); void handleCreateModalClose();
return; return;
} }
markCreateFlowInteraction(); markCreateFlowInteraction();
if (selectedIds.includes(pendingCardId)) { const persistWizardBlocks =
if (modalEditUnlocked) { modalUsesWizardFieldBlocksBody && draftFieldBlocks !== null;
if (!customizeHeaderDraft) {
return;
}
const nextMeta = methodCardMetaWithCustomizeHeader(
state.customMethodCardMetaById,
pendingCardId,
customizeHeaderDraft,
);
if (
pendingCardId &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
usesWizardFieldBlocksModalBody({
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
draftFieldBlocks,
customFacetDetailsMatchPreset,
})
) {
updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
});
} else if (pendingDraft) {
updateState({
customMethodCardMetaById: nextMeta,
decisionApproachDetailsById: {
...(state.decisionApproachDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
});
}
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
return;
}
if (modalEditUnlocked) { if (selectedIds.includes(pendingCardId)) {
if (!customizeHeaderDraft) { replaceState((prev) => {
return; if (persistWizardBlocks) {
} return {
const nextMeta = methodCardMetaWithCustomizeHeader( ...prev,
state.customMethodCardMetaById,
pendingCardId,
customizeHeaderDraft,
);
if (
pendingCardId &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
usesWizardFieldBlocksModalBody({
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
draftFieldBlocks,
customFacetDetailsMatchPreset,
})
) {
updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: { customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}), ...(prev.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []), [pendingCardId]: structuredClone(draftFieldBlocks ?? []),
}, },
}); };
} else if (pendingDraft) {
updateState({
customMethodCardMetaById: nextMeta,
decisionApproachDetailsById: {
...(state.decisionApproachDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
});
} }
if (!pendingDraft) {
return prev;
}
return {
...prev,
decisionApproachDetailsById: {
...(prev.decisionApproachDetailsById ?? {}),
[pendingCardId]: structuredClone(pendingDraft),
},
};
});
pendingEphemeralDuplicateIdRef.current = null;
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setModalEditUnlocked(false); void handleCreateModalClose();
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return; return;
} }
if (!pendingDraft) { if (!pendingDraft) {
handleCreateModalClose(); void handleCreateModalClose();
return; return;
} }
updateState({ replaceState((prev) => ({
...prev,
selectedDecisionApproachIds: moveFacetSelectionIdToFront( selectedDecisionApproachIds: moveFacetSelectionIdToFront(
selectedIds, prev.selectedDecisionApproachIds ?? [],
pendingCardId, pendingCardId,
), ),
decisionApproachDetailsById: { decisionApproachDetailsById: {
...(state.decisionApproachDetailsById ?? {}), ...(prev.decisionApproachDetailsById ?? {}),
[pendingCardId]: pendingDraft, [pendingCardId]: structuredClone(pendingDraft),
}, },
}); ...(persistWizardBlocks
? {
customMethodCardFieldBlocksById: {
...(prev.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
}
: {}),
}));
pendingEphemeralDuplicateIdRef.current = null; pendingEphemeralDuplicateIdRef.current = null;
handleCreateModalClose(); customizeSnapshotRef.current = null;
void handleCreateModalClose();
}, [ }, [
customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
handleCreateModalClose, handleCreateModalClose,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked, modalUsesWizardFieldBlocksBody,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
replaceState,
selectedIds, selectedIds,
state,
updateState,
]); ]);
const modalConfig = pendingCardId const modalConfig = pendingCardId
@@ -728,7 +732,7 @@ export function DecisionApproachesScreen() {
meta?.supportText ?? meta?.supportText ??
method?.supportText ?? method?.supportText ??
da.confirmModal.description, da.confirmModal.description,
nextButtonText: modalEditUnlocked nextButtonText: isSelectedCardModal
? saveLabel ? saveLabel
: da.addApproach.nextButtonText, : da.addApproach.nextButtonText,
}; };
@@ -761,7 +765,11 @@ export function DecisionApproachesScreen() {
</div> </div>
} }
> >
<div className="flex w-full min-w-0 flex-col items-stretch gap-6 py-0"> <div
className="flex w-full min-w-0 flex-col items-stretch gap-6 py-0"
aria-busy={!recommendationsReady}
>
{recommendationsReady && (
<CardStack <CardStack
cards={sampleCards} cards={sampleCards}
selectedIds={selectedIds} selectedIds={selectedIds}
@@ -791,39 +799,20 @@ export function DecisionApproachesScreen() {
className="w-full" className="w-full"
headerLockupSize={mdUp ? "L" : "M"} headerLockupSize={mdUp ? "L" : "M"}
/> />
)}
</div> </div>
<Create <Create
isOpen={createModalOpen} isOpen={createModalOpen}
onClose={handleCreateModalClose} onClose={handleCreateModalClose}
headerContent={
modalEditUnlocked && customizeHeaderDraft ? (
<MethodCardCustomizeModalHeader
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
titleValue={customizeHeaderDraft.title}
descriptionValue={customizeHeaderDraft.description}
onTitleChange={(title) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, title } : null,
)
}
onDescriptionChange={(description) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, description } : null,
)
}
/>
) : undefined
}
onNext={handleCreateModalPrimary} onNext={handleCreateModalPrimary}
title={modalConfig.title} title={modalConfig.title}
description={modalConfig.description} description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText} nextButtonText={modalConfig.nextButtonText}
showBackButton={modalEditUnlocked} showBackButton={false}
onBack={handleCancelCustomize}
backButtonText={modalKebabMenu.cancelCustomize}
showNextButton={showMethodModalPrimary} showNextButton={showMethodModalPrimary}
showRemoveButton={isSelectedCardModal}
onRemove={handleRemoveSelectedFromModal}
backdropVariant="blurredYellow" backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel} kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
@@ -835,14 +824,14 @@ export function DecisionApproachesScreen() {
cardId={pendingCardId} cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById} blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={ blocksOverride={
modalEditUnlocked && draftFieldBlocks !== null draftFieldBlocks !== null ? draftFieldBlocks : undefined
? draftFieldBlocks
: undefined
} }
policyMeta={state.customMethodCardMetaById?.[pendingCardId]} policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked} showPolicyContentLockupWhenNoBlocks={
draftFieldBlocks === null || draftFieldBlocks.length === 0
}
onFieldBlocksChange={ onFieldBlocksChange={
fieldsLocked draftFieldBlocks === null
? undefined ? undefined
: (next) => setDraftFieldBlocks(next) : (next) => setDraftFieldBlocks(next)
} }
@@ -851,7 +840,6 @@ export function DecisionApproachesScreen() {
<DecisionApproachEditFields <DecisionApproachEditFields
value={pendingDraft} value={pendingDraft}
onChange={handleDraftChange} onChange={handleDraftChange}
readOnly={fieldsLocked}
/> />
) )
) : null} ) : null}
@@ -860,6 +848,7 @@ export function DecisionApproachesScreen() {
<CustomMethodCardWizard <CustomMethodCardWizard
isOpen={addCustomWizardOpen} isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard} onClose={handleCloseAddWizard}
initialValues={wizardInitialValues}
onFinalize={handleFinalizeCustomCard} onFinalize={handleFinalizeCustomCard}
onPersistCustomUploadFile={(file) => onPersistCustomUploadFile={(file) =>
uploadCreateFlowFile(file, "customMethodAttachment") uploadCreateFlowFile(file, "customMethodAttachment")
@@ -303,7 +303,6 @@ export function CommunityStructureSelectScreen() {
<> <>
<MultiSelect <MultiSelect
label={cs.organizationMultiSelect.label} label={cs.organizationMultiSelect.label}
showHelpIcon
size="s" size="s"
options={organizationTypeOptions} options={organizationTypeOptions}
onChipClick={handleOrganizationTypeClick} onChipClick={handleOrganizationTypeClick}
@@ -313,7 +312,6 @@ export function CommunityStructureSelectScreen() {
/> />
<MultiSelect <MultiSelect
label={cs.scaleMultiSelect.label} label={cs.scaleMultiSelect.label}
showHelpIcon
size="s" size="s"
options={scaleOptions} options={scaleOptions}
onChipClick={handleScaleClick} onChipClick={handleScaleClick}
@@ -323,7 +321,6 @@ export function CommunityStructureSelectScreen() {
/> />
<MultiSelect <MultiSelect
label={cs.maturityMultiSelect.label} label={cs.maturityMultiSelect.label}
showHelpIcon
size="s" size="s"
options={maturityOptions} options={maturityOptions}
onChipClick={handleMaturityClick} onChipClick={handleMaturityClick}
@@ -9,8 +9,7 @@ import { useTranslation } from "../../../../contexts/MessagesContext";
import { MAX_STAKEHOLDER_EMAILS } from "../../../../../lib/create/stakeholderLimits"; import { MAX_STAKEHOLDER_EMAILS } from "../../../../../lib/create/stakeholderLimits";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell"; import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
import { import {
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY, CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE, CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
@@ -131,45 +130,34 @@ export function ConfirmStakeholdersScreen() {
if (managePublishedMode) { if (managePublishedMode) {
return ( return (
<CreateFlowStepShell <CreateFlowTwoColumnSelectShell
variant="centeredNarrowBottomPad" header={
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 <CreateFlowHeaderLockup
title={t("managePublished.lockupTitle")} title={t("managePublished.lockupTitle")}
description={t("managePublished.lockupDescription")} description={t("managePublished.lockupDescription")}
justification="left" justification="left"
/> />
</div> }
>
<PublishedStakeholdersManagePanel ruleId={editingPublishedRuleId} /> <PublishedStakeholdersManagePanel ruleId={editingPublishedRuleId} />
</div> </CreateFlowTwoColumnSelectShell>
</CreateFlowStepShell>
); );
} }
return ( return (
<> <>
<CreateFlowStepShell <CreateFlowTwoColumnSelectShell
variant="centeredNarrowBottomPad" header={
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 <CreateFlowHeaderLockup
title={t("title")} title={t("title")}
description={t("description")} description={t("description")}
justification="left" justification="left"
/> />
</div> }
>
{chipError ? ( {chipError ? (
<p <p
className="font-inter text-sm text-[var(--color-border-default-utility-negative)]" className="text-small-paragraph text-[var(--color-border-default-utility-negative)]"
role="alert" role="alert"
> >
{chipError} {chipError}
@@ -187,8 +175,7 @@ export function ConfirmStakeholdersScreen() {
addButton addButton
addButtonText={t("addStakeholder")} addButtonText={t("addStakeholder")}
/> />
</div> </CreateFlowTwoColumnSelectShell>
</CreateFlowStepShell>
{!toastDismissed && ( {!toastDismissed && (
<div <div
@@ -8,7 +8,8 @@ import ContentLockup from "../../../../components/type/ContentLockup";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { buildCoreValueChipOptionsFromDraft } from "../../../../../lib/create/coreValueChipOptionsFromDraft"; import { buildCoreValueChipOptionsFromDraft } from "../../../../../lib/create/coreValueChipOptionsFromDraft";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm";
import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import type { import type {
CommunityStructureChipSnapshotRow, CommunityStructureChipSnapshotRow,
CoreValueDetailEntry, CoreValueDetailEntry,
@@ -16,20 +17,25 @@ import type {
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell"; import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import { CoreValueEditFields } from "../../components/methodEditFields"; import { CoreValueEditFields } from "../../components/methodEditFields";
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader"; import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { usesWizardFieldBlocksModalBody } from "../../../../../lib/create/usesWizardFieldBlocksModalBody";
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu"; import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
import {
captureMethodCardCustomizeSnapshot,
isMethodCardCustomizeSessionDirty,
type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession";
import { import {
duplicateCoreValueChipInDraft, duplicateCoreValueChipInDraft,
MAX_SELECTED_CORE_VALUES, MAX_SELECTED_CORE_VALUES,
removeCoreValueChipFromDraft, removeCoreValueChipFromDraft,
} from "../../../../../lib/create/coreValueChipFacet"; } from "../../../../../lib/create/coreValueChipFacet";
import { omitIdFromStringRecord } from "../../../../../lib/create/duplicateMethodCardModalDraft"; import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
import {
buildMethodCardWizardInitialValues,
coreValueDetailsFromWizardFieldBlocks,
overlayFacetPrefillValues,
type MethodCardWizardInitialValues,
} from "../../../../../lib/create/methodCardWizardPrefill";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
const MAX_CORE_VALUES = MAX_SELECTED_CORE_VALUES; const MAX_CORE_VALUES = MAX_SELECTED_CORE_VALUES;
@@ -37,13 +43,14 @@ const MAX_CORE_VALUES = MAX_SELECTED_CORE_VALUES;
* Why three sessions, not two: * Why three sessions, not two:
* *
* - `pending` preset chip just selected; modal opened to capture * - `pending` preset chip just selected; modal opened to capture
* meaning/signals. Dismiss = unselect the chip (keep it in the * meaning/signals. Close (X) confirms, then unselects the chip.
* preset row, just not selected). * - `customPending` leftover inline custom-chip drafts (older sessions).
* - `customPending` brand-new custom chip just created via the Add * Dismiss = drop the chip entirely.
* value flow; modal opened with empty fields. Dismiss = drop the
* chip entirely (it was never confirmed via the Add Value button).
* - `editing` chip already exists & is selected; modal reopened to * - `editing` chip already exists & is selected; modal reopened to
* tweak meaning/signals. Dismiss = no-op (chip stays as-is). * tweak meaning/signals. Dismiss = no-op (chip stays as-is).
*
* **Add value** (and the header "add" link) opens an empty custom-policy
* wizard. Finalize writes a selected chip named from the wizard title.
*/ */
type ModalSession = "pending" | "customPending" | "editing"; type ModalSession = "pending" | "customPending" | "editing";
@@ -101,13 +108,11 @@ export function CoreValuesSelectScreen() {
[cv.values], [cv.values],
); );
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } = const { requestConfirm, confirmDialog } = useAsyncConfirm();
useDiscardCustomizeConfirm();
const { markCreateFlowInteraction, updateState, replaceState, state } = const { markCreateFlowInteraction, updateState, replaceState, state } =
useCreateFlow(); useCreateFlow();
const coreCustomizeSnapshotRef = const initialDraftRef = useRef<CoreValueDetailEntry | null>(null);
useRef<MethodCardCustomizeSnapshot<CoreValueDetailEntry> | null>(null);
const pendingEphemeralCoreDuplicateRef = useRef<string | null>(null); const pendingEphemeralCoreDuplicateRef = useRef<string | null>(null);
const [coreValueOptions, setCoreValueOptions] = useState<ChipOption[]>(() => const [coreValueOptions, setCoreValueOptions] = useState<ChipOption[]>(() =>
@@ -123,9 +128,15 @@ export function CoreValuesSelectScreen() {
); );
const [modalSession, setModalSession] = useState<ModalSession | null>(null); const [modalSession, setModalSession] = useState<ModalSession | null>(null);
const [draft, setDraft] = useState<CoreValueDetailEntry>(EMPTY_DETAIL); const [draft, setDraft] = useState<CoreValueDetailEntry>(EMPTY_DETAIL);
const [modalEditUnlocked, setModalEditUnlocked] = useState(false); const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
const [customizeHeaderDraft, setCustomizeHeaderDraft] = const [wizardCustomizeChipId, setWizardCustomizeChipId] = useState<
useState<MethodCardHeaderDraft | null>(null); string | null
>(null);
const [wizardInitialValues, setWizardInitialValues] =
useState<MethodCardWizardInitialValues | null>(null);
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
CustomMethodCardFieldBlock[] | null
>(null);
useEffect(() => { useEffect(() => {
setCoreValueOptions( setCoreValueOptions(
@@ -178,6 +189,7 @@ export function CoreValuesSelectScreen() {
return { return {
meaning: saved?.meaning ?? preset.meaning, meaning: saved?.meaning ?? preset.meaning,
signals: saved?.signals ?? preset.signals, signals: saved?.signals ?? preset.signals,
...(saved?.supportText ? { supportText: saved.supportText } : {}),
}; };
}, },
[state.coreValueDetailsByChipId, getPresetTexts], [state.coreValueDetailsByChipId, getPresetTexts],
@@ -190,15 +202,24 @@ export function CoreValuesSelectScreen() {
valueLabel: string, valueLabel: string,
seedDetail?: CoreValueDetailEntry, seedDetail?: CoreValueDetailEntry,
) => { ) => {
setDraft(seedDetail ?? getInitialTexts(chipId, valueLabel)); const initial = seedDetail ?? getInitialTexts(chipId, valueLabel);
initialDraftRef.current = { ...initial };
const persisted = state.customMethodCardFieldBlocksById?.[chipId];
setDraftFieldBlocks(
Array.isArray(persisted) && persisted.length > 0
? structuredClone(persisted)
: null,
);
setDraft(initial);
setActiveModalChipId(chipId); setActiveModalChipId(chipId);
setModalSession(session); setModalSession(session);
setModalEditUnlocked(false);
setCustomizeHeaderDraft(null);
coreCustomizeSnapshotRef.current = null;
markCreateFlowInteraction(); markCreateFlowInteraction();
}, },
[getInitialTexts, markCreateFlowInteraction], [
getInitialTexts,
markCreateFlowInteraction,
state.customMethodCardFieldBlocksById,
],
); );
const handleDraftChange = useCallback( const handleDraftChange = useCallback(
@@ -209,87 +230,44 @@ export function CoreValuesSelectScreen() {
[markCreateFlowInteraction], [markCreateFlowInteraction],
); );
const resetCustomizeSession = useCallback(() => {
coreCustomizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setCustomizeHeaderDraft(null);
}, []);
const finalizeModalDismiss = useCallback(() => { const finalizeModalDismiss = useCallback(() => {
pendingEphemeralCoreDuplicateRef.current = null; pendingEphemeralCoreDuplicateRef.current = null;
resetCustomizeSession(); initialDraftRef.current = null;
setActiveModalChipId(null); setActiveModalChipId(null);
setModalSession(null); setModalSession(null);
}, [resetCustomizeSession]); setDraftFieldBlocks(null);
}, []);
const handleCustomize = useCallback(() => { const confirmLeaveWithoutSaving = useCallback(async () => {
if (!activeModalChipId) return; const initial = initialDraftRef.current;
const chipLabelNow = const fieldsDirty =
coreValueOptions.find((o) => o.id === activeModalChipId)?.label ?? ""; initial != null &&
if (!chipLabelNow) return; (draft.meaning !== initial.meaning || draft.signals !== initial.signals);
markCreateFlowInteraction(); if (!fieldsDirty) {
const headerDraft: MethodCardHeaderDraft = { return true;
title: chipLabelNow,
description: "",
};
coreCustomizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
draft,
null,
headerDraft,
);
setCustomizeHeaderDraft(headerDraft);
setModalEditUnlocked(true);
}, [activeModalChipId, coreValueOptions, draft, markCreateFlowInteraction]);
const handleCancelCustomize = useCallback(async () => {
if (!modalEditUnlocked) return;
const snap = coreCustomizeSnapshotRef.current;
if (!snap) {
resetCustomizeSession();
return;
} }
if ( const isPendingAdd =
!(await confirmDirtyCustomizeCancel( modalSession === "pending" || modalSession === "customPending";
snap, return requestConfirm({
draft, title: cv.detailModal.discardTitle,
null, description: isPendingAdd
customizeHeaderDraft, ? cv.detailModal.discardPendingDescription
)) : cv.detailModal.discardEditsDescription,
) { proceedText: cv.detailModal.discardProceed,
return; cancelText: cv.detailModal.discardKeepEditing,
} });
setDraft(structuredClone(snap.pendingDraft)); }, [cv.detailModal, draft, modalSession, requestConfirm]);
resetCustomizeSession();
}, [
confirmDirtyCustomizeCancel,
customizeHeaderDraft,
draft,
modalEditUnlocked,
resetCustomizeSession,
]);
const syncLabelFromCustomizeHeaderToOptions = useCallback(() => { useBeforeUnloadGuard(
if (!activeModalChipId || !customizeHeaderDraft) return coreValueOptions; activeModalChipId != null &&
const trimmed = customizeHeaderDraft.title.trim(); !addCustomWizardOpen &&
if (!trimmed) return coreValueOptions; initialDraftRef.current != null &&
return coreValueOptions.map((opt) => (draft.meaning !== initialDraftRef.current.meaning ||
opt.id === activeModalChipId ? { ...opt, label: trimmed } : opt, draft.signals !== initialDraftRef.current.signals),
); );
}, [activeModalChipId, customizeHeaderDraft, coreValueOptions]);
const handleDuplicateCoreChip = useCallback(async () => { const handleDuplicateCoreChip = useCallback(() => {
if (!activeModalChipId || !modalSession) return; if (!activeModalChipId || !modalSession) return;
if (
!(await confirmDiscard(
modalEditUnlocked,
coreCustomizeSnapshotRef.current,
draft,
null,
customizeHeaderDraft,
))
) {
return;
}
markCreateFlowInteraction(); markCreateFlowInteraction();
const priorEphemeral = pendingEphemeralCoreDuplicateRef.current; const priorEphemeral = pendingEphemeralCoreDuplicateRef.current;
let outcome: ReturnType<typeof duplicateCoreValueChipInDraft> | null = null; let outcome: ReturnType<typeof duplicateCoreValueChipInDraft> | null = null;
@@ -312,7 +290,6 @@ export function CoreValuesSelectScreen() {
if (!outcome) { if (!outcome) {
return; return;
} }
resetCustomizeSession();
pendingEphemeralCoreDuplicateRef.current = outcome.newId; pendingEphemeralCoreDuplicateRef.current = outcome.newId;
openModal( openModal(
outcome.newId, outcome.newId,
@@ -322,30 +299,15 @@ export function CoreValuesSelectScreen() {
); );
}, [ }, [
activeModalChipId, activeModalChipId,
confirmDiscard,
customizeHeaderDraft,
draft, draft,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix, modalKebabMenu.duplicateTitleSuffix,
modalSession, modalSession,
openModal, openModal,
replaceState, replaceState,
resetCustomizeSession,
]); ]);
const handleRemoveFromKebab = useCallback(async () => { const handleRemoveFromKebab = useCallback(() => {
if (
!(await confirmDiscard(
modalEditUnlocked,
coreCustomizeSnapshotRef.current,
draft,
null,
customizeHeaderDraft,
))
) {
return;
}
markCreateFlowInteraction(); markCreateFlowInteraction();
const ep = pendingEphemeralCoreDuplicateRef.current; const ep = pendingEphemeralCoreDuplicateRef.current;
@@ -375,39 +337,23 @@ export function CoreValuesSelectScreen() {
markCreateFlowInteraction(); markCreateFlowInteraction();
replaceState((prev) => ({ replaceState((prev) => ({
...prev, ...prev,
selectedCoreValueIds: selectedIdsFromOptions(nextFiltered), ...removeCoreValueChipFromDraft(prev, activeModalChipId),
coreValuesChipsSnapshot:
chipOptionsToSnapshotRows(nextFiltered),
coreValueDetailsByChipId:
omitIdFromStringRecord(prev.coreValueDetailsByChipId, activeModalChipId),
})); }));
setCoreValueOptions(nextFiltered); setCoreValueOptions(nextFiltered);
} }
finalizeModalDismiss(); finalizeModalDismiss();
}, [ }, [
activeModalChipId, activeModalChipId,
confirmDiscard,
coreValueOptions, coreValueOptions,
customizeHeaderDraft,
draft,
finalizeModalDismiss, finalizeModalDismiss,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked,
modalSession, modalSession,
persistCoreValues, persistCoreValues,
replaceState, replaceState,
]); ]);
const handleModalDismiss = useCallback(async () => { const handleModalDismiss = useCallback(async () => {
if ( if (!(await confirmLeaveWithoutSaving())) {
!(await confirmDiscard(
modalEditUnlocked,
coreCustomizeSnapshotRef.current,
draft,
null,
customizeHeaderDraft,
))
) {
return; return;
} }
@@ -436,118 +382,242 @@ export function CoreValuesSelectScreen() {
finalizeModalDismiss(); finalizeModalDismiss();
}, [ }, [
activeModalChipId, activeModalChipId,
confirmDiscard, confirmLeaveWithoutSaving,
coreValueOptions, coreValueOptions,
customizeHeaderDraft,
draft,
finalizeModalDismiss, finalizeModalDismiss,
modalEditUnlocked,
modalSession, modalSession,
persistCoreValues, persistCoreValues,
replaceState, replaceState,
]); ]);
const coreCustomizeSaveDisabled = useMemo(() => {
if (!modalEditUnlocked) return false;
const snap = coreCustomizeSnapshotRef.current;
if (!snap) return true;
return !isMethodCardCustomizeSessionDirty(
snap,
draft,
null,
customizeHeaderDraft,
);
}, [customizeHeaderDraft, draft, modalEditUnlocked]);
const handleModalConfirm = useCallback(() => { const handleModalConfirm = useCallback(() => {
if (!activeModalChipId || !modalSession) return; if (!activeModalChipId || !modalSession) return;
if (modalEditUnlocked && customizeHeaderDraft) {
if (coreCustomizeSaveDisabled) {
return;
}
markCreateFlowInteraction(); markCreateFlowInteraction();
pendingEphemeralCoreDuplicateRef.current = null; pendingEphemeralCoreDuplicateRef.current = null;
const nextOpts = syncLabelFromCustomizeHeaderToOptions(); const existingBlocks =
persistCoreValues(nextOpts); draftFieldBlocks && draftFieldBlocks.length > 0
? draftFieldBlocks
: state.customMethodCardFieldBlocksById?.[activeModalChipId];
updateState({ updateState({
coreValueDetailsByChipId: { coreValueDetailsByChipId: {
...(state.coreValueDetailsByChipId ?? {}), ...(state.coreValueDetailsByChipId ?? {}),
[activeModalChipId]: draft, [activeModalChipId]: draft,
}, },
}); ...(existingBlocks && existingBlocks.length > 0
resetCustomizeSession(); ? {
return; customMethodCardFieldBlocksById: {
} ...(state.customMethodCardFieldBlocksById ?? {}),
[activeModalChipId]: overlayFacetPrefillValues(existingBlocks, {
if (modalSession === "pending" || modalSession === "customPending") { group: "coreValues",
markCreateFlowInteraction(); draft,
pendingEphemeralCoreDuplicateRef.current = null; headings: {
updateState({ meaning: cv.detailModal.meaningLabel,
coreValueDetailsByChipId: { signals: cv.detailModal.signalsLabel,
...(state.coreValueDetailsByChipId ?? {}), },
[activeModalChipId]: draft, }),
}, },
});
resetCustomizeSession();
setActiveModalChipId(null);
setModalSession(null);
} }
: {}),
});
finalizeModalDismiss();
}, [ }, [
activeModalChipId, activeModalChipId,
coreCustomizeSaveDisabled, cv.detailModal.meaningLabel,
customizeHeaderDraft, cv.detailModal.signalsLabel,
draft, draft,
draftFieldBlocks,
finalizeModalDismiss,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked,
modalSession, modalSession,
persistCoreValues,
resetCustomizeSession,
state.coreValueDetailsByChipId, state.coreValueDetailsByChipId,
syncLabelFromCustomizeHeaderToOptions, state.customMethodCardFieldBlocksById,
updateState, updateState,
]); ]);
const modalChipLabel = const modalChipLabel =
coreValueOptions.find((o) => o.id === activeModalChipId)?.label ?? ""; coreValueOptions.find((o) => o.id === activeModalChipId)?.label ?? "";
const modalFieldsLocked = const modalUsesWizardFieldBlocksBody = Boolean(
!modalEditUnlocked && activeModalChipId &&
Boolean( usesWizardFieldBlocksModalBody({
modalSession === "pending" || methodId: activeModalChipId,
modalSession === "customPending" || meta: {},
modalSession === "editing", fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked: true,
draftFieldBlocks,
}),
); );
const showFooterPrimary = const showFooterPrimary = Boolean(modalSession);
modalEditUnlocked ||
modalSession === "pending" || const handleCustomize = useCallback(() => {
modalSession === "customPending"; if (!activeModalChipId) return;
markCreateFlowInteraction();
setWizardInitialValues(
buildMethodCardWizardInitialValues({
cardId: activeModalChipId,
fallbackTitle: modalChipLabel,
fallbackDescription:
draft.supportText?.trim() || cv.detailModal.subtitle,
meta: {},
persistedBlocks: state.customMethodCardFieldBlocksById,
draftFieldBlocks,
facetPrefill: {
group: "coreValues",
draft,
headings: {
meaning: cv.detailModal.meaningLabel,
signals: cv.detailModal.signalsLabel,
},
},
}),
);
setWizardCustomizeChipId(activeModalChipId);
setAddCustomWizardOpen(true);
}, [
activeModalChipId,
cv.detailModal.meaningLabel,
cv.detailModal.signalsLabel,
cv.detailModal.subtitle,
draft,
draftFieldBlocks,
markCreateFlowInteraction,
modalChipLabel,
state.customMethodCardFieldBlocksById,
]);
const handleCloseAddWizard = useCallback(() => {
setAddCustomWizardOpen(false);
setWizardCustomizeChipId(null);
setWizardInitialValues(null);
}, []);
const handleFinalizeCustomCard = useCallback(
({
title,
description,
fieldBlocks,
}: {
title: string;
description: string;
fieldBlocks: CustomMethodCardFieldBlock[];
}) => {
const trimmedTitle = title.trim();
const trimmedDescription = description.trim();
if (!trimmedTitle) return;
markCreateFlowInteraction();
pendingEphemeralCoreDuplicateRef.current = null;
const existingId = wizardCustomizeChipId;
if (!existingId) {
const id = crypto.randomUUID();
const nextDetails = {
...coreValueDetailsFromWizardFieldBlocks(fieldBlocks, EMPTY_DETAIL),
...(trimmedDescription.length > 0
? { supportText: trimmedDescription }
: {}),
};
replaceState((prev) => {
const sel = prev.selectedCoreValueIds ?? [];
if (sel.length >= MAX_CORE_VALUES) {
return prev;
}
const snap = [...(prev.coreValuesChipsSnapshot ?? [])];
snap.push({
id,
label: trimmedTitle,
state: "selected",
});
return {
...prev,
selectedCoreValueIds: moveFacetSelectionIdToFront(sel, id),
coreValuesChipsSnapshot: snap,
coreValueDetailsByChipId: {
...(prev.coreValueDetailsByChipId ?? {}),
[id]: nextDetails,
},
customMethodCardFieldBlocksById: {
...(prev.customMethodCardFieldBlocksById ?? {}),
[id]: structuredClone(fieldBlocks),
},
customMethodCardMetaById: methodCardMetaWithCustomizeHeader(
prev.customMethodCardMetaById,
id,
{ title: trimmedTitle, description: trimmedDescription },
),
};
});
setAddCustomWizardOpen(false);
setWizardCustomizeChipId(null);
setWizardInitialValues(null);
return;
}
const nextDetails = {
...coreValueDetailsFromWizardFieldBlocks(fieldBlocks, draft),
...(trimmedDescription.length > 0
? { supportText: trimmedDescription }
: {}),
};
replaceState((prev) => {
const snap = [...(prev.coreValuesChipsSnapshot ?? [])];
const i = snap.findIndex((r) => r.id === existingId);
if (i >= 0) {
snap[i] = { ...snap[i], label: trimmedTitle };
}
return {
...prev,
coreValuesChipsSnapshot: snap,
coreValueDetailsByChipId: {
...(prev.coreValueDetailsByChipId ?? {}),
[existingId]: nextDetails,
},
customMethodCardFieldBlocksById: {
...(prev.customMethodCardFieldBlocksById ?? {}),
[existingId]: structuredClone(fieldBlocks),
},
};
});
setDraft(nextDetails);
setDraftFieldBlocks(structuredClone(fieldBlocks));
setAddCustomWizardOpen(false);
setWizardCustomizeChipId(null);
setWizardInitialValues(null);
},
[draft, markCreateFlowInteraction, replaceState, wizardCustomizeChipId],
);
const selectedCount = useMemo(
() => coreValueOptions.filter((o) => o.state === "selected").length,
[coreValueOptions],
);
const atSelectionLimit = selectedCount >= MAX_CORE_VALUES;
const selectionCountText = cv.multiSelect.selectionCount
.replace("{count}", String(selectedCount))
.replace("{max}", String(MAX_CORE_VALUES));
const kebabMenuItems = useMemo(() => { const kebabMenuItems = useMemo(() => {
if (!modalSession || !activeModalChipId) return []; if (!modalSession || !activeModalChipId) return [];
const selectedCount = coreValueOptions.filter(
(o) => o.state === "selected",
).length;
return buildCustomRuleModalKebabMenu(modalKebabMenu, { return buildCustomRuleModalKebabMenu(modalKebabMenu, {
showCustomize: !modalEditUnlocked, showCustomize: true,
onCustomize: handleCustomize, onCustomize: handleCustomize,
onDuplicate: onDuplicate:
modalSession !== "editing" || selectedCount >= MAX_CORE_VALUES (state.editingPublishedRuleId?.trim() ?? "") !== "" ||
atSelectionLimit
? undefined ? undefined
: handleDuplicateCoreChip, : handleDuplicateCoreChip,
showRemove: true, showRemove: modalSession === "editing",
onRemove: handleRemoveFromKebab, onRemove: handleRemoveFromKebab,
}); });
}, [ }, [
activeModalChipId, activeModalChipId,
coreValueOptions, atSelectionLimit,
handleCustomize, handleCustomize,
handleDuplicateCoreChip, handleDuplicateCoreChip,
handleRemoveFromKebab, handleRemoveFromKebab,
modalEditUnlocked,
modalKebabMenu, modalKebabMenu,
modalSession, modalSession,
state.editingPublishedRuleId,
]); ]);
const handleChipClick = (chipId: string) => { const handleChipClick = (chipId: string) => {
const target = coreValueOptions.find((o) => o.id === chipId); const target = coreValueOptions.find((o) => o.id === chipId);
@@ -575,15 +645,14 @@ export function CoreValuesSelectScreen() {
const addHandlers = { const addHandlers = {
onAddClick: () => { onAddClick: () => {
const selectedCount = coreValueOptions.filter(
(o) => o.state === "selected",
).length;
if (selectedCount >= MAX_CORE_VALUES) return;
markCreateFlowInteraction(); markCreateFlowInteraction();
setCoreValueOptions((prev) => { setWizardCustomizeChipId(null);
const next: ChipOption[] = [ setWizardInitialValues(null);
...prev, setAddCustomWizardOpen(true);
{ id: crypto.randomUUID(), label: "", state: "custom" },
];
queueMicrotask(() => syncCoreValuesToDraft(next));
return next;
});
}, },
onCustomChipConfirm: (chipId: string, value: string) => { onCustomChipConfirm: (chipId: string, value: string) => {
markCreateFlowInteraction(); markCreateFlowInteraction();
@@ -632,7 +701,8 @@ export function CoreValuesSelectScreen() {
<button <button
type="button" type="button"
onClick={addHandlers.onAddClick} onClick={addHandlers.onAddClick}
className="cursor-pointer font-inter font-normal leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] hover:opacity-90" disabled={atSelectionLimit}
className="cursor-pointer font-normal leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] hover:opacity-90 disabled:cursor-not-allowed disabled:no-underline disabled:opacity-60 disabled:hover:opacity-60"
> >
{cv.header.addLink} {cv.header.addLink}
</button> </button>
@@ -667,49 +737,37 @@ export function CoreValuesSelectScreen() {
onCustomChipClose={addHandlers.onCustomChipClose} onCustomChipClose={addHandlers.onCustomChipClose}
addButton addButton
addButtonText={cv.multiSelect.addButtonText} addButtonText={cv.multiSelect.addButtonText}
maxSelections={MAX_CORE_VALUES}
selectionCountText={selectionCountText}
limitReachedAnnouncement={cv.multiSelect.limitReached}
/> />
{detailModal && ( {detailModal && (
<Create <Create
isOpen={activeModalChipId !== null} isOpen={activeModalChipId !== null && !addCustomWizardOpen}
onClose={handleModalDismiss} onClose={handleModalDismiss}
backdropVariant="blurredYellow" backdropVariant="blurredYellow"
headerContent={ headerContent={
modalEditUnlocked && customizeHeaderDraft ? (
<MethodCardCustomizeModalHeader
titleLabel={detailModal.customizeValueNameLabel}
descriptionLabel=""
titleValue={customizeHeaderDraft.title}
descriptionValue=""
onTitleChange={(title) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, title } : null,
)
}
onDescriptionChange={() => {}}
showDescription={false}
/>
) : (
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0"> <div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup <ContentLockup
title={modalChipLabel} title={modalChipLabel}
description={detailModal.subtitle} description={
draft.supportText?.trim() || detailModal.subtitle
}
variant="modal" variant="modal"
alignment="left" alignment="left"
/> />
</div> </div>
)
} }
showBackButton={modalEditUnlocked} showBackButton={false}
onBack={handleCancelCustomize}
backButtonText={modalKebabMenu.cancelCustomize}
showNextButton={showFooterPrimary} showNextButton={showFooterPrimary}
nextButtonDisabled={ showRemoveButton={modalSession === "editing"}
modalEditUnlocked && coreCustomizeSaveDisabled onRemove={handleRemoveFromKebab}
}
onNext={handleModalConfirm} onNext={handleModalConfirm}
nextButtonText={ nextButtonText={
modalEditUnlocked ? modalKebabMenu.saveEdits : detailModal.addValueButton modalSession === "editing"
? modalKebabMenu.saveEdits
: detailModal.addValueButton
} }
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel} kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
@@ -718,14 +776,43 @@ export function CoreValuesSelectScreen() {
} }
ariaLabel={modalChipLabel || "Core value details"} ariaLabel={modalChipLabel || "Core value details"}
> >
{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 <CoreValueEditFields
readOnly={modalFieldsLocked}
value={draft} value={draft}
onChange={handleDraftChange} onChange={handleDraftChange}
/> />
)}
</Create> </Create>
)} )}
</CreateFlowTwoColumnSelectShell> </CreateFlowTwoColumnSelectShell>
<CustomMethodCardWizard
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
initialValues={wizardInitialValues}
onFinalize={handleFinalizeCustomCard}
onPersistCustomUploadFile={(file) =>
uploadCreateFlowFile(file, "customMethodAttachment")
}
/>
{confirmDialog} {confirmDialog}
</> </>
); );
@@ -109,7 +109,7 @@ export function PublishedStakeholdersManagePanel({
<section className="flex w-full flex-col gap-4 pt-1 pb-2"> <section className="flex w-full flex-col gap-4 pt-1 pb-2">
{bannerError ? ( {bannerError ? (
<p <p
className="font-inter text-sm text-[var(--color-border-default-utility-negative)]" className="text-small-paragraph text-[var(--color-border-default-utility-negative)]"
role="alert" role="alert"
> >
{bannerError} {bannerError}
@@ -117,15 +117,15 @@ export function PublishedStakeholdersManagePanel({
) : null} ) : null}
{loadError ? ( {loadError ? (
<p className="font-inter text-sm text-[var(--color-border-default-utility-negative)]"> <p className="text-small-paragraph text-[var(--color-border-default-utility-negative)]">
{t("managePublished.loadFailed")} {t("managePublished.loadFailed")}
</p> </p>
) : items === null ? ( ) : items === null ? (
<p className="font-inter text-sm text-[var(--color-content-default-secondary)]"> <p className="text-small-paragraph text-[var(--color-content-default-secondary)]">
{t("managePublished.loading")} {t("managePublished.loading")}
</p> </p>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<p className="font-inter text-sm text-[var(--color-content-default-tertiary)]"> <p className="text-small-paragraph text-[var(--color-content-default-tertiary)]">
{t("managePublished.empty")} {t("managePublished.empty")}
</p> </p>
) : ( ) : (
@@ -136,10 +136,10 @@ export function PublishedStakeholdersManagePanel({
className="flex flex-col gap-2 rounded-lg bg-black/5 px-3 py-3 md:flex-row md:items-center md:justify-between" className="flex flex-col gap-2 rounded-lg bg-black/5 px-3 py-3 md:flex-row md:items-center md:justify-between"
> >
<div className="flex min-w-0 flex-col gap-1"> <div className="flex min-w-0 flex-col gap-1">
<span className="truncate font-inter text-sm font-medium text-[var(--color-content-default-primary)] md:text-base"> <span className="truncate text-sm font-medium text-[var(--color-content-default-primary)] md:text-base">
{row.email} {row.email}
</span> </span>
<span className="font-inter text-xs text-[var(--color-content-default-tertiary)] md:text-sm"> <span className="text-x-small-paragraph text-[var(--color-content-default-tertiary)] md:text-sm">
{row.status === "pending" {row.status === "pending"
? t("managePublished.pending") ? t("managePublished.pending")
: t("managePublished.accepted")} : t("managePublished.accepted")}
@@ -2,6 +2,7 @@
import { useState, useEffect, type HTMLInputTypeAttribute } from "react"; import { useState, useEffect, type HTMLInputTypeAttribute } from "react";
import TextInput from "../../../../components/controls/TextInput"; import TextInput from "../../../../components/controls/TextInput";
import TextArea from "../../../../components/controls/TextArea";
import type { HeaderLockupJustificationValue } from "../../../../components/type/HeaderLockup/HeaderLockup.types"; import type { HeaderLockupJustificationValue } from "../../../../components/type/HeaderLockup/HeaderLockup.types";
import { useTranslation } from "../../../../contexts/MessagesContext"; import { useTranslation } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
@@ -12,6 +13,7 @@ import {
type CreateFlowContentTopBelowMd, type CreateFlowContentTopBelowMd,
} from "../../components/CreateFlowStepShell"; } from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens"; import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
import { CREATE_FLOW_COMMUNITY_SAVE_FORM_ID } from "../../utils/createFlowPaths";
import type { CreateFlowTextStateField } from "../../types"; import type { CreateFlowTextStateField } from "../../types";
type Props = { type Props = {
@@ -21,6 +23,10 @@ type Props = {
/** Figma Flow — Text (`20094:41243`): main column `items-center` + horizontal padding token. */ /** Figma Flow — Text (`20094:41243`): main column `items-center` + horizontal padding token. */
mainAlign?: "start" | "center"; mainAlign?: "start" | "center";
inputType?: HTMLInputTypeAttribute; inputType?: HTMLInputTypeAttribute;
/** Multi-line control (community description). */
multiline?: boolean;
/** Native `required` (community name and save-progress email). */
required?: boolean;
showCharacterCount?: boolean; showCharacterCount?: boolean;
headerJustification?: HeaderLockupJustificationValue; headerJustification?: HeaderLockupJustificationValue;
/** Top spacing under top chrome (`CreateFlowStepShell` / `CreateFlowContentTopBelowMd`). */ /** Top spacing under top chrome (`CreateFlowStepShell` / `CreateFlowContentTopBelowMd`). */
@@ -28,7 +34,7 @@ type Props = {
}; };
/** /**
* Shared narrow-column + TextInput pattern for Create Community text frames. * Shared narrow-column + labelled field pattern for Create Community text frames.
*/ */
export function CreateFlowTextFieldScreen({ export function CreateFlowTextFieldScreen({
messageNamespace, messageNamespace,
@@ -36,6 +42,8 @@ export function CreateFlowTextFieldScreen({
maxLength, maxLength,
mainAlign = "start", mainAlign = "start",
inputType = "text", inputType = "text",
multiline = false,
required = false,
showCharacterCount = true, showCharacterCount = true,
headerJustification = "left", headerJustification = "left",
contentTopBelowMd = "space-1400", contentTopBelowMd = "space-1400",
@@ -69,6 +77,15 @@ export function CreateFlowTextFieldScreen({
const mainItems = const mainItems =
mainAlign === "center" ? "items-center" : "items-start"; mainAlign === "center" ? "items-center" : "items-start";
const isEmail = inputType === "email";
const persistValue = (next: string) => {
setValue(next);
markCreateFlowInteraction();
updateState({ [stateField]: next } as Record<string, string>);
};
const fieldLabel = t("inputLabel");
return ( return (
<CreateFlowStepShell <CreateFlowStepShell
variant="centeredNarrow" variant="centeredNarrow"
@@ -85,22 +102,60 @@ export function CreateFlowTextFieldScreen({
/> />
</div> </div>
<div className="w-full"> <div className="w-full">
{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 <TextInput
className="!transition-none" className="!transition-none"
type={inputType} type={inputType}
name={isEmail ? "email" : undefined}
label={fieldLabel}
placeholder={t("placeholder")} placeholder={t("placeholder")}
value={value} value={value}
onChange={(e) => { onChange={(e) => persistValue(e.target.value)}
const v = e.target.value; onBlur={() => {
setValue(v); const trimmed = isEmail
markCreateFlowInteraction(); ? value.trim().toLowerCase()
updateState({ [stateField]: v } as Record<string, string>); : 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"} inputSize={mdUp ? "medium" : "small"}
formHeader={false} formHeader={false}
showHelpIcon={false}
textHint={hint} textHint={hint}
maxLength={maxLength} maxLength={maxLength}
required={required || isEmail}
autoComplete={isEmail ? "email" : undefined}
inputMode={isEmail ? "email" : undefined}
form={isEmail ? CREATE_FLOW_COMMUNITY_SAVE_FORM_ID : undefined}
/> />
)}
</div> </div>
</div> </div>
</CreateFlowStepShell> </CreateFlowStepShell>
@@ -16,14 +16,24 @@ import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowL
import { fetchAuthSession } from "../../../../../lib/create/api"; import { fetchAuthSession } from "../../../../../lib/create/api";
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils"; import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
import { import {
UploadToServerError, createFlowUploadFailureMessageKey,
uploadCreateFlowFile, uploadCreateFlowFile,
} from "../../../../../lib/create/uploadToServer"; } from "../../../../../lib/create/uploadToServer";
import {
COMMUNITY_AVATAR_ACCEPT,
messageKeyForCreateFlowUploadReason,
validateCreateFlowUploadFile,
} from "../../../../../lib/create/createFlowUploadValidation";
import { import {
clearPendingCommunityAvatarFile, clearPendingCommunityAvatarFile,
readPendingCommunityAvatarFile,
storePendingCommunityAvatarFile, storePendingCommunityAvatarFile,
} from "../../../../../lib/create/pendingCommunityAvatarUpload"; } from "../../../../../lib/create/pendingCommunityAvatarUpload";
function hasCommunityAvatarUrl(url: string | undefined): boolean {
return typeof url === "string" && url.trim().length > 0;
}
/** Create Community — Figma Flow — Upload `20094:41524`. */ /** Create Community — Figma Flow — Upload `20094:41524`. */
export function CommunityUploadScreen() { export function CommunityUploadScreen() {
const m = useMessages(); const m = useMessages();
@@ -54,17 +64,57 @@ export function CommunityUploadScreen() {
[localPreviewUrl], [localPreviewUrl],
); );
const 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 () => {
cancelled = true;
};
}, []);
const resolveUploadError = useCallback( const resolveUploadError = useCallback(
(err: unknown) => { (err: unknown) => tUpload(createFlowUploadFailureMessageKey(err)),
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");
}
}
return tUpload("errors.generic");
},
[tUpload], [tUpload],
); );
@@ -94,6 +144,16 @@ export function CommunityUploadScreen() {
} }
if (signedIn === false) { if (signedIn === false) {
const validated = await validateCreateFlowUploadFile(
file,
"communityAvatar",
);
if (validated.ok === false) {
setErrorMessage(
tUpload(messageKeyForCreateFlowUploadReason(validated.reason)),
);
return;
}
try { try {
await storePendingCommunityAvatarFile(file); await storePendingCommunityAvatarFile(file);
setLocalPreviewUrl((prev) => { setLocalPreviewUrl((prev) => {
@@ -121,24 +181,16 @@ export function CommunityUploadScreen() {
if (prev) URL.revokeObjectURL(prev); if (prev) URL.revokeObjectURL(prev);
return null; return null;
}); });
if ( if (hasCommunityAvatarUrl(state.communityAvatarUrl)) {
typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0
) {
updateState({ communityAvatarUrl: undefined }); updateState({ communityAvatarUrl: undefined });
} }
// Clear any anonymous staged blob so the post-sign-in flush won't resurrect it.
void clearPendingCommunityAvatarFile(); void clearPendingCommunityAvatarFile();
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ""; fileInputRef.current.value = "";
} }
}, [markCreateFlowInteraction, state.communityAvatarUrl, updateState]); }, [markCreateFlowInteraction, state.communityAvatarUrl, updateState]);
const displaySrc = const displaySrc = serverAvatarUrl ?? localPreviewUrl;
typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0
? state.communityAvatarUrl.trim()
: localPreviewUrl;
const hasPreview = typeof displaySrc === "string" && displaySrc.length > 0; const hasPreview = typeof displaySrc === "string" && displaySrc.length > 0;
return ( return (
@@ -161,7 +213,7 @@ export function CommunityUploadScreen() {
type="file" type="file"
className="sr-only" className="sr-only"
tabIndex={-1} tabIndex={-1}
accept="image/jpeg,image/png,image/webp,image/gif" accept={COMMUNITY_AVATAR_ACCEPT}
aria-label={u.hintText} aria-label={u.hintText}
onChange={handleFileChange} onChange={handleFileChange}
/> />
@@ -0,0 +1,45 @@
"use client";
import { GovernanceTemplateGrid } from "../../../components/sections/GovernanceTemplateGrid";
import type { TemplateGridCardEntry } from "../../../../lib/templates/templateGridPresentation";
import { useTranslation } from "../../../contexts/MessagesContext";
import { useTemplatesFacetGridEntries } from "../../../(marketing)/templates/useTemplatesFacetGridEntries";
import { CreateFlowHeaderLockup } from "../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../components/CreateFlowStepShell";
import { CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS } from "../components/createFlowLayoutTokens";
import { buildTemplateReviewHref } from "../utils/flowSteps";
export function CreateFlowTemplatesPageClient({
initialGridEntries,
}: {
initialGridEntries: TemplateGridCardEntry[];
}) {
const t = useTranslation("pages.templates");
const entries = useTemplatesFacetGridEntries({
initialGridEntries,
enableFacetRecommendations: true,
});
return (
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-1400"
>
<div
className={`mx-auto flex w-full min-w-0 flex-col gap-6 pb-8 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
>
<CreateFlowHeaderLockup
title={t("title")}
description={t("subtitle")}
justification="left"
/>
<GovernanceTemplateGrid
entries={entries}
hrefForTemplate={(slug) =>
buildTemplateReviewHref(slug, { fromCreateWizard: true })
}
/>
</div>
</CreateFlowStepShell>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import messages from "../../../../messages/en/index";
import { listRuleTemplatesFromDb } from "../../../../lib/server/ruleTemplates";
import { routeMetadata } from "../../../../lib/siteMetadata";
import { gridEntriesForFullCatalogWithFallback } from "../../../../lib/templates/templateGridPresentation";
import { CREATE_ROUTES } from "../utils/createFlowPaths";
import { CreateFlowTemplatesPageClient } from "./CreateFlowTemplatesPageClient";
export const metadata: Metadata = routeMetadata(CREATE_ROUTES.templatesPicker, {
title: messages.metadata.templates.title,
});
/** In-flow template catalog after review “Create from template”. */
export default async function CreateFlowTemplatesPage() {
const rows = await listRuleTemplatesFromDb();
const initialGridEntries = gridEntriesForFullCatalogWithFallback(rows);
return <CreateFlowTemplatesPageClient initialGridEntries={initialGridEntries} />;
}
+26 -17
View File
@@ -59,6 +59,7 @@ export type CommunityStructureChipSnapshotRow = {
export type CoreValueDetailEntry = { export type CoreValueDetailEntry = {
meaning: string; meaning: string;
signals: string; signals: string;
supportText?: string;
}; };
/** /**
@@ -84,7 +85,11 @@ export type DecisionApproachDetailEntry = {
applicableScope: string[]; applicableScope: string[];
selectedApplicableScope: string[]; selectedApplicableScope: string[];
stepByStepInstructions: string; stepByStepInstructions: string;
consensusLevel: number; /**
* Catalog presets always set this. User-authored custom cards omit it until
* the author adds a proportion field or edits consensus in the facet form.
*/
consensusLevel?: number;
objectionsDeadlocks: string; objectionsDeadlocks: string;
}; };
@@ -164,6 +169,12 @@ export interface CreateFlowState {
>; >;
membershipMethodDetailsById?: Record<string, MembershipMethodDetailEntry>; membershipMethodDetailsById?: Record<string, MembershipMethodDetailEntry>;
decisionApproachDetailsById?: Record<string, DecisionApproachDetailEntry>; decisionApproachDetailsById?: Record<string, DecisionApproachDetailEntry>;
/**
* Checked key resource ids from the decision-approaches InfoMessageBox
* (`amend`, `finances`, `project`, `discipline`). Selecting one also selects
* the matching Applicable Scope chip on every decision approach.
*/
selectedDecisionKeyResourceIds?: string[];
conflictManagementDetailsById?: Record< conflictManagementDetailsById?: Record<
string, string,
ConflictManagementDetailEntry ConflictManagementDetailEntry
@@ -183,11 +194,13 @@ export interface CreateFlowState {
customMethodCardFieldBlocksById?: Record<string, CustomMethodCardFieldBlock[]>; customMethodCardFieldBlocksById?: Record<string, CustomMethodCardFieldBlock[]>;
/** /**
* Set when a user picks a template (Customize or Use without changes) before * Set when a user picks a template (Customize or Use without changes) before
* completing the community stage. The community-review screen consumes this * completing the community stage. Customize: the community-review screen
* to `router.replace` past `/create/review` to the correct downstream step * `router.replace`s past `/create/review` to `core-values`. Use without
* (`core-values` for customize; `confirm-stakeholders` for use-without-changes) * changes: navigation skips to name description optional photo, then
* once community data is captured. Cleared the moment the redirect fires, so * `confirm-stakeholders` (the pin keeps that skip list active). Set only for
* later visits to `/create/review` render normally. * **direct** template entry, not when community identity was already collected
* in-flow. Cleared when community-review consumes a customize pin, or when
* the flow is reset.
*/ */
pendingTemplateAction?: { pendingTemplateAction?: {
slug: string; slug: string;
@@ -195,23 +208,20 @@ export interface CreateFlowState {
}; };
/** /**
* Set when the user chooses **Use without changes** on a template-review * Set when the user chooses **Use without changes** on a template-review
* page. The custom-rule segment (`core-values` `conflict-management`) is * page. Used so Back from `community-name` (identity-only path) returns to
* skipped, so linear `getPreviousStep("confirm-stakeholders")` would wrongly * `/create/review-template/{slug}` instead of the intro. Cleared when the user
* point at `conflict-management`. Navigation uses this slug so Back from * picks **Customize** from template review or when the flow state is cleared.
* `confirm-stakeholders` returns to `/create/review-template/{slug}`.
* Cleared when the user picks **Customize** from template review (normal
* linear back applies) or when the flow state is cleared.
*/ */
templateReviewBackSlug?: string; templateReviewBackSlug?: string;
/** /**
* True when the user opened `/create/review-template/{slug}` from the create * True when the user opened `/create/review-template/{slug}` from the create
* wizard (`/templates?fromFlow=1` after `/create/review`). Persisted so Back * wizard (`/create/templates` after `/create/review`). Persisted so Back
* from template review targets `/create/review` and so returning from * from template review targets `/create/review` and so returning from
* `confirm-stakeholders` can re-apply `?fromFlow=1` on the template URL. * `confirm-stakeholders` can re-apply `?fromFlow=1` on the template URL.
*/ */
templateReviewEntryFromCreateFlow?: boolean; templateReviewEntryFromCreateFlow?: boolean;
/** /**
* When set, **Finalize** and signed-in **Save & Exit** update this published * When set, **Publish** and signed-in **Save & Exit** update this published
* rule (PATCH) instead of POSTing a new rule or only saving a draft. * rule (PATCH) instead of POSTing a new rule or only saving a draft.
*/ */
editingPublishedRuleId?: string; editingPublishedRuleId?: string;
@@ -268,9 +278,8 @@ export interface CreateFlowContextValue {
* *
* Current consumer: {@link SignedInDraftHydration} when a signed-in user * Current consumer: {@link SignedInDraftHydration} when a signed-in user
* has already started editing, we skip replaying their server draft on top * has already started editing, we skip replaying their server draft on top
* of in-progress local state. Save & Exit visibility is driven by step * of in-progress local state. Save & Exit visibility is
* index (`SAVE_EXIT_FROM_STEP_INDEX` in `CreateFlowLayoutClient`), not this * `shouldOfferCreateFlowSaveAndExit` in `utils/flowSteps.ts`, not this flag.
* flag.
*/ */
interactionTouched: boolean; interactionTouched: boolean;
markCreateFlowInteraction: () => void; markCreateFlowInteraction: () => void;
@@ -21,7 +21,13 @@ export function readCoreValueDetailsFromLocalStorage(): Record<
if (typeof o.meaning !== "string" || typeof o.signals !== "string") { if (typeof o.meaning !== "string" || typeof o.signals !== "string") {
continue; continue;
} }
out[k] = { meaning: o.meaning, signals: o.signals }; out[k] = {
meaning: o.meaning,
signals: o.signals,
...(typeof o.supportText === "string"
? { supportText: o.supportText }
: {}),
};
} }
return out; return out;
} catch { } catch {
@@ -11,7 +11,7 @@
* exposes a native size that matches, this module should collapse. * exposes a native size that matches, this module should collapse.
*/ */
export const CREATE_FLOW_FOOTER_BUTTON_CLASS = export const CREATE_FLOW_FOOTER_BUTTON_CLASS =
"md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] " + "!text-x-small-label md:!text-small-label " +
"!px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] " + "!px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] " +
"!py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]"; "!py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]";
@@ -10,8 +10,8 @@ type FooterMessages = typeof footerMessages;
* from this map fall back to `footer.next`. * from this map fall back to `footer.next`.
* *
* `final-review` is handled separately by the caller because its label * `final-review` is handled separately by the caller because its label
* also depends on the in-flight publish flag (`finalizeButtonPublishing` * also depends on the in-flight publish flag (`publishButtonPublishing`
* vs `finalizeCommunityRule`). * vs `publishCommunityRule`).
*/ */
const DEFAULT_FOOTER_LABEL_BY_STEP: ReadonlyMap< const DEFAULT_FOOTER_LABEL_BY_STEP: ReadonlyMap<
CreateFlowStep, CreateFlowStep,
+9 -2
View File
@@ -7,14 +7,21 @@ import type { CreateFlowStep } from "../types";
import { import {
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY, CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY, CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
FIRST_STEP,
} from "./flowSteps"; } from "./flowSteps";
/** Associates the save-progress email field with the layout footer submit. */
export const CREATE_FLOW_COMMUNITY_SAVE_FORM_ID =
"create-flow-community-save";
export const CREATE_ROUTES = { export const CREATE_ROUTES = {
root: "/", root: "/",
createRoot: "/create", createRoot: "/create",
/** First step resolves via redirect from `/create`. */ /** Direct path to the first wizard step so client navigations skip the redirect hop. */
createFirstStep: "/create", createFirstStep: `/create/${FIRST_STEP}`,
review: "/create/review", review: "/create/review",
/** In-flow template catalog (wizard chrome). Marketing catalog remains `/templates`. */
templatesPicker: "/create/templates",
finalReview: "/create/final-review", finalReview: "/create/final-review",
completed: "/create/completed", completed: "/create/completed",
editRule: "/create/edit-rule", editRule: "/create/edit-rule",
@@ -20,7 +20,7 @@ const PROPORTION_BY_STEP_INDEX: readonly ProportionBarState[] = [
"2-2", // membership-methods (Figma — Compact Card Stack `20858:13947`) "2-2", // membership-methods (Figma — Compact Card Stack `20858:13947`)
"2-3", // decision-approaches (Figma Flow — Right Rail `20523:23509`) "2-3", // decision-approaches (Figma Flow — Right Rail `20523:23509`)
"3-0", // conflict-management (Figma Flow — Compact Card Stack `20879:15979`; start of Review segment) "3-0", // conflict-management (Figma Flow — Compact Card Stack `20879:15979`; start of Review segment)
"3-1", // confirm-stakeholders "3-2", // confirm-stakeholders (same fill as final-review)
"3-2", // final-review "3-2", // final-review
"3-2", // completed "3-2", // completed
] as const; ] as const;
+128 -19
View File
@@ -47,8 +47,44 @@ export const FIRST_STEP: CreateFlowStep = FLOW_STEP_ORDER[0];
/** Options for navigation when the email / magic-link save step is not shown (signed-in users). */ /** Options for navigation when the email / magic-link save step is not shown (signed-in users). */
export type CreateFlowNavigationOptions = { export type CreateFlowNavigationOptions = {
skipCommunitySave?: boolean; skipCommunitySave?: boolean;
/**
* Template **Use without changes**: collect community name, description, and
* optional photo only. Skips intro, structure, size, save, community review,
* and the custom-rule authoring segment.
*/
useWithoutChangesIdentityOnly?: boolean;
}; };
/**
* Community + custom-rule steps skipped when
* {@link CreateFlowNavigationOptions.useWithoutChangesIdentityOnly} is set.
* Remaining identity steps: `community-name` `community-context`
* `community-upload`, then `confirm-stakeholders`.
*/
const USE_WITHOUT_CHANGES_IDENTITY_SKIP = new Set<CreateFlowStep>([
"informational",
"community-structure",
"community-size",
"community-save",
"review",
"core-values",
"communication-methods",
"membership-methods",
"decision-approaches",
"conflict-management",
]);
function shouldSkipStep(
step: CreateFlowStep,
options?: CreateFlowNavigationOptions,
): boolean {
if (options?.skipCommunitySave && step === "community-save") return true;
return Boolean(
options?.useWithoutChangesIdentityOnly &&
USE_WITHOUT_CHANGES_IDENTITY_SKIP.has(step),
);
}
/** /**
* Returns the next step in the flow, or null if current is last/invalid * Returns the next step in the flow, or null if current is last/invalid
*/ */
@@ -57,13 +93,14 @@ export function getNextStep(
options?: CreateFlowNavigationOptions, options?: CreateFlowNavigationOptions,
): CreateFlowStep | null { ): CreateFlowStep | null {
if (!currentStep) return null; if (!currentStep) return null;
const index = FLOW_STEP_ORDER.indexOf(currentStep); let index = FLOW_STEP_ORDER.indexOf(currentStep);
if (index === -1 || index === FLOW_STEP_ORDER.length - 1) return null; if (index === -1) return null;
const next = FLOW_STEP_ORDER[index + 1] as CreateFlowStep; while (index < FLOW_STEP_ORDER.length - 1) {
if (options?.skipCommunitySave && next === "community-save") { index += 1;
return getNextStep("community-save", options); const next = FLOW_STEP_ORDER[index] as CreateFlowStep;
if (!shouldSkipStep(next, options)) return next;
} }
return next; return null;
} }
/** /**
@@ -74,20 +111,27 @@ export function getPreviousStep(
options?: CreateFlowNavigationOptions, options?: CreateFlowNavigationOptions,
): CreateFlowStep | null { ): CreateFlowStep | null {
if (!currentStep) return null; if (!currentStep) return null;
const index = FLOW_STEP_ORDER.indexOf(currentStep); let index = FLOW_STEP_ORDER.indexOf(currentStep);
if (index <= 0) return null; if (index <= 0) return null;
const prev = FLOW_STEP_ORDER[index - 1] as CreateFlowStep; while (index > 0) {
if (options?.skipCommunitySave && prev === "community-save") { index -= 1;
return getPreviousStep("community-save", options); const prev = FLOW_STEP_ORDER[index] as CreateFlowStep;
if (!shouldSkipStep(prev, options)) return prev;
} }
return prev; return null;
} }
/** /**
* Where the create-flow footer Back action should go. Usually the previous * Where the create-flow footer Back action should go. Usually the previous
* step in {@link FLOW_STEP_ORDER}; when the user reached `confirm-stakeholders` * step in {@link FLOW_STEP_ORDER}.
* via template **Use without changes**, Back returns to template review instead *
* of `conflict-management` (that segment was skipped). * Template **Use without changes** exceptions:
* - Direct from a template (`useWithoutChangesIdentityOnly`): Back from
* `community-name` returns to template review; Back from
* `confirm-stakeholders` is `community-upload`.
* - In-flow (`?fromFlow=1`, community identity already collected): Back
* from `confirm-stakeholders` returns to template review instead of
* `conflict-management`.
*/ */
export type CreateFlowBackTarget = export type CreateFlowBackTarget =
| { kind: "step"; step: CreateFlowStep } | { kind: "step"; step: CreateFlowStep }
@@ -102,7 +146,18 @@ export function resolveCreateFlowBackTarget(
typeof templateReviewBackSlug === "string" typeof templateReviewBackSlug === "string"
? templateReviewBackSlug.trim() ? templateReviewBackSlug.trim()
: ""; : "";
if (currentStep === "confirm-stakeholders" && slug.length > 0) { if (
currentStep === "community-name" &&
options?.useWithoutChangesIdentityOnly &&
slug.length > 0
) {
return { kind: "templateReview", slug };
}
if (
currentStep === "confirm-stakeholders" &&
slug.length > 0 &&
!options?.useWithoutChangesIdentityOnly
) {
return { kind: "templateReview", slug }; return { kind: "templateReview", slug };
} }
const prev = getPreviousStep(currentStep, options); const prev = getPreviousStep(currentStep, options);
@@ -117,15 +172,37 @@ export function getStepIndex(step: CreateFlowStep | null | undefined): number {
return FLOW_STEP_ORDER.indexOf(step); return FLOW_STEP_ORDER.indexOf(step);
} }
/** First wizard step that offers Save & Exit (first Create Community select). */
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-structure");
/**
* Top-nav Save & Exit (vs Exit). Guests and signed-in users share this from
* `community-structure` onward and on `edit-rule`. On completed, guests still
* get Save & Exit (claim/login); signed-in users get Exit (already owned).
*
* @param sessionUser `null` is a guest. `undefined` (session in flight) is
* not a guest completed stays Exit until the session resolves.
*/
export function shouldOfferCreateFlowSaveAndExit(
currentStep: CreateFlowStep | null | undefined,
sessionUser?: { id: string } | null,
): boolean {
if (currentStep == null) return false;
if (currentStep === "completed") return sessionUser === null;
if (currentStep === "edit-rule") return true;
return getStepIndex(currentStep) >= SAVE_EXIT_FROM_STEP_INDEX;
}
/** /**
* Steps where below `lg` the main column scrolls with split layout * Steps where below `lg` the main column scrolls with split layout
* (`CreateFlowLayoutClient` Linear CR-92 §4). * (`CreateFlowLayoutClient`).
*/ */
export const CREATE_FLOW_SELECT_SPLIT_SCROLL_STEPS: readonly CreateFlowStep[] = [ export const CREATE_FLOW_SELECT_SPLIT_SCROLL_STEPS: readonly CreateFlowStep[] = [
"community-size", "community-size",
"community-structure", "community-structure",
"core-values", "core-values",
"decision-approaches", "decision-approaches",
"confirm-stakeholders",
] as const; ] as const;
export function createFlowStepUsesSelectSplitScroll( export function createFlowStepUsesSelectSplitScroll(
@@ -158,21 +235,53 @@ export function parseCreateFlowScreenFromPathname(
): CreateFlowStep | null { ): CreateFlowStep | null {
if (!pathname || pathname.length === 0) return null; if (!pathname || pathname.length === 0) return null;
if (pathname.includes("/create/review-template/")) return null; if (pathname.includes("/create/review-template/")) return null;
if (isCreateFlowTemplatesPickerPath(pathname)) return null;
const parts = pathname.split("/").filter(Boolean); const parts = pathname.split("/").filter(Boolean);
const createIdx = parts.indexOf("create"); const createIdx = parts.indexOf("create");
if (createIdx === -1 || createIdx >= parts.length - 1) return null; if (createIdx === -1 || createIdx >= parts.length - 1) return null;
const segment = parts[createIdx + 1]; const segment = parts[createIdx + 1];
if (segment === "review-template") return null; if (segment === "review-template" || segment === "templates") return null;
return isValidStep(segment) ? segment : null; return isValidStep(segment) ? segment : null;
} }
/** Same query as `/templates?fromFlow=1` — template was picked after `/create/review`. */ /**
* `/create/templates` in-flow catalog after review Create from template.
* Not a wizard step; layout keeps Back / Save & Exit and does not use
* marketing `/templates` chrome.
*/
export function isCreateFlowTemplatesPickerPath(
pathname: string | null | undefined,
): boolean {
if (!pathname) return false;
const parts = pathname.split("/").filter(Boolean);
const createIdx = parts.indexOf("create");
if (createIdx === -1 || createIdx !== parts.length - 2) return false;
return parts[createIdx + 1] === "templates";
}
/** Same query as `/create/review-template/…?fromFlow=1` — template was picked in-flow. */
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY = "fromFlow" as const; export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY = "fromFlow" as const;
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const; export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
/**
* Catalog / marketing template preview (`/create/review-template/[slug]`
* without `?fromFlow=1`). There is nothing to save yet; top-nav Exit should
* leave immediately. In-flow picks keep the usual save / leave-confirm path.
*/
export function isDirectTemplateReviewEntry(
pathname: string | null | undefined,
searchParams?: { get: (name: string) => string | null } | null,
): boolean {
if (!pathname?.includes("/create/review-template/")) return false;
return (
searchParams?.get(TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY) !==
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE
);
}
/** /**
* Only set from `/create/review` Create from template with `fromFlow=1`. * Only set from `/create/review` Create from template with `fromFlow=1`.
* Enables facet-ranked `GET /api/templates` + RECOMMENDED on the grid; omit * Enables facet-ranked `GET /api/templates` + RECOMMENDED on the grid; omit
@@ -181,7 +290,7 @@ export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
export const TEMPLATES_FACET_RECOMMEND_QUERY = "recommendTemplates" as const; export const TEMPLATES_FACET_RECOMMEND_QUERY = "recommendTemplates" as const;
export const TEMPLATES_FACET_RECOMMEND_VALUE = "1" as const; export const TEMPLATES_FACET_RECOMMEND_VALUE = "1" as const;
/** `/create/completed?celebrate=1` — post-finalize toast; set only after **initial** POST publish, not PATCH updates. */ /** `/create/completed?celebrate=1` — post-publish toast; set only after **initial** POST publish, not PATCH updates. */
export const CREATE_FLOW_COMPLETED_CELEBRATE_QUERY = "celebrate" as const; export const CREATE_FLOW_COMPLETED_CELEBRATE_QUERY = "celebrate" as const;
export const CREATE_FLOW_COMPLETED_CELEBRATE_VALUE = "1" as const; export const CREATE_FLOW_COMPLETED_CELEBRATE_VALUE = "1" as const;
@@ -4,19 +4,92 @@ import { clearCoreValueDetailsLocalStorage } from "./coreValueDetailsLocalStorag
import { isBackendSyncEnabled } from "../../../../lib/create/backendSyncEnabled"; import { isBackendSyncEnabled } from "../../../../lib/create/backendSyncEnabled";
/**
* Sentinel set on click and cleared once the in-flight DELETE settles. Read by
* {@link SignedInDraftHydration} so it skips the server draft fetch while the
* fresh-entry cleanup is racing the user's first paint of `/create`.
*/
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 {
return window.sessionStorage.getItem(FRESH_ENTRY_PENDING_KEY) === "1";
} catch {
return false;
}
}
function setFreshEntryPending(): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.setItem(FRESH_ENTRY_PENDING_KEY, "1");
} catch {
/* ignore — sessionStorage may be unavailable */
}
}
function clearFreshEntryPending(): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.removeItem(FRESH_ENTRY_PENDING_KEY);
} catch {
/* ignore */
}
}
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 * Call **before** navigating into `/create` from marketing or profile new rule
* entry points so signed-in + sync matches an anonymous fresh start: wipe * 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,
* Anonymous `DELETE` is harmless (401). Await ensures the server draft is gone * `DELETE /api/drafts/me`.
* before mount so {@link SignedInDraftHydration} does not rehydrate stale work. *
* Synchronous variant: returns immediately after clearing local state and
* scheduling the server draft delete in the background. Sets a sessionStorage
* sentinel that {@link SignedInDraftHydration} checks before fetching, so the
* brief race window does not hydrate from a not-yet-deleted server draft.
* *
* Do **not** use for Continue draft that path should load the server draft. * Do **not** use for Continue draft that path should load the server draft.
*/ */
export async function prepareFreshCreateFlowEntry(): Promise<void> { export function prepareFreshCreateFlowEntrySync(
options: PrepareFreshCreateFlowEntryOptions = {},
): void {
const signedIn = options.signedIn === true;
clearAnonymousCreateFlowStorage(); clearAnonymousCreateFlowStorage();
clearCoreValueDetailsLocalStorage(); clearCoreValueDetailsLocalStorage();
if (isBackendSyncEnabled()) { clearServerDraftWhenSignedIn(signedIn);
}
/**
* Awaitable variant kept for callers that genuinely need the DELETE to settle
* before continuing (e.g. tests, programmatic reset flows). Most click handlers
* should use {@link prepareFreshCreateFlowEntrySync} for instant navigation.
*/
export async function prepareFreshCreateFlowEntry(
options: PrepareFreshCreateFlowEntryOptions = {},
): Promise<void> {
const signedIn = options.signedIn === true;
clearAnonymousCreateFlowStorage();
clearCoreValueDetailsLocalStorage();
if (!signedIn || !isBackendSyncEnabled()) return;
setFreshEntryPending();
try {
await deleteServerDraft(); await deleteServerDraft();
} finally {
clearFreshEntryPending();
} }
} }
+40 -2
View File
@@ -1,8 +1,46 @@
import type { ReactNode } from "react"; 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()`
// transitively in `ConditionalNavigationClient`) — both are uncached, so it
// lives behind a `<Suspense>` boundary so the rest of the layout stays in the
// static shell while the session/pathname-aware nav streams in. The fallback
// is `null` because any non-null fallback would also need to live in the
// static shell, and the nav's chromeless decision depends on the pathname
// (e.g. `/create/*` and `/login` render no top-nav). Brief blank-nav while
// the dynamic island resolves is acceptable on signed-in product surfaces.
//
// Signed-in product surfaces (`/create/*`, `/login`) run without the marketing // Signed-in product surfaces (`/create/*`, `/login`) run without the marketing
// footer. `/profile` adds it via `profile/layout.tsx`. Per-route chrome (e.g. // footer. `/profile` adds it via `profile/layout.tsx`. Per-route chrome (e.g.
// CreateFlow) is composed in nested layouts. // CreateFlow) is composed in nested layouts.
export default function AppLayout({ children }: { children: ReactNode }) { export default function AppLayout({ children }: { children: ReactNode }) {
return <main className="flex-1">{children}</main>; return (
<MessagesProvider messages={messages}>
<AuthModalProvider>
<SkipToContent />
<Suspense fallback={null}>
<ConditionalNavigation />
</Suspense>
<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 type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { NO_INDEX_ROBOTS, routeMetadata } from "../../../lib/siteMetadata";
export const metadata: Metadata = { export const metadata: Metadata = routeMetadata("/login", {
title: "Log in · CommunityRule", title: messages.metadata.login.title,
robots: { index: false, follow: false }, robots: NO_INDEX_ROBOTS,
}; });
export default function LoginLayout({ export default function LoginLayout({
children, children,
+2 -2
View File
@@ -14,7 +14,7 @@ function LoginLoadingFallback() {
const t = useTranslation("pages.login"); const t = useTranslation("pages.login");
return ( return (
<div className={`${loginPageBgClass} flex items-center justify-center`}> <div className={`${loginPageBgClass} flex items-center justify-center`}>
<p className="font-inter text-[14px] text-[var(--color-content-default-primary)]"> <p className="text-[14px] text-[var(--color-content-default-primary)]">
{t("loadingFallback")} {t("loadingFallback")}
</p> </p>
</div> </div>
@@ -42,7 +42,7 @@ function LoginWithSearchParams() {
belowCard={ belowCard={
<Link <Link
href="/" href="/"
className="font-inter font-normal text-[14px] leading-[20px] text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90" className="text-small-paragraph text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
> >
{t("backToHome")} {t("backToHome")}
</Link> </Link>
+4 -6
View File
@@ -23,7 +23,7 @@ import {
import type { CreateFlowStep } from "../create/types"; import type { CreateFlowStep } from "../create/types";
import { clearAnonymousCreateFlowStorage } from "../create/utils/anonymousDraftStorage"; import { clearAnonymousCreateFlowStorage } from "../create/utils/anonymousDraftStorage";
import { clearCoreValueDetailsLocalStorage } from "../create/utils/coreValueDetailsLocalStorage"; import { clearCoreValueDetailsLocalStorage } from "../create/utils/coreValueDetailsLocalStorage";
import { prepareFreshCreateFlowEntry } from "../create/utils/prepareFreshCreateFlowEntry"; import { prepareFreshCreateFlowEntrySync } from "../create/utils/prepareFreshCreateFlowEntry";
import { useMediaQuery } from "../../hooks/useMediaQuery"; import { useMediaQuery } from "../../hooks/useMediaQuery";
import { import {
ProfilePageSignedOutView, ProfilePageSignedOutView,
@@ -253,10 +253,8 @@ export default function ProfilePageClient() {
}, [draft, router]); }, [draft, router]);
const handleStartNewCustomRule = useCallback(() => { const handleStartNewCustomRule = useCallback(() => {
void (async () => { prepareFreshCreateFlowEntrySync({ signedIn: true });
await prepareFreshCreateFlowEntry(); router.push("/create/informational");
router.push("/create");
})();
}, [router]); }, [router]);
const handleRequestDeleteDraft = useCallback(() => { const handleRequestDeleteDraft = useCallback(() => {
@@ -305,7 +303,7 @@ export default function ProfilePageClient() {
if (!sessionLoaded) { if (!sessionLoaded) {
return ( return (
<div className="mx-auto max-w-3xl px-4 py-16"> <div className="mx-auto max-w-3xl px-4 py-16">
<p className="font-inter text-sm text-[var(--color-content-default-secondary)]"> <p className="text-small-paragraph text-[var(--color-content-default-secondary)]">
{t("loading")} {t("loading")}
</p> </p>
</div> </div>
@@ -81,7 +81,7 @@ export type ProfilePageViewProps = {
* mobile: smaller Bricolage. * mobile: smaller Bricolage.
*/ */
const profileSectionHeadingClass = const profileSectionHeadingClass =
"font-bricolage text-base font-bold leading-[22px] text-[var(--color-content-default-primary)] md:font-inter md:text-xl md:font-bold md:leading-7 xl:font-bricolage-grotesque xl:font-bold xl:text-[28px] xl:leading-9"; "font-bricolage text-xx-small-heading text-[var(--color-content-default-primary)] md:font-sans md:text-x-small-heading xl:font-bricolage-grotesque xl:text-medium-heading";
export type ProfilePageSignedOutViewProps = { export type ProfilePageSignedOutViewProps = {
onSignIn: () => void; onSignIn: () => void;
@@ -122,11 +122,11 @@ export function ProfilePageSignedOutView({
<> <>
<h1 <h1
id={titleId} id={titleId}
className="font-inter text-xl font-bold leading-7 text-[var(--color-content-default-primary)] md:font-bricolage-grotesque md:text-[28px] md:font-bold md:leading-[36px]" className="text-x-small-heading text-[var(--color-content-default-primary)] md:font-bricolage-grotesque md:text-medium-heading"
> >
{t("pageTitle")} {t("pageTitle")}
</h1> </h1>
<p className="max-w-[640px] font-inter text-sm font-normal leading-5 text-[var(--color-content-default-tertiary)] md:text-base md:leading-6"> <p className="max-w-[640px] text-small-paragraph text-[var(--color-content-default-tertiary)] md:text-medium-paragraph">
{t("signInPrompt")} {t("signInPrompt")}
</p> </p>
</> </>
@@ -275,11 +275,11 @@ export function ProfilePageView({
<> <>
<h1 <h1
id={titleId} id={titleId}
className="font-inter text-xl font-bold leading-7 text-[var(--color-content-default-primary)] md:font-bricolage-grotesque md:text-[28px] md:font-bold md:leading-[36px]" className="text-x-small-heading text-[var(--color-content-default-primary)] md:font-bricolage-grotesque md:text-medium-heading"
> >
{welcomeTitle} {welcomeTitle}
</h1> </h1>
<p className="max-w-[640px] font-inter text-sm font-normal leading-5 text-[var(--color-content-default-tertiary)] md:text-base md:leading-6"> <p className="max-w-[640px] text-small-paragraph text-[var(--color-content-default-tertiary)] md:text-medium-paragraph">
{welcomeBody} {welcomeBody}
</p> </p>
</> </>
@@ -381,7 +381,7 @@ export function ProfilePageView({
))} ))}
</div> </div>
{rules.length === 0 && !rulesError && !showDraftCard ? ( {rules.length === 0 && !rulesError && !showDraftCard ? (
<p className="font-inter text-sm text-[var(--color-content-default-tertiary)]"> <p className="text-small-paragraph text-[var(--color-content-default-tertiary)]">
{t("yourRulesEmpty")} {t("yourRulesEmpty")}
</p> </p>
) : null} ) : null}
+6 -4
View File
@@ -1,10 +1,12 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { NO_INDEX_ROBOTS, routeMetadata } from "../../../lib/siteMetadata";
import ProfilePageClient from "./ProfilePageClient"; import ProfilePageClient from "./ProfilePageClient";
export const metadata: Metadata = { export const metadata: Metadata = routeMetadata("/profile", {
title: "Profile · CommunityRule", title: messages.metadata.profile.title,
robots: { index: false, follow: false }, robots: NO_INDEX_ROBOTS,
}; });
export default function ProfilePage() { export default function ProfilePage() {
return <ProfilePageClient />; return <ProfilePageClient />;
+13 -13
View File
@@ -45,7 +45,7 @@ export default function ComponentsPreview() {
<h1 className="font-bricolage-grotesque text-[48px] font-bold leading-[56px] text-[var(--color-content-default-primary)]"> <h1 className="font-bricolage-grotesque text-[48px] font-bold leading-[56px] text-[var(--color-content-default-primary)]">
Component Preview Component Preview
</h1> </h1>
<p className="font-inter text-[18px] leading-[24px] text-[var(--color-content-default-secondary)]"> <p className="text-[18px] leading-[24px] text-[var(--color-content-default-secondary)]">
Navigation Link (Figma 21861:21428) and List (21863:45631 / 45493 / Navigation Link (Figma 21861:21428) and List (21863:45631 / 45493 /
4405) with ListEntry (21844:4118). Use Tab and hover to review 4405) with ListEntry (21844:4118). Use Tab and hover to review
states. states.
@@ -53,12 +53,12 @@ export default function ComponentsPreview() {
</header> </header>
<section className="space-y-[var(--spacing-scale-016)]"> <section className="space-y-[var(--spacing-scale-016)]">
<h2 className="font-bricolage-grotesque text-[32px] font-bold leading-[40px] text-[var(--color-content-default-primary)]"> <h2 className="font-bricolage-grotesque text-large-heading text-[var(--color-content-default-primary)]">
Link Link
</h2> </h2>
<div className="flex flex-col gap-8 rounded-[var(--radius-300,12px)] bg-[var(--color-surface-default-secondary)] p-[var(--spacing-scale-032)]"> <div className="flex flex-col gap-8 rounded-[var(--radius-300,12px)] bg-[var(--color-surface-default-secondary)] p-[var(--spacing-scale-032)]">
<div> <div>
<p className="mb-4 font-inter text-sm text-[var(--color-content-default-tertiary)]"> <p className="mb-4 text-small-paragraph text-[var(--color-content-default-tertiary)]">
Light surface Light surface
</p> </p>
<div className="flex flex-col gap-4 rounded-lg bg-white p-6"> <div className="flex flex-col gap-4 rounded-lg bg-white p-6">
@@ -105,7 +105,7 @@ export default function ComponentsPreview() {
</div> </div>
<div> <div>
<p className="mb-4 font-inter text-sm text-[var(--color-content-default-tertiary)]"> <p className="mb-4 text-small-paragraph text-[var(--color-content-default-tertiary)]">
Dark surface Dark surface
</p> </p>
<div className="flex flex-col gap-4 rounded-lg bg-[var(--color-gray-800)] p-6"> <div className="flex flex-col gap-4 rounded-lg bg-[var(--color-gray-800)] p-6">
@@ -154,22 +154,22 @@ export default function ComponentsPreview() {
</section> </section>
<section className="space-y-[var(--spacing-scale-016)]"> <section className="space-y-[var(--spacing-scale-016)]">
<h2 className="font-bricolage-grotesque text-[32px] font-bold leading-[40px] text-[var(--color-content-default-primary)]"> <h2 className="font-bricolage-grotesque text-large-heading text-[var(--color-content-default-primary)]">
Divider Divider
</h2> </h2>
<p className="font-inter text-sm text-[var(--color-content-default-tertiary)]"> <p className="text-small-paragraph text-[var(--color-content-default-tertiary)]">
Utility / Divider (450:1941). List rows use the Content line; Menu is Utility / Divider (450:1941). List rows use the Content line; Menu is
slightly higher-contrast. slightly higher-contrast.
</p> </p>
<div className="max-w-md space-y-4"> <div className="max-w-md space-y-4">
<div> <div>
<p className="mb-1 font-inter text-xs text-[var(--color-content-default-tertiary)]"> <p className="mb-1 text-x-small-paragraph text-[var(--color-content-default-tertiary)]">
Content Content
</p> </p>
<Divider type="content" orientation="horizontal" /> <Divider type="content" orientation="horizontal" />
</div> </div>
<div> <div>
<p className="mb-1 font-inter text-xs text-[var(--color-content-default-tertiary)]"> <p className="mb-1 text-x-small-paragraph text-[var(--color-content-default-tertiary)]">
Menu Menu
</p> </p>
<Divider type="menu" orientation="horizontal" /> <Divider type="menu" orientation="horizontal" />
@@ -178,16 +178,16 @@ export default function ComponentsPreview() {
</section> </section>
<section className="space-y-[var(--spacing-scale-024)]"> <section className="space-y-[var(--spacing-scale-024)]">
<h2 className="font-bricolage-grotesque text-[32px] font-bold leading-[40px] text-[var(--color-content-default-primary)]"> <h2 className="font-bricolage-grotesque text-large-heading text-[var(--color-content-default-primary)]">
List List
</h2> </h2>
<p className="font-inter text-sm text-[var(--color-content-default-tertiary)]"> <p className="text-small-paragraph text-[var(--color-content-default-tertiary)]">
List frame: S (21863:45631), M (21863:45493), L (21844:4405). Row: List frame: S (21863:45631), M (21863:45493), L (21844:4405). Row:
ListEntry (21844:4118). ListEntry (21844:4118).
</p> </p>
<div className="space-y-[var(--spacing-scale-024)]"> <div className="space-y-[var(--spacing-scale-024)]">
<div> <div>
<h3 className="mb-2 font-inter text-sm font-medium text-[var(--color-content-default-secondary)]"> <h3 className="mb-2 text-sm font-medium text-[var(--color-content-default-secondary)]">
Small Small
</h3> </h3>
<div className="mx-auto max-w-[1044px]"> <div className="mx-auto max-w-[1044px]">
@@ -195,7 +195,7 @@ export default function ComponentsPreview() {
</div> </div>
</div> </div>
<div> <div>
<h3 className="mb-2 font-inter text-sm font-medium text-[var(--color-content-default-secondary)]"> <h3 className="mb-2 text-sm font-medium text-[var(--color-content-default-secondary)]">
Medium Medium
</h3> </h3>
<div className="mx-auto max-w-[1044px]"> <div className="mx-auto max-w-[1044px]">
@@ -203,7 +203,7 @@ export default function ComponentsPreview() {
</div> </div>
</div> </div>
<div> <div>
<h3 className="mb-2 font-inter text-sm font-medium text-[var(--color-content-default-secondary)]"> <h3 className="mb-2 text-sm font-medium text-[var(--color-content-default-secondary)]">
Large Large
</h3> </h3>
<div className="mx-auto max-w-[1590px]"> <div className="mx-auto max-w-[1590px]">
+23 -1
View File
@@ -1,10 +1,32 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { MessagesProvider } from "../contexts/MessagesContext";
import { 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. // Development-only previews (e.g. `/components-preview`) — no public chrome.
export default function DevLayout({ children }: { children: ReactNode }) { export default function DevLayout({ children }: { children: ReactNode }) {
if (process.env.NODE_ENV === "production") { if (process.env.NODE_ENV === "production") {
notFound(); notFound();
} }
return <main className="flex-1">{children}</main>; return (
<MessagesProvider messages={messages}>
<AuthModalProvider>
<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 messages from "../../../messages/en/index";
import { getAssetPath, governanceBookletPath } from "../../../lib/assetUtils"; import {
ASSETS,
getAssetPath,
governanceBookletPath,
structureBeforeCrisisPath,
} from "../../../lib/assetUtils";
import { getTranslation } from "../../../lib/i18n/getTranslation"; import { getTranslation } from "../../../lib/i18n/getTranslation";
import { routeMetadata } from "../../../lib/siteMetadata";
import AboutHeader from "../../components/type/AboutHeader"; import AboutHeader from "../../components/type/AboutHeader";
import type { AboutHeaderSegment } from "../../components/type/AboutHeader"; import type { AboutHeaderSegment } from "../../components/type/AboutHeader";
import Stats from "../../components/sections/Stats"; import Stats from "../../components/sections/Stats";
@@ -13,6 +20,10 @@ import type { FaqAccordionItem } from "../../components/sections/Accordion";
import QuoteBlock from "../../components/sections/QuoteBlock"; import QuoteBlock from "../../components/sections/QuoteBlock";
import AskOrganizer from "../../components/sections/AskOrganizer"; import AskOrganizer from "../../components/sections/AskOrganizer";
export const metadata: Metadata = routeMetadata("/about", {
title: messages.metadata.about.title,
});
function asArray<T>(value: unknown): T[] { function asArray<T>(value: unknown): T[] {
return Array.isArray(value) ? value : []; return Array.isArray(value) ? value : [];
} }
@@ -25,11 +36,6 @@ export default function AboutPage() {
const headerSegments = asArray<AboutHeaderSegment>(page.aboutHeader.segments); const headerSegments = asArray<AboutHeaderSegment>(page.aboutHeader.segments);
const statsItems = asArray<StatItem>(page.stats.items); const statsItems = asArray<StatItem>(page.stats.items);
const statsAsOf =
typeof page.stats.asOf === "string"
? page.stats.asOf
: String(page.stats.asOf ?? "");
const faqItems = asArray<FaqAccordionItem>(page.faq.items); const faqItems = asArray<FaqAccordionItem>(page.faq.items);
const tripleColumns = asArray<TripleTextBlockColumn>(page.tripleTextBlock.columns); const tripleColumns = asArray<TripleTextBlockColumn>(page.tripleTextBlock.columns);
@@ -46,18 +52,25 @@ export default function AboutPage() {
titlePrefix={page.stats.titlePrefix} titlePrefix={page.stats.titlePrefix}
titleEmphasis={page.stats.titleEmphasis} titleEmphasis={page.stats.titleEmphasis}
titleSuffix={page.stats.titleSuffix} titleSuffix={page.stats.titleSuffix}
items={statsItems.map((item) => ({ asOfPrefix={page.stats.asOfPrefix}
...item, items={statsItems}
asOf: statsAsOf,
}))}
/> />
<TripleTextBlock columns={tripleColumns} /> <TripleTextBlock columns={tripleColumns} />
<Book <Book
title={page.book.title} title={page.structureBeforeCrisis.title}
description={page.book.description} description={page.structureBeforeCrisis.description}
buttonText={page.book.buttonText} buttonText={page.structureBeforeCrisis.buttonText}
buttonHref={getAssetPath(structureBeforeCrisisPath())}
imageSrc={getAssetPath(ASSETS.STRUCTURE_BEFORE_CRISIS_COVER)}
imageAlt={page.structureBeforeCrisis.imageAlt}
/>
<Book
title={page.communityRules.title}
description={page.communityRules.description}
buttonText={page.communityRules.buttonText}
buttonHref={getAssetPath(governanceBookletPath())} buttonHref={getAssetPath(governanceBookletPath())}
imageAlt={page.book.imageAlt} imageSrc={getAssetPath(ASSETS.COMMUNITY_RULES_COVER)}
imageAlt={page.communityRules.imageAlt}
/> />
<FaqAccordion title={page.faq.title} items={faqItems} /> <FaqAccordion title={page.faq.title} items={faqItems} />
<QuoteBlock <QuoteBlock
+22 -49
View File
@@ -8,9 +8,11 @@ import {
getRelatedBlogPosts, getRelatedBlogPosts,
} from "../../../../lib/content"; } from "../../../../lib/content";
import { logger } from "../../../../lib/logger"; import { logger } from "../../../../lib/logger";
import { routeMetadata } from "../../../../lib/siteMetadata";
import messages from "../../../../messages/en/index";
import ContentBanner from "../../../components/sections/ContentBanner"; import ContentBanner from "../../../components/sections/ContentBanner";
import AskOrganizer from "../../../components/sections/AskOrganizer"; import AskOrganizer from "../../../components/sections/AskOrganizer";
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils"; import ContentTemplateDecorativeShapes from "../../_components/ContentTemplateDecorativeShapes";
import "../blog.css"; import "../blog.css";
// Code split RelatedArticles - blog-specific, below the fold // Code split RelatedArticles - blog-specific, below the fold
@@ -62,13 +64,13 @@ export async function generateMetadata({
const post = getBlogPostBySlug(slug); const post = getBlogPostBySlug(slug);
if (!post) { if (!post) {
return { return routeMetadata(`/blog/${slug}`, {
title: "Post Not Found", title: messages.metadata.blog.notFoundTitle,
description: "The requested blog post could not be found.", description: messages.metadata.blog.fallbackDescription,
}; });
} }
return { return routeMetadata(`/blog/${slug}`, {
title: post.frontmatter.title, title: post.frontmatter.title,
description: post.frontmatter.description, description: post.frontmatter.description,
authors: [{ name: post.frontmatter.author }], authors: [{ name: post.frontmatter.author }],
@@ -78,8 +80,8 @@ export async function generateMetadata({
type: "article", type: "article",
publishedTime: post.frontmatter.date, publishedTime: post.frontmatter.date,
authors: [post.frontmatter.author], authors: [post.frontmatter.author],
url: `https://communityrule.com/blog/${slug}`, url: `/blog/${slug}`,
siteName: "CommunityRule", siteName: messages.metadata.siteName,
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
@@ -87,12 +89,12 @@ export async function generateMetadata({
description: post.frontmatter.description, description: post.frontmatter.description,
creator: "@communityrule", creator: "@communityrule",
}, },
}; });
} catch (error) { } catch (error) {
logger.error("Error generating metadata:", error); logger.error("Error generating metadata:", error);
return { return {
title: "Blog Post", title: messages.metadata.blog.fallbackTitle,
description: "A blog post from our community.", description: messages.metadata.blog.fallbackDescription,
}; };
} }
} }
@@ -126,7 +128,7 @@ export default async function BlogPostPage({ params }: PageProps) {
headline: post.frontmatter.title, headline: post.frontmatter.title,
description: post.frontmatter.description, description: post.frontmatter.description,
author: { author: {
"@type": "Person", "@type": "Organization",
name: post.frontmatter.author, name: post.frontmatter.author,
}, },
publisher: { publisher: {
@@ -205,57 +207,28 @@ export default async function BlogPostPage({ params }: PageProps) {
className="relative min-h-screen overflow-x-clip" className="relative min-h-screen overflow-x-clip"
style={{ backgroundColor }} style={{ backgroundColor }}
> >
{/* Content Banner */}
<ContentBanner post={post} /> <ContentBanner post={post} />
{/* Decorative Shapes */} <div className="relative w-full overflow-x-clip">
{/* Right Side Shape (3/4 up the page) */} <ContentTemplateDecorativeShapes
<div variant="article"
className="hidden md:block absolute top-1/4 right-0 pointer-events-none z-10" slug={post.slug}
style={{ transform: "translateX(40%)" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getAssetPath(ASSETS.CONTENT_SHAPE_1)}
alt=""
className="w-auto h-auto max-w-none"
style={{
width: "clamp(120px, 15vw, 200px)",
height: "auto",
}}
/> />
</div>
{/* Left Side Shape (3/4 down the page) */}
<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",
}}
/>
</div>
{/* Main Content — Figma Content page Template (19003:23305) article body instances */}
<article <article
data-node-id="19031:10426" data-node-id="19031:10426"
className=" className="
relative z-[2] flex w-full justify-center relative z-10 flex w-full justify-center
p-[var(--spacing-scale-024)] p-[var(--spacing-scale-024)]
sm:px-0 sm:py-[var(--spacing-scale-032)] 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-[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 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 dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
</div> </div>
</article> </article>
</div>
{/* Related Articles Section */} {/* Related Articles Section */}
<RelatedArticles <RelatedArticles
+16 -16
View File
@@ -1,26 +1,27 @@
import { getAllBlogPosts } from "../../../lib/content"; import { getAllBlogPosts } from "../../../lib/content";
import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate"; import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate";
import type { Metadata } from "next"; import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { routeMetadata } from "../../../lib/siteMetadata";
export const metadata: Metadata = { const blogMeta = messages.metadata.blog;
title: "Blog - CommunityRule",
description: export const metadata: Metadata = routeMetadata("/blog", {
"Learn about community governance, decision-making, and building successful organizations.", title: blogMeta.title,
description: blogMeta.description,
openGraph: { openGraph: {
title: "Blog - CommunityRule", title: blogMeta.title,
description: description: blogMeta.description,
"Learn about community governance, decision-making, and building successful organizations.", url: "/blog",
url: "https://communityrule.com/blog", siteName: messages.metadata.siteName,
siteName: "CommunityRule",
type: "website", type: "website",
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: "Blog - CommunityRule", title: blogMeta.title,
description: description: blogMeta.description,
"Learn about community governance, decision-making, and building successful organizations.",
}, },
}; });
export default function BlogPage() { export default function BlogPage() {
const posts = getAllBlogPosts(); const posts = getAllBlogPosts();
@@ -34,11 +35,10 @@ export default function BlogPage() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="text-center mb-12"> <div className="text-center mb-12">
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-content-default-primary)] mb-4"> <h1 className="text-4xl md:text-5xl font-bold text-[var(--color-content-default-primary)] mb-4">
Blog {blogMeta.title}
</h1> </h1>
<p className="text-lg text-[var(--color-content-default-secondary)] max-w-2xl mx-auto"> <p className="text-lg text-[var(--color-content-default-secondary)] max-w-2xl mx-auto">
Learn about community governance, decision-making, and building {blogMeta.description}
successful organizations.
</p> </p>
</div> </div>
+31
View File
@@ -0,0 +1,31 @@
/**
* Figma: "Content page Template" (19003:23305)
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=19003-23305
*/
import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import { legalPathForSlug } from "../../../lib/legalSyntheticPost";
import { routeMetadata } from "../../../lib/siteMetadata";
import LegalDocumentPage, {
legalPageMetadata,
} from "../_components/LegalDocumentPage";
const SLUG = "cookies" as const;
const PATH = legalPathForSlug(SLUG);
export function generateMetadata(): Metadata {
return routeMetadata(
PATH,
legalPageMetadata(messages.metadata.cookies, messages.pages.cookies),
);
}
export default function CookiesPage() {
return (
<LegalDocumentPage
slug={SLUG}
path={PATH}
page={messages.pages.cookies}
/>
);
}
@@ -1,43 +0,0 @@
/**
* Figma: "A Guide to CommunityRule" body ornaments (22078:791901)
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22078-791901
*
* - 19003:23575 concentric circles, right (`how-shape-2.svg`)
* - 19003:23576 loop mark, left (`how-shape-1.svg`)
*/
import {
getAssetPath,
howItWorksOrnamentLeftPath,
howItWorksOrnamentRightPath,
} from "../../../../lib/assetUtils";
export default function HowItWorksDecorativeShapes() {
return (
<>
<div
aria-hidden
className="pointer-events-none absolute z-0 hidden aspect-square md:block left-[84.86%] right-[-9.28%] top-[clamp(200px,20vw,255px)]"
data-node-id="19003:23575"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getAssetPath(howItWorksOrnamentRightPath())}
alt=""
className="pointer-events-none size-full max-w-none object-cover"
/>
</div>
<div
aria-hidden
className="pointer-events-none absolute z-0 hidden aspect-square md:block left-[-1.66%] right-[88.38%] top-[clamp(520px,55vw,811px)]"
data-node-id="19003:23576"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getAssetPath(howItWorksOrnamentLeftPath())}
alt=""
className="pointer-events-none size-full max-w-none object-cover"
/>
</div>
</>
);
}

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