feat: isolate all four portals with route groups + polished design system
Fix blank /org/login (and /admin/login): the guarded portal layout was wrapping its own login route, rendering null when unauthenticated. Move each portal's authed pages into a (authed)/(chapter) route group so login pages live outside the guard. Structure: - app/(chapter)/* — chapter admin pages + guarded layout (was root-level) - app/org/(authed)/* — org pages + guarded layout; /org/login now free - app/admin/(authed)/* — admin pages + guarded layout; /admin/login free - Root layout slimmed to providers only (no shared sidebar) - Convert moved files' relative imports to @/app alias Design system: - PortalShell: shared sidebar shell (brand mark, themed active nav with accent bar, avatar dropdown user menu, loading skeletons) - portal-theme.ts: per-portal theme tokens (violet/slate/blue/emerald) - PortalLoginShell redesigned: two-column with gradient branding panel, dotted pattern, value-prop highlights, built-in back link - New shadcn components: Avatar, DropdownMenu, Skeleton - Move shared DraftCard to _components/draft-card.tsx (used by 2 portals) - Portal selector: remove Super Admin card, icon tiles, hover lift - All 4 login pages use react-hook-form + Zod + themed shell - Member nav restored to full 11-section list with icons Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { SearchResults } from './page';
|
||||
|
||||
const makeHit = (id: string, content: string) => ({
|
||||
id,
|
||||
content,
|
||||
senderName: 'Alice',
|
||||
sourceGroupName: 'UP Parivar Dallas',
|
||||
tags: ['#important'],
|
||||
approvedAt: 1748390400000,
|
||||
});
|
||||
|
||||
describe('SearchResults', () => {
|
||||
it('shows "no results" when hits array is empty', () => {
|
||||
render(<SearchResults hits={[]} total={0} q="missing" page={1} />);
|
||||
expect(screen.getByText(/no results/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders each hit content', () => {
|
||||
render(
|
||||
<SearchResults
|
||||
hits={[makeHit('m1', 'Hello world'), makeHit('m2', 'Event tonight')]}
|
||||
total={2}
|
||||
q="hello"
|
||||
page={1}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Hello world')).toBeInTheDocument();
|
||||
expect(screen.getByText('Event tonight')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the total result count', () => {
|
||||
render(<SearchResults hits={[makeHit('m1', 'Test')]} total={42} q="test" page={1} />);
|
||||
expect(screen.getByText(/42/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows sender name, group name, and formatted date for each hit', () => {
|
||||
render(<SearchResults hits={[makeHit('m1', 'Test')]} total={1} q="test" page={1} />);
|
||||
expect(screen.getByText(/alice/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/UP Parivar Dallas/i)).toBeInTheDocument();
|
||||
// approvedAt: 1748390400000 — verify a date string is rendered (locale-aware)
|
||||
expect(screen.getByText(new Date(1748390400000).toLocaleDateString())).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows tags for each hit', () => {
|
||||
render(<SearchResults hits={[makeHit('m1', 'Test')]} total={1} q="test" page={1} />);
|
||||
expect(screen.getByText('#important')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import Link from 'next/link';
|
||||
import { apiFetch } from '@/app/_lib/api';
|
||||
|
||||
interface MeiliHit {
|
||||
id: string;
|
||||
content: string;
|
||||
senderName: string;
|
||||
sourceGroupName: string;
|
||||
tags: string[];
|
||||
approvedAt: number;
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
hits: MeiliHit[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
export function SearchResults({
|
||||
hits,
|
||||
total,
|
||||
q,
|
||||
page,
|
||||
}: {
|
||||
hits: MeiliHit[];
|
||||
total: number;
|
||||
q: string;
|
||||
page: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<form method="GET" className="flex gap-2">
|
||||
<input
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Search approved messages…"
|
||||
className="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{hits.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">No results{q ? ` for "${q}"` : ''}.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-gray-500">
|
||||
{total} result{total !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<ul className="flex flex-col gap-3">
|
||||
{hits.map((hit) => (
|
||||
<li key={hit.id}>
|
||||
<Link
|
||||
href={`/messages/${hit.id}`}
|
||||
className="block rounded-xl border border-gray-200 bg-white p-4 hover:border-blue-300 hover:shadow-sm transition-colors"
|
||||
>
|
||||
<p className="text-sm">{hit.content}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-400">
|
||||
<span>{hit.senderName}</span>
|
||||
<span>·</span>
|
||||
<span>{hit.sourceGroupName}</span>
|
||||
<span>·</span>
|
||||
<span>{new Date(hit.approvedAt).toLocaleDateString()}</span>
|
||||
{hit.tags.map((tag) => (
|
||||
<span key={tag} className="rounded-full bg-blue-50 px-2 py-0.5 text-blue-600">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function SearchPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string; page?: string }>;
|
||||
}) {
|
||||
const { q = '', page = '1' } = await searchParams;
|
||||
|
||||
let data: SearchResponse = { hits: [], total: 0, page: 1, limit: 20, query: q };
|
||||
try {
|
||||
const res = await apiFetch(`/search?q=${encodeURIComponent(q)}&page=${page}`);
|
||||
if (res.ok) data = await res.json();
|
||||
} catch {
|
||||
// API unavailable — render empty results
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<h1 className="text-xl font-semibold mb-4">Search</h1>
|
||||
<SearchResults hits={data.hits} total={data.total} q={q} page={Number(page)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user