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:
2026-06-17 16:44:43 +05:30
parent a0dc94ce79
commit cd1eef0098
23 changed files with 978 additions and 2 deletions
+1
View File
@@ -6,6 +6,7 @@ const NAV = [
{ href: '/my', label: 'Dashboard' },
{ href: '/my/digest', label: 'Digest' },
{ href: '/my/events', label: 'Events' },
{ href: '/my/seva', label: 'Seva & Points' },
{ href: '/my/groups', label: 'My Groups' },
{ href: '/my/settings', label: 'Settings' },
];
+59
View File
@@ -0,0 +1,59 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
type EntryStatus = 'SIGNED_UP' | 'COMPLETED' | 'CANCELLED' | null;
interface Props {
opportunityId: string;
initialStatus: EntryStatus;
full: boolean;
}
export function SevaSignupButton({ opportunityId, initialStatus, full }: Props) {
const router = useRouter();
const [status, setStatus] = useState<EntryStatus>(initialStatus);
const [loading, setLoading] = useState(false);
const act = async (action: 'signup' | 'cancel') => {
setLoading(true);
try {
const res = await fetch(`/api/my/seva/${opportunityId}/${action}`, { method: 'POST' });
if (res.ok) {
setStatus(action === 'signup' ? 'SIGNED_UP' : 'CANCELLED');
router.refresh();
}
} finally {
setLoading(false);
}
};
if (status === 'COMPLETED') {
return <span className="text-xs bg-green-100 text-green-700 rounded-full px-3 py-1.5 font-medium"> Completed</span>;
}
if (status === 'SIGNED_UP') {
return (
<button
type="button"
disabled={loading}
onClick={() => act('cancel')}
className="text-xs rounded-full px-3 py-1.5 font-medium border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50"
>
{loading ? '…' : 'Signed up · Cancel'}
</button>
);
}
return (
<button
type="button"
disabled={loading || full}
onClick={() => act('signup')}
className="text-xs rounded-full px-4 py-1.5 font-medium bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{full ? 'Full' : loading ? '…' : 'Sign up'}
</button>
);
}
+178
View File
@@ -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 &amp; 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>
);
}