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 (
+
+ )
+ }
+
+ 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 (
+
+ )
+ }
+
+ if (!mergedHistory.length) {
+ return (
+
+ )
+ }
+
+ 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 (
+
+ )
+}