From 1bb4e46987e2a9da4a6de6de36117c915e0d5125 Mon Sep 17 00:00:00 2001 From: No767 <73260931+No767@users.noreply.github.com> Date: Sat, 27 Jun 2026 02:30:38 -0700 Subject: [PATCH 1/3] The "base" of the dashboard --- .oxfmtrc.json | 1 + .oxlintrc.json | 1 + eslint.config.js | 1 + mise.toml | 2 +- package.json | 5 +- pnpm-lock.yaml | 71 ++ src/components/app/check-in-dialog.tsx | 214 ++++++ src/components/app/dashboard-events.tsx | 858 ++++++++++++++++++++++++ src/components/ui/badge.tsx | 52 ++ src/components/ui/button.tsx | 2 +- src/components/ui/carousel.tsx | 6 +- src/components/ui/dialog.tsx | 2 +- src/components/ui/dropdown-menu.tsx | 266 ++++++++ src/components/ui/separator.tsx | 23 + src/components/ui/sidebar.tsx | 723 ++++++++++++++++++++ src/components/ui/tabs.tsx | 82 +++ src/components/ui/tooltip.tsx | 64 ++ src/hooks/use-mobile.ts | 22 + src/index.css | 8 +- src/lib/dashboard-events.ts | 209 ++++++ src/main.tsx | 6 + src/routeTree.gen.ts | 121 +++- src/routes/__root.tsx | 8 +- src/routes/dashboard/events.tsx | 736 ++++++++++++++++++++ src/routes/dashboard/events_.past.tsx | 218 ++++++ src/routes/dashboard/index.tsx | 478 +++++++++++++ src/routes/dashboard/projects.tsx | 25 + src/routes/dashboard/route.tsx | 283 ++++++++ tsconfig.app.json | 2 +- 29 files changed, 4476 insertions(+), 13 deletions(-) create mode 100644 src/components/app/check-in-dialog.tsx create mode 100644 src/components/app/dashboard-events.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/dropdown-menu.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/components/ui/sidebar.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/hooks/use-mobile.ts create mode 100644 src/lib/dashboard-events.ts create mode 100644 src/routes/dashboard/events.tsx create mode 100644 src/routes/dashboard/events_.past.tsx create mode 100644 src/routes/dashboard/index.tsx create mode 100644 src/routes/dashboard/projects.tsx create mode 100644 src/routes/dashboard/route.tsx diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 4e8caa7b..59848e48 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -6,6 +6,7 @@ // Automatically generated code "src/routeTree.gen.ts", + "src/hooks/use-mobile.ts", // Shadcn/ui components "src/components/ui", diff --git a/.oxlintrc.json b/.oxlintrc.json index 7ca8cbe8..c4e896ab 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -16,6 +16,7 @@ // Automatically generated code "src/routeTree.gen.ts", + "src/hooks/use-mobile.ts", // Shadcn/ui components "src/components/ui", diff --git a/eslint.config.js b/eslint.config.js index ee467eef..6811e10b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -12,6 +12,7 @@ export default defineConfig([ // Automatically generated code "src/routeTree.gen.ts", + "src/hooks/use-mobile.ts", // Shadcn/ui components "src/components/ui", diff --git a/mise.toml b/mise.toml index 27d30ad6..75c61585 100644 --- a/mise.toml +++ b/mise.toml @@ -1,6 +1,6 @@ [tools] node = "24.17.0" -pnpm = "11.8.0" +pnpm = "11.9.0" [settings] diff --git a/package.json b/package.json index 534cc74a..0fb9fe8c 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,11 @@ "@fontsource-variable/raleway": "^5.2.8", "@icons-pack/react-simple-icons": "^13.13.0", "@schedule-x/calendar": "^4.6.0", + "@schedule-x/calendar-controls": "^4.6.0", "@schedule-x/react": "^4.1.0", "@schedule-x/scroll-controller": "^4.6.0", "@schedule-x/theme-shadcn": "^4.6.0", + "@tanstack/react-form": "^1.33.0", "@tanstack/react-query": "^5.101.0", "@tanstack/react-router": "^1.170.15", "axios": "^1.18.0", @@ -28,7 +30,8 @@ "react-dom": "^19.2.7", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", - "temporal-polyfill": "^1.0.0" + "temporal-polyfill": "^1.0.0", + "zod": "^4.4.3" }, "devDependencies": { "@rolldown/plugin-babel": "^0.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 894fbe9f..2e6a41ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@schedule-x/calendar': specifier: ^4.6.0 version: 4.6.0(@preact/signals@2.9.2(preact@10.29.2))(preact@10.29.2)(temporal-polyfill@1.0.1) + '@schedule-x/calendar-controls': + specifier: ^4.6.0 + version: 4.6.0 '@schedule-x/react': specifier: ^4.1.0 version: 4.1.0(@schedule-x/calendar@4.6.0(@preact/signals@2.9.2(preact@10.29.2))(preact@10.29.2)(temporal-polyfill@1.0.1))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -29,6 +32,9 @@ importers: '@schedule-x/theme-shadcn': specifier: ^4.6.0 version: 4.6.0 + '@tanstack/react-form': + specifier: ^1.33.0 + version: 1.33.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-query': specifier: ^5.101.0 version: 5.101.0(react@19.2.7) @@ -68,6 +74,9 @@ importers: temporal-polyfill: specifier: ^1.0.0 version: 1.0.1 + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@rolldown/plugin-babel': specifier: ^0.2.3 @@ -1472,6 +1481,9 @@ packages: rollup: optional: true + '@schedule-x/calendar-controls@4.6.0': + resolution: {integrity: sha512-+VVmJ4AhZkAqTsz/U6L6mftuohBXSdJ10/HFWoMrO5coaoj0K5MExCdcG7UkFK3ZmoPz03koO0RnqRt5FKyXsg==} + '@schedule-x/calendar@4.6.0': resolution: {integrity: sha512-8Rf5bUf0DVphJB2BaudtG4IHkw21wgMJiiuk5o+7SIOq6YH4hkVJo+Kk12pWSfp2KTZ9mnnUuLV+szBVhiEPlg==} peerDependencies: @@ -1645,6 +1657,11 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/devtools-event-client@0.4.4': + resolution: {integrity: sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==} + engines: {node: '>=18'} + hasBin: true + '@tanstack/eslint-plugin-query@5.101.0': resolution: {integrity: sha512-wsfg821y4yw21J7nKI2oM5yyGSz3vASXqgWbmWCXZpnyY9ObLrBCcXivwZKj4YHF2fUWiqoOIRX2pbE79cf6gQ==} peerDependencies: @@ -1659,13 +1676,29 @@ packages: peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + '@tanstack/form-core@1.33.0': + resolution: {integrity: sha512-AV4Pw9Dk4orFsuPBcDssfWMJFs+yMYBae7zZ4oTqrCf4ftNGQKxvrQRZeqKHG6A4TkiLeSvf2kzIjcVkrW7E6w==} + '@tanstack/history@1.162.0': resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} engines: {node: '>=20.19'} + '@tanstack/pacer-lite@0.1.1': + resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==} + engines: {node: '>=18'} + '@tanstack/query-core@5.101.0': resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==} + '@tanstack/react-form@1.33.0': + resolution: {integrity: sha512-unaee+VS4MvKo+s1dmgGUXI4902VeAhuaUbKsQbhFe3MceOpB3JpAUGCDpyzjQPXVFkFY0COKfLrUNX2XZYW4g==} + peerDependencies: + '@tanstack/react-start': '*' + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@tanstack/react-start': + optional: true + '@tanstack/react-query@5.101.0': resolution: {integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==} peerDependencies: @@ -1678,6 +1711,12 @@ packages: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' + '@tanstack/react-store@0.11.0': + resolution: {integrity: sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-store@0.9.3': resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} peerDependencies: @@ -1717,6 +1756,9 @@ packages: resolution: {integrity: sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ==} engines: {node: '>=20.19'} + '@tanstack/store@0.11.0': + resolution: {integrity: sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw==} + '@tanstack/store@0.9.3': resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} @@ -4832,6 +4874,8 @@ snapshots: estree-walker: 2.0.2 picomatch: 4.0.4 + '@schedule-x/calendar-controls@4.6.0': {} + '@schedule-x/calendar@4.6.0(@preact/signals@2.9.2(preact@10.29.2))(preact@10.29.2)(temporal-polyfill@1.0.1)': dependencies: '@preact/signals': 2.9.2(preact@10.29.2) @@ -4976,6 +5020,8 @@ snapshots: tailwindcss: 4.3.1 vite: 8.0.16(@types/node@25.9.4)(jiti@2.7.0)(yaml@2.9.0) + '@tanstack/devtools-event-client@0.4.4': {} + '@tanstack/eslint-plugin-query@5.101.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) @@ -4993,10 +5039,26 @@ snapshots: - supports-color - typescript + '@tanstack/form-core@1.33.0': + dependencies: + '@tanstack/devtools-event-client': 0.4.4 + '@tanstack/pacer-lite': 0.1.1 + '@tanstack/store': 0.11.0 + '@tanstack/history@1.162.0': {} + '@tanstack/pacer-lite@0.1.1': {} + '@tanstack/query-core@5.101.0': {} + '@tanstack/react-form@1.33.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/form-core': 1.33.0 + '@tanstack/react-store': 0.11.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + transitivePeerDependencies: + - react-dom + '@tanstack/react-query@5.101.0(react@19.2.7)': dependencies: '@tanstack/query-core': 5.101.0 @@ -5011,6 +5073,13 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + '@tanstack/react-store@0.11.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/store': 0.11.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) + '@tanstack/react-store@0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@tanstack/store': 0.9.3 @@ -5068,6 +5137,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@tanstack/store@0.11.0': {} + '@tanstack/store@0.9.3': {} '@tanstack/virtual-file-routes@1.162.0': {} diff --git a/src/components/app/check-in-dialog.tsx b/src/components/app/check-in-dialog.tsx new file mode 100644 index 00000000..dd6fa13a --- /dev/null +++ b/src/components/app/check-in-dialog.tsx @@ -0,0 +1,214 @@ +import { useForm } from "@tanstack/react-form"; +import { useMutation } from "@tanstack/react-query"; +import axios from "axios"; +import { type LucideIcon, Check, Clock, Lock, QrCode } from "lucide-react"; +import { type ChangeEvent, type SyntheticEvent, useCallback } from "react"; +import { toast } from "sonner"; +import { z } from "zod"; + +import { EmptyState, PILL_CLASS } from "@/components/app/dashboard-events"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { type CheckInState, determineCheckIn, type DashboardEvent } from "@/lib/dashboard-events"; +import { cn } from "@/lib/utils"; + +/// Constants + +const API_BASE_URL = + (import.meta.env.VITE_API_URL as string | undefined) || "http://localhost:8000"; + +const STATUS_GREEN = "text-[#15a66e] bg-[#15a66e]/15 dark:text-[#3fd68c] dark:bg-[#3fd68c]/15"; +const STATUS_MUTED = "text-muted-foreground bg-muted"; + +const CHECKIN_WINDOW = { + too_early: { label: "Opens 1 hour before start", className: STATUS_MUTED, icon: Clock }, + open: { label: "Check-in open", className: STATUS_GREEN, icon: Clock }, + ended: { label: "Check-in closed", className: STATUS_MUTED, icon: Lock }, +} satisfies Record; + +const CHECKIN_EMPTY = { + too_early: { + icon: Clock, + title: "Check-in hasn't opened yet", + sub: "Check-in opens one hour before the event starts.", + }, + ended: { + icon: Lock, + title: "Check-in has closed", + sub: "The window runs until the event ends.", + }, +} satisfies Partial>; + +const checkInSchema = z.object({ + code: z + .string() + .regex(/^[A-Z0-9]*$/, "Letters and numbers only") + .length(8, "Enter all 8 characters"), +}); + +/// Component + +interface CheckInDialogProps { + event: DashboardEvent; + now: Date; + open: boolean; + onOpenChange: (open: boolean) => void; + onVerified?: () => unknown; +} + +export function CheckInDialog({ + event, + now, + open, + onOpenChange, + onVerified, +}: Readonly) { + const { mutate, reset, isError, isPending, isSuccess } = useMutation({ + mutationFn: async (code: string) => { + const { data } = await axios.post<{ message: string }>( + `${API_BASE_URL}/events/${event.id}/verify`, + { code }, + ); + return data; + }, + onSuccess: () => { + toast.success(`You're checked in to ${event.name}.`); + return onVerified?.(); + }, + }); + + const form = useForm({ + defaultValues: { code: "" }, + validators: { onChange: checkInSchema }, + onSubmit: ({ value }) => { + mutate(value.code); + }, + }); + + const handleOpenChange = useCallback( + (next: boolean) => { + if (!next) { + form.reset(); + reset(); + } + onOpenChange(next); + }, + [form, onOpenChange, reset], + ); + const handleSubmit = useCallback( + (event: SyntheticEvent) => { + event.preventDefault(); + void form.handleSubmit(); + }, + [form], + ); + const handleCodeChange = useCallback( + (event: ChangeEvent) => { + const code = event.target.value + .replaceAll(/[^a-z0-9]/gi, "") + .toUpperCase() + .slice(0, 8); + form.setFieldValue("code", code); + }, + [form], + ); + + const state = determineCheckIn(event, now); + const succeeded = event.attended || isSuccess; + const status = CHECKIN_WINDOW[state]; + + const StatusIcon = status.icon; + + return ( + + + + Check in + {event.name} + + +
+ + + {status.label} + +
+ + {succeeded && ( +
+
+ +
+
You're checked in!
+

+ Marked as attended for {event.name}. Re-submitting is safe — verify is idempotent. +

+
+ )} + + {!succeeded && state !== "open" && } + + {!succeeded && state === "open" && ( +
+
+ +

+ Scan the organizer's QR, or type the 8-character code they're showing. +

+
+ + {(field) => ( + + )} + + {isError && ( +
+ Code not recognized — check and try again. +
+ )} + + {(formState) => ( + + )} + +
+ )} + + +
+
+ ); +} diff --git a/src/components/app/dashboard-events.tsx b/src/components/app/dashboard-events.tsx new file mode 100644 index 00000000..58f0726f --- /dev/null +++ b/src/components/app/dashboard-events.tsx @@ -0,0 +1,858 @@ +import { + type LucideIcon, + Calendar, + Check, + Clock, + Info, + LayoutGrid, + List, + MapPin, + QrCode, + ScanLine, + Search, + Ticket, + User, + X, +} from "lucide-react"; +import { + type ChangeEvent, + type ComponentProps, + type MouseEvent, + type ReactNode, + useCallback, +} from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + type DashboardEvent, + type EventType, + type EventView, + type FullEvent, + EVENT_TYPE_CLASSES, + EVENT_TYPE_META, + EVENT_TYPES, + determineCheckIn, + fmtClock, + fmtDay, + isPastEvent, + monthDay, +} from "@/lib/dashboard-events"; +import { cn } from "@/lib/utils"; + +/// Types & interfaces + +export interface EventCallbacks { + onOpen?: (event: DashboardEvent) => void; + onRsvp?: (event: DashboardEvent, planned: boolean) => void; + onCheckin?: (event: DashboardEvent) => void; +} + +interface StateMeta { + label: string; + icon: LucideIcon; + className: string; +} + +/// Constants + +const STATUS_GREEN = "text-[#15a66e] bg-[#15a66e]/15 dark:text-[#3fd68c] dark:bg-[#3fd68c]/15"; +const STATUS_RED = "text-[#e13737] bg-[#e13737]/12 dark:text-[#ff6b6b] dark:bg-[#ff6b6b]/15"; +const STATUS_MUTED = "text-muted-foreground bg-muted"; +const STATUS_TEAL = "text-[#078c79] bg-brand-teal/15 dark:text-[#2fead0] dark:bg-brand-teal/15"; + +const STATE_ATTENDED: StateMeta = { label: "Attended", icon: Check, className: STATUS_GREEN }; +const STATE_MISSED: StateMeta = { label: "Missed", icon: X, className: STATUS_RED }; +const STATE_ENDED: StateMeta = { label: "Ended", icon: Clock, className: STATUS_MUTED }; +const STATE_GOING: StateMeta = { label: "Going", icon: Check, className: STATUS_GREEN }; +const STATE_OPEN: StateMeta = { label: "Open", icon: Ticket, className: STATUS_TEAL }; + +export const PILL_CLASS = + "h-auto gap-1.5 rounded-full px-2.5 py-1 text-[11.5px] font-bold [&>svg]:size-3.5!"; + +const VIEW_META: Record = { + calendar: { icon: Calendar, label: "Calendar" }, + grid: { icon: LayoutGrid, label: "Gallery" }, + list: { icon: List, label: "List" }, +}; + +const LONG_DATE_OPTIONS: Intl.DateTimeFormatOptions = { + weekday: "long", + month: "long", + day: "numeric", +}; + +/// Helpers + +function eventStateMeta(event: DashboardEvent, now: Date): StateMeta { + if (event.attended) return STATE_ATTENDED; + if (isPastEvent(event, now)) return event.planned ? STATE_MISSED : STATE_ENDED; + return event.planned ? STATE_GOING : STATE_OPEN; +} + +/// EventTypeChip + +interface EventTypeChipProps { + type: EventType; +} + +function EventTypeChip({ type }: Readonly) { + return ( + + + {EVENT_TYPE_META[type].label} + + ); +} + +/// TagPill + +interface TagPillProps { + children: ReactNode; +} + +function TagPill({ children }: Readonly) { + return ( + + {children} + + ); +} + +/// StatusBadge + +interface StatusBadgeProps { + event: DashboardEvent; + now: Date; +} + +function StatusBadge({ event, now }: Readonly) { + const meta = eventStateMeta(event, now); + const Icon = meta.icon; + return ( + + + {meta.label} + + ); +} + +/// EmptyState + +interface EmptyStateProps { + icon: LucideIcon; + title: string; + sub: string; + className?: string; +} + +export function EmptyState({ icon: Icon, title, sub, className }: Readonly) { + return ( +
+
+ +
+
{title}
+
{sub}
+
+ ); +} + +/// EventMeta + +interface EventMetaProps { + icon: LucideIcon; + children: ReactNode; +} + +function EventMeta({ icon: Icon, children }: Readonly) { + return ( + + + {children} + + ); +} + +/// DateBlock + +function DateBlock({ event }: Readonly<{ event: FullEvent }>) { + const { mon, day } = monthDay(event.start_at, event.timezone); + return ( +
+
+ {mon} +
+
+ {day} +
+
+ ); +} + +/// EventThumbnail + +interface EventThumbnailProps { + event: FullEvent; + className?: string; +} + +// At some point the svg will be replaced with the proper image stuff +function EventThumbnail({ event, className }: Readonly) { + const meta = EVENT_TYPE_META[event.type]; + const gradId = `evg-${event.id}`; + const dotId = `evd-${event.id}`; + return event.thumbnail ? ( +
+ +
+ ) : ( +
+ + + + + + + + + + + + + + + + {meta.label.toUpperCase()} + + +
+ + {meta.label} + +
+
+ ); +} + +/// EventActions + +interface EventActionsProps extends EventCallbacks { + event: DashboardEvent; + now: Date; + size?: ComponentProps["size"]; +} + +function EventActions({ event, now, onRsvp, onCheckin, size = "sm" }: Readonly) { + const state = determineCheckIn(event, now); + const past = isPastEvent(event, now); + + const handleCheckin = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + onCheckin?.(event); + }, + [onCheckin, event], + ); + const handleRsvp = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + onRsvp?.(event, true); + }, + [onRsvp, event], + ); + + return ( +
+ {state === "open" && !event.attended && ( + + )} + {!past && !event.attended && !event.planned && ( + + )} +
+ ); +} + +/// CancelRsvpButton + +interface CancelRsvpButtonProps { + event: DashboardEvent; + now: Date; + onRsvp?: (event: DashboardEvent, planned: boolean) => void; +} + +// Corner cancel control for the grid card thumbnail. Rows use GoingCancelPill. +function CancelRsvpButton({ event, now, onRsvp }: Readonly) { + const handleCancel = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + onRsvp?.(event, false); + }, + [onRsvp, event], + ); + + return isPastEvent(event, now) || event.attended || !event.planned ? undefined : ( + + ); +} + +/// GoingCancelPill + +function GoingCancelPill({ + event, + onRsvp, +}: Readonly<{ + event: DashboardEvent; + onRsvp?: (event: DashboardEvent, planned: boolean) => void; +}>) { + const handleCancel = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + onRsvp?.(event, false); + }, + [onRsvp, event], + ); + + return ( + + ); +} + +/// Clickable + +interface ClickableProps { + event: DashboardEvent; + onOpen?: (event: DashboardEvent) => void; + className: string; + children: ReactNode; +} + +function Clickable({ event, onOpen, className, children }: Readonly) { + const handleOpen = useCallback(() => { + onOpen?.(event); + }, [onOpen, event]); + + return ( +
+
+ ); +} + +/// EventCard & EventRow + +interface EventItemProps extends EventCallbacks { + event: DashboardEvent; + now: Date; +} + +export function EventCard({ event, now, onOpen, onRsvp, onCheckin }: Readonly) { + return ( + +
+ +
+ +
+
+ +
+
+
+ + {event.tags?.slice(0, 2).map((tag) => ( + {tag} + ))} +
+

