feat: member portal Sprint 6 — Seva + multi-signal gamification
- 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>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user