Add auth guard, navigation and picks page

This commit is contained in:
Hector Villarreal 2026-03-21 15:08:34 -06:00
parent 1f621ec8e6
commit 853b2283ec
22 changed files with 5644 additions and 6708 deletions

2
.gitignore vendored
View file

@ -39,3 +39,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.idea

10
.idea/.gitignore generated vendored Normal file
View file

@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

View file

@ -1,26 +1,2 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
@import "flag-icons/css/flag-icons.min.css";

47
app/home/index.tsx Normal file
View file

@ -0,0 +1,47 @@
'use client';
import React from 'react';
import {Box, Button, CircularProgress, Paper, Typography} from '@mui/material';
import {useAuth} from "@/src/auth/AuthProvider";
import {AuthGuard} from "@/src/auth/AuthGuard";
const HomePage: React.FC = () => {
const {logout} = useAuth()
const handleLogoutClick = async () => {
await logout()
}
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,
}
}}
>
</Paper>
</Box>
</AuthGuard>
);
};
export default HomePage;

View file

@ -1,34 +1,47 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import type {Metadata, Viewport} from "next";
import {AppRouterCacheProvider} from '@mui/material-nextjs/v15-appRouter';
import {Roboto} from 'next/font/google'
import {ThemeProvider} from '@mui/material/styles'
import theme from '@/src/theme'
import "./globals.css";
import React from "react";
import {CssBaseline} from "@mui/material";
import {AuthProvider} from "@/src/auth/AuthProvider";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const roboto = Roboto({
weight: ['300', '400', '500', '700'],
subsets: ['latin'],
display: 'swap',
variable: '--font-roboto',
})
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Quiniela Sembradores",
description: "Quiniela Sembradores - Mundial 2026",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
export const viewport: Viewport = {
initialScale: 1,
width: 'device-width',
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="es-MX" className={roboto.variable}>
<body>
<AppRouterCacheProvider>
<CssBaseline/>
<ThemeProvider theme={theme}>
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
</AppRouterCacheProvider>
</body>
</html>
);
}

219
app/login/page.tsx Normal file
View file

