Files
maaz519 0ffdc362b5 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>
2026-06-17 16:20:11 +05:30

119 lines
4.7 KiB
TypeScript
Raw Permalink 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.
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>
);
}