diff --git a/frontend-v2/package.json b/frontend-v2/package.json index ad6542b7..8084736f 100644 --- a/frontend-v2/package.json +++ b/frontend-v2/package.json @@ -15,7 +15,7 @@ "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", - "@tanstack/react-form": "^0.41.0", + "@tanstack/react-form": "^1.27.7", "@tanstack/react-query": "^5.66.0", "@tanstack/react-query-devtools": "^5.66.0", "@tanstack/react-table": "^8.20.5", diff --git a/frontend-v2/pnpm-lock.yaml b/frontend-v2/pnpm-lock.yaml index 784017f0..9fb0961a 100644 --- a/frontend-v2/pnpm-lock.yaml +++ b/frontend-v2/pnpm-lock.yaml @@ -27,8 +27,8 @@ importers: specifier: ^1.2.4 version: 1.2.4(@types/react@19.0.8)(react@19.2.3) '@tanstack/react-form': - specifier: ^0.41.0 - version: 0.41.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.7.3) + specifier: ^1.27.7 + version: 1.27.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-query': specifier: ^5.66.0 version: 5.66.0(react@19.2.3) @@ -842,66 +842,39 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - '@remix-run/node@2.17.2': - resolution: {integrity: sha512-NHBIQI1Fap3ZmyHMPVsMcma6mvi2oUunvTzOcuWHHkkx1LrvWRzQTlaWqEnqCp/ff5PfX5r0eBEPrSkC8zrHRQ==} - engines: {node: '>=18.0.0'} - peerDependencies: - typescript: ^5.1.0 - peerDependenciesMeta: - typescript: - optional: true - - '@remix-run/router@1.23.0': - resolution: {integrity: sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==} - engines: {node: '>=14.0.0'} - - '@remix-run/server-runtime@2.17.2': - resolution: {integrity: sha512-dTrAG1SgOLgz1DFBDsLHN0V34YqLsHEReVHYOI4UV/p+ALbn/BRQMw1MaUuqGXmX21ZTuCzzPegtTLNEOc8ixA==} - engines: {node: '>=18.0.0'} - peerDependencies: - typescript: ^5.1.0 - peerDependenciesMeta: - typescript: - optional: true - - '@remix-run/web-blob@3.1.0': - resolution: {integrity: sha512-owGzFLbqPH9PlKb8KvpNJ0NO74HWE2euAn61eEiyCXX/oteoVzTVSN8mpLgDjaxBf2btj5/nUllSUgpyd6IH6g==} - - '@remix-run/web-fetch@4.4.2': - resolution: {integrity: sha512-jgKfzA713/4kAW/oZ4bC3MoLWyjModOVDjFPNseVqcJKSafgIscrYL9G50SurEYLswPuoU3HzSbO0jQCMYWHhA==} - engines: {node: ^10.17 || >=12.3} - - '@remix-run/web-file@3.1.0': - resolution: {integrity: sha512-dW2MNGwoiEYhlspOAXFBasmLeYshyAyhIdrlXBi06Duex5tDr3ut2LFKVj7tyHLmn8nnNwFf1BjNbkQpygC2aQ==} - - '@remix-run/web-form-data@3.1.0': - resolution: {integrity: sha512-NdeohLMdrb+pHxMQ/Geuzdp0eqPbea+Ieo8M8Jx2lGC6TBHsgHzYcBvr0LyPdPVycNRDEpWpiDdCOdCryo3f9A==} - - '@remix-run/web-stream@1.1.0': - resolution: {integrity: sha512-KRJtwrjRV5Bb+pM7zxcTJkhIqWWSy+MYsIxHK+0m5atcznsf15YwUBWHWulZerV2+vvHH1Lp1DD7pw6qKW8SgA==} - '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@tanstack/devtools-event-client@0.4.0': + resolution: {integrity: sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw==} + engines: {node: '>=18'} + '@tanstack/form-core@0.41.4': resolution: {integrity: sha512-XZJtN7mWJmi3apsc2J+GpWbcsXbv0pWBkZKP47ZW1QD/2Tj1UWsM6JjcaAkzIlrBdaoEFYmrHToLKr/Ddk8BVg==} + '@tanstack/form-core@1.27.7': + resolution: {integrity: sha512-nvogpyE98fhb0NDw1Bf2YaCH+L7ZIUgEpqO9TkHucDn6zg3ni521boUpv0i8HKIrmmFwDYjWZoCnrgY4HYWTkw==} + + '@tanstack/pacer-lite@0.1.1': + resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==} + engines: {node: '>=18'} + '@tanstack/query-core@5.66.0': resolution: {integrity: sha512-J+JeBtthiKxrpzUu7rfIPDzhscXF2p5zE/hVdrqkACBP8Yu0M96mwJ5m/8cPPYQE9aRNvXztXHlNwIh4FEeMZw==} '@tanstack/query-devtools@5.65.0': resolution: {integrity: sha512-g5y7zc07U9D3esMdqUfTEVu9kMHoIaVBsD0+M3LPdAdD710RpTcLiNvJY1JkYXqkq9+NV+CQoemVNpQPBXVsJg==} - '@tanstack/react-form@0.41.4': - resolution: {integrity: sha512-uIfIDZJNqR1dLW03TNByK/woyKd2jfXIrEBq6DPJbqupqyfYXTDo5TMd/7koTYLO4dgTM5wd+2v3uBX3M2bRaA==} + '@tanstack/react-form@1.27.7': + resolution: {integrity: sha512-xTg4qrUY0fuLaSnkATLZcK3BWlnwLp7IuAb6UTbZKngiDEvvDCNTvVvHgPlgef1O2qN4klZxInRyRY6oEkXZ2A==} peerDependencies: - '@tanstack/start': ^1.43.13 + '@tanstack/react-start': '*' react: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: - '@tanstack/start': + '@tanstack/react-start': optional: true '@tanstack/react-query-devtools@5.66.0': @@ -915,8 +888,8 @@ packages: peerDependencies: react: ^18 || ^19 - '@tanstack/react-store@0.7.7': - resolution: {integrity: sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==} + '@tanstack/react-store@0.8.0': + resolution: {integrity: sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -931,6 +904,9 @@ packages: '@tanstack/store@0.7.7': resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==} + '@tanstack/store@0.8.0': + resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==} + '@tanstack/table-core@8.21.3': resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} @@ -940,9 +916,6 @@ packages: peerDependencies: zod: ^3.x - '@types/cookie@0.6.0': - resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} - '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} @@ -1028,16 +1001,6 @@ packages: resolution: {integrity: sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@web3-storage/multipart-parser@1.0.0': - resolution: {integrity: sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw==} - - '@zxing/text-encoding@0.9.0': - resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==} - - abort-controller@3.0.0: - resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} - engines: {node: '>=6.5'} - acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1169,9 +1132,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - buffer-from@1.1.2: - resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - call-bind-apply-helpers@1.0.1: resolution: {integrity: sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==} engines: {node: '>= 0.4'} @@ -1238,14 +1198,6 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookie-signature@1.2.2: - resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} - engines: {node: '>=6.6.0'} - - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1261,10 +1213,6 @@ packages: damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} - data-uri-to-buffer@3.0.1: - resolution: {integrity: sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==} - engines: {node: '>= 6'} - data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -1294,9 +1242,6 @@ packages: supports-color: optional: true - decode-formdata@0.8.0: - resolution: {integrity: sha512-iUzDgnWsw5ToSkFY7VPFA5Gfph6ROoOxOB7Ybna4miUSzLZ4KaSJk6IAB2AdW6+C9vCVWhjjNA4gjT6wF3eZHQ==} - deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1518,10 +1463,6 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - event-target-shim@5.0.1: - resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} - engines: {node: '>=6'} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1708,17 +1649,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} - is-arguments@1.2.0: - resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} - engines: {node: '>= 0.4'} - is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -1957,10 +1891,6 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} - mrmime@1.0.1: - resolution: {integrity: sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==} - engines: {node: '>=10'} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2281,9 +2211,6 @@ packages: engines: {node: '>=10'} hasBin: true - set-cookie-parser@2.7.2: - resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} - set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -2332,17 +2259,6 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - source-map-support@0.5.21: - resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} - - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - - source-map@0.7.6: - resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} - engines: {node: '>= 12'} - stable-hash@0.0.4: resolution: {integrity: sha512-LjdcbuBeLcdETCrPn9i8AYAZ1eCtu4ECAWtP7UleOiZ9LzVxRzzUZEoZ8zB24nhkQnDWyET0I+3sWokSDS3E7g==} @@ -2350,9 +2266,6 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} - stream-slice@0.1.2: - resolution: {integrity: sha512-QzQxpoacatkreL6jsxnVb7X5R/pGw9OUv2qWTYWnmLpg4NdN31snPy/f3TdQE1ZUXaThRvj1Zw4/OGg0ZkaLMA==} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -2473,9 +2386,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - turbo-stream@2.4.1: - resolution: {integrity: sha512-v8kOJXpG3WoTN/+at8vK7erSzo6nW6CIaeOvNOkHQVDajfz1ZVeSxCbc6tOH4hrGZW7VUCV0TOXd8CPzYnYkrw==} - type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -2515,10 +2425,6 @@ packages: undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} - undici@6.22.0: - resolution: {integrity: sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==} - engines: {node: '>=18.17'} - update-browserslist-db@1.2.2: resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==} hasBin: true @@ -2556,16 +2462,6 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - util@0.12.5: - resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} - - web-encoding@1.1.5: - resolution: {integrity: sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==} - - web-streams-polyfill@3.3.3: - resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} - engines: {node: '>= 8'} - which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -3284,84 +3180,37 @@ snapshots: '@radix-ui/rect@1.1.1': {} - '@remix-run/node@2.17.2(typescript@5.7.3)': - dependencies: - '@remix-run/server-runtime': 2.17.2(typescript@5.7.3) - '@remix-run/web-fetch': 4.4.2 - '@web3-storage/multipart-parser': 1.0.0 - cookie-signature: 1.2.2 - source-map-support: 0.5.21 - stream-slice: 0.1.2 - undici: 6.22.0 - optionalDependencies: - typescript: 5.7.3 - - '@remix-run/router@1.23.0': {} - - '@remix-run/server-runtime@2.17.2(typescript@5.7.3)': - dependencies: - '@remix-run/router': 1.23.0 - '@types/cookie': 0.6.0 - '@web3-storage/multipart-parser': 1.0.0 - cookie: 0.7.2 - set-cookie-parser: 2.7.2 - source-map: 0.7.6 - turbo-stream: 2.4.1 - optionalDependencies: - typescript: 5.7.3 - - '@remix-run/web-blob@3.1.0': - dependencies: - '@remix-run/web-stream': 1.1.0 - web-encoding: 1.1.5 - - '@remix-run/web-fetch@4.4.2': - dependencies: - '@remix-run/web-blob': 3.1.0 - '@remix-run/web-file': 3.1.0 - '@remix-run/web-form-data': 3.1.0 - '@remix-run/web-stream': 1.1.0 - '@web3-storage/multipart-parser': 1.0.0 - abort-controller: 3.0.0 - data-uri-to-buffer: 3.0.1 - mrmime: 1.0.1 - - '@remix-run/web-file@3.1.0': - dependencies: - '@remix-run/web-blob': 3.1.0 - - '@remix-run/web-form-data@3.1.0': - dependencies: - web-encoding: 1.1.5 - - '@remix-run/web-stream@1.1.0': - dependencies: - web-streams-polyfill: 3.3.3 - '@rtsao/scc@1.1.0': {} '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 + '@tanstack/devtools-event-client@0.4.0': {} + '@tanstack/form-core@0.41.4': dependencies: '@tanstack/store': 0.7.7 + '@tanstack/form-core@1.27.7': + dependencies: + '@tanstack/devtools-event-client': 0.4.0 + '@tanstack/pacer-lite': 0.1.1 + '@tanstack/store': 0.7.7 + + '@tanstack/pacer-lite@0.1.1': {} + '@tanstack/query-core@5.66.0': {} '@tanstack/query-devtools@5.65.0': {} - '@tanstack/react-form@0.41.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.7.3)': + '@tanstack/react-form@1.27.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@remix-run/node': 2.17.2(typescript@5.7.3) - '@tanstack/form-core': 0.41.4 - '@tanstack/react-store': 0.7.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - decode-formdata: 0.8.0 + '@tanstack/form-core': 1.27.7 + '@tanstack/react-store': 0.8.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 transitivePeerDependencies: - react-dom - - typescript '@tanstack/react-query-devtools@5.66.0(@tanstack/react-query@5.66.0(react@19.2.3))(react@19.2.3)': dependencies: @@ -3374,9 +3223,9 @@ snapshots: '@tanstack/query-core': 5.66.0 react: 19.2.3 - '@tanstack/react-store@0.7.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-store@0.8.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/store': 0.7.7 + '@tanstack/store': 0.8.0 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) use-sync-external-store: 1.6.0(react@19.2.3) @@ -3389,6 +3238,8 @@ snapshots: '@tanstack/store@0.7.7': {} + '@tanstack/store@0.8.0': {} + '@tanstack/table-core@8.21.3': {} '@tanstack/zod-form-adapter@0.41.4(zod@3.24.2)': @@ -3396,8 +3247,6 @@ snapshots: '@tanstack/form-core': 0.41.4 zod: 3.24.2 - '@types/cookie@0.6.0': {} - '@types/estree@1.0.6': {} '@types/json-schema@7.0.15': {} @@ -3514,15 +3363,6 @@ snapshots: '@typescript-eslint/types': 8.48.1 eslint-visitor-keys: 4.2.1 - '@web3-storage/multipart-parser@1.0.0': {} - - '@zxing/text-encoding@0.9.0': - optional: true - - abort-controller@3.0.0: - dependencies: - event-target-shim: 5.0.1 - acorn-jsx@5.3.2(acorn@8.14.0): dependencies: acorn: 8.14.0 @@ -3678,8 +3518,6 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.2(browserslist@4.28.1) - buffer-from@1.1.2: {} - call-bind-apply-helpers@1.0.1: dependencies: es-errors: 1.3.0 @@ -3750,10 +3588,6 @@ snapshots: convert-source-map@2.0.0: {} - cookie-signature@1.2.2: {} - - cookie@0.7.2: {} - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3766,8 +3600,6 @@ snapshots: damerau-levenshtein@1.0.8: {} - data-uri-to-buffer@3.0.1: {} - data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -3794,8 +3626,6 @@ snapshots: dependencies: ms: 2.1.3 - decode-formdata@0.8.0: {} - deep-is@0.1.4: {} define-data-property@1.1.4: @@ -4215,8 +4045,6 @@ snapshots: esutils@2.0.3: {} - event-target-shim@5.0.1: {} - fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -4412,19 +4240,12 @@ snapshots: imurmurhash@0.1.4: {} - inherits@2.0.4: {} - internal-slot@1.1.0: dependencies: es-errors: 1.3.0 hasown: 2.0.2 side-channel: 1.1.0 - is-arguments@1.2.0: - dependencies: - call-bound: 1.0.3 - has-tostringtag: 1.0.2 - is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -4654,8 +4475,6 @@ snapshots: minipass@7.1.2: {} - mrmime@1.0.1: {} - ms@2.1.3: {} mz@2.7.0: @@ -4971,8 +4790,6 @@ snapshots: semver@7.7.3: optional: true - set-cookie-parser@2.7.2: {} - set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -5065,15 +4882,6 @@ snapshots: source-map-js@1.2.1: {} - source-map-support@0.5.21: - dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 - - source-map@0.6.1: {} - - source-map@0.7.6: {} - stable-hash@0.0.4: {} stop-iteration-iterator@1.1.0: @@ -5081,8 +4889,6 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - stream-slice@0.1.2: {} - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -5247,8 +5053,6 @@ snapshots: tslib@2.8.1: {} - turbo-stream@2.4.1: {} - type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -5308,8 +5112,6 @@ snapshots: undici-types@6.19.8: {} - undici@6.22.0: {} - update-browserslist-db@1.2.2(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -5341,22 +5143,6 @@ snapshots: util-deprecate@1.0.2: {} - util@0.12.5: - dependencies: - inherits: 2.0.4 - is-arguments: 1.2.0 - is-generator-function: 1.1.0 - is-typed-array: 1.1.15 - which-typed-array: 1.1.18 - - web-encoding@1.1.5: - dependencies: - util: 0.12.5 - optionalDependencies: - '@zxing/text-encoding': 0.9.0 - - web-streams-polyfill@3.3.3: {} - which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 diff --git a/frontend-v2/src/app/coaching/[id]/page.tsx b/frontend-v2/src/app/coaching/[id]/page.tsx new file mode 100644 index 00000000..a6fbde33 --- /dev/null +++ b/frontend-v2/src/app/coaching/[id]/page.tsx @@ -0,0 +1,41 @@ +import { ContentWrapper } from '@/app/content-wrapper' +import { AuthedPageLayout } from '@/app/authed-page-layout' +import { EventForm } from '../../event/event-form' +import { Navbar } from '@/components/nav' +import { ApiClient, API_PATH } from '@/lib/api' +import { + QueryClient, + HydrationBoundary, + dehydrate, +} from '@tanstack/react-query' +import { getCookies } from '@/lib/auth' + +type EditCoachingPageProps = { + params: Promise<{ id: string }> +} + +export default async function EditCoachingPage({ + params, +}: EditCoachingPageProps) { + const { id } = await params + const apiClient = new ApiClient(await getCookies()) + const queryClient = new QueryClient() + + // Prefetch event data during SSR + await queryClient.prefetchQuery({ + queryKey: [API_PATH.EVENT_GET, id], + queryFn: () => apiClient.getEvent(Number(id)), + }) + + return ( + + + + +

Coaching

+ +
+
+
+ ) +} diff --git a/frontend-v2/src/app/coaching/page.tsx b/frontend-v2/src/app/coaching/page.tsx new file mode 100644 index 00000000..dd1e85b6 --- /dev/null +++ b/frontend-v2/src/app/coaching/page.tsx @@ -0,0 +1,19 @@ +import { ContentWrapper } from '@/app/content-wrapper' +import { AuthedPageLayout } from '@/app/authed-page-layout' +import { EventForm } from '../event/event-form' +import { Navbar } from '@/components/nav' +import { Suspense } from 'react' + +export default async function CoachingPage() { + return ( + + + +

Coaching

+ Loading form...}> + + +
+
+ ) +} diff --git a/frontend-v2/src/app/content-wrapper.tsx b/frontend-v2/src/app/content-wrapper.tsx index d706f821..3207b052 100644 --- a/frontend-v2/src/app/content-wrapper.tsx +++ b/frontend-v2/src/app/content-wrapper.tsx @@ -21,7 +21,7 @@ export const ContentWrapper = (props: { return (
+} + +export default async function EditEventPage({ params }: EditEventPageProps) { + const { id } = await params + const apiClient = new ApiClient(await getCookies()) + const queryClient = new QueryClient() + + // Prefetch event data during SSR + await queryClient.prefetchQuery({ + queryKey: [API_PATH.EVENT_GET, id], + queryFn: () => apiClient.getEvent(Number(id)), + }) + + return ( + + + + +

Attendance

+ +
+
+
+ ) +} diff --git a/frontend-v2/src/app/event/activist-registry.ts b/frontend-v2/src/app/event/activist-registry.ts new file mode 100644 index 00000000..703a2aec --- /dev/null +++ b/frontend-v2/src/app/event/activist-registry.ts @@ -0,0 +1,52 @@ +type ActivistRecord = { + name: string + email?: string + phone?: string +} + +/** + * Encapsulates basic activist data and provides lookup methods. + */ +export class ActivistRegistry { + private activists: ActivistRecord[] + private activistsByName: Map + + constructor(activists: ActivistRecord[]) { + this.activists = activists + this.activistsByName = new Map(activists.map((a) => [a.name, a])) + } + + getActivist(name: string): ActivistRecord | null { + return this.activistsByName.get(name) ?? null + } + + getSuggestions(input: string, maxResults = 10): string[] { + const trimmedInput = input.trim() + if (!trimmedInput.length) { + return [] + } + + return this.activists + .filter(({ name }) => nameFilter(name, input)) + .slice(0, maxResults) + .map((a) => a.name) + } +} + +/** + * Filters text based on a flexible name matching pattern. + * Treats whitespace as a wildcard allowing any characters in between, + * enabling partial and out-of-order matching (e.g., "john doe" matches "John Q. Doe"). + * Matching is case-insensitive. + * + * @param text - The text to search within + * @param input - The search pattern + * @returns true if the pattern matches the text + */ +function nameFilter(text: string, input: string): boolean { + const pattern = input + .trim() + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape special regex chars + .replace(/ +/g, '.*') // whitespace matches anything + return new RegExp(pattern, 'i').test(text) +} diff --git a/frontend-v2/src/app/event/attendee-input-field.tsx b/frontend-v2/src/app/event/attendee-input-field.tsx new file mode 100644 index 00000000..c72a62ba --- /dev/null +++ b/frontend-v2/src/app/event/attendee-input-field.tsx @@ -0,0 +1,177 @@ +import { KeyboardEvent, useState } from 'react' +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' +import { MailX, PhoneMissed, UserRoundPlus, Check } from 'lucide-react' +import { AnyFieldApi } from '@tanstack/react-form' +import { ActivistRegistry } from './activist-registry' + +type AttendeeInputFieldProps = { + field: AnyFieldApi + index: number + isFocused: boolean + inputRef: (el: HTMLInputElement | null) => void + onFocus: (index: number) => void + onAdvanceFocus: () => void + onChange: () => void + registry: ActivistRegistry + checkForDuplicate: (value: string, index: number) => boolean +} + +export const AttendeeInputField = ({ + field, + index, + isFocused, + inputRef, + onFocus, + onAdvanceFocus, + onChange, + registry, + checkForDuplicate, +}: AttendeeInputFieldProps) => { + const [suggestions, setSuggestions] = useState([]) + const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1) + + const handleInputChange = (value: string) => { + field.handleChange(value) + setSuggestions(registry.getSuggestions(value)) + setSelectedSuggestionIndex(-1) + onChange() + } + + const handleSelectSuggestion = (value: string) => { + field.handleChange(value) + field.handleBlur() + field.validate('change') + setSuggestions([]) + setSelectedSuggestionIndex(-1) + onAdvanceFocus() + } + + const handleKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': { + e.preventDefault() + setSelectedSuggestionIndex((prev) => + prev === suggestions.length - 1 ? 0 : prev + 1, + ) + return + } + case 'ArrowUp': { + e.preventDefault() + setSelectedSuggestionIndex((prev) => + prev === 0 ? suggestions.length - 1 : prev - 1, + ) + return + } + case 'Escape': { + setSuggestions([]) + return + } + case 'Enter': { + e.preventDefault() + const trimmedValue: string = field.state.value?.trim() ?? '' + if (!trimmedValue.length) { + return + } + const selectedValue = + selectedSuggestionIndex >= 0 + ? suggestions[selectedSuggestionIndex] + : trimmedValue + handleSelectSuggestion(selectedValue) + return + } + case 'Tab': { + if (e.shiftKey) { + return + } + const trimmedValue = field.state.value?.trim() ?? '' + if (!trimmedValue.length) { + return + } + e.preventDefault() + const selectedValue = + selectedSuggestionIndex >= 0 + ? suggestions[selectedSuggestionIndex] + : trimmedValue + handleSelectSuggestion(selectedValue) + } + } + } + + const trimmedName = field.state.value?.trim() ?? '' + const isDuplicate = !!trimmedName && checkForDuplicate(trimmedName, index) + const activist = registry.getActivist(trimmedName) + const isNewName = !!trimmedName && !activist + const isExisting = !!trimmedName && !!activist + const isMissingEmail = isExisting && !activist.email + const isMissingPhone = isExisting && !activist.phone + const hasAllInfo = isExisting && !isMissingEmail && !isMissingPhone + const isError = !!field.state.meta.errors[0] + + return ( +
+
+
+
+ handleInputChange(e.target.value)} + onKeyDown={handleKeyDown} + name="attendee" + placeholder="" + className={cn( + 'w-full transition-colors duration-300 border-2', + isDuplicate || isError + ? 'text-red-500 border-red-500 focus:border-red-500' + : isNewName + ? 'border-purple-500 focus:border-transparent' + : '', + )} + autoComplete="off" + onFocus={() => onFocus(index)} + onBlur={() => { + field.handleBlur() + setSuggestions([]) + }} + /> +
+ {hasAllInfo && } + {isNewName && } + {isMissingEmail && } + {isMissingPhone && } +
+
+ {isFocused && !!suggestions.length && ( +
    + {suggestions.map((suggestion, i) => ( +
  • { + // Use onMouseDown instead of onClick to fire before onBlur. + e.preventDefault() + handleSelectSuggestion(suggestion) + }} + > + {suggestion} +
  • + ))} +
+ )} +
+
+ {field.state.meta.errors[0] && ( +

+ {field.state.meta.errors[0]?.message} +

+ )} + {isDuplicate && ( +

Duplicate entry

+ )} +
+ ) +} diff --git a/frontend-v2/src/app/event/event-form.tsx b/frontend-v2/src/app/event/event-form.tsx new file mode 100644 index 00000000..621141d9 --- /dev/null +++ b/frontend-v2/src/app/event/event-form.tsx @@ -0,0 +1,527 @@ +'use client' + +import { useRef, useState, useEffect, useMemo } from 'react' +import { useForm, useStore } from '@tanstack/react-form' +import { z } from 'zod' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Checkbox } from '@/components/ui/checkbox' +import { cn } from '@/lib/utils' +import { API_PATH, apiClient } from '@/lib/api' +import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' +import { useParams, useRouter } from 'next/navigation' +import toast from 'react-hot-toast' +import { useAuthedPageContext } from '@/hooks/useAuthedPageContext' +import { SF_BAY_CHAPTER_ID } from '@/lib/constants' +import { AttendeeInputField } from './attendee-input-field' +import { ActivistRegistry } from './activist-registry' + +// TODO(jh): +// - test in prod +// - improve styling +// - replace vue page w/ react page & update api to not return a redirect response on save + +// TODO: store list of names from server in indexed db & only update what's been created, updated, or deleted since last load? +// - https://app.asana.com/1/71341131816665/project/1209217418568645/task/1212688232815554 + +// TODO: store unsaved data in session storage to prevent accidental loss? +// - https://app.asana.com/1/71341131816665/project/1209217418568645/task/1212688232815556 + +const EVENT_TYPES = [ + 'Action', + 'Campaign Action', + 'Community', + 'Frontline Surveillance', + 'Meeting', + 'Outreach', + 'Animal Care', + 'Training', +] as const + +const DEFAULT_FIELD_COUNT = 5 +const MIN_EMPTY_FIELDS = 1 + +// Zod schema for form validation. +const attendeeSchema = z.object({ + name: z.string().refine( + (name) => { + const trimmed = name.trim() + // Empty is ok, as it will be filtered out. + if (trimmed === '') return true + // Must have at least first and last name (contains a space). + return trimmed.indexOf(' ') !== -1 + }, + { + message: 'First & last name are required', + }, + ), +}) + +const formSchema = z.object({ + eventName: z.string().min(1, 'Event name is required'), + eventType: z.string().min(1, 'Event type is required'), + eventDate: z.string().min(1, 'Event date is required'), + suppressSurvey: z.boolean(), + attendees: z.array(attendeeSchema), +}) + +type FormValues = z.infer + +type EventFormProps = { + mode: 'event' | 'connection' +} + +export const EventForm = ({ mode }: EventFormProps) => { + const router = useRouter() + const params = useParams() + const queryClient = useQueryClient() + const { user } = useAuthedPageContext() + const eventId = params.id ? String(params.id) : undefined + const isConnection = mode === 'connection' + + const inputRefs = useRef<(HTMLInputElement | null)[]>( + Array(DEFAULT_FIELD_COUNT).fill(null), + ) + const [activeInputIndex, setActiveInputIndex] = useState(0) + + // Fetch activist list from server. + // We don't want to do this during SSR b/c it's several MB, + // and eventually we'd like to cache this data on the client + // to avoid sending it on every page load. + const { data: activistData, isLoading: isLoadingActivists } = useQuery({ + queryKey: [API_PATH.ACTIVIST_LIST_BASIC], + queryFn: apiClient.getActivistListBasic, + }) + + // Fetch existing event/connection, if editing. + // (Note: This data is prefetched during SSR for edit pages.) + const { data: eventData } = useQuery({ + queryKey: [API_PATH.EVENT_GET, eventId], + queryFn: () => apiClient.getEvent(Number(eventId)), + enabled: !!eventId, + }) + + // Create activist registry for autocomplete. + const activistRegistry = useMemo( + () => new ActivistRegistry(activistData?.activists || []), + [activistData?.activists], + ) + + const saveEventMutation = useMutation({ + mutationFn: apiClient.saveEvent, + onSuccess: (result, variables) => { + toast.success(`${isConnection ? 'Connection' : 'Event'} saved!`) + + // TODO(jh): once the vue page is removed, update the api to just + // return a json payload w/ the event id instead of a redirect. + // for now, we'll extract the id from the redirect url to stay + // in the react app for testing. + if (result.redirect) { + // Parse redirect like "/update_event/8" or "/update_connection/5" + const match = result.redirect.match( + /\/(update_event|update_connection)\/(\d+)/, + ) + if (match) { + const newEventId = match[2] + // Update URL to include the new event ID. + const newPath = isConnection + ? `/coaching/${newEventId}` + : `/event/${newEventId}` + router.push(newPath) + } else { + // Fallback: redirect to legacy route if we can't parse it. + window.location.href = result.redirect + return + } + } + + // Reset the form's dirty state after successful save. + // This prevents "unsaved changes" warning after successful save. + // TanStack form has a bug requiring the `keepDefaultValues` option: + // https://github.com/TanStack/form/issues/1798. + form.reset( + { + eventName: variables.event_name, + eventType: variables.event_type, + eventDate: variables.event_date, + suppressSurvey: variables.suppress_survey, + attendees: (result.attendees ?? []) + .map((name) => ({ name })) + .concat( + Array(MIN_EMPTY_FIELDS) + .fill(null) + .map(() => ({ name: '' })), + ), + }, + { keepDefaultValues: true }, + ) + + // Refresh activist list to include newly created activists. + // This ensures they appear in autocomplete suggestions. + queryClient.invalidateQueries({ + queryKey: [API_PATH.ACTIVIST_LIST_BASIC], + }) + queryClient.invalidateQueries({ + queryKey: [API_PATH.EVENT_GET, eventId], + }) + }, + onError: (error: Error) => { + toast.error(error.message || 'Error saving event') + }, + }) + + const initialValues: FormValues = useMemo(() => { + if (eventId && !eventData) { + throw new Error('Expected event data to be prefetched') + } + return { + eventName: eventData?.event_name || '', + eventType: eventData?.event_type || (isConnection ? 'Connection' : ''), + eventDate: eventData?.event_date || '', + // For new events, non-SF Bay chapters default to not sending surveys. + suppressSurvey: + eventData?.suppress_survey ?? user.ChapterID !== SF_BAY_CHAPTER_ID, + attendees: + eventData?.attendees && eventData.attendees.length > 0 + ? [ + ...eventData.attendees.map((name) => ({ name })), + ...Array(MIN_EMPTY_FIELDS) + .fill(null) + .map(() => ({ name: '' })), + ] + : Array(DEFAULT_FIELD_COUNT) + .fill(null) + .map(() => ({ name: '' })), + } + }, [eventData, eventId, isConnection, user.ChapterID]) + + const form = useForm({ + defaultValues: initialValues, + validators: { + onSubmit: formSchema, + }, + onSubmitInvalid: () => { + toast.error('Please fix the errors before saving') + }, + onSubmit: async ({ value }) => { + // Filter out empty attendees. + const attendeeNames = value.attendees + .map((a) => a.name.trim()) + .filter((n) => n !== '') + + if (attendeeNames.length === 0) { + toast.error('At least one attendee is required') + return + } + + // Check for duplicates. + const uniqueNames = new Set(attendeeNames) + if (uniqueNames.size !== attendeeNames.length) { + toast.error('Please remove duplicates before saving') + return + } + + // Calculate diff from original. + const oldAttendeesSet = new Set( + (form.options.defaultValues?.attendees || []) + .map((a) => a.name.trim()) + .filter((n) => n !== ''), + ) + + const addedAttendees = attendeeNames.filter( + (name) => !oldAttendeesSet.has(name), + ) + const deletedAttendees = Array.from(oldAttendeesSet).filter( + (name) => !uniqueNames.has(name), + ) + + await saveEventMutation.mutateAsync({ + event_id: Number(eventId || '0'), + event_name: value.eventName.trim(), + event_date: value.eventDate, + event_type: value.eventType, + added_attendees: addedAttendees, + deleted_attendees: deletedAttendees, + suppress_survey: value.suppressSurvey, + }) + }, + }) + + const checkForDuplicate = (value: string, currentIndex: number): boolean => { + const attendees = form.state.values.attendees + const matches = attendees.filter( + (a, idx) => idx !== currentIndex && a.name === value, + ) + return matches.length > 0 + } + + // Subscribe to form state to reactively show/hide the survey checkbox. + const eventType = useStore(form.store, (state) => state.values.eventType) + const eventName = useStore(form.store, (state) => state.values.eventName) + + // Predicts whether the server will send a survey by default. + const shouldShowSuppressSurveyCheckbox = useMemo(() => { + if (user.ChapterID !== SF_BAY_CHAPTER_ID) return false + + const surveyMatchers = [ + // Surveys are sent for events containing these strings in the name. + { nameContains: 'chapter meeting' }, + // Surveys are sent for all events of these types. + { type: 'Action' }, + { type: 'Campaign Action' }, + { type: 'Community' }, + { type: 'Animal Care' }, + ] + + return surveyMatchers.some((matcher) => { + // If event name, check if included (case-insensitive). + if ( + matcher.nameContains && + !eventName.toLowerCase().includes(matcher.nameContains) + ) { + return false + } + // If event type, check for match. + if (matcher.type && matcher.type !== eventType) { + return false + } + return true + }) + }, [eventName, eventType, user.ChapterID]) + + const attendeeCount = useMemo( + () => + form.state.values.attendees.filter((a) => a.name.trim() !== '').length, + [form.state.values.attendees], + ) + + const ensureMinimumEmptyFields = () => { + const emptyCount = form.state.values.attendees.filter( + (it) => !it.name.length, + ).length + if (emptyCount < MIN_EMPTY_FIELDS) { + form.pushFieldValue('attendees', { name: '' }) + } + } + + // Warn before leaving with unsaved changes. + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (form.state.isDirty) { + // `preventDefault()` + setting `returnValue` triggers the + // browser's native unsaved changes warning dialog. Modern + // browsers ignore custom messages in returnValue for security, + // so we use empty string. + e.preventDefault() + e.returnValue = '' + } + } + + window.addEventListener('beforeunload', handleBeforeUnload) + return () => window.removeEventListener('beforeunload', handleBeforeUnload) + }, [form.state.isDirty]) + + const setDateToToday = () => { + // Get today's date in YYYY-MM-DD format in the browser's local timezone + const today = new Date() + const year = today.getFullYear() + const month = String(today.getMonth() + 1).padStart(2, '0') + const day = String(today.getDate()).padStart(2, '0') + form.setFieldValue('eventDate', `${year}-${month}-${day}`) + } + + // Only show loading for activist list since event data is prefetched during SSR + if (isLoadingActivists) { + return ( +
+
+
+

