From 5a7283266cc7aff49debc97d32bd87f73d59f497 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Mon, 18 May 2026 15:44:06 -0700 Subject: [PATCH] =?UTF-8?q?refactor(me):=20tabbed=20member=20portal=20shel?= =?UTF-8?q?l=20=E2=80=94=20Profile/Membership/Activity=20(P3a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /me restructured into a 3-tab portal (Tabs primitive): Profile (permissions), Membership (dues + certifications), Activity (notifications, classes, reservations, cards, visits). Server page keeps all data fetching; sections moved verbatim into a client portal component. No behavior change; sets the frame for P3b-e. 521 tests. --- app/(app)/me/me-portal-client.tsx | 339 ++++++++++++++++++++++++++++++ app/(app)/me/page.tsx | 337 +++-------------------------- 2 files changed, 366 insertions(+), 310 deletions(-) create mode 100644 app/(app)/me/me-portal-client.tsx diff --git a/app/(app)/me/me-portal-client.tsx b/app/(app)/me/me-portal-client.tsx new file mode 100644 index 0000000..71cccab --- /dev/null +++ b/app/(app)/me/me-portal-client.tsx @@ -0,0 +1,339 @@ +'use client' + +import { Badge } from '@/components/ui/badge' +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' +import { + certificationStatus, + CERT_STATUS_LABEL, + type CertStatus, +} from '@/lib/certifications-logic' +import { SIGNUP_STATUS_LABEL, SESSION_STATUS_LABEL } from '@/lib/classes-logic' +import { RESERVATION_STATUS_LABEL } from '@/lib/equipment-logic' +import { presenceStatus } from '@/lib/presence-logic' +import { DuesCard } from '@/components/billing/dues-card' + +const STATUS_VARIANT: Record = { + active: 'default', + expiring_soon: 'outline', + expired: 'outline', + revoked: 'outline', +} + +export type Grant = { + id: string + granted_at: string + expires_at: string | null + revoked_at: string | null + revoked_reason: string | null + note: string | null + certifications: { name: string; description: string | null; validity_months: number | null } | null +} +export type ClassSignup = { + id: string + status: string + attended: boolean + signed_up_at: string + class_sessions: + | { starts_at: string; ends_at: string | null; location: string | null; status: string; classes: { title: string } | { title: string }[] | null } + | { starts_at: string; ends_at: string | null; location: string | null; status: string; classes: { title: string } | { title: string }[] | null }[] + | null +} +export type Reservation = { + id: string + starts_at: string + ends_at: string + status: string + notes: string | null + equipment: { name: string; location: string | null } | { name: string; location: string | null }[] | null +} +export type MyCard = { id: string; card_type: string; label: string | null; is_active: boolean; last4: string } +export type MyVisit = { id: string; checked_in_at: string; checked_out_at: string | null; is_host: boolean; check_in_note: string | null; check_out_note: string | null } +export type MyNotif = { id: string; type: string; subject: string; status: string; createdAt: string; sentAt: string | null } +export type HeldPermission = { code: string; label: string } +export type Billing = { status: string | null; currentPeriodEnd: string | null; hasCustomer: boolean } | null + +type Props = { + grants: Grant[] + held: HeldPermission[] + billing: Billing + notifs: MyNotif[] + classSignups: ClassSignup[] + reservations: Reservation[] + myCards: MyCard[] + myVisits: MyVisit[] +} + +const sectionH = 'font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-3' + +export function MePortalClient({ + grants, + held, + billing, + notifs, + classSignups, + reservations, + myCards, + myVisits, +}: Props) { + const sessionOf = (cs: ClassSignup) => + Array.isArray(cs.class_sessions) ? cs.class_sessions[0] : cs.class_sessions + const equipOf = (r: Reservation) => (Array.isArray(r.equipment) ? r.equipment[0] : r.equipment) + const titleOf = (cs: ClassSignup) => { + const sess = sessionOf(cs) + const c = sess ? (Array.isArray(sess.classes) ? sess.classes[0] : sess.classes) : null + return c?.title ?? 'Class' + } + + return ( +
+ + + Profile + Membership + Activity + + + +
+

My permissions

+ {held.length === 0 ? ( +

+ You have the standard member access for this space. No extra permissions are granted to your role. +

+ ) : ( +
    + {held.map(p => ( +
  • + {p.label} +
  • + ))} +
+ )} +

+ Permissions are set per role by a space admin. This is a read-only view. +

+
+
+ + +
+

Dues

+ +
+ +
+

Certifications

+ {grants.length === 0 ? ( +

+ You have no certifications yet. An instructor can award one to you. +

+ ) : ( +
    + {grants.map(g => { + const status = certificationStatus(g) + return ( +
  • +
    +
    + + {g.certifications?.name ?? 'Certification'} + + {CERT_STATUS_LABEL[status]} +
    + {g.certifications?.description && ( +

    + {g.certifications.description} +

    + )} +

    + granted {new Date(g.granted_at).toLocaleDateString()} + {g.expires_at + ? ` · expires ${new Date(g.expires_at).toLocaleDateString()}` + : ' · no expiry'} + {g.revoked_at ? ` · revoked ${new Date(g.revoked_at).toLocaleDateString()}` : ''} +

    + {g.revoked_at && g.revoked_reason && ( +

    + Reason: {g.revoked_reason} +

    + )} +
    +
  • + ) + })} +
+ )} +
+
+ + +
+

Notifications

+ {notifs.length === 0 ? ( +

+ No notifications yet. Dues receipts and payment alerts will show up here. +

+ ) : ( +
    + {notifs.map(n => ( +
  • +
    + {n.subject} +

    + {new Date(n.createdAt).toLocaleString()} + {n.sentAt ? ` · sent ${new Date(n.sentAt).toLocaleDateString()}` : ''} +

    +
    + + {n.status === 'sent' ? 'Sent' : n.status === 'failed' ? 'Failed' : 'Queued'} + +
  • + ))} +
+ )} +
+ +
+

My classes

+ {classSignups.length === 0 ? ( +

+ You have not signed up for any classes. Browse what’s on at{' '} + Classes. +

+ ) : ( +
    + {classSignups.map(cs => { + const sess = sessionOf(cs) + return ( +
  • +
    +
    + {titleOf(cs)} + + {SIGNUP_STATUS_LABEL[cs.status] ?? cs.status} + + {cs.attended && Attended} +
    + {sess && ( +

    + {new Date(sess.starts_at).toLocaleString()} + {sess.location ? ` · ${sess.location}` : ''} + {' · '} + {SESSION_STATUS_LABEL[sess.status] ?? sess.status} +

    + )} +
    +
  • + ) + })} +
+ )} +
+ +
+

My equipment reservations

+ {reservations.length === 0 ? ( +

+ You have no reservations. Browse what you can reserve at{' '} + Equipment. +

+ ) : ( +
    + {reservations.map(r => { + const eq = equipOf(r) + return ( +
  • +
    +
    + {eq?.name ?? 'Equipment'} + + {RESERVATION_STATUS_LABEL[r.status] ?? r.status} + +
    +

    + {new Date(r.starts_at).toLocaleString()} – {new Date(r.ends_at).toLocaleString()} + {eq?.location ? ` · ${eq.location}` : ''} +

    + {r.notes &&

    {r.notes}

    } +
    +
  • + ) + })} +
+ )} +
+ +
+

My access cards

+ {myCards.length === 0 ? ( +

+ No access cards are registered to you. A door manager can add one. +

+ ) : ( +
    + {myCards.map(c => ( +
  • +
    + •••• {c.last4} + {c.card_type} + {c.label && {c.label}} +
    + + {c.is_active ? 'Active' : 'Inactive'} + +
  • + ))} +
+ )} +

+ Only the last 4 digits are shown. The full card number is never displayed here. +

+
+ +
+

My recent visits

+ {myVisits.length === 0 ? ( +

+ No visits yet. Check in from the dashboard when you are at the space. +

+ ) : ( +
    + {myVisits.map(v => { + const st = presenceStatus(v.checked_in_at, v.checked_out_at) + return ( +
  • +
    +
    + + {new Date(v.checked_in_at).toLocaleString()} + + {v.is_host && Host} + {st === 'present' && here now} + {st === 'stale' && no checkout} +
    + {(v.check_in_note || v.check_out_note) && ( +

    + {v.check_in_note ? `in: ${v.check_in_note}` : ''} + {v.check_in_note && v.check_out_note ? ' · ' : ''} + {v.check_out_note ? `out: ${v.check_out_note}` : ''} +

    + )} +
    + + {v.checked_out_at ? `out ${new Date(v.checked_out_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : 'open'} + +
  • + ) + })} +
+ )} +
+
+
+
+ ) +} diff --git a/app/(app)/me/page.tsx b/app/(app)/me/page.tsx index e93a1c6..a43c953 100644 --- a/app/(app)/me/page.tsx +++ b/app/(app)/me/page.tsx @@ -1,38 +1,20 @@ import { redirect } from 'next/navigation' import { createClient } from '@/lib/supabase/server' import { PageHeader, PageTitle } from '@/components/ui/page-title' -import { Badge } from '@/components/ui/badge' -import { - certificationStatus, - CERT_STATUS_LABEL, - type CertStatus, -} from '@/lib/certifications-logic' import { PERMISSIONS, PERMISSION_CODES } from '@/lib/permissions-catalog' import { getMyClassSignups, getMyReservations, getMyCards, getMyVisits, getMyBilling, getMyNotifications } from '@/lib/actions' -import { SIGNUP_STATUS_LABEL, SESSION_STATUS_LABEL } from '@/lib/classes-logic' -import { RESERVATION_STATUS_LABEL } from '@/lib/equipment-logic' -import { presenceStatus } from '@/lib/presence-logic' -import { DuesCard } from '@/components/billing/dues-card' +import { + MePortalClient, + type Grant, + type ClassSignup, + type Reservation, + type MyCard, + type MyVisit, + type MyNotif, +} from './me-portal-client' export const dynamic = 'force-dynamic' -type Grant = { - id: string - granted_at: string - expires_at: string | null - revoked_at: string | null - revoked_reason: string | null - note: string | null - certifications: { name: string; description: string | null; validity_months: number | null } | null -} - -const STATUS_VARIANT: Record = { - active: 'default', - expiring_soon: 'outline', - expired: 'outline', - revoked: 'outline', -} - export default async function MePage() { const supabase = await createClient() const { @@ -77,312 +59,47 @@ export default async function MePage() { heldCodes = Array.from(new Set((perms ?? []).map(p => p.permission as string))) } } - const held = PERMISSIONS.filter(p => heldCodes.includes(p.code)) + const held = PERMISSIONS.filter(p => heldCodes.includes(p.code)).map(p => ({ + code: p.code, + label: p.label, + })) const signupRes = await getMyClassSignups() - type ClassSignup = { - id: string - status: string - attended: boolean - signed_up_at: string - class_sessions: - | { starts_at: string; ends_at: string | null; location: string | null; status: string; classes: { title: string } | { title: string }[] | null } - | { starts_at: string; ends_at: string | null; location: string | null; status: string; classes: { title: string } | { title: string }[] | null }[] - | null - } const classSignups: ClassSignup[] = 'data' in signupRes ? (signupRes.data as ClassSignup[]) : [] - const sessionOf = (cs: ClassSignup) => - Array.isArray(cs.class_sessions) ? cs.class_sessions[0] : cs.class_sessions const reservationRes = await getMyReservations() - type Reservation = { - id: string - starts_at: string - ends_at: string - status: string - notes: string | null - equipment: { name: string; location: string | null } | { name: string; location: string | null }[] | null - } const reservations: Reservation[] = 'data' in reservationRes ? (reservationRes.data as Reservation[]) : [] - const equipOf = (r: Reservation) => (Array.isArray(r.equipment) ? r.equipment[0] : r.equipment) const cardsRes = await getMyCards() - type MyCard = { id: string; card_type: string; label: string | null; is_active: boolean; last4: string } const myCards: MyCard[] = 'data' in cardsRes ? (cardsRes.data as MyCard[]) : [] + const visitsRes = await getMyVisits() - type MyVisit = { id: string; checked_in_at: string; checked_out_at: string | null; is_host: boolean; check_in_note: string | null; check_out_note: string | null } const myVisits: MyVisit[] = 'data' in visitsRes ? (visitsRes.data as MyVisit[]) : [] + const billingRes = await getMyBilling() const billing = ('data' in billingRes ? billingRes.data : null) as | { status: string | null; currentPeriodEnd: string | null; hasCustomer: boolean } | null + const notifsRes = await getMyNotifications() - type MyNotif = { id: string; type: string; subject: string; status: string; createdAt: string; sentAt: string | null } const notifs: MyNotif[] = 'data' in notifsRes ? (notifsRes.data as MyNotif[]) : [] - const titleOf = (cs: ClassSignup) => { - const sess = sessionOf(cs) - const c = sess ? (Array.isArray(sess.classes) ? sess.classes[0] : sess.classes) : null - return c?.title ?? 'Class' - } return ( <> - My certifications & access + My membership -
-
-

- Dues -

- -
- -
-

- Notifications -

- {notifs.length === 0 ? ( -

- No notifications yet. Dues receipts and payment alerts will show up here. -

- ) : ( -
    - {notifs.map(n => ( -
  • -
    - {n.subject} -

    - {new Date(n.createdAt).toLocaleString()} - {n.sentAt ? ` · sent ${new Date(n.sentAt).toLocaleDateString()}` : ''} -

    -
    - - {n.status === 'sent' ? 'Sent' : n.status === 'failed' ? 'Failed' : 'Queued'} - -
  • - ))} -
- )} -
- -
-

- Certifications -

- {grants.length === 0 ? ( -

- You have no certifications yet. An instructor can award one to you. -

- ) : ( -
    - {grants.map(g => { - const status = certificationStatus(g) - return ( -
  • -
    -
    - - {g.certifications?.name ?? 'Certification'} - - {CERT_STATUS_LABEL[status]} -
    - {g.certifications?.description && ( -

    - {g.certifications.description} -

    - )} -

    - granted {new Date(g.granted_at).toLocaleDateString()} - {g.expires_at - ? ` · expires ${new Date(g.expires_at).toLocaleDateString()}` - : ' · no expiry'} - {g.revoked_at ? ` · revoked ${new Date(g.revoked_at).toLocaleDateString()}` : ''} -

    - {g.revoked_at && g.revoked_reason && ( -

    - Reason: {g.revoked_reason} -

    - )} -
    -
  • - ) - })} -
- )} -
- -
-

- My permissions -

- {held.length === 0 ? ( -

- You have the standard member access for this space. No extra permissions are granted to your role. -

- ) : ( -
    - {held.map(p => ( -
  • - {p.label} -
  • - ))} -
- )} -

- Permissions are set per role by a space admin. This is a read-only view. -

-
- -
-

- My classes -

- {classSignups.length === 0 ? ( -

- You have not signed up for any classes. Browse what’s on at{' '} - Classes. -

- ) : ( -
    - {classSignups.map(cs => { - const sess = sessionOf(cs) - return ( -
  • -
    -
    - {titleOf(cs)} - - {SIGNUP_STATUS_LABEL[cs.status] ?? cs.status} - - {cs.attended && Attended} -
    - {sess && ( -

    - {new Date(sess.starts_at).toLocaleString()} - {sess.location ? ` · ${sess.location}` : ''} - {' · '} - {SESSION_STATUS_LABEL[sess.status] ?? sess.status} -

    - )} -
    -
  • - ) - })} -
- )} -
- -
-

- My equipment reservations -

- {reservations.length === 0 ? ( -

- You have no reservations. Browse what you can reserve at{' '} - Equipment. -

- ) : ( -
    - {reservations.map(r => { - const eq = equipOf(r) - return ( -
  • -
    -
    - {eq?.name ?? 'Equipment'} - - {RESERVATION_STATUS_LABEL[r.status] ?? r.status} - -
    -

    - {new Date(r.starts_at).toLocaleString()} – {new Date(r.ends_at).toLocaleString()} - {eq?.location ? ` · ${eq.location}` : ''} -

    - {r.notes &&

    {r.notes}

    } -
    -
  • - ) - })} -
- )} -
- -
-

- My access cards -

- {myCards.length === 0 ? ( -

- No access cards are registered to you. A door manager can add one. -

- ) : ( -
    - {myCards.map(c => ( -
  • -
    - •••• {c.last4} - {c.card_type} - {c.label && {c.label}} -
    - - {c.is_active ? 'Active' : 'Inactive'} - -
  • - ))} -
- )} -

- Only the last 4 digits are shown. The full card number is never displayed here. -

-
- -
-

- My recent visits -

- {myVisits.length === 0 ? ( -

- No visits yet. Check in from the dashboard when you are at the space. -

- ) : ( -
    - {myVisits.map(v => { - const st = presenceStatus(v.checked_in_at, v.checked_out_at) - return ( -
  • -
    -
    - - {new Date(v.checked_in_at).toLocaleString()} - - {v.is_host && Host} - {st === 'present' && here now} - {st === 'stale' && no checkout} -
    - {(v.check_in_note || v.check_out_note) && ( -

    - {v.check_in_note ? `in: ${v.check_in_note}` : ''} - {v.check_in_note && v.check_out_note ? ' · ' : ''} - {v.check_out_note ? `out: ${v.check_out_note}` : ''} -

    - )} -
    - - {v.checked_out_at ? `out ${new Date(v.checked_out_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : 'open'} - -
  • - ) - })} -
- )} -
-
+ ) }