From 7c546c645b52b3b681d12e89082c0298574a384c Mon Sep 17 00:00:00 2001 From: zoe | bito Date: Sat, 12 Sep 2026 15:31:42 -0500 Subject: [PATCH 01/10] feat/292/aliases (#591) * added multitext field and alises user field * breathing room * lint * prettier * working session with benj removal moved to onclick for whole pill styling changes * lint --- .../endorsements/panel_views/DetailView.tsx | 16 +-- src/app/admin/panels/members/page.tsx | 1 + .../panels/members/panel_views/MemberView.tsx | 2 + src/app/endorsements/endorsements.utils.ts | 5 +- .../common/forms/FormField.module.css | 66 +++++++++++++ .../common/forms/MultiTextField.tsx | 97 +++++++++++++++++++ 6 files changed, 172 insertions(+), 15 deletions(-) create mode 100644 src/components/common/forms/MultiTextField.tsx diff --git a/src/app/admin/panels/endorsements/panel_views/DetailView.tsx b/src/app/admin/panels/endorsements/panel_views/DetailView.tsx index 757d2392..c2668636 100644 --- a/src/app/admin/panels/endorsements/panel_views/DetailView.tsx +++ b/src/app/admin/panels/endorsements/panel_views/DetailView.tsx @@ -14,13 +14,7 @@ import { useConfigure, } from '@/components/common/forms' import formFieldStyles from '@/components/common/forms/FormField.module.css' -import { - BackgroundColor, - ElectionStatus, - Endorsement, - EndorsementType, - InitiativeType, -} from '@/contracts/data' +import { BackgroundColor, Endorsement } from '@/contracts/data' import { electionStatusOptions, endorsementLevelOptions, @@ -95,7 +89,7 @@ export function DetailView({ getter={(form) => form.avatarBgColor} setter={(form, field) => ({ ...form, - avatarBgColor: Number(field) as BackgroundColor, + avatarBgColor: Number(field), })} options={avatarBgColorOptions} /> @@ -111,7 +105,7 @@ export function DetailView({ getter={(form) => form.electionStatus} setter={(form, field) => ({ ...form, - electionStatus: Number(field) as ElectionStatus, + electionStatus: Number(field), })} options={electionStatusOptions} /> @@ -139,7 +133,7 @@ export function DetailView({ getter={(form) => form.endorsementLevel} setter={(form, field) => ({ ...form, - endorsementLevel: Number(field) as EndorsementType, + endorsementLevel: Number(field), })} options={endorsementLevelOptions} /> @@ -149,7 +143,7 @@ export function DetailView({ getter={(form) => form.initiativeLevel} setter={(form, field) => ({ ...form, - initiativeLevel: Number(field) as InitiativeType, + initiativeLevel: Number(field), })} options={initiativeLevelOptions} /> diff --git a/src/app/admin/panels/members/page.tsx b/src/app/admin/panels/members/page.tsx index 64eaf65b..55c92e86 100644 --- a/src/app/admin/panels/members/page.tsx +++ b/src/app/admin/panels/members/page.tsx @@ -532,6 +532,7 @@ export default function Page() { nameConfirmed: user.nameConfirmed, addressConfirmed: user.addressConfirmed, roles: user.roles?.map((role) => role.id), + aliases: [...(user.aliases ?? [])], } satisfies UpdateUserRequest) if (addressIsDirty) request.address = address diff --git a/src/app/admin/panels/members/panel_views/MemberView.tsx b/src/app/admin/panels/members/panel_views/MemberView.tsx index 7753157f..2fadf3a4 100644 --- a/src/app/admin/panels/members/panel_views/MemberView.tsx +++ b/src/app/admin/panels/members/panel_views/MemberView.tsx @@ -11,6 +11,7 @@ import { 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' @@ -141,6 +142,7 @@ export function MemberView({ getter={(form) => form.discordUsers?.[0]?.id} readonly /> + label="Aliases" field="aliases" /> ([ const STATUS_SECTION_SORT_VALUES = new Map([ ...Object.entries(ELECTION_STATUS_LABELS).map( ([status, label]) => - [ - label, - ELECTION_STATUS_SORT_ORDER[Number(status) as ElectionStatus], - ] as const + [label, ELECTION_STATUS_SORT_ORDER[Number(status)]] as const ), [ UPCOMING_STATUS_LABEL, diff --git a/src/components/common/forms/FormField.module.css b/src/components/common/forms/FormField.module.css index 9d9f72bb..69d5b682 100644 --- a/src/components/common/forms/FormField.module.css +++ b/src/components/common/forms/FormField.module.css @@ -69,3 +69,69 @@ line-height: 1rem; color: #ef4444; } + +.multiTextRoot { + display: flex; + flex-flow: row wrap; + gap: 0.25rem; + padding-inline: 0.25rem; + + &.textField { + border-radius: 0.66rem; + background-color: white; + } +} + +.blendInput { + border: none; + outline: none; + background: transparent; + flex-shrink: 1; + margin: 0.33rem; + + &:active, + &:focus-within, + &:focus-visible, + &:focus { + border: none; + outline: none; + background: transparent; + } +} + +.valueTagContainer { + display: flex; + flex-flow: row wrap; + gap: 0.25rem; + padding: 0.25rem; +} + +.valueTag { + position: relative; + max-width: 100%; + + padding-inline: 1rem; + padding-block: 0.45rem; + border-radius: 9999px; + + font-weight: 600; + font-size: 0.85rem; + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: start; + + transition: all 120ms linear; + + &.mutable { + text-decoration: line-through; + text-decoration-inset: -0.25rem; + cursor: pointer; + + &:hover { + background: rgba(255, 95, 75, 0.3); + color: rgba(177, 2, 2, 1); + } + } +} diff --git a/src/components/common/forms/MultiTextField.tsx b/src/components/common/forms/MultiTextField.tsx new file mode 100644 index 00000000..3057e698 --- /dev/null +++ b/src/components/common/forms/MultiTextField.tsx @@ -0,0 +1,97 @@ +import { FormField, FormFieldProps, useConfigure } from './FormField' +import styles from './FormField.module.css' +import tagStyles from '@/app/admin/panels/endorsements/page.module.css' +import { cn } from '@/util' +import { ChangeEvent, KeyboardEventHandler, useCallback, useState } from 'react' + +export interface MultiTextProps extends FormFieldProps< + T, + Set | null | undefined +> { + readonlyClassName?: string +} + +export function MultiTextField(props: MultiTextProps) { + const [inputValue, setInputValue] = useState('') + const { getter, onChange, readonly, disabled } = useConfigure( + props, + useCallback( + (field: Set | null | undefined) => + !props.required || !!field?.size, + [props.required] + ) + ) + + const value = new Set(getter(props.dynamic!.form) ?? []) + + const handleKeyDown: KeyboardEventHandler = (event) => { + if (!inputValue) return + + switch (event.key) { + case 'Enter': + case 'Tab': + onChange(value.add(inputValue)) + setInputValue('') + event.preventDefault() + } + } + + const handleInputChange = (event: ChangeEvent) => { + setInputValue(event.target.value) + } + + const handleRemoveValue = (val: string) => { + value.delete(val) + onChange(value) + } + + const renderValue = (val: string, idx: number, readonly: boolean) => { + return ( + handleRemoveValue(val) : undefined} + > + {val} + + ) + } + + return ( + + {readonly ? ( +
+ {[...value].map((v, i) => renderValue(v, i, readonly))} +
+ ) : ( +
+
+ {[...value].map((v, i) => renderValue(v, i, readonly))} +
+ +
+ )} +
+ ) +} From 8d5779e9a90c11f62c86859ab27116dee4693ed5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:51:11 -0400 Subject: [PATCH 02/10] Bump next from 15.5.21 to 15.5.24 (#589) Bumps [next](https://github.com/vercel/next.js) from 15.5.21 to 15.5.24. - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/compare/v15.5.21...v15.5.24) --- updated-dependencies: - dependency-name: next dependency-version: 15.5.24 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: zoe | bito --- package.json | 2 +- pnpm-lock.yaml | 392 ++++++++++++++++++++++++++----------------------- 2 files changed, 207 insertions(+), 187 deletions(-) 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: From 7cb3847b71b4df97e1584a6c84a25a15a7471ea0 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Sun, 13 Sep 2026 09:46:42 -0400 Subject: [PATCH 03/10] admin version --- .../panels/members/MemberBanner.module.css | 98 +++ .../panels/members/MemberBanner.tsx | 60 ++ .../panels/members/page.module.css | 134 +++ .../panels/members/page.tsx | 792 +++++++++++++++++- .../members/panel_views/DonorView.module.css | 175 ++++ .../panels/members/panel_views/DonorView.tsx | 523 ++++++++++++ .../panel_views/HistoryView.module.css | 163 ++++ .../members/panel_views/HistoryView.tsx | 379 +++++++++ .../panels/members/panel_views/MemberView.tsx | 355 ++++++++ 9 files changed, 2678 insertions(+), 1 deletion(-) create mode 100644 src/app/volunteer_dashboard/panels/members/MemberBanner.module.css create mode 100644 src/app/volunteer_dashboard/panels/members/MemberBanner.tsx create mode 100644 src/app/volunteer_dashboard/panels/members/page.module.css create mode 100644 src/app/volunteer_dashboard/panels/members/panel_views/DonorView.module.css create mode 100644 src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx create mode 100644 src/app/volunteer_dashboard/panels/members/panel_views/HistoryView.module.css create mode 100644 src/app/volunteer_dashboard/panels/members/panel_views/HistoryView.tsx create mode 100644 src/app/volunteer_dashboard/panels/members/panel_views/MemberView.tsx 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..384e3c0d --- /dev/null +++ b/src/app/volunteer_dashboard/panels/members/page.module.css @@ -0,0 +1,134 @@ +.listWidth { + --list-width: 25.5rem; +} + +.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; +} + +.detailsHeader::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% + ); +} + +.detailsContent { + margin: 1rem; +} + +.userMeta { + display: flex; + flex-direction: column; +} + +.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); +} + +.userName { + font-weight: 500; + color: #000; +} + +.userUsername { + color: #6b7280; +} + +.emptyState { + display: flex; + height: 100%; + align-items: center; + justify-content: center; +} + +.loading { + display: flex; + align-items: center; +} + +.filterMenu { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 12rem; +} + +.nestedFilterMenu { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 12rem; +} + +@media (max-width: 720px) { + .detailsHeader { + --banner-cover-height: 4rem; + padding: 0.45rem 0.45rem 0.35rem; + } + + .bannerCover { + height: var(--banner-cover-height); + } +} diff --git a/src/app/volunteer_dashboard/panels/members/page.tsx b/src/app/volunteer_dashboard/panels/members/page.tsx index 1af24e58..55c92e86 100644 --- a/src/app/volunteer_dashboard/panels/members/page.tsx +++ b/src/app/volunteer_dashboard/panels/members/page.tsx @@ -1,3 +1,793 @@ +'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, List } from '@/app/admin/layout/List' +import { + DiscordAvatar, + DropdownOverlay, + DropdownOverlayButton, +} from '@/components/common' +import { FormState } from '@/components/common/forms' +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 { useCurrentUser, useFetch, usePaginatedSearch } from '@/util/hooks' +import { + keepPreviousData, + skipToken, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { useCallback, 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 { PulseLoader } from 'react-spinners' +import z from 'zod' + +type MemberTabKey = 'overview' | 'donorMatching' | 'history' + +const tabs: TabSpec[] = [ + { key: 'overview', label: 'Overview' }, + { key: 'donorMatching', label: 'Donations' }, + { key: 'history', label: 'History' }, +] + +// 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 [selectedId, setSelectedId] = useState(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 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), + aliases: [...(user.aliases ?? [])], + } 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 renderItem = (item: User | UserProfile) => { + return ( + handleSelectItem(item)} + > + +
+ {makeTitle(item)} + + {item.discordUsers?.[0]?.username ?? 'NOT FOUND'} + +
+
+ ) + } + + 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 ( + <> +
+ + +
+ } + searchFields={[ + { value: 'first_name', label: 'First Name' }, + { value: 'last_name', label: 'Last Name' }, + { + value: 'discord_usernames', + label: 'Discord Username', + }, + { value: 'email', label: 'Email' }, + { value: 'phone', label: 'Phone Number' }, + { value: 'zip', label: 'Zip Code' }, + { value: 'birthdate', label: 'Birthdate' }, + { value: 'created_at_utc', label: 'Join Date' }, + // { value: 'county', label: 'County' }, + // { value: 'city', label: 'City' }, + // { value: 'state', label: 'State' }, + // { value: 'preferred_name', label: 'Preferred Name' }, + + // { + // value: 'accepted_alerts', + // label: 'Accepted Notifications', + // }, + // { value: 'onboarding_stage', label: 'Onboarding Stage' }, + // { value: 'joined_at_utc', label: 'Date Joined Server' }, + // { + // value: 'completed_intake_utc', + // label: 'Date Intake Done', + // }, + // { value: 'aliases', label: 'Aliases' }, + ]} + sortFields={[ + { 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: 'Date Modified' }, + ]} + filters={[ + { + label: 'Role', + value: 'roleIds', + options: roles.map((role) => ({ + label: role.name, + value: role.id, + })), + }, + ]} + pinnedContent={ + loggedInUser.data ? ( + renderItem(loggedInUser.data) + ) : ( +
    + + +
    + +
    +
    +
+ ) + } + onSearch={onSearch} + > + {searchQuery.data?.data?.map((item) => renderItem(item))} + + + +
+ {selectedId == null && ( +
No user selected
+ )} + {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..cc99eeea --- /dev/null +++ b/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.module.css @@ -0,0 +1,175 @@ +.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; +} + +.ghostButton: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; +} + +.ghostDangerButton:hover { + background: rgba(239, 68, 68, 0.352); +} + +.ghostDangerButton:active { + transform: translateY(0px); +} + +.refetchingPill { + display: inline-flex; + align-items: center; + font-weight: 500; + color: #475569; + padding-right: 1rem; +} 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..d30b484f --- /dev/null +++ b/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx @@ -0,0 +1,523 @@ +'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 { + 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, + } +} + +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 }) + } + + return ( +
+ {!hasLinked && ( +
+
+
+
+ No Donors Found +
+
+ Automatic donor matching not implemented yet. +
+
+ +
+ +
+
+
+ )} + + {hasLinked && ( +
+
+
+
+ Donor Details +
+
+ View donor information sourced from ActBlue + contributions. +
+
+ +
+ {isRefetching ? ( + + Loading... + + ) : null} + + +
+
+ +
+ {linkedDonors.map((donor) => { + const contributionData = calcContributionData(donor) + const contributionFormByLineitemId = new Map< + number, + string + >() + const contributionIsRecurringByLineitemId = new Map< + number, + boolean + >() + + const contributions = donor.contributions ?? [] + contributions.forEach( + (contribution: ActBlueContribution) => { + const lineitems = + contribution.lineitems ?? [] + lineitems.forEach( + (lineitem: ActBlueLineitem) => { + contributionFormByLineitemId.set( + lineitem.lineitemId, + contribution.contributionForm + ) + contributionIsRecurringByLineitemId.set( + lineitem.lineitemId, + contribution.isRecurring + ) + } + ) + } + ) + + return ( +
+
+ + key={donor.userId} + title="" + readonly + form={donor} + > + + + + + + + + + + + + + + + + + + + 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}` + } + /> + + + + Open in Donors Panel + + + +
+
+ + key={donor.userId} + title="" + readonly + form={donor} + > + + + {[ + ...(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 + +
+ ))} +
+
+
+ +
+
+ ) + })} +
+
+ )} + + + + {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..311c3bab --- /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; +} + +.historyEntryMain { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 0.25rem; + flex-wrap: wrap; +} + +.historyEntry:last-child { + border-bottom: 0; +} + +.historyEntry:hover { + background: rgba(15, 23, 42, 0.06); +} + +.historyEntrySelected { + background: rgba(15, 23, 42, 0.08); +} + +.historyEntrySelected: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); +} + +.historyEntryDateTag::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: 560; + 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; +} + +.historyEntryDateTag:hover::after, +.historyEntryDateTag: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; +} + +.historyEntryUi: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..2fadf3a4 --- /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 + /> + label="Aliases" field="aliases" /> + + + {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="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, + })} + /> + + + ) +} From d8e474e6edb9068da37b84ad99171bb471989e51 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Sun, 13 Sep 2026 09:51:56 -0400 Subject: [PATCH 04/10] new memberview --- .../panels/members/page.module.css | 58 ++-- .../panels/members/page.tsx | 296 +++++++++++------- .../panels/members/panel_views/DonorView.tsx | 122 +++++++- .../panels/members/panel_views/MemberView.tsx | 2 +- 4 files changed, 309 insertions(+), 169 deletions(-) diff --git a/src/app/volunteer_dashboard/panels/members/page.module.css b/src/app/volunteer_dashboard/panels/members/page.module.css index 384e3c0d..fb177150 100644 --- a/src/app/volunteer_dashboard/panels/members/page.module.css +++ b/src/app/volunteer_dashboard/panels/members/page.module.css @@ -1,5 +1,16 @@ -.listWidth { - --list-width: 25.5rem; +.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 { @@ -65,42 +76,20 @@ rgba(241, 245, 249, 0.98) 38%, rgba(14, 165, 233, 0.04) 100% ); + z-index: 0; } .detailsContent { margin: 1rem; } -.userMeta { - display: flex; - flex-direction: column; -} - -.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); -} - -.userName { - font-weight: 500; - color: #000; -} - -.userUsername { - color: #6b7280; -} - .emptyState { display: flex; height: 100%; align-items: center; justify-content: center; + color: rgba(15, 23, 42, 0.5); + font-weight: 500; } .loading { @@ -108,6 +97,10 @@ align-items: center; } +.filterTagsWrapper { + border-bottom: 1px solid rgba(17, 24, 39, 0.08); +} + .filterMenu { display: flex; flex-direction: column; @@ -122,13 +115,6 @@ min-width: 12rem; } -@media (max-width: 720px) { - .detailsHeader { - --banner-cover-height: 4rem; - padding: 0.45rem 0.45rem 0.35rem; - } - - .bannerCover { - height: var(--banner-cover-height); - } +:global([data-sidebar-collapsed='true']) .filterTagsWrapper { + display: none; } diff --git a/src/app/volunteer_dashboard/panels/members/page.tsx b/src/app/volunteer_dashboard/panels/members/page.tsx index 55c92e86..1f4a6bad 100644 --- a/src/app/volunteer_dashboard/panels/members/page.tsx +++ b/src/app/volunteer_dashboard/panels/members/page.tsx @@ -6,13 +6,13 @@ 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, List } from '@/app/admin/layout/List' -import { - DiscordAvatar, - DropdownOverlay, - DropdownOverlayButton, -} from '@/components/common' +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, @@ -43,7 +43,8 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query' -import { useCallback, useMemo, useState } from 'react' +import { useSearchParams } from 'next/navigation' +import { useCallback, useEffect, useMemo, useState } from 'react' import { FaUsers, FaUserTag, @@ -52,7 +53,7 @@ import { } from 'react-icons/fa' import { FaClipboardUser, FaDollarSign, FaAddressCard } from 'react-icons/fa6' import { MdVerified } from 'react-icons/md' -import { PulseLoader } from 'react-spinners' +import { useMediaQuery } from 'usehooks-ts' import z from 'zod' type MemberTabKey = 'overview' | 'donorMatching' | 'history' @@ -63,6 +64,34 @@ const tabs: TabSpec[] = [ { 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: }, @@ -77,8 +106,13 @@ const birthYears = Array.from( export default function Page() { const queryClient = useQueryClient() const { ready, onGet, onPatch, onPost } = useFetch() + const navParams = useSearchParams() + const navUserId = navParams.get('userId') - const [selectedId, setSelectedId] = useState(null) + const initialUserId = navUserId ? Number(navUserId) : null + const [selectedId, setSelectedId] = useState( + Number.isFinite(initialUserId) ? initialUserId : null + ) const [selectedHistory, setSelectedHistory] = useState | null>(null) @@ -269,6 +303,9 @@ export default function Page() { onSearch({ ...rest, page: 0, ...tagFilters }) } + const [sidebarMobileVisible, setSidebarMobileVisible] = useState(true) + const isDesktop = useMediaQuery('(min-width: 64rem)') + const loggedInUser = useCurrentUser() const positionQueries = usePositionQueries() @@ -532,7 +569,6 @@ export default function Page() { nameConfirmed: user.nameConfirmed, addressConfirmed: user.addressConfirmed, roles: user.roles?.map((role) => role.id), - aliases: [...(user.aliases ?? [])], } satisfies UpdateUserRequest) if (addressIsDirty) request.address = address @@ -578,27 +614,29 @@ export default function Page() { [handleSelectDonorItem] ) - const renderItem = (item: User | UserProfile) => { - return ( - handleSelectItem(item)} - > - -
- {makeTitle(item)} - - {item.discordUsers?.[0]?.username ?? 'NOT FOUND'} - -
-
- ) - } + 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 @@ -666,70 +704,37 @@ export default function Page() { } return ( - <> -
- - -
- } - searchFields={[ - { value: 'first_name', label: 'First Name' }, - { value: 'last_name', label: 'Last Name' }, - { - value: 'discord_usernames', - label: 'Discord Username', - }, - { value: 'email', label: 'Email' }, - { value: 'phone', label: 'Phone Number' }, - { value: 'zip', label: 'Zip Code' }, - { value: 'birthdate', label: 'Birthdate' }, - { value: 'created_at_utc', label: 'Join Date' }, - // { value: 'county', label: 'County' }, - // { value: 'city', label: 'City' }, - // { value: 'state', label: 'State' }, - // { value: 'preferred_name', label: 'Preferred Name' }, - - // { - // value: 'accepted_alerts', - // label: 'Accepted Notifications', - // }, - // { value: 'onboarding_stage', label: 'Onboarding Stage' }, - // { value: 'joined_at_utc', label: 'Date Joined Server' }, - // { - // value: 'completed_intake_utc', - // label: 'Date Intake Done', - // }, - // { value: 'aliases', label: 'Aliases' }, - ]} - sortFields={[ - { 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: 'Date Modified' }, - ]} - filters={[ + + 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', @@ -738,35 +743,84 @@ export default function Page() { value: role.id, })), }, - ]} - pinnedContent={ - loggedInUser.data ? ( - renderItem(loggedInUser.data) - ) : ( -
    - - -
    - -
    -
    -
- ) - } - onSearch={onSearch} - > - {searchQuery.data?.data?.map((item) => renderItem(item))} - -
- + ], + }, + }} + 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:{' '} + {userQuery.error instanceof Error + ? userQuery.error.message + : 'Unknown error'} +
+ )} + {selectedId && userQuery.data && ( <>
@@ -788,6 +842,6 @@ export default function Page() { )}
- + ) } diff --git a/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx b/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx index d30b484f..276df149 100644 --- a/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx +++ b/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx @@ -9,6 +9,7 @@ import { FormGroup, TextField, } from '@/components/common/forms' +import { NavigationButton } from '@/components/common/navigation_stack/navigation_button/NavigationButton' import { ActBlueContribution, ActBlueContributionCustomField, @@ -99,6 +100,12 @@ const calcContributionData = (donor: ActBlueDonor): ContributionData => { } } +const formatLineitemDate = (value: Date) => + Intl.DateTimeFormat('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(value) + export function DonorView({ selectedId, user, @@ -366,17 +373,110 @@ export function DonorView({ /> - ( + + + 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 + } + /> + ) + )} + + + + ))} + - Open in Donors Panel - + tag={{ + className: + styles.detailsNavigationTagSection, + }} + />
@@ -477,7 +577,7 @@ export function DonorView({ form.discordUsers?.[0]?.id} readonly /> - label="Aliases" field="aliases" /> + label="Aliases" field="aliases" /> From 776795b80040bb5986aa0838dcf0dca60d1c81a9 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Sun, 13 Sep 2026 09:52:17 -0400 Subject: [PATCH 05/10] Revert "new memberview" This reverts commit d8e474e6edb9068da37b84ad99171bb471989e51. --- .../panels/members/page.module.css | 58 ++-- .../panels/members/page.tsx | 296 +++++++----------- .../panels/members/panel_views/DonorView.tsx | 122 +------- .../panels/members/panel_views/MemberView.tsx | 2 +- 4 files changed, 169 insertions(+), 309 deletions(-) diff --git a/src/app/volunteer_dashboard/panels/members/page.module.css b/src/app/volunteer_dashboard/panels/members/page.module.css index fb177150..384e3c0d 100644 --- a/src/app/volunteer_dashboard/panels/members/page.module.css +++ b/src/app/volunteer_dashboard/panels/members/page.module.css @@ -1,16 +1,5 @@ -.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; +.listWidth { + --list-width: 25.5rem; } .detailsHeader { @@ -76,20 +65,42 @@ rgba(241, 245, 249, 0.98) 38%, rgba(14, 165, 233, 0.04) 100% ); - z-index: 0; } .detailsContent { margin: 1rem; } +.userMeta { + display: flex; + flex-direction: column; +} + +.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); +} + +.userName { + font-weight: 500; + color: #000; +} + +.userUsername { + color: #6b7280; +} + .emptyState { display: flex; height: 100%; align-items: center; justify-content: center; - color: rgba(15, 23, 42, 0.5); - font-weight: 500; } .loading { @@ -97,10 +108,6 @@ align-items: center; } -.filterTagsWrapper { - border-bottom: 1px solid rgba(17, 24, 39, 0.08); -} - .filterMenu { display: flex; flex-direction: column; @@ -115,6 +122,13 @@ min-width: 12rem; } -:global([data-sidebar-collapsed='true']) .filterTagsWrapper { - display: none; +@media (max-width: 720px) { + .detailsHeader { + --banner-cover-height: 4rem; + padding: 0.45rem 0.45rem 0.35rem; + } + + .bannerCover { + height: var(--banner-cover-height); + } } diff --git a/src/app/volunteer_dashboard/panels/members/page.tsx b/src/app/volunteer_dashboard/panels/members/page.tsx index 1f4a6bad..55c92e86 100644 --- a/src/app/volunteer_dashboard/panels/members/page.tsx +++ b/src/app/volunteer_dashboard/panels/members/page.tsx @@ -6,13 +6,13 @@ 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 { ListElement, List } from '@/app/admin/layout/List' +import { + DiscordAvatar, + 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, @@ -43,8 +43,7 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query' -import { useSearchParams } from 'next/navigation' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { FaUsers, FaUserTag, @@ -53,7 +52,7 @@ import { } from 'react-icons/fa' import { FaClipboardUser, FaDollarSign, FaAddressCard } from 'react-icons/fa6' import { MdVerified } from 'react-icons/md' -import { useMediaQuery } from 'usehooks-ts' +import { PulseLoader } from 'react-spinners' import z from 'zod' type MemberTabKey = 'overview' | 'donorMatching' | 'history' @@ -64,34 +63,6 @@ const tabs: TabSpec[] = [ { 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: }, @@ -106,13 +77,8 @@ const birthYears = Array.from( export default function Page() { 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 [selectedId, setSelectedId] = useState(null) const [selectedHistory, setSelectedHistory] = useState | null>(null) @@ -303,9 +269,6 @@ export default function Page() { onSearch({ ...rest, page: 0, ...tagFilters }) } - const [sidebarMobileVisible, setSidebarMobileVisible] = useState(true) - const isDesktop = useMediaQuery('(min-width: 64rem)') - const loggedInUser = useCurrentUser() const positionQueries = usePositionQueries() @@ -569,6 +532,7 @@ export default function Page() { nameConfirmed: user.nameConfirmed, addressConfirmed: user.addressConfirmed, roles: user.roles?.map((role) => role.id), + aliases: [...(user.aliases ?? [])], } satisfies UpdateUserRequest) if (addressIsDirty) request.address = address @@ -614,29 +578,27 @@ export default function Page() { [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 renderItem = (item: User | UserProfile) => { + return ( + handleSelectItem(item)} + > + +
+ {makeTitle(item)} + + {item.discordUsers?.[0]?.username ?? 'NOT FOUND'} + +
+
+ ) + } const renderPage = () => { if (!selectedId || !userQuery.data) return null @@ -704,37 +666,70 @@ export default function Page() { } return ( - - onSearch({ ...search, page: nextPage }), - }, - filters: { - search, - onSearch, - searchFieldOptions: MEMBER_FIELD_OPTIONS, - sortFieldOptions: MEMBER_SORT_FIELD_OPTIONS, - showSort: true, - showLimit: true, - options: [ + <> +
+ + +
+ } + searchFields={[ + { value: 'first_name', label: 'First Name' }, + { value: 'last_name', label: 'Last Name' }, + { + value: 'discord_usernames', + label: 'Discord Username', + }, + { value: 'email', label: 'Email' }, + { value: 'phone', label: 'Phone Number' }, + { value: 'zip', label: 'Zip Code' }, + { value: 'birthdate', label: 'Birthdate' }, + { value: 'created_at_utc', label: 'Join Date' }, + // { value: 'county', label: 'County' }, + // { value: 'city', label: 'City' }, + // { value: 'state', label: 'State' }, + // { value: 'preferred_name', label: 'Preferred Name' }, + + // { + // value: 'accepted_alerts', + // label: 'Accepted Notifications', + // }, + // { value: 'onboarding_stage', label: 'Onboarding Stage' }, + // { value: 'joined_at_utc', label: 'Date Joined Server' }, + // { + // value: 'completed_intake_utc', + // label: 'Date Intake Done', + // }, + // { value: 'aliases', label: 'Aliases' }, + ]} + sortFields={[ + { 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: 'Date Modified' }, + ]} + filters={[ { label: 'Role', value: 'roleIds', @@ -743,84 +738,35 @@ export default function Page() { 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} - /> + ]} + pinnedContent={ + loggedInUser.data ? ( + renderItem(loggedInUser.data) + ) : ( +
    + + +
    + +
    +
    +
+ ) + } + onSearch={onSearch} + > + {searchQuery.data?.data?.map((item) => renderItem(item))} + +
+
{selectedId == null && (
No user selected
)} - - {selectedId != null && userQuery.isPending && ( -
- Loading user details... -
- )} - - {selectedId != null && userQuery.error && ( -
- Error:{' '} - {userQuery.error instanceof Error - ? userQuery.error.message - : 'Unknown error'} -
- )} - {selectedId && userQuery.data && ( <>
@@ -842,6 +788,6 @@ export default function Page() { )}
- + ) } diff --git a/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx b/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx index 276df149..d30b484f 100644 --- a/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx +++ b/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx @@ -9,7 +9,6 @@ import { FormGroup, TextField, } from '@/components/common/forms' -import { NavigationButton } from '@/components/common/navigation_stack/navigation_button/NavigationButton' import { ActBlueContribution, ActBlueContributionCustomField, @@ -100,12 +99,6 @@ const calcContributionData = (donor: ActBlueDonor): ContributionData => { } } -const formatLineitemDate = (value: Date) => - Intl.DateTimeFormat('en-US', { - dateStyle: 'medium', - timeStyle: 'short', - }).format(value) - export function DonorView({ selectedId, user, @@ -373,110 +366,17 @@ export function DonorView({ /> - {( - 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 - } - /> - ) - )} - - - - ))} - + > + Open in Donors Panel +
@@ -577,7 +477,7 @@ export function DonorView({ form.discordUsers?.[0]?.id} readonly /> + label="Aliases" field="aliases" /> - label="Aliases" field="aliases" /> From 4fe2b2100cc838dddd1b43dc9fe4f9aa783e11d4 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Sun, 13 Sep 2026 09:52:46 -0400 Subject: [PATCH 06/10] Reapply "new memberview" This reverts commit 776795b80040bb5986aa0838dcf0dca60d1c81a9. --- .../panels/members/page.module.css | 58 ++-- .../panels/members/page.tsx | 296 +++++++++++------- .../panels/members/panel_views/DonorView.tsx | 122 +++++++- .../panels/members/panel_views/MemberView.tsx | 2 +- 4 files changed, 309 insertions(+), 169 deletions(-) diff --git a/src/app/volunteer_dashboard/panels/members/page.module.css b/src/app/volunteer_dashboard/panels/members/page.module.css index 384e3c0d..fb177150 100644 --- a/src/app/volunteer_dashboard/panels/members/page.module.css +++ b/src/app/volunteer_dashboard/panels/members/page.module.css @@ -1,5 +1,16 @@ -.listWidth { - --list-width: 25.5rem; +.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 { @@ -65,42 +76,20 @@ rgba(241, 245, 249, 0.98) 38%, rgba(14, 165, 233, 0.04) 100% ); + z-index: 0; } .detailsContent { margin: 1rem; } -.userMeta { - display: flex; - flex-direction: column; -} - -.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); -} - -.userName { - font-weight: 500; - color: #000; -} - -.userUsername { - color: #6b7280; -} - .emptyState { display: flex; height: 100%; align-items: center; justify-content: center; + color: rgba(15, 23, 42, 0.5); + font-weight: 500; } .loading { @@ -108,6 +97,10 @@ align-items: center; } +.filterTagsWrapper { + border-bottom: 1px solid rgba(17, 24, 39, 0.08); +} + .filterMenu { display: flex; flex-direction: column; @@ -122,13 +115,6 @@ min-width: 12rem; } -@media (max-width: 720px) { - .detailsHeader { - --banner-cover-height: 4rem; - padding: 0.45rem 0.45rem 0.35rem; - } - - .bannerCover { - height: var(--banner-cover-height); - } +:global([data-sidebar-collapsed='true']) .filterTagsWrapper { + display: none; } diff --git a/src/app/volunteer_dashboard/panels/members/page.tsx b/src/app/volunteer_dashboard/panels/members/page.tsx index 55c92e86..1f4a6bad 100644 --- a/src/app/volunteer_dashboard/panels/members/page.tsx +++ b/src/app/volunteer_dashboard/panels/members/page.tsx @@ -6,13 +6,13 @@ 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, List } from '@/app/admin/layout/List' -import { - DiscordAvatar, - DropdownOverlay, - DropdownOverlayButton, -} from '@/components/common' +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, @@ -43,7 +43,8 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query' -import { useCallback, useMemo, useState } from 'react' +import { useSearchParams } from 'next/navigation' +import { useCallback, useEffect, useMemo, useState } from 'react' import { FaUsers, FaUserTag, @@ -52,7 +53,7 @@ import { } from 'react-icons/fa' import { FaClipboardUser, FaDollarSign, FaAddressCard } from 'react-icons/fa6' import { MdVerified } from 'react-icons/md' -import { PulseLoader } from 'react-spinners' +import { useMediaQuery } from 'usehooks-ts' import z from 'zod' type MemberTabKey = 'overview' | 'donorMatching' | 'history' @@ -63,6 +64,34 @@ const tabs: TabSpec[] = [ { 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: }, @@ -77,8 +106,13 @@ const birthYears = Array.from( export default function Page() { const queryClient = useQueryClient() const { ready, onGet, onPatch, onPost } = useFetch() + const navParams = useSearchParams() + const navUserId = navParams.get('userId') - const [selectedId, setSelectedId] = useState(null) + const initialUserId = navUserId ? Number(navUserId) : null + const [selectedId, setSelectedId] = useState( + Number.isFinite(initialUserId) ? initialUserId : null + ) const [selectedHistory, setSelectedHistory] = useState | null>(null) @@ -269,6 +303,9 @@ export default function Page() { onSearch({ ...rest, page: 0, ...tagFilters }) } + const [sidebarMobileVisible, setSidebarMobileVisible] = useState(true) + const isDesktop = useMediaQuery('(min-width: 64rem)') + const loggedInUser = useCurrentUser() const positionQueries = usePositionQueries() @@ -532,7 +569,6 @@ export default function Page() { nameConfirmed: user.nameConfirmed, addressConfirmed: user.addressConfirmed, roles: user.roles?.map((role) => role.id), - aliases: [...(user.aliases ?? [])], } satisfies UpdateUserRequest) if (addressIsDirty) request.address = address @@ -578,27 +614,29 @@ export default function Page() { [handleSelectDonorItem] ) - const renderItem = (item: User | UserProfile) => { - return ( - handleSelectItem(item)} - > - -
- {makeTitle(item)} - - {item.discordUsers?.[0]?.username ?? 'NOT FOUND'} - -
-
- ) - } + 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 @@ -666,70 +704,37 @@ export default function Page() { } return ( - <> -
- - -
- } - searchFields={[ - { value: 'first_name', label: 'First Name' }, - { value: 'last_name', label: 'Last Name' }, - { - value: 'discord_usernames', - label: 'Discord Username', - }, - { value: 'email', label: 'Email' }, - { value: 'phone', label: 'Phone Number' }, - { value: 'zip', label: 'Zip Code' }, - { value: 'birthdate', label: 'Birthdate' }, - { value: 'created_at_utc', label: 'Join Date' }, - // { value: 'county', label: 'County' }, - // { value: 'city', label: 'City' }, - // { value: 'state', label: 'State' }, - // { value: 'preferred_name', label: 'Preferred Name' }, - - // { - // value: 'accepted_alerts', - // label: 'Accepted Notifications', - // }, - // { value: 'onboarding_stage', label: 'Onboarding Stage' }, - // { value: 'joined_at_utc', label: 'Date Joined Server' }, - // { - // value: 'completed_intake_utc', - // label: 'Date Intake Done', - // }, - // { value: 'aliases', label: 'Aliases' }, - ]} - sortFields={[ - { 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: 'Date Modified' }, - ]} - filters={[ + + 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', @@ -738,35 +743,84 @@ export default function Page() { value: role.id, })), }, - ]} - pinnedContent={ - loggedInUser.data ? ( - renderItem(loggedInUser.data) - ) : ( -
    - - -
    - -
    -
    -
- ) - } - onSearch={onSearch} - > - {searchQuery.data?.data?.map((item) => renderItem(item))} - - - + ], + }, + }} + 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:{' '} + {userQuery.error instanceof Error + ? userQuery.error.message + : 'Unknown error'} +
+ )} + {selectedId && userQuery.data && ( <>
@@ -788,6 +842,6 @@ export default function Page() { )}
- + ) } diff --git a/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx b/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx index d30b484f..276df149 100644 --- a/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx +++ b/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx @@ -9,6 +9,7 @@ import { FormGroup, TextField, } from '@/components/common/forms' +import { NavigationButton } from '@/components/common/navigation_stack/navigation_button/NavigationButton' import { ActBlueContribution, ActBlueContributionCustomField, @@ -99,6 +100,12 @@ const calcContributionData = (donor: ActBlueDonor): ContributionData => { } } +const formatLineitemDate = (value: Date) => + Intl.DateTimeFormat('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(value) + export function DonorView({ selectedId, user, @@ -366,17 +373,110 @@ export function DonorView({ /> - ( + + + 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 + } + /> + ) + )} + + + + ))} + - Open in Donors Panel - + tag={{ + className: + styles.detailsNavigationTagSection, + }} + />
@@ -477,7 +577,7 @@ export function DonorView({ form.discordUsers?.[0]?.id} readonly /> - label="Aliases" field="aliases" /> + label="Aliases" field="aliases" /> From d66c08aa33ffef53bea21d5f2d5592a0f19bd90a Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Sun, 13 Sep 2026 09:53:01 -0400 Subject: [PATCH 07/10] and this --- .../members/panel_views/DonorView.module.css | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) 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 index cc99eeea..362c4709 100644 --- a/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.module.css +++ b/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.module.css @@ -173,3 +173,199 @@ 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; +} + +.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; +} + +.modal::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; +} + +.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; +} + +.modalBody :global(.pinned), +.modalBody :global(.elementList), +.modalBody :global(.listStatus) { + padding: 0.9rem 1rem 1rem; +} + +.modalBody :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; +} + +.searchInputBare > 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; +} + +.detailsNavigationLink:hover { + background: rgba(14, 165, 233, 0.18); +} + +.detailsNavigationLink: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; +} + +@media (prefers-reduced-motion: reduce) { + .modalBackdrop { + will-change: auto; + } + + .modal { + will-change: auto; + } +} From 3b0100cda9d1e9ccfcaeb07b14b83c8b800a7618 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Sun, 13 Sep 2026 10:05:52 -0400 Subject: [PATCH 08/10] chain selectors and undoing a false && --- .../panels/members/page.module.css | 36 +++--- .../panels/members/page.tsx | 2 +- .../members/panel_views/DonorView.module.css | 108 +++++++++--------- .../panel_views/HistoryView.module.css | 82 ++++++------- 4 files changed, 113 insertions(+), 115 deletions(-) diff --git a/src/app/volunteer_dashboard/panels/members/page.module.css b/src/app/volunteer_dashboard/panels/members/page.module.css index fb177150..925f1abc 100644 --- a/src/app/volunteer_dashboard/panels/members/page.module.css +++ b/src/app/volunteer_dashboard/panels/members/page.module.css @@ -42,21 +42,21 @@ ); border-radius: 1rem; -} -.detailsHeader::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; + &::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 { @@ -99,6 +99,10 @@ .filterTagsWrapper { border-bottom: 1px solid rgba(17, 24, 39, 0.08); + + :global([data-sidebar-collapsed='true']) & { + display: none; + } } .filterMenu { @@ -114,7 +118,3 @@ gap: 0.15rem; min-width: 12rem; } - -:global([data-sidebar-collapsed='true']) .filterTagsWrapper { - display: none; -} diff --git a/src/app/volunteer_dashboard/panels/members/page.tsx b/src/app/volunteer_dashboard/panels/members/page.tsx index 1f4a6bad..1f14fce5 100644 --- a/src/app/volunteer_dashboard/panels/members/page.tsx +++ b/src/app/volunteer_dashboard/panels/members/page.tsx @@ -714,7 +714,7 @@ export default function Page() { sidebarWidth="25.5rem" collapsedSidebarWidth="5rem" sidebarClassName={styles.sidebarBg} - sidebarMobileVisible={isDesktop && sidebarMobileVisible} + sidebarMobileVisible={isDesktop || sidebarMobileVisible} label="Members" showScrollbar={false} sidebarList={{ 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 index 362c4709..d020fc7f 100644 --- a/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.module.css +++ b/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.module.css @@ -138,10 +138,10 @@ transition: transform 120ms ease, background 120ms ease; -} -.ghostButton:hover { - background-color: #e5e7eb; + &:hover { + background-color: #e5e7eb; + } } .ghostDangerButton { @@ -156,14 +156,14 @@ transition: transform 120ms ease, background 120ms ease; -} -.ghostDangerButton:hover { - background: rgba(239, 68, 68, 0.352); -} + &:hover { + background: rgba(239, 68, 68, 0.352); + } -.ghostDangerButton:active { - transform: translateY(0px); + &:active { + transform: translateY(0px); + } } .refetchingPill { @@ -186,6 +186,10 @@ 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 { @@ -206,19 +210,23 @@ overflow: hidden; transform-origin: 50% 40%; will-change: transform, opacity; -} -.modal::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; + &::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 { @@ -270,17 +278,17 @@ -webkit-backdrop-filter: blur(18px) saturate(160%); font-weight: 500; padding-inline: 1rem; -} -.modalBody :global(.pinned), -.modalBody :global(.elementList), -.modalBody :global(.listStatus) { - padding: 0.9rem 1rem 1rem; -} + & :global(.pinned), + & :global(.elementList), + & :global(.listStatus) { + padding: 0.9rem 1rem 1rem; + } -.modalBody :global(.listStatus) { - flex: 1; - min-height: 0; + & :global(.listStatus) { + flex: 1; + min-height: 0; + } } .modalFooter { @@ -304,16 +312,16 @@ display: flex; width: 100%; align-items: center; -} -.searchInputBare > 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); + & > 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 { @@ -335,14 +343,14 @@ font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; -} -.detailsNavigationLink:hover { - background: rgba(14, 165, 233, 0.18); -} + &:hover { + background: rgba(14, 165, 233, 0.18); + } -.detailsNavigationLink:active { - transform: translateY(0) scale(0.99); + &:active { + transform: translateY(0) scale(0.99); + } } .detailsNavigationLabel { @@ -359,13 +367,3 @@ .detailsNavigationTagSection { display: none; } - -@media (prefers-reduced-motion: reduce) { - .modalBackdrop { - will-change: auto; - } - - .modal { - will-change: auto; - } -} 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 index 311c3bab..04066207 100644 --- a/src/app/volunteer_dashboard/panels/members/panel_views/HistoryView.module.css +++ b/src/app/volunteer_dashboard/panels/members/panel_views/HistoryView.module.css @@ -46,6 +46,14 @@ transition: background 140ms ease, transform 140ms ease; + + &:last-child { + border-bottom: 0; + } + + &:hover { + background: rgba(15, 23, 42, 0.06); + } } .historyEntryMain { @@ -56,20 +64,12 @@ flex-wrap: wrap; } -.historyEntry:last-child { - border-bottom: 0; -} - -.historyEntry:hover { - background: rgba(15, 23, 42, 0.06); -} - .historyEntrySelected { background: rgba(15, 23, 42, 0.08); -} -.historyEntrySelected:hover { - background: rgba(15, 23, 42, 0.1); + &:hover { + background: rgba(15, 23, 42, 0.1); + } } .historyEntryPrefix { @@ -101,39 +101,39 @@ color: rgba(15, 23, 42, 0.72); background: rgba(15, 23, 42, 0.06); -} -.historyEntryDateTag::after { - content: attr(data-full-date); - position: absolute; - right: 0; - top: calc(100% + 0.35rem); + &::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; + 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: 560; - line-height: 1.35; + color: rgba(241, 245, 249, 0.95); + background: rgba(15, 23, 42, 0.92); + font-size: 0.72rem; + font-weight: 560; + line-height: 1.35; - box-shadow: 0 8px 20px rgba(2, 6, 23, 0.28); + 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; -} + opacity: 0; + transform: translateY(-3px); + transition: + opacity 100ms ease, + transform 100ms ease; + pointer-events: none; + z-index: 8; + } -.historyEntryDateTag:hover::after, -.historyEntryDateTag:focus-visible::after { - opacity: 1; - transform: translateY(0); + &:hover::after, + &:focus-visible::after { + opacity: 1; + transform: translateY(0); + } } .historyEntryCode { @@ -152,10 +152,10 @@ .historyEntryUi { cursor: default; -} -.historyEntryUi:hover { - background: transparent; + &:hover { + background: transparent; + } } .snapshotWrap { From 3ab26a9925c0ff0082908c81afe19a1104d0ffd5 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Sun, 13 Sep 2026 15:19:50 -0400 Subject: [PATCH 09/10] bitos changes --- .../panels/members/page.module.css | 4 + .../panels/members/page.tsx | 21 +- .../panels/members/panel_views/DonorView.tsx | 760 ++++++++---------- .../panel_views/HistoryView.module.css | 2 +- 4 files changed, 328 insertions(+), 459 deletions(-) diff --git a/src/app/volunteer_dashboard/panels/members/page.module.css b/src/app/volunteer_dashboard/panels/members/page.module.css index 925f1abc..08a84899 100644 --- a/src/app/volunteer_dashboard/panels/members/page.module.css +++ b/src/app/volunteer_dashboard/panels/members/page.module.css @@ -92,6 +92,10 @@ font-weight: 500; } +.errorText { + color: #ef4444; +} + .loading { display: flex; align-items: center; diff --git a/src/app/volunteer_dashboard/panels/members/page.tsx b/src/app/volunteer_dashboard/panels/members/page.tsx index 1f14fce5..ded86342 100644 --- a/src/app/volunteer_dashboard/panels/members/page.tsx +++ b/src/app/volunteer_dashboard/panels/members/page.tsx @@ -35,6 +35,7 @@ import { 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, @@ -629,9 +630,7 @@ export default function Page() { }, [searchQuery.data?.data, loggedInUser.data]) useEffect(() => { - if (navUserId == null) { - return - } + if (navUserId == null) return const nextSelectedId = Number(navUserId) @@ -652,9 +651,9 @@ export default function Page() { saving={updateMutation.isPending} editing={formState?.mode === 'edit'} isInvalid={ - (formState?.form?.address?.zip != null && - locationQuery.data == null) || - locationQuery.isPending + formState?.form?.address?.zip != null && + (locationQuery.data == null || + locationQuery.isPending) } roles={roles} roleOptions={roleOptions} @@ -810,14 +809,8 @@ export default function Page() { )} {selectedId != null && userQuery.error && ( -
- Error:{' '} - {userQuery.error instanceof Error - ? userQuery.error.message - : 'Unknown error'} +
+ Error: {parseErrorMessage(userQuery.error)}
)} diff --git a/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx b/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx index 276df149..d8d3d13e 100644 --- a/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx +++ b/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx @@ -145,457 +145,329 @@ export function DonorView({ onDonorSearch({ ...donorSearch, query: e.target.value }) } - return ( -
- {!hasLinked && ( -
-
-
-
- No Donors Found -
-
- Automatic donor matching not implemented yet. -
-
- -
- -
+ const renderEmptyState = () => ( +
+
+
+
No Donors Found
+
+ Automatic donor matching not implemented yet.
- )} + +
+ +
+
+
+ ) + + const renderLinkedHeader = () => ( +
+
+
Donor Details
+
+ View donor information sourced from ActBlue contributions. +
+
+ +
+ {isRefetching ? ( + Loading... + ) : null} + + +
+
+ ) + + 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 && (
-
-
-
- Donor Details -
-
- View donor information sourced from ActBlue - contributions. -
-
- -
- {isRefetching ? ( - - Loading... - - ) : null} - - -
-
+ {renderLinkedHeader()}
- {linkedDonors.map((donor) => { - const contributionData = calcContributionData(donor) - const contributionFormByLineitemId = new Map< - number, - string - >() - const contributionIsRecurringByLineitemId = new Map< - number, - boolean - >() - - const contributions = donor.contributions ?? [] - contributions.forEach( - (contribution: ActBlueContribution) => { - const lineitems = - contribution.lineitems ?? [] - lineitems.forEach( - (lineitem: ActBlueLineitem) => { - contributionFormByLineitemId.set( - lineitem.lineitemId, - contribution.contributionForm - ) - contributionIsRecurringByLineitemId.set( - lineitem.lineitemId, - contribution.isRecurring - ) - } - ) - } - ) - - return ( -
-
- - key={donor.userId} - title="" - readonly - form={donor} - > - - - - - - - - - - - - - - - - - - - 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 - } - /> - ) - )} - - - - ))} - - - -
-
- - key={donor.userId} - title="" - readonly - form={donor} - > - - - {[ - ...(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 - -
- ))} -
-
-
- -
-
- ) - })} + {linkedDonors.map(renderDonorCard)}
)} 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 index 04066207..4c16b1cc 100644 --- a/src/app/volunteer_dashboard/panels/members/panel_views/HistoryView.module.css +++ b/src/app/volunteer_dashboard/panels/members/panel_views/HistoryView.module.css @@ -115,7 +115,7 @@ color: rgba(241, 245, 249, 0.95); background: rgba(15, 23, 42, 0.92); font-size: 0.72rem; - font-weight: 560; + font-weight: 500; line-height: 1.35; box-shadow: 0 8px 20px rgba(2, 6, 23, 0.28); From 4fb5214c147d67203a9c9a98ffe682d08c734854 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert-Lif Date: Sun, 13 Sep 2026 15:31:57 -0400 Subject: [PATCH 10/10] Update DonorView.tsx --- .../panels/members/panel_views/DonorView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx b/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx index d8d3d13e..066f3ecf 100644 --- a/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx +++ b/src/app/volunteer_dashboard/panels/members/panel_views/DonorView.tsx @@ -178,9 +178,9 @@ export function DonorView({
- {isRefetching ? ( + {isRefetching && ( Loading... - ) : null} + )}