@ -0,0 +1,219 @@
'use client';
import React, {ChangeEvent, useState} from 'react';
import Link from 'next/link';
import {useRouter, useSearchParams} from 'next/navigation';
import {Alert, Box, Button, Paper, Stack, TextField, Typography} from '@mui/material';
import GoogleIcon from '@mui/icons-material/Google';
import AppleIcon from '@mui/icons-material/Apple';
import {ApiError} from '@/lib/api/client';
import {useAuth} from "@/src/auth/AuthProvider";
import {GuestGuard} from "@/src/auth/GuestGuard";
type LoginValues = {
email: string;
password: string;
};
const Page: React.FC = () => {
const router = useRouter();
const searchParams = useSearchParams();
const next = searchParams.get('next') || '/';
const {login} = useAuth()
const [values, setValues] = useState<LoginValues>({
email: '',
password: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [serverError, setServerError] = useState('');
const handleChange =
(field: keyof LoginValues) => (event: ChangeEvent<HTMLInputElement>) => {
setValues((current) => ({
...current,
[field]: event.target.value,
}));
};
const handleSubmit = async (event: React.SubmitEvent<HTMLFormElement>) => {
event.preventDefault();
setServerError('');
setIsSubmitting(true);
try {
await login(values)
router.replace(next);
router.refresh()
} catch (error: unknown) {
if (error instanceof ApiError) {
setServerError(error.message);
} else {
setServerError('No fue posible iniciar sesión')
}
} finally {
setIsSubmitting(false)
}
};
const handleGoogleLogin = () => {
// TODO: wire up Google auth here
console.log('Continuar con Google');
};
const handleAppleLogin = () => {
// TODO: wire up Apple auth here
console.log('Continuar con Apple');
};
return (
<GuestGuard>
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'grey.100',
p: 3,
}}
>
<Paper
elevation={4}
sx={{
width: '100%',
maxWidth: 360,
borderRadius: 4,
px: 3,
py: 4,
textAlign: 'center',
}}
>
<Typography variant="h4" fontWeight={700} gutterBottom>
Bienvenido
</Typography>
<Typography variant="body1" color="text.secondary" sx={{mb: 3}}>
Regístrate o inicia sesión con tu cuenta preferida
</Typography>
<Box component="form" onSubmit={handleSubmit}>
<Stack spacing={2}>
{serverError ? <Alert severity="error">{serverError}</Alert> : null}
<TextField
label="Correo electrónico"
name="email"
type="email"
autoComplete="email"
value={values.email}
onChange={handleChange('email')}
required
fullWidth
/>
<TextField
label="Contraseña"
name="password"
type="password"
autoComplete="current-password"
value={values.password}
onChange={handleChange('password')}
required
fullWidth
/>
<Button
type="submit"
variant="contained"
size="large"
fullWidth
disabled={isSubmitting}
sx={{
py: 1.5,
borderRadius: 2.5,
textTransform: 'none',
fontSize: 16,
fontWeight: 700,
}}
>
{isSubmitting ? 'Iniciando sesión...' : 'Iniciar sesión'}
</Button>
<Typography variant="body2" color="text.secondary">
¿No tienes cuenta?{' '}
<Box
component={Link}
href="/register"
sx={{
color: 'primary.main',
textDecoration: 'none',
fontWeight: 600,
}}
>
Regístrate
</Box>
</Typography>
<Typography variant="body2" color="text.secondary">
---------- o -----------
</Typography>
</Stack>
</Box>
<Stack spacing={1.5} sx={{mt: 2}}>
<Button
type="button"
variant="outlined"
size="large"
fullWidth
onClick={handleGoogleLogin}
sx={{
py: 1.5,
borderRadius: 2.5,
textTransform: 'none',
fontSize: 16,
fontWeight: 600,
justifyContent: 'center',
}}
>
<Box display='flex' flexDirection='row' sx={{justifyContent: 'center'}}>
<GoogleIcon sx={{mr: 1}}/>
<Typography>Continuar con Google</Typography>
</Box>
</Button>
<Button
type="button"
variant="contained"
size="large"
fullWidth
onClick={handleAppleLogin}
sx={{
py: 1.5,
borderRadius: 2.5,
textTransform: 'none',
fontSize: 16,
fontWeight: 600,
bgcolor: '#111827',
'&:hover': {
bgcolor: '#000000',
},
justifyItems: 'center',
}}
>
<Box display='flex' flexDirection='row' sx={{justifyContent: 'center'}}>
<AppleIcon sx={{mr: 1}}/>
<Typography>Continuar con Apple</Typography>
</Box>
</Button>
</Stack>
</Paper>
</Box>
</GuestGuard>
);
};
export default Page;

View file

@ -1,65 +1,8 @@
import Image from "next/image";
import React from "react";
import HomePage from '@/app/home'
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
return (
<HomePage/>
);
}

635
app/picks/page.tsx Normal file
View file

@ -0,0 +1,635 @@
'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;

352
app/register/page.tsx Normal file
View file

