cd1eef0098
- SevaOpportunity + SevaEntry + GamificationEvent models + migration - GamificationService: multi-signal point table (helpful answer +10, seva +20, event +5, FAQ +15, welcome +3, profile +5, business +5), idempotent award via unique(userId,type,refId), lifetime + time-decayed "recent" score, leaderboard - SevaModule: admin CRUD + mark-entry-complete (awards SEVA_COMPLETED points) - Member endpoints in MyController: GET /my/seva, POST /my/seva/:id/signup|cancel - PROFILE_COMPLETED auto-awarded on profile update (name+hometown+interest) - /my/seva page: points hero with per-type breakdown, open opportunities w/ signup, leaderboard (respects directoryVisible), my seva history - Member + admin BFF routes; Seva & Points nav item - Register GamificationModule + SevaModule; add SEVA_* audit actions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
179 lines
6.9 KiB
TypeScript
179 lines
6.9 KiB
TypeScript
import { getMemberToken, getApiBaseUrl } from '../../_lib/api';
|
|
import { SevaSignupButton } from './SevaSignupButton';
|
|
import Link from 'next/link';
|
|
|
|
type EntryStatus = 'SIGNED_UP' | 'COMPLETED' | 'CANCELLED';
|
|
|
|
interface SevaData {
|
|
score: { lifetime: number; recent: number; byType: Record<string, number>; eventCount: number };
|
|
leaderboard: Array<{ rank: number; userId: string; displayName: string; points: number }>;
|
|
opportunities: Array<{
|
|
id: string;
|
|
title: string;
|
|
description: string | null;
|
|
location: string | null;
|
|
slots: number | null;
|
|
startsAt: string | null;
|
|
pointsAward: number;
|
|
signupCount: number;
|
|
myEntryStatus: EntryStatus | null;
|
|
}>;
|
|
myEntries: Array<{
|
|
id: string;
|
|
opportunityTitle: string;
|
|
status: EntryStatus;
|
|
hours: number | null;
|
|
pointsAward: number;
|
|
completedAt: string | null;
|
|
createdAt: string;
|
|
}>;
|
|
}
|
|
|
|
async function fetchSeva(token: string): Promise<SevaData | null> {
|
|
const res = await fetch(`${getApiBaseUrl()}/my/seva`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
cache: 'no-store',
|
|
}).catch(() => null);
|
|
if (!res || !res.ok) return null;
|
|
return (await res.json()) as SevaData;
|
|
}
|
|
|
|
const TYPE_LABELS: Record<string, string> = {
|
|
HELPFUL_ANSWER: 'Helpful answers',
|
|
SEVA_COMPLETED: 'Seva completed',
|
|
EVENT_ATTENDANCE: 'Event attendance',
|
|
FAQ_APPROVED: 'FAQ contributions',
|
|
WELCOME: 'Welcomes',
|
|
PROFILE_COMPLETED: 'Profile',
|
|
BUSINESS_ADDED: 'Business',
|
|
};
|
|
|
|
export default async function SevaPage() {
|
|
const token = await getMemberToken();
|
|
if (!token) return null;
|
|
|
|
const data = await fetchSeva(token);
|
|
if (!data) {
|
|
return (
|
|
<div className="max-w-2xl mx-auto mt-12 text-center text-gray-400 text-sm">
|
|
Could not load seva data.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const { score, leaderboard, opportunities, myEntries } = data;
|
|
|
|
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">Seva & points</h1>
|
|
<Link href="/my" className="text-sm text-indigo-600 hover:underline">← Dashboard</Link>
|
|
</div>
|
|
|
|
{/* Points hero */}
|
|
<div className="bg-gradient-to-br from-indigo-500 to-indigo-700 rounded-xl p-6 text-white">
|
|
<div className="flex items-end justify-between">
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-indigo-200">Your points</p>
|
|
<p className="text-4xl font-bold mt-1">{score.lifetime}</p>
|
|
<p className="text-xs text-indigo-200 mt-1">{score.recent} active · {score.eventCount} contributions</p>
|
|
</div>
|
|
</div>
|
|
{Object.keys(score.byType).length > 0 && (
|
|
<div className="flex flex-wrap gap-2 mt-4">
|
|
{Object.entries(score.byType).map(([type, pts]) => (
|
|
<span key={type} className="text-xs bg-white/15 rounded-full px-2.5 py-1">
|
|
{TYPE_LABELS[type] ?? type}: {pts}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Open opportunities */}
|
|
<section>
|
|
<h2 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">Open opportunities</h2>
|
|
{opportunities.length === 0 ? (
|
|
<p className="text-sm text-gray-400 py-6 text-center">No open seva opportunities right now.</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{opportunities.map((o) => {
|
|
const full = o.slots != null && o.signupCount >= o.slots && o.myEntryStatus == null;
|
|
return (
|
|
<div key={o.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">{o.title}</p>
|
|
<p className="text-xs text-indigo-600 mt-0.5">+{o.pointsAward} points</p>
|
|
{(o.location || o.startsAt) && (
|
|
<p className="text-xs text-gray-400 mt-0.5">
|
|
{[o.location, o.startsAt ? new Date(o.startsAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : null]
|
|
.filter(Boolean)
|
|
.join(' · ')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<SevaSignupButton opportunityId={o.id} initialStatus={o.myEntryStatus} full={full} />
|
|
</div>
|
|
{o.description && <p className="text-sm text-gray-600 mt-2 leading-relaxed">{o.description}</p>}
|
|
<p className="text-xs text-gray-400 mt-2">
|
|
{o.signupCount} signed up{o.slots != null ? ` · ${o.slots} slots` : ''}
|
|
</p>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Leaderboard */}
|
|
{leaderboard.length > 0 && (
|
|
<section>
|
|
<h2 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">Top contributors</h2>
|
|
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
|
|
{leaderboard.map((l) => (
|
|
<div key={l.userId} className="flex items-center justify-between px-5 py-3">
|
|
<div className="flex items-center gap-3">
|
|
<span className={`text-sm font-bold w-6 ${l.rank <= 3 ? 'text-indigo-600' : 'text-gray-400'}`}>
|
|
#{l.rank}
|
|
</span>
|
|
<span className="text-sm text-gray-800">{l.displayName}</span>
|
|
</div>
|
|
<span className="text-sm font-medium text-gray-500">{l.points} pts</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* My seva history */}
|
|
{myEntries.length > 0 && (
|
|
<section>
|
|
<h2 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">My seva</h2>
|
|
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
|
|
{myEntries.map((e) => (
|
|
<div key={e.id} className="flex items-center justify-between px-5 py-3">
|
|
<div>
|
|
<p className="text-sm text-gray-800">{e.opportunityTitle}</p>
|
|
<p className="text-xs text-gray-400">
|
|
{e.status === 'COMPLETED' && e.completedAt
|
|
? `Completed ${new Date(e.completedAt).toLocaleDateString()}`
|
|
: e.status === 'SIGNED_UP'
|
|
? 'Signed up'
|
|
: 'Cancelled'}
|
|
</p>
|
|
</div>
|
|
{e.status === 'COMPLETED' && (
|
|
<span className="text-xs bg-green-100 text-green-700 rounded-full px-2.5 py-1 font-medium">
|
|
+{e.pointsAward}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|