6d89d8a609
- 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>
95 lines
3.5 KiB
TypeScript
95 lines
3.5 KiB
TypeScript
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>
|
|
);
|
|
}
|