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
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
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";
|
@import "tailwindcss";
|
||||||
|
@import "flag-icons/css/flag-icons.min.css";
|
||||||
: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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
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,34 +1,47 @@
|
||||||
import type { Metadata } from "next";
|
import type {Metadata, Viewport} from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
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 "./globals.css";
|
||||||
|
import React from "react";
|
||||||
|
import {CssBaseline} from "@mui/material";
|
||||||
|
import {AuthProvider} from "@/src/auth/AuthProvider";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const roboto = Roboto({
|
||||||
variable: "--font-geist-sans",
|
weight: ['300', '400', '500', '700'],
|
||||||
subsets: ["latin"],
|
subsets: ['latin'],
|
||||||
});
|
display: 'swap',
|
||||||
|
variable: '--font-roboto',
|
||||||
const geistMono = Geist_Mono({
|
})
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Quiniela Sembradores",
|
||||||
description: "Generated by create next app",
|
description: "Quiniela Sembradores - Mundial 2026",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export const viewport: Viewport = {
|
||||||
children,
|
initialScale: 1,
|
||||||
}: Readonly<{
|
width: 'device-width',
|
||||||
children: React.ReactNode;
|
}
|
||||||
}>) {
|
|
||||||
return (
|
export default function RootLayout({
|
||||||
<html lang="en">
|
children,
|
||||||
<body
|
}: Readonly<{
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
children: React.ReactNode;
|
||||||
>
|
}>) {
|
||||||
{children}
|
return (
|
||||||
</body>
|
<html lang="es-MX" className={roboto.variable}>
|
||||||
</html>
|
<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;
|
||||||
67
app/page.tsx
67
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() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<HomePage/>
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
|
devIndicators: false
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
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",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev --port 3001",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"yarn": "^1.22.22"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@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