@ -0,0 +1,352 @@
'use client';
import React, {ChangeEvent, SubmitEvent, useState} from 'react';
import Link from 'next/link';
import {useRouter} from 'next/navigation';
import {
Box,
Button,
InputAdornment,
Paper,
Stack,
TextField,
Typography,
Alert,
} from '@mui/material';
import {authClient} from '@/lib/api/auth';
import {ApiError, ApiValidationErrors} from '@/lib/api/client';
import {GuestGuard} from "@/src/auth/GuestGuard";
type FormValues = {
username: string;
firstName: string;
lastName: string;
email: string;
phone: string;
password: string;
confirmPassword: string;
};
type FormErrors = Partial<Record<keyof FormValues, string>>;
const initialValues: FormValues = {
username: '',
firstName: '',
lastName: '',
email: '',
phone: '',
password: '',
confirmPassword: '',
};
const validationFieldMap: Partial<Record<string, keyof FormValues>> = {
username: 'username',
first_name: 'firstName',
last_name: 'lastName',
email: 'email',
phone: 'phone',
password: 'password',
password_confirmation: 'confirmPassword',
};
const mapValidationErrors = (errors?: ApiValidationErrors): FormErrors => {
if (!errors) {
return {};
}
return Object.entries(errors).reduce<FormErrors>((accumulator, [key, messages]) => {
const field = validationFieldMap[key];
if (field && messages.length > 0) {
accumulator[field] = messages[0];
}
return accumulator;
}, {});
};
const Page: React.FC = () => {
const router = useRouter();
const [values, setValues] = useState<FormValues>(initialValues);
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [serverError, setServerError] = useState('');
const handleChange =
(field: keyof FormValues) => (event: ChangeEvent<HTMLInputElement>) => {
const nextValue =
field === 'phone'
? event.target.value.replace(/\D/g, '').slice(0, 10)
: event.target.value;
setValues((current) => ({
...current,
[field]: nextValue,
}));
setErrors((current) => {
if (!current[field]) {
return current;
}
const nextErrors = {...current};
delete nextErrors[field];
return nextErrors;
});
};
const validateForm = () => {
const nextErrors: FormErrors = {};
if (!values.username.trim()) {
nextErrors.username = 'El nombre de usuario es obligatorio.';
}
if (!values.firstName.trim()) {
nextErrors.firstName = 'El nombre es obligatorio.';
}
if (!values.lastName.trim()) {
nextErrors.lastName = 'El apellido es obligatorio.';
}
if (!values.email.trim()) {
nextErrors.email = 'El correo electrónico es obligatorio.';
} else if (!/\S+@\S+\.\S+/.test(values.email)) {
nextErrors.email = 'Ingresa un correo electrónico válido.';
}
if (!values.phone.trim()) {
nextErrors.phone = 'El número celular es obligatorio.';
} else if (!/^\d{10}$/.test(values.phone)) {
nextErrors.phone = 'Ingresa 10 dígitos numéricos.';
}
if (!values.password) {
nextErrors.password = 'La contraseña es obligatoria.';
} else if (values.password.length < 8) {
nextErrors.password = 'La contraseña debe tener al menos 8 caracteres.';
}
if (!values.confirmPassword) {
nextErrors.confirmPassword = 'Confirma tu contraseña.';
} else if (values.password !== values.confirmPassword) {
nextErrors.confirmPassword = 'Las contraseñas no coinciden.';
}
setErrors(nextErrors);
return Object.keys(nextErrors).length === 0;
};
const handleSubmit = (event: SubmitEvent<HTMLFormElement>) => {
event.preventDefault();
setServerError('');
if (!validateForm()) {
return;
}
setIsSubmitting(true);
authClient
.register({
username: values.username.trim(),
first_name: values.firstName.trim(),
last_name: values.lastName.trim(),
email: values.email.trim(),
phone: values.phone.trim(),
password: values.password,
password_confirmation: values.confirmPassword,
})
.then(() => router.push('/'))
.catch((error: unknown) => {
if (error instanceof ApiError) {
setErrors((current) => ({
...current,
...mapValidationErrors(error.errors),
}));
setServerError(error.message);
return;
}
setServerError('No fue posible crear la cuenta.');
})
.finally(() => {
setIsSubmitting(false);
});
};
return (
<GuestGuard>
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'grey.100',
p: 3,
}}
>
<Paper
elevation={4}
sx={{
width: '100%',
maxWidth: 520,
borderRadius: 4,
px: {xs: 3, sm: 4},
py: 4,
}}
>
<Typography variant="h4" fontWeight={700} gutterBottom>
Crea tu cuenta
</Typography>
<Typography variant="body1" color="text.secondary" sx={{mb: 3}}>
Llena todos los campos para crear tu cuenta
</Typography>
<Box component="form" noValidate onSubmit={handleSubmit}>
<Stack spacing={2}>
{serverError ? <Alert severity="error">{serverError}</Alert> : null}
<TextField
label="Apodo"
name="username"
value={values.username}
onChange={handleChange('username')}
error={Boolean(errors.username)}
helperText={errors.username}
required
fullWidth
/>
<Stack direction={{xs: 'column', sm: 'row'}} spacing={2}>
<TextField
label="Nombre(s)"
name="firstName"
value={values.firstName}
onChange={handleChange('firstName')}
error={Boolean(errors.firstName)}
helperText={errors.firstName}
required
fullWidth
/>
<TextField
label="Apellido(s)"
name="lastName"
value={values.lastName}
onChange={handleChange('lastName')}
error={Boolean(errors.lastName)}
helperText={errors.lastName}
required
fullWidth
/>
</Stack>
<TextField
label="Correo electrónico"
name="email"
type="email"
autoComplete="email"
value={values.email}
onChange={handleChange('email')}
error={Boolean(errors.email)}
helperText={errors.email}
required
fullWidth
/>
<TextField
label="Número celular"
name="phone"
type="tel"
autoComplete="tel-national"
value={values.phone}
onChange={handleChange('phone')}
error={Boolean(errors.phone)}
helperText={errors.phone ?? 'Captura 10 dígitos.'}
required
fullWidth
slotProps={{
htmlInput: {
inputMode: 'numeric',
pattern: '[0-9]*',
maxLength: 10,
},
input: {
startAdornment: (
<InputAdornment position="start">+52</InputAdornment>
),
},
}}
/>
<TextField
label="Contraseña"
name="password"
type="password"
autoComplete="new-password"
value={values.password}
onChange={handleChange('password')}
error={Boolean(errors.password)}
helperText={errors.password}
required
fullWidth
/>
<TextField
label="Confirmar contraseña"
name="confirmPassword"
type="password"
autoComplete="new-password"
value={values.confirmPassword}
onChange={handleChange('confirmPassword')}
error={Boolean(errors.confirmPassword)}
helperText={errors.confirmPassword}
required
fullWidth
/>
<Button
type="submit"
variant="contained"
size="large"
fullWidth
disabled={isSubmitting}
sx={{
mt: 1,
py: 1.5,
borderRadius: 2.5,
textTransform: 'none',
fontSize: 16,
fontWeight: 700,
}}
>
{isSubmitting ? 'Creando cuenta...' : 'Registrarme'}
</Button>
<Typography variant="body2" color="text.secondary" textAlign="center">
¿Ya tienes cuenta?{' '}
<Box
component={Link}
href="/login"
sx={{
color: 'primary.main',
textDecoration: 'none',
fontWeight: 600,
}}
>
Inicia sesión
</Box>
</Typography>
</Stack>
</Box>
</Paper>
</Box>
</GuestGuard>
);
};
export default Page;

