diff --git a/package.json b/package.json index e7b7dd39..1ef494c6 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "leaflet.markercluster": "^1.5.3", "leva": "^0.10.1", "motion": "^12.29.0", - "next": "15.5.21", + "next": "15.5.24", "next-sitemap": "^4.2.3", "phone": "^3.1.70", "postal-code-validator": "^1.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24e03880..863c8829 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,7 +38,7 @@ importers: version: 2.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@vercel/analytics': specifier: ^1.6.1 - version: 1.6.1(next@15.5.21(@babel/core@7.28.6)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 1.6.1(next@15.5.24(@babel/core@7.28.6)(@playwright/test@1.60.0)(@types/node@20.19.30)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) '@xyflow/react': specifier: ^12.10.0 version: 12.11.2(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -64,11 +64,11 @@ importers: specifier: ^12.29.0 version: 12.29.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next: - specifier: 15.5.21 - version: 15.5.21(@babel/core@7.28.6)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: 15.5.24 + version: 15.5.24(@babel/core@7.28.6)(@playwright/test@1.60.0)(@types/node@20.19.30)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-sitemap: specifier: ^4.2.3 - version: 4.2.3(next@15.5.21(@babel/core@7.28.6)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) + version: 4.2.3(next@15.5.24(@babel/core@7.28.6)(@playwright/test@1.60.0)(@types/node@20.19.30)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) phone: specifier: ^3.1.70 version: 3.1.70 @@ -792,152 +792,161 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -1083,8 +1092,8 @@ packages: '@next/env@13.5.11': resolution: {integrity: sha512-fbb2C7HChgM7CemdCY+y3N1n8pcTKdqtQLbC7/EQtPdLvlMUT9JX/dBYl8MMZAtYG4uVMyPFHXckb68q/NRwqg==} - '@next/env@15.5.21': - resolution: {integrity: sha512-hjJI/GfrjWHgNguRIBzItjRRu0m3Nrz17GhxsjuHfjIvg9hyg3239REd2dpI+bpMTFuVrVprHzEQ19m++cDtbw==} + '@next/env@15.5.24': + resolution: {integrity: sha512-mBDF7T0XKZjs9SpUAl0buizVO+O02ULjOvWX8o/AZo/5AGw/UAS1Zzcylmd4pqbftzmKQi+L/nB4jgBYKEAl5Q==} '@next/eslint-plugin-next@15.5.9': resolution: {integrity: sha512-kUzXx0iFiXw27cQAViE1yKWnz/nF8JzRmwgMRTMh8qMY90crNsdXJRh2e+R0vBpFR3kk1yvAR7wev7+fCCb79Q==} @@ -1092,54 +1101,54 @@ packages: '@next/eslint-plugin-next@16.2.9': resolution: {integrity: sha512-UZi8+YT/MLgTC9nrrn2Xd4lBYv1B7lVmtWHfPcthAI5Tt/C1LuDe6DfmtCtJ+WQod3ksY4VrKSvk3oMVAnL7qw==} - '@next/swc-darwin-arm64@15.5.21': - resolution: {integrity: sha512-ZfjqPEdi6TRC/fWx7UDbwb1fbVgyh2uD5tVTRKIDZDlYM+UNuE/LafDG2fwuAoZilADpABh46OY/F5qf9JjqLQ==} + '@next/swc-darwin-arm64@15.5.24': + resolution: {integrity: sha512-AGdNLvxZNY6eR2iSnV+6wUa8CiHTMr4F7g3uHH7fT4ICIJBE00R9u4tzN/Vuwsw0cOi8MTD2HJcTCb6siMH88Q==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.5.21': - resolution: {integrity: sha512-TlCf1NpxgQLzTrexuev75xwmNCJMd1/qkJpTVP1GRRcih93hlIBn1P72hkh8T0gnRFr6BmWksQtbyG3jT6jnww==} + '@next/swc-darwin-x64@15.5.24': + resolution: {integrity: sha512-9HrQajBMmGcrrrvDfRimiCrbAPh3E6uHJmwBovYr6Yrmi9p9PZqI876BrXX280wICh3o2XwUlp4blkB0NNBqFg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.5.21': - resolution: {integrity: sha512-LXRsq1p+HvHSi7ygwNcSEEcK0zuo5jS75ZlqFHtOH+LF7qntXAJVJxah+1Pi/GyBm7EpkwU7m4EgbvIKrMqm9A==} + '@next/swc-linux-arm64-gnu@15.5.24': + resolution: {integrity: sha512-rl9LSfE75si0WT3cDgdUC1XYCKS+TgxC+/IjitmeycrAG18X/plIP1/vy8dd/HPycYcIvE688PD7FuvEAiEAew==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@15.5.21': - resolution: {integrity: sha512-hyGixhFxpDKjqoev6l4KlcRBlt9AXWrGhDZwmwg49sMJM5tnKQPSi+SEj9+e5n+l/bthRGZUdh59GKIs6lQPRw==} + '@next/swc-linux-arm64-musl@15.5.24': + resolution: {integrity: sha512-TlNAnpsjxSF3aAUtqnfmtXXf8m9sIDBlmF3c7bTAlnshUYu2U0OxN2uf5d0gcFwqHVEdivJNBcCaqNOwPGNimw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@15.5.21': - resolution: {integrity: sha512-qfE+YfOba6S2+13e8qn1/UozDVNZ2clBlrs8UtDoax4s8ediu6sq93z66OEHUYlb69Tffh5JTNkgtsAKiSuugg==} + '@next/swc-linux-x64-gnu@15.5.24': + resolution: {integrity: sha512-7dwtlhr0SLndqTG1z9ncRkbJswDZiKWlxzFyXDvJ2RDZRDRHp8zyMJ4D9UH/FgnQeXxxB6gZy2pMcIUoNKQ4pA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@15.5.21': - resolution: {integrity: sha512-BXLGG+EvIwp/Rrgl6HY8sqvD6BOUOIRz8/naDbeLNX7mlA5H2XRcL6MW/0IGnJISfj5BA9gNhFyJj5yOoiIDJQ==} + '@next/swc-linux-x64-musl@15.5.24': + resolution: {integrity: sha512-kGZxM+WhkYs0276lFrMkj7PRtXT3Btp6cwvfSO/cCVLxJJttB5Ccnl2niaCgUja8HgSbEVnMHpg3FJWoOJ9e/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@15.5.21': - resolution: {integrity: sha512-tNGNOlT0Wn7E4IMsSnufjXN/l2L2/AGdLLpa2vzS89SYCBuihgLn3ngLsIrvndAnWo9nAkus+4gZHTI/Ijx9HA==} + '@next/swc-win32-arm64-msvc@15.5.24': + resolution: {integrity: sha512-jBDDkZ/qKAqkWivWDMkJSXUzbzV0QKRBKJjEHUAvSB97Hzw7NLzJ6yV56Lts/wjir7s4P31GYgpbS6ZL+hasAA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.5.21': - resolution: {integrity: sha512-DmIdWmC9p4rdNIiQqo8ap0+Cnj6kKtTZnuSCxoYydSc8sgpDgAg9wFhxplunak9imLV0pTvc5WVCOHwm5eHLtQ==} + '@next/swc-win32-x64-msvc@15.5.24': + resolution: {integrity: sha512-JqtwjvvorjacQ0spgjmUJoxySoYgPwdT1sFdQ0/zmW4iMlP2hjYlCoJIyS7o6Epb4Fug8eco3HXFoBDUCDeH7Q==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2568,8 +2577,8 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001809: - resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} @@ -3980,11 +3989,6 @@ packages: postprocessing: '>=6.30.0' three: '>=0.137' - nanoid@3.3.18: - resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - nanoid@3.3.19: resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -4005,8 +4009,8 @@ packages: peerDependencies: next: '*' - next@15.5.21: - resolution: {integrity: sha512-/TsdBtkWLhkl+NVL3Uqws2UphNd6IPzOtzSk1fHaf+0P7GQKLZDUytyhns/Ykbzdy9+YRjwG7ONvrHaaTDdFqQ==} + next@15.5.24: + resolution: {integrity: sha512-Y+xn8EQCoC3ZbsFPyzE+tE8XOdrWeUdUF7NeXbmg9DsgAxl5UYxlsrvgVESHTyTGigoTa1bCUrxn70F5bqt0Gw==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: @@ -4534,9 +4538,14 @@ packages: engines: {node: '>= 0.10'} hasBin: true - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} @@ -5768,98 +5777,108 @@ snapshots: '@img/colour@1.1.0': optional: true - '@img/sharp-darwin-arm64@0.34.5': + '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 optional: true - '@img/sharp-darwin-x64@0.34.5': + '@img/sharp-darwin-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.3.3 optional: true - '@img/sharp-libvips-darwin-arm64@1.2.4': + '@img/sharp-freebsd-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.3': optional: true - '@img/sharp-libvips-darwin-x64@1.2.4': + '@img/sharp-libvips-darwin-x64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm64@1.2.4': + '@img/sharp-libvips-linux-arm64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm@1.2.4': + '@img/sharp-libvips-linux-arm@1.3.3': optional: true - '@img/sharp-libvips-linux-ppc64@1.2.4': + '@img/sharp-libvips-linux-ppc64@1.3.3': optional: true - '@img/sharp-libvips-linux-riscv64@1.2.4': + '@img/sharp-libvips-linux-riscv64@1.3.3': optional: true - '@img/sharp-libvips-linux-s390x@1.2.4': + '@img/sharp-libvips-linux-s390x@1.3.3': optional: true - '@img/sharp-libvips-linux-x64@1.2.4': + '@img/sharp-libvips-linux-x64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.2.4': + '@img/sharp-libvips-linuxmusl-x64@1.3.3': optional: true - '@img/sharp-linux-arm64@0.34.5': + '@img/sharp-linux-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.3.3 optional: true - '@img/sharp-linux-arm@0.34.5': + '@img/sharp-linux-arm@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.3.3 optional: true - '@img/sharp-linux-ppc64@0.34.5': + '@img/sharp-linux-ppc64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.3.3 optional: true - '@img/sharp-linux-riscv64@0.34.5': + '@img/sharp-linux-riscv64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.3.3 optional: true - '@img/sharp-linux-s390x@0.34.5': + '@img/sharp-linux-s390x@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.3.3 optional: true - '@img/sharp-linux-x64@0.34.5': + '@img/sharp-linux-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.3.3 optional: true - '@img/sharp-linuxmusl-arm64@0.34.5': + '@img/sharp-linuxmusl-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 optional: true - '@img/sharp-linuxmusl-x64@0.34.5': + '@img/sharp-linuxmusl-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 optional: true - '@img/sharp-wasm32@0.34.5': + '@img/sharp-wasm32@0.35.4': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-win32-arm64@0.34.5': + '@img/sharp-webcontainers-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-win32-arm64@0.35.4': optional: true - '@img/sharp-win32-ia32@0.34.5': + '@img/sharp-win32-ia32@0.35.4': optional: true - '@img/sharp-win32-x64@0.34.5': + '@img/sharp-win32-x64@0.35.4': optional: true '@isaacs/cliui@8.0.2': @@ -6114,7 +6133,7 @@ snapshots: '@next/env@13.5.11': {} - '@next/env@15.5.21': {} + '@next/env@15.5.24': {} '@next/eslint-plugin-next@15.5.9': dependencies: @@ -6124,28 +6143,28 @@ snapshots: dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@15.5.21': + '@next/swc-darwin-arm64@15.5.24': optional: true - '@next/swc-darwin-x64@15.5.21': + '@next/swc-darwin-x64@15.5.24': optional: true - '@next/swc-linux-arm64-gnu@15.5.21': + '@next/swc-linux-arm64-gnu@15.5.24': optional: true - '@next/swc-linux-arm64-musl@15.5.21': + '@next/swc-linux-arm64-musl@15.5.24': optional: true - '@next/swc-linux-x64-gnu@15.5.21': + '@next/swc-linux-x64-gnu@15.5.24': optional: true - '@next/swc-linux-x64-musl@15.5.21': + '@next/swc-linux-x64-musl@15.5.24': optional: true - '@next/swc-win32-arm64-msvc@15.5.21': + '@next/swc-win32-arm64-msvc@15.5.24': optional: true - '@next/swc-win32-x64-msvc@15.5.21': + '@next/swc-win32-x64-msvc@15.5.24': optional: true '@nodelib/fs.scandir@2.1.5': @@ -7068,9 +7087,9 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.2.3 - '@vercel/analytics@1.6.1(next@15.5.21(@babel/core@7.28.6)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': + '@vercel/analytics@1.6.1(next@15.5.24(@babel/core@7.28.6)(@playwright/test@1.60.0)(@types/node@20.19.30)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': optionalDependencies: - next: 15.5.21(@babel/core@7.28.6)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + next: 15.5.24(@babel/core@7.28.6)(@playwright/test@1.60.0)(@types/node@20.19.30)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 '@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@20.19.30)(jiti@1.21.7)(yaml@2.8.0))': @@ -7445,7 +7464,7 @@ snapshots: browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.9.17 - caniuse-lite: 1.0.30001809 + caniuse-lite: 1.0.30001810 electron-to-chromium: 1.5.277 node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) @@ -7453,7 +7472,7 @@ snapshots: browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.37 - caniuse-lite: 1.0.30001809 + caniuse-lite: 1.0.30001810 electron-to-chromium: 1.5.374 node-releases: 2.0.47 update-browserslist-db: 1.2.3(browserslist@4.28.2) @@ -7508,7 +7527,7 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001809: {} + caniuse-lite@1.0.30001810: {} chai@6.2.2: {} @@ -9375,45 +9394,44 @@ snapshots: postprocessing: 6.38.2(three@0.176.0) three: 0.176.0 - nanoid@3.3.18: {} - nanoid@3.3.19: {} napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} - next-sitemap@4.2.3(next@15.5.21(@babel/core@7.28.6)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)): + next-sitemap@4.2.3(next@15.5.24(@babel/core@7.28.6)(@playwright/test@1.60.0)(@types/node@20.19.30)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)): dependencies: '@corex/deepmerge': 4.0.43 '@next/env': 13.5.11 fast-glob: 3.3.3 minimist: 1.2.8 - next: 15.5.21(@babel/core@7.28.6)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + next: 15.5.24(@babel/core@7.28.6)(@playwright/test@1.60.0)(@types/node@20.19.30)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - next@15.5.21(@babel/core@7.28.6)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@15.5.24(@babel/core@7.28.6)(@playwright/test@1.60.0)(@types/node@20.19.30)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - '@next/env': 15.5.21 + '@next/env': 15.5.24 '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001809 + caniuse-lite: 1.0.30001810 postcss: 8.4.31 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) styled-jsx: 5.1.6(@babel/core@7.28.6)(react@19.2.3) optionalDependencies: - '@next/swc-darwin-arm64': 15.5.21 - '@next/swc-darwin-x64': 15.5.21 - '@next/swc-linux-arm64-gnu': 15.5.21 - '@next/swc-linux-arm64-musl': 15.5.21 - '@next/swc-linux-x64-gnu': 15.5.21 - '@next/swc-linux-x64-musl': 15.5.21 - '@next/swc-win32-arm64-msvc': 15.5.21 - '@next/swc-win32-x64-msvc': 15.5.21 + '@next/swc-darwin-arm64': 15.5.24 + '@next/swc-darwin-x64': 15.5.24 + '@next/swc-linux-arm64-gnu': 15.5.24 + '@next/swc-linux-arm64-musl': 15.5.24 + '@next/swc-linux-x64-gnu': 15.5.24 + '@next/swc-linux-x64-musl': 15.5.24 + '@next/swc-win32-arm64-msvc': 15.5.24 + '@next/swc-win32-x64-msvc': 15.5.24 '@playwright/test': 1.60.0 babel-plugin-react-compiler: 1.0.0 - sharp: 0.34.5 + sharp: 0.35.4(@types/node@20.19.30) transitivePeerDependencies: - '@babel/core' + - '@types/node' - babel-plugin-macros node-exports-info@1.6.0: @@ -9646,7 +9664,7 @@ snapshots: postcss@8.4.31: dependencies: - nanoid: 3.3.18 + nanoid: 3.3.19 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -9996,36 +10014,38 @@ snapshots: safe-buffer: 5.2.1 to-buffer: 1.2.2 - sharp@0.34.5: + sharp@0.35.4(@types/node@20.19.30): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 20.19.30 optional: true shebang-command@2.0.0: diff --git a/src/app/volunteer_dashboard/panels/members/MemberBanner.module.css b/src/app/volunteer_dashboard/panels/members/MemberBanner.module.css new file mode 100644 index 00000000..7a7951f0 --- /dev/null +++ b/src/app/volunteer_dashboard/panels/members/MemberBanner.module.css @@ -0,0 +1,98 @@ +.headerTop { + border-radius: 0.75rem; + margin-top: -2rem; + margin-right: 0.9rem; + margin-bottom: 0.9rem; + margin-left: 0.9rem; + padding-top: 0.8rem; + padding-bottom: 0.9rem; + padding-inline: 1rem; + border: 1px solid rgba(255, 255, 255, 0.72); + background: rgba(255, 255, 255, 0.84); + backdrop-filter: blur(16px) saturate(145%); + -webkit-backdrop-filter: blur(16px) saturate(145%); + box-shadow: 0 14px 30px rgba(15, 23, 42, 0.11); + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: space-between; +} + +.cardStyle { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.75rem; +} + +.cardAvatar { + display: flex; + align-items: center; + justify-content: center; + width: 4.5rem; + height: 4.5rem; + border-radius: 9999px; + border: 3px solid #fff; + background: #fff; + box-shadow: 0 10px 20px rgba(15, 23, 42, 0.18); +} + +.userInfo { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.headerUserName { + margin: 0; + font-size: 1.25rem; + font-weight: 700; + color: #0f172a; +} + +.headerUserUsername { + margin: 0; + padding: 0; + color: #475569; + font-size: 0.95rem; + font-weight: 500; +} + +.roleList { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + align-items: center; +} + +.rolePill { + font-size: 0.85rem; + font-weight: 600; + padding-inline: 1rem; + padding-block: 0.45rem; + border-radius: 9999px; + white-space: nowrap; + color: rgba(15, 23, 42, 0.72); + background: rgba(15, 23, 42, 0.06); +} + +.roleEmpty { + font-size: 0.82rem; + color: #64748b; +} + +@media (max-width: 720px) { + .headerTop { + margin: -1.2rem 0.65rem 0.65rem; + padding-inline: 0.8rem; + } + + .cardAvatar { + width: 4rem; + height: 4rem; + } + + .headerUserName { + font-size: 1.1rem; + } +} diff --git a/src/app/volunteer_dashboard/panels/members/MemberBanner.tsx b/src/app/volunteer_dashboard/panels/members/MemberBanner.tsx new file mode 100644 index 00000000..1b880af7 --- /dev/null +++ b/src/app/volunteer_dashboard/panels/members/MemberBanner.tsx @@ -0,0 +1,60 @@ +import styles from './MemberBanner.module.css' +import { DiscordAvatar } from '@/components/common' +import { TabBar, TabSpec } from '@/components/common/tab_bar/TabBar' +import { Position, User, UserProfile } from '@/contracts/data' + +interface MemberBannerProps { + user: User + makeTitle: (user: User | UserProfile) => string + selectedTab: string + tabs: TabSpec[] + onTabChange: (key: string) => void + positions?: Position[] +} + +export function MemberBanner({ + user, + makeTitle, + selectedTab, + tabs, + onTabChange, + positions, +}: MemberBannerProps) { + const userPositions = (positions ?? []).filter((p) => + p.userIds.includes(user.id) + ) + + return ( +
+
+
+ +
+
+

