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')