69
lib/api/auth.ts Normal file
View file

@ -0,0 +1,69 @@
import {apiClient} from './client';
const AUTH_ENDPOINTS = {
login: process.env.NEXT_PUBLIC_AUTH_LOGIN_PATH ?? '/api/auth/login',
register: process.env.NEXT_PUBLIC_AUTH_REGISTER_PATH ?? '/api/auth/register',
logout: process.env.NEXT_PUBLIC_AUTH_LOGOUT_PATH ?? '/api/auth/logout',
user: process.env.NEXT_PUBLIC_AUTH_USER_PATH ?? '/api/auth/user',
};
export type AuthUser = {
id: number;
name?: string;
username?: string;
first_name?: string;
last_name?: string;
email: string;
phone?: string;
};
export type LoginPayload = {
email: string;
password: string;
remember?: boolean;
};
export type RegisterPayload = {
username: string;
first_name: string;
last_name: string;
email: string;
phone: string;
password: string;
password_confirmation: string;
};
export const authClient = {
async login(payload: LoginPayload) {
await apiClient.ensureCsrfCookie();
await apiClient.post(AUTH_ENDPOINTS.login, payload, {
skipAuthRedirect: true,
});
return apiClient.get<AuthUser>(AUTH_ENDPOINTS.user, {
skipAuthRedirect: true,
});
},
async register(payload: RegisterPayload) {
await apiClient.ensureCsrfCookie();
await apiClient.post(AUTH_ENDPOINTS.register, payload, {
skipAuthRedirect: true
});
return apiClient.get<AuthUser>(AUTH_ENDPOINTS.user, {
skipAuthRedirect: true
});
},
async logout() {
await apiClient.ensureCsrfCookie();
await apiClient.post<void>(AUTH_ENDPOINTS.logout);
},
async me() {
return apiClient.get<AuthUser>(AUTH_ENDPOINTS.user, {
skipAuthRedirect: true,
});
}
};

