Add auth guard, navigation and picks page
This commit is contained in:
parent
1f621ec8e6
commit
853b2283ec
22 changed files with 5644 additions and 6708 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -39,3 +39,5 @@ yarn-error.log*
|
|||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
.idea
|
||||
|
|
|
|||
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal 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/
|
||||
|
|
@ -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
47
app/home/index.tsx
Normal 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;
|
||||
|
|
@ -1,33 +1,46 @@
|
|||
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 const viewport: Viewport = {
|
||||
initialScale: 1,
|
||||
width: 'device-width',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<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
219
app/login/page.tsx
Normal 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;
|
||||
63
app/page.tsx
63
app/page.tsx
|
|
@ -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>
|
||||
<HomePage/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
635
app/picks/page.tsx
Normal file
635
app/picks/page.tsx
Normal 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
352
app/register/page.tsx
Normal 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
69
lib/api/auth.ts
Normal 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
148
lib/api/client.ts
Normal 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
62
lib/api/picks.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
6592
package-lock.json
generated
File diff suppressed because it is too large
Load diff
14
package.json
14
package.json
|
|
@ -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
40
src/auth/AuthGuard.tsx
Normal 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
100
src/auth/AuthProvider.tsx
Normal 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
33
src/auth/GuestGuard.tsx
Normal 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}</>;
|
||||
}
|
||||
250
src/components/AuthenticatedAppShell.tsx
Normal file
250
src/components/AuthenticatedAppShell.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
22
src/components/FullScreenLoader.tsx
Normal file
22
src/components/FullScreenLoader.tsx
Normal 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
12
src/theme.ts
Normal 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
|
||||
Loading…
Add table
Reference in a new issue