Loading activist names...

+
+
+ ) + } + + return ( +
{ + e.preventDefault() + e.stopPropagation() + await form.handleSubmit() + }} + className="flex flex-col gap-6" + > + {/* Event/Connection Name Field */} + + {(field) => ( +
+ + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + placeholder={`Enter ${isConnection ? 'connection' : 'event'} name`} + className={cn(field.state.meta.errors[0] && 'border-red-500')} + /> + {field.state.meta.errors[0] && ( +

+ {field.state.meta.errors[0]?.message} +

+ )} +
+ )} +
+ + {/* Event Type Field - Only show for events, not connections */} + {!isConnection && ( + + {(field) => ( +
+ + + {field.state.meta.errors[0] && ( +

+ {field.state.meta.errors[0]?.message} +

+ )} +
+ )} +
+ )} + + {/* Event Date Field */} + + {(field) => ( +
+ +
+
+ field.handleChange(e.target.value)} + onBlur={field.handleBlur} + className={cn(field.state.meta.errors[0] && 'border-red-500')} + /> + {field.state.meta.errors[0] && ( +

+ {field.state.meta.errors[0]?.message} +

+ )} +
+ +
+
+ )} +
+ + {/* Suppress Survey Checkbox */} + {shouldShowSuppressSurveyCheckbox && ( + + {(field) => ( +
+ + field.handleChange(Boolean(checked)) + } + /> + {/* TODO: Consider renaming to "Send survey" with box checked by default. */} + +
+ )} +
+ )} + + {/* Attendees/Coachees Section */} + + {(arrayField) => ( +
+ +
+ {arrayField.state.value.map((_, index) => { + const isFocused = index === activeInputIndex + return ( + + {(field) => ( + { + inputRefs.current[index] = el + }} + onFocus={setActiveInputIndex} + onAdvanceFocus={() => { + if (index < arrayField.state.value.length - 1) { + inputRefs.current[index + 1]?.focus() + } + }} + onChange={ensureMinimumEmptyFields} + /> + )} + + ) + })} +
+
+ )} +
+ + {/* Save Button with Attendee/Coachee Count */} +
+
+