{event.name}

+
+

{event.description}

+
+ + {fmtClock(event.start_at, event.timezone)}–{fmtClock(event.end_at, event.timezone)} + + {event.location} +
+
+ + +
+
+
+ ); +} + +function EventRow({ event, now, onOpen, onRsvp, onCheckin }: Readonly) { + const past = isPastEvent(event, now); + const showStatus = past || event.attended; + const showGoing = + !past && !event.attended && event.planned && determineCheckIn(event, now) !== "open"; + + return ( + + +
+
+ +

{event.name}

+
+
+ {fmtDay(event.start_at, event.timezone)} + {fmtClock(event.start_at, event.timezone)} + {event.location} +
+
+
+ + {showStatus && } + {showGoing && } +
+
+ ); +} + +/// StatTile + +interface StatTileProps { + icon: LucideIcon; + value: number | string; + label: string; + accentClassName?: string; +} + +export function StatTile({ icon: Icon, value, label, accentClassName }: Readonly) { + return ( +
+
+ +
+
+ {value} +
+
{label}
+
+ ); +} + +/// EventToolbar + +interface EventToolbarProps { + query: string; + onQueryChange: (value: string) => void; + type: "all" | EventType; + onTypeChange: (value: "all" | EventType) => void; + view: EventView; + onViewChange: (value: EventView) => void; + views: EventView[]; + searchPlaceholder?: string; +} + +export function EventToolbar({ + query, + onQueryChange, + type, + onTypeChange, + view, + onViewChange, + views, + searchPlaceholder = "Search events by name, location, or tag…", +}: Readonly) { + const handleQueryChange = useCallback( + (e: ChangeEvent) => { + onQueryChange(e.target.value); + }, + [onQueryChange], + ); + const handleTypeChange = useCallback( + (value: "all" | EventType | null) => { + if (value) onTypeChange(value); + }, + [onTypeChange], + ); + + return ( +
+
+
+ + +
+ + + + {views.map((key) => { + const ViewIcon = VIEW_META[key].icon; + return ( + + + {VIEW_META[key].label} + + ); + })} + + +
+
+ ); +} + +/// EventResults + +interface EventResultsProps { + events: DashboardEvent[]; + view: EventView; + now: Date; + onOpen: (event: DashboardEvent) => void; + onRsvp: (event: DashboardEvent, planned: boolean) => void; + onCheckin: (event: DashboardEvent) => void; + emptyTitle?: string; + emptySub?: string; +} + +export function EventResults({ + events, + view, + now, + onOpen, + onRsvp, + onCheckin, + emptyTitle = "No upcoming events match", + emptySub = "Try a different type or clear the search.", +}: Readonly) { + if (events.length === 0) { + return ( + + ); + } + + if (view === "grid") { + return ( +
+ {events.map((event) => ( + + ))} +
+ ); + } + + return ( +
+
+ {events.map((event) => ( + + ))} +
+
+ ); +} + +/// DetailTile + +interface DetailTileProps { + icon: LucideIcon; + label: string; + children: ReactNode; +} + +function DetailTile({ icon: Icon, label, children }: Readonly) { + return ( +
+ +
+
+ {label} +
+
{children}
+
+
+ ); +} + +/// EventDetailDialog + +interface EventDetailDialogProps { + event: DashboardEvent; + now: Date; + open: boolean; + onOpenChange: (open: boolean) => void; + canManage: boolean; + organizerName?: string; + onRsvp: (event: DashboardEvent, planned: boolean) => void; + onCheckin: (event: DashboardEvent) => void; + onManageAttendance?: (event: DashboardEvent) => void; +} + +export function EventDetailDialog({ + event, + now, + open, + onOpenChange, + canManage, + organizerName, + onRsvp, + onCheckin, + onManageAttendance, +}: Readonly) { + const handleManage = useCallback(() => { + onManageAttendance?.(event); + }, [onManageAttendance, event]); + const handleCheckin = useCallback(() => { + onCheckin(event); + }, [onCheckin, event]); + const handleRsvpYes = useCallback(() => { + onRsvp(event, true); + onOpenChange(false); + }, [onRsvp, event, onOpenChange]); + const handleRsvpNo = useCallback(() => { + onRsvp(event, false); + onOpenChange(false); + }, [onRsvp, event, onOpenChange]); + + const meta = EVENT_TYPE_META[event.type]; + const state = determineCheckIn(event, now); + const past = isPastEvent(event, now); + const organizer = event.creator_id ? (organizerName ?? "…") : "Unknown organizer"; + + return ( + + + + {event.name} + + {meta.label} · organized by {organizer} + + + +
+ +
+ +
+ + + {event.tags?.map((tag) => ( + {tag} + ))} +
+ +
+ + {fmtDay(event.start_at, event.timezone, LONG_DATE_OPTIONS)} + + + {fmtClock(event.start_at, event.timezone)} - {fmtClock(event.end_at, event.timezone)} + + + {event.location} + + + {organizer} + +
+ + {!event.creator_id && ( +
+ + + This event's creator was removed — organizer is unknown. + +
+ )} + + + +

