diff --git a/apps/backend/src/routers/portal/teams/index.ts b/apps/backend/src/routers/portal/teams/index.ts index 567ae8a70..bf506d797 100644 --- a/apps/backend/src/routers/portal/teams/index.ts +++ b/apps/backend/src/routers/portal/teams/index.ts @@ -13,9 +13,29 @@ import { makePortalTeamResponse, makePortalTeamSummaryResponse } from './util'; const router = express.Router({ mergeParams: true }); router.get('/', async (req: Request, res: Response) => { - const { page } = req.query; + const { page, region, search } = req.query; - const numberOfPages = await db.teams.numberOfPages(); + const searchQuery = search ? String(search).trim() : undefined; + const regionFilter = region ? String(region) : undefined; + + if (searchQuery) { + const pageNumber = page ? parseInt(page as string, 10) : undefined; + + if (pageNumber !== undefined && (isNaN(pageNumber) || pageNumber < 1)) { + res.status(400).json({ error: 'Invalid page number' }); + return; + } + + const [teams, totalCount] = await Promise.all([ + db.teams.search(searchQuery, pageNumber, regionFilter), + db.teams.searchCount(searchQuery, regionFilter) + ]); + const numberOfPages = Math.ceil(totalCount / 200); + res.status(200).json({ teams: teams.map(makePortalTeamResponse), numberOfPages }); + return; + } + + const numberOfPages = await db.teams.numberOfPages(regionFilter); if (!page) { const teams = await db.teams.getAll(); @@ -30,10 +50,15 @@ router.get('/', async (req: Request, res: Response) => { return; } - const teams = await db.teams.getPage(pageNumber); + const teams = await db.teams.getPage(pageNumber, regionFilter); res.status(200).json({ teams: teams.map(makePortalTeamResponse), numberOfPages }); }); +router.get('/regions', async (_req: Request, res: Response) => { + const regions = await db.teams.getRegions(); + res.status(200).json(regions); +}); + router.use('/:teamSlug', attachTeam()); /** diff --git a/apps/portal/locale/en.json b/apps/portal/locale/en.json index 6838df9d3..fe0772392 100644 --- a/apps/portal/locale/en.json +++ b/apps/portal/locale/en.json @@ -29,7 +29,7 @@ "title": "Search Teams & Events", "placeholder": "Search for teams, or events...", "start-typing": "Start typing to search", - "search-hint": "Search by team name, number, city, event name, or location", + "search-hint": "Search team...", "searching": "Searching...", "no-results": "No results found", "no-results-hint": "No results found for \"{query}\". Try different keywords.", @@ -126,11 +126,12 @@ }, "teams": { "title": "FIRST LEGO League Challenge Teams", + "search": { + "placeholder": "Search team..." + }, "region": { "label": "Region", - "all": "All regions", - "current-all": "Showing teams from all regions", - "current": "Showing teams from region {region}" + "all": "All regions" }, "no-teams": { "title": "No Teams to Show", diff --git a/apps/portal/locale/he.json b/apps/portal/locale/he.json index b8782d717..18b179732 100644 --- a/apps/portal/locale/he.json +++ b/apps/portal/locale/he.json @@ -59,15 +59,16 @@ }, "teams": { "title": "קבוצות FIRST LEGO League Challenge", + "search": { + "placeholder": "חיפוש קבוצות..." + }, "region": { "label": "מדינה", - "all": "כל המדינות", - "current-all": "מציג קבוצות מכל המדינות", - "current": "מציג קבוצות מהמדינה {region}" + "all": "כל המדינות" }, "no-teams": { - "title": "אין קבוצות להציג", - "message": "אנא בדקו שוב מאוחר יותר" + "title": "לא נמצאו תוצאות שתואמות לחיפוש", + "message": "נסו לחפש במלים אחרות" } }, "events": { diff --git a/apps/portal/locale/pl.json b/apps/portal/locale/pl.json index 1eef731dd..b0534b5eb 100644 --- a/apps/portal/locale/pl.json +++ b/apps/portal/locale/pl.json @@ -126,11 +126,12 @@ }, "teams": { "title": "Drużyny FIRST LEGO League Challenge", + "search": { + "placeholder": "Szukaj drużyny..." + }, "region": { "label": "Region", - "all": "Wszystkie regiony", - "current-all": "Wyświetlane drużyny ze wszystkich regionów", - "current": "Wyświetlane drużyny z regionu {region}" + "all": "Wszystkie regiony" }, "no-teams": { "title": "Brak drużyn do wyświetlenia", diff --git a/apps/portal/src/app/[locale]/teams/components/team-list.tsx b/apps/portal/src/app/[locale]/teams/components/team-list.tsx index 28a88549f..abb99adf5 100644 --- a/apps/portal/src/app/[locale]/teams/components/team-list.tsx +++ b/apps/portal/src/app/[locale]/teams/components/team-list.tsx @@ -13,9 +13,19 @@ export const TeamList: React.FC = () => { const t = useTranslations('pages.teams'); const searchParams = useSearchParams(); const pageNumber = Number(searchParams.get('page')) || 1; + const region = searchParams.get('region') || ''; + const search = searchParams.get('search') || ''; + + const buildQuery = () => { + const params = new URLSearchParams(); + params.set('page', pageNumber.toString()); + if (region) params.set('region', region); + if (search && search.length >= 2) params.set('search', search); + return params.toString(); + }; const { data, isLoading } = useSWR<{ teams: Team[]; numberOfPages: number }>( - `/portal/teams?page=${pageNumber}`, + `/portal/teams?${buildQuery()}`, { suspense: true, fallbackData: { teams: [], numberOfPages: 0 } diff --git a/apps/portal/src/app/[locale]/teams/components/team-search-input.tsx b/apps/portal/src/app/[locale]/teams/components/team-search-input.tsx new file mode 100644 index 000000000..18d3238d7 --- /dev/null +++ b/apps/portal/src/app/[locale]/teams/components/team-search-input.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useRef, useEffect } from 'react'; +import { TextField, InputAdornment, IconButton } from '@mui/material'; +import { Search as SearchIcon, Clear as ClearIcon } from '@mui/icons-material'; + +interface TeamSearchInputProps { + initialValue: string; + placeholder: string; + onSearchChange: (value: string) => void; + onClear: () => void; + showClearButton: boolean; +} + +export const TeamSearchInput: React.FC = ({ + initialValue, + placeholder, + onSearchChange, + onClear, + showClearButton +}) => { + const inputRef = useRef(null); + const debounceTimerRef = useRef(undefined); + + useEffect(() => { + if (inputRef.current && inputRef.current.value !== initialValue) { + inputRef.current.value = initialValue; + } + }, [initialValue]); + + const handleInput = (value: string) => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + debounceTimerRef.current = setTimeout(() => { + onSearchChange(value); + }, 300); + }; + + const handleClear = () => { + if (inputRef.current) { + inputRef.current.value = ''; + } + onClear(); + }; + + return ( + handleInput(e.target.value)} + inputRef={inputRef} + slotProps={{ + input: { + startAdornment: ( + + + + ), + endAdornment: showClearButton && ( + + + + + + ) + } + }} + /> + ); +}; diff --git a/apps/portal/src/app/[locale]/teams/components/teams-page-header.tsx b/apps/portal/src/app/[locale]/teams/components/teams-page-header.tsx new file mode 100644 index 000000000..1b61a5702 --- /dev/null +++ b/apps/portal/src/app/[locale]/teams/components/teams-page-header.tsx @@ -0,0 +1,116 @@ +'use client'; + +import React from 'react'; +import useSWR from 'swr'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { Box, FormControl, InputLabel, MenuItem, Select, Stack, Typography } from '@mui/material'; +import { RichText } from '@lems/localization'; +import { Flag } from '@lems/shared'; +import { TeamSearchInput } from './team-search-input'; + +export const TeamsPageHeader: React.FC = () => { + const t = useTranslations('pages.teams'); + const router = useRouter(); + const searchParams = useSearchParams(); + const region = searchParams.get('region') || ''; + const search = searchParams.get('search') || ''; + + const { data: regions = [] } = useSWR('/portal/teams/regions', { + fallbackData: [] + }); + + const handleRegionChange = (newRegion: string) => { + const params = new URLSearchParams(searchParams.toString()); + if (newRegion) { + params.set('region', newRegion); + } else { + params.delete('region'); + } + params.set('page', '1'); + router.replace(`?${params.toString()}`, { scroll: false }); + }; + + const handleSearchChange = (searchValue: string) => { + const params = new URLSearchParams(searchParams.toString()); + if (searchValue.trim()) { + params.set('search', searchValue.trim()); + } else { + params.delete('search'); + } + params.set('page', '1'); + router.replace(`?${params.toString()}`, { scroll: false }); + }; + + const handleClearSearch = () => { + const params = new URLSearchParams(searchParams.toString()); + params.delete('search'); + params.set('page', '1'); + router.replace(`?${params.toString()}`, { scroll: false }); + }; + + return ( + + + {{tags => t.rich('title', tags)}} + + + + + + + + + {t('region.label')} + + + + + ); +}; diff --git a/apps/portal/src/app/[locale]/teams/page.tsx b/apps/portal/src/app/[locale]/teams/page.tsx index 754f92c8c..d29e550f0 100644 --- a/apps/portal/src/app/[locale]/teams/page.tsx +++ b/apps/portal/src/app/[locale]/teams/page.tsx @@ -1,26 +1,13 @@ import React from 'react'; -import { getTranslations } from 'next-intl/server'; -import { Box, Container, Typography } from '@mui/material'; -import { RichText } from '@lems/localization'; +import { Box, Container } from '@mui/material'; import { TeamList } from './components/team-list'; +import { TeamsPageHeader } from './components/teams-page-header'; export default async function TeamsPage() { - const t = await getTranslations('pages.teams'); return ( - - {{tags => t.rich('title', tags)}} - - + diff --git a/libs/database/src/repositories/teams.ts b/libs/database/src/repositories/teams.ts index ba74b6e82..3f0b04ecf 100644 --- a/libs/database/src/repositories/teams.ts +++ b/libs/database/src/repositories/teams.ts @@ -1,4 +1,4 @@ -import { Kysely } from 'kysely'; +import { Kysely, sql } from 'kysely'; import { KyselyDatabaseSchema } from '../schema/kysely'; import { ObjectStorage } from '../object-storage'; import { InsertableTeam, Team, UpdateableTeam } from '../schema/tables/teams'; @@ -213,10 +213,14 @@ export class TeamsRepository { return teams; } - async getPage(page: number): Promise { - const teams = await this.db - .selectFrom('teams') - .selectAll('teams') + async getPage(page: number, region?: string): Promise { + let query = this.db.selectFrom('teams').selectAll('teams'); + + if (region) { + query = query.where('region', '=', region); + } + + const teams = await query .orderBy('number', 'asc') .offset((page - 1) * this.TEAMS_PER_PAGE) .limit(this.TEAMS_PER_PAGE) @@ -224,43 +228,100 @@ export class TeamsRepository { return teams; } - async numberOfPages(): Promise { - const count = await this.db.selectFrom('teams').select('id').execute(); + async numberOfPages(region?: string): Promise { + let query = this.db.selectFrom('teams').select('id'); + + if (region) { + query = query.where('region', '=', region); + } + + const count = await query.execute(); return Math.ceil(count.length / this.TEAMS_PER_PAGE); } - async search(searchTerm: string, limit: number): Promise { - const teams = await this.db + async getRegions(): Promise { + const regions = await this.db + .selectFrom('teams') + .select('region') + .distinct() + .where('region', 'is not', null) + .orderBy('region', 'asc') + .execute(); + return regions.map(r => r.region).filter(r => r && r.trim() !== ''); + } + + async search( + searchTerm: string, + page?: number, + region?: string, + limit?: number + ): Promise { + const effectiveLimit = limit ?? this.TEAMS_PER_PAGE; + + let query = this.db .selectFrom('teams') .selectAll() .where(eb => eb.or([ eb('name', 'ilike', `%${searchTerm}%`), - eb('number', '=', parseInt(searchTerm) || -1), + sql`CAST(number AS TEXT) LIKE ${searchTerm + '%'}`, eb('affiliation', 'ilike', `%${searchTerm}%`), eb('city', 'ilike', `%${searchTerm}%`) ]) - ) - .orderBy( - eb => - eb - .case() - .when('name', 'ilike', searchTerm) - .then(100) - .when('number', '=', parseInt(searchTerm) || -1) - .then(95) - .when('name', 'ilike', `${searchTerm}%`) - .then(80) - .else(50) - .end(), - 'desc' - ) - .limit(limit) - .execute(); + ); + + if (region) { + query = query.where('region', '=', region); + } + + query = query.orderBy( + eb => + eb + .case() + .when('name', 'ilike', searchTerm) + .then(100) + .when(sql`CAST(number AS TEXT) = ${searchTerm}`) + .then(95) + .when(sql`CAST(number AS TEXT) LIKE ${searchTerm + '%'}`) + .then(90) + .when('name', 'ilike', `${searchTerm}%`) + .then(80) + .else(50) + .end(), + 'desc' + ); + + if (page !== undefined) { + query = query.offset((page - 1) * effectiveLimit); + } + + const teams = await query.limit(effectiveLimit).execute(); return teams; } + async searchCount(searchTerm: string, region?: string): Promise { + let query = this.db + .selectFrom('teams') + .select(eb => eb.fn.count('id').as('count')) + .where(eb => + eb.or([ + eb('name', 'ilike', `%${searchTerm}%`), + sql`CAST(number AS TEXT) LIKE ${searchTerm + '%'}`, + eb('affiliation', 'ilike', `%${searchTerm}%`), + eb('city', 'ilike', `%${searchTerm}%`) + ]) + ); + + if (region) { + query = query.where('region', '=', region); + } + + const result = await query.executeTakeFirst(); + + return Number(result?.count ?? 0); + } + async getAllWithActiveStatus(): Promise> { const currentSeason = await this.db .selectFrom('seasons')