148
lib/api/client.ts Normal file
View file

@ -0,0 +1,148 @@
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
type RequestOptions = {
body?: unknown;
headers?: HeadersInit;
signal?: AbortSignal;
skipAuthRedirect?: boolean;
};
export type ApiValidationErrors = Record<string, string[]>;
export class ApiError extends Error {
status: number;
errors?: ApiValidationErrors;
constructor(message: string, status: number, errors?: ApiValidationErrors) {
super(message);
this.name = 'ApiError';
this.status = status;
this.errors = errors;
}
}
const buildUrl = (path: string) => {
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
return `${API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`;
};
const parseJsonSafely = async (response: Response) => {
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) {
return null;
}
return response.json();
};
const ensureCsrfCookie = async () => {
await fetch(`${API_BASE_URL}/sanctum/csrf-cookie`, {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
});
};
const getCookie = (name: string) => {
if (typeof document === 'undefined') {
return null;
}
const match = document.cookie
.split('; ')
.find((row) => row.startsWith(`${name}=`));
return match ? decodeURIComponent(match.split('=')[1]) : null;
};
const redirectToLogin = () => {
if (typeof window === 'undefined') {
return;
}
const pathname = window.location.pathname;
const search = window.location.search;
const isAuthPage = pathname === '/login' || pathname === '/register';
if (isAuthPage) {
return;
}
const next = encodeURIComponent(`${pathname}${search}`);
window.location.href = `/login?next=${next}`;
};
const request = async <T>(
method: HttpMethod,
path: string,
options: RequestOptions = {},
): Promise<T> => {
const headers = new Headers(options.headers);
const hasBody = options.body !== undefined;
headers.set('Accept', 'application/json');
headers.set('X-Requested-With', 'XMLHttpRequest');
if (hasBody) {
headers.set('Content-Type', 'application/json');
}
const xsrfToken = getCookie('XSRF-TOKEN');
if (xsrfToken) {
headers.set('X-XSRF-TOKEN', xsrfToken);
}
const response = await fetch(buildUrl(path), {
method,
headers,
body: hasBody ? JSON.stringify(options.body) : undefined,
signal: options.signal,
credentials: 'include',
});
if (!response.ok) {
const payload = await parseJsonSafely(response);
const message =
payload && typeof payload.message === 'string'
? payload.message
: 'La solicitud al servidor falló.';
if (response.status === 401 && !options.skipAuthRedirect) {
redirectToLogin();
}
throw new ApiError(message, response.status, payload?.errors);
}
if (response.status === 204) {
return undefined as T;
}
return (await parseJsonSafely(response)) as T;
};
export const apiClient = {
baseUrl: API_BASE_URL,
ensureCsrfCookie,
get: <T>(path: string, options?: Omit<RequestOptions, 'body'>) =>
request<T>('GET', path, options),
post: <T>(path: string, body?: unknown, options?: Omit<RequestOptions, 'body'>) =>
request<T>('POST', path, { ...options, body }),
put: <T>(path: string, body?: unknown, options?: Omit<RequestOptions, 'body'>) =>
request<T>('PUT', path, { ...options, body }),
patch: <T>(path: string, body?: unknown, options?: Omit<RequestOptions, 'body'>) =>
request<T>('PATCH', path, { ...options, body }),
delete: <T>(path: string, options?: Omit<RequestOptions, 'body'>) =>
request<T>('DELETE', path, options),
};

