Compare commits

..

405 Commits

Author SHA1 Message Date
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
adilallo a3119d3f90 Doc updates 2026-05-23 14:55:19 -06:00
an.di a14cae744d Merge pull request 'Cloudron-native environment variables' (#55) from adilallo/Backend/BridgeCloudronEnv into main
Reviewed-on: #55
2026-05-23 20:46:20 +00:00
adilallo a10dac8675 Update start.sh 2026-05-23 14:45:58 -06:00
adilallo d9b9c8e4e1 Merge branch 'main' into adilallo/Backend/BridgeCloudronEnv 2026-05-23 14:41:40 -06:00
an.di 528ee0e2d9 Merge pull request 'Fix Docker build' (#57) from adilallo/FixDockerBuild into main
Reviewed-on: #57
2026-05-23 20:30:32 +00:00
adilallo 753e1e320d Fix Docker build 2026-05-23 14:29:34 -06:00
an.di 062a9e4068 Merge pull request 'Backend: Cloudron container image packaging and Gitea registry workflow' (#56) from adilallo/Backend/ContainerRegistry into main
Reviewed-on: #56
2026-05-23 19:52:39 +00:00
adilallo d084ea3b33 Drop legacy peer deps 2026-05-23 13:45:02 -06:00
adilallo 2fd20d5b2a Container image registry 2026-05-23 13:30:34 -06:00
adilallo 2ca8d9adcf Update backend-linear-tickets.md 2026-05-23 10:56:52 -06:00
adilallo 8137593aa0 Migrate env variables to Cloudron 2026-05-22 15:50:33 -06:00
an.di c663e051da Merge pull request 'Public catalog API: templates, methods, and core values' (#54) from adilallo/PublicAPI into main
Reviewed-on: #54
2026-05-22 21:28:37 +00:00
adilallo 9e11063a11 Add public API for methods and values 2026-05-22 14:32:15 -06:00
an.di cef7c98205 Merge pull request 'Repo cleanup pass: assets, FeatureGrid, templates, create-flow UX, and API tests' (#53) from adilallo/Cleanup into main
Reviewed-on: #53
2026-05-22 20:14:14 +00:00
adilallo 5863a256f6 Fix featured grid 2026-05-22 13:50:55 -06:00
adilallo 3dbb6b61d2 Update template cards 2026-05-22 13:36:23 -06:00
adilallo 753220f97b Cleanup pass 2 2026-05-22 13:30:47 -06:00
adilallo b7c804bac8 Swap favicon 2026-05-22 13:07:54 -06:00
adilallo 23f528caec Add downloadable book PDF 2026-05-22 12:33:46 -06:00
adilallo 99f535f821 Full cleanup pass 2026-05-21 23:25:56 -06:00
adilallo 28de8ef3bc Cleanup assets 2026-05-21 22:56:34 -06:00
an.di f3b73527fc Merge pull request 'Marketing About, How It Works, and Use Cases pages' (#52) from adilallo/feature/AboutUseCasesPages into main
Reviewed-on: #52
2026-05-21 05:09:14 +00:00
adilallo ea346abad8 Learn page svgs updated 2026-05-20 23:01:55 -06:00
adilallo 1688ac85c9 Update learn page 2026-05-20 22:17:00 -06:00
adilallo 7ee6282c1a Fix save progress bug 2026-05-20 19:58:32 -06:00
adilallo 2f2b5d0dc2 Refine use cases rule examples 2026-05-19 22:16:08 -06:00
adilallo 7c46cbd87b Create use cases pages 2026-05-18 16:50:44 -06:00
adilallo 40ce5064d6 Implement how it works page 2026-05-17 22:40:06 -06:00
adilallo 450da4d8ab Implement use cases page 2026-05-17 21:41:54 -06:00
adilallo b6b9b63608 Implement about page 2026-05-13 23:08:36 -06:00
an.di d2dfa099a2 Merge pull request 'Stakeholder invites + Ask an organizer (modal, API, email)' (#51) from adilallo/feature/StakeholderAndAskOrganizer into main
Reviewed-on: #51
2026-05-12 00:18:42 +00:00
adilallo 874de7a112 Merge branch 'main' into adilallo/feature/StakeholderAndAskOrganizer 2026-05-11 18:17:32 -06:00
adilallo 625a8c3161 Ask organizer modal implemented 2026-05-11 18:03:52 -06:00
adilallo b5930331c0 Remove progress bar from edit rule screens 2026-05-09 23:12:11 -06:00
adilallo 9f2141a62d Manage stakeholders implemented 2026-05-09 23:12:11 -06:00
adilallo 534c6c7c0e Create flow cleanup leftover 2026-05-09 23:12:11 -06:00
adilallo 4595e2648a refactor(create): DRY rule export media + TextBlock row view 2026-05-08 21:40:43 -06:00
adilallo 89fd5f3ade feat(create): wizard uploads render as images in display and exports (imageUrl/fileUrl) 2026-05-08 21:38:18 -06:00
an.di 9de1e85817 Merge pull request 'Create flow cleanup: edit published rules, custom method cards, review polish, share/export & draft fixes' (#50) from adilallo/maintenance/CreateFlowCleanup into main
Reviewed-on: #50
2026-05-09 03:30:04 +00:00
adilallo 026a1e6d71 Custom add and create flow polish 2026-05-08 20:32:24 -06:00
adilallo 26bcd61ea3 Add button and custom modal flow implemented 2026-05-07 21:15:27 -06:00
adilallo dee2dd800e Add custom intervention modals 2026-05-01 22:05:05 -06:00
adilallo 58d0e33500 Update conflict management modal 2026-04-30 09:23:40 -06:00
adilallo b7446873cd Create flow centralization and cleanup 2026-04-30 08:11:55 -06:00
adilallo a37a72c71d Implement share and export components 2026-04-29 22:27:46 -06:00
adilallo a31a36c926 Cleanup pass 2026-04-29 21:18:36 -06:00
adilallo 7fde82a94c Pin selected after edit 2026-04-29 19:39:08 -06:00
adilallo a4f0c4bf27 Template recommendation implemented 2026-04-29 19:24:50 -06:00
adilallo c4c74ecdb4 Edit add feature refined 2026-04-29 18:38:40 -06:00
adilallo fc845d8308 Edit flow configured 2026-04-29 18:29:16 -06:00
adilallo 3a9727bceb New edit-rule page created 2026-04-29 16:05:37 -06:00
adilallo ac1157a172 Persist choices through to completed page 2026-04-29 15:02:47 -06:00
adilallo 815de2fdfd Signed in create rule clear 2026-04-29 07:34:40 -06:00
an.di 048dceced9 Merge pull request 'Component cleanup' (#49) from adilallo/maintenance/ComponentCleanup into main
Reviewed-on: #49
2026-04-29 13:25:52 +00:00
adilallo 0973b65743 Merge branch 'main' into adilallo/maintenance/ComponentCleanup 2026-04-29 07:24:33 -06:00
adilallo e6127f1a3f Component cleanup 2026-04-29 07:20:16 -06:00
an.di 5c8512ccd9 Merge pull request 'Profile, email change, alerts, and not-found' (#48) from adilallo/feature/ProfileShareAndUtility into main
Reviewed-on: #48
2026-04-29 03:47:21 +00:00
adilallo 252848eba9 Add 404 design 2026-04-26 21:27:03 -06:00
adilallo 9962f44ff1 Update and refine alert modals 2026-04-26 08:08:02 -06:00
adilallo 0ce05372bf Implement email change 2026-04-26 07:47:25 -06:00
adilallo 68517796a9 Profile page UI and functionality implemented 2026-04-25 17:57:58 -06:00
an.di 7dd2562bae Merge pull request 'Session lifecycle, public rules API, web vitals RUM, ops docs, and project guidelines' (#47) from adilallo/feature/BackendImplementation5 into main
Reviewed-on: #47
2026-04-24 01:01:30 +00:00
adilallo ce204bff03 Update docs and CI to local testing 2026-04-23 19:00:55 -06:00
adilallo 701db2aa1a Add coding guidelines
Migrate Smoke / migrate (pull_request) Has been cancelled
2026-04-23 18:27:14 -06:00
adilallo 56da6d21ea Update docs 2026-04-22 23:24:02 -06:00
adilallo 208ddfb8ca Custom session lifecycle 2026-04-22 22:24:59 -06:00
adilallo 5457d3554b API error contract 2026-04-22 19:15:04 -06:00
adilallo 4d066dad0e Cloudron deployment plan 2026-04-22 18:54:32 -06:00
adilallo c7f22a0990 CI: Postgres migration smoke 2026-04-21 22:48:23 -06:00
adilallo 0e7a57052b Public rule and detail page added 2026-04-21 22:35:49 -06:00
adilallo 2d58887a15 Web vitals: prefer external RUM 2026-04-21 07:08:31 -06:00
an.di aaa3e4d654 Merge pull request 'Create flow: Community + custom rule UI, template review, facet recommendations, and app/docs reorg' (#46) from adilallo/feature/BackendImplementation4 into main
Reviewed-on: #46
2026-04-21 04:54:12 +00:00
adilallo b01a49bc18 Update docs 2026-04-20 22:35:46 -06:00
adilallo 2438c6f707 Template navigation and review/complete cleanup 2026-04-20 19:00:30 -06:00
adilallo 707d08642c Tighten final-review screen 2026-04-20 18:33:33 -06:00
adilallo a22d53e860 Final review edit modals created 2026-04-20 17:57:17 -06:00
adilallo c08cd62872 Template flow cleaned up 2026-04-20 16:45:15 -06:00
adilallo d3bb8cdd0f Template remove add, read-only, chips open modals 2026-04-20 13:14:56 -06:00
adilallo 45bbbb8a35 Implement create custom recommendations 2026-04-20 12:41:10 -06:00
adilallo e9dab04b34 App reorganization 2026-04-18 14:12:49 -06:00
adilallo f866d11ff8 Establish cursor rules 2026-04-18 09:33:24 -06:00
adilallo 4854c49c4a Implement modals across create flow 2026-04-17 23:45:29 -06:00
adilallo 36dcb79870 Create custom flow UI 2026-04-17 22:25:24 -06:00
adilallo eedb70f9f3 Implement core value modals 2026-04-15 23:13:28 -06:00
adilallo beae150f02 Implement core-values screen 2026-04-15 22:14:46 -06:00
adilallo b15f0d6226 Align DB with create community stage 2026-04-15 21:23:48 -06:00
adilallo 92149d9fb0 Skip create save page when logged in 2026-04-15 21:15:02 -06:00
an.di d597a2348f Merge pull request 'adilallo/feature/BackendImplementation3' (#45) from adilallo/feature/BackendImplementation3 into main
Reviewed-on: #45
2026-04-14 15:28:06 +00:00
adilallo f8255bc2c7 Create Community stage implemented 2026-04-14 09:22:03 -06:00
adilallo a0de78c020 Update create flow pages 2026-04-13 18:24:13 -06:00
adilallo a39b4aa04b Load rule templates from API 2026-04-12 21:56:34 -06:00
adilallo cae4df261e Update rule card 2026-04-11 00:43:07 -06:00
adilallo 60d4ae6dfd Create flow cleanup 2026-04-11 00:29:15 -06:00
adilallo a5c6b8971f Create flow UX updates 2026-04-11 00:22:02 -06:00
adilallo ec5afd1464 RuleTemplate seed and create flow 2026-04-10 22:17:52 -06:00
an.di cee81eda16 Merge pull request 'Create flow: session UI, draft sync, publish' (#44) from adilallo/feature/BackendImplementation2 into main
Reviewed-on: #44
2026-04-09 03:26:43 +00:00
adilallo 8f932e95cd Wire Publish rule from create flow 2026-04-07 22:26:25 -06:00
adilallo a4f0b449b6 Harden server draft sync (Save & Exit + post-login transfer) 2026-04-06 22:46:00 -06:00
adilallo b6b833e80f docs updates 2026-04-06 19:31:49 -06:00
adilallo 759f5f1555 Create flow: session UI + sign out 2026-04-06 19:22:50 -06:00
an.di 4b14510dde Merge pull request 'adilallo/feature/BackendImplementation1' (#43) from adilallo/feature/BackendImplementation1 into main
Reviewed-on: #43
2026-04-06 23:08:18 +00:00
adilallo 7218947df3 Magic-link sign in UI and APIs 2026-04-06 16:37:15 -06:00
adilallo 331ed40234 Initiate backend setup 2026-04-04 23:13:04 -06:00
adilallo be88135a03 Update mailhog platform 2026-04-04 22:56:32 -06:00
adilallo c4b600e944 Formalize CreateFlowState + validate draft/publish API payloads 2026-04-04 22:37:46 -06:00
adilallo c8e930552b Align backend plan with codebase 2026-04-04 22:20:02 -06:00
an.di fe54390849 Merge pull request 'adilallo/feature/PageTemplateImplementations' (#42) from adilallo/feature/PageTemplateImplementations into main
Reviewed-on: #42
2026-04-04 17:05:46 +00:00
adilallo 1f6d38f71d Run prettier and lint 2026-04-04 11:04:37 -06:00
adilallo 427dc44476 Cleanup, add tests and storybook 2026-04-04 10:57:01 -06:00
adilallo 5d6530e914 Confirm stakeholder template 2026-04-04 10:36:26 -06:00
adilallo 3a3e54d455 Navigation, state management, create rule button integration 2026-03-02 22:40:29 -07:00
adilallo 3e3d2881f5 Completed template 2026-03-02 22:12:50 -07:00
adilallo d811b87b12 Final review template 2026-03-01 21:44:15 -07:00
adilallo 0799636c78 Right rail template 2026-02-28 23:16:10 -07:00
adilallo f5bfb25f5e Footer next button fix 2026-02-28 21:47:27 -07:00
adilallo b2ed1d438c Card compact and expanded template 2026-02-11 22:02:10 -07:00
adilallo f60df15c2b Review template 2026-02-10 22:13:08 -07:00
adilallo 4bb6fe0a89 Select and upload templates 2026-02-09 19:05:28 -07:00
adilallo 2e1538770c Informational and text templates 2026-02-08 22:04:36 -07:00
an.di c43f74f345 Merge pull request 'Create Rule Flow Core Infrastructure' (#41) from adilallo/feature/CreateRuleFlowCoreInfrastructure into main
Reviewed-on: #41
2026-02-08 22:08:58 +00:00
adilallo 8d9b9d6ff3 Create tests and stories for createflownav 2026-02-07 23:35:22 -07:00
adilallo 37555b2725 Clean up logo component 2026-02-07 22:51:27 -07:00
adilallo e6c1002dbb Implement create flow topnav and footer 2026-02-07 22:42:30 -07:00
adilallo 343b96a9bb Create rule flow core infrastructure and routing 2026-02-07 21:13:46 -07:00
an.di 12b1f59886 Merge pull request 'adilallo/maintanence/ComponentOrganizationPolish' (#40) from adilallo/maintanence/ComponentOrganizationPolish into main
Reviewed-on: #40
2026-02-07 05:31:44 +00:00
adilallo a1d7505b9f Fix Learn page 2026-02-06 22:23:52 -07:00
adilallo 9ef6d69db4 Update proportion bar 2026-02-06 22:17:44 -07:00
adilallo 51990ca149 Organize app using Next.js route groups 2026-02-06 21:59:43 -07:00
adilallo aa7364769e Update and resolve tests 2026-02-06 19:04:29 -07:00
adilallo 8c7c074d59 Remove backwards compatibility 2026-02-06 18:58:59 -07:00
adilallo af0888798f Update props in components pass 2 2026-02-06 17:46:07 -07:00
adilallo 1ca11a2229 Update props in components 2026-02-06 17:36:12 -07:00
adilallo 85ff3b8f01 Update proportion bar component 2026-02-06 14:33:25 -07:00
adilallo 162fdf94db Update TopNav component 2026-02-06 14:25:27 -07:00
adilallo d5c7262794 Remove conditional header component 2026-02-06 08:29:58 -07:00
adilallo 0aaa694fab Rename header components into one TopNav 2026-02-06 08:27:44 -07:00
adilallo e3478e6105 Update and resolves tests 2026-02-06 08:06:50 -07:00
adilallo aef04c525a Update stories to match new component organization 2026-02-05 22:46:16 -07:00
adilallo 6f178e934f Reorganize components 2026-02-05 22:37:00 -07:00
adilallo db3c0274f6 Start organizational migration 2026-02-05 18:21:56 -07:00
an.di 69074b23f3 Merge pull request 'Rule Card and Rule Stack Update' (#39) from adilallo/component/RuleCardUpdate into main
Reviewed-on: #39
2026-02-06 00:46:45 +00:00
adilallo 794b978aab Update and resolve test issues 2026-02-05 17:45:57 -07:00
adilallo b012c73e65 Update Rule Stack component and tests 2026-02-05 16:58:15 -07:00
adilallo 7e2348048a Implement S and XS Rule Card 2026-02-05 14:18:30 -07:00
adilallo fc5933e6ba Create and adjust rule card tests 2026-02-05 10:36:14 -07:00
adilallo 4c147780ac Fix rule card header layout 2026-02-05 10:29:58 -07:00
adilallo 0dedebfaf8 Create InputLabel component and add RuleCard chips 2026-02-05 09:25:42 -07:00
adilallo 3e935ecd9e Create multi-select component 2026-02-05 09:07:42 -07:00
adilallo 8ba11070d3 Create chip component 2026-02-05 09:02:15 -07:00
adilallo 769fc8e7c6 Update Rule Card component 2026-02-04 21:19:27 -07:00
an.di 3db151b40c Merge pull request 'Align Prop Names' (#38) from adilallo/maintenance/AlignPropNames into main
Reviewed-on: #38
2026-02-05 00:03:32 +00:00
adilallo b0b9699ced Resolve test errors 2026-02-04 17:03:10 -07:00
adilallo 967fc11ae8 Add rule about figma props 2026-02-04 16:57:16 -07:00
adilallo af7e2d3e51 Align props with figma 2026-02-04 16:52:03 -07:00
an.di ee9784271f Merge pull request 'Number Card and Form Component Updates' (#37) from adilallo/component/NumberedCardUpdate into main
Reviewed-on: #37
2026-02-04 21:16:05 +00:00
adilallo 97e2680c57 Resolve errors and pass tests 2026-02-04 14:15:22 -07:00
adilallo 0ebad759f9 Update radio group component 2026-02-04 13:57:51 -07:00
adilallo 3f35e581b7 Update radio button component 2026-02-04 13:54:08 -07:00
adilallo 87a1e1d2a8 Created checkbox group component 2026-02-04 13:34:22 -07:00
adilallo 05e403e3c6 Update checkbox component 2026-02-04 13:31:04 -07:00
adilallo 0e7985287f Update select input component 2026-02-04 13:10:14 -07:00
adilallo 255f16477c Update text input component 2026-02-04 11:29:51 -07:00
adilallo d8fa525514 Adjust NumberCard story 2026-02-04 10:49:05 -07:00
adilallo 91635cbf4c Update vitest.config.mjs 2026-02-03 21:39:15 -07:00
adilallo 139780d867 Update NumberCard component 2026-02-03 21:01:55 -07:00
an.di ecaef5d797 Merge pull request 'Button Updates' (#36) from adilallo/component/ButtonUpdates into main
Reviewed-on: #36
2026-02-03 17:56:10 +00:00
adilallo 7f2caeaad7 Update button tests and components to pass 2026-02-03 10:39:46 -07:00
adilallo 6ea840505a Fix buttons across app 2026-02-03 10:30:13 -07:00
adilallo 8f0db08d0f Update button components 2026-02-03 10:02:18 -07:00
adilallo 5beb3193cb Added ghost button 2026-02-03 09:20:24 -07:00
adilallo 7f7c643e9b Added danger button 2026-02-03 09:06:32 -07:00
an.di 000cbc02bc Merge pull request 'Icon Card' (#35) from adilallo/component/IconCard into main
Reviewed-on: #35
2026-02-03 01:57:24 +00:00
adilallo 9a42035b0c Update and resolve storybook issues 2026-02-02 18:56:47 -07:00
adilallo 1ec9e9d639 Update Create.stories.js 2026-02-02 15:51:52 -07:00
adilallo 4ab37afaf3 Fix icon card component and tests to pass 2026-02-02 14:55:49 -07:00
adilallo e227e0c658 Implementation of icon card 2026-02-02 14:43:32 -07:00
an.di 59d766cda2 Merge pull request 'Create Modal' (#34) from adilallo/component/ModalCreate into main
Reviewed-on: #34
2026-02-02 20:27:22 +00:00
adilallo 3b8f2e791f Fixes on create component tests 2026-02-02 13:08:55 -07:00
adilallo a8eb9e192b Implement create component 2026-02-02 12:53:52 -07:00
an.di b98b9dded3 Merge pull request 'Progress and Stepper' (#33) from adilallo/component/ProgressStepper into main
Reviewed-on: #33
2026-02-02 18:47:35 +00:00
adilallo 6223ac5475 Adjust progress stories 2026-02-02 11:41:49 -07:00
adilallo 9d4a5f4238 Fixes on progress component to pass tests 2026-02-02 11:32:06 -07:00
adilallo 462488ddce Implement stepper and progress bar 2026-02-02 11:24:47 -07:00
adilallo 9149883ae0 Resolve missing commit 2026-02-02 10:13:01 -07:00
an.di 293bbfc474 Merge pull request 'Tooltip and Alert Components' (#32) from adilallo/feature/TooltipAlertComponents into main
Reviewed-on: #32
2026-02-02 16:57:48 +00:00
adilallo 291625e3e7 Fix testing errors 2026-02-02 09:32:41 -07:00
adilallo abe4bff09e Implement Alert and Tooltip components 2026-02-02 09:24:03 -07:00
an.di a94df9be37 Merge pull request 'Update Design Tokens' (#31) from adilallo/maintenance/UpdateDesignTokens into main
Reviewed-on: #31
2026-02-02 14:42:45 +00:00
adilallo 6127f355bf Update tokens and snapshots to pass tests 2026-02-01 22:31:40 -07:00
adilallo b723fc4a85 Update app design tokens 2026-01-31 16:58:26 -07:00
an.di 3fb5a389c7 Merge pull request 'adilallo/feature/TextLocalization' (#30) from adilallo/feature/TextLocalization into main
Reviewed-on: #30
2026-01-31 01:42:21 +00:00
adilallo ebd025fe27 Adjust testing with localization 2026-01-30 18:39:15 -07:00
adilallo 1280844706 Localization with pages context 2026-01-30 18:03:50 -07:00
adilallo 14ec2dd2a0 Second pass on localization 2026-01-30 17:28:48 -07:00
adilallo 2f37031411 Initial implementation of localization 2026-01-29 22:17:44 -07:00
an.di 1714e7f930 Merge pull request 'Refactor Components' (#29) from adilallo/maintenance/RefactorComponents into main
Reviewed-on: #29
2026-01-30 03:59:40 +00:00
adilallo adac7d0545 Update local testing script and resolve errors 2026-01-29 20:57:39 -07:00
adilallo ca42982dea Remove CI runner for now 2026-01-29 20:32:31 -07:00
adilallo 16166f5cc5 Additional runner support
CI Pipeline / test (pull_request) Has been cancelled
CI Pipeline / e2e (chromium) (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
2026-01-29 20:18:01 -07:00
adilallo 5b77ed1c12 Adjust runner script
CI Pipeline / test (pull_request) Has been cancelled
CI Pipeline / e2e (chromium) (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
2026-01-29 20:08:29 -07:00
adilallo 44c0a53c0b Update gitea-runner.yaml
CI Pipeline / test (pull_request) Has been cancelled
CI Pipeline / e2e (chromium) (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
2026-01-29 20:02:44 -07:00
adilallo 89a2e7b1b7 Update gitea-runner.yaml
CI Pipeline / test (pull_request) Has been cancelled
CI Pipeline / e2e (chromium) (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
2026-01-29 19:55:49 -07:00
adilallo 1f249f92ff Update gitea-runner.yaml
CI Pipeline / test (pull_request) Has been cancelled
CI Pipeline / e2e (chromium) (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
2026-01-29 19:51:04 -07:00
adilallo ba3a15a7d2 Fix components based on failed tests
CI Pipeline / test (pull_request) Has been cancelled
CI Pipeline / e2e (chromium) (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
2026-01-29 19:38:03 -07:00
adilallo f7e0b5f517 Resolve errors with migrated components 2026-01-29 19:07:59 -07:00
adilallo 539f6c62e3 Finish migrating components 2026-01-29 17:59:11 -07:00
adilallo b5735bb2ad Second pass on component refactor 2026-01-29 17:35:51 -07:00
adilallo 7b9101824a First pass on component refactor 2026-01-29 17:29:37 -07:00
an.di 11f32d7051 Merge pull request 'Simplify Testing Structure' (#28) from adilallo/maintenance/SimplifyTestingStructure into main
Reviewed-on: #28
2026-01-29 02:00:23 +00:00
adilallo a30bf6be4c Update E2E tests and simplify performance tests
CI Pipeline / e2e (chromium) (pull_request) Successful in 6m13s
CI Pipeline / e2e (firefox) (pull_request) Successful in 7m3s
CI Pipeline / e2e (webkit) (pull_request) Successful in 5m52s
CI Pipeline / visual-regression (pull_request) Successful in 7m48s
CI Pipeline / performance (pull_request) Successful in 7m59s
CI Pipeline / lint (pull_request) Successful in 6m16s
CI Pipeline / build (pull_request) Successful in 5m30s
CI Pipeline / test (pull_request) Successful in 6m26s
2026-01-28 18:22:59 -07:00
adilallo 9cb89162ab Fix TypeScript matcher typing issue
CI Pipeline / test (pull_request) Successful in 7m5s
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / e2e (chromium) (pull_request) Successful in 54m11s
CI Pipeline / e2e (firefox) (pull_request) Failing after 22m9s
CI Pipeline / visual-regression (pull_request) Successful in 11m50s
CI Pipeline / performance (pull_request) Successful in 13m59s
2026-01-28 15:57:47 -07:00
adilallo 2652015e80 Update ci.yaml
CI Pipeline / e2e (chromium) (pull_request) Failing after 2m18s
CI Pipeline / e2e (firefox) (pull_request) Failing after 3m31s
CI Pipeline / test (pull_request) Successful in 6m33s
CI Pipeline / e2e (webkit) (pull_request) Failing after 2m19s
CI Pipeline / visual-regression (pull_request) Failing after 4m25s
CI Pipeline / performance (pull_request) Failing after 3m45s
CI Pipeline / lint (pull_request) Failing after 3m24s
CI Pipeline / build (pull_request) Failing after 1m0s
CI Pipeline / storybook (pull_request) Successful in 7m11s
2026-01-28 15:35:23 -07:00
adilallo d8fab7604f Update ci.yaml 2026-01-28 15:03:20 -07:00
adilallo 5ffad5551c Remove concurrency 2026-01-28 15:01:03 -07:00
adilallo e6e5499646 Update tests with new configuration 2026-01-28 14:50:30 -07:00
adilallo 63489ee38f Simplify CI pipeline 2026-01-28 14:40:44 -07:00
adilallo e6324a1eb7 Clean up node version unit testing 2026-01-28 14:31:08 -07:00
adilallo 27700cacb0 Cleanup 2026-01-28 14:21:52 -07:00
adilallo 6de3a07811 Remove and cleanup storybook testing 2026-01-28 14:14:40 -07:00
adilallo 7ea724a8d9 Simplify and standardize testing structure 2026-01-28 14:04:04 -07:00
an.di e7a31789e3 Merge pull request 'ESLint Fixes' (#27) from adilallo/maintenance/ESLintFixes into main
Reviewed-on: #27
2026-01-28 19:44:05 +00:00
adilallo 29496dbaac Adjust tests after fixing ESLint
CI Pipeline / test (20) (pull_request) Successful in 6m20s
CI Pipeline / test (18) (pull_request) Successful in 9m25s
CI Pipeline / e2e (firefox) (pull_request) Successful in 4m14s
CI Pipeline / e2e (webkit) (pull_request) Successful in 4m29s
CI Pipeline / e2e (chromium) (pull_request) Successful in 14m33s
CI Pipeline / visual-regression (pull_request) Successful in 6m24s
CI Pipeline / performance (pull_request) Successful in 5m40s
CI Pipeline / storybook (pull_request) Successful in 2m20s
CI Pipeline / lint (pull_request) Successful in 1m55s
CI Pipeline / build (pull_request) Successful in 2m4s
2026-01-28 12:11:32 -07:00
adilallo 01468ab5c8 Add ESLint back into CI pipeline 2026-01-28 11:52:42 -07:00
adilallo 29a3bd3824 Address ESlint console statements 2026-01-28 11:49:56 -07:00
adilallo 6b8d646f8a Fix ESLint errors 2026-01-28 11:38:38 -07:00
an.di 2e027f5bb2 Merge pull request 'Project Cleanup and Organization' (#26) from adilallo/maintenance/ProjectOrganization into main
Reviewed-on: #26
2026-01-27 00:08:43 +00:00
adilallo f2cdb6fec9 Update tracked files
CI Pipeline / test (20) (pull_request) Successful in 6m27s
CI Pipeline / test (18) (pull_request) Successful in 8m15s
CI Pipeline / e2e (firefox) (pull_request) Successful in 3m22s
CI Pipeline / e2e (webkit) (pull_request) Successful in 3m39s
CI Pipeline / e2e (chromium) (pull_request) Successful in 11m31s
CI Pipeline / visual-regression (pull_request) Successful in 6m22s
CI Pipeline / storybook (pull_request) Successful in 1m26s
CI Pipeline / performance (pull_request) Successful in 6m44s
CI Pipeline / build (pull_request) Successful in 2m8s
2026-01-26 15:58:08 -07:00
adilallo 94a7922b30 Project cleanup and reorganization 2026-01-26 15:41:25 -07:00
an.di f65b9c4e6b Merge pull request 'Performance and Reusability' (#25) from adilallo/enhancement/PerformanceandReusability into main
Reviewed-on: #25
2026-01-26 22:30:18 +00:00
adilallo ab806fbc16 Skip failing integration tests
CI Pipeline / test (20) (pull_request) Successful in 6m28s
CI Pipeline / test (18) (pull_request) Successful in 8m20s
CI Pipeline / e2e (firefox) (pull_request) Successful in 3m15s
CI Pipeline / e2e (webkit) (pull_request) Successful in 3m39s
CI Pipeline / e2e (chromium) (pull_request) Successful in 11m5s
CI Pipeline / visual-regression (pull_request) Successful in 6m4s
CI Pipeline / storybook (pull_request) Successful in 2m27s
CI Pipeline / build (pull_request) Successful in 2m29s
CI Pipeline / performance (pull_request) Successful in 4m54s
2026-01-26 14:49:36 -07:00
adilallo 1bb4627ab2 Fix failing performance and unit tests
CI Pipeline / test (18) (pull_request) Failing after 3m29s
CI Pipeline / test (20) (pull_request) Failing after 4m23s
CI Pipeline / e2e (chromium) (pull_request) Successful in 3m0s
CI Pipeline / e2e (firefox) (pull_request) Successful in 5m45s
CI Pipeline / e2e (webkit) (pull_request) Successful in 4m22s
CI Pipeline / performance (pull_request) Successful in 4m0s
CI Pipeline / storybook (pull_request) Successful in 1m20s
CI Pipeline / visual-regression (pull_request) Successful in 6m7s
CI Pipeline / build (pull_request) Successful in 1m33s
2026-01-26 14:07:08 -07:00
adilallo bef13261b3 Fix and update tests
CI Pipeline / test (20) (pull_request) Failing after 2m41s
CI Pipeline / test (18) (pull_request) Failing after 4m30s
CI Pipeline / e2e (firefox) (pull_request) Successful in 3m17s
CI Pipeline / e2e (webkit) (pull_request) Successful in 3m40s
CI Pipeline / e2e (chromium) (pull_request) Successful in 11m13s
CI Pipeline / visual-regression (pull_request) Successful in 6m7s
CI Pipeline / performance (pull_request) Failing after 3m40s
CI Pipeline / storybook (pull_request) Successful in 1m14s
CI Pipeline / build (pull_request) Successful in 1m37s
2026-01-26 13:16:57 -07:00
adilallo 0dec7c41ee Add code splitting 2026-01-26 13:05:13 -07:00
adilallo 86d7cff5d4 Extract custom hooks for reusable logic 2026-01-26 12:51:27 -07:00
an.di f513aecacc Merge pull request 'Next.js Update' (#24) from adilallo/maintenence/NextjsUpdate into main
Reviewed-on: #24
2026-01-26 19:25:32 +00:00
adilallo d7bc40acd5 Disable Storybook tests
CI Pipeline / test (20) (pull_request) Successful in 3m54s
CI Pipeline / test (18) (pull_request) Successful in 4m41s
CI Pipeline / e2e (firefox) (pull_request) Successful in 4m6s
CI Pipeline / e2e (webkit) (pull_request) Successful in 5m9s
CI Pipeline / e2e (chromium) (pull_request) Successful in 13m7s
CI Pipeline / visual-regression (pull_request) Successful in 6m41s
CI Pipeline / storybook (pull_request) Successful in 2m23s
CI Pipeline / performance (pull_request) Successful in 9m21s
CI Pipeline / build (pull_request) Successful in 2m2s
2026-01-26 09:59:11 -07:00
adilallo 22d869afa2 Fix Storybook test runner
CI Pipeline / test (20) (pull_request) Successful in 2m27s
CI Pipeline / test (18) (pull_request) Successful in 3m20s
CI Pipeline / e2e (firefox) (pull_request) Successful in 3m21s
CI Pipeline / e2e (webkit) (pull_request) Successful in 3m39s
CI Pipeline / e2e (chromium) (pull_request) Successful in 10m27s
CI Pipeline / visual-regression (pull_request) Successful in 6m21s
CI Pipeline / storybook (pull_request) Failing after 1m29s
CI Pipeline / build (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
2026-01-26 09:38:41 -07:00
adilallo 4dbc5cda1a Update Storybook config
CI Pipeline / test (20) (pull_request) Successful in 3m22s
CI Pipeline / test (18) (pull_request) Successful in 3m57s
CI Pipeline / e2e (firefox) (pull_request) Successful in 4m32s
CI Pipeline / e2e (chromium) (pull_request) Successful in 6m58s
CI Pipeline / e2e (webkit) (pull_request) Successful in 3m47s
CI Pipeline / performance (pull_request) Successful in 4m6s
CI Pipeline / visual-regression (pull_request) Successful in 7m31s
CI Pipeline / storybook (pull_request) Failing after 1m56s
CI Pipeline / lint (pull_request) Failing after 1m41s
CI Pipeline / build (pull_request) Successful in 2m5s
2026-01-26 09:13:29 -07:00
adilallo d707ed8b58 Update ESLint configuration
CI Pipeline / test (20) (pull_request) Successful in 3m16s
CI Pipeline / test (18) (pull_request) Successful in 3m51s
CI Pipeline / e2e (chromium) (pull_request) Successful in 3m43s
CI Pipeline / e2e (firefox) (pull_request) Successful in 3m29s
CI Pipeline / e2e (webkit) (pull_request) Successful in 4m33s
CI Pipeline / visual-regression (pull_request) Successful in 6m18s
CI Pipeline / storybook (pull_request) Failing after 59s
CI Pipeline / performance (pull_request) Successful in 3m25s
CI Pipeline / lint (pull_request) Failing after 1m59s
CI Pipeline / build (pull_request) Failing after 2m25s
2026-01-26 08:19:49 -07:00
adilallo 9e8b767128 Update Nextjs 2026-01-26 08:09:31 -07:00
an.di 5442114c85 Merge pull request 'TypeScript Conversion' (#23) from adilallo/enhancement/TypeScriptConversion into main
Reviewed-on: #23
2026-01-25 01:03:57 +00:00
adilallo 9c3800c394 Update prettier in package.json
CI Pipeline / test (20) (pull_request) Successful in 2m37s
CI Pipeline / test (18) (pull_request) Successful in 3m19s
CI Pipeline / e2e (firefox) (pull_request) Successful in 3m30s
CI Pipeline / e2e (webkit) (pull_request) Successful in 3m48s
CI Pipeline / e2e (chromium) (pull_request) Successful in 10m39s
CI Pipeline / visual-regression (pull_request) Successful in 6m10s
CI Pipeline / performance (pull_request) Successful in 3m58s
CI Pipeline / lint (pull_request) Successful in 1m7s
CI Pipeline / storybook (pull_request) Successful in 1m40s
CI Pipeline / build (pull_request) Successful in 1m43s
2025-12-11 10:36:18 -07:00
adilallo ae5fd62dc3 Update visual regression snapshots
CI Pipeline / test (18) (pull_request) Failing after 49s
CI Pipeline / test (20) (pull_request) Failing after 55s
CI Pipeline / e2e (chromium) (pull_request) Failing after 15s
CI Pipeline / e2e (firefox) (pull_request) Failing after 16s
CI Pipeline / e2e (webkit) (pull_request) Failing after 13s
CI Pipeline / performance (pull_request) Failing after 17s
CI Pipeline / storybook (pull_request) Failing after 13s
CI Pipeline / lint (pull_request) Failing after 12s
CI Pipeline / visual-regression (pull_request) Failing after 51s
CI Pipeline / build (pull_request) Failing after 13s
2025-12-11 10:26:14 -07:00
adilallo c7e3048c09 Fix tcs type errors
CI Pipeline / test (20) (pull_request) Successful in 3m13s
CI Pipeline / test (18) (pull_request) Successful in 3m57s
CI Pipeline / e2e (firefox) (pull_request) Successful in 5m6s
CI Pipeline / e2e (webkit) (pull_request) Successful in 5m16s
CI Pipeline / e2e (chromium) (pull_request) Successful in 14m47s
CI Pipeline / performance (pull_request) Successful in 4m32s
CI Pipeline / storybook (pull_request) Successful in 1m35s
CI Pipeline / visual-regression (pull_request) Failing after 9m55s
CI Pipeline / lint (pull_request) Failing after 49s
CI Pipeline / build (pull_request) Successful in 1m48s
2025-12-11 09:05:18 -07:00
adilallo 92a3337aeb Fix tests after ts change
CI Pipeline / test (20) (pull_request) Successful in 2m41s
CI Pipeline / test (18) (pull_request) Successful in 3m21s
CI Pipeline / e2e (chromium) (pull_request) Failing after 1m25s
CI Pipeline / e2e (firefox) (pull_request) Failing after 1m24s
CI Pipeline / e2e (webkit) (pull_request) Failing after 1m24s
CI Pipeline / visual-regression (pull_request) Failing after 1m53s
CI Pipeline / performance (pull_request) Failing after 1m31s
CI Pipeline / lint (pull_request) Failing after 1m5s
CI Pipeline / storybook (pull_request) Successful in 1m36s
CI Pipeline / build (pull_request) Failing after 1m19s
2025-12-10 22:43:36 -07:00
adilallo f6a0673082 Convert from JSX to TSX
CI Pipeline / test (20) (pull_request) Failing after 1m17s
CI Pipeline / test (18) (pull_request) Failing after 1m28s
CI Pipeline / e2e (chromium) (pull_request) Failing after 1m33s
CI Pipeline / e2e (firefox) (pull_request) Failing after 1m27s
CI Pipeline / e2e (webkit) (pull_request) Failing after 1m34s
CI Pipeline / visual-regression (pull_request) Failing after 2m9s
CI Pipeline / storybook (pull_request) Failing after 1m5s
CI Pipeline / performance (pull_request) Failing after 1m42s
CI Pipeline / lint (pull_request) Failing after 49s
CI Pipeline / build (pull_request) Failing after 1m29s
2025-12-10 22:14:17 -07:00
an.di 1ad6bc85b4 Merge pull request 'Form Components' (#22) from adilallo/component/FormComponents into main
CI Pipeline / e2e (chromium) (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
CI Pipeline / test (18) (pull_request) Has been cancelled
CI Pipeline / test (20) (pull_request) Has been cancelled
Reviewed-on: #22
2025-12-11 04:42:45 +00:00
adilallo fa5a190416 Fix failing tests
CI Pipeline / test (20) (pull_request) Successful in 2m30s
CI Pipeline / test (18) (pull_request) Successful in 3m51s
CI Pipeline / e2e (firefox) (pull_request) Successful in 3m22s
CI Pipeline / e2e (webkit) (pull_request) Successful in 3m45s
CI Pipeline / e2e (chromium) (pull_request) Successful in 11m49s
CI Pipeline / visual-regression (pull_request) Successful in 6m48s
CI Pipeline / storybook (pull_request) Successful in 1m35s
CI Pipeline / lint (pull_request) Successful in 1m12s
CI Pipeline / build (pull_request) Successful in 1m54s
CI Pipeline / performance (pull_request) Successful in 4m6s
2025-10-14 20:47:34 -06:00
adilallo c4a631a5d8 Cleanup code and tests
CI Pipeline / test (20) (pull_request) Successful in 2m55s
CI Pipeline / test (18) (pull_request) Successful in 3m32s
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
CI Pipeline / e2e (chromium) (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
2025-10-14 17:34:05 -06:00
adilallo 9de194bfc0 Switch component with storybook and testing 2025-10-14 17:27:09 -06:00
adilallo 460237fc66 Toggle Group component with storybook and testing 2025-10-14 17:00:27 -06:00
adilallo 929729a67f Toggle component with storybook and testing 2025-10-14 15:40:51 -06:00
adilallo b71f0a7dea Text area component with storybook and testing 2025-10-10 12:37:52 -06:00
adilallo 9c72afdc52 Select and Context Menu component with storybook and testing 2025-10-10 12:07:13 -06:00
adilallo 2bc5fcdf45 Input component with storybook and testing 2025-10-10 09:07:47 -06:00
adilallo 04783d3f62 Radio button and group component with storybook and testing 2025-10-09 14:57:51 -06:00
adilallo 0b9e918fd0 Checkbox component with testing and storybook 2025-10-08 17:49:13 -06:00
an.di 2835fac38b Merge pull request 'Frontend Performance Optimization' (#21) from adilallo/enhancement/FrontendPerformanceOptimization into main
Reviewed-on: #21
2025-10-08 17:15:25 +00:00
adilallo a58e5c91a2 Update NavigationItem.js
CI Pipeline / test (20) (pull_request) Successful in 2m43s
CI Pipeline / test (18) (pull_request) Successful in 3m3s
CI Pipeline / e2e (firefox) (pull_request) Successful in 3m23s
CI Pipeline / e2e (webkit) (pull_request) Successful in 3m56s
CI Pipeline / e2e (chromium) (pull_request) Successful in 10m42s
CI Pipeline / visual-regression (pull_request) Successful in 6m38s
CI Pipeline / lint (pull_request) Successful in 58s
CI Pipeline / storybook (pull_request) Successful in 1m57s
CI Pipeline / build (pull_request) Successful in 2m16s
CI Pipeline / performance (pull_request) Successful in 3m24s
2025-10-08 10:31:41 -06:00
adilallo 6bd751957c Run lint and prettier
CI Pipeline / test (20) (pull_request) Successful in 3m0s
CI Pipeline / test (18) (pull_request) Successful in 3m18s
CI Pipeline / e2e (firefox) (pull_request) Successful in 3m20s
CI Pipeline / e2e (chromium) (pull_request) Successful in 3m54s
CI Pipeline / e2e (webkit) (pull_request) Successful in 3m41s
CI Pipeline / performance (pull_request) Successful in 3m3s
CI Pipeline / visual-regression (pull_request) Successful in 7m12s
CI Pipeline / storybook (pull_request) Successful in 1m29s
CI Pipeline / lint (pull_request) Failing after 1m7s
CI Pipeline / build (pull_request) Successful in 1m20s
2025-10-07 17:27:07 -06:00
adilallo c991e22f09 Update optimization documentation 2025-10-07 17:20:26 -06:00
adilallo 25c03705a8 Update LogoWall.test.jsx 2025-10-07 17:16:51 -06:00
adilallo 8fe7eb4798 Integrate performance monitoring with existing setup 2025-10-07 17:12:58 -06:00
adilallo 104208c7df Bundle analysis and monitoring 2025-10-07 17:08:57 -06:00
adilallo 2ed878af81 Add memo optimization 2025-10-07 16:50:33 -06:00
an.di e3861f6857 Merge pull request 'adiallo/feature/LearnPage' (#20) from adiallo/feature/LearnPage into main
Reviewed-on: #20
2025-10-01 03:15:22 +00:00
adilallo 74b09eaf09 Run prettier
CI Pipeline / test (20) (pull_request) Successful in 8m20s
CI Pipeline / test (18) (pull_request) Successful in 8m41s
CI Pipeline / e2e (chromium) (pull_request) Successful in 3m26s
CI Pipeline / e2e (firefox) (pull_request) Successful in 4m36s
CI Pipeline / e2e (webkit) (pull_request) Successful in 3m39s
CI Pipeline / performance (pull_request) Successful in 2m43s
CI Pipeline / visual-regression (pull_request) Successful in 6m5s
CI Pipeline / storybook (pull_request) Successful in 1m25s
CI Pipeline / lint (pull_request) Successful in 1m25s
CI Pipeline / build (pull_request) Successful in 1m48s
2025-09-30 17:49:01 -06:00
adilallo f48fdff716 Update storybook testing
CI Pipeline / test (20) (pull_request) Successful in 8m52s
CI Pipeline / test (18) (pull_request) Successful in 9m56s
CI Pipeline / e2e (chromium) (pull_request) Successful in 6m35s
CI Pipeline / e2e (firefox) (pull_request) Successful in 6m33s
CI Pipeline / e2e (webkit) (pull_request) Successful in 5m47s
CI Pipeline / visual-regression (pull_request) Successful in 9m32s
CI Pipeline / storybook (pull_request) Successful in 9m12s
CI Pipeline / performance (pull_request) Successful in 15m1s
CI Pipeline / lint (pull_request) Failing after 3m14s
CI Pipeline / build (pull_request) Successful in 3m53s
2025-09-30 17:01:46 -06:00
adilallo e11f333915 Update tests 2025-09-30 15:30:02 -06:00
adilallo b15f913a14 Update storybook and tests 2025-09-30 10:42:35 -06:00
adilallo febf04b059 Add third banner image to content upload pipeline 2025-09-30 10:24:43 -06:00
adilallo e47e955c7d Learn page xl breakpoint 2025-09-30 09:54:46 -06:00
adilallo fd006d1605 Learn page lg and lg2 breakpoint 2025-09-30 09:51:00 -06:00
adilallo 91e60d5b30 Learn page lg breakpoint 2025-09-30 09:23:22 -06:00
adilallo dfca76a320 Learn page md breakpoint 2025-09-30 09:17:09 -06:00
adilallo 6d86ede87d Learn page smd breakpoint 2025-09-30 09:05:01 -06:00
adilallo 27692750a6 Update content page bg color documentation 2025-09-30 08:49:32 -06:00
adilallo af4a08b934 Update content page background 2025-09-30 08:43:32 -06:00
adilallo cc1d2ec7de Resolving article images 2025-09-29 14:33:39 -06:00
adilallo 8d97e647e7 Learn page default breakpoint 2025-09-29 14:14:31 -06:00
adilallo d530e43664 Adjust content image upload pipeline 2025-09-29 14:00:28 -06:00
adilallo eadfe561b8 Learn page content thumbnails at default breakpoint 2025-09-29 13:44:05 -06:00
adilallo 6580f428c6 Learn page content lockup 2025-09-29 13:33:40 -06:00
adilallo b309971c6c update header tests and storybook 2025-09-29 13:13:51 -06:00
adilallo fa257984d6 Update header for additional pages 2025-09-29 13:11:50 -06:00
adilallo e4f3beb662 Update package-lock.json 2025-09-29 12:13:15 -06:00
an.di 4b3a28f6b8 Merge pull request 'Content Page' (#19) from adilallo/feature/Blog into main
Reviewed-on: #19
2025-09-18 15:44:44 +00:00
adilallo ef5467b6c7 Update ci.yaml
CI Pipeline / test (20) (pull_request) Successful in 2m52s
CI Pipeline / test (18) (pull_request) Successful in 3m14s
CI Pipeline / e2e (chromium) (pull_request) Successful in 3m3s
CI Pipeline / e2e (firefox) (pull_request) Successful in 5m32s
CI Pipeline / e2e (webkit) (pull_request) Successful in 3m35s
CI Pipeline / performance (pull_request) Successful in 2m29s
CI Pipeline / storybook (pull_request) Successful in 1m35s
CI Pipeline / lint (pull_request) Successful in 1m14s
CI Pipeline / visual-regression (pull_request) Successful in 6m6s
CI Pipeline / build (pull_request) Successful in 1m33s
2025-09-14 15:09:52 -06:00
adilallo a878fdf72f Update ci.yaml
CI Pipeline / test (20) (pull_request) Successful in 3m10s
CI Pipeline / test (18) (pull_request) Successful in 3m36s
CI Pipeline / e2e (chromium) (pull_request) Successful in 2m45s
CI Pipeline / e2e (firefox) (pull_request) Successful in 3m8s
CI Pipeline / visual-regression (pull_request) Failing after 2m3s
CI Pipeline / e2e (webkit) (pull_request) Successful in 4m54s
CI Pipeline / performance (pull_request) Successful in 2m28s
CI Pipeline / storybook (pull_request) Successful in 1m21s
CI Pipeline / lint (pull_request) Successful in 1m1s
CI Pipeline / build (pull_request) Successful in 1m18s
2025-09-14 14:44:32 -06:00
adilallo 0e4188a390 Attempt to fix visual regression test fail on first run
CI Pipeline / test (20) (pull_request) Successful in 2m52s
CI Pipeline / test (18) (pull_request) Successful in 3m20s
CI Pipeline / e2e (chromium) (pull_request) Successful in 2m48s
CI Pipeline / e2e (firefox) (pull_request) Successful in 4m20s
CI Pipeline / e2e (webkit) (pull_request) Successful in 3m36s
CI Pipeline / performance (pull_request) Successful in 2m35s
CI Pipeline / storybook (pull_request) Successful in 1m24s
CI Pipeline / visual-regression (pull_request) Failing after 6m17s
CI Pipeline / lint (pull_request) Successful in 56s
CI Pipeline / build (pull_request) Successful in 1m19s
2025-09-14 14:18:41 -06:00
adilallo 2550eaa9b9 Try splitting up unit and integration tests
CI Pipeline / test (20) (pull_request) Successful in 2m51s
CI Pipeline / test (18) (pull_request) Successful in 3m9s
CI Pipeline / e2e (chromium) (pull_request) Successful in 3m6s
CI Pipeline / e2e (firefox) (pull_request) Successful in 4m13s
CI Pipeline / e2e (webkit) (pull_request) Successful in 3m35s
CI Pipeline / performance (pull_request) Successful in 2m37s
CI Pipeline / storybook (pull_request) Successful in 1m24s
CI Pipeline / lint (pull_request) Successful in 1m9s
CI Pipeline / build (pull_request) Successful in 1m22s
CI Pipeline / visual-regression (pull_request) Successful in 3m51s
2025-09-14 13:55:03 -06:00
adilallo 9ca35b8c02 Batch tests
CI Pipeline / test (20) (pull_request) Failing after 1m30s
CI Pipeline / test (18) (pull_request) Failing after 1m36s
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
CI Pipeline / e2e (chromium) (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
2025-09-14 13:46:56 -06:00
adilallo c6c4425846 Fix timeout communication issues
CI Pipeline / test (18) (pull_request) Failing after 5m35s
CI Pipeline / test (20) (pull_request) Successful in 5m58s
CI Pipeline / e2e (chromium) (pull_request) Successful in 2m52s
CI Pipeline / visual-regression (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
2025-09-14 13:30:32 -06:00
adilallo bfa04ad096 Update ci.yaml
CI Pipeline / test (18) (pull_request) Failing after 5m45s
CI Pipeline / test (20) (pull_request) Successful in 6m1s
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
CI Pipeline / e2e (chromium) (pull_request) Has been cancelled
2025-09-14 13:22:47 -06:00
adilallo c030187bc8 Update timeouts
CI Pipeline / test (20) (pull_request) Failing after 1m2s
CI Pipeline / test (18) (pull_request) Failing after 1m12s
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
CI Pipeline / e2e (chromium) (pull_request) Has been cancelled
2025-09-14 13:19:53 -06:00
adilallo abd4a7f0f8 Update CI memory allocation
CI Pipeline / test (20) (pull_request) Failing after 12m6s
CI Pipeline / test (18) (pull_request) Failing after 12m23s
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / e2e (chromium) (pull_request) Successful in 5m49s
CI Pipeline / visual-regression (pull_request) Successful in 4m53s
2025-09-14 12:44:50 -06:00
adilallo df418328c6 Fix failing tests and lint
CI Pipeline / test (20) (pull_request) Successful in 5m31s
CI Pipeline / test (18) (pull_request) Successful in 5m49s
CI Pipeline / e2e (chromium) (pull_request) Successful in 3m6s
CI Pipeline / e2e (firefox) (pull_request) Successful in 4m15s
CI Pipeline / e2e (webkit) (pull_request) Successful in 3m33s
CI Pipeline / performance (pull_request) Successful in 2m32s
CI Pipeline / visual-regression (pull_request) Failing after 5m43s
CI Pipeline / storybook (pull_request) Successful in 1m21s
CI Pipeline / lint (pull_request) Successful in 1m6s
CI Pipeline / build (pull_request) Successful in 1m20s
2025-09-13 23:26:47 -06:00
adilallo 337a35d367 Update ContentLockup.integration.test.jsx
CI Pipeline / test (20) (pull_request) Successful in 2m22s
CI Pipeline / test (18) (pull_request) Successful in 2m33s
CI Pipeline / e2e (chromium) (pull_request) Successful in 3m36s
CI Pipeline / e2e (firefox) (pull_request) Successful in 4m39s
CI Pipeline / e2e (webkit) (pull_request) Successful in 3m57s
CI Pipeline / visual-regression (pull_request) Failing after 4m41s
CI Pipeline / performance (pull_request) Successful in 2m30s
CI Pipeline / storybook (pull_request) Successful in 1m22s
CI Pipeline / lint (pull_request) Failing after 1m0s
CI Pipeline / build (pull_request) Successful in 1m16s
2025-09-13 21:52:46 -06:00
adilallo eb9a108d86 Fix storybook test
CI Pipeline / test (20) (pull_request) Failing after 2m19s
CI Pipeline / test (18) (pull_request) Failing after 2m35s
CI Pipeline / e2e (chromium) (pull_request) Successful in 3m44s
CI Pipeline / e2e (firefox) (pull_request) Successful in 3m49s
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
2025-09-13 21:44:24 -06:00
adilallo 29c4e6e4bd Run Prettier
CI Pipeline / test (18) (pull_request) Successful in 2m50s
CI Pipeline / test (20) (pull_request) Successful in 2m48s
CI Pipeline / e2e (chromium) (pull_request) Successful in 3m42s
CI Pipeline / e2e (firefox) (pull_request) Successful in 4m3s
CI Pipeline / e2e (webkit) (pull_request) Successful in 4m20s
CI Pipeline / visual-regression (pull_request) Failing after 4m43s
CI Pipeline / storybook (pull_request) Failing after 1m37s
CI Pipeline / performance (pull_request) Successful in 2m41s
CI Pipeline / lint (pull_request) Successful in 1m19s
CI Pipeline / build (pull_request) Successful in 1m40s
2025-09-13 20:47:36 -06:00
adilallo 699de76e7f Update visual-regression.spec.ts 2025-09-13 20:46:21 -06:00
adilallo e6f29a2d97 Fix failing tests
CI Pipeline / test (20) (pull_request) Successful in 2m28s
CI Pipeline / test (18) (pull_request) Successful in 2m36s
CI Pipeline / e2e (chromium) (pull_request) Successful in 3m41s
CI Pipeline / e2e (firefox) (pull_request) Successful in 3m55s
CI Pipeline / e2e (webkit) (pull_request) Successful in 4m10s
CI Pipeline / visual-regression (pull_request) Failing after 4m25s
CI Pipeline / storybook (pull_request) Successful in 1m33s
CI Pipeline / performance (pull_request) Successful in 2m31s
CI Pipeline / lint (pull_request) Successful in 1m2s
CI Pipeline / build (pull_request) Successful in 1m28s
2025-09-13 17:34:12 -06:00
adilallo a867fc45d6 Lint and prettier
CI Pipeline / test (20) (pull_request) Failing after 1m51s
CI Pipeline / test (18) (pull_request) Failing after 2m6s
CI Pipeline / e2e (chromium) (pull_request) Successful in 3m30s
CI Pipeline / e2e (firefox) (pull_request) Successful in 4m50s
CI Pipeline / e2e (webkit) (pull_request) Successful in 4m17s
CI Pipeline / performance (pull_request) Successful in 2m48s
CI Pipeline / visual-regression (pull_request) Failing after 6m15s
CI Pipeline / storybook (pull_request) Failing after 1m40s
CI Pipeline / lint (pull_request) Successful in 1m12s
CI Pipeline / build (pull_request) Successful in 1m26s
2025-09-13 16:33:47 -06:00
adilallo 102a10457a Update content documentation and implement basic learn page 2025-09-13 16:31:50 -06:00
adilallo 56a42db2da Fix visual regression server crash 2025-09-13 15:42:06 -06:00
adilallo 26de297ac5 Run linter and prettier
CI Pipeline / test (20) (pull_request) Successful in 2m33s
CI Pipeline / test (18) (pull_request) Successful in 2m45s
CI Pipeline / e2e (chromium) (pull_request) Successful in 2m33s
CI Pipeline / e2e (firefox) (pull_request) Successful in 2m42s
CI Pipeline / e2e (webkit) (pull_request) Successful in 3m3s
CI Pipeline / visual-regression (pull_request) Failing after 5m7s
CI Pipeline / performance (pull_request) Successful in 3m8s
CI Pipeline / lint (pull_request) Successful in 1m11s
CI Pipeline / storybook (pull_request) Successful in 2m1s
CI Pipeline / build (pull_request) Successful in 1m25s
2025-09-12 17:36:23 -06:00
adilallo ec6bdebc44 Fix Lighthouse CI performance tests 2025-09-12 17:34:19 -06:00
adilallo 54cddb5041 Update ci.yaml
CI Pipeline / test (20) (pull_request) Failing after 5m47s
CI Pipeline / test (18) (pull_request) Failing after 5m59s
CI Pipeline / e2e (chromium) (pull_request) Successful in 2m39s
CI Pipeline / e2e (firefox) (pull_request) Successful in 5m30s
CI Pipeline / e2e (webkit) (pull_request) Successful in 3m11s
CI Pipeline / performance (pull_request) Failing after 2m47s
CI Pipeline / visual-regression (pull_request) Successful in 3m22s
CI Pipeline / lint (pull_request) Failing after 1m15s
CI Pipeline / storybook (pull_request) Successful in 1m39s
CI Pipeline / build (pull_request) Successful in 1m26s
2025-09-12 16:55:15 -06:00
adilallo 9150b8a502 Update storybook base config
CI Pipeline / test (18) (pull_request) Has been cancelled
CI Pipeline / test (20) (pull_request) Has been cancelled
CI Pipeline / e2e (chromium) (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
2025-09-12 16:51:30 -06:00
adilallo a276978743 Add blog snapshots
CI Pipeline / test (18) (pull_request) Has been cancelled
CI Pipeline / test (20) (pull_request) Has been cancelled
CI Pipeline / e2e (chromium) (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
2025-09-12 16:19:51 -06:00
adilallo 62a7046612 Fix performance tests 2025-09-12 16:16:56 -06:00
adilallo 842bbe44f1 Update visual regression tests 2025-09-12 16:01:12 -06:00
adilallo 500d2d0965 Run lint and prettier
CI Pipeline / test (20) (pull_request) Failing after 8m5s
CI Pipeline / test (18) (pull_request) Failing after 8m36s
CI Pipeline / e2e (chromium) (pull_request) Successful in 2m32s
CI Pipeline / e2e (webkit) (pull_request) Successful in 3m40s
CI Pipeline / e2e (firefox) (pull_request) Successful in 5m43s
CI Pipeline / performance (pull_request) Failing after 3m18s
CI Pipeline / visual-regression (pull_request) Failing after 3m20s
CI Pipeline / lint (pull_request) Successful in 1m7s
CI Pipeline / storybook (pull_request) Successful in 1m32s
CI Pipeline / build (pull_request) Successful in 1m22s
2025-09-12 14:33:46 -06:00
adilallo 8daea70cb8 Content page storybook added 2025-09-12 14:17:41 -06:00
adilallo ea023d5ec6 Content contributor documentation 2025-09-12 11:43:53 -06:00
adilallo a3a62fab91 Implement E2E tests for content page 2025-09-12 10:59:55 -06:00
adilallo 6319d549df Add integration tests for content 2025-09-12 10:44:45 -06:00
adilallo c8f63ca39a Fix failing tests and add unit tests 2025-09-12 10:26:21 -06:00
adilallo 0f9bc0d74e Add structured data for search engines 2025-09-12 09:00:59 -06:00
adilallo f1214167e6 Ask Organizer variant added 2025-09-12 08:55:21 -06:00
adilallo 3820076435 Related Articles lg breakpoint implemented 2025-09-11 14:27:18 -06:00
adilallo 8a31671bbc Related Article section implemented 2025-09-11 14:06:31 -06:00
adilallo ec2db8be22 Finish text section on article page add home link 2025-09-11 12:58:52 -06:00
adilallo 6123ced665 Content Banner md lg xl breakpoints 2025-09-08 19:23:30 -06:00
adilallo d500cf2c91 Content Banner default and sm breakpoint 2025-09-08 18:34:28 -06:00
adilallo d22f10af0d Fix spacing on MD upload 2025-09-07 12:24:04 -06:00
adilallo b85b4248c0 Update blog page asset and md loading 2025-09-05 09:46:48 -06:00
adilallo 5e83655f49 Dynamic link to blog page 2025-09-05 08:20:28 -06:00
adilallo 93182e6c2d Remove unnecessary props and data structure 2025-09-05 08:09:44 -06:00
adilallo fc096129dd Cleanup Content Thumbnail 2025-09-05 07:45:28 -06:00
adilallo d1400fe52c Content Container fully implemented 2025-09-04 22:00:55 -06:00
adilallo 3cca30ae28 Consolidate component folders 2025-09-04 21:13:18 -06:00
adilallo 3a4b2d44c2 Content Thumbnail default breakpoint 2025-09-04 21:11:17 -06:00
adilallo b54ddb16ba Added content processing system 2025-09-04 10:49:48 -06:00
adilallo 3d6d4ed251 Infrastructure setup 2025-09-04 10:27:29 -06:00
an.di 0ee9725f3f Merge pull request 'Testing Framework 2' (#18) from adilallo/enhancement/TestingFramework3 into main
Reviewed-on: #18
2025-09-04 15:13:11 +00:00
adilallo 3e42fe86a4 Run tests only on PRs
CI Pipeline / test (18) (pull_request) Successful in 4m57s
CI Pipeline / e2e (chromium) (pull_request) Successful in 1m50s
CI Pipeline / test (20) (pull_request) Successful in 7m20s
CI Pipeline / e2e (firefox) (pull_request) Successful in 2m25s
CI Pipeline / e2e (webkit) (pull_request) Successful in 3m33s
CI Pipeline / visual-regression (pull_request) Successful in 3m54s
CI Pipeline / performance (pull_request) Successful in 3m3s
CI Pipeline / storybook (pull_request) Successful in 1m20s
CI Pipeline / lint (pull_request) Successful in 1m1s
CI Pipeline / build (pull_request) Successful in 1m17s
2025-09-04 08:02:24 -06:00
adilallo bf706de68e Update visual regression snapshot path
CI Pipeline / test (20) (pull_request) Successful in 6m3s
CI Pipeline / test (18) (pull_request) Successful in 7m34s
CI Pipeline / e2e (chromium) (pull_request) Successful in 1m56s
CI Pipeline / e2e (firefox) (pull_request) Successful in 2m54s
CI Pipeline / e2e (webkit) (pull_request) Successful in 4m39s
CI Pipeline / visual-regression (pull_request) Successful in 4m10s
CI Pipeline / storybook (pull_request) Successful in 1m28s
CI Pipeline / performance (pull_request) Successful in 3m29s
CI Pipeline / lint (pull_request) Successful in 1m2s
CI Pipeline / build (pull_request) Successful in 1m26s
2025-09-03 15:27:47 -06:00
adilallo b265aace57 Run lint and prettier
CI Pipeline / test (20) (pull_request) Successful in 7m32s
CI Pipeline / test (18) (pull_request) Successful in 7m34s
CI Pipeline / e2e (chromium) (pull_request) Successful in 3m13s
CI Pipeline / e2e (firefox) (pull_request) Successful in 3m46s
CI Pipeline / e2e (webkit) (pull_request) Successful in 3m57s
CI Pipeline / performance (pull_request) Successful in 3m41s
CI Pipeline / visual-regression (pull_request) Failing after 8m6s
CI Pipeline / storybook (pull_request) Successful in 1m31s
CI Pipeline / lint (pull_request) Successful in 1m6s
CI Pipeline / build (pull_request) Successful in 1m24s
2025-09-03 14:49:37 -06:00
adilallo ce53de9003 Move accessibility tests to new folder 2025-09-03 14:47:36 -06:00
adilallo 25998e946f Update testing documentation 2025-09-03 14:23:10 -06:00
adilallo 3f1327b116 Remove seed vr snapshots
CI Pipeline / test (20) (pull_request) Successful in 7m13s
CI Pipeline / test (18) (pull_request) Successful in 7m19s
CI Pipeline / e2e (chromium) (pull_request) Successful in 2m46s
CI Pipeline / e2e (firefox) (pull_request) Successful in 3m43s
CI Pipeline / e2e (webkit) (pull_request) Successful in 3m22s
CI Pipeline / visual-regression (pull_request) Successful in 4m53s
CI Pipeline / performance (pull_request) Successful in 3m47s
CI Pipeline / storybook (pull_request) Successful in 1m33s
CI Pipeline / lint (pull_request) Failing after 1m0s
CI Pipeline / build (pull_request) Successful in 1m23s
2025-09-03 14:00:13 -06:00
adilallo dfb64a590d Update mobile screenshot 2025-09-03 13:59:01 -06:00
adilallo 8a6ac5e346 Add more unit testing 2025-09-03 13:46:17 -06:00
adilallo d84abf5aa7 Fix visual regression tests
CI Pipeline / test (20) (pull_request) Successful in 2m17s
CI Pipeline / e2e (chromium) (pull_request) Successful in 1m53s
CI Pipeline / test (18) (pull_request) Successful in 5m10s
CI Pipeline / e2e (firefox) (pull_request) Successful in 2m22s
CI Pipeline / e2e (webkit) (pull_request) Successful in 3m17s
CI Pipeline / visual-regression (pull_request) Failing after 4m18s
CI Pipeline / seed-vr-snapshots (pull_request) Has been skipped
CI Pipeline / performance (pull_request) Successful in 3m13s
CI Pipeline / storybook (pull_request) Successful in 1m33s
CI Pipeline / lint (pull_request) Failing after 1m20s
CI Pipeline / build (pull_request) Successful in 1m23s
2025-09-03 13:29:30 -06:00
an.di 50efb6a22a Merge pull request 'Testing Framwork' (#17) from adilallo/enhancement/TestingFramework2 into main
CI Pipeline / test (20) (push) Successful in 1m48s
CI Pipeline / test (18) (push) Successful in 2m11s
CI Pipeline / e2e (chromium) (push) Successful in 2m17s
CI Pipeline / e2e (firefox) (push) Successful in 2m35s
CI Pipeline / e2e (webkit) (push) Successful in 4m56s
CI Pipeline / performance (push) Successful in 2m27s
CI Pipeline / visual-regression (push) Failing after 6m58s
CI Pipeline / storybook (push) Successful in 1m35s
CI Pipeline / lint (push) Successful in 2m3s
CI Pipeline / seed-vr-snapshots (push) Failing after 3m51s
CI Pipeline / build (push) Successful in 1m19s
Reviewed-on: #17
2025-09-03 18:50:39 +00:00
adilallo a34cc1d788 Remove playwright caching
CI Pipeline / test (20) (pull_request) Successful in 6m15s
CI Pipeline / test (18) (pull_request) Successful in 6m22s
CI Pipeline / e2e (chromium) (pull_request) Successful in 2m34s
CI Pipeline / e2e (firefox) (pull_request) Successful in 2m40s
CI Pipeline / e2e (webkit) (pull_request) Successful in 5m3s
CI Pipeline / visual-regression (pull_request) Failing after 6m56s
CI Pipeline / seed-vr-snapshots (pull_request) Has been skipped
CI Pipeline / performance (pull_request) Successful in 2m29s
CI Pipeline / lint (pull_request) Successful in 1m2s
CI Pipeline / storybook (pull_request) Successful in 1m34s
CI Pipeline / build (pull_request) Successful in 1m21s
2025-09-03 12:18:14 -06:00
adilallo b6446899b1 Attempt to fix playwright and performance again
CI Pipeline / test (18) (pull_request) Successful in 4m40s
CI Pipeline / test (20) (pull_request) Successful in 4m43s
CI Pipeline / e2e (chromium) (pull_request) Failing after 1m59s
CI Pipeline / e2e (firefox) (pull_request) Failing after 2m2s
CI Pipeline / e2e (webkit) (pull_request) Failing after 3m23s
CI Pipeline / visual-regression (pull_request) Failing after 3m52s
CI Pipeline / seed-vr-snapshots (pull_request) Has been skipped
CI Pipeline / storybook (pull_request) Successful in 6m3s
CI Pipeline / performance (pull_request) Successful in 8m5s
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
2025-09-03 11:59:03 -06:00
adilallo 3bb4139aeb Attempt to fix playwright key and performance issue
CI Pipeline / test (20) (pull_request) Successful in 5m59s
CI Pipeline / test (18) (pull_request) Successful in 7m2s
CI Pipeline / e2e (chromium) (pull_request) Failing after 3m17s
CI Pipeline / e2e (firefox) (pull_request) Failing after 3m16s
CI Pipeline / e2e (webkit) (pull_request) Failing after 1m55s
CI Pipeline / visual-regression (pull_request) Failing after 1m15s
CI Pipeline / seed-vr-snapshots (pull_request) Has been skipped
CI Pipeline / performance (pull_request) Failing after 2m8s
CI Pipeline / build (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
2025-09-03 11:42:13 -06:00
adilallo 704bc01e23 Fix cache playwrite and performance job
CI Pipeline / test (20) (pull_request) Successful in 4m33s
CI Pipeline / test (18) (pull_request) Successful in 4m48s
CI Pipeline / e2e (chromium) (pull_request) Failing after 25s
CI Pipeline / e2e (firefox) (pull_request) Failing after 23s
CI Pipeline / e2e (webkit) (pull_request) Failing after 23s
CI Pipeline / visual-regression (pull_request) Failing after 2m3s
CI Pipeline / seed-vr-snapshots (pull_request) Has been skipped
CI Pipeline / performance (pull_request) Failing after 2m59s
CI Pipeline / build (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
2025-09-03 11:27:42 -06:00
adilallo 1e16c8b6ff Run lint and prettier
CI Pipeline / test (18) (pull_request) Successful in 2m47s
CI Pipeline / e2e (chromium) (pull_request) Failing after 54s
CI Pipeline / e2e (firefox) (pull_request) Failing after 32s
CI Pipeline / e2e (webkit) (pull_request) Failing after 23s
CI Pipeline / visual-regression (pull_request) Failing after 57s
CI Pipeline / test (20) (pull_request) Successful in 6m10s
CI Pipeline / seed-vr-snapshots (pull_request) Has been skipped
CI Pipeline / performance (pull_request) Failing after 1m55s
CI Pipeline / storybook (pull_request) Successful in 2m17s
CI Pipeline / lint (pull_request) Successful in 1m59s
CI Pipeline / build (pull_request) Successful in 2m7s
2025-09-03 11:03:29 -06:00
adilallo 5a7295ff5d Implement comprehensive visual regression stability improvements 2025-09-03 11:01:52 -06:00
adilallo 24c8fc525e Fix Chrome path resolution in performance test step 2025-09-03 10:47:50 -06:00
adilallo 8ab65d8c3c Attempt to fix snapshot issue 2025-09-03 10:44:06 -06:00
adilallo 2fcc360e7d Fix performance test: use mac_arm platform and ensure arm64 Node for Lighthouse
CI Pipeline / test (18) (pull_request) Successful in 6m26s
CI Pipeline / test (20) (pull_request) Successful in 7m6s
CI Pipeline / e2e (chromium) (pull_request) Failing after 5m0s
CI Pipeline / e2e (firefox) (pull_request) Failing after 5m35s
CI Pipeline / e2e (webkit) (pull_request) Failing after 3m13s
CI Pipeline / visual-regression (pull_request) Failing after 5m50s
CI Pipeline / performance (pull_request) Failing after 4m26s
CI Pipeline / storybook (pull_request) Successful in 5m25s
CI Pipeline / lint (pull_request) Successful in 5m0s
CI Pipeline / build (pull_request) Successful in 2m15s
2025-09-03 10:21:11 -06:00
adilallo 0fc5bf30f6 Fix chromium and webkit tests 2025-09-03 10:18:53 -06:00
adilallo 6b1bed3395 Run Prettier
CI Pipeline / test (18) (pull_request) Successful in 4m51s
CI Pipeline / test (20) (pull_request) Successful in 4m57s
CI Pipeline / e2e (chromium) (pull_request) Failing after 5m28s
CI Pipeline / e2e (firefox) (pull_request) Successful in 8m35s
CI Pipeline / e2e (webkit) (pull_request) Failing after 6m0s
CI Pipeline / visual-regression (pull_request) Failing after 5m38s
CI Pipeline / performance (pull_request) Failing after 3m53s
CI Pipeline / lint (pull_request) Successful in 5m27s
CI Pipeline / storybook (pull_request) Successful in 7m6s
CI Pipeline / build (pull_request) Successful in 6m59s
2025-09-03 09:33:23 -06:00
adilallo 1e299af234 Attempt to fix performance runner 2025-09-03 09:31:46 -06:00
adilallo d7495d8f31 Resolve pixel drift 2025-09-03 09:27:42 -06:00
adilallo fae2b57c4f Fix visual regression tests
CI Pipeline / test (20) (pull_request) Successful in 4m40s
CI Pipeline / test (18) (pull_request) Successful in 5m6s
CI Pipeline / e2e (chromium) (pull_request) Failing after 2m53s
CI Pipeline / e2e (firefox) (pull_request) Successful in 5m9s
CI Pipeline / e2e (webkit) (pull_request) Failing after 6m0s
CI Pipeline / visual-regression (pull_request) Failing after 7m41s
CI Pipeline / performance (pull_request) Failing after 4m45s
CI Pipeline / lint (pull_request) Failing after 3m53s
CI Pipeline / storybook (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
2025-09-03 08:52:35 -06:00
adilallo a2381fe148 Remove redundant visual regression from E2E test
CI Pipeline / test (18) (pull_request) Successful in 2m28s
CI Pipeline / test (20) (pull_request) Successful in 2m26s
CI Pipeline / e2e (chromium) (pull_request) Failing after 3m8s
CI Pipeline / e2e (firefox) (pull_request) Failing after 3m40s
CI Pipeline / e2e (webkit) (pull_request) Failing after 3m15s
CI Pipeline / visual-regression (pull_request) Failing after 5m40s
CI Pipeline / performance (pull_request) Failing after 3m3s
CI Pipeline / lint (pull_request) Failing after 2m58s
CI Pipeline / storybook (pull_request) Successful in 4m8s
CI Pipeline / build (pull_request) Successful in 2m12s
2025-09-02 23:29:10 -06:00
adilallo 4a8f99a907 Fix more indentation and syntax errors
CI Pipeline / test (20) (pull_request) Successful in 2m4s
CI Pipeline / test (18) (pull_request) Successful in 2m10s
CI Pipeline / e2e (chromium) (pull_request) Failing after 2m52s
CI Pipeline / e2e (firefox) (pull_request) Failing after 6m20s
CI Pipeline / e2e (webkit) (pull_request) Failing after 4m13s
CI Pipeline / storybook (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
2025-09-02 21:46:27 -06:00
adilallo 4ba5075a9f Fix indentation issue 2025-09-02 21:41:58 -06:00
adilallo fd14667da2 Try single step approach 2025-09-02 21:40:20 -06:00
adilallo 1253779c00 Update ci.yaml
CI Pipeline / test (20) (pull_request) Successful in 5m17s
CI Pipeline / test (18) (pull_request) Successful in 6m36s
CI Pipeline / e2e (chromium) (pull_request) Failing after 4m48s
CI Pipeline / e2e (firefox) (pull_request) Failing after 3m40s
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
2025-09-02 21:28:32 -06:00
adilallo e071449ce9 Add server debugging
CI Pipeline / test (18) (pull_request) Successful in 6m30s
CI Pipeline / test (20) (pull_request) Successful in 7m17s
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
CI Pipeline / e2e (chromium) (pull_request) Has been cancelled
2025-09-02 20:57:54 -06:00
adilallo e6bde95e6f Change runner to use dev server
CI Pipeline / test (20) (pull_request) Successful in 2m1s
CI Pipeline / test (18) (pull_request) Successful in 2m28s
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
CI Pipeline / e2e (chromium) (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
2025-09-02 19:27:03 -06:00
adilallo 1cda0e7ad3 Fix playwright config mismatch
CI Pipeline / test (20) (pull_request) Successful in 7m8s
CI Pipeline / test (18) (pull_request) Successful in 7m24s
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
CI Pipeline / e2e (chromium) (pull_request) Has been cancelled
2025-09-02 19:06:51 -06:00
adilallo edf8637d7d Fix failing E2E tests
CI Pipeline / test (20) (pull_request) Successful in 13m57s
CI Pipeline / test (18) (pull_request) Successful in 14m40s
CI Pipeline / e2e (firefox) (pull_request) Failing after 17m0s
CI Pipeline / e2e (chromium) (pull_request) Failing after 17m1s
CI Pipeline / visual-regression (pull_request) Successful in 6m25s
CI Pipeline / e2e (webkit) (pull_request) Failing after 7m56s
CI Pipeline / performance (pull_request) Failing after 5m25s
CI Pipeline / storybook (pull_request) Successful in 7m10s
CI Pipeline / lint (pull_request) Failing after 3m36s
CI Pipeline / build (pull_request) Successful in 7m21s
2025-09-02 17:38:03 -06:00
adilallo 394173161c Fix Storybook runner tests 2025-09-02 16:45:04 -06:00
adilallo eb407e03ee Fix performance runner test
CI Pipeline / test (20) (pull_request) Successful in 1m58s
CI Pipeline / test (18) (pull_request) Successful in 2m8s
CI Pipeline / e2e (chromium) (pull_request) Failing after 15m39s
CI Pipeline / e2e (firefox) (pull_request) Failing after 20m26s
CI Pipeline / visual-regression (pull_request) Successful in 5m5s
CI Pipeline / performance (pull_request) Successful in 5m25s
CI Pipeline / e2e (webkit) (pull_request) Failing after 18m57s
CI Pipeline / lint (pull_request) Successful in 1m28s
CI Pipeline / storybook (pull_request) Failing after 5m46s
CI Pipeline / build (pull_request) Successful in 1m48s
2025-08-30 22:34:35 -06:00
adilallo 494fd9cca1 Remove canary test
CI Pipeline / test (20) (pull_request) Successful in 1m56s
CI Pipeline / test (18) (pull_request) Successful in 2m6s
CI Pipeline / e2e (chromium) (pull_request) Failing after 15m51s
CI Pipeline / e2e (firefox) (pull_request) Failing after 20m34s
CI Pipeline / visual-regression (pull_request) Successful in 5m41s
CI Pipeline / performance (pull_request) Failing after 2m12s
CI Pipeline / storybook (pull_request) Failing after 5m50s
CI Pipeline / e2e (webkit) (pull_request) Failing after 19m7s
CI Pipeline / lint (pull_request) Successful in 1m38s
CI Pipeline / build (pull_request) Successful in 1m57s
2025-08-30 13:47:54 -06:00
adilallo 12deae75e8 Fix prettier formatting issues 2025-08-30 13:46:35 -06:00
adilallo 1e795e1340 Fix visual regression and performance server issue 2025-08-30 13:41:43 -06:00
adilallo c20f704ccf Fix and improve basline tests 2025-08-30 13:37:55 -06:00
adilallo de04405de7 Revert "Apply robust server startup pattern to all jobs (visual-regression, performance) with proper cleanup"
CI Pipeline / canary (pull_request) Successful in 1s
CI Pipeline / test (20) (pull_request) Successful in 2m0s
CI Pipeline / test (18) (pull_request) Successful in 2m10s
CI Pipeline / e2e (chromium) (pull_request) Failing after 54m36s
CI Pipeline / e2e (firefox) (pull_request) Failing after 1h2m46s
CI Pipeline / visual-regression (pull_request) Failing after 3m9s
CI Pipeline / performance (pull_request) Failing after 1m55s
CI Pipeline / e2e (webkit) (pull_request) Failing after 15m47s
CI Pipeline / lint (pull_request) Failing after 1m23s
CI Pipeline / build (pull_request) Successful in 2m24s
CI Pipeline / storybook (pull_request) Failing after 6m16s
This reverts commit 8efe237018.
2025-08-30 12:03:16 -06:00
adilallo 41a1fa84d7 Revert "Update ci.yaml"
This reverts commit bb483c6139.
2025-08-30 12:03:08 -06:00
adilallo bb483c6139 Update ci.yaml
CI Pipeline / canary (pull_request) Has been cancelled
CI Pipeline / test (18) (pull_request) Has been cancelled
CI Pipeline / test (20) (pull_request) Has been cancelled
CI Pipeline / e2e (chromium) (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
2025-08-30 11:59:28 -06:00
adilallo 8efe237018 Apply robust server startup pattern to all jobs (visual-regression, performance) with proper cleanup 2025-08-30 11:59:05 -06:00
adilallo 92f67dc678 Fix E2E job: Implement robust server startup with proper health checks and cleanup
CI Pipeline / canary (pull_request) Successful in 1s
CI Pipeline / test (20) (pull_request) Successful in 1m50s
CI Pipeline / test (18) (pull_request) Successful in 2m7s
CI Pipeline / e2e (chromium) (pull_request) Has been cancelled
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
2025-08-29 23:28:28 -06:00
adilallo 7b660e41eb Implement robust server startup with comprehensive debugging for all jobs
CI Pipeline / canary (pull_request) Successful in 1s
CI Pipeline / test (20) (pull_request) Successful in 1m49s
CI Pipeline / test (18) (pull_request) Successful in 2m11s
CI Pipeline / e2e (chromium) (pull_request) Failing after 1m39s
CI Pipeline / e2e (firefox) (pull_request) Failing after 1m38s
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
2025-08-29 23:17:32 -06:00
adilallo 89f1ee328f Fix server startup: Use explicit host binding and verification for all jobs
CI Pipeline / canary (pull_request) Successful in 7s
CI Pipeline / test (20) (pull_request) Successful in 1m55s
CI Pipeline / test (18) (pull_request) Successful in 2m10s
CI Pipeline / e2e (chromium) (pull_request) Failing after 1m59s
CI Pipeline / e2e (firefox) (pull_request) Failing after 1m49s
CI Pipeline / e2e (webkit) (pull_request) Failing after 1m52s
CI Pipeline / visual-regression (pull_request) Failing after 2m17s
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
2025-08-29 23:06:39 -06:00
adilallo 8969ead3bf Fix E2E tests: Use npm run start instead of preview to avoid double build
CI Pipeline / canary (pull_request) Successful in 0s
CI Pipeline / test (20) (pull_request) Successful in 1m58s
CI Pipeline / test (18) (pull_request) Successful in 2m8s
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
CI Pipeline / e2e (chromium) (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
2025-08-29 22:44:36 -06:00
adilallo afdf7ce595 Fix axe-core imports: Use AxeBuilder API for newer version
CI Pipeline / canary (pull_request) Successful in 0s
CI Pipeline / test (20) (pull_request) Successful in 2m7s
CI Pipeline / test (18) (pull_request) Successful in 2m9s
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
CI Pipeline / e2e (chromium) (pull_request) Has been cancelled
2025-08-29 22:35:22 -06:00
adilallo e44d7c1b92 Fix test issues: Use getAllByRole for multiple elements, add React cleanup
CI Pipeline / canary (pull_request) Successful in 6s
CI Pipeline / test (20) (pull_request) Successful in 1m45s
CI Pipeline / test (18) (pull_request) Successful in 1m49s
CI Pipeline / e2e (firefox) (pull_request) Failing after 1m33s
CI Pipeline / e2e (chromium) (pull_request) Failing after 1m38s
CI Pipeline / e2e (webkit) (pull_request) Failing after 1m31s
CI Pipeline / visual-regression (pull_request) Failing after 1m56s
CI Pipeline / performance (pull_request) Failing after 1m51s
CI Pipeline / lint (pull_request) Failing after 1m14s
CI Pipeline / build (pull_request) Failing after 1m18s
CI Pipeline / storybook (pull_request) Failing after 5m58s
2025-08-29 22:21:28 -06:00
adilallo 8e0b4246b2 Test runner
CI Pipeline / canary (pull_request) Successful in 1s
CI Pipeline / test (20) (pull_request) Failing after 1m8s
CI Pipeline / test (18) (pull_request) Failing after 1m20s
CI Pipeline / e2e (chromium) (pull_request) Failing after 1m1s
CI Pipeline / e2e (firefox) (pull_request) Failing after 1m0s
CI Pipeline / e2e (webkit) (pull_request) Failing after 1m0s
CI Pipeline / visual-regression (pull_request) Failing after 1m26s
CI Pipeline / performance (pull_request) Failing after 1m23s
CI Pipeline / lint (pull_request) Failing after 59s
CI Pipeline / build (pull_request) Failing after 56s
CI Pipeline / storybook (pull_request) Failing after 5m39s
2025-08-29 22:10:40 -06:00
adilallo 0e08e838e8 Update runner config
CI Pipeline / canary (pull_request) Has been cancelled
CI Pipeline / test (18) (pull_request) Has been cancelled
CI Pipeline / test (20) (pull_request) Has been cancelled
CI Pipeline / e2e (chromium) (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
2025-08-29 22:06:23 -06:00
adilallo 9133ff8697 Test runner with container config disabled 2025-08-29 22:05:59 -06:00
adilallo 908fc79303 Update runner
CI Pipeline / canary (pull_request) Has been cancelled
CI Pipeline / test (18) (pull_request) Has been cancelled
CI Pipeline / test (20) (pull_request) Has been cancelled
CI Pipeline / e2e (chromium) (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
2025-08-29 22:03:36 -06:00
adilallo b76d62deb5 Test new runner registration 2025-08-29 22:01:35 -06:00
adilallo cfe54737b8 Test workflow from working commit 6994f75 2025-08-29 21:54:44 -06:00
adilallo bead0c7373 Fix duplicate triggers: Only run CI on PRs to main/develop, not feature branch pushes
CI Pipeline / canary (pull_request) Has been cancelled
CI Pipeline / test (18) (pull_request) Has been cancelled
CI Pipeline / test (20) (pull_request) Has been cancelled
CI Pipeline / e2e (chromium) (pull_request) Has been cancelled
CI Pipeline / e2e (firefox) (pull_request) Has been cancelled
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / visual-regression (pull_request) Has been cancelled
CI Pipeline / performance (pull_request) Has been cancelled
CI Pipeline / storybook (pull_request) Has been cancelled
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
2025-08-29 21:52:06 -06:00
1516 changed files with 109911 additions and 112803 deletions
+48
View File
@@ -0,0 +1,48 @@
---
description: Unified Alert (toast/banner) for app notifications — Figma + drift prevention
globs: app/**/*.tsx, stories/modals/Alert.stories.js, tests/components/Alert.test.tsx
alwaysApply: false
---
# Alerts and notifications
## Source of truth
- **Figma:** [Community Rule System — Modal / Alert](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=6351-14646) (node **6351-14646**).
- **Code:** `app/components/modals/Alert` — default export `Alert` from `Alert.container.tsx` (Figma docstring on the container).
## When to use `Alert`
Use **`Alert`** for **app-level, section-level, and shell-level** success, warning, error, and neutral status messages that should read as a designed system surface (not body copy alone).
Do **not** recreate the same job with ad-hoc UI: bordered `<p>`, free-standing `role="alert"` blocks, or raw `text-[var(--color-border-default-utility-negative)]` paragraphs for product messaging.
## Props (lowercase in code; match Figma intent)
| Concern | Prop | Notes |
| --- | --- | --- |
| Layout | `type` | `toast` — bottom accent bar, top rounded corners; `banner` — full rounded block, inline or stacked. |
| Intent | `status` | `default` \| `positive` \| `warning` \| `danger`. |
| Density | `size` | `s` \| `m` (Figma S/M). Typography and padding are implemented inside `Alert.container.tsx` — do not fork spacing per call site. |
| Copy | `title`, `description?` | Required title; optional description when `hasBodyText` is true. |
| Icon | `hasLeadingIcon?` | Default `true`. |
| Body | `hasBodyText?` | Default `true`; set `false` for title-only. |
| Dismiss | `onClose?`, `hasTrailingIcon?` | Close control shows only when `onClose` is provided **and** `hasTrailingIcon` is not `false`. Omit `onClose` for non-dismissible messages. |
Valid enum slices for Storybook / guards: `ALERT_*_OPTIONS` in `lib/propNormalization.ts`.
## Choosing toast vs banner
- **`toast`** — transient edge / bottom emphasis (e.g. completed flow), strong bottom border accent.
- **`banner`** — rounded block; for **page / shell / modal** messaging, mount inside a **`fixed`** (or equivalent) overlay wrapper with `pointer-events-none` on the outer layer and `pointer-events-auto` on the alert so layout chrome does not reflow when the message appears (see `CreateFlowLayoutClient` `topBanners`, profile overlays, `LoginForm`, `PostLoginDraftTransfer`).
## Exemptions (do not force `Alert`)
1. **Single-field validation** under a control — keep `TextInput` / `TextArea` `error` and helper text (e.g. invalid email on the login form) unless design explicitly moves that line into `Alert`.
2. **Marketing layout** — `HeroBanner`, `ContentBanner` are not system alerts.
3. **Landmarks** — `role="banner"` on headers/nav is not the `Alert` “banner” type.
4. **A11y-only live regions** — e.g. tooltip / incrementer `aria-live` for widget state, not product notifications.
## Copy
All user-visible strings go through **`messages/`** and `useTranslation` / message modules per `localization.mdc`.
+85
View File
@@ -0,0 +1,85 @@
---
description: App Router API handler conventions (Next.js + Prisma + Zod)
globs: app/api/**/*.ts,lib/server/**/*.ts
alwaysApply: false
---
# API route anatomy
Every DB-touching handler in `app/api/**/route.ts` follows the same skeleton.
Keep new routes within this shape so auth, config, and validation stay uniform.
1. **Config guard (first line of the handler).**
```typescript
if (!isDatabaseConfigured()) return dbUnavailable();
```
From `lib/server/env` + `lib/server/responses`. Returns a consistent 503
when `CLOUDRON_POSTGRESQL_URL` is missing (local dev, preview builds).
2. **Auth (when the route requires a user).**
```typescript
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
```
From `lib/server/session`. Never read session cookies or tokens directly.
3. **Body parsing + validation (POST/PUT/PATCH).**
```typescript
const parsed = await readLimitedJson(request);
const result = mySchema.safeParse(parsed);
if (!result.success) return jsonFromZodError(result.error);
```
Helpers live in `lib/server/validation/{requestBody,zodHttp}.ts`. All
payload schemas belong in `lib/server/validation/*.ts` (today:
`createFlowSchemas.ts`) — colocate new schemas there rather than inline in
the route.
4. **Prisma access** via `import { prisma } from "lib/server/db"`. Do not
instantiate `PrismaClient` directly.
5. **Responses** via `NextResponse.json(...)`. Shared shapes
(`dbUnavailable`, `unauthorized`, `notFound`, `rateLimited`,
`serverMisconfigured`, `internalError`) and the generic `errorJson(code,
message, status, opts?)` live in `lib/server/responses.ts`. Add new
shared responses there when a pattern repeats in two routes.
6. **Errors + observability.** All 4xx/5xx bodies use the canonical shape
`{ error: { code, message }, details? }` with codes from the
`ApiErrorCode` union in `lib/server/responses.ts`. Wrap handlers with
`apiRoute("scope.name", async (req, ctx, { requestId }) => { ... })`
from `lib/server/apiRoute.ts` so an `x-request-id` is generated /
forwarded onto every response and uncaught throws return a canonical
500 with the id logged via `lib/logger`.
# Server-only isolation
`lib/server/*` is the server boundary. Anything that:
- imports `@prisma/client`,
- reads secrets from `env`,
- sends email, hashes tokens, or touches sessions
…lives under `lib/server/`. Never import `lib/server/*` from client
components, `app/components/**`, or any file marked `"use client"`. Shared
logic safe for both sides goes in `lib/*`.
# Deferred — follow existing code, don't invent
These areas are still settling. Match whatever the nearest route already does
instead of introducing new patterns:
- **Rate limiting.** `lib/server/rateLimit.ts` is an in-memory stopgap marked
for replacement. Reuse `rateLimitKey()` where limiting is needed; don't
design a new limiter. When returning 429, prefer `rateLimited(retryAfterMs)`
from `responses.ts` so the body and `Retry-After` header stay uniform.
- **Pagination / filtering.** Only `rules/route.ts` paginates (`take` capped
at 100). Mirror it if you add list endpoints; don't invent cursors or
offset contracts unilaterally.
+70
View File
@@ -0,0 +1,70 @@
---
description: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria.
alwaysApply: true
---
# Coding behavioral guidelines
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
---
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
+52
View File
@@ -0,0 +1,52 @@
---
description: Component prop conventions — lowercase-canonical enums, Figma traceability
globs: app/components/**/*.{ts,tsx}
alwaysApply: false
---
# Component prop alignment
Figma is the source of truth for component **design** (existence, variants,
visual specification). The codebase implements those components using
idiomatic TypeScript naming. Enum props are **lowercase** in code; PascalCase
is a Figma-side concern only.
## Enum prop convention
- Types use lowercase string unions: `"small" | "medium" | "large"`.
- Do NOT add PascalCase variants to type unions.
- Do NOT call normalizers in containers. The container layer is for `memo`,
derived state, prop defaults, and bound logic — not for casing translation.
- Each enum prop has a sibling `<COMPONENT>_<PROP>_OPTIONS as const` array
exported alongside the type. Storybook `argTypes` and any runtime guard
consume that array as the single source of valid values.
```typescript
export const CHIP_PALETTE_OPTIONS = ["primary", "secondary"] as const;
export type ChipPaletteValue = (typeof CHIP_PALETTE_OPTIONS)[number];
```
## Figma traceability
- Container docstring (required on every DS container): `Figma:
"<Component Path>" (<node-id>)`.
- View root element: `data-figma-node="<id>"` when the view maps to a
distinct Figma node.
- For create-flow screens, node ids come from `CREATE_FLOW_SCREEN_REGISTRY`
in `app/(app)/create/utils/createFlowScreenRegistry.ts`. For everything else,
prefer `Figma: "<Path>" (<node-id>)` from the file. If the node id is not
wired yet, use `Figma: "<Path>"` plus a short note (e.g. *canonical code under
\`controls/\`*) rather than omitting the docstring.
```typescript
/**
* Figma: "Control / Incrementer" (17857:30943). A compact [ - value + ]
* row used for numeric step inputs.
*/
```
## Pasting from Figma
Figma's "Inspect → Code" output emits PascalCase. When importing a snippet,
lowercase the enum values before committing — same pattern as removing
inline pixel values in favor of design tokens.
+102
View File
@@ -0,0 +1,102 @@
---
description: File-structure conventions for design-system components
globs: app/components/**/*.{ts,tsx}
alwaysApply: false
---
# Component file structure
## Split-file pattern (default)
Anything in `app/components/controls/**` and `app/components/utility/**` uses
a **4-file split**, one folder per component:
```
app/components/controls/<Name>/
<Name>.types.ts // Public Props + internal ViewProps
<Name>.view.tsx // "use client"; pure render; exports memo(view)
<Name>.container.tsx // "use client"; memo; prop normalization & logic
index.tsx // re-exports default + public types
```
**Container** (`<Name>.container.tsx`):
- Marked `"use client"`.
- Receives `<Name>Props`; computes derived state (clamps, ids, bounds, prop
defaults) and bound event handlers.
- Renders `<<Name>View />`. Containers do **not** translate prop casing —
enum props are lowercase end-to-end (see `component-props.mdc`).
- Default export: `memo(<Name>Container)` with `.displayName = "<Name>"`.
- Carries the Figma docstring (`Figma: "<Path>" (<node-id>)`).
**View** (`<Name>.view.tsx`):
- Marked `"use client"`.
- Pure render of `<Name>ViewProps`. No data fetching, no derived business
logic, no enum casing translation.
- Default export: `memo(<Name>View)` with `.displayName = "<Name>View"`.
**Types** (`<Name>.types.ts`):
- Export `<Name>Props` (consumer-facing).
- Export `<Name>ViewProps` (the shape the view consumes — typically a
resolved superset of `<Name>Props`).
- Export any locally-defined value types (`<Name>SizeValue`, etc.) sourced
from the matching `*_OPTIONS` array in `lib/propNormalization.ts`.
**Index** (`index.tsx`):
```typescript
export { default } from "./<Name>.container";
export type { <Name>Props } from "./<Name>.types";
```
## Small presentational packages (buttons)
`app/components/buttons/<Name>/` holds **`index.tsx`** plus **`<Name>.tsx`**
(the **`Button`**, **`InlineTextButton`** packages today). Promote to the full
container/view/types split when state or logic outgrows a single module (like **`controls/TextInput`**).
## `cards/` packages
Prefer the **container / view / types** layout for **`Selection/`**, **`CardStack/`**, **`Rule/`**,
**`Icon/`**, **`Mini/`**, **`TemplateReviewCard/`**. **`Step/`** keeps a single
**`<Name>.tsx`** next to **`index.tsx`** until complexity justifies a split.
## `modals/` packages
Use the same **container / view / types** split where those files exist (**`Alert`**, **`Create`**, **`Dialog`**, **`Login`**, **`Tooltip`**, **`ModalHeader`**, **`ModalFooter`**).
## `navigation/` packages
Use the **container / view / types** split + per-package **`index.tsx`** for **`Top/`**, **`CreateFlowTopNav/`**, **`CreateFlowFooter/`**, **`NavigationItem/`**, **`Link/`**, **`MenuItem/`**. **`TopWithPathname.tsx`** lives inside **`Top/`** as the pathname + session wrapper.
**Root-level** **`Menu.tsx`**, **`Footer.tsx`**, **`ConditionalNavigation.tsx`**, and **`ConditionalNavigationClient.tsx`** sit beside those folders—no bucket-level barrel. Figma **Navigation / Menu** maps to **`Menu`** + **`MenuItem`** (see **`docs/figma-component-registry.md`**, Navigation conventions) and **`routes.mdc`** for shell behavior.
## `progress/` packages
Use the **container / view / types** split + **`index.tsx`** for **`Stepper/`** and **`ProportionBar/`** (same shape as **`controls/`**). See **`docs/figma-component-registry.md`** — **Progress conventions** for Figma **Progress** vs **Control / Proportion**.
## `sections/` packages
Section-level compositions are **mixed**: many folders use **`container` / `view` / `types`** (**`FeatureGrid/`**, **`QuoteBlock/`**, …), while **`ContentBanner.tsx`** and **`SectionNumber.tsx`** are **single modules** at the bucket root. Prefer the **split** for **new** composites; see **`docs/figma-component-registry.md`** — **Sections conventions**. **`SectionHeader/`** lives under **`type/`** (Figma Type / SectionHeader). Published rule typography body **`CommunityRule/`** lives under **`type/`** (see **Type conventions**).
## `type/` packages
**`type/`** is mostly **`container` / `view` / `types`** + **`index.tsx`** (**`HeaderLockup/`**, **`ContentLockup/`**, **`NumberedList/`**, **`InputLabel/`**). **`SectionHeader/`** is a small presentational package (**`SectionHeader.tsx`** + **`index.tsx`**) for the Figma Type SectionHeader lockup. **`CommunityRule/`**, **`Section/`**, and **`TextBlock/`** are **view + types** packages (Community Rule document tree). See **`docs/figma-component-registry.md`** — **Type conventions**.
## No package-level barrels
Do **not** add **`app/components/<bucket>/index.tsx`** that re-exports every
sibling under that bucket (there is no `buttons/index.tsx` or `asset/index.tsx`).
Import **`…/buttons/Button`**, **`…/asset/icon`**, **`…/asset/Logo`**, etc.—same
mental model everywhere.
**Per-component** **`index.tsx`** entrypoints (**`Logo/index.tsx`**, **`controls/TextInput/index.tsx`**, …) stay as documented above—aggregating an entire **`buttons/`** or **`asset/`** tier in one file does not.
## Wrapper / group components
Related composites live in a **sibling folder**, not inside the base
component's folder — mirror `CheckboxGroup/` ↔ `Checkbox/`,
`IncrementerBlock/` ↔ `Incrementer/`, etc. Each gets its own 4-file split.
Consumers import from the folder's `index.tsx`.
+63
View File
@@ -0,0 +1,63 @@
---
description: Create-flow structure & design-system reuse guardrails
globs: app/(app)/create/**/*.{ts,tsx},messages/en/create/**/*.json
alwaysApply: false
---
# Create-flow guardrails
## Folder & file layout
- Screens live in `app/(app)/create/screens/<layoutKind>/<StepIdPascal>Screen.tsx`
where `<layoutKind>` mirrors `CreateFlowLayoutKind` (`card`, `select`,
`right-rail`, `completed`, …). File + export name is the **step id**, never
the layout kind (e.g. `DecisionApproachesScreen`, not `RightRailScreen`).
- Step id ↔ layout kind mapping is declared in
`app/(app)/create/utils/createFlowScreenRegistry.ts`. Never branch on layout kind
inside a screen — pick the matching shell (`CreateFlowStepShell` /
`CreateFlowTwoColumnSelectShell`).
- Keep create-flow step routing centralized in
`app/(app)/create/utils/createFlowPaths.ts` (`createFlowStepPath`,
`CREATE_ROUTES`) — do not introduce new hardcoded `/create/...` literals.
- Shared create-flow pieces go in `app/(app)/create/components/` (layout shells,
field composites). Generic primitives go in `app/components/`.
## Use the design system — don't hand-roll
Reach for these before writing new markup:
| Need | Component |
| --- | --- |
| Labelled text-area section in a modal | `app/(app)/create/components/ModalTextAreaField` |
| Toggle-chip row + inline "+ Add" input | `app/(app)/create/components/ApplicableScopeField` |
| `[ value +]` numeric stepper (± label) | `app/components/controls/Incrementer` / `IncrementerBlock` |
| Mid-paragraph "expand / see all" link button | `app/components/buttons/InlineTextButton` |
| Help-icon + label above a control | `app/components/type/InputLabel` (`helpIcon` prop) |
| Toggle chip (dim-but-clickable) | `Chip` with `state="Disabled" disabled={false}` |
| Card-click → structured creation modal | `Create` with `backdropVariant="blurredYellow"` |
If a screen grows a 2nd inline copy of any pattern above, **extract a shared
component** rather than duplicate. Local section components inside a screen
file are a smell once they're used more than once.
## Copy & data
- Step copy lives in `messages/en/create/<stage>/<step>.json` where
`<stage>` is one of `community`, `customRule`, `reviewAndComplete`
(matches Figma stages — see `docs/create-flow.md`). Cross-cutting chrome
(`footer.json`, `topNav.json`, `draftHydration.json`,
`templateReview.json`) and shared layout-shell strings (`select.json`,
`text.json`, `upload.json`) live at the `create/` root. Wire each new
JSON into `messages/en/index.ts` under the matching `create.<stage>.*`
namespace (see `localization.mdc`).
- Modal `sections` defaults are DB-shaped seed placeholders, not UI
constants — expect replacement with live data.
- Custom-rule facet mappings (step ids, template-category aliases, selection
keys, strip keys) must be sourced from `lib/create/customRuleFacets.ts`
(`CUSTOM_RULE_FACETS`) instead of adding new ad-hoc switches/tables.
## Interaction tracking
Every user interaction inside a create-flow screen must call
`markCreateFlowInteraction()` from `useCreateFlow()` before mutating state —
progress / footer logic depends on it.
+59
View File
@@ -0,0 +1,59 @@
---
description: Custom hooks live in app/hooks; co-locate logic, document via TSDoc.
globs: app/hooks/**/*.{ts,tsx}
alwaysApply: false
---
# Custom hooks
Reusable component logic lives in `app/hooks/`. Each hook is a small, focused
module with a TSDoc block that doubles as the API reference (no separate doc
file).
## File layout
- One file per hook: `app/hooks/use<Name>.ts`.
- Re-export from `app/hooks/index.ts`. Consumers import from the barrel:
`import { useFoo } from "../hooks";`.
- Companion unit test (when there is non-trivial logic): `tests/unit/hooks/`.
## Authoring rules
- Marked as a regular function (`export function useFoo() {}`); React handles
the `use*` naming convention.
- Wrap exposed callbacks in `useCallback` and computed values in `useMemo`
so consumers can list them in dependency arrays without churn.
- Read DOM/browser APIs only inside `useEffect` so the hook stays SSR-safe.
- Never throw on missing globals (e.g. `window`, `gtag`); guard and no-op.
## TSDoc — the only reference
Every exported hook gets a TSDoc block with:
- 12 sentence summary.
- `@param` per argument and `@returns` describing the shape.
- `@example` showing the typical call site.
```ts
/**
* Detect clicks outside a set of elements (e.g. close a dropdown).
*
* @param refs Elements that should NOT trigger the handler.
* @param handler Invoked when a click lands outside every ref.
* @param enabled Toggle without unmounting the consumer (default true).
*
* @example
* useClickOutside([menuRef, buttonRef], () => setOpen(false), open);
*/
export function useClickOutside(
refs: Array<RefObject<HTMLElement>>,
handler: (event: MouseEvent | TouchEvent) => void,
enabled = true,
): void { /* ... */ }
```
## Container/view consumption
Hooks belong in **container** files (per `component-structure.mdc`). Views
stay pure and read derived values via props — never call hooks that touch
state or side effects from a view.
+65
View File
@@ -0,0 +1,65 @@
---
description: Text localization via messages/ bundles and useMessages()
globs: messages/**/*.{ts,json}
alwaysApply: false
---
# Text localization
All user-visible copy lives in the typed messages bundle under `messages/en/`
and is read via `useMessages()` (fully typed) or `useTranslation()` (dot
notation). Never hard-code user-facing strings in components.
## File layout
- `messages/en/<area>.json` for single-file areas (`common.json`,
`navigation.json`, `metadata.json`).
- `messages/en/<folder>/<entry>.json` for areas with multiple buckets:
`components/*.json`, `pages/*.json`. One JSON per component / page —
don't shoehorn unrelated copy into a shared file.
- `messages/en/create/<stage>/<step>.json` — wizard steps grouped by Figma
stage (`community`, `customRule`, `reviewAndComplete`). Cross-cutting
chrome (footer, top nav, draft hydration, template review) and shared
layout-shell strings (`select.json`, `text.json`, `upload.json`) live at
the `create/` root.
- Optional `"_comment"` at the top of a JSON documents the bundle's purpose.
## Registration — required
Every new JSON must be wired into `messages/en/index.ts`:
```typescript
import createConflictManagement from "./create/customRule/conflictManagement.json";
export default {
// …
create: {
customRule: {
conflictManagement: createConflictManagement,
},
},
};
```
The default export **is** the type source for `useMessages()`; skipping this
step means consumers can't read your strings and TypeScript won't flag the gap.
## Access pattern
```typescript
import { useMessages } from "../contexts/MessagesContext";
const m = useMessages();
const title = m.create.customRule.conflictManagement.page.compactTitle; // fully typed
```
Use `useTranslation(namespace)` only when you need dot-path lookup by dynamic
key; prefer direct property access for the type safety.
## Key conventions
- **Structural keys**: camelCase (`compactTitle`,
`sectionHeadings.corePrinciple`).
- **Content ids**: match the id consumers already use (card id, step id, URL
segment) — typically kebab-case (`"in-person-meetings"`,
`"peer-mediation"`).
+90
View File
@@ -0,0 +1,90 @@
---
description: App Router route organization (groups, layouts, chrome composition)
globs: app/**/*.{ts,tsx}
alwaysApply: false
---
# Route organization
Top-level routes live inside **route groups** so each surface owns its own
layout and chrome. Groups are wrapping folders in `(parens)` — they organize
the file tree without affecting URLs.
## Group map
| Group | URL surface | Audience | Chrome |
|---|---|---|---|
| `app/(marketing)/` | `/`, `/learn`, `/blog`, `/templates`, future public pages | Public, indexable | `Top` (via root) + marketing `<Footer />` |
| `app/(app)/` | `/create/*`, `/login`, `/profile`, future signed-in surfaces | Authenticated product | `Top` (via root) — no footer except **`/profile`** (see `profile/layout.tsx`) |
| `app/(admin)/` | `/monitor`, future ops dashboards | Operators | `Top` (via root) — no footer |
| `app/(dev)/` | `/components-preview`, future dev previews | Local dev (NODE_ENV gated) | `Top` (via root) — no footer |
| `app/(marketing-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 |
Route folders **must not** sit loose at the top level of `app/`. If a new
surface doesn't fit an existing group, add a new group rather than dropping
the folder next to `(marketing)/`.
## Layout responsibilities
- **`app/layout.tsx`** — `<html>`, `<body>`, providers (`MessagesProvider`,
`AuthModalProvider`), fonts, and `ConditionalNavigation`. Renders
`{children}` directly inside the flex column. **Does not** render
`<main>` — each group layout owns that.
- **`app/(marketing)/layout.tsx`** — wraps with `<main className="flex-1">`
and appends the public `<Footer />`.
- **`app/(app)/layout.tsx`** / **`(admin)/layout.tsx`** / **`(dev)/layout.tsx`** —
wrap with `<main className="flex-1">`. No footer by default; **`app/(app)/profile/layout.tsx`**
appends the marketing `<Footer />` for `/profile` only.
- **Nested layouts** (e.g. `(app)/create/layout.tsx`) compose feature-specific
chrome inside the group's `<main>` — never render `<html>`, `<body>`,
`<main>`, or providers.
If a route needs different chrome than its group provides, prefer adding a
**nested layout** under that route — don't introduce pathname-sniffing
client components. (`ConditionalNavigation` is the lone tolerated exception
because it carries SSR session state; do not add new pathname-conditional
chrome components.)
## Co-located component folders
Page-private server/client components that are **only** used by routes in a
given group go in `_components/` inside that group:
```
app/(marketing)/_components/MarketingRuleStackSection.tsx
```
The leading underscore makes Next.js treat the folder as **private** — it's
ignored by the router. Use this instead of letting page-only files sit next
to `page.tsx`.
Components reused across groups belong in `app/components/<category>/`
(see `component-structure.mdc`).
## Adding a new route
1. **Choose the group** by audience: marketing (public), app (signed-in),
admin (operators), dev (local-only). When in doubt, ask whether the
public marketing footer should appear — if yes, it's `(marketing)`.
2. Create `app/(<group>)/<route>/page.tsx`. URLs do **not** include the
group name.
3. If the route needs its own chrome (e.g. a wizard header), add
`app/(<group>)/<route>/layout.tsx`.
4. If the route ships private helpers, put them in
`app/(<group>)/<route>/_components/` (or
`app/(<group>)/_components/` for group-wide page components).
## Splitting a group
Promote a sub-cluster to its own group only when **both** are true:
- It will hold ≥2 routes that share a layout, **or** it has a clearly
distinct audience/access model (e.g. a future `(auth)/` for
signup/forgot/verify alongside login).
- Moving the routes pays for itself by replacing existing pathname
conditionals or by composing real shared chrome — not just by tidying
the folder list.
YAGNI applies: a group with one route and no shared layout is just a
folder with parens.
+114
View File
@@ -0,0 +1,114 @@
---
description: Storybook story conventions — location, naming, titles, decorators
globs: stories/**/*.{js,jsx,ts,tsx,mdx},.storybook/**/*.{js,ts}
alwaysApply: false
---
# Where stories live
All stories live in the top-level `stories/` folder. Two layout rules:
- **Design-system components** mirror `app/components/`. A component at
`app/components/<bucket>/<Name>` gets `stories/<bucket>/<Name>.stories.js`.
- **Create-flow material** has two carve-outs:
- `stories/create-flow/` — shared create-flow pieces that aren't in
`app/components/` (e.g. composed wizard fragments).
- `stories/pages/` — integration stories that exercise an entire
`app/(app)/create/screens/<...>` screen as it appears in the wizard.
| Source | Story location |
| --------------------------------- | --------------------------------------- |
| `app/components/controls/Chip` | `stories/controls/Chip.stories.js` |
| `app/components/buttons/Button` | `stories/buttons/Button.stories.js` |
| `app/(app)/create/screens/.../FooScreen`| `stories/pages/FooPage.stories.js` |
| Shared create-flow fragment | `stories/create-flow/<Name>.stories.js` |
Do **not** colocate `*.stories.*` next to components. The Storybook config
(`.storybook/main.js`) only globs `stories/**`.
# File naming
- `<ComponentName>.stories.js` — matches 69/70 existing files.
- Use `.tsx` only when the story genuinely needs types (rare; prefer JS to
match the codebase convention).
- Variants get a suffix: `Button.visual.stories.js`,
`Footer.responsive.stories.js`.
# Default export shape (CSF2)
```javascript
import MyComponent from "../../app/components/<area>/MyComponent";
export default {
title: "Components/<SubFolder>/MyComponent",
component: MyComponent,
parameters: {
layout: "centered",
docs: {
description: {
component: "Short description of what the component is for.",
},
},
},
argTypes: {
variant: {
control: { type: "select" },
options: ["filled", "outline"],
description: "The variant (Figma prop)",
},
onClick: { action: "clicked" },
},
};
export const Default = { args: { variant: "filled" } };
```
## Title hierarchy
- Design-system components → `Components/<SubFolder>/<Name>` (e.g.
`Components/Controls/Checkbox`).
- Pages → `Pages/<PageName>` (folder: `stories/pages/`).
- Create flow shared pieces → `Create Flow/<Name>`.
## `argTypes`
For every Figma enum prop (`variant`, `size`, `state`, `mode`, `palette`,
…) expose a `select` control listing the **lowercase** option set, sourced
from the matching `*_OPTIONS` const in `lib/propNormalization.ts`. See
`.cursor/rules/component-props.mdc`.
# Rely on the global preview — don't re-wrap
`.storybook/preview.js` already provides:
- `MessagesProvider` with `messages/en` → access copy via `useMessages()`
inside stories exactly like app code. Never hard-code user-facing strings.
- `app/globals.css` + `.font-inter` wrapper → design tokens and fonts are
already present.
Do **not** add your own `MessagesProvider`, font wrapper, or token setup in a
story. If you need a new global, update `preview.js`.
# Interaction tests (`play`)
Use `storybook/test` for interaction assertions — not `@testing-library/*`
directly. This matches `Checkbox.stories.js` and stays compatible with the
Vitest portable-stories runner in `.storybook/vitest.setup.js`.
```javascript
import { within, userEvent, expect } from "storybook/test";
export const Interactive = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("checkbox"));
expect(canvas.getByRole("checkbox")).toHaveAttribute("aria-checked", "true");
},
};
```
# Coverage expectation
Every new component in `app/components/**` ships with a story. Screens in
`app/(app)/create/screens/**` ship with a `stories/pages/<Name>Page.stories.js`
entry. A new component without a story is considered incomplete.
+37
View File
@@ -0,0 +1,37 @@
---
description: Tailwind-first styling for all React components
globs: app/**/*.{ts,tsx},stories/**/*.{ts,tsx}
alwaysApply: false
---
# Tailwind-first styling
Tailwind v4 is the default styling layer. Reach for utility classes + design
tokens **before** anything else.
## Priority
1. **Tailwind utilities** — `className="flex items-center gap-4 p-6 rounded-lg"`.
Use arbitrary values (`w-[200px]`) and responsive variants (`sm:`, `lg:`)
as needed. Design-token CSS variables go in arbitrary values:
`bg-[var(--color-surface-default-primary)]`.
2. **`style` prop** — only for values that truly change at runtime
(`style={{ width: `${dynamicPx}px` }}`).
3. **Custom / global CSS** — last resort. Justified for keyframes, third-party
overrides, dynamic-count CSS Grid, and similar cases Tailwind can't express.
4. **CSS-in-JS / CSS Modules** — don't introduce.
## Anti-patterns
```tsx
// ❌ Opaque class names bypass the design system
<div className="custom-container">
<span className="custom-text">Hello</span>
</div>
// ❌ Inline style for a static value
<div style={{ padding: 16, borderRadius: 8 }}>…</div>
// ✅ Tailwind + token
<div className="p-4 rounded-lg bg-[var(--color-surface-default-primary)]">…</div>
```
+72
View File
@@ -0,0 +1,72 @@
---
description: Test file layout & shared harnesses (vitest + Playwright)
globs: tests/**/*.{ts,tsx,js,jsx}
alwaysApply: false
---
# Testing conventions
## Runner split
- **Vitest** for unit, component, and page-level tests (`tests/components`,
`tests/pages`, `tests/unit`, `tests/contexts`, `tests/accessibility`).
Run via `npm test` or `npx vitest run`.
- **Playwright** for browser e2e and visual regression (`tests/e2e`).
Run via `npm run e2e`. Never put Playwright specs outside `tests/e2e/`.
## File layout
| Path | Use |
| --- | --- |
| `tests/components/<Name>.test.tsx` | Design-system component tests. |
| `tests/pages/<step>.test.jsx` | Page / screen integration tests. |
| `tests/unit/<fn>.test.{ts,js}` | Pure logic — utilities, reducers, hooks without DOM. |
| `tests/contexts/<Ctx>.test.tsx` | Context provider tests. |
| `tests/accessibility/` | `jest-axe` suites (unit) and `wcag-compliance.spec.ts` (e2e). |
| `tests/e2e/` | Playwright specs (user journeys, visual, performance). |
## Providers — always use `renderWithProviders`
`render` from `@testing-library/react` **skips** Messages/AuthModal/CreateFlow
providers. Import the wrapped version instead:
```typescript
import {
renderWithProviders as render,
screen,
} from "../utils/test-utils";
```
Raw `render` is only acceptable for pure-presentational components that read
none of those contexts.
## DS component suites
Reuse `componentTestSuite` for standard DS coverage (renders,
`jest-axe` a11y, keyboard navigation, disabled/error states) instead of
rewriting each check per component:
```typescript
import {
componentTestSuite,
type ComponentTestSuiteConfig,
} from "../utils/componentTestSuite";
const config: ComponentTestSuiteConfig<Props> = {
component: MyComponent,
name: "MyComponent",
props: baseProps,
primaryRole: "button",
testCases: { renders: true, accessibility: true, keyboardNavigation: true },
};
componentTestSuite(config);
```
Custom interaction tests live alongside the suite in the same file.
## Required imports
- `import "@testing-library/jest-dom/vitest";` — required for matcher types
(`toBeInTheDocument`, `toHaveAttribute`, etc.).
- `afterEach(() => cleanup())` in page-level test files where multiple
`render` calls run sequentially.
+10
View File
@@ -0,0 +1,10 @@
node_modules
.next
.git
.env*
!.env.example
coverage
playwright-report
test-results
storybook-static
.runner
+34
View File
@@ -0,0 +1,34 @@
# Copy to `.env` for local development (never commit real secrets).
# PostgreSQL — use `docker compose up -d postgres` and match user/db/password.
CLOUDRON_POSTGRESQL_URL="postgresql://communityrule:communityrule@localhost:5432/communityrule"
# Session signing + secret used when hashing magic-link tokens. Min 16 characters; use a long random string in production.
SESSION_SECRET="dev-only-change-me-16chars-min"
# Optional Mailhog (docker compose mailhog service):
# CLOUDRON_MAIL_SMTP_SERVER=localhost
# CLOUDRON_MAIL_SMTP_PORT=1025
# CLOUDRON_MAIL_SMTP_USERNAME=
# CLOUDRON_MAIL_SMTP_PASSWORD=
# Leave mail vars unset in dev to log the magic-link verify URL to the server console instead of sending email.
SMTP_FROM="Community Rule <noreply@localhost>"
# CR-107: inbox for Ask an organizer form submissions (requires CLOUDRON_MAIL_SMTP_* in production).
ORGANIZER_INQUIRY_TO=
# Set to `true` to sync the create-flow draft with `/api/drafts/me` when the user is signed in.
# Server draft sync (default on). Set to `false` to disable PUT/GET /api/drafts/me.
NEXT_PUBLIC_ENABLE_BACKEND_SYNC=
# Web vitals API (CR-80): `external` = structured logs only, no writes under `.next` (default in production).
# `local` = file-based aggregates under `.next/web-vitals` (default in development). Omit to use defaults.
# WEB_VITALS_STORAGE=external
# Optional: URL shown on /monitor when using external storage (Grafana, Kibana, vendor RUM, etc.).
# NEXT_PUBLIC_RUM_DASHBOARD_URL=
# Writable directory for `POST /api/uploads` (community photo + custom-method attachments).
# In production (e.g. Cloudron localstorage mount), set to the mounted path. Local dev example:
# UPLOAD_ROOT="/absolute/path/to/community-rule/var/uploads"
-186
View File
@@ -1,186 +0,0 @@
name: CI Pipeline
run-name: ${{ gitea.actor }} triggered CI pipeline
on:
workflow_dispatch: {}
push:
branches: [main, develop, "fix-runner-trigger"]
pull_request:
branches: [main, develop]
jobs:
canary:
runs-on: ["self-hosted:host", "macos-latest:host"]
steps:
- run: |
uname -a
node -v || true
echo "Runner labels OK ✅"
test:
runs-on: ["self-hosted:host", "macos-latest:host"]
strategy:
matrix: { node-version: [18, 20] }
env:
NODE_OPTIONS: "--max_old_space_size=4096"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm test
# If the Codecov Action fails on Gitea, replace this with the bash uploader below
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/lcov.info
flags: unittests
# Bash uploader alternative (uncomment if the action above has issues)
# - name: Upload coverage to Codecov (bash)
# run: |
# curl -s https://codecov.io/bash > codecov.sh
# bash codecov.sh -t "${{ secrets.CODECOV_TOKEN }}" -f coverage/lcov.info -F unittests
e2e:
runs-on: ["self-hosted:host", "macos-latest:host"]
strategy:
matrix: { browser: [chromium, firefox, webkit] }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npx playwright install --with-deps ${{ matrix.browser }}
- run: npm run build
# start app, wait, run tests
- run: npm run preview &
env: { CI: true }
- run: npx wait-on http://localhost:3000
- run: npx playwright test --project=${{ matrix.browser }}
env: { CI: true }
# package artifacts (keeps file count small)
- name: Package E2E artifacts
if: always()
run: |
tar -czf playwright-${{ matrix.browser }}.tgz playwright-report test-results || true
- name: Upload E2E artifacts
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-results-${{ matrix.browser }}
path: playwright-${{ matrix.browser }}.tgz
retention-days: 30
visual-regression:
runs-on: ["self-hosted:host", "macos-latest:host"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run build
- run: npm run preview &
env: { CI: true }
- run: npx wait-on http://localhost:3000
# Seed snapshots on main branch only (one-time setup)
- name: Seed snapshots (main only)
if: github.ref == 'refs/heads/main'
run: PLAYWRIGHT_UPDATE_SNAPSHOTS=1 npx playwright test tests/e2e/visual-regression.spec.ts --project=chromium
env: { CI: true }
# Run visual regression tests
- name: Run visual regression tests
run: npx playwright test tests/e2e/visual-regression.spec.ts
env: { CI: true }
- name: Package visual artifacts
if: always()
run: |
tar -czf visual-regression.tgz test-results tests/e2e/visual-regression.spec.ts-snapshots || true
- name: Upload visual artifacts
if: always()
uses: actions/upload-artifact@v3
with:
name: visual-regression-results
path: visual-regression.tgz
retention-days: 30
performance:
runs-on: ["self-hosted:host", "macos-latest:host"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- name: Install LHCI
run: npm i -D @lhci/cli
# Ensure a Chrome binary is available (works on Linux/macOS runners)
- name: Install Chrome via Puppeteer (portable)
run: |
npx @puppeteer/browsers install chrome@stable -P .cache/puppeteer
echo "CHROME_PATH=$(npx @puppeteer/browsers executable-path chrome@stable -P .cache/puppeteer)" >> $GITHUB_ENV
- name: Build application
run: npm run build
- name: Start application
run: npm run preview &
env: { CI: true }
- name: Wait for application
run: npx wait-on http://localhost:3000
- name: Run Lighthouse CI
run: npx lhci autorun --chrome-path="$CHROME_PATH"
env: { CI: true }
- name: Upload LHCI results
if: always()
uses: actions/upload-artifact@v3
with:
name: lhci-results
path: lhci-results
storybook:
runs-on: ["self-hosted:host", "macos-latest:host"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npm run storybook:build:github
- run: npm run test:sb
env: { CI: true }
lint:
runs-on: ["self-hosted:host", "macos-latest:host"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npm run lint
- run: npx prettier --check "**/*.{js,jsx,ts,tsx,json,css,md}"
build:
runs-on: ["self-hosted:host", "macos-latest:host"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npm run build
- run: npm run storybook:build:github
+38 -8
View File
@@ -10,16 +10,25 @@
!.yarn/releases
!.yarn/versions
# npm cache (should never be committed)
.npm-cache/
npm-cache/
# testing
/coverage
# Local user uploads (see UPLOAD_ROOT in .env.example)
/var/uploads
# Playwright
/test-results/
/playwright-report/
# Visual regression snapshots (allow these)
!tests/e2e/visual-regression.spec.ts-snapshots/
!tests/e2e/visual-regression.spec.ts-snapshots/*.png
# Ignore other image files
# Lighthouse CI results
/lhci-results/
/.lighthouseci/
# Ignore other image files (but not visual regression snapshots)
*.png
*.jpg
*.jpeg
@@ -30,6 +39,10 @@
*.avi
*.mkv
# Visual regression snapshots (allow these)
!tests/e2e/visual-regression.spec.ts-snapshots/
!tests/e2e/visual-regression.spec.ts-snapshots/*.png
# next.js
/.next/
/out/
@@ -38,9 +51,16 @@
/build
# misc
.DS_Store
/tmp/
*.pem
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# debug
npm-debug.log*
yarn-debug.log*
@@ -49,17 +69,27 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
tsconfig.tsbuildinfo
next-env.d.ts
*storybook.log
storybook-static
# storybook config files (to avoid git changes when switching between local and production)
.storybook/main.js
.storybook/preview.js
# Gitea runner runtime files
.runner
.runner.pid
act_runner
# OS files
Thumbs.db
.DS_Store
# Cursor rules (local development)
.cursorrules
+82 -7
View File
@@ -1,20 +1,95 @@
{
"ci": {
"collect": {
"url": ["http://localhost:3000/"],
"numberOfRuns": 3
"url": [
"http://127.0.0.1:3010/",
"http://127.0.0.1:3010/blog",
"http://127.0.0.1:3010/blog/resolving-active-conflicts"
],
"numberOfRuns": 3,
"settings": {
"preset": "desktop",
"throttling": {
"rttMs": 40,
"throughputKbps": 10240,
"cpuSlowdownMultiplier": 1,
"requestLatencyMs": 0,
"downloadThroughputKbps": 0,
"uploadThroughputKbps": 0
},
"chromeFlags": [
"--disable-web-security",
"--disable-features=VizDisplayCompositor",
"--ignore-certificate-errors",
"--ignore-ssl-errors",
"--ignore-certificate-errors-spki-list",
"--allow-running-insecure-content",
"--disable-extensions",
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--headless"
]
}
},
"assert": {
"assertions": {
"categories:performance": ["warn", { "minScore": 0.9 }],
"categories:accessibility": ["error", { "minScore": 0.9 }],
"categories:performance": ["error", { "minScore": 0.9 }],
"categories:accessibility": ["warn", { "minScore": 0.95 }],
"categories:best-practices": ["warn", { "minScore": 0.9 }],
"categories:seo": ["warn", { "minScore": 0.9 }],
"first-contentful-paint": ["warn", { "maxNumericValue": 2000 }],
"interactive": ["warn", { "maxNumericValue": 4000 }]
"largest-contentful-paint": ["warn", { "maxNumericValue": 2500 }],
"first-meaningful-paint": ["warn", { "maxNumericValue": 2000 }],
"speed-index": ["warn", { "maxNumericValue": 3000 }],
"interactive": ["warn", { "maxNumericValue": 3000 }],
"total-blocking-time": ["warn", { "maxNumericValue": 300 }],
"cumulative-layout-shift": ["warn", { "maxNumericValue": 0.1 }],
"max-potential-fid": ["warn", { "maxNumericValue": 130 }],
"server-response-time": ["warn", { "maxNumericValue": 600 }],
"render-blocking-resources": ["warn", { "maxLength": 0 }],
"unused-css-rules": ["warn", { "maxLength": 0 }],
"unused-javascript": ["warn", { "maxLength": 0 }],
"modern-image-formats": ["warn", { "maxLength": 0 }],
"uses-optimized-images": ["warn", { "maxLength": 0 }],
"uses-text-compression": ["warn", { "maxLength": 0 }],
"uses-responsive-images": ["warn", { "maxLength": 0 }],
"efficient-animated-content": ["warn", { "maxLength": 0 }],
"preload-lcp-image": ["warn", { "maxLength": 0 }],
"total-byte-weight": ["warn", { "maxNumericValue": 500000 }],
"uses-long-cache-ttl": ["warn", { "maxLength": 0 }],
"dom-size": ["warn", { "maxNumericValue": 1500 }],
"critical-request-chains": ["warn", { "maxLength": 0 }],
"user-timings": ["warn", { "maxLength": 0 }],
"bootup-time": ["warn", { "maxNumericValue": 1000 }],
"mainthread-work-breakdown": ["warn", { "maxLength": 0 }],
"font-display": ["warn", { "maxLength": 0 }],
"resource-summary": ["warn", { "maxLength": 0 }],
"third-party-summary": ["warn", { "maxLength": 0 }],
"largest-contentful-paint-element": ["warn", { "maxLength": 0 }],
"layout-shift-elements": ["warn", { "maxLength": 0 }],
"long-tasks": ["warn", { "maxLength": 0 }],
"non-composited-animations": ["warn", { "maxLength": 0 }],
"unsized-images": ["warn", { "maxLength": 0 }]
}
},
"upload": {
"target": "filesystem",
"outputDir": "lhci-results"
"target": "temporary-public-storage",
"outputDir": "./lighthouse-results"
}
},
"settings": {
"onlyCategories": ["performance", "accessibility", "best-practices", "seo"],
"skipAudits": ["uses-http2"],
"formFactor": "desktop",
"throttling": {
"rttMs": 40,
"throughputKbps": 10240,
"cpuSlowdownMultiplier": 1,
"requestLatencyMs": 0,
"downloadThroughputKbps": 0,
"uploadThroughputKbps": 0
}
}
}
+18
View File
@@ -0,0 +1,18 @@
.next/
out/
build/
dist/
node_modules/
# Test/build artifacts
coverage/
playwright-report/
test-results/
lhci-results/
.lighthouseci/
# Storybook build output
storybook-static/
# Misc generated
*.log
-12
View File
@@ -1,12 +0,0 @@
{
"WARNING": "This file is automatically generated by act-runner. Do not edit it manually unless you know what you are doing. Removing this file will cause act runner to re-register as a new runner.",
"id": 12,
"uuid": "1f114e7b-9330-40fc-9c96-816b07f3e4c2",
"name": "community-rule-test-runner",
"token": "ba42513830cbc9e2eb6abf86ee119fadfcb7a14b",
"address": "https://git.medlab.host",
"labels": [
"self-hosted:host",
"macos-latest:host"
]
}
-1
View File
@@ -1 +0,0 @@
35293
+7
View File
@@ -0,0 +1,7 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
:root {
--font-inter:
"Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
}
+20 -39
View File
@@ -1,54 +1,35 @@
/** @type { import('@storybook/nextjs-vite').StorybookConfig } */
const config = {
/** @type { import('@storybook/nextjs').StorybookConfig } */
module.exports = {
stories: [
"../stories/**/*.mdx",
"../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)",
],
addons: [
"@storybook/addon-actions",
// 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",
"@storybook/addon-interactions",
"@chromatic-com/storybook",
],
framework: {
name: "@storybook/nextjs-vite",
name: "@storybook/nextjs",
options: {},
},
staticDirs: ["../public"],
// Auto-detect environment and apply appropriate settings
managerHead: (head) => {
// Only add base href for GitHub Pages (when CI=true or specific environment)
if (process.env.CI || process.env.STORYBOOK_BASE_PATH) {
return `${head}<base href="/communityrulestorybook/">`;
}
return head;
},
previewHead: (head) => {
// Only add base href for GitHub Pages
if (process.env.CI || process.env.STORYBOOK_BASE_PATH) {
return `${head}<base href="/communityrulestorybook/">`;
}
return head;
},
async viteFinal(cfg) {
// Set base path for GitHub Pages when needed
if (process.env.CI || process.env.STORYBOOK_BASE_PATH) {
cfg.base = "/communityrulestorybook/";
}
// Ensure esbuild treats .js as JSX during dep pre-bundling
cfg.optimizeDeps ??= {};
cfg.optimizeDeps.esbuildOptions ??= {};
cfg.optimizeDeps.esbuildOptions.loader = {
...(cfg.optimizeDeps.esbuildOptions.loader || {}),
".js": "jsx",
".ts": "tsx",
// 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 || {}),
};
return cfg;
// Ensure node_modules are resolved
config.resolve.modules = [
...(config.resolve.modules || []),
"node_modules",
];
return config;
},
};
export default config;
+8 -30
View File
@@ -1,33 +1,11 @@
import "../app/globals.css";
// Import Google Fonts for Storybook
import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
variable: "--font-inter",
display: "swap",
});
const bricolageGrotesque = Bricolage_Grotesque({
subsets: ["latin"],
weight: ["400", "500", "700", "800"],
variable: "--font-bricolage-grotesque",
display: "swap",
});
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
weight: ["400", "500", "700"],
variable: "--font-space-grotesk",
display: "swap",
});
import "./fonts.css";
import { MessagesProvider } from "../app/contexts/MessagesContext";
import messages from "../messages/en/index";
/** @type { import('@storybook/react').Preview } */
const preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
@@ -37,11 +15,11 @@ const preview = {
},
decorators: [
(Story) => (
<div
className={`${inter.variable} ${bricolageGrotesque.variable} ${spaceGrotesk.variable} font-sans`}
>
<Story />
</div>
<MessagesProvider messages={messages}>
<div className="font-inter">
<Story />
</div>
</MessagesProvider>
),
],
};
+3 -3
View File
@@ -1,7 +1,7 @@
import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
import { setProjectAnnotations } from '@storybook/nextjs-vite';
import * as projectAnnotations from './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]);
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
+91
View File
@@ -0,0 +1,91 @@
# Agent guide
Orientation for AI coding agents working in this repo. Per-file conventions
live in `.cursor/rules/*.mdc` (auto-loaded by Cursor; other agents should
read them on demand). This file is the **map** — load it first, then load
the rule(s) matching the file you're editing.
## What this is
Next.js 16 / React 19 app for community decision-making and governance.
Single-locale (English) today; designed for i18n via `messages/`.
## Read before editing
| If you're touching… | Load this rule |
| --- | --- |
| `app/components/**` | `component-structure.mdc`, `component-props.mdc`, `tailwind-styling.mdc` |
| `Alert`, or user-visible notifications / shell errors / success banners | `alerts.mdc` (and `localization.mdc` for copy) |
| `app/(app)/create/**` | `create-flow.mdc` (+ component rules) |
| `app/api/**` | `api-routes.mdc` |
| `app/hooks/**` | `hooks.mdc` |
| `app/**/page.tsx` or `app/**/layout.tsx` | `routes.mdc` |
| `messages/**` or any user-visible string | `localization.mdc` |
| `tests/**` | `testing.mdc` |
| `stories/**` | `storybook.mdc` |
When in doubt about file structure or naming, the rules win over your
priors — they reflect deliberate decisions.
## Cross-cutting principles (no single rule owns these)
1. **Figma is the source of truth for design.** Container files carry a
`Figma: "<Path>" (<node-id>)` docstring; views render Figma intent.
Codebase naming uses lowercase conventions (see `component-props.mdc`)
even when Figma uses PascalCase enum values.
2. **Container / view split is the component pattern.** Never put state
or side effects in a `*.view.tsx`. Hooks belong in containers.
3. **All user-visible text lives in `messages/`.** Hardcoded strings in
components are a bug — even for placeholders.
4. **Tests live in `tests/`, not co-located.** Mirror the source path
(`app/components/Foo``tests/components/Foo.test.tsx`).
5. **Routes live inside groups**`(marketing)`, `(app)`, `(admin)`,
`(dev)`. Don't drop a new route folder loose at the top of `app/`.
**Admin-only widgets** may live in **`app/(admin)/<route>/_components/`**
when only that route uses them (e.g. **`WebVitalsDashboard`** on **`/monitor`**).
6. **No new pathname-sniffing chrome.** Compose chrome via group/nested
layouts, not `usePathname()` checks. (`ConditionalNavigation` is the
sole tolerated exception — it carries SSR session state.)
## Legacy / scaffolding
Some code exists temporarily while backend services are stood up:
- `NEXT_PUBLIC_ENABLE_BACKEND_SYNC` gating
- `migrateLegacyCreateFlowState`, `LEGACY_LIVE_KEY`, `LEGACY_DRAFT_KEY`
- `/create/right-rail` redirect
- `docs/guides/backend-roadmap.md`, `backend-linear-tickets.md`,
`template-recommendation-matrix.md`
**Do not delete** without an explicit ask. Do not add new code in this
shape — when adding scaffolding, leave a `// TODO(legacy): …` with the
removal trigger.
## Verification recipe
Run these (in order) before declaring a change done:
```bash
rm -rf .next # only if you moved/renamed routes or layouts
npx tsc --noEmit # type check
npm run knip # unused files / exports (local; no remote CI)
npx vitest run # unit + component (~185 test files)
npx next build # production build + route manifest
```
For UI-only changes, also: `npm run storybook` and visually confirm.
For E2E-relevant changes: `npm run e2e`.
For changes under `prisma/`: `npm run migrate:smoke` (see
[docs/testing-guide.md](docs/testing-guide.md) § *Running tests*).
## Where else to look
- [README.md](README.md) — human onboarding, scripts, project layout.
- [CONTRIBUTING.md](CONTRIBUTING.md) — local Postgres + Prisma + magic-link
setup, PR workflow.
- [docs/README.md](docs/README.md) — index of user-facing docs.
- [docs/create-flow.md](docs/create-flow.md) — wizard URL/persistence canon
(read alongside `create-flow.mdc`).
- [docs/figma-component-registry.md](docs/figma-component-registry.md) —
Figma ↔ component bucket map after refactors (Type, Sections, admin
`_components/`, etc.).
+148
View File
@@ -0,0 +1,148 @@
# Contributing
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).
## Local setup
Prerequisites: Node **20+**, npm **10+**, Docker.
```bash
cp .env.example .env # set SESSION_SECRET (≥16 chars)
docker compose up -d postgres mailhog # omit `mailhog` if you don't need
# a local inbox
npm ci
npx prisma migrate dev
npx prisma db seed # optional — seeds curated templates
npm run dev
```
Open [http://localhost:3000](http://localhost:3000). Use
`npx prisma studio` to browse the database.
Deploying to staging or production (MEDLab Cloudron at `my.medlab.host`)
is documented in
[`docs/guides/ops-backend-deploy.md`](docs/guides/ops-backend-deploy.md).
### Magic-link sign-in
1. Go to [/login](http://localhost:3000/login) or click **Log in** in
the site header.
2. Submit your email.
3. Open the verify link in the **same browser** (the session cookie is
bound to that origin):
- **Without SMTP:** copy the URL from the dev-server log.
- **With Mailhog:** open the message at
[http://localhost:8025](http://localhost:8025).
### Prisma migrations
- **Never edit a migration** that has already been applied to staging,
production, or any shared database — add a new migration instead.
Full policy: [`docs/guides/backend-roadmap.md`](docs/guides/backend-roadmap.md) §8.
- **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
The custom wizard lives under `/create/…`. Step order, URLs, and Figma
stage mapping are canon in
[`docs/create-flow.md`](docs/create-flow.md); component conventions are
in `.cursor/rules/create-flow.mdc`.
## API routes
All routes return JSON. Non-`GET` requests expect
`Content-Type: application/json` unless noted (uploads are multipart).
### 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>`.
2. Make the change and add or update tests.
3. Run the relevant subset of the testing recipe above.
4. Commit using a conventional-commit prefix: `feat:`, `fix:`,
`chore:`, `docs:`, `refactor:`, `test:`.
5. Open a pull request; link the Linear ticket if there is one (e.g.
`CR-123`).
+19
View File
@@ -0,0 +1,19 @@
{
"manifestVersion": 2,
"id": "com.medlab.communityrule",
"title": "Community Rule",
"author": "MEDLab",
"description": "Community governance and rule-building app",
"version": "0.1.8",
"httpPort": 3000,
"healthCheckPath": "/api/health",
"memoryLimit": 805306368,
"minBoxVersion": "9.0.0",
"addons": {
"postgresql": {},
"sendmail": {},
"localstorage": {}
},
"website": "https://communityrule.info",
"contactEmail": "hello@communityrule.info"
}
+71
View File
@@ -0,0 +1,71 @@
# Production image: Next.js standalone output + Prisma, packaged for Cloudron.
# Build / push: ./scripts/docker-release.sh
# Install: cloudron install (reads CloudronManifest.json from repo root)
# See docs/guides/ops-backend-deploy.md §9.
FROM node:20-bookworm-slim AS base
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
FROM base AS deps
RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
# Copy the Prisma schema so the project's `postinstall` (which runs
# `prisma generate`) succeeds during install.
COPY prisma ./prisma
# `npm install` rather than `npm ci`:
# 1. `npm ci` strictly validates the lockfile and refuses when sub-tree
# resolutions drift (a recurring nuisance because the lockfile is
# generated on darwin-arm64 by default).
# 2. `npm install` reuses the lockfile when it can but tolerates
# platform-specific reshuffles for Linux-only optional deps
# (`lightningcss-linux-*-gnu`, `@tailwindcss/oxide-linux-*-gnu`,
# `@next/swc-linux-*-gnu`, etc.) that Next.js needs at build time.
RUN npm install --no-audit --fund=false
FROM base AS builder
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 . .
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
# openssl: Prisma engines. gosu: privilege drop in start.sh after chown.
RUN apt-get update -y && apt-get install -y openssl gosu && rm -rf /var/lib/apt/lists/*
ENV NODE_ENV=production
# Reuse the `node` user (uid/gid 1000) shipped in node:20-bookworm-slim.
# Cloudron's localstorage addon mounts /app/data with root:root ownership at
# runtime; start.sh chowns it to node:node before dropping privileges.
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/static ./.next/static
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 (devDependency) is not in the Next.js standalone trace. Install
# globally in the runner so start.sh can run `prisma migrate deploy`.
RUN npm install -g prisma@6.19.3
# Cloudron's runtime rootfs is read-only except /tmp, /run, /app/data.
# Three marketing routes use ISR (`revalidate`) and write to .next/cache;
# redirect that path to /tmp/next-cache via a baked-in symlink so writes land
# on a writable mount at runtime.
RUN mkdir -p .next && ln -sfn /tmp/next-cache .next/cache
COPY --chown=node:node scripts/start.sh /start.sh
RUN chmod +x /start.sh
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["/start.sh"]
+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>.
+70 -158
View File
@@ -1,181 +1,93 @@
# Community Rule
A Next.js application for community decision-making and governance documentation.
A Next.js application for community decision-making and governance
documentation — author, browse, and share governance "rules" built from
curated templates and a guided wizard.
## 🚀 Getting Started
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.
Run the development server:
## Requirements
- Node.js **20+** (LTS)
- npm **10+**
- Docker (for local Postgres and Mailhog)
## Quick start
```bash
cp .env.example .env # then set SESSION_SECRET (≥16 chars)
docker compose up -d postgres # add `mailhog` for a local inbox
npm ci
npx prisma migrate dev
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
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.
## 🧪 Testing Framework
Full local backend, API reference, and PR workflow:
[CONTRIBUTING.md](CONTRIBUTING.md).
This project includes a comprehensive testing framework with multiple layers of testing:
## Scripts
### Quick Test Commands
| Command | What it does |
| --- | --- |
| `npm run dev` | Next.js dev server (Turbopack). |
| `npm run build` / `npm start` | Production build / serve. |
| `npm test` | Vitest unit + component tests with coverage. |
| `npm run test:component` | Components only — faster inner loop. |
| `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 knip` | Detect unused files / exports. |
| `npm run lhci` | Lighthouse CI performance pass. |
```bash
# Unit tests with coverage
npm test
See [`package.json`](package.json) for the full list (visual regression,
bundle analysis, seeding, etc.).
# E2E tests
npm run e2e
## Project layout
# Performance tests
npm run lhci
# Storybook tests
npm run test:sb
```text
app/ Next.js app router — route groups (marketing), (app),
(admin), (dev); shared components under app/components/;
admin-only widgets under app/(admin)/<route>/_components/
lib/ Shared library code (server, validation, create-flow logic)
prisma/ Schema, migrations, seed
messages/en/ Localized UI copy (single-locale today; English)
public/ Static assets
stories/ Storybook stories
tests/ Vitest + Playwright suites (mirror source paths)
docs/ Human-facing documentation — start at docs/README.md
.cursor/rules/ Per-file conventions (auto-loaded by Cursor)
scripts/ Build, release, and smoke-test scripts
```
### Test Coverage
## Tech stack
-**124 Unit Tests** (8 components + 1 integration)
-**308 E2E Tests** (4 browsers × 77 tests)
-**92 Visual Regression Screenshots**
-**Performance Budgets**
-**Accessibility Compliance**
Next.js 16 · React 19 · TypeScript · Tailwind CSS 4 · Prisma 6 ·
PostgreSQL · Vitest · Playwright · Storybook 10 · Lighthouse CI.
### CI/CD Pipeline
## Documentation
- **Gitea Actions** with 7 parallel jobs
- **Cross-browser testing** (Chromium, Firefox, WebKit, Mobile)
- **Visual regression testing**
- **Performance monitoring**
- **Code coverage reporting**
- [docs/README.md](docs/README.md) — index of guides and rules.
- [docs/create-flow.md](docs/create-flow.md) — create-rule wizard canon.
- [docs/testing-guide.md](docs/testing-guide.md) — testing philosophy.
- [docs/guides/ops-backend-deploy.md](docs/guides/ops-backend-deploy.md)
— Cloudron deploy + cutover plan.
- [CONTRIBUTING.md](CONTRIBUTING.md) — local backend, API routes, PR workflow.
- [AGENTS.md](AGENTS.md) — orientation for AI coding agents.
📖 **For detailed testing documentation, see [docs/TESTING.md](docs/TESTING.md)**
## License
## 📚 Storybook Development
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.
This project includes Storybook for component development and documentation. The setup automatically detects the environment and applies the appropriate configuration.
### Local Development
For local Storybook development:
```bash
npm run storybook:local
# or simply
npm run storybook
```
This will:
- Start Storybook at `http://localhost:6006`
- Use relative paths for assets (no base path)
### GitHub Pages Deployment
For GitHub Pages deployment with base path:
```bash
npm run storybook:build:github
```
This will:
- Build Storybook with `/communityrulestorybook/` base path
- Generate files ready for GitHub Pages deployment
### CI/CD Integration
The CI pipeline automatically uses the GitHub Pages configuration when building Storybook.
### Configuration
The Storybook configuration automatically detects the environment:
- **Local development**: No base path, relative assets
- **CI/Production**: Base path `/communityrulestorybook/` for GitHub Pages
## 📋 Available Scripts
### Development
- `npm run dev` - Start Next.js development server
- `npm run build` - Build Next.js application for production
- `npm run start` - Start Next.js production server
### Testing
- `npm test` - Run unit tests with coverage
- `npm run test:watch` - Run tests in watch mode
- `npm run test:ui` - Run tests with UI
- `npm run e2e` - Run E2E tests
- `npm run e2e:ui` - Run E2E tests with UI
- `npm run e2e:serve` - Start dev server and run E2E tests
- `npm run lhci` - Run performance tests
- `npm run test:sb` - Run Storybook tests
### Storybook
- `npm run storybook:local` - Start Storybook for local development
- `npm run storybook:github` - Start Storybook with GitHub Pages configuration
- `npm run storybook:build` - Build Storybook for local deployment
- `npm run storybook:build:github` - Build Storybook for GitHub Pages
- `npm run storybook` - Start Storybook with current configuration
## 🏗️ Project Structure
```
community-rule/
├── app/ # Next.js app directory
│ ├── components/ # React components
│ ├── layout.js # Root layout
│ └── page.js # Homepage
├── tests/ # Test files
│ ├── unit/ # Unit tests (8 components)
│ ├── integration/ # Integration tests
│ └── e2e/ # E2E tests (4 test suites)
├── docs/ # Documentation
│ └── TESTING.md # Comprehensive testing guide
├── .storybook/ # Storybook configuration
├── .gitea/ # Gitea Actions workflows
│ └── workflows/
│ └── ci.yml # CI/CD pipeline
└── public/ # Static assets
```
## 🔧 Technology Stack
- **Framework**: Next.js 15 + React 19
- **Styling**: Tailwind CSS 4
- **Testing**: Vitest + Playwright + Lighthouse CI
- **Documentation**: Storybook 9
- **CI/CD**: Gitea Actions
- **Hosting**: Gitea (Git hosting)
## 📖 Documentation
- **[Testing Framework](docs/TESTING.md)** - Comprehensive testing guide
- **[Storybook](http://localhost:6006)** - Component documentation (local)
- **[GitHub Pages Storybook](https://your-username.github.io/communityrulestorybook/)** - Public component docs
## 🤝 Contributing
1. **Fork the repository**
2. **Create a feature branch**: `git checkout -b feature/amazing-feature`
3. **Write tests first** (see [Testing Guide](docs/TESTING.md))
4. **Make your changes**
5. **Run tests**: `npm test && npm run e2e`
6. **Commit changes**: `git commit -m "feat: add amazing feature"`
7. **Push to branch**: `git push origin feature/amazing-feature`
8. **Create Pull Request**
### Development Workflow
- All changes must have tests
- CI pipeline runs automatically on PRs
- Visual regression tests ensure UI consistency
- Performance budgets must be met
- Accessibility standards must be maintained
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
# Test trigger
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/).
-258
View File
@@ -1,258 +0,0 @@
# Testing Strategy for CommunityRule
## Overview
This document outlines our comprehensive testing strategy that properly separates unit testing from responsive behavior testing, following best practices for JSDOM limitations and real browser testing.
## Current Test Status
- **236 total tests** across the project
- **227 tests passing** (96.2% success rate)
- **9 tests failing** (performance and interaction tests)
- **15 test files** covering all major components
- **Performance Monitoring**: Comprehensive regression detection and budget enforcement
## Testing Philosophy
### The Problem with JSDOM and Responsive Testing
**Short take: Unit tests in JSDOM can't truly "switch breakpoints."** JSDOM doesn't evaluate CSS media queries, so Tailwind's `hidden sm:block …` won't change visibility when you "resize" the window.
### Solution: Proper Test Separation
- **Unit / component tests (Vitest + RTL):** assert **structure and classes**, not responsive visibility.
- **Responsive behavior:** verify with **browser-based tests** (Playwright) or **visual tests** (Chromatic/Storybook) at real viewport widths.
## Test Categories
### 1. Unit Tests (Vitest + React Testing Library)
**Purpose:** Test component structure, accessibility, and configuration data.
**What to test:**
- DOM roles/labels exist: `role="banner"`, nav landmark, menu items
- The right **Tailwind classes** are present on wrappers (`block sm:hidden`, `hidden md:block`, etc.)
- Data-driven bits produce the expected count/order (e.g., `navigationItems`, `avatarImages`, `logoConfig`)
- Component configuration and exported data structures
**Example:**
```javascript
// tests/unit/Header.structure.test.js
test("logo wrappers include breakpoint classes", () => {
render(<Header />);
const logoWrappers = screen.getAllByTestId("logo-wrapper");
// Check first logo variant (xs only)
expect(logoWrappers[0]).toHaveClass("block", "sm:hidden");
// Check second logo variant (sm only)
expect(logoWrappers[1]).toHaveClass("hidden", "sm:block", "md:hidden");
});
```
### 2. Browser-Based Tests (Playwright)
**Purpose:** Test real responsive behavior at actual viewport widths.
**What to test:**
- **Visibility** at real breakpoints
- **Layout changes** between breakpoints
- **Interactive behavior** at different screen sizes
- **Accessibility** across viewports
**Example:**
```javascript
// tests/e2e/header.responsive.spec.js
const breakpoints = [
{ name: "xs", width: 360, height: 700 },
{ name: "sm", width: 640, height: 700 },
{ name: "md", width: 768, height: 700 },
{ name: "lg", width: 1024, height: 700 },
{ name: "xl", width: 1280, height: 700 },
];
for (const bp of breakpoints) {
test(`header layout at ${bp.name}`, async ({ page }) => {
await page.setViewportSize({ width: bp.width, height: bp.height });
await page.goto("/");
const nav = page.getByRole("navigation", { name: /main navigation/i });
await expect(nav).toBeVisible();
});
}
```
### 3. Visual Tests (Storybook + Chromatic)
**Purpose:** Visual regression testing and design system validation.
**What to test:**
- **Visual diffs** per breakpoint
- **Design consistency** across viewports
- **Component variations** and states
**Example:**
```javascript
// stories/Header.responsive.stories.js
export default {
parameters: {
chromatic: {
viewports: [360, 640, 768, 1024, 1280],
delay: 100,
},
},
};
```
## Component Improvements
### Header Component Enhancements
1. **Added Test IDs** for easier testing:
```jsx
<div data-testid="logo-wrapper" className={config.breakpoint}>
{renderLogo(config.size, config.showText)}
</div>
```
2. **Exported Configuration** for testing:
```javascript
export const navigationItems = [...];
export const avatarImages = [...];
export const logoConfig = [...];
```
3. **Structured Breakpoint Containers**:
```jsx
<div data-testid="nav-xs" className="block sm:hidden">
<div data-testid="nav-sm" className="hidden sm:block md:hidden">
<div data-testid="nav-md" className="hidden md:block lg:hidden">
```
## Test File Structure
```
tests/
├── unit/ # Unit tests (Vitest + RTL)
│ ├── Header.test.jsx # CONSOLIDATED: Comprehensive Header tests
│ ├── Footer.test.jsx
│ ├── Layout.test.jsx
│ └── Page.test.jsx
├── integration/ # Integration tests
│ └── ContentLockup.integration.test.jsx
├── e2e/ # Browser tests (Playwright)
│ └── header.responsive.spec.js # NEW: Responsive behavior tests
└── stories/ # Storybook stories
└── Header.responsive.stories.js # NEW: Visual testing
```
## Best Practices
### Unit Testing (JSDOM)
1. **Test structure, not visibility**:
```javascript
// ✅ Good: Test classes exist
expect(element).toHaveClass("block", "sm:hidden");
// ❌ Bad: Test visibility (doesn't work in JSDOM)
expect(element).toBeVisible();
```
2. **Use test IDs for containers**:
```javascript
// ✅ Good: Test specific containers
const logoWrapper = screen.getByTestId("logo-wrapper");
// ❌ Bad: Query by complex class strings
const logoWrapper = document.querySelector(".block.sm\\:hidden");
```
3. **Test configuration data**:
```javascript
// ✅ Good: Test exported configuration
expect(navigationItems).toHaveLength(3);
expect(logoConfig).toHaveLength(5);
```
### Browser Testing (Playwright)
1. **Test real viewport sizes**:
```javascript
await page.setViewportSize({ width: 640, height: 700 });
```
2. **Test visibility at breakpoints**:
```javascript
if (bp.name === "xs") {
await expect(page.getByTestId("auth-xs")).toBeVisible();
}
```
3. **Test accessibility across viewports**:
```javascript
const interactiveElements = [
page.getByRole("link", { name: /use cases/i }),
page.getByRole("button", { name: /create rule/i }),
];
for (const element of interactiveElements) {
await expect(element).toBeVisible();
await expect(element).toBeEnabled();
}
```
## Running Tests
### Unit Tests
```bash
npm test # Run all unit tests
npm test tests/unit/ # Run only unit tests
npm test Header.structure # Run specific test file
```
### Browser Tests
```bash
npx playwright test # Run all browser tests
npx playwright test header.responsive.spec.js # Run specific test
```
### Visual Tests
```bash
npm run storybook # Start Storybook
npx chromatic --project-token=xxx # Run visual tests
```
## Future Improvements
1. **Add more Playwright tests** for other components
2. **Set up Chromatic** for visual regression testing
3. **Add performance tests** for responsive behavior
4. **Create component-specific test utilities**
5. **Add accessibility testing** with axe-core
## Key Takeaways
1. **JSDOM limitations** require separating structure tests from visibility tests
2. **Test IDs** make testing more reliable and maintainable
3. **Exported configuration** enables better data structure testing
4. **Real browser testing** is essential for responsive behavior
5. **Visual testing** catches design regressions across breakpoints
This strategy provides comprehensive coverage while respecting the limitations of different testing environments.
BIN
View File
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
import type { ReactNode } from "react";
// Operator/admin dashboards (e.g. `/monitor`) intentionally render without the
// public marketing footer. Auth/access is enforced upstream.
export default function AdminLayout({ children }: { children: ReactNode }) {
return <main className="flex-1">{children}</main>;
}
+168
View File
@@ -0,0 +1,168 @@
"use client";
import WebVitalsDashboard from "./_components/WebVitalsDashboard";
import Top from "../../components/navigation/Top";
import Footer from "../../components/navigation/Footer";
import { useMessages } from "../../contexts/MessagesContext";
export default function MonitorPageContent() {
const m = useMessages();
const p = m.pages.monitor;
return (
<div className="min-h-screen bg-[var(--color-surface-default-primary)]">
<Top folderTop={false} />
<main className="container mx-auto px-[var(--spacing-scale-024)] py-[var(--spacing-scale-032)]">
<div className="max-w-6xl mx-auto">
<div className="mb-[var(--spacing-scale-032)]">
<h1 className="text-4xl font-bold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-016)]">
{p.title}
</h1>
<p className="text-[var(--font-size-body-large)] text-[var(--color-content-default-secondary)]">
{p.description}
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[var(--spacing-scale-032)] mb-[var(--spacing-scale-032)]">
<div className="p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
{p.performanceTargets.title}
</h2>
<div className="space-y-[var(--spacing-scale-012)]">
<div className="flex justify-between items-center">
<span className="text-[var(--font-size-body-medium)]">
{p.performanceTargets.loadTime}
</span>
<span className="font-semibold text-green-600">
{p.performanceTargets.loadTimeTarget}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[var(--font-size-body-medium)]">
{p.performanceTargets.lcp}
</span>
<span className="font-semibold text-green-600">
{p.performanceTargets.lcpTarget}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[var(--font-size-body-medium)]">
{p.performanceTargets.fid}
</span>
<span className="font-semibold text-green-600">
{p.performanceTargets.fidTarget}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[var(--font-size-body-medium)]">
{p.performanceTargets.cls}
</span>
<span className="font-semibold text-green-600">
{p.performanceTargets.clsTarget}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[var(--font-size-body-medium)]">
{p.performanceTargets.lighthouse}
</span>
<span className="font-semibold text-green-600">
{p.performanceTargets.lighthouseTarget}
</span>
</div>
</div>
</div>
<div className="p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
{p.optimizationStatus.title}
</h2>
<div className="space-y-[var(--spacing-scale-012)]">
<div className="flex items-center gap-[var(--spacing-scale-008)]">
<span className="text-green-600"></span>
<span className="text-[var(--font-size-body-medium)]">
{p.optimizationStatus.codeSplitting}
</span>
</div>
<div className="flex items-center gap-[var(--spacing-scale-008)]">
<span className="text-green-600"></span>
<span className="text-[var(--font-size-body-medium)]">
{p.optimizationStatus.reactMemo}
</span>
</div>
<div className="flex items-center gap-[var(--spacing-scale-008)]">
<span className="text-green-600"></span>
<span className="text-[var(--font-size-body-medium)]">
{p.optimizationStatus.imageOptimization}
</span>
</div>
<div className="flex items-center gap-[var(--spacing-scale-008)]">
<span className="text-green-600"></span>
<span className="text-[var(--font-size-body-medium)]">
{p.optimizationStatus.fontPreloading}
</span>
</div>
<div className="flex items-center gap-[var(--spacing-scale-008)]">
<span className="text-green-600"></span>
<span className="text-[var(--font-size-body-medium)]">
{p.optimizationStatus.bundleAnalysis}
</span>
</div>
<div className="flex items-center gap-[var(--spacing-scale-008)]">
<span className="text-green-600"></span>
<span className="text-[var(--font-size-body-medium)]">
{p.optimizationStatus.errorBoundaries}
</span>
</div>
</div>
</div>
</div>
<WebVitalsDashboard />
<div className="mt-[var(--spacing-scale-032)] p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
{p.monitoringCommands.title}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-[var(--spacing-scale-016)]">
<div>
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
{p.monitoringCommands.bundleAnalyze.title}
</h3>
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
{p.monitoringCommands.bundleAnalyze.command}
</code>
</div>
<div>
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
{p.monitoringCommands.e2ePerformance.title}
</h3>
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
{p.monitoringCommands.e2ePerformance.command}
</code>
</div>
<div>
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
{p.monitoringCommands.lhciDesktop.title}
</h3>
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
{p.monitoringCommands.lhciDesktop.command}
</code>
</div>
<div>
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
{p.monitoringCommands.performanceBudget.title}
</h3>
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
{p.monitoringCommands.performanceBudget.command}
</code>
</div>
</div>
</div>
</div>
</main>
<Footer />
</div>
);
}
@@ -0,0 +1,166 @@
"use client";
/**
* Figma: "WebVitalsDashboard" (see registry)
*/
import { memo, useEffect, useState } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import { logger } from "../../../../../lib/logger";
import WebVitalsDashboardView from "./WebVitalsDashboard.view";
import type { Metrics, Vitals, VitalData } from "./WebVitalsDashboard.types";
const createInitialVital = (): VitalData => ({
value: 0,
rating: "unknown",
});
const createInitialVitals = (): Vitals => ({
lcp: createInitialVital(),
fid: createInitialVital(),
cls: createInitialVital(),
fcp: createInitialVital(),
ttfb: createInitialVital(),
});
function reportWebVitalToApi(
metric: keyof Vitals,
value: number,
rating: VitalData["rating"],
): void {
if (typeof window === "undefined") return;
if (rating === "unknown") return;
const body = {
metric,
data: { value, rating },
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
};
void fetch("/api/web-vitals", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).catch((err: unknown) => {
logger.error("Web vitals ingest failed:", err);
});
}
const WebVitalsDashboardContainer = memo(() => {
const m = useMessages();
const copy = m.webVitalsDashboard;
const [vitals, setVitals] = useState<Vitals>(createInitialVitals);
const [metrics, setMetrics] = useState<Metrics>({});
const [loading, setLoading] = useState(true);
const [storage, setStorage] = useState<"external" | "local">("local");
const rumDashboardUrl =
typeof process.env.NEXT_PUBLIC_RUM_DASHBOARD_URL === "string" &&
process.env.NEXT_PUBLIC_RUM_DASHBOARD_URL.trim() !== ""
? process.env.NEXT_PUBLIC_RUM_DASHBOARD_URL.trim()
: null;
useEffect(() => {
const fetchVitals = async () => {
try {
const response = await fetch("/api/web-vitals");
const data = (await response.json()) as {
metrics?: Metrics;
storage?: "external" | "local";
};
setMetrics(data.metrics || {});
setStorage(data.storage === "external" ? "external" : "local");
} catch (error) {
logger.error("Error fetching web vitals:", error);
} finally {
setLoading(false);
}
};
fetchVitals();
if (typeof window !== "undefined") {
import("web-vitals").then(
({ onCLS, onFID, onFCP, onLCP, onTTFB }) => {
onLCP((metric) => {
const rating = metric.rating as VitalData["rating"];
setVitals((prev) => ({
...prev,
lcp: {
value: Math.round(metric.value),
rating,
},
}));
reportWebVitalToApi("lcp", Math.round(metric.value), rating);
});
onFID((metric) => {
const rating = metric.rating as VitalData["rating"];
setVitals((prev) => ({
...prev,
fid: {
value: Math.round(metric.value),
rating,
},
}));
reportWebVitalToApi("fid", Math.round(metric.value), rating);
});
onCLS((metric) => {
const rounded = Math.round(metric.value * 1000) / 1000;
const rating = metric.rating as VitalData["rating"];
setVitals((prev) => ({
...prev,
cls: {
value: rounded,
rating,
},
}));
reportWebVitalToApi("cls", rounded, rating);
});
onFCP((metric) => {
const rating = metric.rating as VitalData["rating"];
setVitals((prev) => ({
...prev,
fcp: {
value: Math.round(metric.value),
rating,
},
}));
reportWebVitalToApi("fcp", Math.round(metric.value), rating);
});
onTTFB((metric) => {
const rating = metric.rating as VitalData["rating"];
setVitals((prev) => ({
...prev,
ttfb: {
value: Math.round(metric.value),
rating,
},
}));
reportWebVitalToApi("ttfb", Math.round(metric.value), rating);
});
},
);
}
}, []);
return (
<WebVitalsDashboardView
vitals={vitals}
metrics={metrics}
loading={loading}
storage={storage}
copy={copy}
rumDashboardUrl={rumDashboardUrl}
/>
);
});
WebVitalsDashboardContainer.displayName = "WebVitalsDashboard";
export default WebVitalsDashboardContainer;
@@ -0,0 +1,40 @@
import type messages from "../../../../../messages/en/index";
export interface VitalData {
value: number;
rating: "good" | "needs-improvement" | "poor" | "unknown";
}
export interface Vitals {
lcp: VitalData;
fid: VitalData;
cls: VitalData;
fcp: VitalData;
ttfb: VitalData;
}
export interface MetricData {
count: number;
average: number;
min: number;
max: number;
goodCount: number;
needsImprovementCount: number;
poorCount: number;
lastUpdated?: string;
}
export interface Metrics {
[key: string]: MetricData;
}
export type WebVitalsDashboardCopy = typeof messages.webVitalsDashboard;
export interface WebVitalsDashboardViewProps {
vitals: Vitals;
metrics: Metrics;
loading: boolean;
storage: "external" | "local";
copy: WebVitalsDashboardCopy;
rumDashboardUrl: string | null;
}
@@ -0,0 +1,169 @@
import type { WebVitalsDashboardViewProps } from "./WebVitalsDashboard.types";
const getRatingColor = (rating: string): string => {
switch (rating) {
case "good":
return "text-green-600 bg-green-50";
case "needs-improvement":
return "text-yellow-600 bg-yellow-50";
case "poor":
return "text-red-600 bg-red-50";
default:
return "text-gray-600 bg-gray-50";
}
};
const getRatingIcon = (rating: string): string => {
switch (rating) {
case "good":
return "✅";
case "needs-improvement":
return "⚠️";
case "poor":
return "❌";
default:
return "❓";
}
};
function formatValue(metric: string, value: number): string {
if (metric === "cls") {
return value.toFixed(3);
}
return `${value}ms`;
}
function WebVitalsDashboardView({
vitals,
metrics,
loading,
storage,
copy,
rumDashboardUrl,
}: WebVitalsDashboardViewProps) {
if (loading) {
return (
<div className="p-6 bg-white rounded-lg shadow-lg">
<div className="animate-pulse">
<div className="h-6 bg-gray-200 rounded w-1/3 mb-4"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="p-4 border rounded-lg">
<div className="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
<div className="h-3 bg-gray-200 rounded w-3/4"></div>
</div>
))}
</div>
</div>
</div>
);
}
return (
<div className="p-6 bg-white rounded-lg shadow-lg">
<h2 className="text-2xl font-bold mb-6 text-[var(--color-content-default-primary)]">
{copy.title}
</h2>
{storage === "external" && (
<div
className="mb-6 p-4 rounded-lg border border-[var(--color-border-default-primary)] bg-[var(--color-surface-default-secondary)] text-[var(--font-size-body-medium)] text-[var(--color-content-default-secondary)]"
role="status"
>
<p className="font-semibold text-[var(--color-content-default-primary)] mb-2">
{copy.externalNoticeTitle}
</p>
<p className="mb-3">{copy.externalNoticeBody}</p>
{rumDashboardUrl ? (
<a
href={rumDashboardUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--color-content-default-primary)] underline font-medium"
>
{copy.externalDashboardLinkLabel}
</a>
) : null}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
{Object.entries(vitals).map(([metric, data]) => (
<div
key={metric}
className={`p-4 border rounded-lg ${getRatingColor(data.rating)}`}
>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold text-lg">{metric.toUpperCase()}</h3>
<span className="text-2xl">{getRatingIcon(data.rating)}</span>
</div>
<div className="text-sm">
<div className="font-medium">
{copy.valueLabel}: {formatValue(metric, data.value)}
</div>
<div className="capitalize">
{copy.ratingLabel}: {data.rating.replace("-", " ")}
</div>
</div>
</div>
))}
</div>
{Object.keys(metrics).length > 0 && (
<div className="mb-6">
<h3 className="text-lg font-semibold mb-4 text-[var(--color-content-default-primary)]">
{copy.historicalMetricsTitle}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(metrics).map(([metric, data]) => (
<div
key={metric}
className="p-4 border rounded-lg bg-[var(--color-surface-default-secondary)]"
>
<h4 className="font-semibold mb-2">{metric.toUpperCase()}</h4>
<div className="text-sm space-y-1">
<div>
{copy.countLabel}: {data.count}
</div>
<div>
{copy.averageLabel}: {formatValue(metric, data.average)}
</div>
<div>
{copy.rangeLabel}: {formatValue(metric, data.min)} -{" "}
{formatValue(metric, data.max)}
</div>
<div className="flex gap-2 text-xs">
<span className="text-green-600">
{copy.goodLabel}: {data.goodCount}
</span>
<span className="text-yellow-600">
{copy.needsImprovementLabel}: {data.needsImprovementCount}
</span>
<span className="text-red-600">
{copy.poorLabel}: {data.poorCount}
</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
<div className="p-4 bg-[var(--color-surface-default-secondary)] rounded-lg">
<h3 className="font-semibold mb-2 text-[var(--color-content-default-primary)]">
{copy.performanceGuidelinesTitle}
</h3>
<ul className="text-sm space-y-1 text-[var(--color-content-default-secondary)]">
<li> {copy.guidelines.lcp}</li>
<li> {copy.guidelines.fid}</li>
<li> {copy.guidelines.cls}</li>
<li> {copy.guidelines.fcp}</li>
<li> {copy.guidelines.ttfb}</li>
</ul>
</div>
</div>
);
}
export default WebVitalsDashboardView;
@@ -0,0 +1,2 @@
export { default } from "./WebVitalsDashboard.container";
export * from "./WebVitalsDashboard.types";
+5
View File
@@ -0,0 +1,5 @@
import MonitorPageContent from "./MonitorPageContent";
export default function MonitorPage() {
return <MonitorPageContent />;
}
+931
View File
@@ -0,0 +1,931 @@
"use client";
import {
Suspense,
useCallback,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
import { useCreateFlowFinalize } from "./hooks/useCreateFlowFinalize";
import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions";
import { useCompletedRuleShareExport } from "./hooks/useCompletedRuleShareExport";
import CreateFlowFooter from "../../components/navigation/CreateFlowFooter";
import CreateFlowTopNav from "../../components/navigation/CreateFlowTopNav";
import {
CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY,
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
getNextStep,
getStepIndex,
parseReviewReturnSearchParam,
createFlowStepUsesSelectSplitScroll,
TEMPLATES_FACET_RECOMMEND_QUERY,
TEMPLATES_FACET_RECOMMEND_VALUE,
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
} from "./utils/flowSteps";
import {
CREATE_FLOW_SYNC_DRAFT_QUERY,
CREATE_FLOW_SYNC_DRAFT_VALUE,
CREATE_ROUTES,
createFlowStepPath,
createFlowStepPathAfterStrippingReviewReturn,
createFlowStepPathWithSyncDraft,
} from "./utils/createFlowPaths";
import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress";
import {
createFlowStepUsesCenteredTextLayout,
createFlowStepUsesCardLayout,
} from "./utils/createFlowScreenRegistry";
import Button from "../../components/buttons/Button";
import { isValidCreateFlowSaveEmail } from "../../../lib/create/isValidCreateFlowSaveEmail";
import { buildCreateFlowDraftPayload } from "../../../lib/create/buildCreateFlowDraftPayload";
import {
fetchAuthSession,
requestMagicLink,
} from "../../../lib/create/api";
import { safeInternalPath } from "../../../lib/safeInternalPath";
import {
clearAnonymousCreateFlowStorage,
setTransferPendingFlag,
writeAnonymousCreateFlowState,
} from "./utils/anonymousDraftStorage";
import {
createFlowStateFromPublishedRule,
isPublishedRuleHydratePatchIncomplete,
methodSectionsPinsFromPublishedHydratePatch,
} from "../../../lib/create/publishedDocumentToCreateFlowState";
import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets";
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
import { runCompletedStepExit } from "./utils/runCompletedStepExit";
import messages from "../../../messages/en/index";
import {
CREATE_FLOW_FOOTER_BUTTON_CLASS,
CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS,
} from "./utils/createFlowFooterClassNames";
import {
CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP,
methodCardFacetSectionForConfirmStep,
type CustomRuleConfirmFooterStep,
} from "./utils/customRuleConfirmFooterSteps";
import { getDefaultFooterLabel } from "./utils/createFlowFooterLabels";
import { useAuthModal } from "../../contexts/AuthModalContext";
import { useMessages, useTranslation } from "../../contexts/MessagesContext";
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
import { SignedInDraftHydration } from "./SignedInDraftHydration";
import { CreateFlowPendingAvatarFlush } from "./components/CreateFlowPendingAvatarFlush";
import Alert from "../../components/modals/Alert";
import Create from "../../components/modals/Create";
import Share from "../../components/modals/Share";
import {
CreateFlowDraftSaveBannerProvider,
useCreateFlowDraftSaveBanner,
} from "./context/CreateFlowDraftSaveBannerContext";
/** First step where Save & Exit is offered (first Create Community select per Figma). */
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-structure");
function CreateFlowSessionShell({ children }: { children: ReactNode }) {
const [sessionUser, setSessionUser] = useState<
{ id: string; email: string } | null | undefined
>(undefined);
useEffect(() => {
let cancelled = false;
void fetchAuthSession().then(({ user }) => {
if (!cancelled) setSessionUser(user);
});
return () => {
cancelled = true;
};
}, []);
const sessionResolved = sessionUser !== undefined;
// Mirror in-progress draft to localStorage for ALL visitors once we know who
// they are. Refresh-survival is the same UX for guest and signed-in users;
// signed-in users additionally get an explicit "Save & Exit" that PUTs to
// the server (handled in `useCreateFlowExit`).
const enableLocalDraftMirroring = sessionResolved;
return (
<CreateFlowProvider enableLocalDraftMirroring={enableLocalDraftMirroring}>
<CreateFlowDraftSaveBannerProvider>
<Suspense fallback={null}>
<CreateFlowLayoutContent
sessionUser={sessionUser}
sessionResolved={sessionResolved}
>
{children}
</CreateFlowLayoutContent>
</Suspense>
</CreateFlowDraftSaveBannerProvider>
</CreateFlowProvider>
);
}
function CreateFlowLayoutContent({
children,
sessionUser,
sessionResolved,
}: {
children: ReactNode;
sessionUser: { id: string; email: string } | null | undefined;
sessionResolved: boolean;
}) {
const { create } = useMessages();
const footer = create.footer;
const communitySaveMessages = create.community.communitySave;
const tLogin = useTranslation("pages.login");
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const reviewReturnTarget = parseReviewReturnSearchParam(searchParams);
const { openLogin } = useAuthModal();
const skipCommunitySave = sessionResolved && Boolean(sessionUser);
const {
currentStep,
nextStep,
previousStep,
goToNextStep,
goToPreviousStep,
templateReviewFooterBackToCreateReview,
} = useCreateFlowNavigation(
skipCommunitySave ? { skipCommunitySave: true } : undefined,
);
const {
state,
clearState,
updateState,
resetCustomRuleSelections,
setMethodSectionsPinCommitted,
replaceState,
markCreateFlowInteraction,
} = useCreateFlow();
const manageStakeholdersIntent =
searchParams?.get(CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY) ===
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE;
const editingPublishedRuleIdTrimmed =
state.editingPublishedRuleId?.trim() ?? "";
const isConfirmStakeholdersManagePublished =
currentStep === "confirm-stakeholders" &&
manageStakeholdersIntent &&
editingPublishedRuleIdTrimmed.length > 0;
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
useCreateFlowDraftSaveBanner();
const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] =
useState(false);
const [communitySaveMagicLinkError, setCommunitySaveMagicLinkError] = useState<
string | null
>(null);
const [communitySaveMagicLinkSuccess, setCommunitySaveMagicLinkSuccess] =
useState(false);
const [completedFlowBanner, setCompletedFlowBanner] = useState<{
key: string;
status: "positive" | "danger";
title: string;
description?: string;
} | null>(null);
const [shareModalOpen, setShareModalOpen] = useState(false);
const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false);
const leaveConfirmResolverRef = useRef<((proceed: boolean) => void) | null>(
null,
);
const confirmLeave = useCallback(
() =>
new Promise<boolean>((resolve) => {
leaveConfirmResolverRef.current = resolve;
setLeaveConfirmOpen(true);
}),
[],
);
const closeLeaveConfirm = useCallback((proceed: boolean) => {
setLeaveConfirmOpen(false);
const resolve = leaveConfirmResolverRef.current;
leaveConfirmResolverRef.current = null;
resolve?.(proceed);
}, []);
const {
copyPublishedRuleLink,
mailtoPublishedRule,
sharePublishedRuleViaSignal,
sharePublishedRuleViaSlack,
sharePublishedRuleViaDiscord,
onSelectExportFormat: onCompletedExportFormat,
} = useCompletedRuleShareExport({
setActionBanner: setCompletedFlowBanner,
});
const handleOpenCompletedShareModal = () => {
if (!readLastPublishedRule()) {
setCompletedFlowBanner({
key: "completedShareNoRule",
status: "danger",
title: create.reviewAndComplete.completed.shareNoRuleTitle,
description: create.reviewAndComplete.completed.shareNoRuleDescription,
});
return;
}
setShareModalOpen(true);
};
const loginReturnPath =
currentStep === "edit-rule"
? createFlowStepPathWithSyncDraft("edit-rule")
: createFlowStepPathWithSyncDraft("final-review");
const {
publishBannerMessage,
setPublishBannerMessage,
isPublishing,
finalize: handleFinalize,
} = useCreateFlowFinalize({
state,
router,
openLogin,
updateState,
loginReturnPath,
});
const {
isTemplateReviewRoute,
templateReviewSlug,
isApplyingTemplate,
templateReviewApplyError,
setTemplateReviewApplyError,
handleCustomize: handleCustomizeTemplate,
handleUseWithoutChanges: handleUseTemplateWithoutChanges,
} = useTemplateReviewActions({
pathname,
state,
updateState,
replaceState,
router,
});
const runAuthenticatedExit = useCreateFlowExit({
state,
currentStep,
clearState,
router,
user: sessionUser ?? null,
setDraftSaveBannerMessage,
confirmLeave,
});
const handleExit = async (opts?: { saveDraft?: boolean }) => {
const saveDraft = opts?.saveDraft ?? false;
if (!sessionResolved) return;
// Exit from `/create/completed` is post-publish: the rule is saved, so we
// skip the leave-confirm + login prompt and just wipe the in-flight draft.
// For signed-in users we also DELETE the server draft so a future visit to
// /create starts fresh instead of rehydrating yesterday's work.
if (currentStep === "completed") {
runCompletedStepExit({
clearState,
clearAnonymousCreateFlowStorage,
router,
});
return;
}
if (sessionUser === null) {
if (saveDraft) return;
const returnToTemplateReview =
templateReviewSlug != null
? `/create/review-template/${encodeURIComponent(templateReviewSlug)}?syncDraft=1`
: null;
openLogin({
variant: "saveProgress",
nextPath:
returnToTemplateReview ??
`${pathname != null && pathname.length > 0 ? pathname : CREATE_ROUTES.createRoot}?${CREATE_FLOW_SYNC_DRAFT_QUERY}=${CREATE_FLOW_SYNC_DRAFT_VALUE}`,
backdropVariant: "blurredYellow",
});
return;
}
if (!sessionUser) return;
await runAuthenticatedExit(opts);
};
useEffect(() => {
if (
sessionResolved &&
sessionUser &&
currentStep === "community-save"
) {
router.replace(CREATE_ROUTES.review);
}
}, [sessionResolved, sessionUser, currentStep, router]);
useEffect(() => {
if (currentStep !== "community-save") {
setCommunitySaveMagicLinkError(null);
setCommunitySaveMagicLinkSuccess(false);
setCommunitySaveMagicLinkSubmitting(false);
}
}, [currentStep]);
useEffect(() => {
if (currentStep !== "edit-rule") return;
const last = readLastPublishedRule();
if (!last) {
router.replace(CREATE_ROUTES.completed);
return;
}
const editingId = state.editingPublishedRuleId?.trim() ?? "";
if (editingId.length > 0 && editingId !== last.id) {
router.replace(CREATE_ROUTES.completed);
return;
}
const titleOk =
typeof state.title === "string" && state.title.trim().length > 0;
const sectionsClear = (state.sections?.length ?? 0) === 0;
const patch = createFlowStateFromPublishedRule(last);
const pinPatch = methodSectionsPinsFromPublishedHydratePatch(patch);
const needsPinMerge = METHOD_FACET_API_SECTION_IDS.some(
(key) =>
pinPatch[key] === true &&
state.methodSectionsPinCommitted?.[key] !== true,
);
/**
* Skip repeat merges once template `sections` are cleared **and** published
* facet selections are present. Without the selection check, TopNav **Edit**
* (`sections: []` before navigate) matched only `sectionsClear` and skipped
* the merge — method-card steps saw empty `selected*Ids` until a confirm.
*
* Still merge {@link methodSectionsPinsFromPublishedHydratePatch}: selections
* may already match draft state while compact CardStack pins stayed false
* (pins are normally set only on facet **Confirm**).
*/
if (
titleOk &&
editingId === last.id &&
sectionsClear &&
!isPublishedRuleHydratePatchIncomplete(state, patch)
) {
if (needsPinMerge) {
updateState({
methodSectionsPinCommitted: {
...state.methodSectionsPinCommitted,
...pinPatch,
},
});
}
return;
}
updateState({
...patch,
methodSectionsPinCommitted: {
...state.methodSectionsPinCommitted,
...pinPatch,
},
});
}, [
currentStep,
router,
updateState,
state.editingPublishedRuleId,
state.title,
state.methodSectionsPinCommitted,
state.sections?.length,
state.customMethodCardMetaById,
]);
useEffect(() => {
if (currentStep !== "completed") {
setCompletedFlowBanner(null);
}
}, [currentStep]);
const handleCommunitySaveMagicLinkSubmit = useCallback(async () => {
setCommunitySaveMagicLinkError(null);
setCommunitySaveMagicLinkSuccess(false);
const raw = state.communitySaveEmail;
const trimmed = typeof raw === "string" ? raw.trim().toLowerCase() : "";
if (!isValidCreateFlowSaveEmail(trimmed)) return;
setCommunitySaveMagicLinkSubmitting(true);
try {
const stepAfterSave = getNextStep("community-save");
const segment = stepAfterSave ?? "review";
const rawNext = `/create/${segment}?syncDraft=1`;
const nextPath = safeInternalPath(rawNext);
const draftPayload = buildCreateFlowDraftPayload(state, currentStep);
writeAnonymousCreateFlowState({
...draftPayload,
communitySaveEmail: trimmed,
});
const result = await requestMagicLink(trimmed, nextPath, {
...draftPayload,
communitySaveEmail: trimmed,
});
if (result.ok === false) {
if (result.retryAfterMs != null && result.retryAfterMs > 0) {
const seconds = Math.ceil(result.retryAfterMs / 1000);
setCommunitySaveMagicLinkError(
tLogin("errors.rateLimited").replace("{seconds}", String(seconds)),
);
} else {
setCommunitySaveMagicLinkError(
result.error || tLogin("errors.generic"),
);
}
return;
}
setTransferPendingFlag();
updateState({ communitySaveEmail: trimmed });
setCommunitySaveMagicLinkSuccess(true);
} catch {
setCommunitySaveMagicLinkError(tLogin("errors.network"));
} finally {
setCommunitySaveMagicLinkSubmitting(false);
}
}, [state, currentStep, tLogin, updateState]);
const isCompletedStep = currentStep === "completed";
const isRightRailStep = currentStep === "decision-approaches";
const isFinalReviewLike =
currentStep === "final-review" || currentStep === "edit-rule";
const isEditRuleStep = currentStep === "edit-rule";
const isCardLayoutStep = createFlowStepUsesCardLayout(currentStep);
/** Two-column select / right-rail: below `lg` main scrolls; at `lg+` only the right column scrolls. */
const isSelectSplitScrollStep = createFlowStepUsesSelectSplitScroll(
currentStep,
);
const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
/** At `md+`, main cross-axis: center by default; exceptions stay top-aligned (see product spec). */
const mainContentClass = isCompletedStep
? "items-stretch overflow-y-auto md:overflow-hidden"
: isSelectSplitScrollStep
? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden"
: isFinalReviewLike || isCardLayoutStep || isTemplateReviewRoute
? "items-start justify-center overflow-y-auto"
: "items-start justify-center overflow-y-auto md:items-center";
const isTextStep = createFlowStepUsesCenteredTextLayout(currentStep);
const mainMaxMdJustify =
isTextStep && !isCompletedStep && !isRightRailStep
? "max-md:justify-center"
: "max-md:justify-start";
const mainMaxMdCross = isCompletedStep
? "max-md:flex-col max-md:items-stretch"
: "max-md:flex-col max-md:items-center";
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
const saveDraftOnExit =
Boolean(sessionUser) &&
(stepIdx >= SAVE_EXIT_FROM_STEP_INDEX || currentStep === "edit-rule");
const proportionBarProgress = getProportionBarProgressForCreateFlowStep(
currentStep,
);
/**
* Custom Rule stage "confirm selection" steps: all five render the same
* primary footer button, differing only by disable predicate and label.
* Driving JSX from a config keeps the five sites aligned — adding a new
* selection screen means one row here, not a new branch below.
*/
const customRuleConfirmFooter: CustomRuleConfirmFooterStep | undefined =
currentStep != null
? CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP.get(currentStep)
: undefined;
/** Method-card steps tolerate `reviewReturn={edit-rule}` when `edit-rule ∉ FLOW_STEP_ORDER` makes `nextStep` null. Core values stay gated on linear `nextStep`. */
const showCustomRuleFooterConfirm =
Boolean(customRuleConfirmFooter) &&
(nextStep != null ||
(reviewReturnTarget != null &&
methodCardFacetSectionForConfirmStep(customRuleConfirmFooter.step) !=
undefined));
/**
* Top banner stack above the main column; order is top → bottom.
*/
const topBanners: Array<{
key: string;
status: "danger" | "positive";
title: string;
description?: string;
onClose: () => void;
}> = [
draftSaveBannerMessage
? {
key: "draftSave",
status: "danger" as const,
title: messages.create.topNav.draftSaveBannerTitle,
description: draftSaveBannerMessage,
onClose: () => setDraftSaveBannerMessage(null),
}
: null,
publishBannerMessage
? {
key: "publish",
status: "danger" as const,
title:
messages.create.reviewAndComplete.publish.finalizeBannerTitle,
description: publishBannerMessage,
onClose: () => setPublishBannerMessage(null),
}
: null,
templateReviewApplyError
? {
key: "templateApply",
status: "danger" as const,
title: messages.create.templateReview.errors.applyFailed,
description: templateReviewApplyError,
onClose: () => setTemplateReviewApplyError(null),
}
: null,
communitySaveMagicLinkError
? {
key: "magicLinkError",
status: "danger" as const,
title: communitySaveMessages.magicLinkErrorTitle,
description: communitySaveMagicLinkError,
onClose: () => setCommunitySaveMagicLinkError(null),
}
: null,
communitySaveMagicLinkSuccess
? {
key: "magicLinkSuccess",
status: "positive" as const,
title: communitySaveMessages.magicLinkSuccessTitle,
description: communitySaveMessages.magicLinkSuccessDescription,
onClose: () => setCommunitySaveMagicLinkSuccess(false),
}
: null,
completedFlowBanner
? {
key: `completedFlow-${completedFlowBanner.key}`,
status: completedFlowBanner.status,
title: completedFlowBanner.title,
description: completedFlowBanner.description,
onClose: () => setCompletedFlowBanner(null),
}
: null,
].filter((b): b is NonNullable<typeof b> => b !== null);
return (
<div className="relative flex h-screen min-h-0 flex-col overflow-hidden bg-black">
{topBanners.length > 0 ? (
<div
className="pointer-events-none fixed left-0 right-0 top-0 z-[200] flex flex-col gap-2 px-[var(--spacing-measures-spacing-500,20px)] pt-[var(--spacing-measures-spacing-300,12px)] md:px-[var(--measures-spacing-1800,64px)]"
aria-live="polite"
>
{topBanners.map((b) => (
<div
key={b.key}
className="pointer-events-auto mx-auto w-full max-w-[960px]"
>
<Alert
type="banner"
status={b.status}
title={b.title}
description={b.description}
onClose={b.onClose}
className="w-full"
/>
</div>
))}
</div>
) : null}
<Suspense fallback={null}>
<SignedInDraftHydration
sessionUser={sessionUser}
sessionResolved={sessionResolved}
/>
</Suspense>
<Suspense fallback={null}>
<PostLoginDraftTransfer sessionUser={sessionUser} />
</Suspense>
<Suspense fallback={null}>
<CreateFlowPendingAvatarFlush
sessionUser={sessionUser}
sessionResolved={sessionResolved}
/>
</Suspense>
<Share
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
onCopyLink={() => void copyPublishedRuleLink()}
onEmailShare={mailtoPublishedRule}
onSignalShare={() => void sharePublishedRuleViaSignal()}
onSlackShare={() => void sharePublishedRuleViaSlack()}
onDiscordShare={() => void sharePublishedRuleViaDiscord()}
/>
<Create
isOpen={leaveConfirmOpen}
onClose={() => closeLeaveConfirm(false)}
title={messages.create.topNav.leaveConfirmTitle}
description={messages.create.topNav.leaveConfirmDescription}
showBackButton={false}
showNextButton
nextButtonText={messages.create.topNav.leaveConfirmProceed}
onNext={() => closeLeaveConfirm(true)}
footerContent={
<Button
buttonType="ghost"
palette="default"
size="xsmall"
onClick={() => closeLeaveConfirm(false)}
>
{messages.create.topNav.leaveConfirmCancel}
</Button>
}
backdropVariant="blurredYellow"
ariaLabel={messages.create.topNav.leaveConfirmTitle}
/>
<CreateFlowTopNav
hasShare={isCompletedStep}
hasExport={isCompletedStep}
hasEdit={isCompletedStep}
hasManageStakeholders={isEditRuleStep}
saveDraftOnExit={saveDraftOnExit}
onShare={
isCompletedStep ? () => void handleOpenCompletedShareModal() : undefined
}
onSelectExportFormat={
isCompletedStep ? onCompletedExportFormat : undefined
}
onEdit={
isCompletedStep
? () => {
const last = readLastPublishedRule();
if (!last) return;
updateState({
editingPublishedRuleId: last.id,
sections: [],
});
router.push(createFlowStepPath("edit-rule"));
}
: undefined
}
onManageStakeholders={
isEditRuleStep
? () => {
markCreateFlowInteraction();
router.push(
createFlowStepPath("confirm-stakeholders", {
[CREATE_FLOW_REVIEW_RETURN_QUERY_KEY]: "edit-rule",
[CREATE_FLOW_MANAGE_STAKEHOLDERS_QUERY]:
CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE,
}),
);
}
: undefined
}
onExit={(opts) => void handleExit(opts)}
buttonPalette={isCompletedStep ? "inverse" : undefined}
className={`shrink-0 ${
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : ""
}`.trim()}
/>
<main
className={`flex min-h-0 flex-1 w-full ${mainContentClass} ${mainResponsiveLayout}`}
>
{children}
</main>
{!isCompletedStep && (
<CreateFlowFooter
className="shrink-0"
progressBar={
!isTemplateReviewRoute &&
!isFinalReviewLike &&
reviewReturnTarget !== "edit-rule"
}
proportionBarProgress={proportionBarProgress}
proportionBarVariant="segmented"
secondButton={
isTemplateReviewRoute ? (
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
<Button
buttonType="ghost"
palette="default"
size="xsmall"
disabled={isApplyingTemplate}
className={CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS}
onClick={() => void handleUseTemplateWithoutChanges()}
>
{messages.create.templateReview.footer.useWithoutChanges}
</Button>
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={isApplyingTemplate}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => void handleCustomizeTemplate()}
>
{messages.create.templateReview.footer.customize}
</Button>
</div>
) : currentStep === "community-name" && nextStep ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing ||
typeof state.title !== "string" ||
state.title.trim().length === 0
}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
goToNextStep();
}}
>
{footer.confirmName}
</Button>
) : currentStep === "community-save" && nextStep ? (
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
<Button
buttonType="outline"
palette="default"
size="xsmall"
disabled={isPublishing}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
goToNextStep();
}}
>
{footer.saveLater}
</Button>
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing ||
communitySaveMagicLinkSubmitting ||
communitySaveMagicLinkSuccess ||
!isValidCreateFlowSaveEmail(state.communitySaveEmail)
}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
void handleCommunitySaveMagicLinkSubmit();
}}
>
{communitySaveMagicLinkSubmitting
? footer.submitEmailSending
: footer.submitEmail}
</Button>
</div>
) : currentStep === "review" && nextStep ? (
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
<Button
buttonType="outline"
palette="default"
size="xsmall"
disabled={isPublishing}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
// Scrub any prior template-customize prefill so entering
// the custom-rule stage from review is always a clean slate.
resetCustomRuleSelections();
goToNextStep();
}}
>
{footer.createCustom}
</Button>
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={isPublishing}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
// `fromFlow=1` tells `/templates` to skip the fresh-slate
// draft clear it normally runs on template click, so the
// user's in-progress Create Community stage survives this
// detour. Direct entries to `/templates` (no marker) and
// home "Popular templates" clicks always start fresh by
// wiping anonymous draft storage at click time.
router.push(
`/templates?${TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY}=${TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE}&${TEMPLATES_FACET_RECOMMEND_QUERY}=${TEMPLATES_FACET_RECOMMEND_VALUE}`,
);
}}
>
{footer.createFromTemplate}
</Button>
</div>
) : showCustomRuleFooterConfirm &&
customRuleConfirmFooter ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing ||
customRuleConfirmFooter.selectionIds(state).length === 0
}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
const cf = customRuleConfirmFooter;
const facet = methodCardFacetSectionForConfirmStep(cf.step);
if (facet != null && cf.selectionIds(state).length > 0) {
setMethodSectionsPinCommitted(facet, true);
}
if (reviewReturnTarget) {
router.push(
createFlowStepPathAfterStrippingReviewReturn(
reviewReturnTarget,
searchParams,
),
);
return;
}
goToNextStep();
}}
>
{footer[customRuleConfirmFooter.footerMessageKey]}
</Button>
) : isConfirmStakeholdersManagePublished ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={isPublishing}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
router.push(
createFlowStepPathAfterStrippingReviewReturn(
"edit-rule",
searchParams,
),
);
}}
>
{
create.reviewAndComplete.confirmStakeholders.managePublished
.footerDone
}
</Button>
) : nextStep || isFinalReviewLike ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={isPublishing}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
if (isFinalReviewLike) {
void handleFinalize();
} else {
goToNextStep();
}
}}
>
{isFinalReviewLike
? isPublishing
? messages.create.reviewAndComplete.publish
.finalizeButtonPublishing
: footer.finalizeCommunityRule
: getDefaultFooterLabel(currentStep, footer)}
</Button>
) : null
}
onBackClick={
isTemplateReviewRoute
? () =>
router.push(
templateReviewFooterBackToCreateReview
? CREATE_ROUTES.review
: CREATE_ROUTES.root,
)
: reviewReturnTarget
? () => {
router.push(
createFlowStepPathAfterStrippingReviewReturn(
reviewReturnTarget,
searchParams,
),
);
}
: previousStep
? goToPreviousStep
: undefined
}
/>
)}
</div>
);
}
export default function CreateFlowLayoutClient({
children,
}: {
children: ReactNode;
}) {
return <CreateFlowSessionShell>{children}</CreateFlowSessionShell>;
}
+32
View File
@@ -0,0 +1,32 @@
"use client";
import dynamic from "next/dynamic";
import type { ReactNode } from "react";
import { useTranslation } from "../../contexts/MessagesContext";
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 />,
},
);
export default function CreateFlowLayoutGate({
children,
}: {
children: ReactNode;
}) {
return <CreateFlowLayoutClient>{children}</CreateFlowLayoutClient>;
}
+171
View File
@@ -0,0 +1,171 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
clearAnonymousCreateFlowStorage,
hasTransferPendingFlag,
readAnonymousCreateFlowState,
} from "./utils/anonymousDraftStorage";
import { useCreateFlow } from "./context/CreateFlowContext";
import { parseCreateFlowScreenFromPathname } from "./utils/flowSteps";
import { fetchDraftFromServer, saveDraftToServer } from "../../../lib/create/api";
import { createFlowStateHasKeys } from "../../../lib/create/draftHydrationUtils";
import type { CreateFlowState } from "./types";
import messages from "../../../messages/en/index";
import Alert from "../../components/modals/Alert";
import { isBackendSyncEnabled } from "../../../lib/create/backendSyncEnabled";
function buildPayloadWithStep(
base: CreateFlowState,
pathname: string | null,
): CreateFlowState {
const step =
parseCreateFlowScreenFromPathname(pathname ?? null) ?? undefined;
return {
...base,
...(step ? { currentStep: step } : {}),
};
}
/**
* Prefer the on-device anonymous mirror when present; otherwise use the draft
* stored on the magic-link token at request time (written during verify).
*/
async function resolvePostLoginDraftPayload(
local: CreateFlowState,
pathname: string | null,
): Promise<CreateFlowState | null> {
const localPayload = createFlowStateHasKeys(local)
? buildPayloadWithStep(local, pathname)
: null;
const serverDraft = await fetchDraftFromServer();
const serverPayload =
serverDraft != null && createFlowStateHasKeys(serverDraft)
? buildPayloadWithStep(serverDraft, pathname)
: null;
if (localPayload && serverPayload) {
return { ...serverPayload, ...localPayload };
}
return localPayload ?? serverPayload;
}
/**
* After magic-link verify, redirects to `/create/...?syncDraft=1` with session cookie.
* With backend sync: PUT draft once when the device mirror is non-empty, then hydrates
* context. Without sync: hydrates from localStorage and/or the server draft saved at
* verify. Never writes an empty payload over an existing server draft.
*/
export function PostLoginDraftTransfer({
sessionUser,
}: {
sessionUser: { id: string; email: string } | null | undefined;
}) {
const { replaceState } = useCreateFlow();
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const syncDraft = searchParams.get("syncDraft");
const [transferError, setTransferError] = useState<string | null>(null);
const attemptedRef = useRef(false);
useEffect(() => {
if (sessionUser == null || sessionUser === undefined) return;
const wantsTransfer = syncDraft === "1" || hasTransferPendingFlag();
if (!wantsTransfer) return;
if (attemptedRef.current) return;
attemptedRef.current = true;
let cancelled = false;
void (async () => {
const local = readAnonymousCreateFlowState();
const pending = hasTransferPendingFlag();
if (!createFlowStateHasKeys(local) && !pending) {
const params = new URLSearchParams(searchParams.toString());
params.delete("syncDraft");
const q = params.toString();
if (pathname) {
router.replace(q ? `${pathname}?${q}` : pathname);
}
attemptedRef.current = false;
return;
}
const payload = await resolvePostLoginDraftPayload(local, pathname);
if (cancelled) return;
if (payload == null || !createFlowStateHasKeys(payload)) {
const params = new URLSearchParams(searchParams.toString());
params.delete("syncDraft");
const q = params.toString();
if (pathname) {
router.replace(q ? `${pathname}?${q}` : pathname);
}
attemptedRef.current = false;
return;
}
if (isBackendSyncEnabled() && createFlowStateHasKeys(local)) {
const saveResult = await saveDraftToServer(payload);
if (cancelled) return;
if (saveResult.ok === false) {
setTransferError(
messages.create.topNav.postLoginSaveFailedWithReason.replace(
"{reason}",
saveResult.message,
),
);
attemptedRef.current = false;
return;
}
}
clearAnonymousCreateFlowStorage();
replaceState(payload);
if (pathname) {
const params = new URLSearchParams(searchParams.toString());
params.delete("syncDraft");
const q = params.toString();
router.replace(q ? `${pathname}?${q}` : pathname);
}
})();
return () => {
cancelled = true;
};
}, [sessionUser, pathname, syncDraft, replaceState, router, searchParams]);
if (!transferError) return null;
const [titleLine, ...rest] = transferError.split(/\n\n+/);
const title = (titleLine ?? transferError).trim();
const description = rest.join("\n\n").trim() || undefined;
return (
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-[150] flex justify-center px-5 md:bottom-6">
<div className="pointer-events-auto w-full max-w-[640px]">
<Alert
type="banner"
status="danger"
size="s"
title={title}
description={description}
hasBodyText={Boolean(description)}
hasLeadingIcon
onClose={() => {
setTransferError(null);
}}
className="w-full"
/>
</div>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import type { CreateFlowState } from "./types";
import { createFlowStateHasKeys } from "../../../lib/create/draftHydrationUtils";
import {
hasTransferPendingFlag,
readAnonymousCreateFlowState,
} from "./utils/anonymousDraftStorage";
import { useCreateFlow } from "./context/CreateFlowContext";
import { fetchDraftFromServer } from "../../../lib/create/api";
import messages from "../../../messages/en/index";
import Alert from "../../components/modals/Alert";
import {
isValidStep,
parseCreateFlowScreenFromPathname,
} from "./utils/flowSteps";
import { isBackendSyncEnabled } from "../../../lib/create/backendSyncEnabled";
/**
* When sync is on and the user is signed in, restore the server-side draft only
* when there is no in-flight localStorage draft to defer to. localStorage is
* the on-every-keystroke buffer (CreateFlowProvider mirrors state there for
* everyone), so a refresh mid-flow already has the freshest data; pulling the
* server draft on top would clobber unsaved keystrokes with a stale snapshot.
*
* Server draft becomes authoritative only when localStorage is empty — i.e.
* fresh device, after explicit Save & Exit (which clears localStorage),
* after Exit-from-completed clears local state, or after
* {@link prepareFreshCreateFlowEntry} (Create rule / new template entry) clears
* local + deletes the server draft when sync is on.
*
* Skips when `?syncDraft=1` or transfer-pending — {@link PostLoginDraftTransfer}
* owns that path.
*/
export function SignedInDraftHydration({
sessionUser,
sessionResolved,
}: {
sessionUser: { id: string; email: string } | null | undefined;
sessionResolved: boolean;
}) {
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const syncDraftParam = searchParams.get("syncDraft");
const { replaceState, interactionTouched } = useCreateFlow();
const touchedRef = useRef(interactionTouched);
touchedRef.current = interactionTouched;
const [loadingHydration, setLoadingHydration] = useState(false);
const finishedUserIdRef = useRef<string | null>(null);
useEffect(() => {
if (!isBackendSyncEnabled()) return;
if (!sessionResolved) return;
if (sessionUser == null || sessionUser === undefined) {
finishedUserIdRef.current = null;
return;
}
const userId = sessionUser.id;
if (finishedUserIdRef.current === userId) return;
if (syncDraftParam === "1" || hasTransferPendingFlag()) {
return;
}
// Local draft wins over server: no fetch, no replaceState. The provider
// already hydrated from localStorage at mount, so the user sees their
// unsaved keystrokes immediately.
if (createFlowStateHasKeys(readAnonymousCreateFlowState())) {
finishedUserIdRef.current = userId;
return;
}
const urlStep = parseCreateFlowScreenFromPathname(pathname ?? null);
/** Owner “view published rule” shell — never merge server draft or redirect to `currentStep`. */
if (urlStep === "completed") {
return;
}
let cancelled = false;
setLoadingHydration(true);
void (async () => {
try {
const serverDraft = await fetchDraftFromServer();
if (cancelled) return;
if (touchedRef.current) {
finishedUserIdRef.current = userId;
return;
}
if (serverDraft != null && createFlowStateHasKeys(serverDraft)) {
const next = serverDraft as CreateFlowState;
replaceState(next);
const saved = next.currentStep;
if (saved && isValidStep(saved)) {
const urlStep = parseCreateFlowScreenFromPathname(pathname ?? null);
if (urlStep !== saved) {
router.replace(`/create/${saved}`);
}
}
}
finishedUserIdRef.current = userId;
} finally {
if (!cancelled) setLoadingHydration(false);
}
})();
return () => {
cancelled = true;
};
}, [
sessionResolved,
sessionUser,
syncDraftParam,
replaceState,
pathname,
router,
]);
if (!loadingHydration) return null;
return (
<div className="pointer-events-none fixed left-0 right-0 top-14 z-[170] flex justify-center px-[var(--spacing-measures-spacing-500,20px)] pt-2 md:top-16 md:px-[var(--measures-spacing-1800,64px)]">
<div className="pointer-events-auto w-full max-w-[960px]">
<Alert
type="banner"
status="default"
size="s"
title={messages.create.draftHydration.loadingSavedProgress}
hasBodyText={false}
hasLeadingIcon={false}
hasTrailingIcon={false}
className="w-full"
/>
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { notFound } from "next/navigation";
import { CreateFlowScreenView } from "../screens/CreateFlowScreenView";
import { isValidStep } from "../utils/flowSteps";
import type { CreateFlowStep } from "../types";
/**
* Single dynamic route for the whole create wizard (every step in `FLOW_STEP_ORDER`).
*
* Only **canonical** `screenId` values from `CreateFlowStep` are valid. Old placeholder
* segments from pre-product shells are not redirected — unknown slugs `notFound()`.
*/
interface PageProps {
params: Promise<{ screenId: string }>;
}
export default async function CreateFlowScreenPage({ params }: PageProps) {
const { screenId: raw } = await params;
if (!isValidStep(raw)) {
notFound();
}
return <CreateFlowScreenView screenId={raw as CreateFlowStep} />;
}
@@ -0,0 +1,146 @@
"use client";
/**
* Shared "Applicable Scope" field used by the `decision-approaches` create-flow
* modal. Pairs an `InputLabel` with a horizontally-wrapping list of
* toggle-chips plus an inline "+ Add" affordance that reveals a pill text input
* for creating new scope values. Conflict management uses
* `ModalTextAreaField` instead (Figma `20874:172292`).
*/
import { memo, useState } from "react";
import Chip from "../../../components/controls/Chip";
import InputLabel from "../../../components/type/InputLabel";
export interface ApplicableScopeFieldProps {
/** Label rendered above the capsule row. */
label: string;
/** Text for the "+ Add …" affordance (e.g. "Add Applicable Scope"). */
addLabel: string;
/**
* The full list of chip values shown to the user. Each value is a unique
* string (chip label).
*/
scopes: string[];
/** Values currently toggled on (rendered in the Chip "Selected" state). */
selectedScopes: string[];
/** Fired when a chip is clicked; caller toggles inclusion in `selectedScopes`. */
onToggleScope: (_scope: string) => void;
/**
* Fired when the user submits a new scope via the inline input. Duplicate
* values (already in `scopes`) are filtered out before the callback fires.
*/
onAddScope: (_scope: string) => void;
/**
* Optional placeholder for the inline input. Defaults to `addLabel`.
*/
inputPlaceholder?: string;
/** When true, scope chips and add affordance are non-interactive. */
readOnly?: boolean;
className?: string;
}
function ApplicableScopeFieldComponent({
label,
addLabel,
scopes,
selectedScopes,
onToggleScope,
onAddScope,
inputPlaceholder,
readOnly = false,
className = "",
}: ApplicableScopeFieldProps) {
const [draft, setDraft] = useState("");
const [isAdding, setIsAdding] = useState(false);
const submitDraft = () => {
const trimmed = draft.trim();
if (!trimmed) {
setIsAdding(false);
setDraft("");
return;
}
if (!scopes.includes(trimmed)) {
onAddScope(trimmed);
}
setDraft("");
setIsAdding(false);
};
return (
<div className={`flex flex-col gap-2 ${className}`.trim()}>
<InputLabel label={label} helpIcon size="s" palette="default" />
<div className="flex flex-wrap items-center gap-2">
{scopes.map((scope) => {
const isSelected = selectedScopes.includes(scope);
return (
<Chip
key={scope}
label={scope}
state={isSelected ? "selected" : "disabled"}
palette="default"
size="s"
disabled={readOnly}
onClick={() => !readOnly && onToggleScope(scope)}
ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`}
/>
);
})}
{readOnly ? null : isAdding ? (
<input
type="text"
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={submitDraft}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submitDraft();
} else if (e.key === "Escape") {
setDraft("");
setIsAdding(false);
}
}}
placeholder={inputPlaceholder ?? addLabel}
aria-label={inputPlaceholder ?? addLabel}
className="h-[30px] rounded-[9999px] border border-[var(--color-border-default-tertiary)] bg-transparent px-3 font-inter text-[length:var(--sizing-300,12px)] font-medium leading-[14px] text-[color:var(--color-content-default-primary)] outline-none placeholder:text-[color:var(--color-content-default-tertiary)] focus-visible:border-[var(--color-border-default-brand-primary)]"
/>
) : (
<button
type="button"
onClick={() => setIsAdding(true)}
className="inline-flex items-center gap-[var(--measures-spacing-050,2px)] rounded-[var(--measures-radius-full,9999px)] px-[var(--space-250,10px)] py-[var(--measures-spacing-200,8px)] font-inter text-[length:var(--sizing-300,12px)] font-medium leading-[14px] text-[color:var(--color-content-default-primary)] hover:bg-[var(--color-surface-default-secondary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-transparent"
>
<AddGlyph />
{addLabel}
</button>
)}
</div>
</div>
);
}
function AddGlyph() {
return (
<svg
aria-hidden
viewBox="0 0 24 24"
className="block size-[14px]"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 5v14M5 12h14"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
ApplicableScopeFieldComponent.displayName = "ApplicableScopeField";
export default memo(ApplicableScopeFieldComponent);
@@ -0,0 +1,22 @@
"use client";
import HeaderLockup from "../../../components/type/HeaderLockup";
import type { HeaderLockupProps } from "../../../components/type/HeaderLockup/HeaderLockup.types";
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
export type CreateFlowHeaderLockupProps = Omit<HeaderLockupProps, "size"> & {
/** Omit for responsive `M` below `md`, `L` at/above `md` (matches `--breakpoint-md`). */
size?: HeaderLockupProps["size"];
};
/**
* Create-flow HeaderLockup: **`L` at/above `md`**, `M` below unless `size` is passed explicitly.
*/
export function CreateFlowHeaderLockup({
size: sizeProp,
...rest
}: CreateFlowHeaderLockupProps) {
const mdUp = useCreateFlowMdUp();
const size = sizeProp ?? (mdUp ? "L" : "M");
return <HeaderLockup {...rest} size={size} />;
}
@@ -0,0 +1,49 @@
"use client";
import type { ReactNode } from "react";
import { CreateFlowHeaderLockup } from "./CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "./CreateFlowStepShell";
import {
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "./createFlowLayoutTokens";
/** Shared `Rule` / template card chrome: width + radius; padding comes from `Rule` (L+expanded = 24px). */
export const CREATE_FLOW_REVIEW_RULE_LAYOUT_CLASS =
"w-full min-w-0 rounded-[12px] md:rounded-[24px] md:max-w-[640px]";
type CreateFlowLockupCardStepShellProps = {
lockupTitle: string;
lockupDescription?: string;
children: ReactNode;
};
/** Final-review layout: `wideGrid`, two columns from `md:`, column widths from `createFlowLayoutTokens`. */
export function CreateFlowLockupCardStepShell({
lockupTitle,
lockupDescription,
children,
}: CreateFlowLockupCardStepShellProps) {
return (
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
<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}`}
>
<div
className={`flex min-w-0 flex-col justify-start md:justify-center ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
<CreateFlowHeaderLockup
title={lockupTitle}
description={lockupDescription}
justification="left"
/>
</div>
<div
className={`flex min-w-0 flex-col items-stretch ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
{children}
</div>
</div>
</CreateFlowStepShell>
);
}
@@ -0,0 +1,51 @@
"use client";
import { useEffect, useRef } from "react";
import { useCreateFlow } from "../context/CreateFlowContext";
import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer";
import {
clearPendingCommunityAvatarFile,
readPendingCommunityAvatarFile,
} from "../../../../lib/create/pendingCommunityAvatarUpload";
/**
* After sign-in, uploads a community avatar staged in IndexedDB (anonymous pick)
* and writes `communityAvatarUrl` on success.
*/
export function CreateFlowPendingAvatarFlush({
sessionUser,
sessionResolved,
}: {
sessionUser: { id: string; email: string } | null | undefined;
sessionResolved: boolean;
}) {
const { updateState } = useCreateFlow();
/** One successful flush per signed-in user id (survives React StrictMode remounts). */
const lastFlushedUserIdRef = useRef<string | null>(null);
useEffect(() => {
if (!sessionResolved || !sessionUser) return;
if (lastFlushedUserIdRef.current === sessionUser.id) return;
let cancelled = false;
void (async () => {
const file = await readPendingCommunityAvatarFile();
if (cancelled || !file) return;
try {
const { url } = await uploadCreateFlowFile(file, "communityAvatar");
if (cancelled) return;
await clearPendingCommunityAvatarFile();
updateState({ communityAvatarUrl: url });
lastFlushedUserIdRef.current = sessionUser.id;
} catch {
// Leave pending blob in place so the user can retry after fixing auth / UPLOAD_ROOT.
}
})();
return () => {
cancelled = true;
};
}, [sessionResolved, sessionUser, updateState]);
return null;
}
@@ -0,0 +1,60 @@
"use client";
import type { ReactNode } from "react";
export type CreateFlowStepShellVariant =
| "centeredNarrow"
| "centeredNarrowBottomPad"
| "wideGrid"
| "wideGridLoosePadding"
| "bare";
/** Semantic top padding below create-flow top nav (applied at all breakpoints; name is legacy). */
export type CreateFlowContentTopBelowMd = "none" | "space-1400" | "space-800";
const outerByVariant: Record<CreateFlowStepShellVariant, string> = {
centeredNarrow:
"flex w-full min-w-0 flex-col items-center px-5 md:px-16",
centeredNarrowBottomPad:
"flex w-full min-w-0 flex-col items-center px-5 pb-28 md:px-[var(--measures-spacing-1800,64px)] md:pb-32",
/** Wide two-column steps; 1328px = two 640px columns + 48px gutter. */
wideGrid: "w-full min-w-0 max-w-[1328px] shrink-0 px-5 md:px-12",
/** Create Community review + card grid (Figma Flow — Review `19706:12135`): max width 1440. */
wideGridLoosePadding:
"w-full min-w-0 max-w-[1440px] shrink-0 px-5 md:px-16",
bare: "w-full min-w-0",
};
const contentTopBelowMdClass: Record<CreateFlowContentTopBelowMd, string> = {
none: "",
"space-1400": "pt-[var(--space-1400)]",
"space-800": "pt-[var(--space-800)]",
};
interface CreateFlowStepShellProps {
children: ReactNode;
variant?: CreateFlowStepShellVariant;
/** Top spacing below top chrome (`CreateFlowTextFieldScreen` defaults to `space-1400`). */
contentTopBelowMd?: CreateFlowContentTopBelowMd;
className?: string;
}
/**
* 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`).
*/
export function CreateFlowStepShell({
children,
variant = "centeredNarrow",
contentTopBelowMd = "none",
className = "",
}: CreateFlowStepShellProps) {
const topClass = contentTopBelowMdClass[contentTopBelowMd];
return (
<div
className={`${outerByVariant[variant]} ${topClass} ${className}`.trim()}
>
{children}
</div>
);
}
@@ -0,0 +1,84 @@
"use client";
import type { ReactNode } from "react";
import {
CreateFlowStepShell,
type CreateFlowContentTopBelowMd,
} from "./CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "./createFlowLayoutTokens";
export type CreateFlowSelectShellLgVerticalAlign = "center" | "start";
interface CreateFlowTwoColumnSelectShellProps {
header: ReactNode;
children: ReactNode;
/**
* Top padding below create-flow chrome. Select steps use `space-1400`; right-rail uses `space-800`
* (Figma Flow — Right Rail).
*/
contentTopBelowMd?: CreateFlowContentTopBelowMd;
/**
* At `lg+`, layout variant: `"center"` = vertically centered pair (community size/structure).
* `"start"` = top-weighted layout with a scrollable right column (core values, right-rail): uses `items-stretch`
* so the right column gets a bounded height; `items-start` would grow with content and break scroll.
*/
lgVerticalAlign?: CreateFlowSelectShellLgVerticalAlign;
}
/**
* Two-column layout for create-flow select steps (community size/structure, core values) and
* {@link DecisionApproachesScreen} (decision approaches). Below `lg` (1024px), one column + main scrolls.
* At `lg+`, mirrors {@link CompletedScreen}: static header column + scrollable controls column
* (`min-h-0` + `overflow-y-auto` height chain; see completed page right rail).
*/
export function CreateFlowTwoColumnSelectShell({
header,
children,
contentTopBelowMd = "space-1400",
lgVerticalAlign = "center",
}: CreateFlowTwoColumnSelectShellProps) {
/** `stretch` is required for `min-h-0` + `overflow-y-auto` on the right column. */
const rowLgCrossAlignClass =
lgVerticalAlign === "start" ? "lg:items-stretch" : "lg:items-center";
const leftLgMainJustifyClass =
lgVerticalAlign === "start" ? "lg:justify-start" : "lg:justify-center";
return (
<CreateFlowStepShell
variant="centeredNarrow"
contentTopBelowMd={contentTopBelowMd}
className={
/* Below `lg`: natural height — same as legacy select screens (main scrolls). */
/* At `lg+`: fill main + clip so only the right column scrolls (CompletedScreen pattern). */
"w-full min-w-0 max-lg:flex-none lg:min-h-0 lg:h-full lg:max-h-full lg:flex-1 lg:overflow-hidden lg:items-stretch lg:self-stretch"
}
>
<div
className={
"flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-400,16px)] md:max-w-[640px] " +
"max-lg:flex-none lg:max-h-full lg:max-w-[1328px] lg:min-h-0 lg:flex-1 lg:flex-row lg:flex-nowrap " +
`${rowLgCrossAlignClass} lg:justify-center lg:gap-[var(--measures-spacing-1200,48px)] lg:overflow-hidden`
}
>
<div
className={
`flex w-full min-w-0 shrink-0 flex-col items-start gap-[var(--measures-spacing-200,8px)] ` +
`lg:flex-1 ${leftLgMainJustifyClass} lg:py-[12px] lg:max-w-[640px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`
}
>
{header}
</div>
<div
className={
`scrollbar-hide relative flex w-full min-w-0 flex-col items-start gap-[var(--measures-spacing-800,32px)] ` +
`overflow-x-hidden lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pb-[var(--measures-spacing-300,12px)] ` +
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS
}
>
{children}
</div>
</div>
</CreateFlowStepShell>
);
}
@@ -0,0 +1,66 @@
"use client";
/**
* Controlled field blocks for wizard-authored method cards in Create modals
* (facet screens + final-review chip edit). When `onBlocksChange` is omitted,
* blocks render read-only (disabled controls).
*
* Layout matches preset method editors ({@link CommunicationMethodEditFields},
* {@link DecisionApproachEditFields}): {@link ModalTextAreaField},
* {@link ApplicableScopeField} chip rows, {@link IncrementerBlock}.
*/
import { memo, useCallback } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import { CustomMethodCardFieldBlocksSummaryView } from "./CustomMethodCardFieldBlocksSummary.view";
import type { CustomMethodCardFieldBlocksSummaryProps } from "./CustomMethodCardFieldBlocksSummary.types";
function CustomMethodCardFieldBlocksSummaryContainerComponent({
blocks,
onBlocksChange,
}: CustomMethodCardFieldBlocksSummaryProps) {
const m = useMessages();
const wiz = m.create.customRule.customMethodCardWizard;
const fm = wiz.fieldModals;
const em = wiz.editModal;
const readOnly = !onBlocksChange;
const onPatch = useCallback(
(next: Parameters<NonNullable<typeof onBlocksChange>>[0]) => {
onBlocksChange?.(next);
},
[onBlocksChange],
);
return (
<CustomMethodCardFieldBlocksSummaryView
blocks={blocks}
readOnly={readOnly}
emptyValue={em.readout.emptyValue}
noFileChosen={em.readout.noFileChosen}
fieldModalsCopy={{
badges: { addOptionLabel: fm.badges.addOptionLabel },
upload: {
uploadFileInputAriaLabel: fm.upload.uploadFileInputAriaLabel,
uploadHint: fm.upload.uploadHint,
clearPendingUploadAriaLabel: fm.upload.clearPendingUploadAriaLabel,
clearPendingUploadTooltip: fm.upload.clearPendingUploadTooltip,
uploadPreviewImageAlt: fm.upload.uploadPreviewImageAlt,
},
proportion: {
decrementAriaLabel: fm.proportion.decrementAriaLabel,
incrementAriaLabel: fm.proportion.incrementAriaLabel,
},
}}
onPatch={onPatch}
/>
);
}
const CustomMethodCardFieldBlocksSummary = memo(
CustomMethodCardFieldBlocksSummaryContainerComponent,
);
CustomMethodCardFieldBlocksSummary.displayName =
"CustomMethodCardFieldBlocksSummary";
export default CustomMethodCardFieldBlocksSummary;
@@ -0,0 +1,55 @@
import type { ChangeEventHandler, RefObject } from "react";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
export interface CustomMethodCardFieldBlocksSummaryProps {
blocks: CustomMethodCardFieldBlock[];
/** When set, fields update the draft via immutable block-array replacements. */
onBlocksChange?: (_next: CustomMethodCardFieldBlock[]) => void;
}
export type CustomMethodCardFieldBlocksSummaryFieldModalsCopy = {
badges: { addOptionLabel: string };
upload: {
uploadFileInputAriaLabel: string;
uploadHint: string;
clearPendingUploadAriaLabel: string;
clearPendingUploadTooltip: string;
uploadPreviewImageAlt: string;
};
proportion: {
decrementAriaLabel: string;
incrementAriaLabel: string;
};
};
export interface CustomMethodCardFieldBlocksSummaryViewProps {
blocks: CustomMethodCardFieldBlock[];
readOnly: boolean;
emptyValue: string;
noFileChosen: string;
fieldModalsCopy: CustomMethodCardFieldBlocksSummaryFieldModalsCopy;
onPatch: (_next: CustomMethodCardFieldBlock[]) => void;
}
export type CustomMethodCardUploadBlockRowProps = {
block: Extract<CustomMethodCardFieldBlock, { kind: "upload" }>;
blocks: CustomMethodCardFieldBlock[];
onPatch: (_next: CustomMethodCardFieldBlock[]) => void;
uploadFileInputAriaLabel: string;
uploadHint: string;
clearPendingUploadAriaLabel: string;
clearPendingUploadTooltip: string;
uploadPreviewImageAlt: string;
noFileChosen: string;
};
export type CustomMethodCardUploadBlockRowViewProps =
CustomMethodCardUploadBlockRowProps & {
uploadInputRef: RefObject<HTMLInputElement | null>;
busy: boolean;
uploadingHint: string;
errorMessage: string | null;
onClearUpload: () => void;
onFileInputChange: ChangeEventHandler<HTMLInputElement>;
onUploadClick: () => void;
};
@@ -0,0 +1,198 @@
"use client";
import { memo } from "react";
import Chip from "../../../../components/controls/Chip";
import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
import InputLabel from "../../../../components/type/InputLabel";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import ApplicableScopeField from "../ApplicableScopeField";
import ModalTextAreaField from "../ModalTextAreaField";
import { CustomMethodCardUploadBlockRow } from "./CustomMethodCardUploadBlockRow.container";
import type { CustomMethodCardFieldBlocksSummaryViewProps } from "./CustomMethodCardFieldBlocksSummary.types";
const TEXT_VALUE_MAX = 8000;
function mapBlockById(
blocks: CustomMethodCardFieldBlock[],
blockId: string,
mapFn: (_b: CustomMethodCardFieldBlock) => CustomMethodCardFieldBlock,
): CustomMethodCardFieldBlock[] {
return blocks.map((b) => (b.id === blockId ? mapFn(b) : b));
}
function CustomMethodCardFieldBlocksSummaryViewComponent({
blocks,
readOnly,
emptyValue,
noFileChosen,
fieldModalsCopy,
onPatch,
}: CustomMethodCardFieldBlocksSummaryViewProps) {
const fm = fieldModalsCopy;
return (
<div className="flex flex-col gap-6">
{blocks.map((block) => {
if (block.kind === "text") {
return (
<ModalTextAreaField
key={block.id}
label={block.blockTitle}
rows={6}
value={block.placeholderText}
onChange={(v) =>
onPatch(
mapBlockById(blocks, block.id, (b) =>
b.kind === "text"
? { ...b, placeholderText: v.slice(0, TEXT_VALUE_MAX) }
: b,
),
)
}
disabled={readOnly}
/>
);
}
if (block.kind === "badges") {
if (readOnly) {
return (
<div key={block.id} className="flex flex-col gap-2">
<InputLabel
label={block.blockTitle}
helpIcon
size="s"
palette="default"
/>
{block.options.length > 0 ? (
<div className="flex flex-wrap items-center gap-2">
{block.options.map((opt, idx) => (
<Chip
key={`${block.id}-${idx}`}
label={opt}
state="selected"
palette="default"
size="s"
disabled
ariaLabel={opt}
/>
))}
</div>
) : (
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m)] text-[var(--color-content-default-secondary)]">
{emptyValue}
</p>
)}
</div>
);
}
return (
<ApplicableScopeField
key={block.id}
label={block.blockTitle}
addLabel={fm.badges.addOptionLabel}
scopes={block.options}
selectedScopes={block.options}
onToggleScope={(scope) =>
onPatch(
mapBlockById(blocks, block.id, (b) =>
b.kind === "badges"
? { ...b, options: b.options.filter((o) => o !== scope) }
: b,
),
)
}
onAddScope={(scope) =>
onPatch(
mapBlockById(blocks, block.id, (b) => {
if (b.kind !== "badges") return b;
if (b.options.includes(scope) || b.options.length >= 50)
return b;
return { ...b, options: [...b.options, scope] };
}),
)
}
/>
);
}
if (block.kind === "upload") {
return (
<div key={block.id}>
{readOnly ? (
<div className="flex flex-col gap-2">
<InputLabel
label={block.blockTitle}
helpIcon
size="s"
palette="default"
/>
{block.assetUrl?.trim() ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={block.assetUrl.trim()}
alt={
block.fileName?.trim() ||
block.blockTitle ||
noFileChosen
}
className="max-h-[160px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
/>
) : (
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m)] text-[var(--color-content-default-secondary)]">
{noFileChosen}
</p>
)}
</div>
) : (
<CustomMethodCardUploadBlockRow
block={block}
blocks={blocks}
onPatch={onPatch}
uploadFileInputAriaLabel={fm.upload.uploadFileInputAriaLabel}
uploadHint={fm.upload.uploadHint}
clearPendingUploadAriaLabel={
fm.upload.clearPendingUploadAriaLabel
}
clearPendingUploadTooltip={
fm.upload.clearPendingUploadTooltip
}
uploadPreviewImageAlt={fm.upload.uploadPreviewImageAlt}
noFileChosen={noFileChosen}
/>
)}
</div>
);
}
return (
<IncrementerBlock
key={block.id}
label={block.blockTitle}
value={block.defaultPercent}
min={1}
max={100}
step={1}
disabled={readOnly}
onChange={(v) =>
onPatch(
mapBlockById(blocks, block.id, (b) =>
b.kind === "proportion" ? { ...b, defaultPercent: v } : b,
),
)
}
formatValue={(v) => `${v}%`}
decrementAriaLabel={fm.proportion.decrementAriaLabel}
incrementAriaLabel={fm.proportion.incrementAriaLabel}
/>
);
})}
</div>
);
}
export const CustomMethodCardFieldBlocksSummaryView = memo(
CustomMethodCardFieldBlocksSummaryViewComponent,
);
CustomMethodCardFieldBlocksSummaryView.displayName =
"CustomMethodCardFieldBlocksSummaryView";
@@ -0,0 +1,110 @@
"use client";
import { memo, useCallback, useRef, useState } from "react";
import { useTranslation } from "../../../../contexts/MessagesContext";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import { CustomMethodCardUploadBlockRowView } from "./CustomMethodCardUploadBlockRow.view";
import type { CustomMethodCardUploadBlockRowProps } from "./CustomMethodCardFieldBlocksSummary.types";
function mapBlockById(
blocks: CustomMethodCardFieldBlock[],
blockId: string,
mapFn: (_b: CustomMethodCardFieldBlock) => CustomMethodCardFieldBlock,
): CustomMethodCardFieldBlock[] {
return blocks.map((b) => (b.id === blockId ? mapFn(b) : b));
}
function CustomMethodCardUploadBlockRowContainerComponent({
block,
blocks,
onPatch,
uploadFileInputAriaLabel,
uploadHint,
clearPendingUploadAriaLabel,
clearPendingUploadTooltip,
uploadPreviewImageAlt,
noFileChosen,
}: CustomMethodCardUploadBlockRowProps) {
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const tUpload = useTranslation("create.upload");
const [busy, setBusy] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const clearUpload = useCallback(() => {
onPatch(
mapBlockById(blocks, block.id, (b) =>
b.kind === "upload"
? { ...b, fileName: undefined, assetUrl: undefined }
: b,
),
);
}, [block.id, blocks, onPatch]);
const handleFileInputChange = useCallback<
React.ChangeEventHandler<HTMLInputElement>
>(
(e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setErrorMessage(null);
setBusy(true);
void (async () => {
try {
const { url } = await uploadCreateFlowFile(
file,
"customMethodAttachment",
);
const name = file.name?.trim();
onPatch(
mapBlockById(blocks, block.id, (b) =>
b.kind === "upload"
? {
...b,
...(name ? { fileName: name } : {}),
assetUrl: url,
}
: b,
),
);
} catch {
setErrorMessage(tUpload("errors.generic"));
} finally {
setBusy(false);
}
})();
},
[block.id, blocks, onPatch, tUpload],
);
const handleUploadClick = useCallback(() => {
if (!busy) uploadInputRef.current?.click();
}, [busy]);
return (
<CustomMethodCardUploadBlockRowView
block={block}
blocks={blocks}
onPatch={onPatch}
uploadFileInputAriaLabel={uploadFileInputAriaLabel}
uploadHint={uploadHint}
clearPendingUploadAriaLabel={clearPendingUploadAriaLabel}
clearPendingUploadTooltip={clearPendingUploadTooltip}
uploadPreviewImageAlt={uploadPreviewImageAlt}
noFileChosen={noFileChosen}
uploadInputRef={uploadInputRef}
busy={busy}
uploadingHint={tUpload("uploading")}
errorMessage={errorMessage}
onClearUpload={clearUpload}
onFileInputChange={handleFileInputChange}
onUploadClick={handleUploadClick}
/>
);
}
export const CustomMethodCardUploadBlockRow = memo(
CustomMethodCardUploadBlockRowContainerComponent,
);
CustomMethodCardUploadBlockRow.displayName = "CustomMethodCardUploadBlockRow";
@@ -0,0 +1,100 @@
"use client";
import { memo } from "react";
import Upload from "../../../../components/controls/Upload";
import InputLabel from "../../../../components/type/InputLabel";
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
import type { CustomMethodCardUploadBlockRowViewProps } from "./CustomMethodCardFieldBlocksSummary.types";
function CustomMethodCardUploadBlockRowViewComponent({
block,
uploadFileInputAriaLabel,
uploadHint,
clearPendingUploadAriaLabel,
clearPendingUploadTooltip,
uploadPreviewImageAlt,
noFileChosen,
uploadInputRef,
busy,
uploadingHint,
errorMessage,
onClearUpload,
onFileInputChange,
onUploadClick,
}: CustomMethodCardUploadBlockRowViewProps) {
const displayName = block.fileName?.trim() ? block.fileName : noFileChosen;
const assetUrlTrimmed = block.assetUrl?.trim() ?? "";
const hasAsset = assetUrlTrimmed.length > 0;
return (
<div className="flex flex-col gap-2">
<InputLabel
label={block.blockTitle}
helpIcon
size="s"
palette="default"
/>
{!hasAsset ? (
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m)] text-[var(--color-content-default-secondary)]">
{displayName}
</p>
) : null}
<input
ref={uploadInputRef}
type="file"
className="sr-only"
tabIndex={-1}
accept="image/jpeg,image/png,image/webp,image/gif,application/pdf"
aria-label={uploadFileInputAriaLabel}
onChange={onFileInputChange}
/>
{hasAsset ? (
<div className="relative inline-block max-w-full">
<button
type="button"
onClick={onClearUpload}
className="absolute right-[8px] top-[8px] z-[1] flex h-[32px] w-[32px] cursor-pointer items-center justify-center rounded-full bg-[var(--color-surface-default-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]"
aria-label={clearPendingUploadAriaLabel}
title={clearPendingUploadTooltip}
>
{/* eslint-disable-next-line @next/next/no-img-element -- matches ModalHeader close control */}
<img
src={getAssetPath(ASSETS.ICON_CLOSE)}
alt=""
className="h-[16px] w-[16px]"
style={{
filter: "brightness(0) invert(1)",
}}
/>
</button>
{/* eslint-disable-next-line @next/next/no-img-element -- same-origin upload URL */}
<img
src={assetUrlTrimmed}
alt={uploadPreviewImageAlt}
className="max-h-[160px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
/>
</div>
) : (
<Upload
active={!busy}
hintText={busy ? uploadingHint : uploadHint}
onClick={onUploadClick}
/>
)}
{errorMessage ? (
<p
className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-s)] text-[var(--color-content-default-secondary)]"
role="alert"
>
{errorMessage}
</p>
) : null}
</div>
);
}
export const CustomMethodCardUploadBlockRowView = memo(
CustomMethodCardUploadBlockRowViewComponent,
);
CustomMethodCardUploadBlockRowView.displayName =
"CustomMethodCardUploadBlockRowView";
@@ -0,0 +1,2 @@
export { default } from "./CustomMethodCardFieldBlocksSummary.container";
export type { CustomMethodCardFieldBlocksSummaryProps } from "./CustomMethodCardFieldBlocksSummary.types";
@@ -0,0 +1,68 @@
"use client";
import ContentLockup from "../../../components/type/ContentLockup";
import { useMessages } from "../../../contexts/MessagesContext";
import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks";
import type { CreateFlowState } from "../types";
import CustomMethodCardFieldBlocksSummary from "./CustomMethodCardFieldBlocksSummary";
import CustomMethodCardPresetEditPlaceholder from "./CustomMethodCardPresetEditPlaceholder";
/** Body for Create modals when the card is user-authored (custom UUID). */
export default function CustomMethodCardModalBody({
cardId,
blocksById,
/** When set, used instead of `blocksById[cardId]` (e.g. final-review draft). */
blocksOverride,
onFieldBlocksChange,
policyMeta,
/**
* When false, omit {@link ContentLockup} for title/description (Customize mode:
* {@link MethodCardCustomizeModalHeader} already edits them). Summary line still shows.
* @default true
*/
showPolicyContentLockupWhenNoBlocks = true,
}: {
cardId: string;
blocksById: CreateFlowState["customMethodCardFieldBlocksById"];
blocksOverride?: CustomMethodCardFieldBlock[] | null;
onFieldBlocksChange?: (_blocks: CustomMethodCardFieldBlock[]) => void;
policyMeta?: { label: string; supportText: string };
showPolicyContentLockupWhenNoBlocks?: boolean;
}) {
const m = useMessages();
const blocks = blocksOverride ?? blocksById?.[cardId];
if (blocks && blocks.length > 0) {
return (
<CustomMethodCardFieldBlocksSummary
blocks={blocks}
onBlocksChange={onFieldBlocksChange}
/>
);
}
const label = policyMeta?.label?.trim() ?? "";
const support = policyMeta?.supportText?.trim() ?? "";
if (label.length > 0 || support.length > 0) {
const noFieldsHint = m.create.customRule.customMethodCardWizard.editModal
.noCustomFieldsYet;
return (
<div className="flex flex-col gap-4">
{showPolicyContentLockupWhenNoBlocks ? (
<ContentLockup
title={label.length > 0 ? label : undefined}
description={support.length > 0 ? support : undefined}
variant="modal"
alignment="left"
/>
) : null}
{noFieldsHint.trim().length > 0 ? (
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m,15px)] leading-[var(--line-height-body-m,22px)] text-[var(--color-content-default-secondary)]">
{noFieldsHint}
</p>
) : null}
</div>
);
}
return <CustomMethodCardPresetEditPlaceholder />;
}
@@ -0,0 +1,26 @@
"use client";
/**
* Shown in method-card Create modals and final-review chip edit when the chip
* is user-authored (`customMethodCardMetaById`) — preset section editors do
* not apply until structured parity exists with wizard field blocks.
*/
import { memo } from "react";
import { useMessages } from "../../../contexts/MessagesContext";
function CustomMethodCardPresetEditPlaceholderComponent() {
const m = useMessages();
const body = m.create.customRule.customMethodCardWizard.editModal.placeholderBody;
return (
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m,15px)] leading-[var(--line-height-body-m,22px)] text-[var(--color-content-default-secondary)]">
{body}
</p>
);
}
CustomMethodCardPresetEditPlaceholderComponent.displayName =
"CustomMethodCardPresetEditPlaceholder";
export default memo(CustomMethodCardPresetEditPlaceholderComponent);
@@ -0,0 +1,433 @@
"use client";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useMessages,
useTranslation,
} from "../../../../contexts/MessagesContext";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants";
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
import type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.types";
import { CustomMethodCardWizardView } from "./CustomMethodCardWizard.view";
import type { CustomMethodCardWizardProps } from "./CustomMethodCardWizard.types";
/**
* Shared 3-step add-custom-method-card flow (Figma Modal / Create — nodes
* `20066:14748`, `20094:48551`, `20066:14361`).
*/
const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
({ isOpen, onClose, onFinalize, onPersistCustomUploadFile }) => {
const m = useMessages();
const t = useTranslation("common");
const tUpload = useTranslation("create.upload");
const w = m.create.customRule.customMethodCardWizard;
const menuCopy = m.create.customRule.modalKebabMenu;
const copy = useMemo(
() => ({
step1: w.steps["1"],
step2: w.steps["2"],
step3: w.steps["3"],
step3BlocksList: w.step3BlocksList,
fieldTypeLabels: {
text: w.addCustomField.fieldTypes.text,
badges: w.addCustomField.fieldTypes.badges,
upload: w.addCustomField.fieldTypes.upload,
proportion: w.addCustomField.fieldTypes.proportion,
},
footerFinalize: w.footer.finalize,
fieldModals: w.fieldModals,
}),
[
w.addCustomField.fieldTypes,
w.fieldModals,
w.footer.finalize,
w.step3BlocksList,
w.steps,
],
);
const fieldBodiesCopy = useMemo(
() => ({
requiredHint: copy.fieldModals.requiredHint,
text: copy.fieldModals.text,
badges: copy.fieldModals.badges,
upload: copy.fieldModals.upload,
proportion: copy.fieldModals.proportion,
}),
[copy.fieldModals],
);
const [wizardStep, setWizardStep] = useState<1 | 2 | 3>(1);
const [policyTitle, setPolicyTitle] = useState("");
const [policyDescription, setPolicyDescription] = useState("");
const [addFieldExpanded, setAddFieldExpanded] = useState(false);
const [fieldTypeModal, setFieldTypeModal] =
useState<AddCustomFieldType | null>(null);
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
CustomMethodCardFieldBlock[]
>([]);
const [textBlockTitle, setTextBlockTitle] = useState("");
const [textPlaceholderBody, setTextPlaceholderBody] = useState("");
const [badgeBlockTitle, setBadgeBlockTitle] = useState("");
const [badgeOptions, setBadgeOptions] = useState<string[]>([]);
const [uploadBlockTitle, setUploadBlockTitle] = useState("");
const [uploadFileName, setUploadFileName] = useState<string | undefined>(
undefined,
);
const [uploadAssetUrl, setUploadAssetUrl] = useState<string | undefined>(
undefined,
);
const [uploadFieldBusy, setUploadFieldBusy] = useState(false);
const [uploadFieldError, setUploadFieldError] = useState<string | null>(
null,
);
const [proportionBlockTitle, setProportionBlockTitle] = useState("");
const [proportionDefault, setProportionDefault] = useState(50);
const fileInputRef = useRef<HTMLInputElement>(null);
const resetFieldTypeDrafts = useCallback(() => {
setTextBlockTitle("");
setTextPlaceholderBody("");
setBadgeBlockTitle("");
setBadgeOptions([]);
setUploadBlockTitle("");
setUploadFileName(undefined);
setUploadAssetUrl(undefined);
setUploadFieldBusy(false);
setUploadFieldError(null);
setProportionBlockTitle("");
setProportionDefault(50);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}, []);
const reset = useCallback(() => {
setWizardStep(1);
setPolicyTitle("");
setPolicyDescription("");
setAddFieldExpanded(false);
setFieldTypeModal(null);
setDraftFieldBlocks([]);
resetFieldTypeDrafts();
}, [resetFieldTypeDrafts]);
useEffect(() => {
if (!isOpen) {
reset();
}
}, [isOpen, reset]);
const dismiss = useCallback(() => {
reset();
onClose();
}, [onClose, reset]);
const titleTrim = policyTitle.trim();
const descriptionTrim = policyDescription.trim();
const stepValid = useMemo(() => {
const titleOk =
titleTrim.length > 0 &&
titleTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS;
const descriptionOk =
descriptionTrim.length > 0 &&
descriptionTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS;
if (wizardStep === 1) return titleOk;
if (wizardStep === 2) return descriptionOk;
return titleOk && descriptionOk;
}, [
descriptionTrim.length,
titleTrim.length,
wizardStep,
]);
const fieldModalStepValid = useMemo(() => {
if (!fieldTypeModal) return false;
if (fieldTypeModal === "text") {
const t0 = textBlockTitle.trim();
return (
t0.length > 0 &&
t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS
);
}
if (fieldTypeModal === "badges") {
const t0 = badgeBlockTitle.trim();
return (
t0.length > 0 &&
t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS
);
}
if (fieldTypeModal === "upload") {
const t0 = uploadBlockTitle.trim();
const titleOk =
t0.length > 0 &&
t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS;
if (!titleOk) return false;
if (onPersistCustomUploadFile) {
return Boolean(uploadAssetUrl?.trim());
}
return true;
}
const t0 = proportionBlockTitle.trim();
return (
t0.length > 0 &&
t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS &&
proportionDefault >= 1 &&
proportionDefault <= 100
);
}, [
badgeBlockTitle,
fieldTypeModal,
proportionBlockTitle,
proportionDefault,
textBlockTitle,
uploadBlockTitle,
uploadAssetUrl,
onPersistCustomUploadFile,
]);
const headerTitle =
wizardStep === 1
? copy.step1.title
: wizardStep === 2
? copy.step2.title
: copy.step3.title;
const headerDescription =
wizardStep === 1
? copy.step1.description
: wizardStep === 2
? copy.step2.description
: copy.step3.description;
const fieldModalHeader = fieldTypeModal
? copy.fieldModals[fieldTypeModal]
: null;
const shellTitle = fieldModalHeader?.title ?? headerTitle;
const shellDescription = fieldModalHeader?.description ?? headerDescription;
const nextLabel = fieldTypeModal
? copy.fieldModals.addField
: wizardStep === 3
? copy.footerFinalize
: t("buttons.next");
const shellNextDisabled = fieldTypeModal
? !fieldModalStepValid
: !stepValid;
const handleShellClose = useCallback(() => {
if (fieldTypeModal) {
setFieldTypeModal(null);
return;
}
dismiss();
}, [dismiss, fieldTypeModal]);
const kebabMenuItems = useMemo<ModalHeaderMenuItem[]>(() => [], []);
const handleBack = useCallback(() => {
if (fieldTypeModal) {
setFieldTypeModal(null);
return;
}
if (wizardStep === 1) {
dismiss();
return;
}
setWizardStep((s) => (s === 2 ? 1 : 2));
}, [dismiss, fieldTypeModal, wizardStep]);
const handleSelectFieldType = useCallback((ft: AddCustomFieldType) => {
resetFieldTypeDrafts();
setFieldTypeModal(ft);
}, [resetFieldTypeDrafts]);
const handleFileChosen = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
setUploadFileName(file?.name);
setUploadAssetUrl(undefined);
setUploadFieldError(null);
if (!file || !onPersistCustomUploadFile) return;
setUploadFieldBusy(true);
try {
const { url } = await onPersistCustomUploadFile(file);
setUploadAssetUrl(url);
} catch {
setUploadFieldError(tUpload("errors.generic"));
} finally {
setUploadFieldBusy(false);
}
},
[onPersistCustomUploadFile, tUpload],
);
const handleClearPendingUpload = useCallback(() => {
setUploadFileName(undefined);
setUploadAssetUrl(undefined);
setUploadFieldError(null);
setUploadFieldBusy(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}, []);
const handleBadgeAddOption = useCallback((label: string) => {
setBadgeOptions((prev) =>
prev.includes(label) ? prev : [...prev, label],
);
}, []);
const appendFieldBlock = useCallback(() => {
if (!fieldTypeModal || !fieldModalStepValid) return;
const id = crypto.randomUUID();
let block: CustomMethodCardFieldBlock;
switch (fieldTypeModal) {
case "text":
block = {
kind: "text",
id,
blockTitle: textBlockTitle.trim(),
placeholderText: textPlaceholderBody,
};
break;
case "badges":
block = {
kind: "badges",
id,
blockTitle: badgeBlockTitle.trim(),
options: [...badgeOptions],
};
break;
case "upload":
block = {
kind: "upload",
id,
blockTitle: uploadBlockTitle.trim(),
fileName: uploadFileName,
...(uploadAssetUrl?.trim()
? { assetUrl: uploadAssetUrl.trim() }
: {}),
};
break;
default:
block = {
kind: "proportion",
id,
blockTitle: proportionBlockTitle.trim(),
defaultPercent: proportionDefault,
};
}
setDraftFieldBlocks((prev) => [...prev, block]);
setFieldTypeModal(null);
}, [
badgeBlockTitle,
badgeOptions,
fieldModalStepValid,
fieldTypeModal,
proportionBlockTitle,
proportionDefault,
textBlockTitle,
textPlaceholderBody,
uploadBlockTitle,
uploadFileName,
uploadAssetUrl,
]);
const handleNext = useCallback(() => {
if (fieldTypeModal) {
appendFieldBlock();
return;
}
if (!stepValid) return;
if (wizardStep === 3) {
onFinalize({
title: titleTrim,
description: descriptionTrim,
fieldBlocks: draftFieldBlocks,
});
dismiss();
return;
}
setWizardStep((s) => (s === 1 ? 2 : 3));
}, [
appendFieldBlock,
descriptionTrim,
dismiss,
draftFieldBlocks,
fieldTypeModal,
onFinalize,
stepValid,
titleTrim,
wizardStep,
]);
return (
<CustomMethodCardWizardView
isOpen={isOpen}
onDismiss={handleShellClose}
wizardStep={wizardStep}
title={shellTitle}
description={shellDescription}
policyTitle={policyTitle}
policyDescription={policyDescription}
addFieldExpanded={addFieldExpanded}
copy={copy}
maxChars={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
onPolicyTitleChange={setPolicyTitle}
onPolicyDescriptionChange={setPolicyDescription}
onPressAddCustomField={() => setAddFieldExpanded(true)}
onSelectFieldType={handleSelectFieldType}
fieldTypeModal={fieldTypeModal}
fieldBodiesCopy={fieldBodiesCopy}
fieldBodiesProps={{
textBlockTitle,
textPlaceholderBody,
onTextBlockTitleChange: setTextBlockTitle,
onTextPlaceholderBodyChange: setTextPlaceholderBody,
badgeBlockTitle,
badgeOptions,
onBadgeBlockTitleChange: setBadgeBlockTitle,
onBadgeAddOption: handleBadgeAddOption,
uploadBlockTitle,
onUploadBlockTitleChange: setUploadBlockTitle,
fileInputRef,
onFileChosen: handleFileChosen,
onClearPendingUpload: handleClearPendingUpload,
uploadAssetPreviewUrl: uploadAssetUrl,
uploadPersisting:
Boolean(fieldTypeModal === "upload" && uploadFieldBusy),
uploadBusyHint: tUpload("uploading"),
uploadErrorMessage:
fieldTypeModal === "upload" ? uploadFieldError : null,
proportionBlockTitle,
proportionDefault,
onProportionBlockTitleChange: setProportionBlockTitle,
onProportionDefaultChange: setProportionDefault,
}}
nextDisabled={shellNextDisabled}
nextLabel={nextLabel}
showBackButton
onBack={handleBack}
onNext={handleNext}
stepper={!fieldTypeModal}
draftFieldBlocks={draftFieldBlocks}
onDraftFieldBlocksReorder={setDraftFieldBlocks}
kebabMoreOptionsAriaLabel={menuCopy.triggerAriaLabel}
kebabMenuAriaLabel={menuCopy.menuAriaLabel}
kebabMenuItems={kebabMenuItems}
/>
);
},
);
CustomMethodCardWizardContainer.displayName = "CustomMethodCardWizard";
export default CustomMethodCardWizardContainer;
@@ -0,0 +1,148 @@
import type { RefObject } from "react";
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
import type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.types";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
export interface CustomMethodCardWizardFieldBodiesCopy {
requiredHint: string;
text: {
blockTitleLabel: string;
blockTitlePlaceholder: string;
placeholderLabel: string;
placeholderFieldPlaceholder: string;
};
badges: {
blockTitleLabel: string;
blockTitlePlaceholder: string;
optionsLabel: string;
addOptionLabel: string;
};
upload: {
blockTitleLabel: string;
blockTitlePlaceholder: string;
uploadFileInputAriaLabel: string;
uploadHint: string;
uploadPreviewImageAlt: string;
clearPendingUploadAriaLabel: string;
clearPendingUploadTooltip: string;
};
proportion: {
blockTitleLabel: string;
blockTitlePlaceholder: string;
defaultLabel: string;
decrementAriaLabel: string;
incrementAriaLabel: string;
};
}
export interface CustomMethodCardWizardCopy {
step1: { title: string; description: string; fieldPlaceholder: string };
step2: { title: string; description: string; fieldPlaceholder: string };
step3: { title: string; description: string };
step3BlocksList: {
listLabel: string;
dragHandleAriaLabel: string;
};
fieldTypeLabels: Record<AddCustomFieldType, string>;
footerFinalize: string;
fieldModals: {
addField: string;
requiredHint: string;
text: CustomMethodCardWizardFieldBodiesCopy["text"] & {
title: string;
description: string;
};
badges: CustomMethodCardWizardFieldBodiesCopy["badges"] & {
title: string;
description: string;
};
upload: CustomMethodCardWizardFieldBodiesCopy["upload"] & {
title: string;
description: string;
};
proportion: CustomMethodCardWizardFieldBodiesCopy["proportion"] & {
title: string;
description: string;
};
};
}
export interface CustomMethodCardWizardProps {
isOpen: boolean;
onClose: () => void;
/** Called when the user completes step 3; parent assigns id and persists state. */
onFinalize: (payload: {
title: string;
description: string;
fieldBlocks: CustomMethodCardFieldBlock[];
}) => void;
/**
* Persists custom-method upload files to `POST /api/uploads` (purpose
* `customMethodAttachment`). When omitted, upload field only stores `fileName`.
*/
onPersistCustomUploadFile?: (file: File) => Promise<{ url: string }>;
}
export interface CustomMethodCardWizardFieldBodiesViewProps {
fieldType: AddCustomFieldType;
copy: CustomMethodCardWizardFieldBodiesCopy;
textBlockTitle: string;
textPlaceholderBody: string;
onTextBlockTitleChange: (_v: string) => void;
onTextPlaceholderBodyChange: (_v: string) => void;
badgeBlockTitle: string;
badgeOptions: string[];
onBadgeBlockTitleChange: (_v: string) => void;
onBadgeAddOption: (_v: string) => void;
uploadBlockTitle: string;
onUploadBlockTitleChange: (_v: string) => void;
fileInputRef: RefObject<HTMLInputElement | null>;
onFileChosen: (e: React.ChangeEvent<HTMLInputElement>) => void;
/** Clears chosen file, preview URL, and related errors so the user can pick again. */
onClearPendingUpload: () => void;
/** When set after a successful upload, shows an inline image preview in the modal. */
uploadAssetPreviewUrl?: string | null;
/** Shown under the upload control while saving to the server. */
uploadPersisting?: boolean;
/** Replaces upload hint text while `uploadPersisting` is true. */
uploadBusyHint?: string;
uploadErrorMessage?: string | null;
proportionBlockTitle: string;
proportionDefault: number;
onProportionBlockTitleChange: (_v: string) => void;
onProportionDefaultChange: (_v: number) => void;
}
export interface CustomMethodCardWizardViewProps {
isOpen: boolean;
onDismiss: () => void;
wizardStep: 1 | 2 | 3;
title: string;
description: string;
policyTitle: string;
policyDescription: string;
addFieldExpanded: boolean;
copy: CustomMethodCardWizardCopy;
maxChars: number;
onPolicyTitleChange: (v: string) => void;
onPolicyDescriptionChange: (v: string) => void;
onPressAddCustomField: () => void;
onSelectFieldType: (t: AddCustomFieldType) => void;
fieldTypeModal: AddCustomFieldType | null;
fieldBodiesCopy: CustomMethodCardWizardFieldBodiesCopy;
fieldBodiesProps: Omit<
CustomMethodCardWizardFieldBodiesViewProps,
"fieldType" | "copy"
>;
draftFieldBlocks: CustomMethodCardFieldBlock[];
onDraftFieldBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void;
nextDisabled: boolean;
nextLabel: string;
showBackButton: boolean;
onBack: () => void;
onNext: () => void;
stepper: boolean;
kebabMoreOptionsAriaLabel: string;
kebabMenuAriaLabel: string;
kebabMenuItems: ModalHeaderMenuItem[];
}
@@ -0,0 +1,115 @@
"use client";
import { memo } from "react";
import Create from "../../../../components/modals/Create";
import InputWithCounter from "../../../../components/controls/InputWithCounter";
import TextArea from "../../../../components/controls/TextArea";
import AddCustomField from "../../../../components/controls/AddCustomField";
import { CustomMethodCardWizardFieldBodiesView } from "./CustomMethodCardWizardFieldBodies.view";
import { CustomMethodCardWizardBlocksList } from "./CustomMethodCardWizardBlocksList.container";
import type { CustomMethodCardWizardViewProps } from "./CustomMethodCardWizard.types";
function CustomMethodCardWizardViewComponent({
isOpen,
onDismiss,
wizardStep,
title,
description,
policyTitle,
policyDescription,
addFieldExpanded,
copy,
maxChars,
onPolicyTitleChange,
onPolicyDescriptionChange,
onPressAddCustomField,
onSelectFieldType,
fieldTypeModal,
fieldBodiesCopy,
fieldBodiesProps,
nextDisabled,
nextLabel,
showBackButton,
onBack,
onNext,
stepper,
draftFieldBlocks,
onDraftFieldBlocksReorder,
kebabMoreOptionsAriaLabel,
kebabMenuAriaLabel,
kebabMenuItems,
}: CustomMethodCardWizardViewProps) {
return (
<Create
isOpen={isOpen}
onClose={onDismiss}
title={title}
description={description}
showBackButton={showBackButton}
showNextButton
onBack={onBack}
onNext={onNext}
nextButtonText={nextLabel}
nextButtonDisabled={nextDisabled}
currentStep={wizardStep}
totalSteps={3}
stepper={stepper}
backdropVariant="blurredYellow"
kebabTriggerAriaLabel={kebabMoreOptionsAriaLabel}
kebabMenuAriaLabel={kebabMenuAriaLabel}
kebabMenuItems={kebabMenuItems}
>
{fieldTypeModal ? (
<CustomMethodCardWizardFieldBodiesView
fieldType={fieldTypeModal}
copy={fieldBodiesCopy}
{...fieldBodiesProps}
/>
) : null}
{!fieldTypeModal && wizardStep === 1 ? (
<InputWithCounter
placeholder={copy.step1.fieldPlaceholder}
value={policyTitle}
onChange={onPolicyTitleChange}
maxLength={maxChars}
/>
) : null}
{!fieldTypeModal && wizardStep === 2 ? (
<TextArea
appearance="default"
formHeader={false}
placeholder={copy.step2.fieldPlaceholder}
value={policyDescription}
maxLength={maxChars}
onChange={(e) => onPolicyDescriptionChange(e.target.value)}
textHint={`${policyDescription.length}/${maxChars}`}
className="w-full"
rows={4}
/>
) : null}
{!fieldTypeModal && wizardStep === 3 ? (
<div className="flex w-full flex-col gap-4">
{draftFieldBlocks.length > 0 ? (
<CustomMethodCardWizardBlocksList
blocks={draftFieldBlocks}
fieldTypeLabels={copy.fieldTypeLabels}
dragHandleAriaLabel={copy.step3BlocksList.dragHandleAriaLabel}
listLabel={copy.step3BlocksList.listLabel}
onBlocksReorder={onDraftFieldBlocksReorder}
/>
) : null}
<AddCustomField
active={addFieldExpanded}
onPressAdd={onPressAddCustomField}
onSelectFieldType={onSelectFieldType}
/>
</div>
) : null}
</Create>
);
}
export const CustomMethodCardWizardView = memo(
CustomMethodCardWizardViewComponent,
);
CustomMethodCardWizardView.displayName = "CustomMethodCardWizardView";
@@ -0,0 +1,77 @@
"use client";
import { memo, useCallback, useState, type DragEvent } from "react";
import { reorderCustomMethodCardFieldBlocks } from "../../../../../lib/create/reorderCustomMethodCardFieldBlocks";
import { CustomMethodCardWizardBlocksListView } from "./CustomMethodCardWizardBlocksList.view";
import type { CustomMethodCardWizardBlocksListProps } from "./CustomMethodCardWizardBlocksList.types";
function CustomMethodCardWizardBlocksListContainerComponent({
blocks,
fieldTypeLabels,
dragHandleAriaLabel,
listLabel,
onBlocksReorder,
}: CustomMethodCardWizardBlocksListProps) {
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
const [overIndex, setOverIndex] = useState<number | null>(null);
const clearDragUi = useCallback(() => {
setDraggingIndex(null);
setOverIndex(null);
}, []);
const handleDragStart = useCallback(
(index: number) => (e: DragEvent) => {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(index));
setDraggingIndex(index);
},
[],
);
const handleDragOver = useCallback((index: number) => {
return (e: DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setOverIndex(index);
};
}, []);
const handleDrop = useCallback(
(index: number) => (e: DragEvent) => {
e.preventDefault();
const from = Number.parseInt(e.dataTransfer.getData("text/plain"), 10);
if (Number.isNaN(from)) {
clearDragUi();
return;
}
onBlocksReorder(
reorderCustomMethodCardFieldBlocks(blocks, from, index),
);
clearDragUi();
},
[blocks, clearDragUi, onBlocksReorder],
);
return (
<CustomMethodCardWizardBlocksListView
blocks={blocks}
fieldTypeLabels={fieldTypeLabels}
dragHandleAriaLabel={dragHandleAriaLabel}
listLabel={listLabel}
onBlocksReorder={onBlocksReorder}
draggingIndex={draggingIndex}
overIndex={overIndex}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnd={clearDragUi}
/>
);
}
export const CustomMethodCardWizardBlocksList = memo(
CustomMethodCardWizardBlocksListContainerComponent,
);
CustomMethodCardWizardBlocksList.displayName =
"CustomMethodCardWizardBlocksList";
@@ -0,0 +1,21 @@
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import type { DragEvent } from "react";
export interface CustomMethodCardWizardBlocksListProps {
blocks: CustomMethodCardFieldBlock[];
fieldTypeLabels: Record<AddCustomFieldType, string>;
dragHandleAriaLabel: string;
listLabel: string;
onBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void;
}
export interface CustomMethodCardWizardBlocksListViewProps
extends CustomMethodCardWizardBlocksListProps {
draggingIndex: number | null;
overIndex: number | null;
onDragStart: (_index: number) => (_e: DragEvent) => void;
onDragOver: (_index: number) => (_e: DragEvent) => void;
onDrop: (_index: number) => (_e: DragEvent) => void;
onDragEnd: () => void;
}
@@ -0,0 +1,95 @@
"use client";
import { memo } from "react";
import Icon from "../../../../components/asset/icon";
import { ADD_CUSTOM_FIELD_TYPE_ICONS } from "../../../../components/controls/AddCustomField/AddCustomField.types";
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
import type { CustomMethodCardWizardBlocksListViewProps } from "./CustomMethodCardWizardBlocksList.types";
function DragHandleGlyph({ className }: { className?: string }) {
return (
<svg
width={16}
height={16}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
aria-hidden
>
<circle cx={4} cy={4} r={1.25} fill="currentColor" />
<circle cx={12} cy={4} r={1.25} fill="currentColor" />
<circle cx={4} cy={8} r={1.25} fill="currentColor" />
<circle cx={12} cy={8} r={1.25} fill="currentColor" />
<circle cx={4} cy={12} r={1.25} fill="currentColor" />
<circle cx={12} cy={12} r={1.25} fill="currentColor" />
</svg>
);
}
function CustomMethodCardWizardBlocksListViewComponent({
blocks,
fieldTypeLabels,
dragHandleAriaLabel,
listLabel,
draggingIndex,
overIndex,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
}: CustomMethodCardWizardBlocksListViewProps) {
return (
<ul className="flex list-none flex-col gap-2 p-0" aria-label={listLabel}>
{blocks.map((block, index) => {
const kind = block.kind as AddCustomFieldType;
const typeLabel = fieldTypeLabels[kind];
const isOver = overIndex === index && draggingIndex !== index;
return (
<li
key={block.id}
className={`flex min-h-[52px] items-stretch gap-2 rounded-[var(--measures-radius-medium,8px)] border border-[var(--color-border-default-primary)] bg-[var(--color-surface-default-secondary)] pl-1 pr-3 py-2 transition-shadow ${
isOver
? "ring-2 ring-[var(--color-border-invert-primary)] ring-offset-2 ring-offset-[var(--color-surface-default-primary)]"
: ""
} ${draggingIndex === index ? "opacity-60" : ""}`}
onDragOver={onDragOver(index)}
onDrop={onDrop(index)}
>
<button
type="button"
draggable
onDragStart={onDragStart(index)}
onDragEnd={onDragEnd}
className="flex shrink-0 cursor-grab touch-manipulation items-center justify-center rounded-[var(--measures-radius-200,8px)] border-0 bg-transparent px-1 text-[var(--color-content-default-secondary)] active:cursor-grabbing focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)]"
aria-label={dragHandleAriaLabel}
>
<DragHandleGlyph />
</button>
<span className="flex h-8 w-8 shrink-0 items-center justify-center self-center">
<Icon
name={ADD_CUSTOM_FIELD_TYPE_ICONS[kind]}
size={24}
className="text-[var(--color-content-default-brand-primary,#fefcc9)]"
/>
</span>
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
<span className="truncate font-inter text-[14px] font-medium leading-[18px] text-[var(--color-content-default-primary)]">
{block.blockTitle.trim() || typeLabel}
</span>
<span className="font-inter text-[12px] leading-4 text-[var(--color-content-default-secondary)]">
{typeLabel}
</span>
</div>
</li>
);
})}
</ul>
);
}
export const CustomMethodCardWizardBlocksListView = memo(
CustomMethodCardWizardBlocksListViewComponent,
);
CustomMethodCardWizardBlocksListView.displayName =
"CustomMethodCardWizardBlocksListView";
@@ -0,0 +1,213 @@
"use client";
import { memo } from "react";
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
import InputWithCounter from "../../../../components/controls/InputWithCounter";
import TextArea from "../../../../components/controls/TextArea";
import TextInput from "../../../../components/controls/TextInput";
import Upload from "../../../../components/controls/Upload";
import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
import InputLabel from "../../../../components/type/InputLabel";
import ApplicableScopeField from "../ApplicableScopeField";
import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants";
import type { CustomMethodCardWizardFieldBodiesViewProps } from "./CustomMethodCardWizard.types";
const TEXT_PLACEHOLDER_MAX = 8000;
function CustomMethodCardWizardFieldBodiesViewComponent({
fieldType,
copy,
textBlockTitle,
textPlaceholderBody,
onTextBlockTitleChange,
onTextPlaceholderBodyChange,
badgeBlockTitle,
badgeOptions,
onBadgeBlockTitleChange,
onBadgeAddOption,
uploadBlockTitle,
onUploadBlockTitleChange,
fileInputRef,
onFileChosen,
onClearPendingUpload,
uploadAssetPreviewUrl = null,
uploadPersisting = false,
uploadBusyHint,
uploadErrorMessage = null,
proportionBlockTitle,
proportionDefault,
onProportionBlockTitleChange,
onProportionDefaultChange,
}: CustomMethodCardWizardFieldBodiesViewProps) {
const uploadPreviewTrimmed = uploadAssetPreviewUrl?.trim() ?? "";
const hasUploadPreview = uploadPreviewTrimmed.length > 0;
if (fieldType === "text") {
return (
<div className="flex flex-col gap-[var(--spacing-scale-024)]">
<InputWithCounter
label={copy.text.blockTitleLabel}
placeholder={copy.text.blockTitlePlaceholder}
value={textBlockTitle}
onChange={onTextBlockTitleChange}
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
showHelpIcon
/>
<div className="flex flex-col gap-2">
<InputLabel
label={copy.text.placeholderLabel}
helpIcon
size="s"
palette="default"
/>
<TextArea
formHeader={false}
appearance="embedded"
value={textPlaceholderBody}
onChange={(e) => onTextPlaceholderBodyChange(e.target.value)}
maxLength={TEXT_PLACEHOLDER_MAX}
placeholder={copy.text.placeholderFieldPlaceholder}
textHint={`${textPlaceholderBody.length}/${TEXT_PLACEHOLDER_MAX}`}
className="w-full"
rows={3}
/>
</div>
</div>
);
}
if (fieldType === "badges") {
return (
<div className="flex flex-col gap-[var(--spacing-scale-024)]">
<div className="flex flex-col gap-2">
<InputLabel
label={copy.badges.blockTitleLabel}
helpIcon
helperText={copy.requiredHint}
size="s"
palette="default"
/>
<TextInput
formHeader={false}
placeholder={copy.badges.blockTitlePlaceholder}
value={badgeBlockTitle}
onChange={(e) => onBadgeBlockTitleChange(e.target.value)}
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
showHelpIcon={false}
/>
</div>
<ApplicableScopeField
label={copy.badges.optionsLabel}
addLabel={copy.badges.addOptionLabel}
scopes={badgeOptions}
selectedScopes={badgeOptions}
onToggleScope={() => {
/* product: all badge options stay selected */
}}
onAddScope={onBadgeAddOption}
/>
</div>
);
}
if (fieldType === "upload") {
return (
<div className="flex flex-col gap-[var(--spacing-scale-024)]">
<input
ref={fileInputRef}
type="file"
className="sr-only"
tabIndex={-1}
aria-label={copy.upload.uploadFileInputAriaLabel}
onChange={onFileChosen}
/>
<InputWithCounter
label={copy.upload.blockTitleLabel}
placeholder={copy.upload.blockTitlePlaceholder}
value={uploadBlockTitle}
onChange={onUploadBlockTitleChange}
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
showHelpIcon
/>
{hasUploadPreview ? (
<div className="relative inline-block max-w-full">
<button
type="button"
onClick={onClearPendingUpload}
className="absolute right-[8px] top-[8px] z-[1] flex h-[32px] w-[32px] cursor-pointer items-center justify-center rounded-full bg-[var(--color-surface-default-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]"
aria-label={copy.upload.clearPendingUploadAriaLabel}
title={copy.upload.clearPendingUploadTooltip}
>
{/* eslint-disable-next-line @next/next/no-img-element -- matches ModalHeader close control */}
<img
src={getAssetPath(ASSETS.ICON_CLOSE)}
alt=""
className="h-[16px] w-[16px]"
style={{
filter: "brightness(0) invert(1)",
}}
/>
</button>
{/* eslint-disable-next-line @next/next/no-img-element -- blob or same-origin upload URL */}
<img
src={uploadPreviewTrimmed}
alt={copy.upload.uploadPreviewImageAlt}
className="max-h-[160px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
/>
</div>
) : (
<Upload
active={!uploadPersisting}
hintText={
uploadPersisting && uploadBusyHint
? uploadBusyHint
: copy.upload.uploadHint
}
onClick={() => {
if (!uploadPersisting) fileInputRef.current?.click();
}}
/>
)}
{uploadErrorMessage ? (
<p
className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-s)] text-[var(--color-content-default-secondary)]"
role="alert"
>
{uploadErrorMessage}
</p>
) : null}
</div>
);
}
return (
<div className="flex flex-col gap-[var(--spacing-scale-024)]">
<InputWithCounter
label={copy.proportion.blockTitleLabel}
placeholder={copy.proportion.blockTitlePlaceholder}
value={proportionBlockTitle}
onChange={onProportionBlockTitleChange}
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
showHelpIcon
/>
<IncrementerBlock
label={copy.proportion.defaultLabel}
value={proportionDefault}
min={1}
max={100}
step={1}
onChange={onProportionDefaultChange}
formatValue={(v) => `${v}%`}
decrementAriaLabel={copy.proportion.decrementAriaLabel}
incrementAriaLabel={copy.proportion.incrementAriaLabel}
blockClassName="w-full"
/>
</div>
);
}
export const CustomMethodCardWizardFieldBodiesView = memo(
CustomMethodCardWizardFieldBodiesViewComponent,
);
CustomMethodCardWizardFieldBodiesView.displayName =
"CustomMethodCardWizardFieldBodiesView";
@@ -0,0 +1,2 @@
export { default } from "./CustomMethodCardWizard.container";
export type { CustomMethodCardWizardProps } from "./CustomMethodCardWizard.types";
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,111 @@
"use client";
/**
* Edit published rule: community description with the same 200-char limit as
* {@link CreateFlowScreenView} `community-context` step.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import Create from "../../../components/modals/Create";
import TextInput from "../../../components/controls/TextInput";
import ContentLockup from "../../../components/type/ContentLockup";
import { useTranslation } from "../../../contexts/MessagesContext";
/** Matches `community-context` step and `createFlowSchemas` communityContext.max(200). */
export const COMMUNITY_CONTEXT_FIELD_MAX_LENGTH = 200;
export interface FinalReviewCommunityContextEditModalProps {
isOpen: boolean;
onClose: () => void;
/** Current `communityContext` (trimmed for display; draft seeds from raw state in parent). */
initialValue: string;
onSave: (_value: string) => void;
}
export function FinalReviewCommunityContextEditModal({
isOpen,
onClose,
initialValue,
onSave,
}: FinalReviewCommunityContextEditModalProps) {
const tModal = useTranslation(
"create.reviewAndComplete.finalReview.communityContextEditModal",
);
const tField = useTranslation("create.community.communityContext");
const tSave = useTranslation(
"create.reviewAndComplete.finalReview.chipEditModal",
);
const [draft, setDraft] = useState("");
const initialRef = useRef("");
const seededOpenRef = useRef(false);
useEffect(() => {
if (!isOpen) {
seededOpenRef.current = false;
return;
}
if (seededOpenRef.current) return;
seededOpenRef.current = true;
const seed = initialValue;
setDraft(seed);
initialRef.current = seed;
}, [isOpen, initialValue]);
const isDirty = useMemo(
() => draft !== initialRef.current,
[draft],
);
const characterHint = tField("characterCountTemplate")
.replace("{current}", String(draft.length))
.replace("{max}", String(COMMUNITY_CONTEXT_FIELD_MAX_LENGTH));
const handleSave = () => {
if (!isDirty) return;
const trimmed = draft.trimEnd();
const capped = trimmed.slice(0, COMMUNITY_CONTEXT_FIELD_MAX_LENGTH);
onSave(capped);
onClose();
};
return (
<Create
isOpen={isOpen}
onClose={onClose}
backdropVariant="blurredYellow"
headerContent={
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup
title={tModal("title")}
description={tModal("description")}
variant="modal"
alignment="left"
/>
</div>
}
showBackButton={false}
showNextButton
nextButtonText={tSave("saveButton")}
nextButtonDisabled={!isDirty}
onNext={handleSave}
ariaLabel={tModal("title")}
>
<div className="pb-2">
<TextInput
className="!transition-none"
type="text"
placeholder={tField("placeholder")}
value={draft}
onChange={(e) => {
setDraft(e.target.value);
}}
inputSize="medium"
formHeader={false}
textHint={characterHint}
maxLength={COMMUNITY_CONTEXT_FIELD_MAX_LENGTH}
/>
</div>
</Create>
);
}
@@ -0,0 +1,109 @@
"use client";
/**
* Edit published rule: community name with the same 48-char limit as
* {@link CreateFlowTextFieldScreen} `community-name` step.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import Create from "../../../components/modals/Create";
import TextInput from "../../../components/controls/TextInput";
import ContentLockup from "../../../components/type/ContentLockup";
import { useTranslation } from "../../../contexts/MessagesContext";
/** Matches `community-name` step (`CreateFlowTextFieldScreen` `maxLength={48}`). */
export const COMMUNITY_TITLE_FIELD_MAX_LENGTH = 48;
export interface FinalReviewTitleEditModalProps {
isOpen: boolean;
onClose: () => void;
initialValue: string;
onSave: (_value: string) => void;
}
export function FinalReviewTitleEditModal({
isOpen,
onClose,
initialValue,
onSave,
}: FinalReviewTitleEditModalProps) {
const tModal = useTranslation(
"create.reviewAndComplete.finalReview.titleEditModal",
);
const tField = useTranslation("create.community.communityName");
const tSave = useTranslation(
"create.reviewAndComplete.finalReview.chipEditModal",
);
const [draft, setDraft] = useState("");
const initialRef = useRef("");
const seededOpenRef = useRef(false);
useEffect(() => {
if (!isOpen) {
seededOpenRef.current = false;
return;
}
if (seededOpenRef.current) return;
seededOpenRef.current = true;
const seed = initialValue;
setDraft(seed);
initialRef.current = seed;
}, [isOpen, initialValue]);
const isDirty = useMemo(() => draft !== initialRef.current, [draft]);
const trimmedDraft = draft.trim();
const canSave = isDirty && trimmedDraft.length > 0;
const characterHint = tField("characterCountTemplate")
.replace("{current}", String(draft.length))
.replace("{max}", String(COMMUNITY_TITLE_FIELD_MAX_LENGTH));
const handleSave = () => {
if (!canSave) return;
const capped = trimmedDraft.slice(0, COMMUNITY_TITLE_FIELD_MAX_LENGTH);
onSave(capped);
onClose();
};
return (
<Create
isOpen={isOpen}
onClose={onClose}
backdropVariant="blurredYellow"
headerContent={
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup
title={tModal("title")}
description={tModal("description")}
variant="modal"
alignment="left"
/>
</div>
}
showBackButton={false}
showNextButton
nextButtonText={tSave("saveButton")}
nextButtonDisabled={!canSave}
onNext={handleSave}
ariaLabel={tModal("title")}
>
<div className="pb-2">
<TextInput
className="!transition-none"
type="text"
placeholder={tField("placeholder")}
value={draft}
onChange={(e) => {
setDraft(e.target.value);
}}
inputSize="medium"
formHeader={false}
textHint={characterHint}
maxLength={COMMUNITY_TITLE_FIELD_MAX_LENGTH}
/>
</div>
</Create>
);
}
@@ -0,0 +1,52 @@
"use client";
/**
* Editable policy title + description for method-card Create modals in Customize mode.
* View mode continues to use {@link ContentLockup} via the `Create` modal defaults.
*/
import TextInput from "../../../components/controls/TextInput";
import ModalTextAreaField from "./ModalTextAreaField";
export interface MethodCardCustomizeModalHeaderProps {
titleLabel: string;
descriptionLabel: string;
titleValue: string;
descriptionValue: string;
onTitleChange: (_value: string) => void;
onDescriptionChange: (_value: string) => void;
/** @default 3 */
descriptionRows?: number;
/** When false, only the policy title row is rendered (core values rename). */
showDescription?: boolean;
}
export default function MethodCardCustomizeModalHeader({
titleLabel,
descriptionLabel,
titleValue,
descriptionValue,
onTitleChange,
onDescriptionChange,
descriptionRows = 3,
showDescription = true,
}: MethodCardCustomizeModalHeaderProps) {
return (
<div className="bg-[var(--color-surface-default-primary)] flex shrink-0 flex-col gap-4 px-[24px] py-[12px]">
<TextInput
label={titleLabel}
value={titleValue}
onChange={(e) => onTitleChange(e.target.value)}
inputSize="medium"
/>
{showDescription ? (
<ModalTextAreaField
label={descriptionLabel}
value={descriptionValue}
onChange={onDescriptionChange}
rows={descriptionRows}
/>
) : null}
</div>
);
}
@@ -0,0 +1,70 @@
"use client";
/**
* Shared "labelled text area" field used by every create flow modal section.
* Pairs an `InputLabel` (with help icon) with a `TextArea` set to the embedded
* appearance — matching the Figma "Control / Text Area" pattern.
*/
import { memo, useId } from "react";
import TextArea from "../../../components/controls/TextArea";
import InputLabel from "../../../components/type/InputLabel";
export interface ModalTextAreaFieldProps {
/** Label rendered above the text area. */
label: string;
/** Show the help "?" icon next to the label (default `true`). */
helpIcon?: boolean;
/** Current text value. */
value: string;
/** Fired on every change with the new value (no event). */
onChange: (_value: string) => void;
/** Optional rows for the underlying `<textarea>` (default 4). */
rows?: number;
/** Optional placeholder. */
placeholder?: string;
/** Disable the field. */
disabled?: boolean;
className?: string;
}
function ModalTextAreaFieldComponent({
label,
helpIcon = true,
value,
onChange,
rows = 4,
placeholder,
disabled = false,
className = "",
}: ModalTextAreaFieldProps) {
const labelId = useId();
return (
<div className={`flex flex-col gap-2 ${className}`.trim()}>
<div id={labelId}>
<InputLabel
label={label}
helpIcon={helpIcon}
size="s"
palette="default"
/>
</div>
<TextArea
formHeader={false}
value={value}
onChange={(e) => onChange(e.target.value)}
size="large"
rows={rows}
appearance="embedded"
placeholder={placeholder}
disabled={disabled}
aria-labelledby={labelId}
/>
</div>
);
}
ModalTextAreaFieldComponent.displayName = "ModalTextAreaField";
export default memo(ModalTextAreaFieldComponent);
@@ -0,0 +1,18 @@
/** Single column/section: full width under `md`, max 640px from `--breakpoint-md` up. */
export const CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS =
"w-full min-w-0 md:max-w-[640px]";
/** Grid cell: same cap as column max, centered when the track is wider than 640px. */
export const CREATE_FLOW_MD_UP_GRID_CELL_CLASS =
"w-full min-w-0 md:mx-auto md:max-w-[640px]";
/** Two 640px columns + `--measures-spacing-1200` (48px) gutter. */
export const CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS = "md:max-w-[1328px]";
/**
* Card-stack steps only (Figma compact card stack): wider than header lockup so the card grid /
* pyramid fits (max 860px). Header lockup stays {@link CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}.
* Cardcard gap uses `gap-2` in `CardStack` (same on mobile and md+).
*/
export const CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS =
"w-full min-w-0 md:max-w-[min(100%,860px)]";
@@ -0,0 +1,51 @@
import type { ModalHeaderMenuItem } from "../../../components/modals/ModalHeader/ModalHeader.types";
export interface CustomRuleModalKebabMenuCopy {
items: {
customize: string;
duplicate: string;
remove: string;
};
saveEdits: string;
}
export interface CustomRuleModalKebabHandlers {
showCustomize?: boolean;
onCustomize?: () => void;
onDuplicate?: () => void;
showRemove?: boolean;
onRemove?: () => void;
}
export function buildCustomRuleModalKebabMenu(
copy: CustomRuleModalKebabMenuCopy,
handlers: CustomRuleModalKebabHandlers,
): ModalHeaderMenuItem[] {
const items: ModalHeaderMenuItem[] = [];
if (handlers.showCustomize && handlers.onCustomize) {
items.push({
id: "customize",
label: copy.items.customize,
leadingIcon: "custom",
onClick: handlers.onCustomize,
});
}
if (handlers.onDuplicate) {
items.push({
id: "duplicate",
label: copy.items.duplicate,
leadingIcon: "content_copy",
onClick: handlers.onDuplicate,
});
}
if (handlers.showRemove && handlers.onRemove) {
items.push({
id: "remove",
label: copy.items.remove,
leadingIcon: "warning",
variant: "destructive",
onClick: handlers.onRemove,
});
}
return items;
}
@@ -0,0 +1,65 @@
"use client";
/**
* Controlled section editor for a communication-method chip. Used by both
* the custom-rule `communication-methods` add-method modal and the
* `final-review` chip edit modal — caller owns draft state and decides when
* to persist or discard.
*/
import { memo, useCallback } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import ModalTextAreaField from "../ModalTextAreaField";
import type { CommunicationMethodDetailEntry } from "../../types";
export interface CommunicationMethodEditFieldsProps {
value: CommunicationMethodDetailEntry;
onChange: (_next: CommunicationMethodDetailEntry) => void;
/** When true, fields are not editable (view mode). */
readOnly?: boolean;
}
const FIELDS: ReadonlyArray<keyof CommunicationMethodDetailEntry> = [
"corePrinciple",
"logisticsAdmin",
"codeOfConduct",
];
function CommunicationMethodEditFieldsComponent({
value,
onChange,
readOnly = false,
}: CommunicationMethodEditFieldsProps) {
const m = useMessages();
const t = m.create.customRule.communication;
const patch = useCallback(
<K extends keyof CommunicationMethodDetailEntry>(
key: K,
next: CommunicationMethodDetailEntry[K],
) => {
onChange({ ...value, [key]: next });
},
[value, onChange],
);
return (
<div className="flex flex-col gap-6">
{FIELDS.map((field) => (
<ModalTextAreaField
key={field}
label={t.sectionHeadings[field]}
rows={6}
value={value[field]}
onChange={(v) => patch(field, v)}
disabled={readOnly}
/>
))}
</div>
);
}
CommunicationMethodEditFieldsComponent.displayName =
"CommunicationMethodEditFields";
export default memo(CommunicationMethodEditFieldsComponent);
@@ -0,0 +1,96 @@
"use client";
/**
* Controlled section editor for a conflict-management chip. Used by both the
* custom-rule `conflict-management` add-method modal and the `final-review`
* chip edit modal. Caller owns draft state and persistence.
*/
import { memo, useCallback } from "react";
import { formatConflictApplicableScopeForTextarea } from "../../../../../lib/create/ruleSectionsFromMethodSelections";
import { useMessages } from "../../../../contexts/MessagesContext";
import ModalTextAreaField from "../ModalTextAreaField";
import type { ConflictManagementDetailEntry } from "../../types";
function conflictScopeTextareaValue(value: ConflictManagementDetailEntry): string {
return formatConflictApplicableScopeForTextarea(
value.selectedApplicableScope,
value.applicableScope,
);
}
function conflictDetailWithScopeTextarea(
value: ConflictManagementDetailEntry,
text: string,
): ConflictManagementDetailEntry {
const lines = text
.split("\n")
.map((s) => s.trim())
.filter((s) => s.length > 0);
return {
...value,
applicableScope: lines,
selectedApplicableScope: [...lines],
};
}
export interface ConflictManagementEditFieldsProps {
value: ConflictManagementDetailEntry;
onChange: (_next: ConflictManagementDetailEntry) => void;
readOnly?: boolean;
}
function ConflictManagementEditFieldsComponent({
value,
onChange,
readOnly = false,
}: ConflictManagementEditFieldsProps) {
const m = useMessages();
const t = m.create.customRule.conflictManagement;
const patch = useCallback(
<K extends keyof ConflictManagementDetailEntry>(
key: K,
next: ConflictManagementDetailEntry[K],
) => {
onChange({ ...value, [key]: next });
},
[value, onChange],
);
return (
<div className="flex flex-col gap-6">
<ModalTextAreaField
label={t.sectionHeadings.corePrinciple}
value={value.corePrinciple}
onChange={(v) => patch("corePrinciple", v)}
disabled={readOnly}
/>
<ModalTextAreaField
label={t.sectionHeadings.applicableScope}
value={conflictScopeTextareaValue(value)}
placeholder={t.applicableScopePlaceholder}
onChange={(v) => onChange(conflictDetailWithScopeTextarea(value, v))}
rows={4}
disabled={readOnly}
/>
<ModalTextAreaField
label={t.sectionHeadings.processProtocol}
value={value.processProtocol}
onChange={(v) => patch("processProtocol", v)}
disabled={readOnly}
/>
<ModalTextAreaField
label={t.sectionHeadings.restorationFallbacks}
value={value.restorationFallbacks}
onChange={(v) => patch("restorationFallbacks", v)}
disabled={readOnly}
/>
</div>
);
}
ConflictManagementEditFieldsComponent.displayName =
"ConflictManagementEditFields";
export default memo(ConflictManagementEditFieldsComponent);
@@ -0,0 +1,62 @@
"use client";
/**
* Controlled meaning/signals field set for a core-value chip. Rendered both
* by `core-values` (custom-rule selection step) and `final-review` (chip
* edit modal). Holds no state — the parent owns the draft and decides when
* to persist (`updateState`) or discard.
*/
import { memo, useCallback } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import ModalTextAreaField from "../ModalTextAreaField";
import type { CoreValueDetailEntry } from "../../types";
export interface CoreValueEditFieldsProps {
value: CoreValueDetailEntry;
onChange: (_next: CoreValueDetailEntry) => void;
/** View mode until the user taps **Customize**. */
readOnly?: boolean;
}
function CoreValueEditFieldsComponent({
value,
onChange,
readOnly = false,
}: CoreValueEditFieldsProps) {
const m = useMessages();
const t = m.create.customRule.coreValues.detailModal;
const patch = useCallback(
<K extends keyof CoreValueDetailEntry>(
key: K,
next: CoreValueDetailEntry[K],
) => {
onChange({ ...value, [key]: next });
},
[value, onChange],
);
return (
<div className="flex flex-col gap-[var(--measures-spacing-600,24px)] pb-2">
<ModalTextAreaField
label={t.meaningLabel}
value={value.meaning}
onChange={(v) => patch("meaning", v)}
rows={4}
disabled={readOnly}
/>
<ModalTextAreaField
label={t.signalsLabel}
value={value.signals}
onChange={(v) => patch("signals", v)}
rows={4}
disabled={readOnly}
/>
</div>
);
}
CoreValueEditFieldsComponent.displayName = "CoreValueEditFields";
export default memo(CoreValueEditFieldsComponent);
@@ -0,0 +1,102 @@
"use client";
/**
* Controlled section editor for a decision-approach chip. Used by both the
* custom-rule `decision-approaches` add-method modal and the `final-review`
* chip edit modal. Caller owns draft state — Confirm/Save persistence and
* `markCreateFlowInteraction` live in the parent.
*/
import { memo, useCallback } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import ModalTextAreaField from "../ModalTextAreaField";
import ApplicableScopeField from "../ApplicableScopeField";
import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
import type { DecisionApproachDetailEntry } from "../../types";
export interface DecisionApproachEditFieldsProps {
value: DecisionApproachDetailEntry;
onChange: (_next: DecisionApproachDetailEntry) => void;
readOnly?: boolean;
}
const CONSENSUS_LEVEL_MIN = 0;
const CONSENSUS_LEVEL_MAX = 100;
const CONSENSUS_LEVEL_STEP = 5;
function DecisionApproachEditFieldsComponent({
value,
onChange,
readOnly = false,
}: DecisionApproachEditFieldsProps) {
const m = useMessages();
const t = m.create.customRule.decisionApproaches;
const patch = useCallback(
<K extends keyof DecisionApproachDetailEntry>(
key: K,
next: DecisionApproachDetailEntry[K],
) => {
onChange({ ...value, [key]: next });
},
[value, onChange],
);
return (
<div className="flex flex-col gap-6">
<ModalTextAreaField
label={t.sectionHeadings.corePrinciple}
value={value.corePrinciple}
onChange={(v) => patch("corePrinciple", v)}
disabled={readOnly}
/>
<ApplicableScopeField
label={t.sectionHeadings.applicableScope}
addLabel={t.scopeAddButtonLabel}
scopes={value.applicableScope}
selectedScopes={value.selectedApplicableScope}
readOnly={readOnly}
onToggleScope={(scope) =>
patch(
"selectedApplicableScope",
value.selectedApplicableScope.includes(scope)
? value.selectedApplicableScope.filter((s) => s !== scope)
: [...value.selectedApplicableScope, scope],
)
}
onAddScope={(scope) =>
patch("applicableScope", [...value.applicableScope, scope])
}
/>
<ModalTextAreaField
label={t.sectionHeadings.stepByStepInstructions}
value={value.stepByStepInstructions}
onChange={(v) => patch("stepByStepInstructions", v)}
disabled={readOnly}
/>
<IncrementerBlock
label={t.sectionHeadings.consensusLevel}
value={value.consensusLevel}
min={CONSENSUS_LEVEL_MIN}
max={CONSENSUS_LEVEL_MAX}
step={CONSENSUS_LEVEL_STEP}
onChange={(next) => patch("consensusLevel", next)}
formatValue={(v) => `${v}%`}
decrementAriaLabel="Decrease consensus level"
incrementAriaLabel="Increase consensus level"
disabled={readOnly}
/>
<ModalTextAreaField
label={t.sectionHeadings.objectionsDeadlocks}
value={value.objectionsDeadlocks}
onChange={(v) => patch("objectionsDeadlocks", v)}
disabled={readOnly}
/>
</div>
);
}
DecisionApproachEditFieldsComponent.displayName =
"DecisionApproachEditFields";
export default memo(DecisionApproachEditFieldsComponent);
@@ -0,0 +1,64 @@
"use client";
/**
* Controlled section editor for a membership-method chip. Used by both the
* custom-rule `membership-methods` add-method modal and the `final-review`
* chip edit modal — caller owns draft state and decides when to persist or
* discard.
*/
import { memo, useCallback } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import ModalTextAreaField from "../ModalTextAreaField";
import type { MembershipMethodDetailEntry } from "../../types";
export interface MembershipMethodEditFieldsProps {
value: MembershipMethodDetailEntry;
onChange: (_next: MembershipMethodDetailEntry) => void;
readOnly?: boolean;
}
const FIELDS: ReadonlyArray<keyof MembershipMethodDetailEntry> = [
"eligibility",
"joiningProcess",
"expectations",
];
function MembershipMethodEditFieldsComponent({
value,
onChange,
readOnly = false,
}: MembershipMethodEditFieldsProps) {
const m = useMessages();
const t = m.create.customRule.membership;
const patch = useCallback(
<K extends keyof MembershipMethodDetailEntry>(
key: K,
next: MembershipMethodDetailEntry[K],
) => {
onChange({ ...value, [key]: next });
},
[value, onChange],
);
return (
<div className="flex flex-col gap-6">
{FIELDS.map((field) => (
<ModalTextAreaField
key={field}
label={t.sectionHeadings[field]}
rows={6}
value={value[field]}
onChange={(v) => patch(field, v)}
disabled={readOnly}
/>
))}
</div>
);
}
MembershipMethodEditFieldsComponent.displayName =
"MembershipMethodEditFields";
export default memo(MembershipMethodEditFieldsComponent);
@@ -0,0 +1,14 @@
export { default as CoreValueEditFields } from "./CoreValueEditFields";
export type { CoreValueEditFieldsProps } from "./CoreValueEditFields";
export { default as CommunicationMethodEditFields } from "./CommunicationMethodEditFields";
export type { CommunicationMethodEditFieldsProps } from "./CommunicationMethodEditFields";
export { default as MembershipMethodEditFields } from "./MembershipMethodEditFields";
export type { MembershipMethodEditFieldsProps } from "./MembershipMethodEditFields";
export { default as DecisionApproachEditFields } from "./DecisionApproachEditFields";
export type { DecisionApproachEditFieldsProps } from "./DecisionApproachEditFields";
export { default as ConflictManagementEditFields } from "./ConflictManagementEditFields";
export type { ConflictManagementEditFieldsProps } from "./ConflictManagementEditFields";
@@ -0,0 +1,230 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import type {
CreateFlowMethodCardFacetSection,
CreateFlowState,
CreateFlowContextValue,
CreateFlowStep,
} from "../types";
import {
clearAnonymousCreateFlowStorage,
clearLegacyCreateFlowKeysOnce,
hasTransferPendingFlag,
readAnonymousCreateFlowState,
writeAnonymousCreateFlowState,
} from "../utils/anonymousDraftStorage";
import { stripCustomRuleSelectionFields } from "../../../../lib/create/stripCustomRuleSelectionFields";
import {
clearCoreValueDetailsLocalStorage,
readCoreValueDetailsFromLocalStorage,
writeCoreValueDetailsToLocalStorage,
} from "../utils/coreValueDetailsLocalStorage";
const CreateFlowContext = createContext<CreateFlowContextValue | null>(null);
interface CreateFlowProviderProps {
children: ReactNode;
initialStep?: CreateFlowStep | null;
/**
* When true (session resolved, guest or signed-in), mirror in-flight draft to
* `create-flow-anonymous` in localStorage so refresh / dev-restart never wipes
* progress. When false, in-memory only (e.g. unit tests, pre-session-resolve).
*
* Signed-in users additionally get an explicit "Save & Exit" that PUTs to the
* server (`useCreateFlowExit`); the server draft is the cross-device snapshot,
* localStorage is the on-every-keystroke buffer.
*/
enableLocalDraftMirroring?: boolean;
}
/**
* Create flow state. All users mirror in-flight state to localStorage when
* `enableLocalDraftMirroring` is true; signed-in users layer an explicit
* server-draft snapshot on top via {@link useCreateFlowExit}.
*/
export function CreateFlowProvider({
children,
initialStep = null,
enableLocalDraftMirroring = false,
}: CreateFlowProviderProps) {
const [state, setState] = useState<CreateFlowState>(() => {
const base = enableLocalDraftMirroring
? readAnonymousCreateFlowState()
: {};
const storedDetails = readCoreValueDetailsFromLocalStorage();
if (Object.keys(storedDetails).length === 0) return base;
return {
...base,
coreValueDetailsByChipId: {
...storedDetails,
...(base.coreValueDetailsByChipId ?? {}),
},
};
});
const [interactionTouched, setInteractionTouched] = useState(false);
const [currentStep] = useState<CreateFlowStep | null>(initialStep);
const prevPersistRef = useRef(enableLocalDraftMirroring);
const persistWriteSkipRef = useRef(true);
useEffect(() => {
clearLegacyCreateFlowKeysOnce();
}, []);
// Session resolved after initial paint: hydrate from localStorage, merging
// with anything already in state. We can't bail on `prev` being non-empty:
// the initializer pre-populates `coreValueDetailsByChipId` from a separate
// localStorage key, so `prev` is virtually always non-empty here.
// Merge strategy: `prev` wins for fields the user might have touched between
// mount and session-resolve; `from` fills in anything else; coreValueDetails
// is union-merged (prev wins per chip id since it loaded from the dedicated
// `create-flow-core-value-details` key).
useEffect(() => {
if (!enableLocalDraftMirroring) {
prevPersistRef.current = false;
return;
}
const wasOff = !prevPersistRef.current;
prevPersistRef.current = true;
if (!wasOff) return;
if (hasTransferPendingFlag()) return;
if (
typeof window !== "undefined" &&
new URLSearchParams(window.location.search).get("syncDraft") === "1"
) {
return;
}
const from = readAnonymousCreateFlowState();
if (Object.keys(from).length === 0) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- hydrate local draft when mirroring turns on
setState((prev) => {
const merged: CreateFlowState = { ...from, ...prev };
const fromDetails = from.coreValueDetailsByChipId;
const prevDetails = prev.coreValueDetailsByChipId;
if (fromDetails || prevDetails) {
merged.coreValueDetailsByChipId = {
...(fromDetails ?? {}),
...(prevDetails ?? {}),
};
}
return merged;
});
}, [enableLocalDraftMirroring]);
useEffect(() => {
if (!enableLocalDraftMirroring) {
// Reset so the next OFF→ON transition skips its first write again.
persistWriteSkipRef.current = true;
return;
}
// Skip the very first write that runs on the same render where mirroring
// turned ON — the hydrate effect (above) is racing to setState the loaded
// draft, and writing the still-empty pre-hydrate state here would clobber
// localStorage. The next render (with the hydrated state) will write
// normally. Without this guard, drafts get wiped during HMR / any
// auth-session refetch that re-toggles `enableLocalDraftMirroring`.
if (persistWriteSkipRef.current) {
persistWriteSkipRef.current = false;
return;
}
writeAnonymousCreateFlowState(state);
}, [state, enableLocalDraftMirroring]);
/** Meaning/signals for core values: survives refresh for signed-in users; merged with anonymous draft when both exist. */
useEffect(() => {
writeCoreValueDetailsToLocalStorage(state.coreValueDetailsByChipId);
}, [state.coreValueDetailsByChipId]);
const markCreateFlowInteraction = useCallback(() => {
setInteractionTouched(true);
}, []);
const setMethodSectionsPinCommitted = useCallback(
(section: CreateFlowMethodCardFacetSection, committed: boolean) => {
setState((prevState) => ({
...prevState,
methodSectionsPinCommitted: {
...(prevState.methodSectionsPinCommitted ?? {}),
[section]: committed,
},
}));
},
[],
);
const updateState = useCallback((updates: Partial<CreateFlowState>) => {
setState((prevState) => {
const merged: CreateFlowState = { ...prevState, ...updates };
if (updates.communityStructureChipSnapshots !== undefined) {
merged.communityStructureChipSnapshots = {
...(prevState.communityStructureChipSnapshots ?? {}),
...updates.communityStructureChipSnapshots,
};
}
if (updates.coreValueDetailsByChipId !== undefined) {
merged.coreValueDetailsByChipId = {
...(prevState.coreValueDetailsByChipId ?? {}),
...updates.coreValueDetailsByChipId,
};
}
return merged;
});
}, []);
const replaceState = useCallback(
(next: CreateFlowState | ((prev: CreateFlowState) => CreateFlowState)) => {
setState(next);
},
[],
);
const clearState = useCallback(() => {
setState({});
setInteractionTouched(false);
clearAnonymousCreateFlowStorage();
clearCoreValueDetailsLocalStorage();
}, []);
// Keys cleared here match `STRIP_CUSTOM_RULE_SELECTION_STATE_KEYS` from
// `lib/create/customRuleFacets.ts` (CUSTOM_RULE_FACETS / CR-92).
const resetCustomRuleSelections = useCallback(() => {
setState((prev) => stripCustomRuleSelectionFields(prev));
// Effect on `state.coreValueDetailsByChipId` clears its dedicated
// localStorage key when the field goes undefined, so we don't need to
// touch `clearCoreValueDetailsLocalStorage()` directly here.
}, []);
const contextValue: CreateFlowContextValue = {
state,
currentStep,
updateState,
replaceState,
clearState,
resetCustomRuleSelections,
setMethodSectionsPinCommitted,
interactionTouched,
markCreateFlowInteraction,
};
return (
<CreateFlowContext.Provider value={contextValue}>
{children}
</CreateFlowContext.Provider>
);
}
export function useCreateFlow(): CreateFlowContextValue {
const context = useContext(CreateFlowContext);
if (!context) {
throw new Error("useCreateFlow must be used within CreateFlowProvider");
}
return context;
}
@@ -0,0 +1,51 @@
"use client";
import {
createContext,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
type CreateFlowDraftSaveBannerContextValue = {
draftSaveBannerMessage: string | null;
setDraftSaveBannerMessage: (_message: string | null) => void;
};
const CreateFlowDraftSaveBannerContext =
createContext<CreateFlowDraftSaveBannerContextValue | null>(null);
export function CreateFlowDraftSaveBannerProvider({
children,
}: {
children: ReactNode;
}) {
const [draftSaveBannerMessage, setDraftSaveBannerMessage] = useState<
string | null
>(null);
const value = useMemo(
() => ({
draftSaveBannerMessage,
setDraftSaveBannerMessage,
}),
[draftSaveBannerMessage],
);
return (
<CreateFlowDraftSaveBannerContext.Provider value={value}>
{children}
</CreateFlowDraftSaveBannerContext.Provider>
);
}
export function useCreateFlowDraftSaveBanner(): CreateFlowDraftSaveBannerContextValue {
const ctx = useContext(CreateFlowDraftSaveBannerContext);
if (!ctx) {
throw new Error(
"useCreateFlowDraftSaveBanner must be used within CreateFlowDraftSaveBannerProvider",
);
}
return ctx;
}
@@ -0,0 +1,374 @@
"use client";
import { useCallback } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import { readLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
import {
buildMailtoShareHref,
buildSlackWebShareUrl,
DISCORD_NATIVE_DM_HUB_URL,
DISCORD_WEB_DM_HUB_URL,
scheduleNativeSchemeThenFallback,
SLACK_NATIVE_OPEN_URL,
type NativeFallbackTimers,
type NativeNavigateDeps,
} from "../../../../lib/create/shareChannels";
import {
buildPublicRuleUrl,
downloadStoredRuleAsPdf,
downloadTextFile,
exportFilenameBase,
exportStoredRuleAsCsv,
exportStoredRuleAsMarkdown,
} from "../../../../lib/create/ruleExport";
export type CompletedFlowActionBanner = {
key: string;
status: "positive" | "danger";
title: string;
description?: string;
};
function browserNativeShareNavigateDeps(win: Window): NativeNavigateDeps {
return {
assignLocationHref: (url: string): void => {
// Transient <a>: same-tab custom-protocol handshake as location.href without replacing the SPA.
const anchor = win.document.createElement("a");
anchor.href = url;
anchor.rel = "noreferrer noopener";
anchor.style.position = "absolute";
anchor.style.left = "-9999px";
win.document.body.appendChild(anchor);
anchor.click();
anchor.remove();
},
getVisibilityState: (): Document["visibilityState"] =>
win.document.visibilityState,
onVisibilityChange: (listener: () => void): void => {
win.document.addEventListener("visibilitychange", listener);
},
offVisibilityChange: (listener: () => void): void => {
win.document.removeEventListener("visibilitychange", listener);
},
};
}
function browserNativeTimers(win: Window): NativeFallbackTimers {
return {
setTimeout: (cb: () => void, ms: number): unknown => win.setTimeout(cb, ms),
clearTimeout: (handle: unknown): void =>
win.clearTimeout(
handle as ReturnType<typeof win.setTimeout>,
),
};
}
/**
* After native app handoff, the page can stay `visibilityState === "visible"` while
* focus moves to the other app. Skip clipboard fallbacks in that case to avoid
* `NotAllowedError` noise when Slack/compose already succeeded.
*/
function shouldSkipShareClipboardFallback(win: Window): boolean {
return (
win.document.visibilityState === "hidden" || !win.document.hasFocus()
);
}
function resolvePublishedRuleShareContext(windowObj: Window): {
url: string;
title: string;
text: string;
} | null {
const rule = readLastPublishedRule();
if (!rule) return null;
const url = buildPublicRuleUrl(windowObj.location.origin, rule.id);
const summary =
typeof rule.summary === "string" ? rule.summary.trim() : "";
const text = summary.length > 0 ? summary : rule.title;
return { url, title: rule.title, text };
}
/**
* Share / export handlers for the completed step (`readLastPublishedRule`).
*/
export function useCompletedRuleShareExport({
setActionBanner,
}: {
setActionBanner: (_: CompletedFlowActionBanner | null) => void;
}): {
copyPublishedRuleLink: () => Promise<void>;
mailtoPublishedRule: () => void;
sharePublishedRuleViaSignal: () => Promise<void>;
sharePublishedRuleViaSlack: () => Promise<void>;
sharePublishedRuleViaDiscord: () => Promise<void>;
onSelectExportFormat: (_format: "pdf" | "csv" | "markdown") => void;
} {
const t = useTranslation("create.reviewAndComplete.completed");
const bannerNoRule = useCallback(() => {
setActionBanner({
key: "completedShareNoRule",
status: "danger",
title: t("shareNoRuleTitle"),
description: t("shareNoRuleDescription"),
});
}, [setActionBanner, t]);
const bannerCopied = useCallback(() => {
setActionBanner({
key: "completedShareCopied",
status: "positive",
title: t("shareLinkCopiedTitle"),
description: t("shareLinkCopiedDescription"),
});
}, [setActionBanner, t]);
const bannerCopyFailed = useCallback(() => {
setActionBanner({
key: "completedShareCopyFailed",
status: "danger",
title: t("shareCopyFailedTitle"),
description: t("shareCopyFailedDescription"),
});
}, [setActionBanner, t]);
const copyUrlToClipboard = useCallback(
async (
url: string,
banner?: () => void,
options?: { suppressFailureWhenDocumentNotFocused?: boolean },
) => {
try {
await navigator.clipboard.writeText(url);
(banner ?? bannerCopied)();
} catch {
if (
options?.suppressFailureWhenDocumentNotFocused === true &&
typeof window !== "undefined" &&
shouldSkipShareClipboardFallback(window)
) {
return;
}
bannerCopyFailed();
}
},
[bannerCopied, bannerCopyFailed],
);
const copyPublishedRuleLink = useCallback(async () => {
if (typeof window === "undefined") return;
const ctx = resolvePublishedRuleShareContext(window);
if (!ctx) {
bannerNoRule();
return;
}
await copyUrlToClipboard(ctx.url);
}, [bannerNoRule, copyUrlToClipboard]);
const mailtoPublishedRule = useCallback(() => {
if (typeof window === "undefined") return;
const ctx = resolvePublishedRuleShareContext(window);
if (!ctx) {
bannerNoRule();
return;
}
const body = `${ctx.text}\n\n${ctx.url}`;
window.location.href = buildMailtoShareHref({
subject: ctx.title,
body,
});
}, [bannerNoRule]);
const tryNavigatorShareAbortOk = useCallback(
async (data: ShareData): Promise<boolean> => {
if (typeof navigator.share !== "function") return false;
const can =
typeof navigator.canShare !== "function" || navigator.canShare(data);
if (!can) return false;
try {
await navigator.share(data);
return true;
} catch (e) {
const err = e as { name?: string };
if (err?.name === "AbortError") return true;
return false;
}
},
[],
);
/** Prefer URL-only share data when the platform allows it (common on mobile). */
const shareViaWebShareApiOrFalse = useCallback(
async (ctx: { url: string; title: string; text: string }) => {
const urlOnly: ShareData = { url: ctx.url };
if (await tryNavigatorShareAbortOk(urlOnly)) return true;
const full: ShareData = {
title: ctx.title,
text: ctx.text,
url: ctx.url,
};
return tryNavigatorShareAbortOk(full);
},
[tryNavigatorShareAbortOk],
);
const sharePublishedRuleViaSignal = useCallback(async () => {
if (typeof window === "undefined") return;
const ctx = resolvePublishedRuleShareContext(window);
if (!ctx) {
bannerNoRule();
return;
}
if (await shareViaWebShareApiOrFalse(ctx)) return;
await copyUrlToClipboard(ctx.url);
}, [bannerNoRule, copyUrlToClipboard, shareViaWebShareApiOrFalse]);
const sharePublishedRuleViaSlack = useCallback(async () => {
if (typeof window === "undefined") return;
const ctx = resolvePublishedRuleShareContext(window);
if (!ctx) {
bannerNoRule();
return;
}
const runSlackWebComposeFallback = async (): Promise<void> => {
const slackUrl = buildSlackWebShareUrl(ctx.url);
const popup = window.open(
slackUrl,
"_blank",
"noopener,noreferrer",
);
if (popup) return;
if (shouldSkipShareClipboardFallback(window)) return;
if (await shareViaWebShareApiOrFalse(ctx)) return;
if (shouldSkipShareClipboardFallback(window)) return;
await copyUrlToClipboard(
ctx.url,
() =>
setActionBanner({
key: "completedShareSlackFallback",
status: "positive",
title: t("shareSlackFallbackTitle"),
description: t("shareSlackFallbackDescription"),
}),
{ suppressFailureWhenDocumentNotFocused: true },
);
};
scheduleNativeSchemeThenFallback(
SLACK_NATIVE_OPEN_URL,
() => void runSlackWebComposeFallback(),
browserNativeShareNavigateDeps(window),
browserNativeTimers(window),
);
}, [
bannerNoRule,
copyUrlToClipboard,
shareViaWebShareApiOrFalse,
setActionBanner,
t,
]);
const sharePublishedRuleViaDiscord = useCallback(async () => {
if (typeof window === "undefined") return;
const ctx = resolvePublishedRuleShareContext(window);
if (!ctx) {
bannerNoRule();
return;
}
if (await shareViaWebShareApiOrFalse(ctx)) return;
try {
await navigator.clipboard.writeText(ctx.url);
setActionBanner({
key: "completedShareDiscordPaste",
status: "positive",
title: t("shareDiscordPasteTitle"),
description: t("shareDiscordPasteDescription"),
});
} catch {
bannerCopyFailed();
}
scheduleNativeSchemeThenFallback(
DISCORD_NATIVE_DM_HUB_URL,
() =>
void window.open(
DISCORD_WEB_DM_HUB_URL,
"_blank",
"noopener,noreferrer",
),
browserNativeShareNavigateDeps(window),
browserNativeTimers(window),
);
}, [
bannerCopyFailed,
bannerNoRule,
shareViaWebShareApiOrFalse,
setActionBanner,
t,
]);
const onSelectExportFormat = useCallback(
(format: "pdf" | "csv" | "markdown") => {
if (typeof window === "undefined") return;
const rule = readLastPublishedRule();
if (!rule) {
setActionBanner({
key: "completedExportNoRule",
status: "danger",
title: t("shareNoRuleTitle"),
description: t("shareNoRuleDescription"),
});
return;
}
const base = exportFilenameBase(rule);
try {
if (format === "pdf") {
downloadStoredRuleAsPdf(rule);
} else if (format === "csv") {
const csv = exportStoredRuleAsCsv(rule);
downloadTextFile(
`${base}-community-rule.csv`,
csv,
"text/csv;charset=utf-8",
);
} else {
const md = exportStoredRuleAsMarkdown(rule);
downloadTextFile(
`${base}-community-rule.md`,
md,
"text/markdown;charset=utf-8",
);
}
} catch (e) {
const msg = e instanceof Error && e.message === "exportEmptyDocument";
setActionBanner({
key: "completedExportFailed",
status: "danger",
title: msg ? t("exportEmptyDocumentTitle") : t("exportFailedTitle"),
description: msg
? t("exportEmptyDocumentDescription")
: t("exportFailedDescription"),
});
}
},
[setActionBanner, t],
);
return {
copyPublishedRuleLink,
mailtoPublishedRule,
sharePublishedRuleViaSignal,
sharePublishedRuleViaSlack,
sharePublishedRuleViaDiscord,
onSelectExportFormat,
};
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { useCallback } from "react";
import type { CreateFlowState, CreateFlowStep } from "../types";
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
import { saveDraftToServer, updatePublishedRule } from "../../../../lib/create/api";
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
import { isBackendSyncEnabled } from "../../../../lib/create/backendSyncEnabled";
import messages from "../../../../messages/en/index";
export type CreateFlowExitClearState = () => void;
type AppRouterLike = { push: (_href: string) => void };
/**
* Leave the create flow for a **signed-in** user. Caller must not invoke for anonymous users.
*/
export function useCreateFlowExit({
state,
currentStep,
clearState,
router,
user,
setDraftSaveBannerMessage,
confirmLeave,
}: {
state: CreateFlowState;
currentStep: CreateFlowStep | null;
clearState: CreateFlowExitClearState;
router: AppRouterLike;
user: { id: string; email: string } | null;
/** When save fails, surface the server message in the create shell banner (no leave confirm). */
setDraftSaveBannerMessage?: (_message: string | null) => void;
/** When exit would discard unsaved work, return true to proceed. Defaults to denying leave. */
confirmLeave?: () => Promise<boolean>;
}): (_options?: { saveDraft?: boolean }) => Promise<void> {
return useCallback(
async (options?: { saveDraft?: boolean }) => {
if (!user) return;
const saveDraft = options?.saveDraft ?? false;
if (!saveDraft) {
const confirmFn = confirmLeave ?? (async () => false);
const confirmed = await confirmFn();
if (!confirmed) return;
}
if (saveDraft && isBackendSyncEnabled()) {
const editingId =
typeof state.editingPublishedRuleId === "string"
? state.editingPublishedRuleId.trim()
: "";
if (editingId.length > 0) {
const payloadResult = buildPublishPayload(state);
if (payloadResult.ok === false) {
setDraftSaveBannerMessage?.(
payloadResult.error === "missingCommunityName"
? messages.create.reviewAndComplete.publish
.missingCommunityName
: payloadResult.error,
);
return;
}
const { title, summary, document } = payloadResult;
const updateResult = await updatePublishedRule(editingId, {
title,
summary: summary ?? null,
document,
});
if (updateResult.ok === true) {
writeLastPublishedRule({
id: editingId,
title,
summary: summary ?? null,
document,
});
setDraftSaveBannerMessage?.(null);
} else {
setDraftSaveBannerMessage?.(updateResult.error);
return;
}
} else {
const payload: CreateFlowState = {
...state,
...(currentStep ? { currentStep } : {}),
};
const result = await saveDraftToServer(payload);
if (result.ok === true) {
setDraftSaveBannerMessage?.(null);
} else {
setDraftSaveBannerMessage?.(result.message);
return;
}
}
}
clearState();
router.push("/");
},
[
state,
currentStep,
clearState,
router,
user,
setDraftSaveBannerMessage,
confirmLeave,
],
);
}
@@ -0,0 +1,149 @@
"use client";
import { useCallback, useState } from "react";
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
import { publishRule, updatePublishedRule } from "../../../../lib/create/api";
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
import messages from "../../../../messages/en/index";
import type { CreateFlowState } from "../types";
import {
CREATE_FLOW_COMPLETED_CELEBRATE_QUERY,
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
} from "../utils/flowSteps";
import { createFlowStepPath } from "../utils/createFlowPaths";
type AppRouterLike = { push: (_href: string) => void };
type OpenLogin = (args: {
variant: "default" | "saveProgress";
nextPath: string;
backdropVariant: "blurredYellow";
}) => void;
export type UseCreateFlowFinalizeResult = {
publishBannerMessage: string | null;
setPublishBannerMessage: (_message: string | null) => void;
isPublishing: boolean;
finalize: () => Promise<void>;
};
/** Final Review → publish: banner + `isPublishing`, consumed by `CreateFlowLayoutClient`. */
export function useCreateFlowFinalize({
state,
router,
openLogin,
updateState,
loginReturnPath,
}: {
state: CreateFlowState;
router: AppRouterLike;
openLogin: OpenLogin;
updateState: (_patch: Partial<CreateFlowState>) => void;
/** Session gate return path (`?syncDraft=1`) — differs for `/create/edit-rule` vs `/create/final-review`. */
loginReturnPath: string;
}): UseCreateFlowFinalizeResult {
const [publishBannerMessage, setPublishBannerMessage] = useState<
string | null
>(null);
const [isPublishing, setIsPublishing] = useState(false);
const finalize = useCallback(async () => {
setPublishBannerMessage(null);
const payloadResult = buildPublishPayload(state);
if (payloadResult.ok === false) {
setPublishBannerMessage(
payloadResult.error === "missingCommunityName"
? messages.create.reviewAndComplete.publish.missingCommunityName
: payloadResult.error,
);
return;
}
const { title, summary, document: ruleDocument } = payloadResult;
setIsPublishing(true);
const editingId =
typeof state.editingPublishedRuleId === "string"
? state.editingPublishedRuleId.trim()
: "";
if (editingId.length > 0) {
const updateResult = await updatePublishedRule(editingId, {
title,
summary: summary ?? null,
document: ruleDocument,
});
setIsPublishing(false);
if (updateResult.ok === true) {
writeLastPublishedRule({
id: editingId,
title,
summary: summary ?? null,
document: ruleDocument,
});
updateState({ editingPublishedRuleId: undefined });
router.push(createFlowStepPath("completed"));
return;
}
if (updateResult.status === 401) {
openLogin({
variant: "default",
nextPath: loginReturnPath,
backdropVariant: "blurredYellow",
});
return;
}
setPublishBannerMessage(
updateResult.error.trim() !== ""
? updateResult.error
: messages.create.reviewAndComplete.publish.genericPublishFailed,
);
return;
}
const stakeholderEmails = (state.stakeholderEmails ?? []).filter(
(e) => typeof e === "string" && e.trim() !== "",
);
const publishResult = await publishRule({
title,
summary,
document: ruleDocument,
...(stakeholderEmails.length > 0 ? { stakeholderEmails } : {}),
});
setIsPublishing(false);
if (publishResult.ok === true) {
writeLastPublishedRule({
id: publishResult.id,
title,
summary: summary ?? null,
document: ruleDocument,
});
router.push(
createFlowStepPath("completed", {
[CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]:
CREATE_FLOW_COMPLETED_CELEBRATE_VALUE,
}),
);
return;
}
if (publishResult.status === 401) {
openLogin({
variant: "default",
nextPath: loginReturnPath,
backdropVariant: "blurredYellow",
});
return;
}
setPublishBannerMessage(
publishResult.error.trim() !== ""
? publishResult.error
: messages.create.reviewAndComplete.publish.genericPublishFailed,
);
}, [state, router, openLogin, updateState, loginReturnPath]);
return {
publishBannerMessage,
setPublishBannerMessage,
isPublishing,
finalize,
};
}
@@ -0,0 +1,20 @@
"use client";
import { useEffect, useState } from "react";
import { useMediaQuery } from "../../../hooks/useMediaQuery";
/** `--breakpoint-lg` (1024px); same SSR/first-paint pattern as `useCreateFlowMdUp`. */
const CREATE_FLOW_MIN_WIDTH_LG = "(min-width: 1024px)";
/** True at viewport ≥1024px (e.g. review grid column split with Tailwind `lg:`). */
export function useCreateFlowLgUp(): boolean {
const [isMounted, setIsMounted] = useState(false);
const isLgOrLarger = useMediaQuery(CREATE_FLOW_MIN_WIDTH_LG);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer until mount for SSR/first-paint alignment
setIsMounted(true);
}, []);
return !isMounted || isLgOrLarger;
}
@@ -0,0 +1,20 @@
"use client";
import { useEffect, useState } from "react";
import { useMediaQuery } from "../../../hooks/useMediaQuery";
/** `--breakpoint-md` (640px); same SSR/first-paint pattern as `useCreateFlowLgUp`. */
const CREATE_FLOW_MIN_WIDTH_MD = "(min-width: 640px)";
/** True at viewport ≥640px (pairs with Tailwind `md:` on create-flow screens). */
export function useCreateFlowMdUp(): boolean {
const [isMounted, setIsMounted] = useState(false);
const isMdOrLarger = useMediaQuery(CREATE_FLOW_MIN_WIDTH_MD);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer until mount for SSR/first-paint alignment
setIsMounted(true);
}, []);
return !isMounted || isMdOrLarger;
}
@@ -0,0 +1,161 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useLayoutEffect, useMemo } from "react";
import { useCreateFlow } from "../context/CreateFlowContext";
import type { CreateFlowStep } from "../types";
import {
type CreateFlowNavigationOptions,
type CreateFlowReviewReturnTarget,
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
buildTemplateReviewHref,
getNextStep,
getPreviousStep,
parseCreateFlowScreenFromPathname,
resolveCreateFlowBackTarget,
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
} from "../utils/flowSteps";
/**
* Options passed to navigation handlers (e.g. for blur before navigate)
*/
const blurActiveElement = (): void => {
if (
typeof document !== "undefined" &&
document.activeElement instanceof HTMLElement
) {
document.activeElement.blur();
}
};
/**
* Hook for Create Rule Flow navigation.
*
* Resolves the active step from `/create/{screenId}` via
* {@link parseCreateFlowScreenFromPathname} (flowSteps). Footer Back uses
* {@link resolveCreateFlowBackTarget} so template **Use without changes**
* (which skips the custom-rule segment) returns to `/create/review-template/{slug}`
* from `confirm-stakeholders` instead of `conflict-management`.
*
* Template review footer Back uses {@link buildTemplateReviewHref}s
* `?fromFlow=1` marker (and persisted `templateReviewEntryFromCreateFlow`) so
* users who came from `/create/review` return there instead of `/`.
*/
export function useCreateFlowNavigation(
options?: CreateFlowNavigationOptions,
): {
currentStep: CreateFlowStep | null;
goToNextStep: () => void;
goToPreviousStep: () => void;
goToStep: (
_step: CreateFlowStep,
_navOpts?: { reviewReturn?: CreateFlowReviewReturnTarget },
) => void;
canGoNext: () => boolean;
canGoBack: () => boolean;
nextStep: CreateFlowStep | null;
previousStep: CreateFlowStep | null;
/** On `/create/review-template/…`, footer Back should go to `/create/review`. */
templateReviewFooterBackToCreateReview: boolean;
} {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();
const { state, updateState } = useCreateFlow();
const validStep = parseCreateFlowScreenFromPathname(pathname ?? null);
useLayoutEffect(() => {
if (!pathname?.includes("/create/review-template/")) return;
if (
searchParams.get(TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY) !==
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE
) {
return;
}
if (state.templateReviewEntryFromCreateFlow === true) return;
updateState({ templateReviewEntryFromCreateFlow: true });
}, [
pathname,
searchParams,
state.templateReviewEntryFromCreateFlow,
updateState,
]);
const nextStep = getNextStep(validStep, options);
const previousStep = getPreviousStep(validStep, options);
const backTarget = useMemo(
() =>
resolveCreateFlowBackTarget(
validStep,
options,
state.templateReviewBackSlug,
),
[validStep, options?.skipCommunitySave, state.templateReviewBackSlug],
);
const goToNextStep = useCallback(() => {
blurActiveElement();
if (nextStep) {
router.push(`/create/${nextStep}`);
}
}, [router, nextStep]);
const goToPreviousStep = useCallback(() => {
blurActiveElement();
if (!backTarget) return;
if (backTarget.kind === "templateReview") {
router.push(
buildTemplateReviewHref(backTarget.slug, {
fromCreateWizard: state.templateReviewEntryFromCreateFlow === true,
}),
);
return;
}
router.push(`/create/${backTarget.step}`);
}, [router, backTarget, state.templateReviewEntryFromCreateFlow]);
const templateReviewFooterBackToCreateReview = useMemo(
() =>
Boolean(state.templateReviewEntryFromCreateFlow) ||
(pathname?.includes("/create/review-template/") &&
searchParams.get(TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY) ===
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE),
[state.templateReviewEntryFromCreateFlow, pathname, searchParams],
);
const goToStep = useCallback(
(
step: CreateFlowStep,
navOpts?: { reviewReturn?: CreateFlowReviewReturnTarget },
) => {
blurActiveElement();
const params = new URLSearchParams(searchParams?.toString() ?? "");
if (navOpts?.reviewReturn != null) {
params.set(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY, navOpts.reviewReturn);
} else {
params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
}
const qs = params.toString();
router.push(qs.length > 0 ? `/create/${step}?${qs}` : `/create/${step}`);
},
[router, searchParams],
);
const canGoNext = useCallback(() => nextStep !== null, [nextStep]);
const canGoBack = useCallback(() => backTarget != null, [backTarget]);
return {
currentStep: validStep,
goToNextStep,
goToPreviousStep,
goToStep,
canGoNext,
canGoBack,
nextStep,
previousStep,
templateReviewFooterBackToCreateReview,
};
}
@@ -0,0 +1,20 @@
"use client";
import { useEffect, useState } from "react";
import { useMediaQuery } from "../../../hooks/useMediaQuery";
/** `--breakpoint-sm2` (440px); pairs with Tailwind `sm2:` on create-flow chrome. */
const CREATE_FLOW_MIN_WIDTH_SM2 = "(min-width: 440px)";
/** True at viewport ≥440px. */
export function useCreateFlowSm2Up(): boolean {
const [isMounted, setIsMounted] = useState(false);
const isSm2OrLarger = useMediaQuery(CREATE_FLOW_MIN_WIDTH_SM2);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- defer until mount for SSR/first-paint alignment
setIsMounted(true);
}, []);
return !isMounted || isSm2OrLarger;
}
@@ -0,0 +1,29 @@
"use client";
import { useCallback } from "react";
import { useCreateFlow } from "../context/CreateFlowContext";
import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks";
/**
* Stable writer for `customMethodCardFieldBlocksById[id]` used from facet card
* modals. Uses {@link replaceState} so merges read the latest draft (no stale
* closure over `customMethodCardFieldBlocksById`).
*/
export function useCustomMethodCardFieldBlocksChange(cardId: string | null) {
const { replaceState, markCreateFlowInteraction } = useCreateFlow();
return useCallback(
(nextBlocks: CustomMethodCardFieldBlock[]) => {
if (!cardId) return;
markCreateFlowInteraction();
replaceState((prev) => ({
...prev,
customMethodCardFieldBlocksById: {
...(prev.customMethodCardFieldBlocksById ?? {}),
[cardId]: nextBlocks,
},
}));
},
[cardId, markCreateFlowInteraction, replaceState],
);
}
@@ -0,0 +1,78 @@
"use client";
import { useCallback } from "react";
import messages from "../../../../messages/en/index";
import { useAsyncConfirm } from "../../../hooks/useAsyncConfirm";
import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks";
import {
confirmDiscardMethodCardCustomizeSession,
isMethodCardCustomizeSessionDirty,
type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft,
} from "../../../../lib/create/methodCardCustomizeSession";
const copy = messages.create.customRule.modalKebabMenu;
const confirmOptions = {
title: copy.discardUnsavedCustomizeChangesTitle,
description: copy.discardUnsavedCustomizeChangesDescription,
proceedText: copy.discardUnsavedCustomizeChangesProceed,
cancelText: copy.discardUnsavedCustomizeChangesCancel,
};
/**
* Create-flow confirm for exiting customize mode with unsaved edits.
*
* @returns Async helpers plus `confirmDialog` to render once in the screen JSX.
*/
export function useDiscardCustomizeConfirm() {
const { requestConfirm, confirmDialog } = useAsyncConfirm();
const runConfirm = useCallback(
() => requestConfirm(confirmOptions),
[requestConfirm],
);
const confirmDiscard = useCallback(
async <TDraft,>(
modalEditUnlocked: boolean,
snapshot: MethodCardCustomizeSnapshot<TDraft> | null,
pendingDraft: TDraft | null,
draftFieldBlocks: CustomMethodCardFieldBlock[] | null,
headerDraft: MethodCardHeaderDraft | null,
) =>
confirmDiscardMethodCardCustomizeSession(
modalEditUnlocked,
snapshot,
pendingDraft,
draftFieldBlocks,
headerDraft,
runConfirm,
),
[runConfirm],
);
const confirmDirtyCustomizeCancel = useCallback(
async <TDraft,>(
snapshot: MethodCardCustomizeSnapshot<TDraft>,
pendingDraft: TDraft | null,
draftFieldBlocks: CustomMethodCardFieldBlock[] | null,
headerDraft: MethodCardHeaderDraft | null,
) => {
if (
!isMethodCardCustomizeSessionDirty(
snapshot,
pendingDraft,
draftFieldBlocks,
headerDraft,
)
) {
return true;
}
return runConfirm();
},
[runConfirm],
);
return { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog };
}
@@ -0,0 +1,176 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { buildFacetQueryString } from "../../../../lib/create/buildFacetQueryString";
import type { MethodFacetApiSectionId } from "../../../../lib/create/customRuleFacets";
import { useCreateFlow } from "../context/CreateFlowContext";
/**
* Card-deck section ids served by `/api/create-flow/methods` (CR-88 §9.2).
* Same tuple as {@link METHOD_FACET_API_SECTION_IDS} (`CUSTOM_RULE_FACETS`, CR-92).
*/
export type RecommendationSection = MethodFacetApiSectionId;
export type FacetRecommendationsResult = {
/** `true` once the network call completes (or short-circuits with no facets). */
isReady: boolean;
/** `slug → score`; missing slug means `0`. */
scoresBySlug: Record<string, number>;
/**
* `true` iff the user has selected at least one community facet. When
* `false`, callers should preserve authoring order rather than reranking.
*/
hasAnyFacets: boolean;
};
const EMPTY_SCORES: Record<string, number> = {};
/**
* Calls `GET /api/create-flow/methods?section=<section>&facet.*=...` for the
* card-deck step `section` and returns a `slug → score` map for re-ranking
* the messages-file `methods[]` array (CR-88 §10).
*
* Returns `{ isReady: true, scoresBySlug: {} }` when the user has not selected
* any community facets — callers fall back to the authoring order.
*
* Network failures resolve to `scoresBySlug: {}` so the wizard is never
* blocked on the recommendation backend.
*/
export function useFacetRecommendations(
section: RecommendationSection,
): FacetRecommendationsResult {
const { state } = useCreateFlow();
const queryString = useMemo(
() => buildFacetQueryString(state),
[state],
);
const hasAnyFacets = queryString.length > 0;
const [result, setResult] = useState<FacetRecommendationsResult>({
isReady: !hasAnyFacets,
scoresBySlug: EMPTY_SCORES,
hasAnyFacets,
});
// Track the last successful request input so we don't re-fetch on every state poke.
const lastQueryRef = useRef<string | null>(null);
useEffect(() => {
if (!hasAnyFacets) {
setResult({
isReady: true,
scoresBySlug: EMPTY_SCORES,
hasAnyFacets: false,
});
lastQueryRef.current = null;
return;
}
const requestKey = `${section}?${queryString}`;
if (lastQueryRef.current === requestKey) return;
lastQueryRef.current = requestKey;
const ctrl = new AbortController();
setResult((prev) => ({ ...prev, isReady: false, hasAnyFacets: true }));
fetch(`/api/create-flow/methods?section=${section}&${queryString}`, {
credentials: "include",
signal: ctrl.signal,
})
.then(async (res) => {
if (!res.ok) throw new Error(`status ${res.status}`);
return (await res.json()) as {
methods?: { slug: string; matches?: { score?: number } }[];
};
})
.then((json) => {
const scoresBySlug: Record<string, number> = {};
for (const m of json.methods ?? []) {
if (typeof m.slug === "string") {
scoresBySlug[m.slug] = m.matches?.score ?? 0;
}
}
setResult({ isReady: true, scoresBySlug, hasAnyFacets: true });
})
.catch((e) => {
if ((e as { name?: string }).name === "AbortError") return;
setResult({
isReady: true,
scoresBySlug: EMPTY_SCORES,
hasAnyFacets: true,
});
});
return () => {
ctrl.abort();
// Clear the dedup key so React 19 Strict Mode's mount → unmount → mount
// cycle (and any future remount) re-issues the request instead of
// returning early on the same key.
if (lastQueryRef.current === requestKey) {
lastQueryRef.current = null;
}
};
}, [section, queryString, hasAnyFacets]);
return result;
}
/**
* Stable comparator for re-ranking a messages-file `methods[]` array. Higher
* `scoresBySlug[id]` first; ties fall back to authoring index, so a
* zero-facet user sees the original ordering verbatim.
*/
export function rankMethodsByScore<T extends { id: string }>(
methods: readonly T[],
scoresBySlug: Record<string, number>,
): T[] {
const indexById = new Map<string, number>();
methods.forEach((m, i) => indexById.set(m.id, i));
return [...methods].sort((a, b) => {
const sa = scoresBySlug[a.id] ?? 0;
const sb = scoresBySlug[b.id] ?? 0;
if (sa !== sb) return sb - sa;
return (indexById.get(a.id) ?? 0) - (indexById.get(b.id) ?? 0);
});
}
/**
* Picks (a) which method ids fill the compact card stack and (b) which of
* those should render with the "Recommended" tag. The messages JSON no
* longer carries a static `recommended` flag — both selections come
* entirely from facet scores (CR-88 §10).
*
* Behavior:
* - Facets selected & at least one method scored > 0 →
* `compactCardIds` = up to `limit` top-scored methods (1..limit cards;
* never padded with unrecommended fillers). All shown cards get the
* "Recommended" badge.
* - No facets selected, or every method scored 0 → `compactCardIds` =
* first `limit` in ranked/authoring order, `recommendedIds` empty (no
* badges shown — honest "no signal yet" fallback).
*
* `CardStack.view` is responsible for laying out variable-length compact
* arrays gracefully (uses `.map`/`.slice` and length-guarded indexing).
*/
export function deriveCompactCards<T extends { id: string }>(
rankedMethods: readonly T[],
scoresBySlug: Record<string, number>,
hasAnyFacets: boolean,
limit: number,
): { compactCardIds: string[]; recommendedIds: Set<string> } {
const fallback = () => ({
compactCardIds: rankedMethods.slice(0, limit).map((m) => m.id),
recommendedIds: new Set<string>(),
});
if (!hasAnyFacets) return fallback();
const matched = rankedMethods.filter(
(m) => (scoresBySlug[m.id] ?? 0) > 0,
);
if (matched.length === 0) return fallback();
const top = matched.slice(0, limit);
return {
compactCardIds: top.map((m) => m.id),
recommendedIds: new Set(top.map((m) => m.id)),
};
}
@@ -0,0 +1,94 @@
"use client";
import { useMemo } from "react";
import {
mergeCompactCardIdsWithPinnedSelected,
orderRankedMethodsWithPinnedSelection,
} from "../../../../lib/create/methodCardDisplayOrder";
import {
deriveCompactCards,
rankMethodsByScore,
useFacetRecommendations,
type RecommendationSection,
} from "./useFacetRecommendations";
type MethodEntry = { id: string; label: string; supportText: string };
/**
* Applies score ranking, compact-slot rules, then surfaces selected ids first in
* `selected*Ids` order (most-recent add at index 0 via
* {@link moveFacetSelectionIdToFront}). Selection-first applies whenever the facet
* has any selection — not only after footer Confirm (`methodSectionsPinCommitted`).
*/
export function useMethodCardDeckOrdering(
section: RecommendationSection,
methods: readonly MethodEntry[],
selectedIds: readonly string[],
) {
const { scoresBySlug, hasAnyFacets } = useFacetRecommendations(section);
const rankedMethods = useMemo(
() => rankMethodsByScore(methods, scoresBySlug),
[methods, scoresBySlug],
);
const selectionShowcaseActive = selectedIds.length > 0;
const displayMethods = useMemo(
() =>
orderRankedMethodsWithPinnedSelection(
rankedMethods,
selectedIds,
selectionShowcaseActive,
),
[rankedMethods, selectedIds, selectionShowcaseActive],
);
const { compactCardIds: baseCompactCardIds, recommendedIds } = useMemo(
() =>
deriveCompactCards(
rankedMethods,
scoresBySlug,
hasAnyFacets,
/* limit */ 5,
),
[rankedMethods, scoresBySlug, hasAnyFacets],
);
const compactCardIds = useMemo(
() =>
mergeCompactCardIdsWithPinnedSelected(
displayMethods.map((m) => m.id),
baseCompactCardIds,
selectedIds,
selectionShowcaseActive,
5,
),
[displayMethods, baseCompactCardIds, selectedIds, selectionShowcaseActive],
);
const sampleCards = useMemo(
() =>
displayMethods.map((entry) => ({
id: entry.id,
label: entry.label,
supportText: entry.supportText,
recommended: recommendedIds.has(entry.id),
})),
[displayMethods, recommendedIds],
);
const methodById = useMemo(
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
[rankedMethods],
);
return {
rankedMethods,
displayMethods,
compactCardIds,
recommendedIds,
sampleCards,
methodById,
};
}
@@ -0,0 +1,250 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { buildTemplateCustomizePrefill } from "../../../../lib/create/applyTemplatePrefill";
import { loadTemplateReviewBySlug } from "../../../../lib/create/loadTemplateReviewBySlug";
import { methodSectionsPinsForHydratedSelections } from "../../../../lib/create/publishedDocumentToCreateFlowState";
import { stripCustomRuleSelectionFields } from "../../../../lib/create/stripCustomRuleSelectionFields";
import messages from "../../../../messages/en/index";
import type {
CreateFlowContextValue,
CreateFlowState,
} from "../types";
type AppRouterLike = { push: (_href: string) => void };
type UpdateState = CreateFlowContextValue["updateState"];
type ReplaceStateFn = CreateFlowContextValue["replaceState"];
export type UseTemplateReviewActionsResult = {
/** True iff the current pathname is a template-review route (locale/basePath tolerant). */
isTemplateReviewRoute: boolean;
/** Decoded slug parsed out of the template-review pathname, or null. */
templateReviewSlug: string | null;
/** True between the fetch start and resolution for either action. */
isApplyingTemplate: boolean;
/** Set when the template fetch failed or the body was malformed. Cleared at the start of each action. */
templateReviewApplyError: string | null;
setTemplateReviewApplyError: (_message: string | null) => void;
/**
* Customize: apply the template's selections onto state and route to
* `/create/core-values` (if community name is set) or `/create/informational`
* with a `pendingTemplateAction` pin so `/create/review` can later replace
* itself with `/create/core-values`.
*/
handleCustomize: () => Promise<void>;
/**
* Use without changes: scrub any prior customize picks, seed core values +
* method-card selections from the template body (same id mapping as
* Customize) so drilling from final-review via + shows selected cards, drop
* the Values row from `state.sections`, and route to
* `/create/confirm-stakeholders` (or `/create/informational` with a pin to
* skip past `/create/review` to `/create/confirm-stakeholders` later).
*/
handleUseWithoutChanges: () => Promise<void>;
};
/**
* Encapsulates the two template-review footer actions (Customize / Use
* without changes) plus the small amount of state they share (in-flight
* flag, error banner, parsed slug). Called from `CreateFlowLayoutClient`
* once; extracting it here keeps the layout shell focused on rendering
* rather than orchestrating template fetch + state seeding.
*
* @example
* const {
* isTemplateReviewRoute,
* templateReviewSlug,
* isApplyingTemplate,
* templateReviewApplyError,
* setTemplateReviewApplyError,
* handleCustomize,
* handleUseWithoutChanges,
* } = useTemplateReviewActions({ pathname, state, updateState, replaceState, router });
*/
export function useTemplateReviewActions({
pathname,
state,
updateState,
replaceState,
router,
}: {
pathname: string | null | undefined;
state: CreateFlowState;
updateState: UpdateState;
replaceState: ReplaceStateFn;
router: AppRouterLike;
}): UseTemplateReviewActionsResult {
const [isApplyingTemplate, setIsApplyingTemplate] = useState(false);
const [templateReviewApplyError, setTemplateReviewApplyError] = useState<
string | null
>(null);
const templateReviewSlug = useMemo(() => {
const m = pathname?.match(/\/create\/review-template\/([^/?#]+)/);
return m?.[1] ? decodeURIComponent(m[1]) : null;
}, [pathname]);
const isTemplateReviewRoute = Boolean(
pathname?.includes("/create/review-template/"),
);
const handleCustomize = useCallback(async () => {
if (!templateReviewSlug) return;
setTemplateReviewApplyError(null);
setIsApplyingTemplate(true);
const loaded = await loadTemplateReviewBySlug(templateReviewSlug);
setIsApplyingTemplate(false);
if (loaded.ok === false) {
setTemplateReviewApplyError(loaded.message);
return;
}
const prefill = buildTemplateCustomizePrefill(loaded.template.body);
const pinPatch = methodSectionsPinsForHydratedSelections(prefill);
const hasCommunityName =
typeof state.title === "string" && state.title.trim().length > 0;
updateState({
...prefill,
methodSectionsPinCommitted: {
...state.methodSectionsPinCommitted,
...pinPatch,
},
templateReviewBackSlug: undefined,
...(hasCommunityName
? { pendingTemplateAction: undefined }
: {
pendingTemplateAction: {
slug: templateReviewSlug,
mode: "customize",
},
}),
});
router.push(
hasCommunityName ? "/create/core-values" : "/create/informational",
);
}, [
router,
state.methodSectionsPinCommitted,
state.title,
templateReviewSlug,
updateState,
]);
const handleUseWithoutChanges = useCallback(async () => {
if (!templateReviewSlug) return;
setTemplateReviewApplyError(null);
setIsApplyingTemplate(true);
const loaded = await loadTemplateReviewBySlug(templateReviewSlug);
setIsApplyingTemplate(false);
if (loaded.ok === false) {
setTemplateReviewApplyError(loaded.message);
return;
}
const { template } = loaded;
const doc = template.body;
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
setTemplateReviewApplyError(
messages.create.templateReview.errors.applyFailed,
);
return;
}
const sectionsRaw = (doc as { sections?: unknown }).sections;
const sections = Array.isArray(sectionsRaw)
? (sectionsRaw as Record<string, unknown>[])
: [];
if (sections.length === 0) {
setTemplateReviewApplyError(
messages.create.templateReview.errors.applyFailed,
);
return;
}
const hasCommunityName =
typeof state.title === "string" && state.title.trim().length > 0;
// Atomic read-modify-write: strip prior custom-rule picks and merge template
// body in one replaceState so method ids are never lost across React batching
// (reset + update separately could leave selections undefined in Strict Mode).
replaceState((prev) => {
const base = stripCustomRuleSelectionFields(prev);
const customizePrefill = buildTemplateCustomizePrefill(doc);
const hasValuesSeed =
customizePrefill.selectedCoreValueIds !== undefined;
const sectionsWithoutValues = hasValuesSeed
? sections.filter((s) => {
const name = (s as { categoryName?: unknown }).categoryName;
if (typeof name !== "string") return true;
const key = name.toLowerCase().replace(/[^a-z]+/g, "");
return key !== "values" && key !== "corevalues";
})
: sections;
const hasCommunityName =
typeof prev.title === "string" && prev.title.trim().length > 0;
const pinPatch =
methodSectionsPinsForHydratedSelections(customizePrefill);
return {
...base,
...(hasValuesSeed
? {
selectedCoreValueIds: customizePrefill.selectedCoreValueIds,
coreValuesChipsSnapshot:
customizePrefill.coreValuesChipsSnapshot,
}
: {}),
...(customizePrefill.selectedCommunicationMethodIds !== undefined
? {
selectedCommunicationMethodIds:
customizePrefill.selectedCommunicationMethodIds,
}
: {}),
...(customizePrefill.selectedMembershipMethodIds !== undefined
? {
selectedMembershipMethodIds:
customizePrefill.selectedMembershipMethodIds,
}
: {}),
...(customizePrefill.selectedDecisionApproachIds !== undefined
? {
selectedDecisionApproachIds:
customizePrefill.selectedDecisionApproachIds,
}
: {}),
...(customizePrefill.selectedConflictManagementIds !== undefined
? {
selectedConflictManagementIds:
customizePrefill.selectedConflictManagementIds,
}
: {}),
sections: sectionsWithoutValues,
methodSectionsPinCommitted: pinPatch,
templateReviewBackSlug: templateReviewSlug,
...(hasCommunityName
? { pendingTemplateAction: undefined }
: {
pendingTemplateAction: {
slug: templateReviewSlug,
mode: "useWithoutChanges",
},
}),
};
});
router.push(
hasCommunityName
? "/create/confirm-stakeholders"
: "/create/informational",
);
}, [replaceState, router, state.title, templateReviewSlug]);
return {
isTemplateReviewRoute,
templateReviewSlug,
isApplyingTemplate,
templateReviewApplyError,
setTemplateReviewApplyError,
handleCustomize,
handleUseWithoutChanges,
};
}
+6
View File
@@ -0,0 +1,6 @@
import type { ReactNode } from "react";
import CreateFlowLayoutGate from "./CreateFlowLayoutGate";
export default function CreateFlowLayout({ children }: { children: ReactNode }) {
return <CreateFlowLayoutGate>{children}</CreateFlowLayoutGate>;
}
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
import { FIRST_STEP } from "./utils/flowSteps";
/** `/create` redirects to the first wizard step (Figma frame 1). */
export default function CreateIndexPage() {
redirect(`/create/${FIRST_STEP}`);
}
@@ -0,0 +1,126 @@
"use client";
import { use, useEffect, useState } from "react";
import { TemplateReviewCard } from "../../../../components/cards/TemplateReviewCard";
import { useTranslation } from "../../../../contexts/MessagesContext";
import {
fetchTemplateBySlug,
isTemplatesFetchAborted,
type RuleTemplateDto,
} from "../../../../../lib/create/fetchTemplates";
import messages from "../../../../../messages/en/index";
import Alert from "../../../../components/modals/Alert";
import {
CREATE_FLOW_REVIEW_RULE_LAYOUT_CLASS,
CreateFlowLockupCardStepShell,
} from "../../components/CreateFlowLockupCardStepShell";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
interface PageProps {
params: Promise<{ slug: string }>;
}
/** Template review route — same shell/grid as final-review; Figma `22142-898702`. */
export default function ReviewTemplatePage({ params }: PageProps) {
const { slug: rawSlug } = use(params);
const slug = decodeURIComponent(rawSlug);
const mdUp = useCreateFlowMdUp();
const t = useTranslation("create.templateReview");
const [template, setTemplate] = useState<RuleTemplateDto | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const ac = new AbortController();
let cancelled = false;
void (async () => {
if (!cancelled) {
setLoading(true);
setError(null);
}
try {
const result = await fetchTemplateBySlug(slug, {
signal: ac.signal,
});
if (cancelled) return;
if (result === null) {
setError(messages.create.templateReview.errors.notFound);
setTemplate(null);
} else if ("error" in result) {
setError(result.error);
setTemplate(null);
} else {
setTemplate(result);
setError(null);
}
} catch (e) {
if (cancelled || isTemplatesFetchAborted(e)) return;
setError(messages.create.templateReview.errors.loadFailed);
setTemplate(null);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
ac.abort();
};
}, [slug]);
if (loading) {
return (
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
<div
className={`flex shrink-0 items-center justify-start pb-16 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
>
<p className="text-[var(--color-content-default-secondary,#a3a3a3)]">
{t("loading")}
</p>
</div>
</CreateFlowStepShell>
);
}
if (error || !template) {
return (
<>
<div
className="pointer-events-none fixed left-0 right-0 top-14 z-[120] flex justify-center px-5 pt-3 md:top-20 md:px-12"
aria-live="polite"
>
<div className="pointer-events-auto w-full max-w-[960px]">
<Alert
type="banner"
status="danger"
title={t("errors.loadFailed")}
description={error ?? t("errors.notFound")}
className="w-full"
/>
</div>
</div>
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
<div
className={`min-h-[40vh] shrink-0 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
aria-hidden
/>
</CreateFlowStepShell>
</>
);
}
return (
<CreateFlowLockupCardStepShell
lockupTitle={t("intro.title")}
lockupDescription={t("intro.description")}
>
<TemplateReviewCard
template={template}
ruleCardClassName={CREATE_FLOW_REVIEW_RULE_LAYOUT_CLASS}
size={mdUp ? "L" : "M"}
/>
</CreateFlowLockupCardStepShell>
);
}
@@ -0,0 +1,23 @@
"use client";
import type { ReactNode } from "react";
import type { CreateFlowStep } from "../types";
import { renderCreateFlowScreen } from "./createFlowScreenComponents";
/**
* Maps each wizard `screenId` to its screen component.
*
* **Folder rule (Figma):** subfolders match `CREATE_FLOW_SCREEN_REGISTRY[].layoutKind`
* — `select/` (two-column chip flows), `card/` (compact card-stack steps), `text/`, etc.
* The URL segment (`communication-methods`) is not the folder name; see `createFlowScreenRegistry.ts`.
*
* Implementation lives in {@link renderCreateFlowScreen} (`createFlowScreenComponents.tsx`)
* so the registry metadata and this router stay easier to keep in sync (CR-92 §3).
*/
export function CreateFlowScreenView({
screenId,
}: {
screenId: CreateFlowStep;
}): ReactNode {
return renderCreateFlowScreen(screenId);
}
@@ -0,0 +1,833 @@
"use client";
/**
* `communication-methods` step — Figma “Flow — Compact Card Stack” (node `20246-15828`).
* Registry: `layoutKind: "card"` (`CREATE_FLOW_SCREEN_REGISTRY["communication-methods"]`).
*
* Lives under `screens/card/` (not `select/`): Figma **card stack** layout is a distinct shell from
* two-column chip **select** frames. Future card-stack steps get their own `*Screen.tsx` here and
* reuse `CardStack` / `CreateFlowStepShell` as needed.
*
* Card click opens the Figma create modal (node `20246-15829`) with three
* editable sections rendered by {@link CommunicationMethodEditFields}. The primary
* action is **Add Platform** for an unselected card; a selected card in view mode has
* no footer primary — **Remove** is available from the kebab (same behavior as legacy
* footer remove via {@link removeMethodCardFromFacetSelection}).
*/
import { useState, useCallback, useMemo, useRef } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import { CommunicationMethodEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import { communicationPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
import { isCustomMethodCardId } from "../../../../../lib/create/isCustomMethodCardId";
import { communicationMethodFacetMatchesPreset } from "../../../../../lib/create/methodCardFacetMatchesPresetForId";
import { usesWizardFieldBlocksModalBody } from "../../../../../lib/create/usesWizardFieldBlocksModalBody";
import { removeMethodCardFromFacetSelection } from "../../../../../lib/create/removeMethodCardFromFacetSelection";
import {
cloneMethodCardBlocksForDuplicate,
cloneMethodCardDetailsForDuplicate,
duplicateMethodCardTitle,
forkMethodCardFacetMapsForDuplicate,
omitIdFromStringRecord,
} from "../../../../../lib/create/duplicateMethodCardModalDraft";
import type { CommunicationMethodDetailEntry } from "../../types";
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
import {
captureMethodCardCustomizeSnapshot,
type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession";
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
export function CommunicationMethodsScreen() {
const m = useMessages();
const comm = m.create.customRule.communication;
const modalKebabMenu = m.create.customRule.modalKebabMenu;
const mdUp = useCreateFlowMdUp();
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
useDiscardCustomizeConfirm();
const { state, updateState, replaceState, markCreateFlowInteraction } =
useCreateFlow();
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
const customizeSnapshotRef = useRef<
MethodCardCustomizeSnapshot<CommunicationMethodDetailEntry> | null
>(null);
const [expanded, setExpanded] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
const [pendingDraft, setPendingDraft] =
useState<CommunicationMethodDetailEntry | null>(null);
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
const [modalEditUnlocked, setModalEditUnlocked] = useState(false);
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
CustomMethodCardFieldBlock[] | null
>(null);
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
useState<MethodCardHeaderDraft | null>(null);
const selectedIds = state.selectedCommunicationMethodIds ?? [];
const mergedMethods = useMemo(
() =>
mergePresetMethodsWithCustom(
comm.methods,
selectedIds,
state.customMethodCardMetaById,
),
[comm.methods, selectedIds, state.customMethodCardMetaById],
);
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
"communication",
mergedMethods,
selectedIds,
);
const handleOpenAddWizard = useCallback(() => {
markCreateFlowInteraction();
setAddCustomWizardOpen(true);
}, [markCreateFlowInteraction]);
const title = expanded ? comm.page.expandedTitle : comm.page.compactTitle;
const description = expanded ? (
<>
{comm.page.expandedDescriptionBefore}
<InlineTextButton onClick={handleOpenAddWizard}>
{comm.page.compactDescriptionLinkLabel}
</InlineTextButton>
{comm.page.expandedDescriptionAfter}
</>
) : (
<>
{comm.page.compactDescriptionBefore}
<InlineTextButton onClick={handleOpenAddWizard}>
{comm.page.compactDescriptionLinkLabel}
</InlineTextButton>
{comm.page.compactDescriptionAfter}
</>
);
const seedDraft = useCallback(
(id: string): CommunicationMethodDetailEntry => {
const saved = state.communicationMethodDetailsById?.[id];
if (saved) {
return { ...saved };
}
return communicationPresetFor(id);
},
[state.communicationMethodDetailsById],
);
const handleCardClick = useCallback(
(id: string) => {
markCreateFlowInteraction();
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
setPendingCardId(id);
setPendingDraft(seedDraft(id));
setCreateModalOpen(true);
},
[markCreateFlowInteraction, seedDraft],
);
const handleDraftChange = useCallback(
(next: CommunicationMethodDetailEntry) => {
markCreateFlowInteraction();
setPendingDraft(next);
},
[markCreateFlowInteraction],
);
const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId);
const fieldsLocked = !modalEditUnlocked;
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false;
if (!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)) {
return false;
}
return communicationMethodFacetMatchesPreset(pendingDraft, pendingCardId);
}, [
pendingCardId,
pendingDraft,
state.customMethodCardMetaById,
]);
const modalUsesWizardFieldBlocksBody = useMemo(
() =>
Boolean(
pendingCardId &&
usesWizardFieldBlocksModalBody({
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
draftFieldBlocks,
customFacetDetailsMatchPreset,
}),
),
[
customFacetDetailsMatchPreset,
draftFieldBlocks,
modalEditUnlocked,
pendingCardId,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
],
);
const handleCreateModalClose = useCallback(async () => {
if (
!(await confirmDiscard(
modalEditUnlocked,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
))
) {
return;
}
customizeSnapshotRef.current = null;
const ephemeralId = pendingEphemeralDuplicateIdRef.current;
if (ephemeralId) {
pendingEphemeralDuplicateIdRef.current = null;
replaceState((prev) => ({
...prev,
customMethodCardMetaById: omitIdFromStringRecord(
prev.customMethodCardMetaById,
ephemeralId,
),
communicationMethodDetailsById: omitIdFromStringRecord(
prev.communicationMethodDetailsById,
ephemeralId,
),
customMethodCardFieldBlocksById: omitIdFromStringRecord(
prev.customMethodCardFieldBlocksById,
ephemeralId,
),
}));
}
setCreateModalOpen(false);
setPendingCardId(null);
setPendingDraft(null);
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
}, [
confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks,
modalEditUnlocked,
pendingDraft,
replaceState,
]);
const handleCancelCustomize = useCallback(async () => {
if (!modalEditUnlocked) {
return;
}
const snap = customizeSnapshotRef.current;
if (!snap) {
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
if (
!(await confirmDirtyCustomizeCancel(
snap,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
))
) {
return;
}
setPendingDraft(structuredClone(snap.pendingDraft));
setDraftFieldBlocks(null);
setModalEditUnlocked(false);
customizeSnapshotRef.current = null;
setCustomizeHeaderDraft(null);
}, [
confirmDirtyCustomizeCancel,
customizeHeaderDraft,
draftFieldBlocks,
modalEditUnlocked,
pendingDraft,
]);
const handleRemoveSelectedFromModal = useCallback(async () => {
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
return;
}
markCreateFlowInteraction();
if (
!(await confirmDiscard(
modalEditUnlocked,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
))
) {
return;
}
customizeSnapshotRef.current = null;
updateState(
removeMethodCardFromFacetSelection(
state,
"communication",
pendingCardId,
),
);
await handleCreateModalClose();
}, [
confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks,
handleCreateModalClose,
markCreateFlowInteraction,
modalEditUnlocked,
pendingDraft,
pendingCardId,
selectedIds,
state,
updateState,
]);
const handleCustomize = useCallback(() => {
markCreateFlowInteraction();
if (!pendingDraft || !pendingCardId) {
return;
}
const persistedBlocks =
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [];
const initialFieldBlocks =
persistedBlocks.length > 0
? structuredClone(persistedBlocks)
: isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? []
: null;
const method = methodById.get(pendingCardId);
const meta = state.customMethodCardMetaById?.[pendingCardId];
const headerDraft: MethodCardHeaderDraft = {
title: meta?.label ?? method?.label ?? comm.confirmModal.title,
description:
meta?.supportText ??
method?.supportText ??
comm.confirmModal.description,
};
setCustomizeHeaderDraft(headerDraft);
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
pendingDraft,
initialFieldBlocks,
headerDraft,
);
setDraftFieldBlocks(initialFieldBlocks);
setModalEditUnlocked(true);
}, [
comm.confirmModal.description,
comm.confirmModal.title,
markCreateFlowInteraction,
methodById,
pendingCardId,
pendingDraft,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
]);
const handleDuplicateCustomCard = useCallback(() => {
if (
!pendingCardId ||
!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
) {
return;
}
markCreateFlowInteraction();
const newId = crypto.randomUUID();
const meta = state.customMethodCardMetaById![pendingCardId]!;
const detailsClone = cloneMethodCardDetailsForDuplicate(
pendingDraft,
state.communicationMethodDetailsById?.[pendingCardId],
() => communicationPresetFor(newId),
);
const blocksClone = structuredClone(
modalEditUnlocked &&
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById,
pendingCardId,
),
);
const suffix = modalKebabMenu.duplicateTitleSuffix;
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
const maps = forkMethodCardFacetMapsForDuplicate({
customMethodCardMetaById: state.customMethodCardMetaById,
facetDetailsById: state.communicationMethodDetailsById,
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
omitId: priorEphemeral,
});
maps.customMethodCardMetaById[newId] = {
label: duplicateMethodCardTitle(meta.label, suffix),
supportText: meta.supportText,
};
maps.facetDetailsById[newId] = detailsClone;
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
updateState({
customMethodCardMetaById: maps.customMethodCardMetaById,
communicationMethodDetailsById: maps.facetDetailsById,
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
});
pendingEphemeralDuplicateIdRef.current = newId;
customizeSnapshotRef.current = null;
setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
}, [
markCreateFlowInteraction,
modalKebabMenu.duplicateTitleSuffix,
pendingCardId,
pendingDraft,
draftFieldBlocks,
modalEditUnlocked,
state.communicationMethodDetailsById,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
updateState,
]);
const handleDuplicatePrefabCard = useCallback(() => {
if (
!pendingCardId ||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
) {
return;
}
const method = methodById.get(pendingCardId);
if (!method || !pendingDraft) {
return;
}
markCreateFlowInteraction();
const newId = crypto.randomUUID();
const detailsClone = cloneMethodCardDetailsForDuplicate(
pendingDraft,
state.communicationMethodDetailsById?.[pendingCardId],
() => communicationPresetFor(newId),
);
const blocksClone = structuredClone(
modalEditUnlocked &&
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById,
pendingCardId,
),
);
const suffix = modalKebabMenu.duplicateTitleSuffix;
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
const maps = forkMethodCardFacetMapsForDuplicate({
customMethodCardMetaById: state.customMethodCardMetaById,
facetDetailsById: state.communicationMethodDetailsById,
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
omitId: priorEphemeral,
});
maps.customMethodCardMetaById[newId] = {
label: duplicateMethodCardTitle(method.label, suffix),
supportText: method.supportText,
};
maps.facetDetailsById[newId] = detailsClone;
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
updateState({
customMethodCardMetaById: maps.customMethodCardMetaById,
communicationMethodDetailsById: maps.facetDetailsById,
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
});
pendingEphemeralDuplicateIdRef.current = newId;
customizeSnapshotRef.current = null;
setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
}, [
draftFieldBlocks,
markCreateFlowInteraction,
methodById,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix,
pendingCardId,
pendingDraft,
state.communicationMethodDetailsById,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
updateState,
]);
const kebabMenuItems = useMemo(
() =>
buildCustomRuleModalKebabMenu(modalKebabMenu, {
showCustomize: !modalEditUnlocked,
onCustomize: handleCustomize,
onDuplicate:
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
? undefined
: isCustomMethodCardId(
pendingCardId,
state.customMethodCardMetaById,
)
? handleDuplicateCustomCard
: handleDuplicatePrefabCard,
showRemove: isSelectedCardModal,
onRemove: handleRemoveSelectedFromModal,
}),
[
handleCustomize,
handleDuplicateCustomCard,
handleDuplicatePrefabCard,
handleRemoveSelectedFromModal,
isSelectedCardModal,
modalEditUnlocked,
modalKebabMenu,
pendingCardId,
state.customMethodCardMetaById,
state.editingPublishedRuleId,
],
);
const modalConfig = pendingCardId
? (() => {
const method = methodById.get(pendingCardId);
const meta = state.customMethodCardMetaById?.[pendingCardId];
const saveLabel = modalKebabMenu.saveEdits;
return {
title: meta?.label ?? method?.label ?? comm.confirmModal.title,
description:
meta?.supportText ??
method?.supportText ??
comm.confirmModal.description,
nextButtonText: modalEditUnlocked
? saveLabel
: comm.addPlatform.nextButtonText,
};
})()
: {
title: comm.confirmModal.title,
description: comm.confirmModal.description,
nextButtonText: comm.confirmModal.nextButtonText,
};
const handleCloseAddWizard = useCallback(() => {
setAddCustomWizardOpen(false);
}, []);
const handleFinalizeCustomCard = useCallback(
({
title,
description,
fieldBlocks,
}: {
title: string;
description: string;
fieldBlocks: CustomMethodCardFieldBlock[];
}) => {
markCreateFlowInteraction();
const id = crypto.randomUUID();
updateState({
selectedCommunicationMethodIds: moveFacetSelectionIdToFront(
selectedIds,
id,
),
customMethodCardMetaById: {
...(state.customMethodCardMetaById ?? {}),
[id]: { label: title, supportText: description },
},
communicationMethodDetailsById: {
...(state.communicationMethodDetailsById ?? {}),
[id]: communicationPresetFor(id),
},
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[id]: fieldBlocks,
},
});
},
[
markCreateFlowInteraction,
selectedIds,
state.communicationMethodDetailsById,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
updateState,
],
);
const handleCreateModalPrimary = useCallback(() => {
if (!pendingCardId) {
handleCreateModalClose();
return;
}
markCreateFlowInteraction();
if (selectedIds.includes(pendingCardId)) {
if (modalEditUnlocked) {
if (!customizeHeaderDraft) {
return;
}
const nextMeta = methodCardMetaWithCustomizeHeader(
state.customMethodCardMetaById,
pendingCardId,
customizeHeaderDraft,
);
if (
pendingCardId &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
usesWizardFieldBlocksModalBody({
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
draftFieldBlocks,
customFacetDetailsMatchPreset,
})
) {
updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
});
} else if (pendingDraft) {
updateState({
customMethodCardMetaById: nextMeta,
communicationMethodDetailsById: {
...(state.communicationMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
});
}
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
return;
}
if (modalEditUnlocked) {
if (!customizeHeaderDraft) {
return;
}
const nextMeta = methodCardMetaWithCustomizeHeader(
state.customMethodCardMetaById,
pendingCardId,
customizeHeaderDraft,
);
if (
pendingCardId &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
usesWizardFieldBlocksModalBody({
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
draftFieldBlocks,
customFacetDetailsMatchPreset,
})
) {
updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
});
} else if (pendingDraft) {
updateState({
customMethodCardMetaById: nextMeta,
communicationMethodDetailsById: {
...(state.communicationMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
});
}
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
if (!pendingDraft) {
handleCreateModalClose();
return;
}
updateState({
selectedCommunicationMethodIds: moveFacetSelectionIdToFront(
selectedIds,
pendingCardId,
),
communicationMethodDetailsById: {
...(state.communicationMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
});
pendingEphemeralDuplicateIdRef.current = null;
handleCreateModalClose();
}, [
customizeHeaderDraft,
draftFieldBlocks,
handleCreateModalClose,
markCreateFlowInteraction,
modalEditUnlocked,
pendingCardId,
pendingDraft,
selectedIds,
state,
updateState,
]);
return (
<>
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-800"
>
<div className="flex w-full min-w-0 flex-col items-center gap-6">
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={title}
description={description}
justification="center"
/>
</div>
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={comm.page.seeAllLink}
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
compactDesktopLayout="flexWrap"
headerLockupSize={mdUp ? "L" : "M"}
/>
</div>
</div>
<Create
isOpen={createModalOpen}
onClose={handleCreateModalClose}
headerContent={
modalEditUnlocked && customizeHeaderDraft ? (
<MethodCardCustomizeModalHeader
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
titleValue={customizeHeaderDraft.title}
descriptionValue={customizeHeaderDraft.description}
onTitleChange={(title) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, title } : null,
)
}
onDescriptionChange={(description) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, description } : null,
)
}
/>
) : undefined
}
onNext={handleCreateModalPrimary}
title={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={modalEditUnlocked}
onBack={handleCancelCustomize}
backButtonText={modalKebabMenu.cancelCustomize}
showNextButton={showMethodModalPrimary}
backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
kebabMenuItems={kebabMenuItems}
>
{pendingCardId && pendingDraft ? (
modalUsesWizardFieldBlocksBody ? (
<CustomMethodCardModalBody
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={
modalEditUnlocked && draftFieldBlocks !== null
? draftFieldBlocks
: undefined
}
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked}
onFieldBlocksChange={
fieldsLocked
? undefined
: (next) => setDraftFieldBlocks(next)
}
/>
) : (
<CommunicationMethodEditFields
value={pendingDraft}
onChange={handleDraftChange}
readOnly={fieldsLocked}
/>
)
) : null}
</Create>
</CreateFlowStepShell>
<CustomMethodCardWizard
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
onFinalize={handleFinalizeCustomCard}
onPersistCustomUploadFile={(file) =>
uploadCreateFlowFile(file, "customMethodAttachment")
}
/>
{confirmDialog}
</>
);
}
@@ -0,0 +1,832 @@
"use client";
/**
* `conflict-management` step — Figma compact card stack (node `20879-15979`).
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["conflict-management"]`.
*
* Card click opens the Figma "Add Approach" create modal (node `20874-172292`)
* with four controls rendered by {@link ConflictManagementEditFields}: Core
* Principle, Applicable Scope (text area), Process Protocol, and Restoration
* & Fallbacks. The same field set is reused on `/create/final-review` — see
* `FinalReviewChipEditModal`. Confirm persists both the chip selection and
* any user edits as a `conflictManagementDetailsById[id]` override.
*/
import { useState, useCallback, useMemo, useRef } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import { ConflictManagementEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import { conflictManagementPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
import { isCustomMethodCardId } from "../../../../../lib/create/isCustomMethodCardId";
import { conflictManagementFacetMatchesPreset } from "../../../../../lib/create/methodCardFacetMatchesPresetForId";
import { usesWizardFieldBlocksModalBody } from "../../../../../lib/create/usesWizardFieldBlocksModalBody";
import { removeMethodCardFromFacetSelection } from "../../../../../lib/create/removeMethodCardFromFacetSelection";
import {
cloneMethodCardBlocksForDuplicate,
cloneMethodCardDetailsForDuplicate,
duplicateMethodCardTitle,
forkMethodCardFacetMapsForDuplicate,
omitIdFromStringRecord,
} from "../../../../../lib/create/duplicateMethodCardModalDraft";
import type { ConflictManagementDetailEntry } from "../../types";
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
import {
captureMethodCardCustomizeSnapshot,
type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession";
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
export function ConflictManagementScreen() {
const m = useMessages();
const cm = m.create.customRule.conflictManagement;
const modalKebabMenu = m.create.customRule.modalKebabMenu;
const mdUp = useCreateFlowMdUp();
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
useDiscardCustomizeConfirm();
const { state, updateState, replaceState, markCreateFlowInteraction } =
useCreateFlow();
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
const customizeSnapshotRef = useRef<
MethodCardCustomizeSnapshot<ConflictManagementDetailEntry> | null
>(null);
const [expanded, setExpanded] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
const [pendingDraft, setPendingDraft] =
useState<ConflictManagementDetailEntry | null>(null);
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
const [modalEditUnlocked, setModalEditUnlocked] = useState(false);
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
CustomMethodCardFieldBlock[] | null
>(null);
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
useState<MethodCardHeaderDraft | null>(null);
const selectedIds = state.selectedConflictManagementIds ?? [];
const mergedMethods = useMemo(
() =>
mergePresetMethodsWithCustom(
cm.methods,
selectedIds,
state.customMethodCardMetaById,
),
[cm.methods, selectedIds, state.customMethodCardMetaById],
);
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
"conflictManagement",
mergedMethods,
selectedIds,
);
const handleOpenAddWizard = useCallback(() => {
markCreateFlowInteraction();
setAddCustomWizardOpen(true);
}, [markCreateFlowInteraction]);
const title = expanded ? cm.page.expandedTitle : cm.page.compactTitle;
const description = expanded ? (
<>
{cm.page.expandedDescriptionBefore}
<InlineTextButton onClick={handleOpenAddWizard}>
{cm.page.compactDescriptionLinkLabel}
</InlineTextButton>
{cm.page.expandedDescriptionAfter}
</>
) : (
<>
{cm.page.compactDescriptionBefore}
<InlineTextButton onClick={handleOpenAddWizard}>
{cm.page.compactDescriptionLinkLabel}
</InlineTextButton>
{cm.page.compactDescriptionAfter}
</>
);
const seedDraft = useCallback(
(id: string): ConflictManagementDetailEntry => {
const saved = state.conflictManagementDetailsById?.[id];
if (saved) {
return {
...saved,
applicableScope: [...saved.applicableScope],
selectedApplicableScope: [...saved.selectedApplicableScope],
};
}
return conflictManagementPresetFor(id);
},
[state.conflictManagementDetailsById],
);
const handleCardClick = useCallback(
(id: string) => {
markCreateFlowInteraction();
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
setPendingCardId(id);
setPendingDraft(seedDraft(id));
setCreateModalOpen(true);
},
[markCreateFlowInteraction, seedDraft],
);
const handleDraftChange = useCallback(
(next: ConflictManagementDetailEntry) => {
markCreateFlowInteraction();
setPendingDraft(next);
},
[markCreateFlowInteraction],
);
const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId);
const fieldsLocked = !modalEditUnlocked;
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false;
if (!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)) {
return false;
}
return conflictManagementFacetMatchesPreset(pendingDraft, pendingCardId);
}, [
pendingCardId,
pendingDraft,
state.customMethodCardMetaById,
]);
const modalUsesWizardFieldBlocksBody = useMemo(
() =>
Boolean(
pendingCardId &&
usesWizardFieldBlocksModalBody({
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
draftFieldBlocks,
customFacetDetailsMatchPreset,
}),
),
[
customFacetDetailsMatchPreset,
draftFieldBlocks,
modalEditUnlocked,
pendingCardId,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
],
);
const handleCreateModalClose = useCallback(async () => {
if (
!(await confirmDiscard(
modalEditUnlocked,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
))
) {
return;
}
customizeSnapshotRef.current = null;
const ephemeralId = pendingEphemeralDuplicateIdRef.current;
if (ephemeralId) {
pendingEphemeralDuplicateIdRef.current = null;
replaceState((prev) => ({
...prev,
customMethodCardMetaById: omitIdFromStringRecord(
prev.customMethodCardMetaById,
ephemeralId,
),
conflictManagementDetailsById: omitIdFromStringRecord(
prev.conflictManagementDetailsById,
ephemeralId,
),
customMethodCardFieldBlocksById: omitIdFromStringRecord(
prev.customMethodCardFieldBlocksById,
ephemeralId,
),
}));
}
setCreateModalOpen(false);
setPendingCardId(null);
setPendingDraft(null);
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
}, [
confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks,
modalEditUnlocked,
pendingDraft,
replaceState,
]);
const handleCancelCustomize = useCallback(async () => {
if (!modalEditUnlocked) {
return;
}
const snap = customizeSnapshotRef.current;
if (!snap) {
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
if (
!(await confirmDirtyCustomizeCancel(
snap,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
))
) {
return;
}
setPendingDraft(structuredClone(snap.pendingDraft));
setDraftFieldBlocks(null);
setModalEditUnlocked(false);
customizeSnapshotRef.current = null;
setCustomizeHeaderDraft(null);
}, [
confirmDirtyCustomizeCancel,
customizeHeaderDraft,
draftFieldBlocks,
modalEditUnlocked,
pendingDraft,
]);
const handleRemoveSelectedFromModal = useCallback(async () => {
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
return;
}
markCreateFlowInteraction();
if (
!(await confirmDiscard(
modalEditUnlocked,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
))
) {
return;
}
customizeSnapshotRef.current = null;
updateState(
removeMethodCardFromFacetSelection(
state,
"conflictManagement",
pendingCardId,
),
);
await handleCreateModalClose();
}, [
confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks,
handleCreateModalClose,
markCreateFlowInteraction,
modalEditUnlocked,
pendingDraft,
pendingCardId,
selectedIds,
state,
updateState,
]);
const handleCustomize = useCallback(() => {
markCreateFlowInteraction();
if (!pendingDraft || !pendingCardId) {
return;
}
const initialFieldBlocks =
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? structuredClone(
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [],
)
: null;
const method = methodById.get(pendingCardId);
const meta = state.customMethodCardMetaById?.[pendingCardId];
const headerDraft: MethodCardHeaderDraft = {
title: meta?.label ?? method?.label ?? cm.confirmModal.title,
description:
meta?.supportText ??
method?.supportText ??
cm.confirmModal.description,
};
setCustomizeHeaderDraft(headerDraft);
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
pendingDraft,
initialFieldBlocks,
headerDraft,
);
setDraftFieldBlocks(initialFieldBlocks);
setModalEditUnlocked(true);
}, [
cm.confirmModal.description,
cm.confirmModal.title,
markCreateFlowInteraction,
methodById,
pendingCardId,
pendingDraft,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
]);
const handleDuplicateCustomCard = useCallback(() => {
if (
!pendingCardId ||
!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
) {
return;
}
markCreateFlowInteraction();
const newId = crypto.randomUUID();
const meta = state.customMethodCardMetaById![pendingCardId]!;
const detailsClone = cloneMethodCardDetailsForDuplicate(
pendingDraft,
state.conflictManagementDetailsById?.[pendingCardId],
() => conflictManagementPresetFor(newId),
);
const blocksClone = structuredClone(
modalEditUnlocked &&
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById,
pendingCardId,
),
);
const suffix = modalKebabMenu.duplicateTitleSuffix;
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
const maps = forkMethodCardFacetMapsForDuplicate({
customMethodCardMetaById: state.customMethodCardMetaById,
facetDetailsById: state.conflictManagementDetailsById,
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
omitId: priorEphemeral,
});
maps.customMethodCardMetaById[newId] = {
label: duplicateMethodCardTitle(meta.label, suffix),
supportText: meta.supportText,
};
maps.facetDetailsById[newId] = detailsClone;
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
updateState({
customMethodCardMetaById: maps.customMethodCardMetaById,
conflictManagementDetailsById: maps.facetDetailsById,
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
});
pendingEphemeralDuplicateIdRef.current = newId;
customizeSnapshotRef.current = null;
setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
}, [
draftFieldBlocks,
markCreateFlowInteraction,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix,
pendingCardId,
pendingDraft,
state.conflictManagementDetailsById,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
updateState,
]);
const handleDuplicatePrefabCard = useCallback(() => {
if (
!pendingCardId ||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
) {
return;
}
const method = methodById.get(pendingCardId);
if (!method || !pendingDraft) {
return;
}
markCreateFlowInteraction();
const newId = crypto.randomUUID();
const detailsClone = cloneMethodCardDetailsForDuplicate(
pendingDraft,
state.conflictManagementDetailsById?.[pendingCardId],
() => conflictManagementPresetFor(newId),
);
const blocksClone = structuredClone(
modalEditUnlocked &&
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById,
pendingCardId,
),
);
const suffix = modalKebabMenu.duplicateTitleSuffix;
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
const maps = forkMethodCardFacetMapsForDuplicate({
customMethodCardMetaById: state.customMethodCardMetaById,
facetDetailsById: state.conflictManagementDetailsById,
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
omitId: priorEphemeral,
});
maps.customMethodCardMetaById[newId] = {
label: duplicateMethodCardTitle(method.label, suffix),
supportText: method.supportText,
};
maps.facetDetailsById[newId] = detailsClone;
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
updateState({
customMethodCardMetaById: maps.customMethodCardMetaById,
conflictManagementDetailsById: maps.facetDetailsById,
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
});
pendingEphemeralDuplicateIdRef.current = newId;
customizeSnapshotRef.current = null;
setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
}, [
draftFieldBlocks,
markCreateFlowInteraction,
methodById,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix,
pendingCardId,
pendingDraft,
state.conflictManagementDetailsById,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
updateState,
]);
const kebabMenuItems = useMemo(
() =>
buildCustomRuleModalKebabMenu(modalKebabMenu, {
showCustomize: !modalEditUnlocked,
onCustomize: handleCustomize,
onDuplicate:
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
? undefined
: isCustomMethodCardId(
pendingCardId,
state.customMethodCardMetaById,
)
? handleDuplicateCustomCard
: handleDuplicatePrefabCard,
showRemove: isSelectedCardModal,
onRemove: handleRemoveSelectedFromModal,
}),
[
handleCustomize,
handleDuplicateCustomCard,
handleDuplicatePrefabCard,
handleRemoveSelectedFromModal,
isSelectedCardModal,
modalEditUnlocked,
modalKebabMenu,
pendingCardId,
state.customMethodCardMetaById,
state.editingPublishedRuleId,
],
);
const modalConfig = pendingCardId
? (() => {
const method = methodById.get(pendingCardId);
const meta = state.customMethodCardMetaById?.[pendingCardId];
const saveLabel = modalKebabMenu.saveEdits;
return {
title: meta?.label ?? method?.label ?? cm.confirmModal.title,
description:
meta?.supportText ??
method?.supportText ??
cm.confirmModal.description,
nextButtonText: modalEditUnlocked
? saveLabel
: cm.addApproach.nextButtonText,
};
})()
: {
title: cm.confirmModal.title,
description: cm.confirmModal.description,
nextButtonText: cm.confirmModal.nextButtonText,
};
const handleCloseAddWizard = useCallback(() => {
setAddCustomWizardOpen(false);
}, []);
const handleFinalizeCustomCard = useCallback(
({
title,
description,
fieldBlocks,
}: {
title: string;
description: string;
fieldBlocks: CustomMethodCardFieldBlock[];
}) => {
markCreateFlowInteraction();
const id = crypto.randomUUID();
updateState({
selectedConflictManagementIds: moveFacetSelectionIdToFront(
selectedIds,
id,
),
customMethodCardMetaById: {
...(state.customMethodCardMetaById ?? {}),
[id]: { label: title, supportText: description },
},
conflictManagementDetailsById: {
...(state.conflictManagementDetailsById ?? {}),
[id]: conflictManagementPresetFor(id),
},
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[id]: fieldBlocks,
},
});
},
[
markCreateFlowInteraction,
selectedIds,
state.conflictManagementDetailsById,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
updateState,
],
);
const handleCreateModalPrimary = useCallback(() => {
if (!pendingCardId) {
handleCreateModalClose();
return;
}
markCreateFlowInteraction();
if (selectedIds.includes(pendingCardId)) {
if (modalEditUnlocked) {
if (!customizeHeaderDraft) {
return;
}
const nextMeta = methodCardMetaWithCustomizeHeader(
state.customMethodCardMetaById,
pendingCardId,
customizeHeaderDraft,
);
if (
pendingCardId &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
usesWizardFieldBlocksModalBody({
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
draftFieldBlocks,
customFacetDetailsMatchPreset,
})
) {
updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
});
} else if (pendingDraft) {
updateState({
customMethodCardMetaById: nextMeta,
conflictManagementDetailsById: {
...(state.conflictManagementDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
});
}
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
return;
}
if (modalEditUnlocked) {
if (!customizeHeaderDraft) {
return;
}
const nextMeta = methodCardMetaWithCustomizeHeader(
state.customMethodCardMetaById,
pendingCardId,
customizeHeaderDraft,
);
if (
pendingCardId &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
usesWizardFieldBlocksModalBody({
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
draftFieldBlocks,
customFacetDetailsMatchPreset,
})
) {
updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
});
} else if (pendingDraft) {
updateState({
customMethodCardMetaById: nextMeta,
conflictManagementDetailsById: {
...(state.conflictManagementDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
});
}
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
if (!pendingDraft) {
handleCreateModalClose();
return;
}
updateState({
selectedConflictManagementIds: moveFacetSelectionIdToFront(
selectedIds,
pendingCardId,
),
conflictManagementDetailsById: {
...(state.conflictManagementDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
});
pendingEphemeralDuplicateIdRef.current = null;
handleCreateModalClose();
}, [
customizeHeaderDraft,
draftFieldBlocks,
handleCreateModalClose,
markCreateFlowInteraction,
modalEditUnlocked,
pendingCardId,
pendingDraft,
selectedIds,
state,
updateState,
]);
return (
<>
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-800"
>
<div className="flex w-full min-w-0 flex-col items-center gap-6">
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={title}
description={description}
justification="center"
/>
</div>
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={cm.page.seeAllLink}
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"}
/>
</div>
</div>
<Create
isOpen={createModalOpen}
onClose={handleCreateModalClose}
headerContent={
modalEditUnlocked && customizeHeaderDraft ? (
<MethodCardCustomizeModalHeader
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
titleValue={customizeHeaderDraft.title}
descriptionValue={customizeHeaderDraft.description}
onTitleChange={(title) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, title } : null,
)
}
onDescriptionChange={(description) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, description } : null,
)
}
/>
) : undefined
}
onNext={handleCreateModalPrimary}
title={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={modalEditUnlocked}
onBack={handleCancelCustomize}
backButtonText={modalKebabMenu.cancelCustomize}
showNextButton={showMethodModalPrimary}
backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
kebabMenuItems={kebabMenuItems}
>
{pendingCardId && pendingDraft ? (
modalUsesWizardFieldBlocksBody ? (
<CustomMethodCardModalBody
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={
modalEditUnlocked && draftFieldBlocks !== null
? draftFieldBlocks
: undefined
}
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked}
onFieldBlocksChange={
fieldsLocked
? undefined
: (next) => setDraftFieldBlocks(next)
}
/>
) : (
<ConflictManagementEditFields
value={pendingDraft}
onChange={handleDraftChange}
readOnly={fieldsLocked}
/>
)
) : null}
</Create>
</CreateFlowStepShell>
<CustomMethodCardWizard
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
onFinalize={handleFinalizeCustomCard}
onPersistCustomUploadFile={(file) =>
uploadCreateFlowFile(file, "customMethodAttachment")
}
/>
{confirmDialog}
</>
);
}
@@ -0,0 +1,825 @@
"use client";
/**
* `membership-methods` step — Figma compact card stack (node `20858-13947`).
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["membership-methods"]`.
*
* Card click opens the Figma create modal (node `20858-13948`) with three
* editable sections rendered by {@link MembershipMethodEditFields}. The same
* field set is reused on `/create/final-review` — see `FinalReviewChipEditModal`.
* Confirm persists both the chip selection and any user edits as a
* `membershipMethodDetailsById[id]` override; section defaults come from
* `messages/en/create/customRule/membership.json` and will be replaced with
* DB-driven content.
*/
import { useState, useCallback, useMemo, useRef } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import { MembershipMethodEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import { membershipPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
import { isCustomMethodCardId } from "../../../../../lib/create/isCustomMethodCardId";
import { membershipMethodFacetMatchesPreset } from "../../../../../lib/create/methodCardFacetMatchesPresetForId";
import { usesWizardFieldBlocksModalBody } from "../../../../../lib/create/usesWizardFieldBlocksModalBody";
import { removeMethodCardFromFacetSelection } from "../../../../../lib/create/removeMethodCardFromFacetSelection";
import {
cloneMethodCardBlocksForDuplicate,
cloneMethodCardDetailsForDuplicate,
duplicateMethodCardTitle,
forkMethodCardFacetMapsForDuplicate,
omitIdFromStringRecord,
} from "../../../../../lib/create/duplicateMethodCardModalDraft";
import type { MembershipMethodDetailEntry } from "../../types";
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
import {
captureMethodCardCustomizeSnapshot,
type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession";
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
export function MembershipMethodsScreen() {
const m = useMessages();
const mem = m.create.customRule.membership;
const modalKebabMenu = m.create.customRule.modalKebabMenu;
const mdUp = useCreateFlowMdUp();
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } =
useDiscardCustomizeConfirm();
const { state, updateState, replaceState, markCreateFlowInteraction } =
useCreateFlow();
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
const customizeSnapshotRef = useRef<
MethodCardCustomizeSnapshot<MembershipMethodDetailEntry> | null
>(null);
const [expanded, setExpanded] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
const [pendingDraft, setPendingDraft] =
useState<MembershipMethodDetailEntry | null>(null);
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
const [modalEditUnlocked, setModalEditUnlocked] = useState(false);
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
CustomMethodCardFieldBlock[] | null
>(null);
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
useState<MethodCardHeaderDraft | null>(null);
const selectedIds = state.selectedMembershipMethodIds ?? [];
const mergedMethods = useMemo(
() =>
mergePresetMethodsWithCustom(
mem.methods,
selectedIds,
state.customMethodCardMetaById,
),
[mem.methods, selectedIds, state.customMethodCardMetaById],
);
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
"membership",
mergedMethods,
selectedIds,
);
const handleOpenAddWizard = useCallback(() => {
markCreateFlowInteraction();
setAddCustomWizardOpen(true);
}, [markCreateFlowInteraction]);
const title = expanded ? mem.page.expandedTitle : mem.page.compactTitle;
const description = expanded ? (
<>
{mem.page.expandedDescriptionBefore}
<InlineTextButton onClick={handleOpenAddWizard}>
{mem.page.compactDescriptionLinkLabel}
</InlineTextButton>
{mem.page.expandedDescriptionAfter}
</>
) : (
<>
{mem.page.compactDescriptionBefore}
<InlineTextButton onClick={handleOpenAddWizard}>
{mem.page.compactDescriptionLinkLabel}
</InlineTextButton>
{mem.page.compactDescriptionAfter}
</>
);
const seedDraft = useCallback(
(id: string): MembershipMethodDetailEntry => {
const saved = state.membershipMethodDetailsById?.[id];
if (saved) {
return { ...saved };
}
return membershipPresetFor(id);
},
[state.membershipMethodDetailsById],
);
const handleCardClick = useCallback(
(id: string) => {
markCreateFlowInteraction();
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
setPendingCardId(id);
setPendingDraft(seedDraft(id));
setCreateModalOpen(true);
},
[markCreateFlowInteraction, seedDraft],
);
const handleDraftChange = useCallback(
(next: MembershipMethodDetailEntry) => {
markCreateFlowInteraction();
setPendingDraft(next);
},
[markCreateFlowInteraction],
);
const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId);
const fieldsLocked = !modalEditUnlocked;
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false;
if (!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)) {
return false;
}
return membershipMethodFacetMatchesPreset(pendingDraft, pendingCardId);
}, [
pendingCardId,
pendingDraft,
state.customMethodCardMetaById,
]);
const modalUsesWizardFieldBlocksBody = useMemo(
() =>
Boolean(
pendingCardId &&
usesWizardFieldBlocksModalBody({
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
draftFieldBlocks,
customFacetDetailsMatchPreset,
}),
),
[
customFacetDetailsMatchPreset,
draftFieldBlocks,
modalEditUnlocked,
pendingCardId,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
],
);
const handleCreateModalClose = useCallback(async () => {
if (
!(await confirmDiscard(
modalEditUnlocked,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
))
) {
return;
}
customizeSnapshotRef.current = null;
const ephemeralId = pendingEphemeralDuplicateIdRef.current;
if (ephemeralId) {
pendingEphemeralDuplicateIdRef.current = null;
replaceState((prev) => ({
...prev,
customMethodCardMetaById: omitIdFromStringRecord(
prev.customMethodCardMetaById,
ephemeralId,
),
membershipMethodDetailsById: omitIdFromStringRecord(
prev.membershipMethodDetailsById,
ephemeralId,
),
customMethodCardFieldBlocksById: omitIdFromStringRecord(
prev.customMethodCardFieldBlocksById,
ephemeralId,
),
}));
}
setCreateModalOpen(false);
setPendingCardId(null);
setPendingDraft(null);
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
}, [
confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks,
modalEditUnlocked,
pendingDraft,
replaceState,
]);
const handleCancelCustomize = useCallback(async () => {
if (!modalEditUnlocked) {
return;
}
const snap = customizeSnapshotRef.current;
if (!snap) {
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
if (
!(await confirmDirtyCustomizeCancel(
snap,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
))
) {
return;
}
setPendingDraft(structuredClone(snap.pendingDraft));
setDraftFieldBlocks(null);
setModalEditUnlocked(false);
customizeSnapshotRef.current = null;
setCustomizeHeaderDraft(null);
}, [
confirmDirtyCustomizeCancel,
customizeHeaderDraft,
draftFieldBlocks,
modalEditUnlocked,
pendingDraft,
]);
const handleRemoveSelectedFromModal = useCallback(async () => {
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
return;
}
markCreateFlowInteraction();
if (
!(await confirmDiscard(
modalEditUnlocked,
customizeSnapshotRef.current,
pendingDraft,
draftFieldBlocks,
customizeHeaderDraft,
))
) {
return;
}
customizeSnapshotRef.current = null;
updateState(
removeMethodCardFromFacetSelection(state, "membership", pendingCardId),
);
await handleCreateModalClose();
}, [
confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks,
handleCreateModalClose,
markCreateFlowInteraction,
modalEditUnlocked,
pendingDraft,
pendingCardId,
selectedIds,
state,
updateState,
]);
const handleCustomize = useCallback(() => {
markCreateFlowInteraction();
if (!pendingDraft || !pendingCardId) {
return;
}
const initialFieldBlocks =
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? structuredClone(
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [],
)
: null;
const method = methodById.get(pendingCardId);
const meta = state.customMethodCardMetaById?.[pendingCardId];
const headerDraft: MethodCardHeaderDraft = {
title: meta?.label ?? method?.label ?? mem.confirmModal.title,
description:
meta?.supportText ??
method?.supportText ??
mem.confirmModal.description,
};
setCustomizeHeaderDraft(headerDraft);
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
pendingDraft,
initialFieldBlocks,
headerDraft,
);
setDraftFieldBlocks(initialFieldBlocks);
setModalEditUnlocked(true);
}, [
mem.confirmModal.description,
mem.confirmModal.title,
markCreateFlowInteraction,
methodById,
pendingCardId,
pendingDraft,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
]);
const handleDuplicateCustomCard = useCallback(() => {
if (
!pendingCardId ||
!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
) {
return;
}
markCreateFlowInteraction();
const newId = crypto.randomUUID();
const meta = state.customMethodCardMetaById![pendingCardId]!;
const detailsClone = cloneMethodCardDetailsForDuplicate(
pendingDraft,
state.membershipMethodDetailsById?.[pendingCardId],
() => membershipPresetFor(newId),
);
const blocksClone = structuredClone(
modalEditUnlocked &&
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById,
pendingCardId,
),
);
const suffix = modalKebabMenu.duplicateTitleSuffix;
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
const maps = forkMethodCardFacetMapsForDuplicate({
customMethodCardMetaById: state.customMethodCardMetaById,
facetDetailsById: state.membershipMethodDetailsById,
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
omitId: priorEphemeral,
});
maps.customMethodCardMetaById[newId] = {
label: duplicateMethodCardTitle(meta.label, suffix),
supportText: meta.supportText,
};
maps.facetDetailsById[newId] = detailsClone;
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
updateState({
customMethodCardMetaById: maps.customMethodCardMetaById,
membershipMethodDetailsById: maps.facetDetailsById,
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
});
pendingEphemeralDuplicateIdRef.current = newId;
customizeSnapshotRef.current = null;
setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
}, [
draftFieldBlocks,
markCreateFlowInteraction,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix,
pendingCardId,
pendingDraft,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
state.membershipMethodDetailsById,
updateState,
]);
const handleDuplicatePrefabCard = useCallback(() => {
if (
!pendingCardId ||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
) {
return;
}
const method = methodById.get(pendingCardId);
if (!method || !pendingDraft) {
return;
}
markCreateFlowInteraction();
const newId = crypto.randomUUID();
const detailsClone = cloneMethodCardDetailsForDuplicate(
pendingDraft,
state.membershipMethodDetailsById?.[pendingCardId],
() => membershipPresetFor(newId),
);
const blocksClone = structuredClone(
modalEditUnlocked &&
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById,
pendingCardId,
),
);
const suffix = modalKebabMenu.duplicateTitleSuffix;
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
const maps = forkMethodCardFacetMapsForDuplicate({
customMethodCardMetaById: state.customMethodCardMetaById,
facetDetailsById: state.membershipMethodDetailsById,
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
omitId: priorEphemeral,
});
maps.customMethodCardMetaById[newId] = {
label: duplicateMethodCardTitle(method.label, suffix),
supportText: method.supportText,
};
maps.facetDetailsById[newId] = detailsClone;
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
updateState({
customMethodCardMetaById: maps.customMethodCardMetaById,
membershipMethodDetailsById: maps.facetDetailsById,
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
});
pendingEphemeralDuplicateIdRef.current = newId;
customizeSnapshotRef.current = null;
setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
}, [
draftFieldBlocks,
markCreateFlowInteraction,
methodById,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix,
pendingCardId,
pendingDraft,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
state.membershipMethodDetailsById,
updateState,
]);
const kebabMenuItems = useMemo(
() =>
buildCustomRuleModalKebabMenu(modalKebabMenu, {
showCustomize: !modalEditUnlocked,
onCustomize: handleCustomize,
onDuplicate:
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
? undefined
: isCustomMethodCardId(
pendingCardId,
state.customMethodCardMetaById,
)
? handleDuplicateCustomCard
: handleDuplicatePrefabCard,
showRemove: isSelectedCardModal,
onRemove: handleRemoveSelectedFromModal,
}),
[
handleCustomize,
handleDuplicateCustomCard,
handleDuplicatePrefabCard,
handleRemoveSelectedFromModal,
isSelectedCardModal,
modalEditUnlocked,
modalKebabMenu,
pendingCardId,
state.customMethodCardMetaById,
state.editingPublishedRuleId,
],
);
const modalConfig = pendingCardId
? (() => {
const method = methodById.get(pendingCardId);
const meta = state.customMethodCardMetaById?.[pendingCardId];
const saveLabel = modalKebabMenu.saveEdits;
return {
title: meta?.label ?? method?.label ?? mem.confirmModal.title,
description:
meta?.supportText ??
method?.supportText ??
mem.confirmModal.description,
nextButtonText: modalEditUnlocked
? saveLabel
: mem.addPlatform.nextButtonText,
};
})()
: {
title: mem.confirmModal.title,
description: mem.confirmModal.description,
nextButtonText: mem.confirmModal.nextButtonText,
};
const handleCloseAddWizard = useCallback(() => {
setAddCustomWizardOpen(false);
}, []);
const handleFinalizeCustomCard = useCallback(
({
title,
description,
fieldBlocks,
}: {
title: string;
description: string;
fieldBlocks: CustomMethodCardFieldBlock[];
}) => {
markCreateFlowInteraction();
const id = crypto.randomUUID();
updateState({
selectedMembershipMethodIds: moveFacetSelectionIdToFront(
selectedIds,
id,
),
customMethodCardMetaById: {
...(state.customMethodCardMetaById ?? {}),
[id]: { label: title, supportText: description },
},
membershipMethodDetailsById: {
...(state.membershipMethodDetailsById ?? {}),
[id]: membershipPresetFor(id),
},
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[id]: fieldBlocks,
},
});
},
[
markCreateFlowInteraction,
selectedIds,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
state.membershipMethodDetailsById,
updateState,
],
);
const handleCreateModalPrimary = useCallback(() => {
if (!pendingCardId) {
handleCreateModalClose();
return;
}
markCreateFlowInteraction();
if (selectedIds.includes(pendingCardId)) {
if (modalEditUnlocked) {
if (!customizeHeaderDraft) {
return;
}
const nextMeta = methodCardMetaWithCustomizeHeader(
state.customMethodCardMetaById,
pendingCardId,
customizeHeaderDraft,
);
if (
pendingCardId &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
usesWizardFieldBlocksModalBody({
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
draftFieldBlocks,
customFacetDetailsMatchPreset,
})
) {
updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
});
} else if (pendingDraft) {
updateState({
customMethodCardMetaById: nextMeta,
membershipMethodDetailsById: {
...(state.membershipMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
});
}
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
return;
}
if (modalEditUnlocked) {
if (!customizeHeaderDraft) {
return;
}
const nextMeta = methodCardMetaWithCustomizeHeader(
state.customMethodCardMetaById,
pendingCardId,
customizeHeaderDraft,
);
if (
pendingCardId &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
usesWizardFieldBlocksModalBody({
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
draftFieldBlocks,
customFacetDetailsMatchPreset,
})
) {
updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
});
} else if (pendingDraft) {
updateState({
customMethodCardMetaById: nextMeta,
membershipMethodDetailsById: {
...(state.membershipMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
});
}
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
if (!pendingDraft) {
handleCreateModalClose();
return;
}
updateState({
selectedMembershipMethodIds: moveFacetSelectionIdToFront(
selectedIds,
pendingCardId,
),
membershipMethodDetailsById: {
...(state.membershipMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
});
pendingEphemeralDuplicateIdRef.current = null;
handleCreateModalClose();
}, [
customizeHeaderDraft,
draftFieldBlocks,
handleCreateModalClose,
markCreateFlowInteraction,
modalEditUnlocked,
pendingCardId,
pendingDraft,
selectedIds,
state,
updateState,
]);
return (
<>
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-800"
>
<div className="flex w-full min-w-0 flex-col items-center gap-6">
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={title}
description={description}
justification="center"
/>
</div>
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={mem.page.seeAllLink}
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"}
/>
</div>
</div>
<Create
isOpen={createModalOpen}
onClose={handleCreateModalClose}
headerContent={
modalEditUnlocked && customizeHeaderDraft ? (
<MethodCardCustomizeModalHeader
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
titleValue={customizeHeaderDraft.title}
descriptionValue={customizeHeaderDraft.description}
onTitleChange={(title) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, title } : null,
)
}
onDescriptionChange={(description) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, description } : null,
)
}
/>
) : undefined
}
onNext={handleCreateModalPrimary}
title={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={modalEditUnlocked}
onBack={handleCancelCustomize}
backButtonText={modalKebabMenu.cancelCustomize}
showNextButton={showMethodModalPrimary}
backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
kebabMenuItems={kebabMenuItems}
>
{pendingCardId && pendingDraft ? (
modalUsesWizardFieldBlocksBody ? (
<CustomMethodCardModalBody
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={
modalEditUnlocked && draftFieldBlocks !== null
? draftFieldBlocks
: undefined
}
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked}
onFieldBlocksChange={
fieldsLocked
? undefined
: (next) => setDraftFieldBlocks(next)
}
/>
) : (
<MembershipMethodEditFields
value={pendingDraft}
onChange={handleDraftChange}
readOnly={fieldsLocked}
/>
)
) : null}
</Create>
</CreateFlowStepShell>
<CustomMethodCardWizard
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
onFinalize={handleFinalizeCustomCard}
onPersistCustomUploadFile={(file) =>
uploadCreateFlowFile(file, "customMethodAttachment")
}
/>
{confirmDialog}
</>
);
}

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