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,28 @@
|
||||
import { apiFetch, jsonResponse } from '../../../../_lib/api';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const res = await apiFetch(`/admin/events/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const resBody = await res.json();
|
||||
return jsonResponse(resBody, res.status);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
const { id } = await params;
|
||||
const res = await apiFetch(`/admin/events/${id}`, { method: 'DELETE' });
|
||||
const resBody = await res.json();
|
||||
return jsonResponse(resBody, res.status);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { apiFetch, jsonResponse } from '../../../_lib/api';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const res = await apiFetch('/admin/events');
|
||||
const body = await res.json();
|
||||
return jsonResponse(body, res.status);
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const body = await request.json();
|
||||
const res = await apiFetch('/admin/events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const resBody = await res.json();
|
||||
return jsonResponse(resBody, res.status);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { jsonResponse, memberApiFetch } from '../../../../../_lib/api';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const res = await memberApiFetch(`/my/events/${id}/rsvp`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const resBody = await res.json();
|
||||
return jsonResponse(resBody, res.status);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { jsonResponse, memberApiFetch } from '../../../_lib/api';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const res = await memberApiFetch('/my/events');
|
||||
const body = await res.json();
|
||||
return jsonResponse(body, res.status);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
type RsvpStatus = 'GOING' | 'NOT_GOING' | 'MAYBE';
|
||||
|
||||
const LABELS: Record<RsvpStatus, string> = {
|
||||
GOING: 'Going',
|
||||
MAYBE: 'Maybe',
|
||||
NOT_GOING: 'Not going',
|
||||
};
|
||||
|
||||
const STYLES: Record<RsvpStatus, string> = {
|
||||
GOING: 'bg-green-600 text-white hover:bg-green-700',
|
||||
MAYBE: 'bg-yellow-500 text-white hover:bg-yellow-600',
|
||||
NOT_GOING: 'bg-gray-200 text-gray-700 hover:bg-gray-300',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
eventId: string;
|
||||
initial: RsvpStatus | null;
|
||||
}
|
||||
|
||||
export function RsvpButton({ eventId, initial }: Props) {
|
||||
const [current, setCurrent] = useState<RsvpStatus | null>(initial);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const rsvp = async (status: RsvpStatus) => {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/my/events/${eventId}/rsvp`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (res.ok) setCurrent(status);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 mt-3">
|
||||
{(['GOING', 'MAYBE', 'NOT_GOING'] as RsvpStatus[]).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => rsvp(s)}
|
||||
className={`text-xs rounded-full px-3 py-1.5 font-medium transition-colors disabled:opacity-50 border ${
|
||||
current === s
|
||||
? STYLES[s]
|
||||
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{LABELS[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { redirect } from 'next/navigation';
|
||||
const NAV = [
|
||||
{ href: '/my', label: 'Dashboard' },
|
||||
{ href: '/my/digest', label: 'Digest' },
|
||||
{ href: '/my/events', label: 'Events' },
|
||||
{ href: '/my/groups', label: 'My Groups' },
|
||||
{ href: '/my/settings', label: 'Settings' },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user