62
lib/api/picks.ts Normal file
View file

@ -0,0 +1,62 @@
import {apiClient} from "@/lib/api/client";
const PICKS_ENDPOINTS = {
getFixtures: process.env.NEXT_PUBLIC_PICKS_FIXTURES_PATH ?? '/api/fixtures',
getPicks: process.env.NEXT_PUBLIC_PICKS_PATH ?? '/api/picks',
savePick: process.env.NEXT_PUBLIC_PICKS_PATH ?? '/api/picks',
};
export type PickSelection = 'home' | 'draw' | 'away';
export type Fixture = {
id: number
home_team: string
away_team: string
stage: string
status: string
venue: string
starts_at: number
home_short_name: string
away_short_name: string
}
export type FixturesResponse = {
fixtures: Fixture[]
}
export type Pick = {
id: number
match_id: number
selection: PickSelection
points_awarded: number
submitted_at: string | null
graded_at: string | null
}
export type PicksResponse = {
picks: Pick[]
}
export type SavePickPayload = {
match_id: number
selection: PickSelection
}
export type SavePickResponse = {
pick: Pick
}
export const picksClient = {
async getFixtures(signal?: AbortSignal) {
return apiClient.get<FixturesResponse>(PICKS_ENDPOINTS.getFixtures, {signal})
},
async getPicks(signal?: AbortSignal) {
return apiClient.get<PicksResponse>(PICKS_ENDPOINTS.getPicks, {signal})
},
async savePick(payload: SavePickPayload) {
await apiClient.ensureCsrfCookie();
return apiClient.post<SavePickResponse>(PICKS_ENDPOINTS.savePick, payload);
}
}

View file

@ -2,6 +2,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
devIndicators: false
};
export default nextConfig;

6592
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -3,15 +3,25 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev --port 3001",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/roboto": "^5.2.10",
"@mui/icons-material": "^7.3.9",
"@mui/material": "^7.3.9",
"@mui/material-nextjs": "^7.3.9",
"add": "^2.0.6",
"flag-icons": "^7.5.0",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"yarn": "^1.22.22"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

40
src/auth/AuthGuard.tsx Normal file
View file

@ -0,0 +1,40 @@
'use client';
import { useEffect } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { useAuth } from '@/src/auth/AuthProvider';
import FullScreenLoader from "@/src/components/FullScreenLoader";
import AuthenticatedAppShell from "@/src/components/AuthenticatedAppShell";
type AuthGuardProps = {
children: React.ReactNode;
redirectTo?: string;
};
export function AuthGuard({
children,
redirectTo = '/login',
}: AuthGuardProps) {
const { isAuthenticated, isLoading } = useAuth();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
const next = pathname ? `?next=${encodeURIComponent(pathname)}` : '';
router.replace(`${redirectTo}${next}`);
}
}, [isAuthenticated, isLoading, redirectTo, router, pathname]);
if (isLoading) {
return (
<FullScreenLoader />
);
}
if (!isAuthenticated) {
return null;
}
return <AuthenticatedAppShell>{children}</AuthenticatedAppShell>;
}

100
src/auth/AuthProvider.tsx Normal file
View file

