quiniela-sembradores-frontend/app/picks/page.tsx

636 lines
33 KiB
TypeScript
Raw Normal View History

'use client';
import React, {useCallback, useEffect, useRef, useState} from "react";
import {Alert, Box, CircularProgress, Paper, Radio, RadioGroup, Stack, Typography} from "@mui/material";
import {AuthGuard} from "@/src/auth/AuthGuard";
import {Fixture, picksClient, PickSelection} from "@/lib/api/picks";
import {ApiError} from "@/lib/api/client";
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
import ScheduleRoundedIcon from '@mui/icons-material/ScheduleRounded';
import SyncRoundedIcon from '@mui/icons-material/SyncRounded';
import {useSearchParams} from "next/navigation";
const PicksPage: React.FC = () => {
const searchParams = useSearchParams();
const footerRef = useRef<HTMLDivElement | null>(null);
const [fixtures, setFixtures] = useState<Fixture[]>([]);
const [selectedPicks, setSelectedPicks] = useState<Record<number, PickSelection>>({});
const [savedPicks, setSavedPicks] = useState<Record<number, PickSelection>>({});
const [savingPickIds, setSavingPickIds] = useState<number[]>([]);
const [isLoadingFixtures, setIsLoadingFixtures] = useState(true);
const [isRefreshingFixtures, setIsRefreshingFixtures] = useState(false);
const [error, setError] = useState('');
const [footerHeight, setFooterHeight] = useState(0);
const loadFixtures = useCallback(async (signal?: AbortSignal) => {
setError('');
setIsLoadingFixtures(true);
try {
const [fixturesResponse, picksResponse] = await Promise.all([
picksClient.getFixtures(signal),
picksClient.getPicks(signal),
]);
setFixtures(fixturesResponse.fixtures);
const nextSavedPicks = picksResponse.picks.reduce<Record<number, PickSelection>>((accumulator, pick) => {
accumulator[pick.match_id] = pick.selection;
return accumulator;
}, {});
setSelectedPicks(nextSavedPicks);
setSavedPicks(nextSavedPicks);
} catch (fetchError: unknown) {
if (signal?.aborted) {
return;
}
if (fetchError instanceof ApiError) {
setError(fetchError.message);
} else {
setError('No fue posible cargar los fixtures.');
}
} finally {
if (!signal?.aborted) {
setIsLoadingFixtures(false);
}
}
}, []);
useEffect(() => {
const controller = new AbortController();
void loadFixtures(controller.signal);
return () => {
controller.abort();
};
}, [loadFixtures]);
const handleRefreshFixtures = async () => {
setIsRefreshingFixtures(true);
try {
await loadFixtures();
} finally {
setIsRefreshingFixtures(false);
}
};
const getLocaleDate = (date: number): string => {
return new Date(date * 1000).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
})
};
const getStatusString = (status: string): string => {
switch (status) {
case "scheduled":
return "Agendado"
case "in_progress":
return "En vivo"
case "finished":
return "Terminado"
case "cancelled":
return "Cancelado"
default:
return "Desconocido"
}
}
const handlePickChange = async (fixtureId: number, pick: PickSelection) => {
const previousPick = selectedPicks[fixtureId];
setSelectedPicks((current) => ({
...current,
[fixtureId]: pick,
}));
setSavingPickIds((current) => [...current, fixtureId]);
setError('');
try {
await picksClient.savePick({
match_id: fixtureId,
selection: pick,
});
setSavedPicks((current) => ({
...current,
[fixtureId]: pick,
}));
} catch (saveError: unknown) {
setSelectedPicks((current) => {
if (!previousPick) {
const remainingPicks = {...current};
delete remainingPicks[fixtureId];
return remainingPicks;
}
return {
...current,
[fixtureId]: previousPick,
};
});
if (saveError instanceof ApiError) {
setError(saveError.message);
} else {
setError('No fue posible guardar tu selección.');
}
} finally {
setSavingPickIds((current) => current.filter((currentFixtureId) => currentFixtureId !== fixtureId));
}
};
const getPickStatus = (fixtureId: number) => {
if (savingPickIds.includes(fixtureId)) {
return {
label: 'Registrando...',
color: 'text.primary',
} as const;
}
if (savedPicks[fixtureId]) {
return {
label: 'Registrado correctamente',
color: 'primary.main',
} as const;
}
return {
label: 'No se ha registrado el resultado',
color: 'error.main',
} as const;
};
const savedCount = fixtures.filter((fixture) => Boolean(savedPicks[fixture.id])).length;
const footerPreviewCountValue = Number(searchParams.get('footerPreview') ?? '0');
const footerPreviewCount =
Number.isFinite(footerPreviewCountValue) && footerPreviewCountValue > 0
? Math.floor(footerPreviewCountValue)
: 0;
const footerTotalCount = Math.max(fixtures.length, footerPreviewCount);
const missingCount = Math.max(footerTotalCount - savedCount, 0);
const footerItems = Array.from({length: footerTotalCount}, (_, index) => {
const fixture = fixtures[index];
if (fixture) {
return {
key: `fixture-${fixture.id}`,
fixtureId: fixture.id,
};
}
return {
key: `preview-${index}`,
fixtureId: null,
};
});
const footerBottomPadding = footerTotalCount > 0 ? Math.max(footerHeight + 12, 190) : 0;
useEffect(() => {
if (footerTotalCount === 0) {
setFooterHeight(0);
return;
}
const footerElement = footerRef.current;
if (!footerElement) {
return;
}
const updateFooterHeight = () => {
setFooterHeight(footerElement.getBoundingClientRect().height);
};
updateFooterHeight();
const observer = new ResizeObserver(() => {
updateFooterHeight();
});
observer.observe(footerElement);
return () => {
observer.disconnect();
};
}, [footerTotalCount, savedCount, missingCount, savingPickIds.length]);
return (
<AuthGuard>
<Box
sx={{
minHeight: '100vh',
backgroundColor: {
xs: 'white',
md: 'grey.100',
},
p: {xs: 0, sm: 4},
}}
>
<Paper
elevation={0}
sx={{
maxWidth: 720,
mx: 'auto',
p: {xs: 3, sm: 4},
borderRadius: 4,
boxShadow: {
xs: 'none',
md: 4,
}
}}
>
<Stack spacing={3}>
<Stack
direction={{xs: 'column', sm: 'row'}}
spacing={2}
alignItems={{xs: 'flex-start', sm: 'center'}}
justifyContent="space-between"
>
<Box>
<Typography variant="h4" fontWeight={700}>
Partidos
</Typography>
</Box>
</Stack>
{error ? <Alert severity="error">{error}</Alert> : null}
{isLoadingFixtures ? (
<Box
sx={{
py: 8,
display: 'flex',
justifyContent: 'center',
}}
>
<CircularProgress/>
</Box>
) : fixtures.length === 0 ? (
<Typography color="text.secondary">
No hay partidos disponibles.
</Typography>
) : (
<Stack spacing={2}>
{fixtures.map((fixture) => (
<Paper
key={fixture.id}
variant="outlined"
sx={{
p: 2,
borderRadius: 3,
}}
>
<Box display={'flex'} flexDirection={'column'}>
<Box sx={{
display: 'grid',
gap: 1.5,
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, minmax(0, 1fr))',
},
}}>
<Box component={'div'}>
<Typography
variant="subtitle1">{getLocaleDate(fixture.starts_at)}
</Typography>
<Typography
variant="subtitle2">{fixture.venue}
</Typography>
</Box>
<Box component={'div'} sx={{textAlign: {xs: 'left', sm: 'right'}}}>
<Typography
variant="subtitle1">{fixture.stage}
</Typography>
<Typography variant="subtitle2">
{getStatusString(fixture.status)}
</Typography>
</Box>
</Box>
<RadioGroup
name={`fixture-${fixture.id}`}
value={selectedPicks[fixture.id] ?? ''}
onChange={(event) => void handlePickChange(fixture.id, event.target.value as PickSelection)}
>
<Box
sx={{
mt: 2,
display: 'grid',
gap: 1.5,
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(3, minmax(0, 1fr))',
},
}}
>
<Paper
component="label"
variant="outlined"
sx={{
px: 1.5,
py: 1,
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
borderWidth: 2,
borderColor: selectedPicks[fixture.id] === 'home' ? 'green' : 'divider',
backgroundColor: selectedPicks[fixture.id] === 'home' ? 'rgba(53,205,53,0.5)' : 'grey.50',
color: selectedPicks[fixture.id] === 'home' ? 'black' : 'text.primary',
transition: 'background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease',
opacity: savingPickIds.includes(fixture.id) ? 0.7 : 1,
}}
>
<Stack
direction="row"
spacing={1}
alignItems="center"
justifyContent="center"
>
<Radio
value={'home'}
checked={selectedPicks[fixture.id] === 'home'}
disabled={savingPickIds.includes(fixture.id)}
sx={{
position: 'absolute',
opacity: 0,
width: 1,
height: 1,
m: 0,
p: 0,
overflow: 'hidden',
'&.Mui-checked': {
color: 'inherit',
},
}}
/>
<span className={`fi fi-${fixture.home_short_name}`}></span>
<Typography
variant="subtitle1"
fontWeight={600}
textAlign="center"
sx={{overflow: 'hidden', overflowWrap: 'break-word'}}
>
{fixture.home_team}
</Typography>
</Stack>
</Paper>
<Paper
component="label"
variant="outlined"
sx={{
px: 1.5,
py: 1,
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
borderWidth: 2,
borderColor: selectedPicks[fixture.id] === 'draw' ? '#e6b94b' : 'divider',
backgroundColor: selectedPicks[fixture.id] === 'draw' ? 'rgba(255,255,0,0.5)' : 'grey.50',
color: selectedPicks[fixture.id] === 'draw' ? 'black' : 'text.primary',
transition: 'background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease',
opacity: savingPickIds.includes(fixture.id) ? 0.7 : 1,
}}
>
<Stack
direction="row"
spacing={1}
alignItems="center"
justifyContent="center"
>
<Radio
value={'draw'}
checked={selectedPicks[fixture.id] === 'draw'}
disabled={savingPickIds.includes(fixture.id)}
sx={{
position: 'absolute',
opacity: 0,
width: 1,
height: 1,
m: 0,
p: 0,
overflow: 'hidden',
'&.Mui-checked': {
color: 'inherit',
},
}}
/>
<Typography
variant="subtitle1"
fontWeight={600}
textAlign="center"
sx={{overflow: 'hidden', overflowWrap: 'break-word'}}
>
{'Empate'}
</Typography>
</Stack>
</Paper>
<Paper
component="label"
variant="outlined"
sx={{
px: 1.5,
py: 1,
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
borderWidth: 2,
borderColor: selectedPicks[fixture.id] === 'away' ? '#7a1800' : 'divider',
backgroundColor: selectedPicks[fixture.id] === 'away' ? 'rgba(255,0,0,0.25)' : 'grey.50',
color: selectedPicks[fixture.id] === 'away' ? 'black' : 'text.primary',
transition: 'background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease',
opacity: savingPickIds.includes(fixture.id) ? 0.7 : 1,
}}
>
<Stack
direction="row"
spacing={1}
alignItems="center"
justifyContent="center"
>
<Radio
value={'away'}
checked={selectedPicks[fixture.id] === 'away'}
disabled={savingPickIds.includes(fixture.id)}
sx={{
position: 'absolute',
opacity: 0,
width: 1,
height: 1,
m: 0,
p: 0,
overflow: 'hidden',
'&.Mui-checked': {
color: 'inherit',
},
}}
/>
<span className={`fi fi-${fixture.away_short_name}`}></span>
<Typography
variant="subtitle1"
fontWeight={600}
textAlign="center"
sx={{overflow: 'hidden', overflowWrap: 'break-word'}}
>
{fixture.away_team}
</Typography>
</Stack>
</Paper>
</Box>
</RadioGroup>
{(() => {
const pickStatus = getPickStatus(fixture.id);
return (
<Typography
variant="caption"
sx={{
mt: 1.5,
color: pickStatus.color,
fontWeight: 600,
}}
>
{pickStatus.label}
</Typography>
);
})()}
</Box>
</Paper>
))}
</Stack>
)}
</Stack>
</Paper>
{footerTotalCount > 0 ? (
<Box
aria-hidden
sx={{
height: `calc(${footerBottomPadding}px + env(safe-area-inset-bottom, 0px))`,
}}
/>
) : null}
{!isLoadingFixtures && footerTotalCount > 0 ? (
<Box
ref={footerRef}
sx={{
position: 'fixed',
left: 0,
right: 0,
bottom: 0,
px: {xs: 1.5, sm: 3},
pb: {xs: 1.5, sm: 2.5},
zIndex: 1200,
}}
>
<Paper
elevation={10}
sx={{
maxWidth: 720,
mx: 'auto',
borderRadius: 4,
px: {xs: 2, sm: 3},
py: 1.75,
border: '1px solid',
borderColor: 'divider',
backgroundColor: 'rgba(255,255,255,0.96)',
backdropFilter: 'blur(10px)',
}}
>
<Stack spacing={1.5}>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
spacing={2}
>
<Box>
<Typography variant="subtitle1" fontWeight={700}>
{savedCount}/{footerTotalCount} resultados registrados
</Typography>
<Typography variant="body2" color="text.secondary">
{missingCount} pendientes por registrar
</Typography>
</Box>
<Stack direction="row" spacing={1.5} alignItems="center">
<Stack direction="row" spacing={0.5} alignItems="center">
<CheckCircleRoundedIcon sx={{fontSize: 18, color: 'primary.main'}}/>
<Typography variant="body2" fontWeight={600} color="primary.main">
{savedCount}
</Typography>
</Stack>
<Stack direction="row" spacing={0.5} alignItems="center">
<ScheduleRoundedIcon sx={{fontSize: 18, color: 'grey.500'}}/>
<Typography variant="body2" fontWeight={600} color="text.secondary">
{missingCount}
</Typography>
</Stack>
</Stack>
</Stack>
{footerPreviewCount > fixtures.length ? (
<Typography variant="caption" color="text.secondary">
Vista previa del footer con {footerPreviewCount} partidos
</Typography>
) : null}
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(18px, 18px))',
gap: 0.75,
justifyContent: 'start',
}}
>
{footerItems.map((item) => {
if (item.fixtureId && savingPickIds.includes(item.fixtureId)) {
return (
<SyncRoundedIcon
key={item.key}
sx={{
fontSize: 18,
color: 'text.secondary',
animation: 'spin 1s linear infinite',
'@keyframes spin': {
from: {transform: 'rotate(0deg)'},
to: {transform: 'rotate(-360deg)'},
},
}}
/>
);
}
if (item.fixtureId && savedPicks[item.fixtureId]) {
return (
<CheckCircleRoundedIcon
key={item.key}
sx={{fontSize: 18, color: 'primary.main'}}
/>
);
}
return (
<ScheduleRoundedIcon
key={item.key}
sx={{fontSize: 18, color: 'grey.500'}}
/>
);
})}
</Box>
</Stack>
</Paper>
</Box>
) : null}
</Box>
</AuthGuard>
)
}
export default PicksPage;