Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions apps/backend/src/routers/portal/teams/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment thread
CoolGame8 marked this conversation as resolved.

const numberOfPages = await db.teams.numberOfPages(regionFilter);

if (!page) {
const teams = await db.teams.getAll();
Expand All @@ -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());

/**
Expand Down
9 changes: 5 additions & 4 deletions apps/portal/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -126,11 +126,12 @@
},
"teams": {
"title": "<i>FIRST</i> 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",
Expand Down
11 changes: 6 additions & 5 deletions apps/portal/locale/he.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,16 @@
},
"teams": {
"title": "קבוצות <i>FIRST</i> LEGO League Challenge",
"search": {
"placeholder": "חיפוש קבוצות..."
},
Comment thread
CoolGame8 marked this conversation as resolved.
"region": {
"label": "מדינה",
"all": "כל המדינות",
"current-all": "מציג קבוצות מכל המדינות",
"current": "מציג קבוצות מהמדינה {region}"
"all": "כל המדינות"
},
"no-teams": {
"title": "אין קבוצות להציג",
"message": "אנא בדקו שוב מאוחר יותר"
"title": "לא נמצאו תוצאות שתואמות לחיפוש",
"message": "נסו לחפש במלים אחרות"
}
},
"events": {
Expand Down
7 changes: 4 additions & 3 deletions apps/portal/locale/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,12 @@
},
"teams": {
"title": "Drużyny <i>FIRST</i> 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",
Expand Down
12 changes: 11 additions & 1 deletion apps/portal/src/app/[locale]/teams/components/team-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};
Comment thread
CoolGame8 marked this conversation as resolved.

const { data, isLoading } = useSWR<{ teams: Team[]; numberOfPages: number }>(
`/portal/teams?page=${pageNumber}`,
`/portal/teams?${buildQuery()}`,
{
suspense: true,
fallbackData: { teams: [], numberOfPages: 0 }
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TeamSearchInputProps> = ({
initialValue,
placeholder,
onSearchChange,
onClear,
showClearButton
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | undefined>(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 (
<TextField
fullWidth
size="small"
placeholder={placeholder}
defaultValue={initialValue}
onChange={e => handleInput(e.target.value)}
inputRef={inputRef}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
endAdornment: showClearButton && (
<InputAdornment position="end">
<IconButton size="small" onClick={handleClear} edge="end">
<ClearIcon />
</IconButton>
</InputAdornment>
)
}
}}
/>
);
};
116 changes: 116 additions & 0 deletions apps/portal/src/app/[locale]/teams/components/teams-page-header.tsx
Original file line number Diff line number Diff line change
@@ -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<string[]>('/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 (
<Stack spacing={3} sx={{ mb: 4 }}>
<Typography
variant="h3"
component="h1"
sx={{
fontWeight: 'bold',
fontSize: { xs: '1.75rem', sm: '2.25rem', md: '2.75rem' }
}}
>
{<RichText>{tags => t.rich('title', tags)}</RichText>}
</Typography>

<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: 2,
alignItems: 'flex-start'
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<TeamSearchInput
initialValue={search}
placeholder={t('search.placeholder')}
onSearchChange={handleSearchChange}
onClear={handleClearSearch}
showClearButton={!!search}
/>
</Box>

<FormControl size="small" sx={{ minWidth: 120, flexShrink: 0 }}>
<InputLabel>{t('region.label')}</InputLabel>
<Select
value={region}
label={t('region.label')}
onChange={e => handleRegionChange(e.target.value)}
renderValue={value => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{value ? (
<>
<Flag region={value} size={20} />
{value}
</>
) : (
t('region.all')
)}
</Box>
)}
>
<MenuItem value="">{t('region.all')}</MenuItem>
{regions?.map(r => (
<MenuItem key={r} value={r}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Flag region={r} size={20} />
{r}
</Box>
</MenuItem>
))}
</Select>
</FormControl>
</Box>
</Stack>
);
};
19 changes: 3 additions & 16 deletions apps/portal/src/app/[locale]/teams/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
<Container maxWidth="lg" sx={{ py: { xs: 3, sm: 4 } }}>
<Typography
variant="h3"
component="h1"
sx={{
fontWeight: 'bold',
mb: 4,
fontSize: { xs: '1.75rem', sm: '2.25rem', md: '2.75rem' }
}}
>
{<RichText>{tags => t.rich('title', tags)}</RichText>}
</Typography>

<TeamsPageHeader />
<TeamList />
</Container>
</Box>
Expand Down
Loading