@ -0,0 +1,100 @@
'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import { authClient, type AuthUser, type LoginPayload, type RegisterPayload } from '@/lib/api/auth';
import { ApiError } from '@/lib/api/client';
type AuthContextValue = {
user: AuthUser | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (payload: LoginPayload) => Promise<AuthUser>;
register: (payload: RegisterPayload) => Promise<AuthUser | null>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const refreshUser = useCallback(async () => {
try {
const me = await authClient.me();
setUser(me);
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
setUser(null);
return;
}
setUser(null);
throw error;
}
}, []);
const login = useCallback(async (payload: LoginPayload) => {
const me = await authClient.login(payload);
setUser(me);
return me;
}, []);
const register = useCallback(async (payload: RegisterPayload) => {
const me = await authClient.register(payload);
setUser(me);
return me;
}, []);
const logout = useCallback(async () => {
try {
await authClient.logout();
} finally {
setUser(null);
}
}, []);
useEffect(() => {
refreshUser()
.catch(() => {
setUser(null);
})
.finally(() => {
setIsLoading(false);
});
}, [refreshUser]);
const value = useMemo<AuthContextValue>(
() => ({
user,
isAuthenticated: !!user,
isLoading,
login,
register,
logout,
refreshUser,
}),
[user, isLoading, login, register, logout, refreshUser],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

33
src/auth/GuestGuard.tsx Normal file
View file

@ -0,0 +1,33 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from './AuthProvider';
import FullScreenLoader from "@/src/components/FullScreenLoader";
export function GuestGuard({
children,
redirectTo = '/',
}: {
children: React.ReactNode;
redirectTo?: string;
}) {
const { isAuthenticated, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && isAuthenticated) {
router.replace(redirectTo);
}
}, [isAuthenticated, isLoading, redirectTo, router]);
if (isLoading) {
return <FullScreenLoader />;
}
if (isAuthenticated) {
return null;
}
return <>{children}</>;
}

View file

