Files
hackerspace.sh/app/(app)/me/me-portal-client.tsx
T
Moheeb Zara 5d153b4d68 In-app notification inbox, consolidate /profile into /me, theme realign
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.
2026-05-19 22:51:44 -07:00

390 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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&rsquo;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">••••&nbsp;{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>
)
}