feat: member portal Sprint 8 — Member Directory

- GET /my/directory in MyService/MyController with q + interest filters
- Respects directoryVisible; excludes self; never exposes jid/phone
- q searches displayName/hometown/currentLocation (case-insensitive);
  interest filter via array `has`; aggregates top-20 interest filter chips
- BFF route forwards query string
- /my/directory page: server-rendered cards (avatar initials, location, interest tags)
- DirectorySearch client component: debounced search box + toggleable interest chips that drive URL params
- Directory nav item

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 16:55:30 +05:30
parent 57fe9d9bf1
commit 6d89d8a609
6 changed files with 240 additions and 1 deletions
@@ -0,0 +1,64 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState, useEffect } from 'react';
interface Props {
interests: Array<{ name: string; count: number }>;
}
export function DirectorySearch({ interests }: Props) {
const router = useRouter();
const searchParams = useSearchParams();
const activeInterest = searchParams.get('interest');
const [q, setQ] = useState(searchParams.get('q') ?? '');
// Debounced push of the search term into the URL.
useEffect(() => {
const handle = setTimeout(() => {
const params = new URLSearchParams(searchParams.toString());
if (q.trim()) params.set('q', q.trim());
else params.delete('q');
router.replace(`/my/directory${params.toString() ? `?${params.toString()}` : ''}`);
}, 300);
return () => clearTimeout(handle);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q]);
const toggleInterest = (name: string) => {
const params = new URLSearchParams(searchParams.toString());
if (activeInterest === name) params.delete('interest');
else params.set('interest', name);
router.replace(`/my/directory${params.toString() ? `?${params.toString()}` : ''}`);
};
return (
<div className="space-y-3">
<input
type="text"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search by name, hometown, or location…"
className="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
{interests.length > 0 && (
<div className="flex flex-wrap gap-2">
{interests.map((i) => (
<button
key={i.name}
type="button"
onClick={() => toggleInterest(i.name)}
className={`text-xs rounded-full px-3 py-1 transition-colors ${
activeInterest === i.name
? 'bg-indigo-600 text-white'
: 'bg-indigo-50 text-indigo-700 hover:bg-indigo-100'
}`}
>
{i.name} <span className="opacity-60">{i.count}</span>
</button>
))}
</div>
)}
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { getMemberToken, getApiBaseUrl } from '../../_lib/api';
import { DirectorySearch } from './DirectorySearch';
import Link from 'next/link';
interface DirectoryMember {
id: string;
displayName: string;
avatar: string | null;
hometown: string | null;
currentLocation: string | null;
interests: string[];
}
interface DirectoryData {
total: number;
members: DirectoryMember[];
interests: Array<{ name: string; count: number }>;
}
async function fetchDirectory(token: string, q?: string, interest?: string): Promise<DirectoryData | null> {
const params = new URLSearchParams();
if (q) params.set('q', q);
if (interest) params.set('interest', interest);
const res = await fetch(`${getApiBaseUrl()}/my/directory${params.toString() ? `?${params.toString()}` : ''}`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}).catch(() => null);
if (!res || !res.ok) return null;
return (await res.json()) as DirectoryData;
}
function initials(name: string): string {
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p.charAt(0).toUpperCase()).join('') || '?';
}
export default async function DirectoryPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; interest?: string }>;
}) {
const token = await getMemberToken();
if (!token) return null;
const { q, interest } = await searchParams;
const data = await fetchDirectory(token, q, interest);
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">Member directory</h1>
<Link href="/my" className="text-sm text-indigo-600 hover:underline"> Dashboard</Link>
</div>
<DirectorySearch interests={data?.interests ?? []} />
{!data || data.members.length === 0 ? (
<div className="text-center py-16 text-gray-400">
<p className="text-sm">No members found.</p>
{(q || interest) && <p className="text-xs mt-1">Try clearing your search or filters.</p>}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{data.members.map((m) => (
<div key={m.id} className="bg-white rounded-xl border border-gray-200 p-4 flex gap-3">
<div className="w-11 h-11 rounded-full bg-indigo-100 flex items-center justify-center text-sm font-bold text-indigo-600 shrink-0">
{initials(m.displayName)}
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-gray-900 truncate">{m.displayName}</p>
{(m.hometown || m.currentLocation) && (
<p className="text-xs text-gray-400 truncate">
{[m.hometown, m.currentLocation].filter(Boolean).join(' · ')}
</p>
)}
{m.interests.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{m.interests.slice(0, 3).map((i) => (
<span key={i} className="text-[10px] bg-gray-100 text-gray-600 rounded-full px-2 py-0.5">
{i}
</span>
))}
{m.interests.length > 3 && (
<span className="text-[10px] text-gray-400">+{m.interests.length - 3}</span>
)}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
+1
View File
@@ -8,6 +8,7 @@ const NAV = [
{ href: '/my/events', label: 'Events' },
{ href: '/my/seva', label: 'Seva & Points' },
{ href: '/my/circles', label: 'Circles' },
{ href: '/my/directory', label: 'Directory' },
{ href: '/my/groups', label: 'My Groups' },
{ href: '/my/settings', label: 'Settings' },
];