In-app notification center (Phase 5, migration 051):
- notifications.read_at + partial unread index. /me Notifications is now
an inbox: unread styling, click to expand the message + mark read,
"mark all as read", unread count. markNotificationsRead is a service-
client write scoped to the caller's own member_id. Always-on channel:
email-muted ('skipped') notifications still appear in-app.
Consolidate /profile into /me:
- Delete app/(app)/profile/*; /me is the single member page. Port the
conflict-of-interest privileged-role nudge + last-disclosed date into
/me's profile editor. Remove the "My profile" sidebar + command-palette
entries; rename the /me nav label to "My membership". Repoint the
recruitment empty-state link to /me.
Theme realign:
- Revert the rounded-lg cards introduced in the prior /me visual pass to
rounded (the app norm, matching the former /profile). Route /me section
headers through the SectionTitle primitive.
Regression guard:
- integration/auth-embed.test.ts runs the auth layout's
space_members.select('*, spaces(*)') embed through PostgREST and fails
on PGRST201, so a future junction table cannot silently break auth again.
390 lines
17 KiB
TypeScript
390 lines
17 KiB
TypeScript
'use client'
|
||
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||
import { SectionTitle } from '@/components/ui/page-title'
|
||
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, type DuesMethod } from '@/components/billing/dues-card'
|
||
import { ProfileForm, type ProfileInitial } from './profile-form'
|
||
import { CancelAction } from './cancel-action'
|
||
import { NotificationPrefs } from './notification-prefs'
|
||
import { NotificationsInbox } from './notifications-inbox'
|
||
import type { PrefMap } from '@/lib/notifications-prefs-logic'
|
||
import { cancelMySignup, cancelReservation } from '@/lib/actions'
|
||
|
||
const STATUS_VARIANT: Record<CertStatus, 'default' | 'outline'> = {
|
||
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:
|
||
| { id: string; starts_at: string; ends_at: string | null; location: string | null; status: string; classes: { title: string } | { title: string }[] | null }
|
||
| { id: string; 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; bodyText: string; status: string; createdAt: string; sentAt: string | null; readAt: string | null }
|
||
export type MyPayment = { id: string; amount: number; currency: string; platform: string; description: string | null; status: string; date: string }
|
||
export type HeldPermission = { code: string; label: string }
|
||
export type Billing = { status: string | null; currentPeriodEnd: string | null; hasCustomer: boolean; configured: boolean } | null
|
||
|
||
type Props = {
|
||
profile: ProfileInitial
|
||
grants: Grant[]
|
||
held: HeldPermission[]
|
||
billing: Billing
|
||
duesMethods: DuesMethod[]
|
||
payments: MyPayment[]
|
||
notifs: MyNotif[]
|
||
unreadNotifs: number
|
||
notifPrefs: PrefMap
|
||
classSignups: ClassSignup[]
|
||
reservations: Reservation[]
|
||
myCards: MyCard[]
|
||
myVisits: MyVisit[]
|
||
}
|
||
|
||
export function MePortalClient({
|
||
profile,
|
||
grants,
|
||
held,
|
||
billing,
|
||
duesMethods,
|
||
payments,
|
||
notifs,
|
||
unreadNotifs,
|
||
notifPrefs,
|
||
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 (
|
||
<div className="p-4 md:p-6 max-w-3xl">
|
||
<Tabs defaultValue="profile">
|
||
<TabsList className="mb-6">
|
||
<TabsTrigger value="profile">Profile</TabsTrigger>
|
||
<TabsTrigger value="membership">Membership</TabsTrigger>
|
||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="profile" className="space-y-8">
|
||
<section>
|
||
<SectionTitle className="mb-3">Profile</SectionTitle>
|
||
<ProfileForm initial={profile} />
|
||
</section>
|
||
|
||
<section>
|
||
<SectionTitle className="mb-3">My permissions</SectionTitle>
|
||
{held.length === 0 ? (
|
||
<p className="font-sans text-sm text-muted-foreground">
|
||
You have the standard member access for this space. No extra permissions are granted to your role.
|
||
</p>
|
||
) : (
|
||
<ul className="flex flex-wrap gap-2">
|
||
{held.map(p => (
|
||
<li
|
||
key={p.code}
|
||
className="font-mono text-[11px] border border-border rounded px-2 py-1 text-foreground"
|
||
title={p.code}
|
||
>
|
||
{p.label}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
<p className="font-sans text-xs text-muted-foreground mt-3">
|
||
Permissions are set per role by a space admin. This is a read-only view.
|
||
</p>
|
||
</section>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="membership" className="space-y-8">
|
||
<section>
|
||
<SectionTitle className="mb-3">Dues</SectionTitle>
|
||
<DuesCard billing={billing} methods={duesMethods} />
|
||
</section>
|
||
|
||
<section>
|
||
<SectionTitle className="mb-3">My payments</SectionTitle>
|
||
{payments.length === 0 ? (
|
||
<p className="font-sans text-sm text-muted-foreground">
|
||
No payments recorded for you yet.
|
||
</p>
|
||
) : (
|
||
<ul className="divide-y rounded border border-border">
|
||
{payments.map(p => (
|
||
<li key={p.id} className="p-4 flex items-start justify-between gap-4 flex-wrap">
|
||
<div className="min-w-0">
|
||
<span className="font-sans text-sm font-semibold text-foreground">
|
||
{p.currency} {p.amount.toFixed(2)}
|
||
</span>
|
||
<p className="font-mono text-[10px] text-muted-foreground mt-1">
|
||
{new Date(p.date).toLocaleDateString()} · {p.platform}
|
||
{p.description ? ` · ${p.description}` : ''}
|
||
</p>
|
||
</div>
|
||
<Badge variant={p.status === 'linked' ? 'default' : 'outline'}>{p.status}</Badge>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
|
||
<section>
|
||
<SectionTitle className="mb-3">Certifications</SectionTitle>
|
||
{grants.length === 0 ? (
|
||
<p className="font-sans text-sm text-muted-foreground">
|
||
You have no certifications yet. An instructor can award one to you.
|
||
</p>
|
||
) : (
|
||
<ul className="divide-y rounded border border-border">
|
||
{grants.map(g => {
|
||
const status = certificationStatus(g)
|
||
return (
|
||
<li key={g.id} className="p-4 flex items-start justify-between gap-4 flex-wrap">
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="font-sans text-sm font-semibold text-foreground">
|
||
{g.certifications?.name ?? 'Certification'}
|
||
</span>
|
||
<Badge variant={STATUS_VARIANT[status]}>{CERT_STATUS_LABEL[status]}</Badge>
|
||
</div>
|
||
{g.certifications?.description && (
|
||
<p className="font-sans text-sm text-muted-foreground mt-1">
|
||
{g.certifications.description}
|
||
</p>
|
||
)}
|
||
<p className="font-mono text-[10px] text-muted-foreground mt-1">
|
||
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()}` : ''}
|
||
</p>
|
||
{g.revoked_at && g.revoked_reason && (
|
||
<p className="font-sans text-xs text-muted-foreground mt-0.5">
|
||
Reason: {g.revoked_reason}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="activity" className="space-y-8">
|
||
<section>
|
||
<SectionTitle className="mb-3">Email preferences</SectionTitle>
|
||
<NotificationPrefs initial={notifPrefs} />
|
||
<p className="font-sans text-xs text-muted-foreground mt-3">
|
||
Billing emails (dues receipts, payment alerts) are always sent.
|
||
</p>
|
||
</section>
|
||
|
||
<section>
|
||
<SectionTitle className="mb-3">Notifications</SectionTitle>
|
||
<NotificationsInbox items={notifs} unreadCount={unreadNotifs} />
|
||
</section>
|
||
|
||
<section>
|
||
<SectionTitle className="mb-3">My classes</SectionTitle>
|
||
{classSignups.length === 0 ? (
|
||
<p className="font-sans text-sm text-muted-foreground">
|
||
You have not signed up for any classes. Browse what’s on at{' '}
|
||
<a href="/classes" className="text-primary underline">Classes</a>.
|
||
</p>
|
||
) : (
|
||
<ul className="divide-y rounded border border-border">
|
||
{classSignups.map(cs => {
|
||
const sess = sessionOf(cs)
|
||
return (
|
||
<li key={cs.id} className="p-4 flex items-start justify-between gap-4 flex-wrap">
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="font-sans text-sm font-semibold text-foreground">{titleOf(cs)}</span>
|
||
<Badge variant={cs.status === 'registered' ? 'default' : 'outline'}>
|
||
{SIGNUP_STATUS_LABEL[cs.status] ?? cs.status}
|
||
</Badge>
|
||
{cs.attended && <Badge variant="outline">Attended</Badge>}
|
||
</div>
|
||
{sess && (
|
||
<p className="font-mono text-[10px] text-muted-foreground mt-1">
|
||
{new Date(sess.starts_at).toLocaleString()}
|
||
{sess.location ? ` · ${sess.location}` : ''}
|
||
{' · '}
|
||
{SESSION_STATUS_LABEL[sess.status] ?? sess.status}
|
||
</p>
|
||
)}
|
||
</div>
|
||
{sess && (cs.status === 'registered' || cs.status === 'waitlisted') && (
|
||
<CancelAction
|
||
title="Cancel signup?"
|
||
description={`Cancel your spot in ${titleOf(cs)}? If a seat frees up, the next waitlisted member is promoted.`}
|
||
successMessage="Signup cancelled."
|
||
action={() => cancelMySignup({ sessionId: sess.id })}
|
||
/>
|
||
)}
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
|
||
<section>
|
||
<SectionTitle className="mb-3">My equipment reservations</SectionTitle>
|
||
{reservations.length === 0 ? (
|
||
<p className="font-sans text-sm text-muted-foreground">
|
||
You have no reservations. Browse what you can reserve at{' '}
|
||
<a href="/equipment" className="text-primary underline">Equipment</a>.
|
||
</p>
|
||
) : (
|
||
<ul className="divide-y rounded border border-border">
|
||
{reservations.map(r => {
|
||
const eq = equipOf(r)
|
||
return (
|
||
<li key={r.id} className="p-4 flex items-start justify-between gap-4 flex-wrap">
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="font-sans text-sm font-semibold text-foreground">{eq?.name ?? 'Equipment'}</span>
|
||
<Badge variant={r.status === 'reserved' ? 'default' : 'outline'}>
|
||
{RESERVATION_STATUS_LABEL[r.status] ?? r.status}
|
||
</Badge>
|
||
</div>
|
||
<p className="font-mono text-[10px] text-muted-foreground mt-1">
|
||
{new Date(r.starts_at).toLocaleString()} – {new Date(r.ends_at).toLocaleString()}
|
||
{eq?.location ? ` · ${eq.location}` : ''}
|
||
</p>
|
||
{r.notes && <p className="font-sans text-xs text-muted-foreground mt-0.5">{r.notes}</p>}
|
||
</div>
|
||
{r.status === 'reserved' && (
|
||
<CancelAction
|
||
title="Cancel reservation?"
|
||
description={`Cancel your reservation for ${eq?.name ?? 'this equipment'}? The slot will be freed for others.`}
|
||
successMessage="Reservation cancelled."
|
||
action={() => cancelReservation({ reservationId: r.id })}
|
||
/>
|
||
)}
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
|
||
<section>
|
||
<SectionTitle className="mb-3">My access cards</SectionTitle>
|
||
{myCards.length === 0 ? (
|
||
<p className="font-sans text-sm text-muted-foreground">
|
||
No access cards are registered to you. A door manager can add one.
|
||
</p>
|
||
) : (
|
||
<ul className="divide-y rounded border border-border">
|
||
{myCards.map(c => (
|
||
<li key={c.id} className="p-4 flex items-center justify-between gap-4">
|
||
<div>
|
||
<span className="font-mono text-sm text-foreground">•••• {c.last4}</span>
|
||
<span className="font-mono text-[10px] text-muted-foreground ml-2 uppercase">{c.card_type}</span>
|
||
{c.label && <span className="font-sans text-xs text-muted-foreground ml-2">{c.label}</span>}
|
||
</div>
|
||
<Badge variant={c.is_active ? 'default' : 'outline'}>
|
||
{c.is_active ? 'Active' : 'Inactive'}
|
||
</Badge>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
<p className="font-sans text-xs text-muted-foreground mt-3">
|
||
Only the last 4 digits are shown. The full card number is never displayed here.
|
||
</p>
|
||
</section>
|
||
|
||
<section>
|
||
<SectionTitle className="mb-3">My recent visits</SectionTitle>
|
||
{myVisits.length === 0 ? (
|
||
<p className="font-sans text-sm text-muted-foreground">
|
||
No visits yet. Check in from the dashboard when you are at the space.
|
||
</p>
|
||
) : (
|
||
<ul className="divide-y rounded border border-border">
|
||
{myVisits.map(v => {
|
||
const st = presenceStatus(v.checked_in_at, v.checked_out_at)
|
||
return (
|
||
<li key={v.id} className="p-4 flex items-start justify-between gap-4">
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="font-sans text-sm text-foreground">
|
||
{new Date(v.checked_in_at).toLocaleString()}
|
||
</span>
|
||
{v.is_host && <Badge variant="default">Host</Badge>}
|
||
{st === 'present' && <span className="font-mono text-[10px] text-primary">here now</span>}
|
||
{st === 'stale' && <span className="font-mono text-[10px] text-amber-600">no checkout</span>}
|
||
</div>
|
||
{(v.check_in_note || v.check_out_note) && (
|
||
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">
|
||
{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}` : ''}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<span className="font-mono text-[10px] text-muted-foreground/80 shrink-0">
|
||
{v.checked_out_at ? `out ${new Date(v.checked_out_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : 'open'}
|
||
</span>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
</TabsContent>
|
||
</Tabs>
|
||
</div>
|
||
)
|
||
}
|