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:
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { MyService } from './my.service';
|
||||
import { SevaService } from '../seva/seva.service';
|
||||
import { CirclesService } from '../circles/circles.service';
|
||||
@@ -45,6 +45,15 @@ export class MyController {
|
||||
return this.seva.cancel(member.sub, member.tenantId, id);
|
||||
}
|
||||
|
||||
@Get('directory')
|
||||
directory(
|
||||
@CurrentMember() member: MemberJwtPayload,
|
||||
@Query('q') q?: string,
|
||||
@Query('interest') interest?: string,
|
||||
) {
|
||||
return this.service.getDirectory(member.sub, member.tenantId, q, interest);
|
||||
}
|
||||
|
||||
@Get('circles')
|
||||
circlesList(@CurrentMember() member: MemberJwtPayload) {
|
||||
return this.circles.listForMember(member.sub, member.tenantId);
|
||||
|
||||
@@ -126,6 +126,66 @@ export class MyService {
|
||||
}));
|
||||
}
|
||||
|
||||
async getDirectory(userId: string, tenantId: string, q?: string, interest?: string) {
|
||||
const where: any = {
|
||||
tenantId,
|
||||
directoryVisible: true,
|
||||
id: { not: userId },
|
||||
};
|
||||
if (interest) where.interests = { has: interest };
|
||||
if (q && q.trim()) {
|
||||
const term = q.trim();
|
||||
where.OR = [
|
||||
{ displayName: { contains: term, mode: 'insensitive' } },
|
||||
{ hometown: { contains: term, mode: 'insensitive' } },
|
||||
{ currentLocation: { contains: term, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
const [members, allVisible] = await Promise.all([
|
||||
this.prisma.towerUser.findMany({
|
||||
where,
|
||||
take: 100,
|
||||
orderBy: { displayName: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
avatar: true,
|
||||
hometown: true,
|
||||
currentLocation: true,
|
||||
interests: true,
|
||||
},
|
||||
}),
|
||||
// Aggregate interest filter chips across the whole visible directory.
|
||||
this.prisma.towerUser.findMany({
|
||||
where: { tenantId, directoryVisible: true, id: { not: userId } },
|
||||
select: { interests: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const interestCounts = new Map<string, number>();
|
||||
for (const u of allVisible) {
|
||||
for (const i of u.interests) interestCounts.set(i, (interestCounts.get(i) ?? 0) + 1);
|
||||
}
|
||||
const topInterests = [...interestCounts.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 20)
|
||||
.map(([name, count]) => ({ name, count }));
|
||||
|
||||
return {
|
||||
total: members.length,
|
||||
members: members.map((m) => ({
|
||||
id: m.id,
|
||||
displayName: m.displayName ?? 'Member',
|
||||
avatar: m.avatar,
|
||||
hometown: m.hometown,
|
||||
currentLocation: m.currentLocation,
|
||||
interests: m.interests,
|
||||
})),
|
||||
interests: topInterests,
|
||||
};
|
||||
}
|
||||
|
||||
async getDashboard(userId: string, tenantId: string) {
|
||||
const user = await this.prisma.towerUser.findFirst({ where: { id: userId, tenantId } });
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { jsonResponse, memberApiFetch } from '../../../_lib/api';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const qs = url.searchParams.toString();
|
||||
const res = await memberApiFetch(`/my/directory${qs ? `?${qs}` : ''}`);
|
||||
const body = await res.json();
|
||||
return jsonResponse(body, res.status);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user