diff --git a/apps/frontend/src/assets/icons/football.tsx b/apps/frontend/src/assets/icons/football.tsx index f171b39..7c69bcd 100644 --- a/apps/frontend/src/assets/icons/football.tsx +++ b/apps/frontend/src/assets/icons/football.tsx @@ -14,6 +14,8 @@ const SoccerBallIcon = forwardRef(({ className, ...props }, re strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" + aria-hidden="true" + focusable="false" className={className} {...props} > @@ -29,4 +31,4 @@ const SoccerBallIcon = forwardRef(({ className, ...props }, re )); -export { SoccerBallIcon as FootballIcon }; \ No newline at end of file +export { SoccerBallIcon as FootballIcon }; diff --git a/apps/frontend/src/components/ui/button.tsx b/apps/frontend/src/components/ui/button.tsx index 008b34b..c085c76 100644 --- a/apps/frontend/src/components/ui/button.tsx +++ b/apps/frontend/src/components/ui/button.tsx @@ -5,13 +5,13 @@ import * as React from 'react'; import { cn } from '@/lib/utils'; const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { - default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', + default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90', outline: - 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', + 'border-border bg-card shadow-sm hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/20 dark:hover:bg-input/30', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground', ghost: @@ -22,16 +22,15 @@ const buttonVariants = cva( }, size: { default: - 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', - xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', - icon: 'size-8', + 'h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5', + xs: "h-6 gap-1 rounded-lg px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-8 gap-1.5 rounded-lg px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3.5", + lg: 'h-10 gap-1.5 rounded-xl px-3.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3', + icon: 'size-9 rounded-xl', 'icon-xs': - "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", - 'icon-sm': - 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg', - 'icon-lg': 'size-9', + "size-6 rounded-lg in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + 'icon-sm': 'size-8 rounded-lg in-data-[slot=button-group]:rounded-lg', + 'icon-lg': 'size-10 rounded-xl', }, }, defaultVariants: { diff --git a/apps/frontend/src/components/ui/input.tsx b/apps/frontend/src/components/ui/input.tsx index c1f9dcb..6b1a5c1 100644 --- a/apps/frontend/src/components/ui/input.tsx +++ b/apps/frontend/src/components/ui/input.tsx @@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) { type={type} data-slot="input" className={cn( - 'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40', + 'h-10 w-full min-w-0 rounded-xl border border-input bg-card px-3 py-2 text-base shadow-[0_1px_0_rgba(15,23,42,0.02)] transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/20 dark:disabled:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40', className )} {...props} diff --git a/apps/frontend/src/components/ui/select.tsx b/apps/frontend/src/components/ui/select.tsx index 41f8c37..5c1f6b3 100644 --- a/apps/frontend/src/components/ui/select.tsx +++ b/apps/frontend/src/components/ui/select.tsx @@ -36,6 +36,7 @@ function SelectTrigger({ data-size={size} className={cn( "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "flex w-fit items-center justify-between gap-1.5 rounded-xl border border-input bg-card py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-10 data-[size=sm]:h-9 data-[size=sm]:rounded-lg *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/20 dark:hover:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} {...props} @@ -61,7 +62,7 @@ function SelectContent({ data-slot="select-content" data-align-trigger={position === 'item-aligned'} className={cn( - 'relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95', + 'relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-xl bg-popover text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95', position === 'popper' && 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', className @@ -105,7 +106,7 @@ function SelectItem({ -
-

- - Configuración del complejo -

-

- {complexQuery.data?.complexName ?? 'Configura los datos de tu complejo'} -

+
+
+
+
+
+ + Administración del complejo +
+

+ + Configuración del complejo +

+

+ {complexQuery.data?.complexName ?? + 'Configurá los datos del complejo, las canchas y los usuarios.'} +

+
+ + {currentMembership?.role && ( +
+ + Tu rol + + + {currentMembership.role} + +
+ )} +
-
-
+ +
+
+ + Mostrar desde + { + const isoDate = date ? toIsoDateLocal(date) : todayIso; + setFromDate(isoDate); + }} + disabled={(date) => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const checkDate = new Date(date); + checkDate.setHours(0, 0, 0, 0); + return checkDate < today; + }} + placeholder="Selecciona una fecha" + /> + +
+ + {bookingsQuery.isLoading && ( +

Cargando reservas...

+ )} + + {bookingsQuery.isError && ( +

+ {extractMessage(bookingsQuery.error, 'No pudimos cargar las reservas.')} +

+ )} + + {!bookingsQuery.isLoading && !bookingsQuery.isError && groupedBookings.length === 0 && ( +

+ No hay reservas para la fecha seleccionada en adelante. +

+ )} + +
+ {groupedBookings.map(([date, bookings]) => ( +
+
+

+ {formatDateLabel(date, todayIso)} +

+ + {bookings.length} reservas + +
+
+ {bookings.map((booking) => { + const effectiveStatus = getEffectiveStatus(booking); + const canManage = + booking.status === 'CONFIRMED' && effectiveStatus === 'CONFIRMED'; + const isMutating = updateStatusMutation.isPending; + + return ( +
+
+
+

+ {booking.startTime} - {booking.endTime} · {booking.courtName} +

+ +
+

+ {booking.customerName} ({booking.customerPhone}) · Código{' '} + {booking.bookingCode} +

+
+ +
+ {canManage && ( + <> + + + + )} +
+
+ ); + })} +
+
+ ))} +
+ + {updateStatusMutation.isError && ( +

+ {extractMessage(updateStatusMutation.error, 'No pudimos actualizar la reserva.')} +

+ )} +
+ + + + ); } diff --git a/apps/frontend/src/features/layout/root-layout.tsx b/apps/frontend/src/features/layout/root-layout.tsx index 7bd98ec..b1ffb2b 100644 --- a/apps/frontend/src/features/layout/root-layout.tsx +++ b/apps/frontend/src/features/layout/root-layout.tsx @@ -1,3 +1,4 @@ +import { FootballIcon } from '@/assets/icons/football'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -66,59 +67,194 @@ export function RootLayout() { await navigate({ to: '/login' }); }; + type NavigationItem = { + label: string; + to: '/' | '/profile' | '/complex/$slug/edit'; + show: boolean; + params?: { slug: string }; + }; + + const navigationItems: NavigationItem[] = [ + { + label: 'Inicio', + to: '/', + show: true, + }, + { + label: 'Configuración', + to: '/complex/$slug/edit', + params: currentComplexSlug ? { slug: currentComplexSlug } : undefined, + show: isAuthenticated && Boolean(currentComplexSlug), + }, + { + label: 'Perfil', + to: '/profile', + show: isAuthenticated, + }, + ] as const; + return (
-
-
-

{currentComplexName}

- -
-
- + {isAuthenticated && ( + + + + + + Mi cuenta + + {myComplexesQuery.data && myComplexesQuery.data.length > 1 && ( + <> + + Cambiar complejo + + {myComplexesQuery.data.map((complex) => ( + { + await apiClient.complexes.select({ complexId: complex.id }); + setCurrentComplex(complex.complexSlug); + }} + > + {complex.complexSlug === complexSlug && } + {complex.complexName} + + ))} + + + )} + { + void navigate({ to: '/profile' }); + }} + > + + Perfil + + { + void handleSignOut(); + }} + > + + Cerrar sesión + + + + )} - {!isAuthenticated && ( - - )} - - {isAuthenticated && ( - + + - - Mi cuenta + + {currentComplexName} + + {navigationItems + .filter((item) => item.show) + .map((item) => + item.params ? ( + { + void navigate({ to: item.to, params: item.params }); + }} + > + {item.label} + + ) : ( + { + void navigate({ to: item.to }); + }} + > + {item.label} + + ) + )} {myComplexesQuery.data && myComplexesQuery.data.length > 1 && ( <> @@ -140,103 +276,41 @@ export function RootLayout() { )} - { - void navigate({ to: '/profile' }); - }} - > - - Profile - - { - void handleSignOut(); - }} - > - - Sign out - + {isAuthenticated ? ( + <> + { + void navigate({ to: '/profile' }); + }} + > + + Perfil + + { + void handleSignOut(); + }} + > + + Cerrar sesión + + + ) : ( + { + void navigate({ to: '/login' }); + }} + > + Ingresar + + )} - )} - - - - - - - Navegación - - { - void navigate({ to: '/' }); - }} - > - Home - - { - void navigate({ to: '/about' }); - }} - > - About - - {isAuthenticated && currentComplexSlug && ( - { - void navigate({ - to: '/complex/$slug/edit', - params: { slug: currentComplexSlug }, - }); - }} - > - Configuración - - )} - - - {isAuthenticated ? ( - <> - { - void navigate({ to: '/profile' }); - }} - > - - Profile - - { - void handleSignOut(); - }} - > - - Sign out - - - ) : ( - { - void navigate({ to: '/login' }); - }} - > - Login - - )} - - +
+
diff --git a/apps/frontend/src/features/layout/theme-switcher.tsx b/apps/frontend/src/features/layout/theme-switcher.tsx index 031385e..ecdd0e1 100644 --- a/apps/frontend/src/features/layout/theme-switcher.tsx +++ b/apps/frontend/src/features/layout/theme-switcher.tsx @@ -30,15 +30,15 @@ export function ThemeSwitcher() { > - Light + Claro - Dark + Oscuro - System + Sistema diff --git a/apps/frontend/src/features/login/login-page.tsx b/apps/frontend/src/features/login/login-page.tsx index f2089e2..d531d50 100644 --- a/apps/frontend/src/features/login/login-page.tsx +++ b/apps/frontend/src/features/login/login-page.tsx @@ -78,82 +78,139 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) { }; return ( -
-
-

Iniciar sesión

-

- Ingresá con tu cuenta para acceder a Home. -

+
+
+
+
+
+
+ + Gestión de reservas y complejos +
- - -
-
- +
+

+ Playzer +

+

+ Un panel para operar el club con velocidad, claridad y presencia. +

+

+ Accedé a reservas, configuración y usuarios desde una interfaz más limpia, pensada + para trabajar rápido en escritorio y también en móvil. +

+
-
- O + +
+ {[ + 'Disponibilidad y reservas en un vistazo', + 'Cambios de complejo sin perder contexto', + 'Estados y acciones con feedback claro', + ].map((item) => ( +
+ {item} +
+ ))}
-
+
-
- - Email - - - +
+
+
+

+ Acceso +

+

Ingresá a tu cuenta

+

+ Entrá con Google o con tu email y contraseña para continuar con tu complejo. +

+
+
+

Estado

+

Acceso seguro

+
+
- - Contraseña - - - - - {submitError &&

{submitError}

} - - - -

- - ¿Olvidaste tu contraseña? - -

-
+
+
+ +
+
+ O continuá con email +
+
+ +
+ + Email + + + + + + Contraseña + + + + + {submitError &&

{submitError}

} + + +
+ +
+

¿Olvidaste tu contraseña?

+ + Recuperarla + +
+
+
); } diff --git a/apps/frontend/src/features/onboard/components/onboarding-check-email-step.tsx b/apps/frontend/src/features/onboard/components/onboarding-check-email-step.tsx index 4ada26b..7e941c4 100644 --- a/apps/frontend/src/features/onboard/components/onboarding-check-email-step.tsx +++ b/apps/frontend/src/features/onboard/components/onboarding-check-email-step.tsx @@ -1,7 +1,7 @@ export function OnboardingCheckEmailStep() { return (

- Revisa tu correo y abre el link de verificacion para continuar. + Revisa tu correo y abrí el link de verificación para continuar.

); } diff --git a/apps/frontend/src/features/onboard/components/onboarding-layout.tsx b/apps/frontend/src/features/onboard/components/onboarding-layout.tsx index 9a7801f..b333708 100644 --- a/apps/frontend/src/features/onboard/components/onboarding-layout.tsx +++ b/apps/frontend/src/features/onboard/components/onboarding-layout.tsx @@ -7,12 +7,55 @@ type OnboardingLayoutProps = PropsWithChildren<{ export function OnboardingLayout({ title, description, children }: OnboardingLayoutProps) { return ( -
-
-

{title}

-

{description}

- {children} -
+
+
+ + +
+
+

+ Inicio +

+

{title}

+

{description}

+
+ {children} +
+
); } diff --git a/apps/frontend/src/features/onboard/components/onboarding-start-step.tsx b/apps/frontend/src/features/onboard/components/onboarding-start-step.tsx index f8f980c..7939426 100644 --- a/apps/frontend/src/features/onboard/components/onboarding-start-step.tsx +++ b/apps/frontend/src/features/onboard/components/onboarding-start-step.tsx @@ -18,7 +18,7 @@ export function OnboardingStartStep({ onSubmit, }: OnboardingStartStepProps) { return ( -
+ Nombre - {form.formState.isSubmitting ? 'Enviando...' : 'Enviar codigo OTP'} + {form.formState.isSubmitting ? 'Enviando...' : 'Enviar código OTP'} ); diff --git a/apps/frontend/src/features/onboard/components/onboarding-verify-otp-step.tsx b/apps/frontend/src/features/onboard/components/onboarding-verify-otp-step.tsx index 05a3a2f..3adb116 100644 --- a/apps/frontend/src/features/onboard/components/onboarding-verify-otp-step.tsx +++ b/apps/frontend/src/features/onboard/components/onboarding-verify-otp-step.tsx @@ -38,13 +38,13 @@ export function OnboardingVerifyOtpStep({ return (

- Ingresa el codigo de 6 digitos que enviamos a{' '} + Ingresa el código de 6 dígitos que enviamos a{' '} {email ?? 'tu email'}.

- Codigo de verificacion + Código de verificación
@@ -101,7 +101,7 @@ export function OnboardingVerifyOtpStep({ className="w-full" disabled={form.formState.isSubmitting || !form.formState.isValid} > - {form.formState.isSubmitting ? 'Verificando...' : 'Verificar codigo'} + {form.formState.isSubmitting ? 'Verificando...' : 'Verificar código'} ); diff --git a/apps/frontend/src/features/profile/profile-page.tsx b/apps/frontend/src/features/profile/profile-page.tsx index 4bbbf1d..80af1ca 100644 --- a/apps/frontend/src/features/profile/profile-page.tsx +++ b/apps/frontend/src/features/profile/profile-page.tsx @@ -15,6 +15,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import type { UserProfileResponse } from '@repo/api-contract'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; +import { Mail, ShieldCheck } from 'lucide-react'; import { useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -100,13 +101,16 @@ export function ProfilePage() { if (!isAuthenticated) { return ( -
-
-

Perfil

-

- Necesitás iniciar sesión para ver tu perfil. +

+
+

+ Perfil

-
@@ -115,130 +119,166 @@ export function ProfilePage() { } return ( -
-
-
-
- -
-

{displayName}

-

- {profileQuery.data?.email ?? user?.email} +

+
+
+
+
+
+ +
+

+ Perfil +

+

{displayName}

+
+ + + {profileQuery.data?.email ?? user?.email} + + {profileQuery.data?.role && ( + + + Rol {profileQuery.data.role} + + )} +
+
+
+ + {profileQuery.isLoading && ( +

Cargando perfil...

+ )} + + {profileQuery.isError && ( +

+ No se pudo cargar el perfil desde el backend.

- {profileQuery.data?.role && ( -

Rol: {profileQuery.data.role}

- )} + )} + +
+
+

Cuenta

+

Actualizá datos personales y acceso.

+
+
+

+ Seguridad +

+

Cambiá tu contraseña cuando quieras.

+
- - {profileQuery.isLoading && ( -

Cargando perfil...

- )} - - {profileQuery.isError && ( -

- No se pudo cargar el perfil desde el backend. -

- )}
-
-

Editar Perfil

-
- - ( - - Nombre completo - - - - - - )} - /> - - - - {updateProfileMutation.isError && ( -

- Error al actualizar el perfil: {updateProfileMutation.error?.message} +

+
+

Editar perfil

+

+ Mantené tu nombre visible y consistente para el resto del equipo.

- )} - {updateProfileMutation.isSuccess && ( -

Perfil actualizado exitosamente.

- )} -
+
+ + ( + + Nombre completo + + + + + + )} + /> + + + + {updateProfileMutation.isError && ( +

+ Error al actualizar el perfil: {updateProfileMutation.error?.message} +

+ )} + {updateProfileMutation.isSuccess && ( +

Perfil actualizado exitosamente.

+ )} +
-
-

Cambiar Contraseña

-
- - ( - - Contraseña actual - - - - - - )} - /> - ( - - Nueva contraseña - - - - - - )} - /> - ( - - Confirmar nueva contraseña - - - - - - )} - /> - - - - {updatePasswordMutation.isError && ( -

- Error al cambiar la contraseña: {updatePasswordMutation.error?.message} +

+

Cambiar contraseña

+

+ Usá una contraseña fuerte para proteger el acceso al panel.

- )} - {updatePasswordMutation.isSuccess && ( -

Contraseña cambiada exitosamente.

- )} -
+
+ + ( + + Contraseña actual + + + + + + )} + /> + ( + + Nueva contraseña + + + + + + )} + /> + ( + + Confirmar nueva contraseña + + + + + + )} + /> + + + + {updatePasswordMutation.isError && ( +

+ Error al cambiar la contraseña: {updatePasswordMutation.error?.message} +

+ )} + {updatePasswordMutation.isSuccess && ( +

Contraseña cambiada exitosamente.

+ )} +
+
); diff --git a/apps/frontend/src/features/public-booking/public-booking-page.tsx b/apps/frontend/src/features/public-booking/public-booking-page.tsx index 1b63865..e14e14a 100644 --- a/apps/frontend/src/features/public-booking/public-booking-page.tsx +++ b/apps/frontend/src/features/public-booking/public-booking-page.tsx @@ -3,13 +3,22 @@ import { Field, FieldError, FieldLabel } from '@/components/ui/field'; import { Input } from '@/components/ui/input'; import { ApiClientError, apiClient } from '@/lib/api-client'; import { zodResolver } from '@hookform/resolvers/zod'; +import { + BarbellIcon, + BasketballIcon, + PersonSimpleRunIcon, + PingPongIcon, + RacquetIcon, + SoccerBallIcon, + TennisBallIcon, + VolleyballIcon, +} from '@phosphor-icons/react'; import { useMutation, useQuery } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import { Dumbbell } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; -import {SoccerBallIcon, RacquetIcon, TennisBallIcon,BasketballIcon, VolleyballIcon, BarbellIcon, PersonSimpleRunIcon, PingPongIcon} from '@phosphor-icons/react' const sportConfig: Record< string, @@ -154,6 +163,18 @@ function extractMessage(error: unknown, fallback: string) { return fallback; } +function formatSelectedDateLabel(dateIso: string) { + const [year, month, day] = dateIso.split('-').map(Number); + if (!year || !month || !day) return dateIso; + + const date = new Date(year, month - 1, day); + return new Intl.DateTimeFormat('es-AR', { + weekday: 'long', + day: '2-digit', + month: 'long', + }).format(date); +} + export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) { const navigate = useNavigate(); const today = useMemo(() => new Date(), []); @@ -289,6 +310,13 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) { .filter((court) => court.availableSlots.length > 0); }, [availabilityQuery.data?.courts, selectedDate, todayIsoDate]); + const selectedSport = useMemo(() => { + return availabilityQuery.data?.sports.find((sport) => sport.id === selectedSportId) ?? null; + }, [availabilityQuery.data?.sports, selectedSportId]); + + const selectedSportLabel = selectedSport ? getSportConfig(selectedSport.slug).label : null; + const selectedDateLabel = useMemo(() => formatSelectedDateLabel(selectedDate), [selectedDate]); + useEffect(() => { if (!selectedSlot) return; @@ -403,249 +431,362 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) { }; return ( -
-
-
-
-

- {availabilityQuery.data?.complexName} -

- {availabilityQuery.data?.complexAddress && ( -

- {availabilityQuery.data.complexAddress} +

+
+
+
+
+
+ + PZ + + Reserva oficial +
+
+

+ {availabilityQuery.data?.complexSlug ?? complexSlug} +

+

+ {availabilityQuery.data?.complexName ?? 'Cargando complejo...'} +

+ {availabilityQuery.data?.complexAddress && ( +

+ {availabilityQuery.data.complexAddress} +

+ )} +
+

+ Elegí un día, encontrá el mejor horario disponible y confirmá en pocos pasos. La + pantalla prioriza disponibilidad, contexto y una lectura rápida desde el móvil.

- )} +
+ +
+
+

+ Fecha elegida +

+

+ {selectedDateLabel} +

+
+
+

Deporte

+

+ {selectedSportLabel ?? 'Sin selección'} +

+
+
+

+ Canchas visibles +

+

+ {visibleCourts.length} disponibles +

+
+
+

Estado

+

+ {selectedSlot ? 'Turno listo para confirmar' : 'Elegí un horario'} +

+
+
-

- Elige un dia, revisa horarios disponibles y confirma con tu nombre y telefono. -

-
-
-
-

Dia

-

Mostramos 5 dias por vez.

-
- {autoAdjustedDateNotice && ( -

- {autoAdjustedDateNotice} -

- )} +
+
+
+
+

+ Día +

+

Mostramos 5 días por vez.

+
+ + {autoAdjustedDateNotice && ( +

+ {autoAdjustedDateNotice} +

+ )} + +
+ {canGoBack ? ( + + ) : ( +
+ )} + + {dayOptions.map((option) => ( + + ))} -
- {canGoBack ? ( - ) : ( -
+
+
+ +
+
+

+ Disponibilidad +

+

Seleccioná cancha y horario.

+
+ + {availabilityQuery.data?.sportSelectionRequired && ( +
+

Elegí tu deporte

+
+ {availabilityQuery.data?.sports.map((sport) => { + const config = getSportConfig(sport.slug); + const Icon = config.icon; + const isSelected = selectedSportId === sport.id; + + return ( + + ); + })} +
+
)} - {dayOptions.map((option) => ( - - ))} - - -
-
-
- -
-
-
-

Disponibilidad

-

Selecciona cancha y horario.

-
-
- - {availabilityQuery.data?.sportSelectionRequired && ( -
-

Elegí tu deporte

-
- {availabilityQuery.data?.sports.map((sport) => { - const config = getSportConfig(sport.slug); - const Icon = config.icon; - const isSelected = selectedSportId === sport.id; - - return ( - - ); - })} -
-
- )} - - {availabilityQuery.isLoading && ( -

Buscando disponibilidad...

- )} - - {availabilityQuery.isError && ( -

- {extractMessage(availabilityQuery.error, 'No pudimos cargar la disponibilidad.')} -

- )} - - {!availabilityQuery.isLoading && - !availabilityQuery.isError && - availabilityQuery.data?.sportSelectionRequired && - !selectedSportId && ( -

- Selecciona un deporte para ver los horarios disponibles. -

- )} - - {!availabilityQuery.isLoading && - !availabilityQuery.isError && - availabilityQuery.data && - (!availabilityQuery.data.sportSelectionRequired || selectedSportId) && - visibleCourts.length === 0 && ( -

- No hay horarios disponibles para la fecha elegida. -

- )} - -
- {visibleCourts.map((court) => ( -
-
-

{court.courtName}

-

- {court.sport.name} · Turnos de {court.slotDurationMinutes} min -

-
- -
- {court.availableSlots.map((slot) => { - const isSelected = - selectedSlot?.courtId === court.courtId && - selectedSlot.startTime === slot.startTime; - - return ( - - ); - })} -
-
- ))} -
-
- - {selectedSlot && ( -
-

Confirmar turno

-

- {selectedSlot.courtName} · {selectedSlot.sportName} · {selectedSlot.startTime} -{' '} - {selectedSlot.endTime} -

- -
- - Nombre - - - - - - Telefono - - - - - {createBookingMutation.isError && ( -

- {extractMessage(createBookingMutation.error, 'No pudimos confirmar el turno.')} + {availabilityQuery.isLoading && ( +

+ Buscando disponibilidad...

)} - {navigationError &&

{navigationError}

} - -
+ {availabilityQuery.isError && ( +

+ {extractMessage(availabilityQuery.error, 'No pudimos cargar la disponibilidad.')} +

+ )} + + {!availabilityQuery.isLoading && + !availabilityQuery.isError && + availabilityQuery.data?.sportSelectionRequired && + !selectedSportId && ( +

+ Seleccioná un deporte para ver los horarios disponibles. +

+ )} + + {!availabilityQuery.isLoading && + !availabilityQuery.isError && + availabilityQuery.data && + (!availabilityQuery.data.sportSelectionRequired || selectedSportId) && + visibleCourts.length === 0 && ( +

+ No hay horarios disponibles para la fecha elegida. +

+ )} + +
+ {visibleCourts.map((court) => ( +
+
+
+

+ {court.courtName} +

+

+ {court.sport.name} · Turnos de {court.slotDurationMinutes} min +

+
+ + {court.availableSlots.length} horarios + +
+ +
+ {court.availableSlots.map((slot) => { + const isSelected = + selectedSlot?.courtId === court.courtId && + selectedSlot.startTime === slot.startTime; + + return ( + + ); + })} +
+
+ ))} +
+
- )} + + +
); diff --git a/apps/frontend/src/features/select-complex/select-complex-page.tsx b/apps/frontend/src/features/select-complex/select-complex-page.tsx index 70ac0da..4c8b3a9 100644 --- a/apps/frontend/src/features/select-complex/select-complex-page.tsx +++ b/apps/frontend/src/features/select-complex/select-complex-page.tsx @@ -3,7 +3,7 @@ import { apiClient } from '@/lib/api-client'; import type { ComplexWithRole } from '@repo/api-contract'; import { useMutation, useQuery } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; -import { Building2, Loader2 } from 'lucide-react'; +import { ArrowRight, Building2, Loader2 } from 'lucide-react'; export function SelectComplexPage() { const navigate = useNavigate(); @@ -30,7 +30,7 @@ export function SelectComplexPage() { if (complexesQuery.isLoading) { return (
- +
); } @@ -61,38 +61,93 @@ export function SelectComplexPage() { } return ( -
-
-
-

Seleccioná un complejo

-

- Elegí el complejo con el que vas a trabajar. -

-
+
+
+
+
+
+ + Cambio de contexto +
+
+

+ Playzer +

+

+ Elegí el complejo con el que vas a operar. +

+

+ Cada complejo tiene su propio estado, su lista de canchas y su panel de reservas. + Seleccioná uno para entrar con todo el contexto listo. +

+
+
-
- {complexes.map((complex) => ( - - ))} -
-
+
+
+

+ Qué cambia +

+

+ Reservas, usuarios, configuración y disponibilidad se actualizan según el complejo + seleccionado. +

+
+
+

Tip

+

+ Si administrás varios complejos, el menú superior te deja cambiar sin perder el + hilo. +

+
+
+
+ +
+
+

+ Selección +

+

Seleccioná un complejo

+

+ Elegí el espacio con el que vas a trabajar ahora mismo. +

+
+ +
+ {complexes.map((complex) => ( + + ))} +
+
+
); } diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css index c762fb3..339bb27 100644 --- a/apps/frontend/src/index.css +++ b/apps/frontend/src/index.css @@ -42,46 +42,46 @@ --radius-sm: calc(var(--radius) * 0.6); --radius-md: calc(var(--radius) * 0.8); --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) * 1.4); - --radius-2xl: calc(var(--radius) * 1.8); - --radius-3xl: calc(var(--radius) * 2.2); - --radius-4xl: calc(var(--radius) * 2.6); + --radius-xl: calc(var(--radius) * 1.2); + --radius-2xl: calc(var(--radius) * 1.5); + --radius-3xl: calc(var(--radius) * 1.8); + --radius-4xl: calc(var(--radius) * 2.1); } :root { - /* Base colors (retain shadcn structure) */ - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); + /* Base colors (retaining the shadcn structure, but with a sports-club identity) */ + --background: oklch(0.985 0.01 145); + --foreground: oklch(0.16 0.02 145); + --card: oklch(0.995 0.005 145); + --card-foreground: oklch(0.16 0.02 145); + --popover: oklch(0.995 0.005 145); + --popover-foreground: oklch(0.16 0.02 145); + --primary: oklch(0.42 0.11 145); + --primary-foreground: oklch(0.985 0.01 145); + --secondary: oklch(0.95 0.015 145); + --secondary-foreground: oklch(0.24 0.02 145); + --muted: oklch(0.95 0.01 145); + --muted-foreground: oklch(0.49 0.02 145); + --accent: oklch(0.93 0.05 82); + --accent-foreground: oklch(0.28 0.05 82); + --destructive: oklch(0.58 0.22 27); + --border: oklch(0.9 0.01 145); + --input: oklch(0.89 0.01 145); + --ring: oklch(0.55 0.12 145); --chart-1: oklch(0.87 0 0); --chart-2: oklch(0.556 0 0); --chart-3: oklch(0.439 0 0); --chart-4: oklch(0.371 0 0); --chart-5: oklch(0.269 0 0); - --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --radius: 0.5rem; + --sidebar: oklch(0.99 0.005 145); + --sidebar-foreground: oklch(0.16 0.02 145); + --sidebar-primary: oklch(0.42 0.11 145); + --sidebar-primary-foreground: oklch(0.985 0.01 145); + --sidebar-accent: oklch(0.93 0.05 82); + --sidebar-accent-foreground: oklch(0.28 0.05 82); + --sidebar-border: oklch(0.9 0.01 145); + --sidebar-ring: oklch(0.55 0.12 145); /* Surface elevation (light mode: higher = lighter) */ --surface-base: oklch(1 0 0); @@ -120,37 +120,37 @@ } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); + --background: oklch(0.14 0.015 145); + --foreground: oklch(0.98 0.01 145); + --card: oklch(0.19 0.02 145); + --card-foreground: oklch(0.98 0.01 145); + --popover: oklch(0.2 0.02 145); + --popover-foreground: oklch(0.98 0.01 145); + --primary: oklch(0.76 0.11 145); + --primary-foreground: oklch(0.15 0.02 145); + --secondary: oklch(0.24 0.02 145); + --secondary-foreground: oklch(0.98 0.01 145); + --muted: oklch(0.24 0.02 145); + --muted-foreground: oklch(0.72 0.02 145); + --accent: oklch(0.28 0.05 82); + --accent-foreground: oklch(0.98 0.01 82); --destructive: oklch(0.704 0.191 22.216); --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); + --input: oklch(1 0 0 / 12%); + --ring: oklch(0.72 0.08 145); --chart-1: oklch(0.87 0 0); --chart-2: oklch(0.556 0 0); --chart-3: oklch(0.439 0 0); --chart-4: oklch(0.371 0 0); --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar: oklch(0.18 0.02 145); + --sidebar-foreground: oklch(0.98 0.01 145); + --sidebar-primary: oklch(0.76 0.11 145); + --sidebar-primary-foreground: oklch(0.15 0.02 145); + --sidebar-accent: oklch(0.28 0.05 82); + --sidebar-accent-foreground: oklch(0.98 0.01 82); --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --sidebar-ring: oklch(0.72 0.08 145); /* Surface elevation (dark mode: higher = lighter) */ --surface-base: oklch(0.145 0 0); @@ -192,10 +192,36 @@ * { @apply border-border outline-ring/50; } - body { - @apply bg-background text-foreground; - } html { @apply font-sans; + font-synthesis-weight: none; + text-rendering: optimizeLegibility; + } + body { + @apply bg-background text-foreground antialiased; + background-image: radial-gradient( + circle at top left, + oklch(0.94 0.04 145 / 0.55), + transparent 32% + ), radial-gradient(circle at top right, oklch(0.96 0.03 220 / 0.45), transparent 28%), + linear-gradient( + to bottom, + oklch(0.99 0.005 145), + oklch(0.985 0.01 145) 28%, + oklch(0.97 0.01 145) + ); + background-attachment: fixed; + } + .dark body { + background-image: radial-gradient( + circle at top left, + oklch(0.32 0.08 145 / 0.35), + transparent 32% + ), radial-gradient(circle at top right, oklch(0.26 0.05 220 / 0.2), transparent 28%), + linear-gradient(to bottom, oklch(0.15 0.015 145), oklch(0.14 0.015 145)); + } + ::selection { + background: color-mix(in oklab, var(--primary) 28%, transparent); + color: var(--foreground); } }