feat: member portal Sprint 4 — Events + RSVP
- Event + EventRsvp models + migration (RsvpStatus enum: GOING/MAYBE/NOT_GOING) - EventsModule: admin CRUD (create/update/delete/publish) + RSVP list - GET /my/events + POST /my/events/:id/rsvp in MyController/MyService - Admin BFF routes: GET/POST /api/admin/events, PATCH/DELETE /api/admin/events/[id] - Member BFF routes: GET /api/my/events, POST /api/my/events/[id]/rsvp - /my/events page: upcoming/past split, RSVP button (client component, optimistic) - Register DigestModule, OrgModule, ThreadsModule, EventsModule in AppModule - Add EVENT_CREATED/DELETED/UPDATED and DIGEST_SENT to AuditAction Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
import { getMemberToken, getApiBaseUrl } from '../../_lib/api';
|
||||
import { RsvpButton } from './RsvpButton';
|
||||
import Link from 'next/link';
|
||||
|
||||
type RsvpStatus = 'GOING' | 'NOT_GOING' | 'MAYBE';
|
||||
|
||||
interface EventItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
location: string | null;
|
||||
startsAt: string;
|
||||
endsAt: string | null;
|
||||
rsvpCount: number;
|
||||
myRsvp: RsvpStatus | null;
|
||||
}
|
||||
|
||||
async function fetchEvents(token: string): Promise<EventItem[]> {
|
||||
const res = await fetch(`${getApiBaseUrl()}/my/events`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
}).catch(() => null);
|
||||
if (!res || !res.ok) return [];
|
||||
return (await res.json()) as EventItem[];
|
||||
}
|
||||
|
||||
function formatEventDate(startsAt: string, endsAt: string | null): string {
|
||||
const start = new Date(startsAt);
|
||||
const dateStr = start.toLocaleDateString('en-IN', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' });
|
||||
const timeStr = start.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' });
|
||||
if (!endsAt) return `${dateStr} · ${timeStr}`;
|
||||
const end = new Date(endsAt);
|
||||
const endTime = end.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' });
|
||||
return `${dateStr} · ${timeStr} – ${endTime}`;
|
||||
}
|
||||
|
||||
const RSVP_BADGE: Record<RsvpStatus, { label: string; cls: string }> = {
|
||||
GOING: { label: 'Going', cls: 'bg-green-100 text-green-700' },
|
||||
MAYBE: { label: 'Maybe', cls: 'bg-yellow-100 text-yellow-700' },
|
||||
NOT_GOING: { label: 'Not going', cls: 'bg-gray-100 text-gray-500' },
|
||||
};
|
||||
|
||||
export default async function EventsPage() {
|
||||
const token = await getMemberToken();
|
||||
if (!token) return null;
|
||||
|
||||
const events = await fetchEvents(token);
|
||||
const upcoming = events.filter((e) => new Date(e.startsAt) >= new Date());
|
||||
const past = events.filter((e) => new Date(e.startsAt) < new Date());
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Events</h1>
|
||||
<Link href="/my" className="text-sm text-indigo-600 hover:underline">← Dashboard</Link>
|
||||
</div>
|
||||
|
||||
{events.length === 0 && (
|
||||
<div className="text-center py-16 text-gray-400">
|
||||
<p className="text-sm">No events scheduled.</p>
|
||||
<p className="text-xs mt-1">Your chapter admin will post events here.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{upcoming.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">Upcoming</h2>
|
||||
<div className="space-y-4">
|
||||
{upcoming.map((e) => (
|
||||
<div key={e.id} className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-gray-900">{e.title}</p>
|
||||
<p className="text-xs text-indigo-600 mt-0.5">{formatEventDate(e.startsAt, e.endsAt)}</p>
|
||||
{e.location && (
|
||||
<p className="text-xs text-gray-400 mt-0.5">{e.location}</p>
|
||||
)}
|
||||
</div>
|
||||
{e.myRsvp && (
|
||||
<span className={`shrink-0 text-xs rounded-full px-2.5 py-1 font-medium ${RSVP_BADGE[e.myRsvp].cls}`}>
|
||||
{RSVP_BADGE[e.myRsvp].label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{e.description && (
|
||||
<p className="text-sm text-gray-600 mt-2 leading-relaxed">{e.description}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<span className="text-xs text-gray-400">{e.rsvpCount} {e.rsvpCount === 1 ? 'response' : 'responses'}</span>
|
||||
</div>
|
||||
<RsvpButton eventId={e.id} initial={e.myRsvp} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{past.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">Past</h2>
|
||||
<div className="space-y-3">
|
||||
{past.map((e) => (
|
||||
<div key={e.id} className="bg-white rounded-xl border border-gray-100 p-4 opacity-60">
|
||||
<p className="text-sm font-medium text-gray-700">{e.title}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{formatEventDate(e.startsAt, e.endsAt)}</p>
|
||||
{e.myRsvp && (
|
||||
<span className={`inline-block mt-1 text-xs rounded-full px-2 py-0.5 ${RSVP_BADGE[e.myRsvp].cls}`}>
|
||||
{RSVP_BADGE[e.myRsvp].label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user