@ -0,0 +1,250 @@
'use client';
import * as React from 'react';
import Link from 'next/link';
import {usePathname} from 'next/navigation';
import AppBar from '@mui/material/AppBar';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Container from '@mui/material/Container';
import IconButton from '@mui/material/IconButton';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Toolbar from '@mui/material/Toolbar';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import MenuIcon from '@mui/icons-material/Menu';
import SportsSoccerRoundedIcon from '@mui/icons-material/SportsSoccerRounded';
import {useAuth} from '@/src/auth/AuthProvider';
const pages = [
{label: 'Inicio', href: '/'},
{label: 'Partidos', href: '/picks'},
];
const getDisplayName = (user: {
first_name?: string;
last_name?: string;
username?: string;
email?: string;
}) => {
if (user.username) {
return user.username;
}
if (user.first_name && user.last_name) {
return `${user.first_name} ${user.last_name}`;
}
if (user.first_name) {
return user.first_name;
}
return user.email ?? 'Usuario';
};
const getInitials = (value: string) => {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
}
return value.slice(0, 2).toUpperCase();
};
export default function AuthenticatedAppShell({children}: {children: React.ReactNode}) {
const pathname = usePathname();
const {logout, user} = useAuth();
const [anchorElNav, setAnchorElNav] = React.useState<null | HTMLElement>(null);
const [anchorElUser, setAnchorElUser] = React.useState<null | HTMLElement>(null);
const [isLoggingOut, setIsLoggingOut] = React.useState(false);
const displayName = getDisplayName(user ?? {});
const userInitials = getInitials(displayName);
const handleOpenNavMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorElNav(event.currentTarget);
};
const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorElUser(event.currentTarget);
};
const handleCloseNavMenu = () => {
setAnchorElNav(null);
};
const handleCloseUserMenu = () => {
setAnchorElUser(null);
};
const handleLogoutClick = async () => {
handleCloseUserMenu();
setIsLoggingOut(true);
try {
await logout();
} finally {
setIsLoggingOut(false);
}
};
return (
<>
<AppBar
position="sticky"
elevation={0}
sx={{
borderBottom: '1px solid',
borderColor: 'divider',
backgroundColor: 'rgba(255, 255, 255, 0.94)',
color: 'text.primary',
backdropFilter: 'blur(10px)',
}}
>
<Container maxWidth="lg">
<Toolbar disableGutters>
<SportsSoccerRoundedIcon sx={{display: {xs: 'none', md: 'flex'}, mr: 1}} />
<Typography
variant="h6"
noWrap
component={Link}
href="/"
sx={{
mr: 3,
display: {xs: 'none', md: 'flex'},
fontWeight: 700,
letterSpacing: '.08rem',
color: 'inherit',
textDecoration: 'none',
}}
>
QUINIELA
</Typography>
<Box sx={{flexGrow: 1, display: {xs: 'flex', md: 'none'}}}>
<IconButton
size="large"
aria-label="abrir navegación"
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={handleOpenNavMenu}
color="inherit"
>
<MenuIcon />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={anchorElNav}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
open={Boolean(anchorElNav)}
onClose={handleCloseNavMenu}
sx={{display: {xs: 'block', md: 'none'}}}
>
{pages.map((page) => (
<MenuItem
key={page.href}
component={Link}
href={page.href}
selected={pathname === page.href}
onClick={handleCloseNavMenu}
>
<Typography sx={{textAlign: 'center'}}>
{page.label}
</Typography>
</MenuItem>
))}
</Menu>
</Box>
<SportsSoccerRoundedIcon sx={{display: {xs: 'flex', md: 'none'}, mr: 1}} />
<Typography
variant="h6"
noWrap
component={Link}
href="/"
sx={{
mr: 2,
display: {xs: 'flex', md: 'none'},
flexGrow: 1,
fontWeight: 700,
letterSpacing: '.08rem',
color: 'inherit',
textDecoration: 'none',
}}
>
QUINIELA
</Typography>
<Box sx={{flexGrow: 1, display: {xs: 'none', md: 'flex'}}}>
{pages.map((page) => (
<Button
key={page.href}
component={Link}
href={page.href}
onClick={handleCloseNavMenu}
sx={{
my: 2,
color: pathname === page.href ? 'primary.main' : 'text.primary',
display: 'block',
fontWeight: pathname === page.href ? 700 : 500,
}}
>
{page.label}
</Button>
))}
</Box>
<Box sx={{flexGrow: 0}}>
<Tooltip title={displayName}>
<IconButton onClick={handleOpenUserMenu} sx={{p: 0}}>
<Avatar sx={{bgcolor: 'primary.main', width: 36, height: 36}}>
{userInitials}
</Avatar>
</IconButton>
</Tooltip>
<Menu
sx={{mt: '45px'}}
id="menu-appbar-user"
anchorEl={anchorElUser}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={Boolean(anchorElUser)}
onClose={handleCloseUserMenu}
>
<MenuItem disabled>
<Typography sx={{textAlign: 'center'}}>
{displayName}
</Typography>
</MenuItem>
<MenuItem onClick={handleLogoutClick} disabled={isLoggingOut}>
<Typography sx={{textAlign: 'center'}}>
{isLoggingOut ? 'Saliendo...' : 'Salir'}
</Typography>
</MenuItem>
</Menu>
</Box>
</Toolbar>
</Container>
</AppBar>
<Box>{children}</Box>
</>
);
}

View file

@ -0,0 +1,22 @@
'use client';
import { Box, CircularProgress } from "@mui/material";
import React from "react";
const FullScreenLoader: React.FC = () => {
return (
<Box
sx={{
minHeight: '100vh',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<CircularProgress />
</Box>
)
}
export default FullScreenLoader;

12
src/theme.ts Normal file
View file

@ -0,0 +1,12 @@
'use client';
import { createTheme } from '@mui/material/styles'
const theme = createTheme({
cssVariables: true,
typography: {
fontFamily: 'var(--font-roboto)',
}
});
export default theme

3584
yarn.lock Normal file

File diff suppressed because it is too large Load diff