{event.description}

+ + + {!past && !event.attended && event.planned ? ( + + ) : ( + + )} +
+ {canManage && ( + + )} + {state === "open" && !event.attended && ( + + )} + {!past && !event.attended && !event.planned && ( + + )} +
+
+
+
+ ); +} diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 00000000..b20959dd --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,52 @@ +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + render, + ...props +}: useRender.ComponentProps<"span"> & VariantProps) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + className: cn(badgeVariants({ variant }), className), + }, + props + ), + render, + state: { + slot: "badge", + variant, + }, + }) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 5dea05e8..60bb92b7 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 hover:not-aria-[haspopup]:-translate-y-px active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { diff --git a/src/components/ui/carousel.tsx b/src/components/ui/carousel.tsx index cf22cd28..6eeca44d 100644 --- a/src/components/ui/carousel.tsx +++ b/src/components/ui/carousel.tsx @@ -160,7 +160,11 @@ function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { const { carouselRef, orientation } = useCarouselApi() return ( -
+
+} + +function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) { + return +} + +function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { + return +} + +function DropdownMenuContent({ + align = "start", + alignOffset = 0, + side = "bottom", + sideOffset = 4, + className, + ...props +}: MenuPrimitive.Popup.Props & + Pick< + MenuPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + + + ) +} + +function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) { + return +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: MenuPrimitive.GroupLabel.Props & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: MenuPrimitive.Item.Props & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: MenuPrimitive.SubmenuTrigger.Props & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + align = "start", + alignOffset = -3, + side = "right", + sideOffset = 0, + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: MenuPrimitive.CheckboxItem.Props & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: MenuPrimitive.RadioItem.Props & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: MenuPrimitive.Separator.Props) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 00000000..4f65961b --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,23 @@ +import { Separator as SeparatorPrimitive } from "@base-ui/react/separator" + +import { cn } from "@/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + ...props +}: SeparatorPrimitive.Props) { + return ( + + ) +} + +export { Separator } diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx new file mode 100644 index 00000000..42375b91 --- /dev/null +++ b/src/components/ui/sidebar.tsx @@ -0,0 +1,723 @@ +"use client" + +import * as React from "react" +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" + +import { useIsMobile } from "@/hooks/use-mobile" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { PanelLeftIcon } from "lucide-react" + +const SIDEBAR_COOKIE_NAME = "sidebar_state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +type SidebarContextProps = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<"div"> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void +}) { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed" + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + +
+ {children} +
+
+ ) +} + +function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + dir, + ...props +}: React.ComponentProps<"div"> & { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offcanvas" | "icon" | "none" +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === "none") { + return ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) +} + +function SidebarTrigger({ + className, + onClick, + ...props +}: React.ComponentProps) { + const { toggleSidebar } = useSidebar() + + return ( + + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar() + + return ( + + +
+
+ +
+
+ ); +} + +function getRosterStatus(member: AttendanceMember): RosterStatus { + if (member.attended) return member.planned ? "checked_in" : "walk_in"; + return member.planned ? "expected" : "no_show"; +} + +function RosterRow({ + disabled, + member, + onUndo, +}: Readonly<{ + disabled: boolean; + member: AttendanceMember; + onUndo: (memberId: string) => void; +}>) { + const handleUndo = useCallback(() => { + onUndo(member.id); + }, [member.id, onUndo]); + + const status = ROSTER_STATUS[getRosterStatus(member)]; + const initials = + member.name + .match(/\S+/g) + ?.slice(0, 2) + .map((part) => part[0]) + .join("") + .toUpperCase() ?? "··"; + + const StatusIcon = status.icon; + + return ( +
+
+ {initials} +
+
+
{member.name}
+
+ + + {status.label} + + {member.attended && ( + + } + title="Member actions" + aria-label="Member actions" + > + + + + + + Undo check-in + + + + )} +
+ ); +} + +function DashboardEvents() { + const queryClient = useQueryClient(); + + const [now] = useState(() => new Date()); + const [query, setQuery] = useState(""); + const [type, setType] = useState<"all" | EventType>("all"); + const [view, setView] = useState("calendar"); + const [detail, setDetail] = useState(); + const [checkin, setCheckin] = useState(); + const [roster, setRoster] = useState(); + const [rosterTab, setRosterTab] = useState<"qr" | "roster">("qr"); + const [copied, setCopied] = useState(false); + + const { data: events } = useQuery(eventsListQueryOptions); + const { data: me } = useQuery(meQueryOptions); + const { data: plannedEvents } = useQuery(plannedEventsQueryOptions); + const { data: attendedEvents } = useQuery(attendedEventsQueryOptions); + const creatorQuery = useQuery({ + ...memberQueryOptions(detail?.creator_id ?? ""), + enabled: !!detail?.creator_id, + }); + const codeQuery = useQuery({ + ...eventAttendanceCodeQueryOptions(roster?.id ?? ""), + enabled: !!roster && rosterTab === "qr", + }); + const rosterQuery = useQuery({ + ...eventAttendanceQueryOptions(roster?.id ?? ""), + enabled: !!roster && rosterTab === "roster", + }); + + const { mutate: joinMutate } = useMutation({ + mutationFn: async (eventId: string) => { + await axios.post<{ message: string }>(`${API_BASE_URL}/events/${eventId}/join`); + }, + onSuccess: () => { + toast.success("You're on the list! We'll see you there."); + return queryClient.invalidateQueries({ queryKey: plannedEventsQueryOptions.queryKey }); + }, + onError: () => toast.error("Couldn't RSVP. Please try again."), + }); + const { mutate: removeMutate, isPending: isRemoving } = useMutation({ + mutationFn: async (memberId: string) => { + await axios.delete(`${API_BASE_URL}/events/${roster?.id ?? ""}/attendance/${memberId}`); + }, + onSuccess: () => { + toast.success("Check-in undone."); + return queryClient.invalidateQueries({ + queryKey: eventAttendanceQueryOptions(roster?.id ?? "").queryKey, + }); + }, + }); + + const handleRsvp = useCallback( + (event: DashboardEvent, planned: boolean) => { + if (planned) { + joinMutate(event.id); + return; + } + queryClient.setQueryData(plannedEventsQueryOptions.queryKey, (old) => + (old ?? []).filter((item) => item.id !== event.id), + ); + toast.success("RSVP cancelled."); + }, + [joinMutate, queryClient], + ); + const invalidateAttended = useCallback( + () => queryClient.invalidateQueries({ queryKey: attendedEventsQueryOptions.queryKey }), + [queryClient], + ); + + const openCheckinFromDetail = useCallback((event: DashboardEvent) => { + setDetail(undefined); + setCheckin(event); + }, []); + const openRosterFromDetail = useCallback((event: DashboardEvent) => { + setDetail(undefined); + setRosterTab("qr"); + setCopied(false); + setRoster(event); + }, []); + const closeDetail = useCallback((open: boolean) => { + if (!open) setDetail(undefined); + }, []); + const closeCheckin = useCallback((open: boolean) => { + if (!open) setCheckin(undefined); + }, []); + const closeRoster = useCallback((open: boolean) => { + if (!open) setRoster(undefined); + }, []); + + const code = (codeQuery.data?.code ?? "").slice(0, 8); + const copyCode = useCallback(() => { + void navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => { + setCopied(false); + }, 1800); + }, [code]); + + const dashboardEvents = useMemo( + () => + (events ?? []).map((event) => + mergeDashboardEvent( + event, + plannedEvents?.has(event.id) ?? false, + attendedEvents?.has(event.id) ?? false, + ), + ), + [events, plannedEvents, attendedEvents], + ); + const upcoming = useMemo( + () => filterEvents(dashboardEvents, type, query).filter((event) => !isPastEvent(event, now)), + [dashboardEvents, type, query, now], + ); + const checkInOpen = useMemo( + () => + dashboardEvents + .filter((event) => determineCheckIn(event, now) === "open" && !event.attended) + .toSorted((a, b) => a.start_at.localeCompare(b.start_at)), + [dashboardEvents, now], + ); + + const nextCheckIn = checkInOpen.at(0); + const handleStripCheckin = useCallback(() => { + if (nextCheckIn) setCheckin(nextCheckIn); + }, [nextCheckIn]); + const handleStripMore = useCallback(() => { + const second = checkInOpen.at(1); + if (second) setDetail(second); + }, [checkInOpen]); + + const organizerName = detail?.creator_id ? creatorQuery.data?.name : undefined; + const canManageDetail = + !!detail && + !!me && + (me.roles.includes("admin") || me.roles.includes("leads") || detail.creator_id === me.id); + + const rosterMembers = rosterQuery.data?.data ?? []; + const plannedCount = rosterMembers.filter((member) => member.planned).length; + const attendedCount = rosterMembers.filter((member) => member.attended).length; + const checkIn = roster ? determineCheckIn(roster, now) : "ended"; + const windowLabel = CHECK_IN_WINDOW_LABEL[checkIn]; + + const WindowIcon = checkIn === "open" ? LockOpen : Lock; + + return ( +
+ {nextCheckIn && ( +
+ +
+
+ + + + + + Check-in open + +
+
+
+ +

{nextCheckIn.name}

+
+
+ + + {nextCheckIn.location} + + + + Closes at {fmtClock(nextCheckIn.end_at, nextCheckIn.timezone)} + +
+
+
+ {checkInOpen.length > 1 && ( + + )} + +
+
+
+ )} + + + + {view === "calendar" ? ( + + ) : ( + <> +
+

+ Upcoming events +

+ +

+ {String(upcoming.length)} event{upcoming.length === 1 ? "" : "s"} scheduled +

+
+ + + )} + + {detail && ( + + )} + + {checkin && ( + + )} + + {roster && ( + + + + Attendance + {roster.name} + + + + + + Check-in QR + + + Roster · {String(rosterMembers.length)} + + + + + + + {windowLabel} + + +
+ + + QR preview unavailable + +
+ +
+
+ {code ? `${code.slice(0, 4)} · ${code.slice(4)}` : "—— · ——"} +
+
+ Attendees submit these 8 characters +
+
+ +
+ +

+ Project this code at the door. The window opens 1 hour before start and closes + at {fmtClock(roster.end_at, roster.timezone)}. +

+
+
+ + +
+
+
+ {String(plannedCount)} +
+
+ Planned (RSVP'd) +
+
+
+
+ {String(attendedCount)} +
+
Attended
+
+
+
+ {rosterQuery.isPending && ( +

+ Loading roster… +

+ )} + {!rosterQuery.isPending && rosterMembers.length === 0 && ( +

+ No attendees yet. +

+ )} + {rosterMembers.map((member) => ( + + ))} +
+

+ Undo clears a member's attended flag but keeps their RSVP — for correcting a + mistaken check-in. +

+
+
+
+
+ )} +
+ ); +} diff --git a/src/routes/dashboard/events_.past.tsx b/src/routes/dashboard/events_.past.tsx new file mode 100644 index 00000000..17a57935 --- /dev/null +++ b/src/routes/dashboard/events_.past.tsx @@ -0,0 +1,218 @@ +import { + attendedEventsQueryOptions, + eventsListQueryOptions, + plannedEventsQueryOptions, +} from "./index"; + +import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import axios from "axios"; +import { Check, Clock } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import { toast } from "sonner"; + +import { CheckInDialog } from "@/components/app/check-in-dialog"; +import { + EventDetailDialog, + EventResults, + EventToolbar, + StatTile, +} from "@/components/app/dashboard-events"; +import { + type DashboardEvent, + type EventType, + type EventView, + type FullEvent, + filterEvents, + isPastEvent, + mergeDashboardEvent, +} from "@/lib/dashboard-events"; + +export const Route = createFileRoute("/dashboard/events_/past")({ + component: DashboardPastEvents, + staticData: { area: "Member", title: "Past events", sub: "Your attendance history" }, + loader: async ({ context: { queryClient } }) => { + await queryClient.prefetchQuery(eventsListQueryOptions); + await queryClient.prefetchQuery(plannedEventsQueryOptions); + await queryClient.prefetchQuery(attendedEventsQueryOptions); + }, +}); + +/// Constants + +const API_BASE_URL = + (import.meta.env.VITE_API_URL as string | undefined) || "http://localhost:8000"; +const PAST_VIEWS: EventView[] = ["list", "grid"]; + +const SHARED_QUERY_OPTIONS = { + staleTime: 60_000, + retry: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, +} as const; + +/// Tanstack Query options + +const memberQueryOptions = (memberId: string) => + queryOptions({ + queryKey: ["members", memberId], + queryFn: async () => { + const { data } = await axios.get<{ id: string; name: string }>( + `${API_BASE_URL}/members/${memberId}`, + ); + return data; + }, + ...SHARED_QUERY_OPTIONS, + }); + +function DashboardPastEvents() { + const queryClient = useQueryClient(); + + const [now] = useState(() => new Date()); + const [query, setQuery] = useState(""); + const [type, setType] = useState<"all" | EventType>("all"); + const [view, setView] = useState("list"); + const [detail, setDetail] = useState(); + const [checkin, setCheckin] = useState(); + + const { data: events } = useQuery(eventsListQueryOptions); + const { data: plannedEvents } = useQuery(plannedEventsQueryOptions); + const { data: attendedEvents } = useQuery(attendedEventsQueryOptions); + const creatorQuery = useQuery({ + ...memberQueryOptions(detail?.creator_id ?? ""), + enabled: !!detail?.creator_id, + }); + + const { mutate: joinMutate } = useMutation({ + mutationFn: async (eventId: string) => { + await axios.post<{ message: string }>(`${API_BASE_URL}/events/${eventId}/join`); + }, + onSuccess: () => { + toast.success("You're on the list! We'll see you there."); + return queryClient.invalidateQueries({ queryKey: plannedEventsQueryOptions.queryKey }); + }, + onError: () => toast.error("Couldn't RSVP. Please try again."), + }); + + const dashboardEvents = useMemo( + () => + (events ?? []).map((event) => + mergeDashboardEvent( + event, + plannedEvents?.has(event.id) ?? false, + attendedEvents?.has(event.id) ?? false, + ), + ), + [events, plannedEvents, attendedEvents], + ); + const past = useMemo( + () => + filterEvents(dashboardEvents, type, query) + .filter((event) => isPastEvent(event, now)) + .toReversed(), + [dashboardEvents, type, query, now], + ); + + const handleRsvp = useCallback( + (event: DashboardEvent, planned: boolean) => { + if (planned) { + joinMutate(event.id); + return; + } + queryClient.setQueryData(plannedEventsQueryOptions.queryKey, (old) => + (old ?? []).filter((item) => item.id !== event.id), + ); + toast.success("RSVP cancelled."); + }, + [joinMutate, queryClient], + ); + const invalidateAttended = useCallback( + () => queryClient.invalidateQueries({ queryKey: attendedEventsQueryOptions.queryKey }), + [queryClient], + ); + const closeDetail = useCallback((open: boolean) => { + if (!open) setDetail(undefined); + }, []); + const closeCheckin = useCallback((open: boolean) => { + if (!open) setCheckin(undefined); + }, []); + const openCheckinFromDetail = useCallback((event: DashboardEvent) => { + setDetail(undefined); + setCheckin(event); + }, []); + + const attendedCount = past.filter((event) => event.attended).length; + const organizerName = detail?.creator_id ? creatorQuery.data?.name : undefined; + + return ( +
+
+ + +
+ + + +
+