+ Total {isConnection ? 'coachees' : 'attendees'} +

+

{attendeeCount}

+
+
+
+ {form.state.isDirty && ( + Unsaved changes + )} +
+ +
+
+
+ ) +} diff --git a/frontend-v2/src/app/event/page.tsx b/frontend-v2/src/app/event/page.tsx new file mode 100644 index 00000000..dac38f3e --- /dev/null +++ b/frontend-v2/src/app/event/page.tsx @@ -0,0 +1,19 @@ +import { ContentWrapper } from '@/app/content-wrapper' +import { AuthedPageLayout } from '@/app/authed-page-layout' +import { EventForm } from './event-form' +import { Navbar } from '@/components/nav' +import { Suspense } from 'react' + +export default async function AttendancePage() { + return ( + + + +

Attendance

+ Loading form...
}> + + + + + ) +} diff --git a/frontend-v2/src/app/login/page.tsx b/frontend-v2/src/app/login/page.tsx index 28686501..7d9d65be 100644 --- a/frontend-v2/src/app/login/page.tsx +++ b/frontend-v2/src/app/login/page.tsx @@ -25,7 +25,7 @@ export default async function LoginPage() { // // This invokes the Sign-in-with-Google HTML API. Note that Google also provides a JavaScript API for more complex // use cases. - let signInwithGoogleHtmlInvocation = ( + const signInwithGoogleHtmlInvocation = ( <>
({ + const form = useForm({ defaultValues: initialValues, validators: { onSubmit: userFormSubmitSchema, @@ -211,7 +211,7 @@ export function UserForm({ userId }: { userId?: number }) { /> {field.state.meta.errors[0] && (

- {field.state.meta.errors[0]} + {field.state.meta.errors[0]?.message}

)}
@@ -232,7 +232,7 @@ export function UserForm({ userId }: { userId?: number }) { /> {field.state.meta.errors[0] && (

- {field.state.meta.errors[0]} + {field.state.meta.errors[0]?.message}

)} @@ -271,7 +271,7 @@ export function UserForm({ userId }: { userId?: number }) { {field.state.meta.errors[0] && (

- {field.state.meta.errors[0]} + {field.state.meta.errors[0]?.message}

)} @@ -327,7 +327,7 @@ export function UserForm({ userId }: { userId?: number }) { {field.state.meta.errors[0] && (

- {field.state.meta.errors[0]} + {field.state.meta.errors[0]?.message}

)} diff --git a/frontend-v2/src/components/nav.tsx b/frontend-v2/src/components/nav.tsx index 93bae8a9..a799d106 100644 --- a/frontend-v2/src/components/nav.tsx +++ b/frontend-v2/src/components/nav.tsx @@ -16,8 +16,7 @@ import buefyStyles from './nav.module.css' import clsx from 'clsx' import { useQuery, useQueryClient } from '@tanstack/react-query' import { API_PATH, apiClient } from '@/lib/api' - -const SF_BAY_CHAPTER_ID = process.env.NODE_ENV === 'production' ? 47 : 1 +import { SF_BAY_CHAPTER_ID } from '@/lib/constants' function userHasAccess( /** Auth'd user. */ diff --git a/frontend-v2/src/lib/api.ts b/frontend-v2/src/lib/api.ts index 30e66e22..0c249475 100644 --- a/frontend-v2/src/lib/api.ts +++ b/frontend-v2/src/lib/api.ts @@ -4,10 +4,13 @@ import { z } from 'zod' export const API_PATH = { STATIC_RESOURCE_HASH: 'static_resources_hash', ACTIVIST_NAMES_GET: 'activist_names/get', + ACTIVIST_LIST_BASIC: 'activist/list_basic', USER_ME: 'user/me', CSRF_TOKEN: 'api/csrf-token', CHAPTER_LIST: 'chapter/list', USERS: 'api/users', + EVENT_GET: 'event/get', + EVENT_SAVE: 'event/save', } export const StaticResourcesHashResp = z.object({ @@ -77,6 +80,46 @@ export const ActivistNamesResp = z.object({ activist_names: z.array(z.string()), }) +export const ActivistListBasicResp = z.object({ + activists: z.array( + z.object({ + name: z.string(), + email: z.string().optional(), + phone: z.string().optional(), + }), + ), +}) + +export type ActivistListBasic = z.infer + +const EventGetResp = z.object({ + event: z.object({ + event_name: z.string(), + event_type: z.string(), + event_date: z.string(), + attendees: z.array(z.string()).nullable(), + suppress_survey: z.boolean(), + }), +}) + +export type EventData = z.infer['event'] + +interface SaveEventParams { + event_id: number + event_name: string + event_date: string + event_type: string + added_attendees: string[] + deleted_attendees: string[] + suppress_survey: boolean +} + +const EventSaveResp = z.object({ + status: z.literal('success'), + redirect: z.string().optional(), + attendees: z.array(z.string()).nullish(), +}) + const ApiErrorResp = z.object({ status: z.literal('error'), message: z.string(), @@ -152,6 +195,11 @@ export class ApiClient { return ActivistNamesResp.parse(resp) } + getActivistListBasic = async () => { + const resp = await this.client.get(API_PATH.ACTIVIST_LIST_BASIC).json() + return ActivistListBasicResp.parse(resp) + } + getChapterList = async () => { try { const resp = await this.client.get(API_PATH.CHAPTER_LIST).json() @@ -208,6 +256,32 @@ export class ApiClient { return this.handleKyError(err) } } + + getEvent = async (eventId: number) => { + try { + const resp = await this.client + .get(`${API_PATH.EVENT_GET}/${eventId}`) + .json() + return EventGetResp.parse(resp).event + } catch (err) { + return this.handleKyError(err) + } + } + + saveEvent = async (payload: SaveEventParams) => { + try { + const csrfToken = this.getCsrfToken() + const resp = await this.client + .post(API_PATH.EVENT_SAVE, { + json: payload, + headers: { 'X-CSRF-Token': csrfToken }, + }) + .json() + return EventSaveResp.parse(resp) + } catch (err) { + return this.handleKyError(err) + } + } } /** Single API client to be used from client-side calls. diff --git a/frontend-v2/src/lib/constants.ts b/frontend-v2/src/lib/constants.ts new file mode 100644 index 00000000..8dbaa7d2 --- /dev/null +++ b/frontend-v2/src/lib/constants.ts @@ -0,0 +1,9 @@ +/** + * Shared constants used across the application. + */ + +/** + * Chapter ID for SF Bay Area chapter. + * Different values for production vs development environments. + */ +export const SF_BAY_CHAPTER_ID = process.env.NODE_ENV === 'production' ? 47 : 1 diff --git a/shared/nav.json b/shared/nav.json index 5d5d0335..cadc08e7 100644 --- a/shared/nav.json +++ b/shared/nav.json @@ -141,6 +141,16 @@ "label": "Beta", "roleRequired": ["admin"], "items": [ + { + "label": "New Event", + "href": "/v2/event", + "page": "NewEvent_beta" + }, + { + "label": "New Connection", + "href": "/v2/coaching", + "page": "NewConnection_beta" + }, { "label": "Test Page", "href": "/v2/example-server-component",