{makeTitle(user)}

+

+ {user.discordUsers?.[0]?.username + ? `@${user.discordUsers[0].username}` + : 'NOT FOUND'} +

+
+
+
+ {userPositions.length > 0 ? ( + userPositions.map((pos) => ( + + {pos.name} + + )) + ) : ( + Community Member + )} +
+ +
+ ) +} diff --git a/src/app/volunteer_dashboard/panels/members/page.module.css b/src/app/volunteer_dashboard/panels/members/page.module.css new file mode 100644 index 00000000..08a84899 --- /dev/null +++ b/src/app/volunteer_dashboard/panels/members/page.module.css @@ -0,0 +1,124 @@ +.sidebarBg { + --navigation-stack-sidebar-bg: #f9fafb; + --navigation-stack-sidebar-padding: 0rem; + --navigation-stack-sidebar-gap: 0rem; +} + +.backButton { + /* --back-button-color: #ff0202; */ + --back-button-hover-color: rgba(255, 255, 255, 0.7); + margin-top: 0.5rem; + --sidebar-back-margin-bottom: -1rem; + position: relative; + z-index: 21; +} + +.detailsHeader { + --banner-cover-height: 3.5rem; + --banner-cover-gradient: + radial-gradient( + circle at 18% 30%, + rgba(2, 132, 199, 0.24), + transparent 35% + ), + radial-gradient( + circle at 82% 20%, + rgba(41, 134, 204, 0.28), + transparent 40% + ), + linear-gradient(120deg, rgba(9, 34, 58, 0.96), rgba(27, 69, 104, 0.9)); + + display: flex; + flex-direction: column; + gap: 0.75rem; + position: sticky; + top: 0; + z-index: 20; + + background: linear-gradient( + to bottom, + rgba(255, 255, 255, 0.95) 72%, + rgba(255, 255, 255, 0) + ); + + border-radius: 1rem; + + &::before { + content: ''; + position: absolute; + top: -100rem; + left: 0; + right: 0; + height: 100rem; + background: var(--banner-cover-gradient); + background-size: 100% var(--banner-cover-height); + background-position: 0 100%; + background-repeat: repeat-y; + pointer-events: none; + z-index: -1; + } +} + +.bannerCover { + height: var(--banner-cover-height); + background: var(--banner-cover-gradient); + background-size: 100% var(--banner-cover-height); + background-position: 0 100%; + background-repeat: no-repeat; +} + +.detailsPane { + flex: 1; + overflow-y: auto; + background: linear-gradient( + 135deg, + rgba(248, 250, 252, 0.98) 0%, + rgba(241, 245, 249, 0.98) 38%, + rgba(14, 165, 233, 0.04) 100% + ); + z-index: 0; +} + +.detailsContent { + margin: 1rem; +} + +.emptyState { + display: flex; + height: 100%; + align-items: center; + justify-content: center; + color: rgba(15, 23, 42, 0.5); + font-weight: 500; +} + +.errorText { + color: #ef4444; +} + +.loading { + display: flex; + align-items: center; +} + +.filterTagsWrapper { + border-bottom: 1px solid rgba(17, 24, 39, 0.08); + + :global([data-sidebar-collapsed='true']) & { + display: none; + } +} + +.filterMenu { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 12rem; +} + +.nestedFilterMenu { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 12rem; +} diff --git a/src/app/volunteer_dashboard/panels/members/page.tsx b/src/app/volunteer_dashboard/panels/members/page.tsx index 1af24e58..ded86342 100644 --- a/src/app/volunteer_dashboard/panels/members/page.tsx +++ b/src/app/volunteer_dashboard/panels/members/page.tsx @@ -1,3 +1,840 @@ +'use client' + +import { MemberBanner } from './MemberBanner' +import styles from './page.module.css' +import { DonorView } from './panel_views/DonorView' +import { HistoryView } from './panel_views/HistoryView' +import { MemberView } from './panel_views/MemberView' +import { FilterTags, FilterTag } from '@/app/admin/layout/FilterTags' +import { ListElement } from '@/app/admin/layout/List' +import { MobileSidebarBackButton } from '@/app/volunteer_dashboard/layout/MobileSidebarBackButton' +import { DiscordAvatar } from '@/components/common' +import { DropdownOverlay, DropdownOverlayButton } from '@/components/common' +import { FormState } from '@/components/common/forms' +import Panel from '@/components/common/panel/Panel' +import { SidebarBody } from '@/components/common/panel/sidebar_list/SidebarBody' +import { TabSpec } from '@/components/common/tab_bar/TabBar' +import { + ActBlueDonor, + Role, + UpdateHistory, + User, + UserProfile, + zActBlueDonor, + zLocation, + zRole, + zUser, + zUserProfile, +} from '@/contracts/data' +import { + ActBlueDonorLinkRequest, + SortDirection, + UpdateUserRequest, + zUpdateUserRequest, +} from '@/contracts/requests' +import { PaginatedResponse } from '@/contracts/responses' +import { FetchError, stateOptions } from '@/models' +import { usePositionQueries } from '@/queries' +import { cn, parseErrorMessage } from '@/util' +import { useCurrentUser, useFetch, usePaginatedSearch } from '@/util/hooks' +import { + keepPreviousData, + skipToken, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { useSearchParams } from 'next/navigation' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + FaUsers, + FaUserTag, + FaBirthdayCake, + FaMapMarkerAlt, +} from 'react-icons/fa' +import { FaClipboardUser, FaDollarSign, FaAddressCard } from 'react-icons/fa6' +import { MdVerified } from 'react-icons/md' +import { useMediaQuery } from 'usehooks-ts' +import z from 'zod' + +type MemberTabKey = 'overview' | 'donorMatching' | 'history' + +const tabs: TabSpec[] = [ + { key: 'overview', label: 'Overview' }, + { key: 'donorMatching', label: 'Donations' }, + { key: 'history', label: 'History' }, +] + +const MEMBER_FIELD_OPTIONS = [ + { value: 'email', label: 'Email' }, + { value: 'phone', label: 'Phone Number' }, + { value: 'zip', label: 'Zip Code' }, + { value: 'county', label: 'County' }, + { value: 'city', label: 'City' }, + { value: 'state', label: 'State' }, + { value: 'preferred_name', label: 'Preferred Name' }, + { value: 'first_name', label: 'First Name' }, + { value: 'last_name', label: 'Last Name' }, + { value: 'birthdate', label: 'Birthdate' }, + { value: 'accepted_alerts', label: 'Accepted Notifications' }, + { value: 'onboarding_stage', label: 'Onboarding Stage' }, + { value: 'created_at_utc', label: 'Date Created' }, + { value: 'joined_at_utc', label: 'Date Joined Server' }, + { value: 'completed_intake_utc', label: 'Date Intake Done' }, + { value: 'aliases', label: 'Aliases' }, + { value: 'discord_usernames', label: 'Discord Usernames' }, +] + +const MEMBER_SORT_FIELD_OPTIONS = [ + { value: 'email', label: 'Email' }, + { value: 'first_name', label: 'First Name' }, + { value: 'last_name', label: 'Last Name' }, + { value: 'created_at_utc', label: 'Created At' }, + { value: 'updated_at_utc', label: 'Recently Edited' }, +] + +// Behavior is pending API support for these filters. +const userFilterOptions = [ + { key: 'verified', label: 'Verified', icon: }, +] + +const currentYear = new Date().getFullYear() +const birthYears = Array.from( + { length: currentYear - 1899 }, + (_, index) => currentYear - index +) + export default function Page() { - return
Dummy Page for Members Panel
+ const queryClient = useQueryClient() + const { ready, onGet, onPatch, onPost } = useFetch() + const navParams = useSearchParams() + const navUserId = navParams.get('userId') + + const initialUserId = navUserId ? Number(navUserId) : null + const [selectedId, setSelectedId] = useState( + Number.isFinite(initialUserId) ? initialUserId : null + ) + + const [selectedHistory, setSelectedHistory] = + useState | null>(null) + const [selectedDonorHistory, setSelectedDonorHistory] = + useState | null>(null) + + const [formState, setFormState] = useState | null>(null) + const [pickingDonor, setPickingDonor] = useState(false) + const [selectedTab, setSelectedTab] = useState('overview') + const [activeFilterTag, setActiveFilterTag] = useState('all') + const [selectedState, setSelectedState] = useState(null) + const [selectedBirthYear, setSelectedBirthYear] = useState( + null + ) + + const selectedStateLabel = stateOptions.find( + (state) => state.value === selectedState + )?.label + const selectedFilterLabel = + [selectedStateLabel, selectedBirthYear].filter(Boolean).join(' · ') || + 'All Users' + const selectedFilterIcon = selectedState ? ( + + ) : selectedBirthYear ? ( + + ) : ( + + ) + + const memberFilterTags: FilterTag[] = [ + { + key: 'members', + label: 'Members', + icon: , + color: '#5997E0', + width: '11.65rem', + activeRedirect: 'all', + scrollLeft: 'members', + scrollRight: 'all', + }, + { + key: 'server', + label: 'Server Members', + icon: , + color: '#62A46C', + width: '11.65rem', + activeRedirect: 'all', + scrollLeft: 'members', + scrollRight: 'all', + }, + { + key: 'donors', + label: 'Donors', + icon: , + color: '#7674B3', + width: '11.65rem', + activeRedirect: 'all', + scrollLeft: 'members', + scrollRight: 'all', + }, + { + key: 'dues', + label: 'Membership', + icon: , + color: '#C65882', + width: '11.65rem', + activeRedirect: 'all', + scrollLeft: 'members', + scrollRight: 'all', + }, + { + key: 'all', + label: selectedFilterLabel, + icon: selectedFilterIcon, + color: '#3A3A3C', + width: '8.4rem', + activeRedirect: 'members', + scrollLeft: 'members', + scrollRight: 'all', + dropdownOverlay: ({ closeDropdown }) => ( + + } + checked={ + selectedState === null && + selectedBirthYear === null + } + onClick={() => { + setSelectedState(null) + setSelectedBirthYear(null) + closeDropdown() + }} + > + All + + {userFilterOptions.map((option) => ( + + {option.label} + + ))} + } + selected={selectedBirthYear !== null} + menu={({ closeMenu }) => ( +
+ {birthYears.map((year) => ( + { + setSelectedState(null) + setSelectedBirthYear(year) + closeMenu() + closeDropdown() + }} + > + {year} + + ))} +
+ )} + > + Age +
+ } + selected={selectedState !== null} + menu={({ closeMenu }) => ( +
+ {stateOptions.map((state) => ( + { + setSelectedBirthYear(null) + setSelectedState( + state.value + ) + closeMenu() + closeDropdown() + }} + > + {state.label} + + ))} +
+ )} + > + State +
+ + } + /> + ), + }, + ] + + const handleFilterTagChange = (key: string) => { + setActiveFilterTag(key) + //others blank on purpose waiting for API side logic to be implemented. + const rest = Object.fromEntries( + Object.entries(search).filter( + ([k]) => + ![ + 'isMember', + 'isDonor', + 'isDuesPaying', + 'isServerMember', + ].includes(k) + ) + ) + const tagFilters: Record = {} + tagFilters.isDonor = [key === 'donors'] + tagFilters.isDuesPaying = [key === 'dues'] + onSearch({ ...rest, page: 0, ...tagFilters }) + } + + const [sidebarMobileVisible, setSidebarMobileVisible] = useState(true) + const isDesktop = useMediaQuery('(min-width: 64rem)') + + const loggedInUser = useCurrentUser() + const positionQueries = usePositionQueries() + + const { + query: searchQuery, + search, + onSearch, + } = usePaginatedSearch('/users', zUserProfile, { + search: { sort: SortDirection.DESC, sortField: 'created_at_utc' }, + }) + + const { query: rolesQuery } = usePaginatedSearch('/roles', zRole, { + search: { limit: 50 }, + all: true, + }) + + const { + query: donorSearchQuery, + search: donorSearch, + onSearch: onDonorSearch, + } = usePaginatedSearch('/actblue/donors', zActBlueDonor) + + const positionHierarchy = useQuery({ + queryKey: ['positionHierarchy'], + queryFn: positionQueries.getPositionHierarchy, + enabled: positionQueries.ready, + }) + + const roles = rolesQuery.data?.data ?? [] + const roleOptions = useMemo( + () => + (rolesQuery.data?.data ?? []).map((role) => ({ + value: role.id, + label: role.name, + })), + [rolesQuery.data] + ) + + const userQuery = useQuery({ + queryKey: [`/users/${selectedId}`], + queryFn: + ready && selectedId != null + ? ({ signal }) => + onGet('/users/:userId', zUser, { + params: { userId: selectedId }, + query: { + includeDiscordUsers: true, + includeHistory: true, + includeDonors: true, + }, + signal, + }) + : skipToken, + placeholderData: keepPreviousData, + }) + + const updateMutation = useMutation< + User, + FetchError, + { id: number; user: User; request: UpdateUserRequest }, + User | undefined + >({ + mutationFn: async ({ id, user, request }) => { + const result = await onPatch('/users/:userId', request, zUser, { + params: { userId: id }, + }) + return { ...user, ...result } + }, + onMutate: ({ id, user }) => { + const prev: User | undefined = queryClient.getQueryData([ + `/users/${id}`, + ]) + + queryClient.setQueryData([`/users/${id}`], user) + + if (id == loggedInUser.data?.id) + queryClient.setQueryData(['/users/current'], user) + + queryClient.setQueryData( + ['/users', search], + (res: PaginatedResponse) => ({ + ...res, + data: res.data.map((prev) => + prev.id == user.id ? user : prev + ), + }) + ) + + return prev + }, + onError: (error, { id }, prev) => { + console.error(error) + + queryClient.setQueryData([`/users/${id}`], prev) + + if (id == loggedInUser.data?.id) + queryClient.setQueryData(['/users/current'], prev) + + queryClient.setQueryData( + [`/users`, search], + (res: PaginatedResponse) => ({ + ...res, + data: res.data.map((user) => + user.id == prev?.id ? prev : user + ), + }) + ) + }, + onSuccess: (data, { id }) => { + queryClient.setQueryData([`/users/${id}`], data) + + if (id == loggedInUser.data?.id) + queryClient.setQueryData(['/users/current'], data) + + queryClient.setQueryData( + [`/users`, search], + (res: PaginatedResponse) => ({ + ...res, + data: res.data.map((user) => + user.id == data.id ? data : user + ), + }) + ) + }, + onSettled: (_data, _error, { id }) => + Promise.all([ + queryClient.invalidateQueries({ queryKey: ['/users', search] }), + queryClient.invalidateQueries({ queryKey: ['/users/current'] }), + queryClient.invalidateQueries({ + queryKey: [`/users/${id}`], + }), + ]), + }) + + const handleSelectDonorItem = useCallback( + async (value: ActBlueDonor, userId: number) => { + setPickingDonor(false) + + await onPost( + '/actblue/donors/:donorEmail/link', + { + userId, + metaData: { + dataSource: 'Member Panel', + userWhoUpdatedId: loggedInUser.data?.id, + }, + } satisfies ActBlueDonorLinkRequest, + null, + { params: { donorEmail: value.email } } + ) + + await queryClient.invalidateQueries({ + queryKey: [`/users/${userId}`], + }) + }, + [onPost, queryClient, loggedInUser.data] + ) + + const handleDeleteDonorItem = useCallback( + async (value: ActBlueDonor, userId: number) => { + await onPost( + '/actblue/donors/:donorEmail/link', + { + userId: null, + metaData: { + dataSource: 'Member Panel', + userWhoUpdatedId: loggedInUser.data?.id, + }, + }, + null, + { params: { donorEmail: value.email } } + ) + + await queryClient.invalidateQueries({ + queryKey: [`/users/${userId}`], + }) + }, + [onPost, queryClient, loggedInUser.data?.id] + ) + + const handleSelectItem = (value: UserProfile | User) => { + if (value?.id === selectedId) return + + if (formState?.dirty) { + const proceed = confirm( + 'You have unsaved changes! Selecting a new list element will discard them.' + ) + if (!proceed) return + } + + setSelectedId(value.id) + + setSelectedHistory(null) + setSelectedDonorHistory(null) + setSelectedTab('overview') + } + + const locationQuery = useQuery({ + queryKey: [`/locations/${formState?.form?.address?.zip}`], + queryFn: + ready && formState?.mode === 'edit' && formState.form.address?.zip + ? async ({ signal }) => { + try { + return await onGet('/locations/:zip', zLocation, { + params: { zip: formState.form.address.zip! }, + signal, + }) + } catch { + return null + } + } + : skipToken, + placeholderData: keepPreviousData, + }) + + const handleSave = useCallback( + (user: User) => { + const orNull = (value: string | null | undefined) => + value?.length ? value : null + + const address = { + addressLine1: orNull(user.address.addressLine1?.trim()), + addressLine2: orNull(user.address.addressLine2?.trim()), + city: + orNull(user.address.city?.trim()) ?? + locationQuery.data?.city, + county: + orNull(user.address.county?.trim()) ?? + locationQuery.data?.county, + state: + orNull(user.address.state?.trim()) ?? + locationQuery.data?.state, + zip: + orNull(user.address.zip?.trim()) ?? + locationQuery.data?.zip?.toString().padStart(5, '0'), + } + + const oldAddress = userQuery.data?.address ?? null + const addressIsDirty = + address.addressLine1 != oldAddress?.addressLine1 || + address.addressLine2 != oldAddress?.addressLine2 || + address.city != oldAddress?.city || + address.county != oldAddress?.county || + address.state != oldAddress?.state || + address.zip != oldAddress?.zip + + const request: UpdateUserRequest = z.parse(zUpdateUserRequest, { + email: user.email, + phone: user.phone, + preferredName: user.preferredName, + firstName: user.firstName, + lastName: user.lastName, + birthdate: user.birthdate, + membershipCardStatus: +user.membershipCardStatus, + membershipMerchStatus: +user.membershipMerchStatus, + shirtSize: user.shirtSize, + duesPayingMember: user.duesPayingMember, + membershipFulfillmentStatus: user.membershipFulfillmentStatus + ? +user.membershipFulfillmentStatus + : null, + nameConfirmed: user.nameConfirmed, + addressConfirmed: user.addressConfirmed, + roles: user.roles?.map((role) => role.id), + } satisfies UpdateUserRequest) + if (addressIsDirty) request.address = address + + updateMutation.mutate({ id: user.id, user, request }) + }, + [locationQuery.data, updateMutation, userQuery.data?.address] + ) + + const makeTitle = useCallback((user: User | UserProfile) => { + if (user.firstName && user.lastName) + return `${user.firstName} ${user.lastName}` + if (user.firstName) return user.firstName + if (user.preferredName) return user.preferredName + return user.email ?? '' + }, []) + + const normalizeMeridiem = useCallback((value: string) => { + return value.replace(/\s*([AP])M\b/g, (_, period: string) => { + return `${period.toLowerCase()}m` + }) + }, []) + + const makeHistoryFormTitle = useCallback( + (user: User | UserProfile) => { + const name = makeTitle(user) + if (!selectedHistory) return name + return `${name} @ ${normalizeMeridiem(selectedHistory.historyWhenUpdatedUtc.toLocaleString())}` + }, + [makeTitle, normalizeMeridiem, selectedHistory] + ) + + const renderDonorItem = useCallback( + (item: ActBlueDonor, userId: number) => { + return ( + void handleSelectDonorItem(item, userId)} + > + {`${item.firstname} ${item.lastname}`} + + ) + }, + [handleSelectDonorItem] + ) + + const pinnedUsers = useMemo(() => { + const currentUser = loggedInUser.data + return currentUser ? [currentUser] : [] + }, [loggedInUser.data]) + + const users = useMemo(() => { + const fetchedUsers = searchQuery.data?.data ?? [] + const currentUser = loggedInUser.data + + if (!currentUser) return fetchedUsers + + return fetchedUsers.filter((user) => user.id !== currentUser.id) + }, [searchQuery.data?.data, loggedInUser.data]) + + useEffect(() => { + if (navUserId == null) return + + const nextSelectedId = Number(navUserId) + + setSelectedId(Number.isFinite(nextSelectedId) ? nextSelectedId : null) + }, [navUserId]) + + const renderPage = () => { + if (!selectedId || !userQuery.data) return null + + switch (selectedTab) { + case 'overview': + return ( + makeTitle(userQuery.data)} + handleSave={handleSave} + /> + ) + + case 'donorMatching': + return ( + + void handleDeleteDonorItem(value, userId) + } + /> + ) + + case 'history': + return ( + makeHistoryFormTitle(u)} + /> + ) + + default: + return null + } + } + + return ( + + onSearch({ ...search, page: nextPage }), + }, + filters: { + search, + onSearch, + searchFieldOptions: MEMBER_FIELD_OPTIONS, + sortFieldOptions: MEMBER_SORT_FIELD_OPTIONS, + showSort: true, + showLimit: true, + options: [ + { + label: 'Role', + value: 'roleIds', + options: roles.map((role) => ({ + label: role.name, + value: role.id, + })), + }, + ], + }, + }} + sidebarBody={ + <> +
+ +
+ + items={users} + pinnedItems={pinnedUsers} + isLoading={searchQuery.isPending} + error={searchQuery.error} + selectedKey={selectedId} + renderItem={(user) => ({ + key: user.id, + label: makeTitle(user), + subtitle: user.discordUsers?.[0]?.username + ? `@${user.discordUsers[0].username}` + : 'NOT FOUND', + tagLabel: + user.id === loggedInUser.data?.id + ? 'You' + : undefined, + icon: ( + + ), + href: `/volunteer_dashboard/panels/members?userId=${user.id}`, + onClick: (event) => { + event.preventDefault() + handleSelectItem(user) + if (!isDesktop) { + setSidebarMobileVisible(false) + } + }, + })} + /> + + } + > +
+ setSidebarMobileVisible(true)} + className={styles.backButton} + /> + + {selectedId == null && ( +
No user selected
+ )} + + {selectedId != null && userQuery.isPending && ( +
+ Loading user details... +
+ )} + + {selectedId != null && userQuery.error && ( +
+ Error: {parseErrorMessage(userQuery.error)} +
+ )} + + {selectedId && userQuery.data && ( + <> +
+
+ + setSelectedTab(key as MemberTabKey) + } + positions={positionHierarchy.data?.positions} + /> +
+
+ {renderPage()} +
+ + )} +
+ + ) } diff --git a/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.module.css b/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.module.css new file mode 100644 index 00000000..d020fc7f --- /dev/null +++ b/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.module.css @@ -0,0 +1,369 @@ +.donorPane { + flex: 1; + overflow-y: auto; +} + +.root { + width: 100%; + min-width: 0; + height: 100%; +} + +.emptyStage { + height: 100%; + min-height: 24rem; + display: flex; + align-items: center; + justify-content: center; + padding: 1.25rem; +} + +.emptyCard { + width: min(38rem, 100%); + border-radius: 1rem; + padding: 1.1rem 1.1rem 1rem; + background: rgba(255, 255, 255, 0.72); + backdrop-filter: blur(18px) saturate(160%); + -webkit-backdrop-filter: blur(18px) saturate(160%); + border: 1px solid rgba(17, 24, 39, 0.1); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.75) inset, + 0 18px 45px rgba(15, 23, 42, 0.025); +} + +.emptyCardHeader { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-bottom: 0.9rem; +} + +.emptyTitle { + font-size: 1.05rem; + font-weight: 650; + letter-spacing: -0.01em; + color: #0f172a; +} + +.emptySubtitle { + font-size: 0.95rem; + color: #475569; + line-height: 1.35; +} + +.emptyActions { + display: flex; + gap: 0.6rem; +} + +.linkedStage { + display: flex; + flex-direction: column; + gap: 0.85rem; + padding: 1rem; +} + +.linkedHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + border-radius: 1rem; + padding: 0.95rem 1rem; + background: rgba(255, 255, 255, 0.72); + backdrop-filter: blur(18px) saturate(160%); + -webkit-backdrop-filter: blur(18px) saturate(160%); + border: 1px solid rgba(17, 24, 39, 0.1); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.75) inset, + 0 18px 45px rgba(15, 23, 42, 0.03); +} + +.linkedHeaderLeft { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.linkedTitle { + font-size: 1.05rem; + font-weight: 650; + letter-spacing: -0.01em; + color: #0f172a; +} + +.linkedSubtitle { + font-size: 0.92rem; + color: #475569; + line-height: 1.35; +} + +.linkedHeaderRight { + display: flex; + align-items: center; + gap: 0.6rem; + flex-shrink: 0; +} + +.linkedList { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.donorCard { + position: relative; + border-radius: 1rem; + margin-bottom: 0.85rem; + padding: 1rem; + background: rgba(255, 255, 255, 0.72); + backdrop-filter: blur(18px) saturate(160%); + -webkit-backdrop-filter: blur(18px) saturate(160%); + border: 1px solid rgba(17, 24, 39, 0.1); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.75) inset, + 0 18px 45px rgba(15, 23, 42, 0.02); +} + +.ghostButton { + appearance: none; + border: 1px solid rgba(17, 24, 39, 0.12); + border-radius: 9999px; + padding: 0.5rem 1rem; + background: rgba(255, 255, 255, 0.55); + color: #0f172a; + font-weight: 600; + cursor: pointer; + transition: + transform 120ms ease, + background 120ms ease; + + &:hover { + background-color: #e5e7eb; + } +} + +.ghostDangerButton { + appearance: none; + border: 1px solid rgba(239, 68, 68, 0.22); + border-radius: 9999px; + padding: 0.5rem 0.75rem; + background: rgba(239, 68, 68, 0.1); + color: #7f1d1d; + font-weight: 650; + cursor: pointer; + transition: + transform 120ms ease, + background 120ms ease; + + &:hover { + background: rgba(239, 68, 68, 0.352); + } + + &:active { + transform: translateY(0px); + } +} + +.refetchingPill { + display: inline-flex; + align-items: center; + font-weight: 500; + color: #475569; + padding-right: 1rem; +} + +.modalBackdrop { + position: fixed; + inset: 0; + z-index: 60; + display: flex; + align-items: center; + justify-content: center; + padding: 1.25rem; + background: rgba(2, 6, 23, 0.38); + backdrop-filter: blur(10px) saturate(140%); + -webkit-backdrop-filter: blur(10px) saturate(140%); + will-change: opacity, backdrop-filter; + + @media (prefers-reduced-motion: reduce) { + will-change: auto; + } +} + +.modal { + position: relative; + width: min(56rem, 100%); + height: min(80dvh, 48rem); + min-height: 18rem; + display: flex; + flex-direction: column; + border-radius: 1rem; + background: rgba(255, 255, 255, 0.86); + backdrop-filter: blur(18px) saturate(160%); + -webkit-backdrop-filter: blur(18px) saturate(160%); + border: 1px solid rgba(17, 24, 39, 0.12); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.75) inset, + 0 30px 80px rgba(15, 23, 42, 0.35); + overflow: hidden; + transform-origin: 50% 40%; + will-change: transform, opacity; + + &::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient( + to bottom, + rgba(255, 255, 255, 0.55), + rgba(255, 255, 255, 0) 22% + ); + opacity: 0.35; + } + + @media (prefers-reduced-motion: reduce) { + will-change: auto; + } +} + +.modalHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.95rem 1rem; + border-bottom: 1px solid rgba(17, 24, 39, 0.1); + background: rgba(255, 255, 255, 0.6); +} + +.modalHeaderLeft { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.modalTitle { + font-size: 1.05rem; + font-weight: 650; + letter-spacing: -0.01em; + color: #0f172a; +} + +.modalSubtitle { + font-size: 0.92rem; + color: #475569; + line-height: 1.35; +} + +.modalHeaderRight { + display: flex; + align-items: center; + gap: 0.6rem; + flex-shrink: 0; +} + +.modalBody { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: auto; + padding: 0; + background: rgba(255, 255, 255, 0.62); + backdrop-filter: blur(18px) saturate(160%); + -webkit-backdrop-filter: blur(18px) saturate(160%); + font-weight: 500; + padding-inline: 1rem; + + & :global(.pinned), + & :global(.elementList), + & :global(.listStatus) { + padding: 0.9rem 1rem 1rem; + } + + & :global(.listStatus) { + flex: 1; + min-height: 0; + } +} + +.modalFooter { + padding: 0.5rem 1rem; + border-top: 1px solid rgba(17, 24, 39, 0.1); + background: rgba(255, 255, 255, 0.6); + display: flex; + justify-content: flex-end; + gap: 0.6rem; +} + +.modalFooterButton { + min-width: 7rem; +} + +.modalSearch { + width: min(22rem, 52vw); +} + +.searchInputBare { + display: flex; + width: 100%; + align-items: center; + + & > input { + width: 100%; + height: 2.35rem; + line-height: 2.35rem; + border-radius: 0.6rem; + border: 1px solid #d1d5db; + padding: 0 0.75rem; + background: rgba(255, 255, 255, 0.75); + } +} + +.detailsNavigationButton { + width: auto; + display: inline-block; + margin-top: 0.35rem; +} + +.detailsNavigationLink { + display: inline-flex; + width: auto; + align-items: center; + justify-content: center; + padding: 0.28rem 0.75rem; + border-radius: 9999px; + background: rgba(14, 165, 233, 0.12); + color: rgba(2, 132, 199, 1); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + + &:hover { + background: rgba(14, 165, 233, 0.18); + } + + &:active { + transform: translateY(0) scale(0.99); + } +} + +.detailsNavigationLabel { + flex: 0 0 auto; + width: auto; + max-width: none; + color: inherit; + font-size: inherit; + font-weight: inherit; + letter-spacing: inherit; + white-space: nowrap; +} + +.detailsNavigationTagSection { + display: none; +} diff --git a/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx b/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx new file mode 100644 index 00000000..066f3ecf --- /dev/null +++ b/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx @@ -0,0 +1,495 @@ +'use client' + +import styles from './DonorView.module.css' +import { ListBody } from '@/app/admin/layout/List' +import { SearchModal } from '@/app/admin/layout/SearchModal' +import { + DateField, + Form, + FormGroup, + TextField, +} from '@/components/common/forms' +import { NavigationButton } from '@/components/common/navigation_stack/navigation_button/NavigationButton' +import { + ActBlueContribution, + ActBlueContributionCustomField, + ActBlueDonor, + ActBlueLineitem, + User, +} from '@/contracts/data' +import type { SearchRequest } from '@/contracts/requests' +import type { PaginatedResponse } from '@/contracts/responses' +import type { UseQueryResult } from '@tanstack/react-query' +import Link from 'next/link' +import React, { ChangeEvent } from 'react' + +export interface DonorViewProps { + selectedId: number + user: User + + pickingDonor: boolean + setPickingDonor: (next: boolean) => void + + isRefetching: boolean + + donorSearch: SearchRequest + donorSearchQuery: UseQueryResult, Error> + onDonorSearch: (req: SearchRequest) => void + + renderDonorItem: (item: ActBlueDonor, userId: number) => React.ReactNode + handleDeleteDonorItem: (value: ActBlueDonor, userId: number) => void +} + +interface ContributionData { + total: number + hasActiveRecurring: boolean + customFields: ActBlueContributionCustomField[] + lineitems: ActBlueLineitem[] +} + +const calcFutureDate = ( + initialTime: Date, + period: 'weekly' | 'monthly', + duration: number +) => { + switch (period) { + case 'weekly': + return new Date( + initialTime.getTime() + + new Date(duration * 7 * 24 * 60 * 60 * 1000).getTime() + ) + case 'monthly': + initialTime.setMonth(initialTime.getMonth()) + return initialTime + } +} + +const calcContributionData = (donor: ActBlueDonor): ContributionData => { + const li: ActBlueLineitem[] = [] + let hasActiveRecurring = false + let total = 0 + let customFields: ActBlueContributionCustomField[] = [] + + const contributions = donor.contributions ?? [] + contributions.forEach((contribution: ActBlueContribution) => { + customFields = contribution.customFields + if ( + contribution.isRecurring && + ((contribution.recurringDuration ?? 1) < 0 || + calcFutureDate( + contribution.createdAt, + contribution.recurringPeriod as 'weekly' | 'monthly', + contribution.recurringDuration ?? 1 + ) > new Date()) + ) { + hasActiveRecurring = true + } + + const lineitems = contribution.lineitems ?? [] + lineitems.forEach((lineitem: ActBlueLineitem) => { + total += lineitem.amount + li.push(lineitem) + }) + }) + + return { + total, + hasActiveRecurring, + customFields, + lineitems: li, + } +} + +const formatLineitemDate = (value: Date) => + Intl.DateTimeFormat('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(value) + +export function DonorView({ + selectedId, + user, + pickingDonor, + setPickingDonor, + isRefetching, + donorSearch, + donorSearchQuery, + onDonorSearch, + renderDonorItem, + handleDeleteDonorItem, +}: DonorViewProps) { + const formatContributionDateTime = (date: Date): string => { + if (Number.isNaN(date.getTime())) return 'Unknown date' + + return new Intl.DateTimeFormat('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date) + } + + const linkedDonors = user.donors ?? [] + const hasLinked = linkedDonors.length > 0 + + const openPicker = () => setPickingDonor(true) + const closePicker = () => setPickingDonor(false) + + const unlinkAll = () => { + for (const donor of linkedDonors) { + handleDeleteDonorItem(donor, selectedId) + } + } + + const queryValue = donorSearch.query ?? '' + + const handleOverlaySearch = (e: ChangeEvent) => { + onDonorSearch({ ...donorSearch, query: e.target.value }) + } + + const renderEmptyState = () => ( +
+
+
+
No Donors Found
+
+ Automatic donor matching not implemented yet. +
+
+ +
+ +
+
+
+ ) + + const renderLinkedHeader = () => ( +
+
+
Donor Details
+
+ View donor information sourced from ActBlue contributions. +
+
+ +
+ {isRefetching && ( + Loading... + )} + + +
+
+ ) + + const renderDonorSummaryFields = ( + donor: ActBlueDonor, + contributionData: ContributionData + ) => ( + <> + + + + + + + + + + + + + + + + + + label="Employer Name" + getter={(form) => form.employerData?.employer} + /> + + label="Occupation" + getter={(form) => form.employerData?.occupation} + /> + + label="Employer Street Address" + getter={(form) => form.employerData?.employerAddr1} + /> + + label="Employer City" + getter={(form) => form.employerData?.employerCity} + /> + + label="Employer State" + getter={(form) => form.employerData?.employerState} + /> + + label="Employer Zip Code" + getter={(form) => `${form.employerData?.employerZip ?? ''}`} + /> + + label="Employer Country" + getter={(form) => form.employerData?.employerCountry} + /> + + + + + label="Total Dollar Donations" + getter={() => `$${contributionData.total}`} + /> + + label="Currently Has a Recurring Donation" + getter={() => `${contributionData.hasActiveRecurring}`} + /> + + label="Total Contributions" + getter={() => `${contributionData.lineitems.length}`} + /> + + + {(contributionData.lineitems ?? []).map((lineitem) => ( + + + label="Paid At" + getter={() => lineitem.paidAt} + /> + + label="Sequence" + getter={() => `${lineitem.sequence}`} + /> + + label="Amount" + getter={() => `$${lineitem.amount}`} + /> + + label="Recurring Amount" + getter={() => `$${lineitem.recurringAmount}`} + /> + + label="Amount Less AB Fees" + getter={() => `$${lineitem.amountLessAbFees}`} + /> + + donor.contributions?.[0]?.contributionForm + } + /> + {contributionData.customFields?.map((field) => ( + field.answer} + /> + ))} + + + + ))} + + ) + + const renderContributionList = ( + contributionData: ContributionData, + contributionFormByLineitemId: Map, + contributionIsRecurringByLineitemId: Map + ) => ( + + {[...(contributionData.lineitems ?? [])] + .sort((a, b) => b.paidAt.getTime() - a.paidAt.getTime()) + .map((lineitem) => ( + + + label="Paid At" + getter={() => lineitem.paidAt} + /> + + label="Sequence" + getter={() => `${lineitem.sequence}`} + /> + + label="Is Recurring" + getter={() => + `${contributionIsRecurringByLineitemId.get(lineitem.lineitemId) ?? false}` + } + /> + + label="Amount" + getter={() => `$${lineitem.amount}`} + /> + + label="Recurring Amount" + getter={() => `$${lineitem.recurringAmount}`} + /> + + contributionFormByLineitemId.get( + lineitem.lineitemId + ) + } + /> +
+ {contributionData.customFields?.map((field) => ( + field.answer} + /> + ))} +
+ + Full Details + +
+ ))} +
+
+ ) + + const renderDonorCard = (donor: ActBlueDonor) => { + const contributionData = calcContributionData(donor) + const contributionFormByLineitemId = new Map() + const contributionIsRecurringByLineitemId = new Map() + + donor.contributions?.forEach((contribution) => { + contribution.lineitems?.forEach((lineitem) => { + contributionFormByLineitemId.set( + lineitem.lineitemId, + contribution.contributionForm + ) + contributionIsRecurringByLineitemId.set( + lineitem.lineitemId, + contribution.isRecurring + ) + }) + }) + + return ( +
+
+ + key={donor.userId} + title="" + readonly + form={donor} + > + + {renderDonorSummaryFields(donor, contributionData)} + + + +
+ +
+ + key={donor.userId} + title="" + readonly + form={donor} + > + + {renderContributionList( + contributionData, + contributionFormByLineitemId, + contributionIsRecurringByLineitemId + )} + + +
+
+ ) + } + + return ( +
+ {!hasLinked && renderEmptyState()} + + {hasLinked && ( +
+ {renderLinkedHeader()} + +
+ {linkedDonors.map(renderDonorCard)} +
+
+ )} + + + + {donorSearchQuery.data?.data.map((donor) => + renderDonorItem(donor, selectedId) + )} + + +
+ ) +} diff --git a/src/app/volunteer_dashboard/panels/members/panel_views/HistoryView.module.css b/src/app/volunteer_dashboard/panels/members/panel_views/HistoryView.module.css new file mode 100644 index 00000000..4c16b1cc --- /dev/null +++ b/src/app/volunteer_dashboard/panels/members/panel_views/HistoryView.module.css @@ -0,0 +1,163 @@ +.section { + padding: 1rem; +} + +.historyContainer { + display: flex; + flex-direction: column; + width: 100%; + + border-radius: 1rem; + + background: rgba(255, 255, 255, 0.72); + backdrop-filter: blur(18px) saturate(160%); + -webkit-backdrop-filter: blur(18px) saturate(160%); + + border: 1px solid rgba(17, 24, 39, 0.1); + + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.75) inset, + 0 18px 45px rgba(15, 23, 42, 0.03); + + overflow: hidden; +} + +.historyEntry { + width: 100%; + text-align: left; + + display: flex; + align-items: center; + gap: 0.35rem; + + padding: 0.75rem 0.9rem; + + background: transparent; + + border: 0; + border-bottom: 1px solid rgba(17, 24, 39, 0.08); + + color: rgba(15, 23, 42, 0.85); + font-weight: 550; + letter-spacing: -0.01em; + + cursor: pointer; + + transition: + background 140ms ease, + transform 140ms ease; + + &:last-child { + border-bottom: 0; + } + + &:hover { + background: rgba(15, 23, 42, 0.06); + } +} + +.historyEntryMain { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 0.25rem; + flex-wrap: wrap; +} + +.historyEntrySelected { + background: rgba(15, 23, 42, 0.08); + + &:hover { + background: rgba(15, 23, 42, 0.1); + } +} + +.historyEntryPrefix { + color: rgba(15, 23, 42, 0.62); + font-weight: 550; +} + +.historyEntryActor { + color: rgba(9, 34, 58, 0.8); + font-weight: 650; +} + +.historyEntryDate { + font-weight: 650; + color: rgba(15, 23, 42, 0.85); +} + +.historyEntryDateTag { + position: relative; + margin-left: auto; + white-space: nowrap; + + font-size: 0.75rem; + font-weight: 600; + + padding-inline: 0.75rem; + padding-block: 0.15rem; + border-radius: 9999px; + + color: rgba(15, 23, 42, 0.72); + background: rgba(15, 23, 42, 0.06); + + &::after { + content: attr(data-full-date); + position: absolute; + right: 0; + top: calc(100% + 0.35rem); + + padding: 0.35rem 0.5rem; + border-radius: 0.45rem; + max-width: 20rem; + + color: rgba(241, 245, 249, 0.95); + background: rgba(15, 23, 42, 0.92); + font-size: 0.72rem; + font-weight: 500; + line-height: 1.35; + + box-shadow: 0 8px 20px rgba(2, 6, 23, 0.28); + + opacity: 0; + transform: translateY(-3px); + transition: + opacity 100ms ease, + transform 100ms ease; + pointer-events: none; + z-index: 8; + } + + &:hover::after, + &:focus-visible::after { + opacity: 1; + transform: translateY(0); + } +} + +.historyEntryCode { + font-size: 0.92em; + color: rgba(15, 23, 42, 0.72); + font-family: + ui-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + Liberation Mono, + Courier New, + monospace; +} + +.historyEntryUi { + cursor: default; + + &:hover { + background: transparent; + } +} + +.snapshotWrap { + margin-top: 1rem; +} diff --git a/src/app/volunteer_dashboard/panels/members/panel_views/HistoryView.tsx b/src/app/volunteer_dashboard/panels/members/panel_views/HistoryView.tsx new file mode 100644 index 00000000..40651770 --- /dev/null +++ b/src/app/volunteer_dashboard/panels/members/panel_views/HistoryView.tsx @@ -0,0 +1,379 @@ +'use client' + +import styles from './HistoryView.module.css' +import { MemberView } from './MemberView' +import { CollapsibleSection } from '@/components/common' +import { + ActBlueDonor, + Role, + UpdateHistory, + User, + zDiscordUser, +} from '@/contracts/data' +import { cn } from '@/util' +import { useFetch } from '@/util/hooks' +import { useQueries } from '@tanstack/react-query' +import { ReactNode } from 'react' +import z from 'zod' + +export interface HistoryViewProps { + selectedId: number + user: User + + selectedHistory: UpdateHistory | null + onSelectHistory: (update: UpdateHistory | null) => void + + selectedDonorHistory: UpdateHistory | null + onSelectDonorHistory: (update: UpdateHistory | null) => void + + isRefetching: boolean + + roles: Role[] + roleOptions: { value: number; label: string }[] + makeFormTitle: (user: User) => string +} + +type UnifiedHistoryItem = + | { kind: 'account'; update: UpdateHistory } + | { kind: 'donor'; update: UpdateHistory } + +const DAY_MS = 24 * 60 * 60 * 1000 + +function normalizeMeridiem(time: string) { + return time.replace(/\s*([AP])M\b/g, (_, period: string) => { + return `${period.toLowerCase()}m` + }) +} + +function formatHistoryTimestamp(value: Date, now = new Date()) { + const today = new Date(now) + today.setHours(0, 0, 0, 0) + + const target = new Date(value) + target.setHours(0, 0, 0, 0) + + const diffMs = today.getTime() - target.getTime() + const diffDays = Math.floor(diffMs / DAY_MS) + + const time = normalizeMeridiem( + value.toLocaleTimeString([], { + hour: 'numeric', + minute: '2-digit', + }) + ) + + if (diffDays === 0) { + return `${time} · Today` + } + + if (diffDays < 0) { + return time + } + + if (diffDays <= 6) { + const weekday = value.toLocaleString([], { weekday: 'long' }) + return `${time} · ${weekday}` + } + + const month = value + .toLocaleString([], { month: 'short' }) + .replace(/\.$/, '') + const day = value.getDate() + const year = value.getFullYear() + + return `${time} · ${month}. ${day}, ${year}` +} + +function formatFullHistoryTimestamp(value: Date) { + return normalizeMeridiem( + value.toLocaleString([], { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) + ) +} + +export function HistoryView({ + selectedId, + user, + selectedHistory, + onSelectHistory, + selectedDonorHistory, + onSelectDonorHistory, + isRefetching, + roles, + roleOptions, + makeFormTitle, +}: HistoryViewProps) { + const { ready, onGet } = useFetch() + + const sortedHistory = (user?.history ?? []).slice().sort((a, b) => { + return ( + b.historyWhenUpdatedUtc.getTime() - + a.historyWhenUpdatedUtc.getTime() + ) + }) + + const sortedDonorHistory = (user?.donorHistory ?? []) + .slice() + .sort((a, b) => { + return ( + b.historyWhenUpdatedUtc.getTime() - + a.historyWhenUpdatedUtc.getTime() + ) + }) + + const mergedHistory = [ + ...sortedHistory.map((update) => ({ + kind: 'account' as const, + update, + })), + ...sortedDonorHistory.map((update) => ({ + kind: 'donor' as const, + update, + })), + ].sort( + (a, b) => + b.update.historyWhenUpdatedUtc.getTime() - + a.update.historyWhenUpdatedUtc.getTime() + ) + + const updaterIds = Array.from( + new Set( + mergedHistory + .map((item) => item.update.historyWhoUpdatedId) + .filter((id): id is number => id != null) + ) + ) + + const updaterDiscordQueries = useQueries({ + queries: updaterIds.map((id) => ({ + queryKey: [`/discordUsers/${id}`], + queryFn: ({ signal }) => + onGet('/discordUsers/:discordUserId', z.array(zDiscordUser), { + params: { discordUserId: id }, + signal, + }), + enabled: ready, + })), + }) + + const updaterUsernameById = new Map() + updaterDiscordQueries.forEach((query, index) => { + const id = updaterIds[index] + const username = query.data?.[0]?.username + + if (id != null && username) updaterUsernameById.set(id, username) + }) + + const updateLabel = (historyWhoUpdatedId: number | null) => { + if (historyWhoUpdatedId == null) return 'Unknown' + + const username = updaterUsernameById.get(historyWhoUpdatedId) + if (username) return `@${username}` + + return `User #${historyWhoUpdatedId}` + } + + const makeHistoryMessage = ( + who: string, + place: 'Account' | 'Donor', + source: string + ) => { + return ( + <> + {who} + {` updated ${place} via ${source}`} + + ) + } + + const handleMakeHistoryLabel = (update: UpdateHistory) => { + const who = updateLabel(update.historyWhoUpdatedId) + const source = update.historyDataSource ?? 'Unknown' + return makeHistoryMessage(who, 'Account', source) + } + + const handleMakeDonorHistoryLabel = ( + update: UpdateHistory + ) => { + const who = updateLabel(update.historyWhoUpdatedId) + const source = update.historyDataSource ?? 'Unknown' + return makeHistoryMessage(who, 'Donor', source) + } + + if (selectedId == null) return null + + if (isRefetching) { + return ( +
+
+
+ Refreshing… +
+
+
+ ) + } + + if (!mergedHistory.length) { + return ( +
+
+
+ No history found +
+
+
+ ) + } + + return ( +
+ + + {selectedHistory ? ( +
+ makeFormTitle(u)} + /> +
+ ) : null} + + {!selectedHistory && selectedDonorHistory ? ( +
+ + Donor:{' '} + {`${selectedDonorHistory.firstname} ${selectedDonorHistory.lastname}`} + +
+ + {selectedDonorHistory.userId ? 'Linked' : 'Unlinked'} + +
+ ) : null} +
+ ) +} + +interface UnifiedHistoryFieldProps { + title: string + defaultCollapsed?: boolean + history: UnifiedHistoryItem[] + selectedAccountHistory: UpdateHistory | null + selectedDonorHistory: UpdateHistory | null + onSelectAccountHistory: (update: UpdateHistory | null) => void + onSelectDonorHistory: (update: UpdateHistory | null) => void + makeAccountLabel: (update: UpdateHistory) => ReactNode + makeDonorLabel: (update: UpdateHistory) => ReactNode +} + +function UnifiedHistoryField({ + title, + defaultCollapsed, + history, + selectedAccountHistory, + selectedDonorHistory, + onSelectAccountHistory, + onSelectDonorHistory, + makeAccountLabel, + makeDonorLabel, +}: UnifiedHistoryFieldProps) { + return ( + +
+ {history.map((item, i) => { + const isSelected = + item.kind == 'account' + ? selectedAccountHistory?.historyId === + item.update.historyId + : selectedDonorHistory?.historyId === + item.update.historyId + + const handleSelect = () => { + if (item.kind == 'account') { + if (isSelected) { + onSelectAccountHistory(null) + return + } + + onSelectAccountHistory(item.update) + onSelectDonorHistory(null) + return + } + + if (isSelected) { + onSelectDonorHistory(null) + return + } + + onSelectDonorHistory(item.update) + onSelectAccountHistory(null) + } + + return ( +
+ +
+ ) + })} +
+
+ ) +} diff --git a/src/app/volunteer_dashboard/panels/members/panel_views/MemberView.tsx b/src/app/volunteer_dashboard/panels/members/panel_views/MemberView.tsx new file mode 100644 index 00000000..7533645f --- /dev/null +++ b/src/app/volunteer_dashboard/panels/members/panel_views/MemberView.tsx @@ -0,0 +1,355 @@ +'use client' + +import { + CheckboxField, + DateField, + DropDownField, + Form, + FormGroup, + FormState, + PhoneField, + SelectManyField, + TextField, +} from '@/components/common/forms' +import { MultiTextField } from '@/components/common/forms/MultiTextField' +import { Role, ShirtSize, UpdateHistory, User } from '@/contracts/data' +import { stateOptions } from '@/models' +import { dateService } from '@/services' + +const membershipCardShipmentOptions = [ + { value: 0, label: 'Not Started' }, + { value: 1, label: 'Cancelled' }, + { value: 2, label: 'Printed' }, + { value: 3, label: 'Shipped' }, + { value: 4, label: 'Received' }, + { value: 5, label: 'Returned (Update Address)' }, +] + +const membershipMerchShipmentOptions = [ + { value: 0, label: 'Not Started' }, + { value: 1, label: 'Cancelled' }, + { value: 2, label: 'Printed' }, + { value: 3, label: 'Shipped' }, + { value: 4, label: 'Received' }, + { value: 5, label: 'Returned (Update Address)' }, +] + +const shirtSizeOptions = [ + { value: '', label: 'None' }, + { value: 'XS', label: 'Extra Small' }, + { value: 'S', label: 'Small' }, + { value: 'M', label: 'Medium' }, + { value: 'L', label: 'Large' }, + { value: 'XL', label: 'Extra Large' }, + { value: '2XL', label: 'Double XL' }, +] + +const membershipFulfillmentStatusOptions = [ + { value: 0, label: 'Not Eligible' }, + { value: 1, label: 'Not Fulfilled' }, + { value: 2, label: 'Fulfilled' }, +] + +const calcFutureDate = ( + initialTime: Date, + period: 'weekly' | 'monthly', + duration: number +) => { + switch (period) { + case 'weekly': + return new Date( + initialTime.getTime() + + new Date(duration * 7 * 24 * 60 * 60 * 1000).getTime() + ) + case 'monthly': + initialTime.setMonth(initialTime.getMonth()) + return initialTime + } +} + +const getHasActiveRecurringValue = (form: User): string => { + const donors = form.donors ?? [] + if (donors.length === 0) return 'No Donor Info' + + const hasActiveRecurring = donors.some((donor) => + (donor.contributions ?? []).some( + (contribution) => + contribution.isRecurring && + ((contribution.recurringDuration ?? 1) < 0 || + calcFutureDate( + contribution.createdAt, + contribution.recurringPeriod as 'weekly' | 'monthly', + contribution.recurringDuration ?? 1 + ) > new Date()) + ) + ) + + return hasActiveRecurring ? 'Yes' : 'No' +} +export interface MemberViewProps { + selectedId: number + user: User + selectedHistory: UpdateHistory | null + + setFormState?: (next: FormState | null) => void + + saving: boolean + editing: boolean + isInvalid: boolean + roles: Role[] + roleOptions: { value: number; label: string }[] + + makeFormTitle: (user: User) => string + handleSave?: (user: User) => void +} + +export function MemberView({ + selectedId, + user, + selectedHistory, + setFormState, + saving, + isInvalid, + editing, + roles, + roleOptions, + makeFormTitle, + handleSave, +}: MemberViewProps) { + return ( + + key={selectedId} + form={selectedHistory ?? user} + title={makeFormTitle(user)} + readonly={selectedHistory != null} + saving={saving} + isInvalid={isInvalid} + onUpdate={setFormState} + onSave={handleSave} + > + + + label="Discord Username" + getter={(form) => + (form.discordUsers ?? []) + ?.map(({ username }) => `@${username}`) + .join(', ') + } + readonly + /> + + label="Discord ID" + getter={(form) => form.discordUsers?.[0]?.id} + readonly + /> + + + {editing ? ( + + ) : ( + + label="Full Name" + getter={(user) => + `${user.firstName ?? ''} ${user.lastName ?? ''}`.trim() + } + readonly + /> + )} + {/* 6/25/36 - There is no way to merge this conditional in with the above trinary. + The obvious solution (fragment) causes the page to break for unknowable reasons. + Check website PR #493 to see details of how each solution breaks */} + {editing && } + + label="Date of Birth" + getter={(form) => + dateService.fromISODateString(form.birthdate) + } + field="birthdate" + format={{ + timeZone: 'UTC', + dateStyle: 'medium', + }} + /> + + label="Age" + readonly + getter={(form) => + dateService.isValid(form.birthdate) + ? dateService.getAge(form.birthdate!)?.toString() + : null + } + /> + label="Aliases" field="aliases" /> + + + + + + label="Address Line 1" + getter={(form) => form.address.addressLine1} + setter={(form, field) => ({ + ...form, + address: { + ...form.address, + addressLine1: field?.slice(0, 100) ?? null, + }, + })} + /> + + label="Address Line 2" + getter={(form) => form.address.addressLine2} + setter={(form, field) => ({ + ...form, + address: { + ...form.address, + addressLine2: field?.slice(0, 100) ?? null, + }, + })} + /> + + label="City" + getter={(form) => form.address.city} + setter={(form, field) => ({ + ...form, + address: { + ...form.address, + city: field?.slice(0, 50) ?? null, + }, + })} + /> + + label="County" + getter={(form) => form.address.county} + setter={(form, field) => ({ + ...form, + address: { + ...form.address, + county: field?.slice(0, 50) ?? null, + }, + })} + /> + + label="State" + getter={(user) => user.address.state} + setter={(user, field) => ({ + ...user, + address: { + ...user.address, + state: (field as string) ?? null, + }, + })} + options={stateOptions} + /> + + label="Zip Code" + getter={(form) => form.address.zip} + setter={(form, field) => ({ + ...form, + address: { + ...form.address, + zip: + field + ?.replace(/[^\d]/, '') + ?.padStart(5, '0') + ?.slice(-5) ?? null, + }, + })} + validator={(field) => !field?.length || field?.length == 5} + /> + + + + + label="Membership Card Shipped" + field="membershipCardStatus" + options={membershipCardShipmentOptions} + /> + + label="Membership Merch Shipped" + field="membershipMerchStatus" + options={membershipMerchShipmentOptions} + /> + + label="Shirt Size" + getter={(form) => form.shirtSize ?? ''} + setter={(form, field) => ({ + ...form, + shirtSize: field ? (field as ShirtSize) : null, + })} + options={shirtSizeOptions} + /> + + label="Dues Paying Member" + field="duesPayingMember" + readonly + /> + + label="Qualifies for Membership Benefits" + field="membershipBenefitEligible" + readonly + /> + + label="Membership Fulfillment Status" + field="membershipFulfillmentStatus" + options={membershipFulfillmentStatusOptions} + /> + + + + label="Has Active Recurring" + readonly + getter={getHasActiveRecurringValue} + /> + + + + + + + + + + + + + label="Roles" + options={roleOptions} + getter={(form) => (form.roles ?? []).map((role) => role.id)} + setter={(form, field) => ({ + ...form, + roles: + field != null + ? roles.filter((role) => + field.includes(role.id) + ) + : form.roles, + })} + /> + + + ) +}