Past events

+ +

+ {String(past.length)} event{past.length === 1 ? "" : "s"} · attendance history +

+
+ + + {detail && ( + + )} + + {checkin && ( + + )} +
+ ); +} diff --git a/src/routes/dashboard/index.tsx b/src/routes/dashboard/index.tsx new file mode 100644 index 00000000..3700ff42 --- /dev/null +++ b/src/routes/dashboard/index.tsx @@ -0,0 +1,478 @@ +import { type ClientMember, ROLE_META, ROLES_BY_RANK } from "./route"; + +import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import axios from "axios"; +import { + ArrowRight, + Calendar, + Check, + Clock, + Folder, + MapPin, + ScanLine, + Shield, + Ticket, +} from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import { toast } from "sonner"; + +import { CheckInDialog } from "@/components/app/check-in-dialog"; +import { EventCard, StatTile } from "@/components/app/dashboard-events"; +import { Button } from "@/components/ui/button"; +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from "@/components/ui/carousel"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + type DashboardEvent, + type FullEvent, + type KanaePage, + EVENT_TYPE_CLASSES, + EVENT_TYPE_META, + determineCheckIn, + fmtClock, + fmtDay, + isPastEvent, + mergeDashboardEvent, + monthDay, +} from "@/lib/dashboard-events"; +import { cn } from "@/lib/utils"; + +export const Route = createFileRoute("/dashboard/")({ + component: DashboardHome, + staticData: { area: "Member", title: "Dashboard", home: true }, + loader: async ({ context: { queryClient } }) => { + await queryClient.prefetchQuery(eventsListQueryOptions); + await queryClient.prefetchQuery(meQueryOptions); + await queryClient.prefetchQuery(plannedEventsQueryOptions); + await queryClient.prefetchQuery(attendedEventsQueryOptions); + }, +}); + +/// Constants + +const API_BASE_URL = + (import.meta.env.VITE_API_URL as string | undefined) ?? "http://localhost:8000"; +const EVENTS_PAGE_SIZE = 50; +const SKELETON_TILES = ["a", "b", "c", "d"]; +const CAROUSEL_OPTS = { align: "start", dragFree: true } as const; +const CAROUSEL_NAV_CLASS = "static size-9 translate-0"; +const HERO_STYLE = { background: "linear-gradient(135deg, #084778 0%, #0a5a6e 60%, #078c79 130%)" }; +const HERO_META_CLASS = + "inline-flex items-center gap-1.5 text-[13.5px] font-semibold text-[#eaf6ff]/90"; +const PILL_CLASS = + "inline-flex items-center justify-center gap-1.75 rounded-full border border-white/25 bg-white/15 px-5.5 py-3 text-sm font-bold"; +const TEAL_BUTTON_CLASS = + "rounded-full bg-brand-teal font-bold text-primary hover:bg-brand-teal/85"; +const RAIL_CLASS = + "rounded-[20px] border border-border bg-card p-4 shadow-[0px_4px_14px_rgba(112,144,176,0.14)] dark:shadow-[0px_4px_14px_rgba(0,0,0,0.4)]"; + +const SHARED_QUERY_OPTIONS = { + staleTime: 60_000, + retry: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, +} as const; + +/// Tanstack Query options + +export const memberEventsQueryOptions = (filter: "attended" | "planned") => + queryOptions({ + queryKey: ["members", "me", "events", filter], + queryFn: async () => { + const { data } = await axios.get(`${API_BASE_URL}/members/me/events`, { + params: { [filter]: true }, + }); + return data; + }, + select: (events: FullEvent[]) => new Set(events.map((event) => event.id)), + ...SHARED_QUERY_OPTIONS, + }); + +export const eventsListQueryOptions = queryOptions({ + queryKey: ["events", "list", {}], + queryFn: async () => { + const fetchPage = (page: number) => + axios.get>(`${API_BASE_URL}/events`, { + params: { page, size: EVENTS_PAGE_SIZE }, + }); + const first = await fetchPage(1); + const events = [...first.data.data]; + const pending = Array.from( + { length: Math.ceil(first.data.total / EVENTS_PAGE_SIZE) - 1 }, + (_, index) => fetchPage(index + 2), + ); + for (const request of pending) { + const page = await request; + events.push(...page.data.data); + } + return events; + }, + ...SHARED_QUERY_OPTIONS, +}); + +export const meQueryOptions = queryOptions({ + queryKey: ["members", "me"], + queryFn: async () => { + const { data } = await axios.get(`${API_BASE_URL}/members/me`); + return data; + }, + ...SHARED_QUERY_OPTIONS, +}); + +export const plannedEventsQueryOptions = memberEventsQueryOptions("planned"); +export const attendedEventsQueryOptions = memberEventsQueryOptions("attended"); + +/// Helper functions + +function heroEyebrowText(live: boolean, planned: boolean) { + if (live) return "Happening now"; + if (planned) return "Your next event"; + return "Next up"; +} + +/// Route + +function DashboardHome() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const { data: events, isPending } = useQuery(eventsListQueryOptions); + const { data: me } = useQuery(meQueryOptions); + const { data: plannedEvents } = useQuery(plannedEventsQueryOptions); + const { data: attendedEvents } = useQuery(attendedEventsQueryOptions); + + const [now] = useState(() => new Date()); + const [checkinEvent, setCheckinEvent] = useState(); + + const { mutate: joinMutate } = useMutation({ + mutationFn: async (eventId: string) => { + await axios.post<{ message: string }>(`${API_BASE_URL}/events/${eventId}/join`); + }, + onSuccess: () => { + toast.success("You're on the list! We'll see you there."); + return queryClient.invalidateQueries({ queryKey: plannedEventsQueryOptions.queryKey }); + }, + onError: () => toast.error("Couldn't RSVP. Please try again."), + }); + + const handleRsvp = useCallback( + (event: DashboardEvent, planned: boolean) => { + if (planned) { + joinMutate(event.id); + return; + } + queryClient.setQueryData(plannedEventsQueryOptions.queryKey, (old) => + (old ?? []).filter((item) => item.id !== event.id), + ); + toast.success("RSVP cancelled."); + }, + [joinMutate, queryClient], + ); + const goToEvents = useCallback(() => { + navigate({ to: "/dashboard/events" }).catch(() => {}); + }, [navigate]); + const closeCheckin = useCallback((open: boolean) => { + if (!open) setCheckinEvent(undefined); + }, []); + const invalidateAttended = useCallback( + () => queryClient.invalidateQueries({ queryKey: attendedEventsQueryOptions.queryKey }), + [queryClient], + ); + + const { hero, heroDate, heroLive, heroEyebrow, myUpcoming, myRsvpsSub, rail, attendedCount } = + useMemo(() => { + const merged: DashboardEvent[] = (events ?? []).map((event) => + mergeDashboardEvent( + event, + plannedEvents?.has(event.id) ?? false, + attendedEvents?.has(event.id) ?? false, + ), + ); + const upcoming = merged + .toSorted((a, b) => a.start_at.localeCompare(b.start_at)) + .filter((event) => !isPastEvent(event, now)); + const live = upcoming.find( + (event) => determineCheckIn(event, now) === "open" && !event.attended, + ); + const heroEvent = live ?? upcoming.find((event) => event.planned) ?? upcoming.at(0); + const heroLive = heroEvent ? determineCheckIn(heroEvent, now) === "open" : false; + const planned = upcoming.filter((event) => event.planned); + const plural = planned.length === 1 ? "" : "s"; + return { + hero: heroEvent, + heroDate: heroEvent ? monthDay(heroEvent.start_at, heroEvent.timezone) : undefined, + heroLive, + heroEyebrow: heroEyebrowText(heroLive, heroEvent?.planned ?? false), + myUpcoming: planned, + myRsvpsSub: + planned.length > 0 + ? `${String(planned.length)} event${plural} you're going to` + : "Events you RSVP to land here", + rail: upcoming.filter((event) => event.id !== heroEvent?.id), + attendedCount: merged.filter((event) => event.attended).length, + }; + }, [events, plannedEvents, attendedEvents, now]); + + const handleHeroCheckin = useCallback(() => { + if (hero) setCheckinEvent(hero); + }, [hero]); + const handleHeroRsvp = useCallback(() => { + if (hero) handleRsvp(hero, true); + }, [hero, handleRsvp]); + + const role = ROLES_BY_RANK.find((item) => me?.roles.includes(item)); + + return isPending ? ( +
+ +
+ {SKELETON_TILES.map((id) => ( + + ))} +
+ +
+ ) : ( +
+ {hero ? ( +
+
+
+
+
+ {heroDate?.mon} +
+
+ {heroDate?.day} +
+
+ +
+
+ + {heroEyebrow} + + + + {EVENT_TYPE_META[hero.type].label} + +
+

{hero.name}

+
+ + + {fmtDay(hero.start_at, hero.timezone)} + + + + {fmtClock(hero.start_at, hero.timezone)}-{fmtClock(hero.end_at, hero.timezone)} + + + + {hero.location} + +
+
+ +
+ {heroLive && !hero.attended && ( + + )} + {hero.attended && ( +
+ + Attended +
+ )} + {hero.planned && !heroLive && !hero.attended && ( +
+ + You're going +
+ )} + {!hero.planned && !heroLive && !hero.attended && ( + + )} + + View details + + +
+
+
+ ) : ( +
+ +
No upcoming events
+

New events will appear here soon.

+
+ )} + +
+ + + + +
+ + +
+
+

My RSVPs

+ +

{myRsvpsSub}

+
+ {myUpcoming.length > 1 && ( +
+ + +
+ )} +
+ {myUpcoming.length === 0 ? ( +
+
+ +
+
No RSVPs yet
+
+ RSVP to an event below and it will show up here. +
+
+ ) : ( + + {myUpcoming.map((event) => ( + + + + ))} + + )} +
+ + +
+
+

+ Upcoming events +

+ +

Open across all SIGs

+
+
+
+
+ {rail.length === 0 ? ( +
+
+ +
+
Nothing scheduled
+
+ New events will appear here. +
+
+ ) : ( + + {rail.map((event) => ( + + + + ))} + + )} + +
+
+
+ + {checkinEvent && ( + + )} +
+ ); +} diff --git a/src/routes/dashboard/projects.tsx b/src/routes/dashboard/projects.tsx new file mode 100644 index 00000000..b734dc48 --- /dev/null +++ b/src/routes/dashboard/projects.tsx @@ -0,0 +1,25 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Folder } from "lucide-react"; + +export const Route = createFileRoute("/dashboard/projects")({ + component: ProjectsPage, + staticData: { + area: "Member", + title: "Projects", + sub: "Browse, join, and manage chapter projects", + }, +}); + +function ProjectsPage() { + return ( +
+
+ +
+

Projects are coming soon

+

+ The chapter projects experience for the member portal is still being built. +

+
+ ); +} diff --git a/src/routes/dashboard/route.tsx b/src/routes/dashboard/route.tsx new file mode 100644 index 00000000..92a13431 --- /dev/null +++ b/src/routes/dashboard/route.tsx @@ -0,0 +1,283 @@ +import { queryOptions, useQuery } from "@tanstack/react-query"; +import { createFileRoute, Link, Outlet, useLocation, useMatches } from "@tanstack/react-router"; +import axios from "axios"; +import { + type LucideIcon, + Calendar, + ChevronRight, + Folder, + History, + Home, + Settings, +} from "lucide-react"; +import { type CSSProperties, useCallback, useState } from "react"; + +import { Footer } from "@/components/app/footer"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInset, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, + SidebarTrigger, + useSidebar, +} from "@/components/ui/sidebar"; +import { cn } from "@/lib/utils"; + +export const Route = createFileRoute("/dashboard")({ + component: DashboardLayout, + loader: ({ context: { queryClient } }) => queryClient.prefetchQuery(meQueryOptions), +}); + +/// Types and Interfaces + +export type Role = "admin" | "leads" | "manager" | "root"; + +export interface ClientSession { + aal: "aal1" | "aal2"; + active: boolean; + authenticated_at: string; + issued_at: string; + expires_at: string; +} + +export interface ClientMember { + id: string; + name: string; + email: string; + display_name?: string | null; + created_at: string; + projects: unknown[]; + events: unknown[]; + roles: Role[]; + session: ClientSession; +} + +interface NavItem { + to: "/dashboard" | "/dashboard/events" | "/dashboard/events/past" | "/dashboard/projects"; + label: string; + icon: LucideIcon; + sub?: boolean; +} + +interface DashboardStaticData { + area?: string; + title?: string; + sub?: string; + home?: boolean; +} + +/// Constants + +const EMPTY_STATIC: DashboardStaticData = {}; +const GRID_GLYPH = ( + + + + + + +); +export const ROLE_META: Record = { + root: { label: "Root", rank: 4 }, + admin: { label: "Admin", rank: 3 }, + manager: { label: "Manager", rank: 2 }, + leads: { label: "Leads", rank: 1 }, +}; + +const NAV_ITEMS: NavItem[] = [ + { to: "/dashboard", label: "Dashboard", icon: Home }, + { to: "/dashboard/events", label: "Events", icon: Calendar }, + { to: "/dashboard/events/past", label: "Past events", icon: History, sub: true }, + { to: "/dashboard/projects", label: "Projects", icon: Folder }, +]; + +export const ROLES_BY_RANK: Role[] = ["root", "admin", "manager", "leads"]; + +const API_BASE_URL = + (import.meta.env.VITE_API_URL as string | undefined) || "http://localhost:8000"; + +const SIDEBAR_STYLE = { + "--sidebar-width": "16.5rem", + "--sidebar-width-icon": "3.5rem", +} as CSSProperties; + +/// Tanstack Query options + +const meQueryOptions = queryOptions({ + queryKey: ["members", "me"], + queryFn: async () => { + const { data } = await axios.get(`${API_BASE_URL}/members/me`); + return data; + }, + staleTime: 60_000, + retry: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, +}); + +/// Route-based components + +function SidebarNav({ pathname }: Readonly<{ pathname: string }>) { + const { setOpenMobile } = useSidebar(); + const closeMobile = useCallback(() => { + setOpenMobile(false); + }, [setOpenMobile]); + + return ( + + {NAV_ITEMS.map((item) => ( + + + + {item.label} + + } + /> + + ))} + + ); +} + +/// Route + +function DashboardLayout() { + const { data: me } = useQuery(meQueryOptions); + const { pathname } = useLocation(); + const meta = useMatches({ select: (matches) => matches.at(-1)?.staticData ?? EMPTY_STATIC }); + + const [collapsed, setCollapsed] = useState(false); + + const handleOpenChange = useCallback((open: boolean) => { + setCollapsed(!open); + }, []); + const toggleSidebar = useCallback(() => { + setCollapsed((value) => !value); + }, []); + + const access = ROLES_BY_RANK.find((role) => me?.roles.includes(role)); + const accessLabel = access ? ROLE_META[access].label : "Member"; + const initials = + me?.name + .match(/\S+/g) + ?.slice(0, 2) + .map((part) => part[0]) + .join("") + .toUpperCase() ?? "··"; + + const firstName = me?.name.match(/\S+/)?.[0]; + const welcome = firstName ? `Welcome back, ${firstName}!` : "Welcome back!"; + const heading = meta.home ? welcome : (meta.title ?? "Dashboard"); + const sub = meta.home ? `${accessLabel} · Computer Science & Engineering · UC Merced` : meta.sub; + + return ( + + + +
+ + + Member Portal + +
+
+ + + + + Member + + + + + + + + + + + + + + {initials} + + +
+ + {me?.name ?? "Member"} + + + {accessLabel} + {me?.display_name ? ` · ${me.display_name}` : ""} + +
+ +
+ } + /> + + + + + + +
+
+
+ + + {meta.area ?? "Member"} + + + + {meta.title ?? "Dashboard"} + +
+

+ {heading} +

+ + {sub &&

{sub}

} +
+ + +
+