From 6ae1130585cbacc5c5e5e65512d6a113926061b8 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Thu, 28 May 2026 01:19:23 +0530 Subject: [PATCH] feat(web): add search page with full-text message search UI --- apps/web/app/search/page.test.tsx | 47 ++++++++++++++ apps/web/app/search/page.tsx | 103 ++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 apps/web/app/search/page.test.tsx create mode 100644 apps/web/app/search/page.tsx diff --git a/apps/web/app/search/page.test.tsx b/apps/web/app/search/page.test.tsx new file mode 100644 index 0000000..3ee4e9e --- /dev/null +++ b/apps/web/app/search/page.test.tsx @@ -0,0 +1,47 @@ +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(); + expect(screen.getByText(/no results/i)).toBeInTheDocument(); + }); + + it('renders each hit content', () => { + render( + , + ); + expect(screen.getByText('Hello world')).toBeInTheDocument(); + expect(screen.getByText('Event tonight')).toBeInTheDocument(); + }); + + it('shows the total result count', () => { + render(); + expect(screen.getByText(/42/)).toBeInTheDocument(); + }); + + it('shows sender name and group name for each hit', () => { + render(); + expect(screen.getByText(/alice/i)).toBeInTheDocument(); + expect(screen.getByText(/UP Parivar Dallas/i)).toBeInTheDocument(); + }); + + it('shows tags for each hit', () => { + render(); + expect(screen.getByText('#important')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/search/page.tsx b/apps/web/app/search/page.tsx new file mode 100644 index 0000000..8a5adaf --- /dev/null +++ b/apps/web/app/search/page.tsx @@ -0,0 +1,103 @@ +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 ( +
+
+ + +
+ + {hits.length === 0 ? ( +

No results{q ? ` for "${q}"` : ''}.

+ ) : ( + <> +

+ {total} result{total !== 1 ? 's' : ''} +

+
    + {hits.map((hit) => ( +
  • +

    {hit.content}

    +
    + {hit.senderName} + · + {hit.sourceGroupName} + · + {new Date(hit.approvedAt).toLocaleDateString()} + {hit.tags.map((tag) => ( + + {tag} + + ))} +
    +
  • + ))} +
+ + )} +
+ ); +} + +export default async function SearchPage({ + searchParams, +}: { + searchParams: Promise<{ q?: string; page?: string }>; +}) { + const { q = '', page = '1' } = await searchParams; + const apiUrl = process.env.API_URL ?? 'http://localhost:3001'; + const url = new URL(`${apiUrl}/search`); + url.searchParams.set('q', q); + url.searchParams.set('page', page); + + let data: SearchResponse = { hits: [], total: 0, page: 1, limit: 20, query: q }; + try { + const res = await fetch(url, { cache: 'no-store' }); + if (res.ok) data = await res.json(); + } catch { + // API unavailable — render empty results + } + + return ( +
+

Search

+ +
+ ); +}