feat: enhance public booking page with improved layout and date formatting
- Added new date formatting function for better user experience. - Updated layout of the public booking page for improved responsiveness and aesthetics. - Integrated sport selection with visual icons for better clarity. - Enhanced error handling and loading states for availability queries. feat: revamp select complex page for better user interaction - Redesigned the select complex page layout for a more modern look. - Added contextual information about complex selection. - Improved button interactions with loading indicators and icons. style: update CSS variables for a cohesive sports-club theme - Adjusted color variables to reflect a sports-club identity. - Enhanced background styles for both light and dark modes. - Improved overall styling consistency across components.
This commit is contained in:
@@ -14,6 +14,8 @@ const SoccerBallIcon = forwardRef<any, LucideProps>(({ className, ...props }, re
|
|||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
className={className}
|
className={className}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import * as React from 'react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
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:
|
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:
|
secondary:
|
||||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||||
ghost:
|
ghost:
|
||||||
@@ -22,16 +22,15 @@ const buttonVariants = cva(
|
|||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default:
|
default:
|
||||||
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
'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-[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",
|
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-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",
|
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-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
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-8',
|
icon: 'size-9 rounded-xl',
|
||||||
'icon-xs':
|
'icon-xs':
|
||||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
"size-6 rounded-lg in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
'icon-sm':
|
'icon-sm': 'size-8 rounded-lg in-data-[slot=button-group]:rounded-lg',
|
||||||
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
'icon-lg': 'size-10 rounded-xl',
|
||||||
'icon-lg': 'size-9',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
|||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ function SelectTrigger({
|
|||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
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-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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -61,7 +62,7 @@ function SelectContent({
|
|||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
data-align-trigger={position === 'item-aligned'}
|
data-align-trigger={position === 'item-aligned'}
|
||||||
className={cn(
|
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' &&
|
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',
|
'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
|
className
|
||||||
@@ -105,7 +106,7 @@ function SelectItem({
|
|||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-lg py-2 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -43,19 +43,39 @@ export function ComplexSettingsPage({ complexSlug }: ComplexSettingsPageProps) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-6xl px-4 py-4 sm:px-6">
|
<div className="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8 lg:py-6">
|
||||||
<section className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm sm:p-5">
|
<section className="overflow-hidden rounded-2xl border border-border/70 bg-card p-6 text-card-foreground shadow-sm sm:p-8">
|
||||||
<h2 className="text-2xl font-semibold">
|
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<Settings className="mb-1 mr-2 inline size-6" />
|
<div className="space-y-3">
|
||||||
Configuración del complejo
|
<div className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-background px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||||
</h2>
|
<span className="size-2 rounded-full bg-primary" />
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
Administración del complejo
|
||||||
{complexQuery.data?.complexName ?? 'Configura los datos de tu complejo'}
|
</div>
|
||||||
</p>
|
<h2 className="text-3xl font-semibold tracking-tight">
|
||||||
|
<Settings className="mb-1 mr-2 inline size-6" />
|
||||||
|
Configuración del complejo
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
{complexQuery.data?.complexName ??
|
||||||
|
'Configurá los datos del complejo, las canchas y los usuarios.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentMembership?.role && (
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-2xl border border-border/70 bg-background px-4 py-3 text-sm">
|
||||||
|
<span className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Tu rol
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-border/70 bg-card px-2.5 py-1 text-xs font-medium text-foreground">
|
||||||
|
{currentMembership.role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="mt-6 grid gap-6 lg:grid-cols-[200px_1fr]">
|
<div className="mt-6 grid gap-6 lg:grid-cols-[220px_1fr]">
|
||||||
<nav className="flex flex-row gap-1 overflow-x-auto lg:flex-col lg:overflow-visible">
|
<nav className="flex flex-row gap-2 overflow-x-auto rounded-2xl border border-border/70 bg-card p-2 shadow-sm lg:flex-col lg:overflow-visible">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
const isActive = activeTab === tab.id;
|
const isActive = activeTab === tab.id;
|
||||||
@@ -64,9 +84,9 @@ export function ComplexSettingsPage({ complexSlug }: ComplexSettingsPageProps) {
|
|||||||
key={tab.id}
|
key={tab.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors lg:w-full lg:justify-start ${
|
className={`inline-flex items-center gap-2 rounded-2xl px-3 py-3 text-sm font-medium transition-all lg:w-full lg:justify-start ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-muted text-foreground'
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -77,7 +97,7 @@ export function ComplexSettingsPage({ complexSlug }: ComplexSettingsPageProps) {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 rounded-2xl border border-border/70 bg-card p-4 shadow-sm sm:p-6">
|
||||||
{activeTab === 'details' && (
|
{activeTab === 'details' && (
|
||||||
<ComplexDetailsSection
|
<ComplexDetailsSection
|
||||||
complex={complexQuery.data ?? null}
|
complex={complexQuery.data ?? null}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { StatusBadge } from '@/components/ui/status-badge';
|
||||||
import { ApiClientError, apiClient } from '@/lib/api-client';
|
import { ApiClientError, apiClient } from '@/lib/api-client';
|
||||||
import { useCurrentComplexStore } from '@/lib/stores/current-complex-store';
|
import { useCurrentComplexStore } from '@/lib/stores/current-complex-store';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
@@ -78,9 +79,7 @@ function formatDateLabel(dateIso: string, todayIso: string) {
|
|||||||
const tmrw = new Date(tYear, tMonth - 1, tDay);
|
const tmrw = new Date(tYear, tMonth - 1, tDay);
|
||||||
tmrw.setDate(tmrw.getDate() + 1);
|
tmrw.setDate(tmrw.getDate() + 1);
|
||||||
const isTomorrow =
|
const isTomorrow =
|
||||||
year === tmrw.getFullYear() &&
|
year === tmrw.getFullYear() && month === tmrw.getMonth() + 1 && day === tmrw.getDate();
|
||||||
month === tmrw.getMonth() + 1 &&
|
|
||||||
day === tmrw.getDate();
|
|
||||||
|
|
||||||
if (isToday) return 'Hoy';
|
if (isToday) return 'Hoy';
|
||||||
if (isTomorrow) return 'Mañana';
|
if (isTomorrow) return 'Mañana';
|
||||||
@@ -340,6 +339,24 @@ export function HomePage() {
|
|||||||
return [...groups.entries()];
|
return [...groups.entries()];
|
||||||
}, [bookingsQuery.data?.bookings]);
|
}, [bookingsQuery.data?.bookings]);
|
||||||
|
|
||||||
|
const dashboardStats = useMemo(() => {
|
||||||
|
const bookings = bookingsQuery.data?.bookings ?? [];
|
||||||
|
const todayBookings = bookings.filter((booking) => booking.date === todayIso);
|
||||||
|
const confirmedBookings = bookings.filter(
|
||||||
|
(booking) => getEffectiveStatus(booking) === 'CONFIRMED'
|
||||||
|
);
|
||||||
|
const completedBookings = bookings.filter(
|
||||||
|
(booking) => getEffectiveStatus(booking) === 'COMPLETED'
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: bookings.length,
|
||||||
|
today: todayBookings.length,
|
||||||
|
confirmed: confirmedBookings.length,
|
||||||
|
completed: completedBookings.length,
|
||||||
|
};
|
||||||
|
}, [bookingsQuery.data?.bookings, todayIso]);
|
||||||
|
|
||||||
const updateStatusMutation = useMutation({
|
const updateStatusMutation = useMutation({
|
||||||
mutationFn: (payload: { bookingId: string; status: 'CANCELLED' | 'COMPLETED' }) =>
|
mutationFn: (payload: { bookingId: string; status: 'CANCELLED' | 'COMPLETED' }) =>
|
||||||
apiClient.adminBookings.updateStatus(payload.bookingId, { status: payload.status }),
|
apiClient.adminBookings.updateStatus(payload.bookingId, { status: payload.status }),
|
||||||
@@ -439,373 +456,427 @@ export function HomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-6">
|
<main className="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8 lg:py-6">
|
||||||
<section className="rounded-xl border bg-card p-5 shadow-sm">
|
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
<section className="space-y-6">
|
||||||
<div>
|
<section className="overflow-hidden rounded-2xl border border-border/70 bg-card p-6 shadow-sm sm:p-8">
|
||||||
<h1 className="text-2xl font-semibold">Panel de reservas</h1>
|
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<div className="space-y-4">
|
||||||
Gestiona turnos del día y futuros para {selectedComplex.complexName}.
|
<div className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-background px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||||
</p>
|
<span className="size-2 rounded-full bg-primary" />
|
||||||
<div className="mt-2 flex items-center gap-2">
|
Panel operativo
|
||||||
<a
|
</div>
|
||||||
href={publicBookingUrl}
|
<div className="space-y-2">
|
||||||
target="_blank"
|
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-muted-foreground">
|
||||||
rel="noopener noreferrer"
|
{selectedComplex.complexSlug}
|
||||||
className="text-sm text-muted-foreground underline hover:text-foreground"
|
</p>
|
||||||
>
|
<h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||||
{publicBookingUrl}
|
Panel de reservas
|
||||||
</a>
|
</h1>
|
||||||
<button
|
<p className="max-w-2xl text-sm text-muted-foreground sm:text-base">
|
||||||
type="button"
|
Gestioná turnos del día y futuros para {selectedComplex.complexName}.
|
||||||
onClick={copyToClipboard}
|
</p>
|
||||||
className="text-muted-foreground hover:text-foreground"
|
</div>
|
||||||
title="Copiar URL"
|
<div className="flex flex-wrap gap-2">
|
||||||
>
|
<span className="rounded-full border border-border/70 bg-background px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||||
{urlCopied ? (
|
{dashboardStats.today} hoy
|
||||||
<Check className="h-4 w-4 text-emerald-500" />
|
</span>
|
||||||
) : (
|
<span className="rounded-full border border-border/70 bg-background px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||||
<Copy className="h-4 w-4" />
|
{dashboardStats.confirmed} confirmadas
|
||||||
)}
|
</span>
|
||||||
</button>
|
<span className="rounded-full border border-border/70 bg-background px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||||
</div>
|
{dashboardStats.completed} cumplidas
|
||||||
</div>
|
</span>
|
||||||
<ResponsiveDialog
|
</div>
|
||||||
open={isManualBookingOpen}
|
</div>
|
||||||
onOpenChange={(open) => {
|
|
||||||
setIsManualBookingOpen(open);
|
|
||||||
if (!open) {
|
|
||||||
reset();
|
|
||||||
setManualDate(todayIso);
|
|
||||||
setSelectedSportId(undefined);
|
|
||||||
setSelectedCourtId('');
|
|
||||||
setSelectedStartTime('');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ResponsiveDialogTrigger asChild>
|
|
||||||
<Button>Nueva Reserva</Button>
|
|
||||||
</ResponsiveDialogTrigger>
|
|
||||||
<ResponsiveDialogContent
|
|
||||||
className="data-[variant=dialog]:max-w-lg"
|
|
||||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<ResponsiveDialogHeader>
|
|
||||||
<ResponsiveDialogTitle>Reserva manual</ResponsiveDialogTitle>
|
|
||||||
<ResponsiveDialogDescription>
|
|
||||||
Crea un turno para atención telefónica o mostrador.
|
|
||||||
</ResponsiveDialogDescription>
|
|
||||||
</ResponsiveDialogHeader>
|
|
||||||
|
|
||||||
<form
|
<div className="w-full max-w-md space-y-3">
|
||||||
id="manual-booking-form"
|
<div className="rounded-2xl border border-border/70 bg-background p-4">
|
||||||
className="grid gap-4"
|
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
onSubmit={handleSubmit(onSubmitManualBooking)}
|
URL pública
|
||||||
>
|
</p>
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<Field>
|
<a
|
||||||
<FieldLabel htmlFor="manualDate">Fecha</FieldLabel>
|
href={publicBookingUrl}
|
||||||
<DatePicker
|
target="_blank"
|
||||||
value={fromIsoDateLocal(manualDate)}
|
rel="noopener noreferrer"
|
||||||
onChange={(date) => {
|
className="min-w-0 truncate text-sm text-foreground underline-offset-4 hover:underline"
|
||||||
const isoDate = date ? toIsoDateLocal(date) : todayIso;
|
|
||||||
setManualDate(isoDate);
|
|
||||||
setSelectedCourtId('');
|
|
||||||
setSelectedStartTime('');
|
|
||||||
}}
|
|
||||||
minDate={new Date()}
|
|
||||||
placeholder="Selecciona una fecha"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{manualAvailabilityQuery.data?.sportSelectionRequired && (
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>Deporte</FieldLabel>
|
|
||||||
<Select
|
|
||||||
value={selectedSportId ?? ''}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setSelectedSportId(value);
|
|
||||||
setSelectedCourtId('');
|
|
||||||
setSelectedStartTime('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Selecciona un deporte" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{manualAvailabilityQuery.data?.sports.map((sport) => (
|
|
||||||
<SelectItem key={sport.id} value={sport.id}>
|
|
||||||
{sport.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>Cancha</FieldLabel>
|
|
||||||
<Select
|
|
||||||
value={selectedCourtId}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setSelectedCourtId(value);
|
|
||||||
setSelectedStartTime('');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
{publicBookingUrl}
|
||||||
<SelectValue placeholder="Selecciona una cancha" />
|
</a>
|
||||||
</SelectTrigger>
|
<button
|
||||||
<SelectContent>
|
type="button"
|
||||||
{(manualAvailabilityQuery.data?.courts ?? []).map((court) => (
|
onClick={copyToClipboard}
|
||||||
<SelectItem key={court.courtId} value={court.courtId}>
|
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||||
{court.courtName} · {court.sport.name}
|
title="Copiar URL"
|
||||||
</SelectItem>
|
>
|
||||||
))}
|
{urlCopied ? (
|
||||||
</SelectContent>
|
<Check className="h-4 w-4 text-emerald-500" />
|
||||||
</Select>
|
) : (
|
||||||
</Field>
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
<Field>
|
</button>
|
||||||
<FieldLabel>Horario</FieldLabel>
|
</div>
|
||||||
<Select value={selectedStartTime} onValueChange={setSelectedStartTime}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Selecciona un horario" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(selectedCourt?.availableSlots ?? []).map((slot) => {
|
|
||||||
const isToday = manualDate === todayIso;
|
|
||||||
const currentTime = new Date();
|
|
||||||
const currentHours = currentTime.getHours().toString().padStart(2, '0');
|
|
||||||
const currentMinutes = currentTime
|
|
||||||
.getMinutes()
|
|
||||||
.toString()
|
|
||||||
.padStart(2, '0');
|
|
||||||
const currentTimeStr = `${currentHours}:${currentMinutes}`;
|
|
||||||
const isPastSlot = isToday && slot.startTime <= currentTimeStr;
|
|
||||||
if (isPastSlot) return null;
|
|
||||||
return (
|
|
||||||
<SelectItem key={slot.startTime} value={slot.startTime}>
|
|
||||||
{slot.startTime}
|
|
||||||
</SelectItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{manualAvailabilityQuery.isLoading && (
|
<ResponsiveDialog
|
||||||
<p className="text-sm text-muted-foreground">Cargando horarios disponibles...</p>
|
open={isManualBookingOpen}
|
||||||
)}
|
onOpenChange={(open) => {
|
||||||
|
setIsManualBookingOpen(open);
|
||||||
{manualAvailabilityQuery.isError && (
|
if (!open) {
|
||||||
<p className="text-sm text-destructive">
|
reset();
|
||||||
{extractMessage(
|
setManualDate(todayIso);
|
||||||
manualAvailabilityQuery.error,
|
setSelectedSportId(undefined);
|
||||||
'No pudimos cargar disponibilidad para la reserva manual.'
|
setSelectedCourtId('');
|
||||||
)}
|
setSelectedStartTime('');
|
||||||
</p>
|
}
|
||||||
)}
|
}}
|
||||||
|
|
||||||
<Field data-invalid={Boolean(errors.customerName)}>
|
|
||||||
<FieldLabel htmlFor="customerName">Nombre</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="customerName"
|
|
||||||
placeholder="Ej: Juan Perez"
|
|
||||||
aria-invalid={Boolean(errors.customerName)}
|
|
||||||
{...register('customerName')}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.customerName]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={Boolean(errors.customerPhone)}>
|
|
||||||
<FieldLabel htmlFor="customerPhone">Teléfono</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="customerPhone"
|
|
||||||
type="tel"
|
|
||||||
placeholder="Ej: 3875551234"
|
|
||||||
aria-invalid={Boolean(errors.customerPhone)}
|
|
||||||
{...register('customerPhone')}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.customerPhone]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{createManualBookingMutation.isError && (
|
|
||||||
<p className="text-sm text-destructive">
|
|
||||||
{extractMessage(
|
|
||||||
createManualBookingMutation.error,
|
|
||||||
'No pudimos crear la reserva manual.'
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<ResponsiveDialogFooter>
|
|
||||||
<ResponsiveDialogClose asChild>
|
|
||||||
<Button variant="outline">Cancelar</Button>
|
|
||||||
</ResponsiveDialogClose>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
form="manual-booking-form"
|
|
||||||
disabled={
|
|
||||||
!isValid ||
|
|
||||||
createManualBookingMutation.isPending ||
|
|
||||||
!selectedCourtId ||
|
|
||||||
!selectedStartTime
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{createManualBookingMutation.isPending ? 'Guardando...' : 'Crear reserva'}
|
<ResponsiveDialogTrigger asChild>
|
||||||
</Button>
|
<Button className="w-full">Nueva reserva manual</Button>
|
||||||
</ResponsiveDialogFooter>
|
</ResponsiveDialogTrigger>
|
||||||
</ResponsiveDialogContent>
|
<ResponsiveDialogContent
|
||||||
</ResponsiveDialog>
|
className="data-[variant=dialog]:max-w-lg"
|
||||||
</div>
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
</section>
|
>
|
||||||
|
<ResponsiveDialogHeader>
|
||||||
|
<ResponsiveDialogTitle>Reserva manual</ResponsiveDialogTitle>
|
||||||
|
<ResponsiveDialogDescription>
|
||||||
|
Crea un turno para atención telefónica o mostrador.
|
||||||
|
</ResponsiveDialogDescription>
|
||||||
|
</ResponsiveDialogHeader>
|
||||||
|
|
||||||
<section className="rounded-xl border bg-card p-5 shadow-sm">
|
<form
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
id="manual-booking-form"
|
||||||
<Field>
|
className="grid gap-4"
|
||||||
<FieldLabel htmlFor="fromDate">Mostrar desde</FieldLabel>
|
onSubmit={handleSubmit(onSubmitManualBooking)}
|
||||||
<DatePicker
|
|
||||||
value={fromIsoDateLocal(fromDate)}
|
|
||||||
onChange={(date) => {
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{bookingsQuery.isLoading && (
|
|
||||||
<p className="mt-4 text-sm text-muted-foreground">Cargando reservas...</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{bookingsQuery.isError && (
|
|
||||||
<p className="mt-4 text-sm text-destructive">
|
|
||||||
{extractMessage(bookingsQuery.error, 'No pudimos cargar las reservas.')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!bookingsQuery.isLoading && !bookingsQuery.isError && groupedBookings.length === 0 && (
|
|
||||||
<p className="mt-4 text-sm text-muted-foreground">
|
|
||||||
No hay reservas para la fecha seleccionada en adelante.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-4 space-y-4">
|
|
||||||
{groupedBookings.map(([date, bookings]) => (
|
|
||||||
<article key={date} className="rounded-lg border bg-background p-3">
|
|
||||||
<h2 className="text-sm font-semibold">{formatDateLabel(date, todayIso)}</h2>
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
{bookings.map((booking) => {
|
|
||||||
const effectiveStatus = getEffectiveStatus(booking);
|
|
||||||
const canManage =
|
|
||||||
booking.status === 'CONFIRMED' && effectiveStatus === 'CONFIRMED';
|
|
||||||
const isMutating = updateStatusMutation.isPending;
|
|
||||||
|
|
||||||
const statusColorMap: Record<string, string> = {
|
|
||||||
CONFIRMED: 'border-l-sky-500 dark:border-l-sky-400',
|
|
||||||
COMPLETED: 'border-l-emerald-500 dark:border-l-emerald-400',
|
|
||||||
CANCELLED: 'border-l-rose-500 dark:border-l-rose-400',
|
|
||||||
NO_SHOW: 'border-l-orange-500 dark:border-l-orange-400',
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusBgMap: Record<string, string> = {
|
|
||||||
CONFIRMED: 'bg-sky-50/30 dark:bg-sky-950/20',
|
|
||||||
COMPLETED: 'bg-emerald-50/30 dark:bg-emerald-950/20',
|
|
||||||
CANCELLED: 'bg-rose-50/30 dark:bg-rose-950/20',
|
|
||||||
NO_SHOW: 'bg-orange-50/30 dark:bg-orange-950/20',
|
|
||||||
};
|
|
||||||
|
|
||||||
const colorClass = statusColorMap[effectiveStatus] || '';
|
|
||||||
const bgClass = statusBgMap[effectiveStatus] || '';
|
|
||||||
|
|
||||||
const badgeColorMap: Record<string, string> = {
|
|
||||||
CONFIRMED: 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-800 dark:bg-sky-950 dark:text-sky-300',
|
|
||||||
COMPLETED: 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300',
|
|
||||||
CANCELLED: 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-800 dark:bg-rose-950 dark:text-rose-300',
|
|
||||||
NO_SHOW: 'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800 dark:bg-orange-950 dark:text-orange-300',
|
|
||||||
};
|
|
||||||
|
|
||||||
const badgeClass = badgeColorMap[effectiveStatus] || '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={booking.id}
|
|
||||||
className={`flex flex-col gap-2 rounded-md border border-l-[3px] p-3 sm:flex-row sm:items-center sm:justify-between ${colorClass} ${bgClass}`}
|
|
||||||
>
|
>
|
||||||
<div>
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<p className="text-sm font-medium">
|
<Field>
|
||||||
{booking.startTime} - {booking.endTime} · {booking.courtName}
|
<FieldLabel htmlFor="manualDate">Fecha</FieldLabel>
|
||||||
</p>
|
<DatePicker
|
||||||
<p className="text-xs text-muted-foreground">
|
value={fromIsoDateLocal(manualDate)}
|
||||||
{booking.customerName} ({booking.customerPhone}) · Código{' '}
|
onChange={(date) => {
|
||||||
{booking.bookingCode}
|
const isoDate = date ? toIsoDateLocal(date) : todayIso;
|
||||||
</p>
|
setManualDate(isoDate);
|
||||||
</div>
|
setSelectedCourtId('');
|
||||||
|
setSelectedStartTime('');
|
||||||
|
}}
|
||||||
|
minDate={new Date()}
|
||||||
|
placeholder="Selecciona una fecha"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
{manualAvailabilityQuery.data?.sportSelectionRequired && (
|
||||||
<span
|
<Field>
|
||||||
className={`hidden sm:inline-flex rounded-full border px-2 py-1 text-xs font-medium ${badgeClass}`}
|
<FieldLabel>Deporte</FieldLabel>
|
||||||
>
|
<Select
|
||||||
{effectiveStatusLabel(effectiveStatus)}
|
value={selectedSportId ?? ''}
|
||||||
</span>
|
onValueChange={(value) => {
|
||||||
|
setSelectedSportId(value);
|
||||||
{canManage && (
|
setSelectedCourtId('');
|
||||||
<>
|
setSelectedStartTime('');
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled={isMutating}
|
|
||||||
onClick={() => {
|
|
||||||
updateStatusMutation.mutate({
|
|
||||||
bookingId: booking.id,
|
|
||||||
status: 'COMPLETED',
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Marcar cumplida
|
<SelectTrigger>
|
||||||
</Button>
|
<SelectValue placeholder="Selecciona un deporte" />
|
||||||
<Button
|
</SelectTrigger>
|
||||||
type="button"
|
<SelectContent>
|
||||||
size="sm"
|
{manualAvailabilityQuery.data?.sports.map((sport) => (
|
||||||
variant="destructive"
|
<SelectItem key={sport.id} value={sport.id}>
|
||||||
disabled={isMutating}
|
{sport.name}
|
||||||
onClick={() => {
|
</SelectItem>
|
||||||
updateStatusMutation.mutate({
|
))}
|
||||||
bookingId: booking.id,
|
</SelectContent>
|
||||||
status: 'CANCELLED',
|
</Select>
|
||||||
});
|
</Field>
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{updateStatusMutation.isError && (
|
<Field>
|
||||||
<p className="mt-4 text-sm text-destructive">
|
<FieldLabel>Cancha</FieldLabel>
|
||||||
{extractMessage(updateStatusMutation.error, 'No pudimos actualizar la reserva.')}
|
<Select
|
||||||
</p>
|
value={selectedCourtId}
|
||||||
)}
|
onValueChange={(value) => {
|
||||||
</section>
|
setSelectedCourtId(value);
|
||||||
|
setSelectedStartTime('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Selecciona una cancha" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(manualAvailabilityQuery.data?.courts ?? []).map((court) => (
|
||||||
|
<SelectItem key={court.courtId} value={court.courtId}>
|
||||||
|
{court.courtName} · {court.sport.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Horario</FieldLabel>
|
||||||
|
<Select value={selectedStartTime} onValueChange={setSelectedStartTime}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Selecciona un horario" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(selectedCourt?.availableSlots ?? []).map((slot) => {
|
||||||
|
const isToday = manualDate === todayIso;
|
||||||
|
const currentTime = new Date();
|
||||||
|
const currentHours = currentTime
|
||||||
|
.getHours()
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0');
|
||||||
|
const currentMinutes = currentTime
|
||||||
|
.getMinutes()
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0');
|
||||||
|
const currentTimeStr = `${currentHours}:${currentMinutes}`;
|
||||||
|
const isPastSlot = isToday && slot.startTime <= currentTimeStr;
|
||||||
|
if (isPastSlot) return null;
|
||||||
|
return (
|
||||||
|
<SelectItem key={slot.startTime} value={slot.startTime}>
|
||||||
|
{slot.startTime}
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{manualAvailabilityQuery.isLoading && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Cargando horarios disponibles...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{manualAvailabilityQuery.isError && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{extractMessage(
|
||||||
|
manualAvailabilityQuery.error,
|
||||||
|
'No pudimos cargar disponibilidad para la reserva manual.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field data-invalid={Boolean(errors.customerName)}>
|
||||||
|
<FieldLabel htmlFor="customerName">Nombre</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="customerName"
|
||||||
|
placeholder="Ej: Juan Perez"
|
||||||
|
aria-invalid={Boolean(errors.customerName)}
|
||||||
|
{...register('customerName')}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.customerName]} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={Boolean(errors.customerPhone)}>
|
||||||
|
<FieldLabel htmlFor="customerPhone">Teléfono</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="customerPhone"
|
||||||
|
type="tel"
|
||||||
|
placeholder="Ej: 3875551234"
|
||||||
|
aria-invalid={Boolean(errors.customerPhone)}
|
||||||
|
{...register('customerPhone')}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.customerPhone]} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{createManualBookingMutation.isError && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{extractMessage(
|
||||||
|
createManualBookingMutation.error,
|
||||||
|
'No pudimos crear la reserva manual.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ResponsiveDialogFooter>
|
||||||
|
<ResponsiveDialogClose asChild>
|
||||||
|
<Button variant="outline">Cancelar</Button>
|
||||||
|
</ResponsiveDialogClose>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="manual-booking-form"
|
||||||
|
disabled={
|
||||||
|
!isValid ||
|
||||||
|
createManualBookingMutation.isPending ||
|
||||||
|
!selectedCourtId ||
|
||||||
|
!selectedStartTime
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{createManualBookingMutation.isPending ? 'Guardando...' : 'Crear reserva'}
|
||||||
|
</Button>
|
||||||
|
</ResponsiveDialogFooter>
|
||||||
|
</ResponsiveDialogContent>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-border/70 bg-card p-5 shadow-sm sm:p-6">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="fromDate">Mostrar desde</FieldLabel>
|
||||||
|
<DatePicker
|
||||||
|
value={fromIsoDateLocal(fromDate)}
|
||||||
|
onChange={(date) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bookingsQuery.isLoading && (
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground">Cargando reservas...</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bookingsQuery.isError && (
|
||||||
|
<p className="mt-4 text-sm text-destructive">
|
||||||
|
{extractMessage(bookingsQuery.error, 'No pudimos cargar las reservas.')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!bookingsQuery.isLoading && !bookingsQuery.isError && groupedBookings.length === 0 && (
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground">
|
||||||
|
No hay reservas para la fecha seleccionada en adelante.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
{groupedBookings.map(([date, bookings]) => (
|
||||||
|
<article
|
||||||
|
key={date}
|
||||||
|
className="rounded-2xl border border-border/70 bg-background p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h2 className="text-sm font-semibold capitalize">
|
||||||
|
{formatDateLabel(date, todayIso)}
|
||||||
|
</h2>
|
||||||
|
<span className="rounded-full border border-border/70 bg-card px-3 py-1 text-[11px] font-medium text-muted-foreground">
|
||||||
|
{bookings.length} reservas
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{bookings.map((booking) => {
|
||||||
|
const effectiveStatus = getEffectiveStatus(booking);
|
||||||
|
const canManage =
|
||||||
|
booking.status === 'CONFIRMED' && effectiveStatus === 'CONFIRMED';
|
||||||
|
const isMutating = updateStatusMutation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={booking.id}
|
||||||
|
className="flex flex-col gap-3 rounded-2xl border border-border/70 bg-card p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{booking.startTime} - {booking.endTime} · {booking.courtName}
|
||||||
|
</p>
|
||||||
|
<StatusBadge
|
||||||
|
status={effectiveStatus}
|
||||||
|
label={effectiveStatusLabel(effectiveStatus)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{booking.customerName} ({booking.customerPhone}) · Código{' '}
|
||||||
|
{booking.bookingCode}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{canManage && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isMutating}
|
||||||
|
onClick={() => {
|
||||||
|
updateStatusMutation.mutate({
|
||||||
|
bookingId: booking.id,
|
||||||
|
status: 'COMPLETED',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Marcar cumplida
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={isMutating}
|
||||||
|
onClick={() => {
|
||||||
|
updateStatusMutation.mutate({
|
||||||
|
bookingId: booking.id,
|
||||||
|
status: 'CANCELLED',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{updateStatusMutation.isError && (
|
||||||
|
<p className="mt-4 text-sm text-destructive">
|
||||||
|
{extractMessage(updateStatusMutation.error, 'No pudimos actualizar la reserva.')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="space-y-6 xl:sticky xl:top-24 h-fit self-start">
|
||||||
|
<section className="rounded-2xl border border-border/70 bg-card p-5 shadow-sm sm:p-6">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Lectura rápida
|
||||||
|
</h2>
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
|
||||||
|
<div className="rounded-2xl border border-border/70 bg-background p-4">
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Total</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold">{dashboardStats.total}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-border/70 bg-background p-4">
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Hoy</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold">{dashboardStats.today}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-border/70 bg-background p-4">
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Confirmadas
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold">{dashboardStats.confirmed}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-border/70 bg-background p-4">
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Cumplidas
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold">{dashboardStats.completed}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { FootballIcon } from '@/assets/icons/football';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -66,59 +67,194 @@ export function RootLayout() {
|
|||||||
await navigate({ to: '/login' });
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
<header className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 py-4">
|
<header className="sticky top-0 z-40 border-b border-border/70 bg-background/80 backdrop-blur-xl">
|
||||||
<div className="flex items-center gap-6">
|
<div className="mx-auto flex w-full max-w-7xl items-center justify-between gap-4 px-4 py-3 sm:px-6">
|
||||||
<h1 className="text-sm font-semibold">{currentComplexName}</h1>
|
<div className="flex min-w-0 items-center gap-4">
|
||||||
<nav className="hidden items-center gap-3 text-sm md:flex">
|
<Link to="/" className="flex min-w-0 items-center gap-3">
|
||||||
<Link
|
<span className="relative flex size-10 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-sm ring-1 ring-primary/20">
|
||||||
to="/"
|
<FootballIcon className="size-5" />
|
||||||
className="text-muted-foreground"
|
<span className="absolute -right-0.5 -bottom-0.5 size-2 rounded-full border border-background bg-accent" />
|
||||||
activeProps={{ className: 'text-foreground font-medium' }}
|
</span>
|
||||||
>
|
<span className="min-w-0">
|
||||||
Home
|
<span className="block text-[10px] font-semibold uppercase tracking-[0.32em] text-muted-foreground">
|
||||||
|
Playzer
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-sm font-semibold text-foreground">
|
||||||
|
{currentComplexName}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
{isAuthenticated && currentComplexSlug && (
|
|
||||||
<Link
|
<nav className="hidden items-center gap-1 lg:flex">
|
||||||
to="/complex/$slug/edit"
|
{navigationItems
|
||||||
params={{ slug: currentComplexSlug }}
|
.filter((item) => item.show)
|
||||||
className="text-muted-foreground"
|
.map((item) =>
|
||||||
activeProps={{ className: 'text-foreground font-medium' }}
|
item.params ? (
|
||||||
>
|
<Link
|
||||||
Configuración
|
key={item.label}
|
||||||
</Link>
|
to={item.to}
|
||||||
|
params={item.params}
|
||||||
|
className="rounded-full px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
activeProps={{ className: 'bg-muted text-foreground font-medium' }}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
key={item.label}
|
||||||
|
to={item.to}
|
||||||
|
className="rounded-full px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
activeProps={{ className: 'bg-muted text-foreground font-medium' }}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="hidden items-center gap-2 rounded-full border border-border/70 bg-card px-3 py-1.5 text-xs text-muted-foreground shadow-sm md:flex">
|
||||||
|
<span className="size-2 rounded-full bg-emerald-500" />
|
||||||
|
{isAuthenticated ? 'Sesión activa' : 'Modo visitante'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ThemeSwitcher />
|
||||||
|
|
||||||
|
{!isAuthenticated && (
|
||||||
|
<Button asChild size="sm" variant="outline" className="hidden md:inline-flex">
|
||||||
|
<Link to="/login">Ingresar</Link>
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
{isAuthenticated && (
|
||||||
<ThemeSwitcher />
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-card px-2 py-1.5 transition-colors hover:bg-muted"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={displayName}
|
||||||
|
fallbackText={displayName}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<span className="hidden max-w-32 truncate text-sm text-foreground sm:block">
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
<DropdownMenuLabel>Mi cuenta</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{myComplexesQuery.data && myComplexesQuery.data.length > 1 && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||||
|
Cambiar complejo
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{myComplexesQuery.data.map((complex) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={complex.id}
|
||||||
|
onSelect={async () => {
|
||||||
|
await apiClient.complexes.select({ complexId: complex.id });
|
||||||
|
setCurrentComplex(complex.complexSlug);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{complex.complexSlug === complexSlug && <span className="mr-2">✓</span>}
|
||||||
|
{complex.complexName}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
void navigate({ to: '/profile' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserRound className="size-4" />
|
||||||
|
Perfil
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onSelect={() => {
|
||||||
|
void handleSignOut();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogOut className="size-4" />
|
||||||
|
Cerrar sesión
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isAuthenticated && (
|
|
||||||
<Button asChild size="sm" variant="outline" className="hidden md:inline-flex">
|
|
||||||
<Link to="/login">Login</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAuthenticated && (
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="hidden items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-muted md:inline-flex"
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
className="lg:hidden"
|
||||||
|
aria-label="Abrir menú"
|
||||||
>
|
>
|
||||||
<UserAvatar
|
<Menu className="size-4" />
|
||||||
src={avatarUrl}
|
</Button>
|
||||||
alt={displayName}
|
|
||||||
fallbackText={displayName}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<span className="max-w-32 truncate text-sm text-foreground">{displayName}</span>
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-52">
|
<DropdownMenuContent align="end" className="w-64 lg:hidden">
|
||||||
<DropdownMenuLabel>Mi cuenta</DropdownMenuLabel>
|
<DropdownMenuLabel>{currentComplexName}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{navigationItems
|
||||||
|
.filter((item) => item.show)
|
||||||
|
.map((item) =>
|
||||||
|
item.params ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={item.label}
|
||||||
|
onSelect={() => {
|
||||||
|
void navigate({ to: item.to, params: item.params });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={item.label}
|
||||||
|
onSelect={() => {
|
||||||
|
void navigate({ to: item.to });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{myComplexesQuery.data && myComplexesQuery.data.length > 1 && (
|
{myComplexesQuery.data && myComplexesQuery.data.length > 1 && (
|
||||||
<>
|
<>
|
||||||
@@ -140,103 +276,41 @@ export function RootLayout() {
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem
|
{isAuthenticated ? (
|
||||||
onSelect={() => {
|
<>
|
||||||
void navigate({ to: '/profile' });
|
<DropdownMenuItem
|
||||||
}}
|
onSelect={() => {
|
||||||
>
|
void navigate({ to: '/profile' });
|
||||||
<UserRound className="size-4" />
|
}}
|
||||||
Profile
|
>
|
||||||
</DropdownMenuItem>
|
<UserRound className="size-4" />
|
||||||
<DropdownMenuItem
|
Perfil
|
||||||
variant="destructive"
|
</DropdownMenuItem>
|
||||||
onSelect={() => {
|
<DropdownMenuItem
|
||||||
void handleSignOut();
|
variant="destructive"
|
||||||
}}
|
onSelect={() => {
|
||||||
>
|
void handleSignOut();
|
||||||
<LogOut className="size-4" />
|
}}
|
||||||
Sign out
|
>
|
||||||
</DropdownMenuItem>
|
<LogOut className="size-4" />
|
||||||
|
Cerrar sesión
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
void navigate({ to: '/login' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ingresar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="icon-sm"
|
|
||||||
className="md:hidden"
|
|
||||||
aria-label="Abrir menú"
|
|
||||||
>
|
|
||||||
<Menu className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56 md:hidden">
|
|
||||||
<DropdownMenuLabel>Navegación</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
void navigate({ to: '/' });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Home
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
void navigate({ to: '/about' });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
About
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{isAuthenticated && currentComplexSlug && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
void navigate({
|
|
||||||
to: '/complex/$slug/edit',
|
|
||||||
params: { slug: currentComplexSlug },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Configuración
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{isAuthenticated ? (
|
|
||||||
<>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
void navigate({ to: '/profile' });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<UserRound className="size-4" />
|
|
||||||
Profile
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
variant="destructive"
|
|
||||||
onSelect={() => {
|
|
||||||
void handleSignOut();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LogOut className="size-4" />
|
|
||||||
Sign out
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
void navigate({ to: '/login' });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,15 +30,15 @@ export function ThemeSwitcher() {
|
|||||||
>
|
>
|
||||||
<DropdownMenuRadioItem value="light">
|
<DropdownMenuRadioItem value="light">
|
||||||
<Sun className="size-4" />
|
<Sun className="size-4" />
|
||||||
Light
|
Claro
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuRadioItem>
|
||||||
<DropdownMenuRadioItem value="dark">
|
<DropdownMenuRadioItem value="dark">
|
||||||
<Moon className="size-4" />
|
<Moon className="size-4" />
|
||||||
Dark
|
Oscuro
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuRadioItem>
|
||||||
<DropdownMenuRadioItem value="system">
|
<DropdownMenuRadioItem value="system">
|
||||||
<Laptop className="size-4" />
|
<Laptop className="size-4" />
|
||||||
System
|
Sistema
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuRadioItem>
|
||||||
</DropdownMenuRadioGroup>
|
</DropdownMenuRadioGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -78,82 +78,139 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
<main className="min-h-screen px-4 py-4 sm:px-6 lg:px-8 lg:py-6">
|
||||||
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
<div className="mx-auto grid min-h-[calc(100vh-2rem)] w-full max-w-6xl items-center gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
<h1 className="mb-2 text-2xl font-semibold">Iniciar sesión</h1>
|
<section className="relative hidden overflow-hidden rounded-2xl border border-border/70 bg-card p-8 text-card-foreground shadow-sm lg:flex lg:min-h-[640px] lg:flex-col lg:justify-between">
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(43,139,82,0.12),transparent_38%),radial-gradient(circle_at_bottom_right,rgba(35,80,128,0.1),transparent_35%)]" />
|
||||||
Ingresá con tu cuenta para acceder a Home.
|
<div className="relative space-y-8">
|
||||||
</p>
|
<div className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-background/70 px-3 py-1 text-xs font-medium text-muted-foreground backdrop-blur">
|
||||||
|
<span className="size-2 rounded-full bg-emerald-500" />
|
||||||
|
Gestión de reservas y complejos
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button type="button" variant="outline" className="w-full" onClick={handleGoogleSignIn}>
|
<div className="max-w-xl space-y-5">
|
||||||
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
<p className="text-[11px] font-semibold uppercase tracking-[0.34em] text-muted-foreground">
|
||||||
<path
|
Playzer
|
||||||
fill="currentColor"
|
</p>
|
||||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
<h1 className="text-4xl font-semibold tracking-tight text-foreground xl:text-5xl">
|
||||||
/>
|
Un panel para operar el club con velocidad, claridad y presencia.
|
||||||
<path
|
</h1>
|
||||||
fill="currentColor"
|
<p className="max-w-lg text-base leading-7 text-muted-foreground">
|
||||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
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.
|
||||||
<path
|
</p>
|
||||||
fill="currentColor"
|
</div>
|
||||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Continuar con Google
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="relative my-4">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<span className="w-full border-t" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
|
||||||
<span className="bg-card px-2 text-muted-foreground">O</span>
|
<div className="relative grid gap-3 sm:grid-cols-3">
|
||||||
|
{[
|
||||||
|
'Disponibilidad y reservas en un vistazo',
|
||||||
|
'Cambios de complejo sin perder contexto',
|
||||||
|
'Estados y acciones con feedback claro',
|
||||||
|
].map((item) => (
|
||||||
|
<div
|
||||||
|
key={item}
|
||||||
|
className="rounded-2xl border border-border/70 bg-background/70 p-4 text-sm text-muted-foreground backdrop-blur"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<form className="space-y-3" onSubmit={handleSubmit(onSubmit)}>
|
<section className="w-full rounded-2xl border border-border/70 bg-card p-6 text-card-foreground shadow-sm sm:p-8">
|
||||||
<Field data-invalid={Boolean(errors.email)}>
|
<div className="mb-8 flex items-start justify-between gap-4">
|
||||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
<div className="space-y-2">
|
||||||
<Input
|
<p className="text-[11px] font-semibold uppercase tracking-[0.34em] text-muted-foreground">
|
||||||
id="email"
|
Acceso
|
||||||
type="email"
|
</p>
|
||||||
placeholder="tu@email.com"
|
<h1 className="text-3xl font-semibold tracking-tight">Ingresá a tu cuenta</h1>
|
||||||
aria-invalid={Boolean(errors.email)}
|
<p className="max-w-sm text-sm text-muted-foreground">
|
||||||
{...register('email')}
|
Entrá con Google o con tu email y contraseña para continuar con tu complejo.
|
||||||
/>
|
</p>
|
||||||
<FieldError errors={[errors.email]} />
|
</div>
|
||||||
</Field>
|
<div className="hidden rounded-2xl border border-border/70 bg-background p-3 text-right sm:block">
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Estado</p>
|
||||||
|
<p className="mt-1 text-sm font-medium">Acceso seguro</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Field data-invalid={Boolean(errors.password)}>
|
<Button
|
||||||
<FieldLabel htmlFor="password">Contraseña</FieldLabel>
|
type="button"
|
||||||
<Input
|
variant="outline"
|
||||||
id="password"
|
className="w-full justify-center"
|
||||||
type="password"
|
onClick={handleGoogleSignIn}
|
||||||
placeholder="••••••••"
|
>
|
||||||
aria-invalid={Boolean(errors.password)}
|
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
{...register('password')}
|
<path
|
||||||
/>
|
fill="currentColor"
|
||||||
<FieldError errors={[errors.password]} />
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
</Field>
|
/>
|
||||||
|
<path
|
||||||
{submitError && <p className="text-sm text-destructive">{submitError}</p>}
|
fill="currentColor"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
<Button type="submit" className="w-full" disabled={!isValid || isSubmitting}>
|
/>
|
||||||
{isSubmitting ? 'Ingresando...' : 'Ingresar'}
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Continuar con Google
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
|
||||||
|
|
||||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
<div className="relative my-5">
|
||||||
<Link to="/reset-password" className="text-primary hover:underline">
|
<div className="absolute inset-0 flex items-center">
|
||||||
¿Olvidaste tu contraseña?
|
<span className="w-full border-t border-border/80" />
|
||||||
</Link>
|
</div>
|
||||||
</p>
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
</section>
|
<span className="bg-card px-2 text-muted-foreground">O continuá con email</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Field data-invalid={Boolean(errors.email)}>
|
||||||
|
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="tu@email.com"
|
||||||
|
aria-invalid={Boolean(errors.email)}
|
||||||
|
{...register('email')}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.email]} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={Boolean(errors.password)}>
|
||||||
|
<FieldLabel htmlFor="password">Contraseña</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
aria-invalid={Boolean(errors.password)}
|
||||||
|
{...register('password')}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.password]} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{submitError && <p className="text-sm text-destructive">{submitError}</p>}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={!isValid || isSubmitting}>
|
||||||
|
{isSubmitting ? 'Ingresando...' : 'Ingresar'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-center justify-between gap-3 text-sm">
|
||||||
|
<p className="text-muted-foreground">¿Olvidaste tu contraseña?</p>
|
||||||
|
<Link to="/reset-password" className="font-medium text-primary hover:underline">
|
||||||
|
Recuperarla
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export function OnboardingCheckEmailStep() {
|
export function OnboardingCheckEmailStep() {
|
||||||
return (
|
return (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Revisa tu correo y abre el link de verificacion para continuar.
|
Revisa tu correo y abrí el link de verificación para continuar.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,55 @@ type OnboardingLayoutProps = PropsWithChildren<{
|
|||||||
|
|
||||||
export function OnboardingLayout({ title, description, children }: OnboardingLayoutProps) {
|
export function OnboardingLayout({ title, description, children }: OnboardingLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-center justify-center px-6">
|
<main className="min-h-screen px-4 py-4 sm:px-6 lg:px-8 lg:py-6">
|
||||||
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
<div className="mx-auto grid min-h-[calc(100vh-2rem)] w-full max-w-6xl gap-6 lg:grid-cols-[0.95fr_1.05fr]">
|
||||||
<h1 className="mb-2 text-2xl font-semibold">{title}</h1>
|
<aside className="hidden overflow-hidden rounded-2xl border border-border/70 bg-card p-8 shadow-sm lg:flex lg:flex-col lg:justify-between">
|
||||||
<p className="mb-4 text-sm text-muted-foreground">{description}</p>
|
<div className="space-y-6">
|
||||||
{children}
|
<div className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-background/70 px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||||
</section>
|
<span className="size-2 rounded-full bg-primary" />
|
||||||
|
Alta guiada
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-muted-foreground">
|
||||||
|
Onboarding
|
||||||
|
</p>
|
||||||
|
<h1 className="max-w-md text-4xl font-semibold tracking-tight">
|
||||||
|
Un alta que guía, no que interrumpe.
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-md text-base leading-7 text-muted-foreground">
|
||||||
|
Verificá email, completá los datos y dejá el complejo listo para operar sin pasos
|
||||||
|
confusos ni pantallas iguales entre sí.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="rounded-2xl border border-border/70 bg-background p-4">
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Paso 1</p>
|
||||||
|
<p className="mt-2 text-sm text-foreground">Ingresar nombre y correo</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-border/70 bg-background p-4">
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Paso 2</p>
|
||||||
|
<p className="mt-2 text-sm text-foreground">Verificar email o código OTP</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-border/70 bg-background p-4">
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Paso 3</p>
|
||||||
|
<p className="mt-2 text-sm text-foreground">Crear el complejo y empezar</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="w-full rounded-2xl border border-border/70 bg-card p-6 text-card-foreground shadow-sm sm:p-8">
|
||||||
|
<div className="mb-6 space-y-2">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-muted-foreground">
|
||||||
|
Inicio
|
||||||
|
</p>
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight">{title}</h1>
|
||||||
|
<p className="max-w-lg text-sm text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function OnboardingStartStep({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
}: OnboardingStartStepProps) {
|
}: OnboardingStartStepProps) {
|
||||||
return (
|
return (
|
||||||
<form className="space-y-3" onSubmit={form.handleSubmit(onSubmit)}>
|
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<Field data-invalid={Boolean(form.formState.errors.fullName)}>
|
<Field data-invalid={Boolean(form.formState.errors.fullName)}>
|
||||||
<FieldLabel htmlFor="onboard-fullname">Nombre</FieldLabel>
|
<FieldLabel htmlFor="onboard-fullname">Nombre</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
@@ -51,7 +51,7 @@ export function OnboardingStartStep({
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={form.formState.isSubmitting || !form.formState.isValid}
|
disabled={form.formState.isSubmitting || !form.formState.isValid}
|
||||||
>
|
>
|
||||||
{form.formState.isSubmitting ? 'Enviando...' : 'Enviar codigo OTP'}
|
{form.formState.isSubmitting ? 'Enviando...' : 'Enviar código OTP'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,13 +38,13 @@ export function OnboardingVerifyOtpStep({
|
|||||||
return (
|
return (
|
||||||
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
|
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Ingresa el codigo de 6 digitos que enviamos a{' '}
|
Ingresa el código de 6 dígitos que enviamos a{' '}
|
||||||
<span className="font-medium text-foreground">{email ?? 'tu email'}</span>.
|
<span className="font-medium text-foreground">{email ?? 'tu email'}</span>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Field data-invalid={Boolean(form.formState.errors.otp)}>
|
<Field data-invalid={Boolean(form.formState.errors.otp)}>
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
<FieldLabel htmlFor="onboard-otp">Codigo de verificacion</FieldLabel>
|
<FieldLabel htmlFor="onboard-otp">Código de verificación</FieldLabel>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -59,7 +59,7 @@ export function OnboardingVerifyOtpStep({
|
|||||||
? 'Reenviando...'
|
? 'Reenviando...'
|
||||||
: resendCooldownSeconds > 0
|
: resendCooldownSeconds > 0
|
||||||
? `Reenviar en ${resendCooldownSeconds}s`
|
? `Reenviar en ${resendCooldownSeconds}s`
|
||||||
: 'Reenviar codigo'}
|
: 'Reenviar código'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ export function OnboardingVerifyOtpStep({
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={form.formState.isSubmitting || !form.formState.isValid}
|
disabled={form.formState.isSubmitting || !form.formState.isValid}
|
||||||
>
|
>
|
||||||
{form.formState.isSubmitting ? 'Verificando...' : 'Verificar codigo'}
|
{form.formState.isSubmitting ? 'Verificando...' : 'Verificar código'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import type { UserProfileResponse } from '@repo/api-contract';
|
import type { UserProfileResponse } from '@repo/api-contract';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { Mail, ShieldCheck } from 'lucide-react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -100,13 +101,16 @@ export function ProfilePage() {
|
|||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-[60vh] w-full max-w-6xl items-center justify-center px-6">
|
<main className="mx-auto flex min-h-[60vh] w-full max-w-6xl items-center justify-center px-4 py-6">
|
||||||
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
<section className="w-full max-w-3xl rounded-2xl border border-border/70 bg-card p-6 text-card-foreground shadow-sm">
|
||||||
<h1 className="text-2xl font-semibold">Perfil</h1>
|
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-muted-foreground">
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
Perfil
|
||||||
Necesitás iniciar sesión para ver tu perfil.
|
|
||||||
</p>
|
</p>
|
||||||
<Button asChild className="mt-4">
|
<h1 className="mt-2 text-3xl font-semibold tracking-tight">Necesitás iniciar sesión</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Ingresá para editar tu nombre, revisar tu correo y cambiar la contraseña.
|
||||||
|
</p>
|
||||||
|
<Button asChild className="mt-5">
|
||||||
<Link to="/login">Ir a Login</Link>
|
<Link to="/login">Ir a Login</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</section>
|
</section>
|
||||||
@@ -115,130 +119,166 @@ export function ProfilePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-[60vh] w-full max-w-6xl items-center justify-center px-6 py-8">
|
<main className="mx-auto w-full max-w-6xl px-4 py-6 sm:px-6 lg:px-8">
|
||||||
<div className="w-full max-w-3xl space-y-6">
|
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
|
||||||
<section className="rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
<section className="relative overflow-hidden rounded-2xl border border-border/70 bg-card p-6 text-card-foreground shadow-sm">
|
||||||
<div className="flex items-center gap-3">
|
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(43,139,82,0.12),transparent_36%)]" />
|
||||||
<UserAvatar
|
<div className="relative space-y-6">
|
||||||
src={profileQuery.data?.avatarUrl ?? avatarUrl}
|
<div className="flex items-center gap-4">
|
||||||
alt={displayName}
|
<UserAvatar
|
||||||
fallbackText={displayName}
|
src={profileQuery.data?.avatarUrl ?? avatarUrl}
|
||||||
size="lg"
|
alt={displayName}
|
||||||
/>
|
fallbackText={displayName}
|
||||||
<div>
|
size="lg"
|
||||||
<h1 className="text-2xl font-semibold">{displayName}</h1>
|
/>
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="min-w-0">
|
||||||
{profileQuery.data?.email ?? user?.email}
|
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-muted-foreground">
|
||||||
|
Perfil
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-2 text-3xl font-semibold tracking-tight">{displayName}</h1>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-background px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||||
|
<Mail className="size-3.5" />
|
||||||
|
{profileQuery.data?.email ?? user?.email}
|
||||||
|
</span>
|
||||||
|
{profileQuery.data?.role && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-background px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||||
|
<ShieldCheck className="size-3.5" />
|
||||||
|
Rol {profileQuery.data.role}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{profileQuery.isLoading && (
|
||||||
|
<p className="text-sm text-muted-foreground">Cargando perfil...</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{profileQuery.isError && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
No se pudo cargar el perfil desde el backend.
|
||||||
</p>
|
</p>
|
||||||
{profileQuery.data?.role && (
|
)}
|
||||||
<p className="text-sm text-muted-foreground">Rol: {profileQuery.data.role}</p>
|
|
||||||
)}
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="rounded-2xl border border-border/70 bg-background p-4">
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Cuenta</p>
|
||||||
|
<p className="mt-2 text-sm text-foreground">Actualizá datos personales y acceso.</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-border/70 bg-background p-4">
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Seguridad
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-foreground">Cambiá tu contraseña cuando quieras.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{profileQuery.isLoading && (
|
|
||||||
<p className="mt-4 text-sm text-muted-foreground">Cargando perfil...</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{profileQuery.isError && (
|
|
||||||
<p className="mt-4 text-sm text-destructive">
|
|
||||||
No se pudo cargar el perfil desde el backend.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
<div className="space-y-6">
|
||||||
<h2 className="text-xl font-semibold mb-4">Editar Perfil</h2>
|
<section className="rounded-2xl border border-border/70 bg-card p-6 text-card-foreground shadow-sm">
|
||||||
<Form {...updateProfileForm}>
|
<h2 className="text-xl font-semibold">Editar perfil</h2>
|
||||||
<form onSubmit={updateProfileForm.handleSubmit(onUpdateProfile)} className="space-y-4">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
<FormField
|
Mantené tu nombre visible y consistente para el resto del equipo.
|
||||||
control={updateProfileForm.control}
|
|
||||||
name="fullName"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Nombre completo</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Button type="submit" disabled={updateProfileMutation.isPending}>
|
|
||||||
{updateProfileMutation.isPending ? 'Guardando...' : 'Guardar cambios'}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
{updateProfileMutation.isError && (
|
|
||||||
<p className="mt-4 text-sm text-destructive">
|
|
||||||
Error al actualizar el perfil: {updateProfileMutation.error?.message}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
<Form {...updateProfileForm}>
|
||||||
{updateProfileMutation.isSuccess && (
|
<form
|
||||||
<p className="mt-4 text-sm text-green-600">Perfil actualizado exitosamente.</p>
|
onSubmit={updateProfileForm.handleSubmit(onUpdateProfile)}
|
||||||
)}
|
className="mt-5 space-y-4"
|
||||||
</section>
|
>
|
||||||
|
<FormField
|
||||||
|
control={updateProfileForm.control}
|
||||||
|
name="fullName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Nombre completo</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={updateProfileMutation.isPending}>
|
||||||
|
{updateProfileMutation.isPending ? 'Guardando...' : 'Guardar cambios'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
{updateProfileMutation.isError && (
|
||||||
|
<p className="mt-4 text-sm text-destructive">
|
||||||
|
Error al actualizar el perfil: {updateProfileMutation.error?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{updateProfileMutation.isSuccess && (
|
||||||
|
<p className="mt-4 text-sm text-emerald-700">Perfil actualizado exitosamente.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
<section className="rounded-2xl border border-border/70 bg-card p-6 text-card-foreground shadow-sm">
|
||||||
<h2 className="text-xl font-semibold mb-4">Cambiar Contraseña</h2>
|
<h2 className="text-xl font-semibold">Cambiar contraseña</h2>
|
||||||
<Form {...updatePasswordForm}>
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
<form
|
Usá una contraseña fuerte para proteger el acceso al panel.
|
||||||
onSubmit={updatePasswordForm.handleSubmit(onUpdatePassword)}
|
|
||||||
className="space-y-4"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={updatePasswordForm.control}
|
|
||||||
name="currentPassword"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Contraseña actual</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="password" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={updatePasswordForm.control}
|
|
||||||
name="newPassword"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Nueva contraseña</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="password" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={updatePasswordForm.control}
|
|
||||||
name="confirmPassword"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Confirmar nueva contraseña</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="password" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Button type="submit" disabled={updatePasswordMutation.isPending}>
|
|
||||||
{updatePasswordMutation.isPending ? 'Cambiando...' : 'Cambiar contraseña'}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
{updatePasswordMutation.isError && (
|
|
||||||
<p className="mt-4 text-sm text-destructive">
|
|
||||||
Error al cambiar la contraseña: {updatePasswordMutation.error?.message}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
<Form {...updatePasswordForm}>
|
||||||
{updatePasswordMutation.isSuccess && (
|
<form
|
||||||
<p className="mt-4 text-sm text-green-600">Contraseña cambiada exitosamente.</p>
|
onSubmit={updatePasswordForm.handleSubmit(onUpdatePassword)}
|
||||||
)}
|
className="mt-5 space-y-4"
|
||||||
</section>
|
>
|
||||||
|
<FormField
|
||||||
|
control={updatePasswordForm.control}
|
||||||
|
name="currentPassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Contraseña actual</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={updatePasswordForm.control}
|
||||||
|
name="newPassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Nueva contraseña</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={updatePasswordForm.control}
|
||||||
|
name="confirmPassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Confirmar nueva contraseña</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={updatePasswordMutation.isPending}>
|
||||||
|
{updatePasswordMutation.isPending ? 'Cambiando...' : 'Cambiar contraseña'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
{updatePasswordMutation.isError && (
|
||||||
|
<p className="mt-4 text-sm text-destructive">
|
||||||
|
Error al cambiar la contraseña: {updatePasswordMutation.error?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{updatePasswordMutation.isSuccess && (
|
||||||
|
<p className="mt-4 text-sm text-emerald-700">Contraseña cambiada exitosamente.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,13 +3,22 @@ import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { ApiClientError, apiClient } from '@/lib/api-client';
|
import { ApiClientError, apiClient } from '@/lib/api-client';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
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 { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { Dumbbell } from 'lucide-react';
|
import { Dumbbell } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import {SoccerBallIcon, RacquetIcon, TennisBallIcon,BasketballIcon, VolleyballIcon, BarbellIcon, PersonSimpleRunIcon, PingPongIcon} from '@phosphor-icons/react'
|
|
||||||
|
|
||||||
const sportConfig: Record<
|
const sportConfig: Record<
|
||||||
string,
|
string,
|
||||||
@@ -154,6 +163,18 @@ function extractMessage(error: unknown, fallback: string) {
|
|||||||
return fallback;
|
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) {
|
export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const today = useMemo(() => new Date(), []);
|
const today = useMemo(() => new Date(), []);
|
||||||
@@ -289,6 +310,13 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
|
|||||||
.filter((court) => court.availableSlots.length > 0);
|
.filter((court) => court.availableSlots.length > 0);
|
||||||
}, [availabilityQuery.data?.courts, selectedDate, todayIsoDate]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!selectedSlot) return;
|
if (!selectedSlot) return;
|
||||||
|
|
||||||
@@ -403,249 +431,362 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-linear-to-b from-background to-muted/40">
|
<main className="min-h-screen px-4 py-4 sm:px-6 lg:px-8 lg:py-6">
|
||||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4 px-4 py-4 sm:gap-6 sm:px-6 sm:py-8">
|
<div className="mx-auto w-full max-w-7xl space-y-6">
|
||||||
<section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
|
<section className="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="grid gap-6 p-6 lg:grid-cols-[1.1fr_0.9fr] lg:p-8">
|
||||||
<h1 className="text-2xl font-bold text-primary sm:text-3xl">
|
<div className="space-y-6">
|
||||||
{availabilityQuery.data?.complexName}
|
<div className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-background px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||||
</h1>
|
<span className="flex size-5 items-center justify-center rounded-full bg-primary text-[10px] font-semibold text-primary-foreground">
|
||||||
{availabilityQuery.data?.complexAddress && (
|
PZ
|
||||||
<p className="text-sm text-muted-foreground">
|
</span>
|
||||||
{availabilityQuery.data.complexAddress}
|
Reserva oficial
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-muted-foreground">
|
||||||
|
{availabilityQuery.data?.complexSlug ?? complexSlug}
|
||||||
|
</p>
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight text-foreground sm:text-4xl">
|
||||||
|
{availabilityQuery.data?.complexName ?? 'Cargando complejo...'}
|
||||||
|
</h1>
|
||||||
|
{availabilityQuery.data?.complexAddress && (
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
{availabilityQuery.data.complexAddress}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="max-w-2xl text-sm leading-6 text-muted-foreground sm:text-base">
|
||||||
|
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.
|
||||||
</p>
|
</p>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="rounded-2xl border border-border/70 bg-background p-4">
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Fecha elegida
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm font-medium capitalize text-foreground">
|
||||||
|
{selectedDateLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-border/70 bg-background p-4">
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Deporte</p>
|
||||||
|
<p className="mt-2 text-sm font-medium text-foreground">
|
||||||
|
{selectedSportLabel ?? 'Sin selección'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-border/70 bg-background p-4">
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Canchas visibles
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm font-medium text-foreground">
|
||||||
|
{visibleCourts.length} disponibles
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-border/70 bg-background p-4">
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Estado</p>
|
||||||
|
<p className="mt-2 text-sm font-medium text-foreground">
|
||||||
|
{selectedSlot ? 'Turno listo para confirmar' : 'Elegí un horario'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-sm text-muted-foreground">
|
|
||||||
Elige un dia, revisa horarios disponibles y confirma con tu nombre y telefono.
|
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
|
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
|
||||||
<div className="space-y-3">
|
<section className="space-y-6">
|
||||||
<div>
|
<section className="rounded-2xl border border-border/70 bg-card p-5 shadow-sm sm:p-6">
|
||||||
<h2 className="text-sm font-medium">Dia</h2>
|
<div className="space-y-2">
|
||||||
<p className="text-xs text-muted-foreground">Mostramos 5 dias por vez.</p>
|
<h2 className="text-sm font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
</div>
|
Día
|
||||||
{autoAdjustedDateNotice && (
|
</h2>
|
||||||
<p className="rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
<p className="text-sm text-muted-foreground">Mostramos 5 días por vez.</p>
|
||||||
{autoAdjustedDateNotice}
|
</div>
|
||||||
</p>
|
|
||||||
)}
|
{autoAdjustedDateNotice && (
|
||||||
|
<p className="mt-4 rounded-2xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-200">
|
||||||
|
{autoAdjustedDateNotice}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-[2.5rem_repeat(5,minmax(0,1fr))_2.5rem] gap-2">
|
||||||
|
{canGoBack ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-full rounded-2xl"
|
||||||
|
onClick={goToPreviousFiveDays}
|
||||||
|
aria-label="Mostrar 5 días anteriores"
|
||||||
|
>
|
||||||
|
{'<'}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dayOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedDate(option.value);
|
||||||
|
setSelectedSlot(null);
|
||||||
|
setAutoAdjustedDateNotice(null);
|
||||||
|
}}
|
||||||
|
className={`rounded-2xl border px-2 py-3 text-center transition-all ${
|
||||||
|
selectedDate === option.value
|
||||||
|
? 'border-primary bg-primary text-primary-foreground shadow-sm'
|
||||||
|
: 'border-border/70 bg-background hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-sm'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.16em]">
|
||||||
|
{option.weekday}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium">{option.dayMonth}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
<div className="grid grid-cols-[2.25rem_repeat(5,minmax(0,1fr))_2.25rem] gap-2">
|
|
||||||
{canGoBack ? (
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-full"
|
className="h-full rounded-2xl"
|
||||||
onClick={goToPreviousFiveDays}
|
onClick={goToNextFiveDays}
|
||||||
aria-label="Mostrar 5 dias anteriores"
|
aria-label="Mostrar próximos 5 días"
|
||||||
>
|
>
|
||||||
{'<'}
|
{'>'}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
</div>
|
||||||
<div />
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-border/70 bg-card p-5 shadow-sm sm:p-6">
|
||||||
|
<div className="mb-4 space-y-2">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Disponibilidad
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Seleccioná cancha y horario.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{availabilityQuery.data?.sportSelectionRequired && (
|
||||||
|
<div className="mb-5">
|
||||||
|
<h3 className="mb-3 text-sm font-medium text-foreground">Elegí tu deporte</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
{availabilityQuery.data?.sports.map((sport) => {
|
||||||
|
const config = getSportConfig(sport.slug);
|
||||||
|
const Icon = config.icon;
|
||||||
|
const isSelected = selectedSportId === sport.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={sport.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSportId(sport.id);
|
||||||
|
setSelectedSlot(null);
|
||||||
|
setAutoAdjustedDateNotice(null);
|
||||||
|
}}
|
||||||
|
className={`flex flex-col items-center gap-2 rounded-2xl border p-4 text-center transition-all ${
|
||||||
|
isSelected
|
||||||
|
? `${config.border} ${config.bg} shadow-sm ring-2 ring-primary/20`
|
||||||
|
: 'border-border/70 bg-background hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-sm'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={`size-8 ${
|
||||||
|
isSelected ? config.color : 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
isSelected ? 'text-foreground' : 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dayOptions.map((option) => (
|
{availabilityQuery.isLoading && (
|
||||||
<button
|
<p className="rounded-2xl border border-border/70 bg-background px-4 py-3 text-sm text-muted-foreground">
|
||||||
key={option.value}
|
Buscando disponibilidad...
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedDate(option.value);
|
|
||||||
setSelectedSlot(null);
|
|
||||||
setAutoAdjustedDateNotice(null);
|
|
||||||
}}
|
|
||||||
className={`rounded-xl border px-1 py-2 text-center transition-colors sm:px-2 ${
|
|
||||||
selectedDate === option.value
|
|
||||||
? 'border-primary bg-primary text-primary-foreground'
|
|
||||||
: 'border-border bg-background hover:bg-muted'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<p className="text-[11px] font-medium uppercase sm:text-xs">{option.weekday}</p>
|
|
||||||
<p className="text-xs sm:text-sm">{option.dayMonth}</p>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-full"
|
|
||||||
onClick={goToNextFiveDays}
|
|
||||||
aria-label="Mostrar proximos 5 dias"
|
|
||||||
>
|
|
||||||
{'>'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
|
|
||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-medium">Disponibilidad</h2>
|
|
||||||
<p className="text-xs text-muted-foreground">Selecciona cancha y horario.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{availabilityQuery.data?.sportSelectionRequired && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<h2 className="text-sm font-medium mb-3">Elegí tu deporte</h2>
|
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
|
||||||
{availabilityQuery.data?.sports.map((sport) => {
|
|
||||||
const config = getSportConfig(sport.slug);
|
|
||||||
const Icon = config.icon;
|
|
||||||
const isSelected = selectedSportId === sport.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={sport.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedSportId(sport.id);
|
|
||||||
setSelectedSlot(null);
|
|
||||||
setAutoAdjustedDateNotice(null);
|
|
||||||
}}
|
|
||||||
className={`relative flex flex-col items-center gap-2 rounded-xl border-2 p-4 transition-all hover:scale-[1.02] ${
|
|
||||||
isSelected
|
|
||||||
? `${config.border} ${config.bg} ring-2 ring-offset-2 ring-primary/20`
|
|
||||||
: 'border-border bg-card hover:border-muted-foreground/30'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className={`size-8 ${isSelected ? config.color : 'text-muted-foreground'}`} />
|
|
||||||
<span className={`text-sm font-medium ${isSelected ? 'text-foreground' : 'text-muted-foreground'}`}>
|
|
||||||
{config.label}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{availabilityQuery.isLoading && (
|
|
||||||
<p className="text-sm text-muted-foreground">Buscando disponibilidad...</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{availabilityQuery.isError && (
|
|
||||||
<p className="text-sm text-destructive">
|
|
||||||
{extractMessage(availabilityQuery.error, 'No pudimos cargar la disponibilidad.')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!availabilityQuery.isLoading &&
|
|
||||||
!availabilityQuery.isError &&
|
|
||||||
availabilityQuery.data?.sportSelectionRequired &&
|
|
||||||
!selectedSportId && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Selecciona un deporte para ver los horarios disponibles.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!availabilityQuery.isLoading &&
|
|
||||||
!availabilityQuery.isError &&
|
|
||||||
availabilityQuery.data &&
|
|
||||||
(!availabilityQuery.data.sportSelectionRequired || selectedSportId) &&
|
|
||||||
visibleCourts.length === 0 && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
No hay horarios disponibles para la fecha elegida.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{visibleCourts.map((court) => (
|
|
||||||
<article key={court.courtId} className="rounded-xl border bg-background p-3 sm:p-4">
|
|
||||||
<div className="mb-3 flex flex-col gap-1">
|
|
||||||
<p className="text-sm font-medium sm:text-base">{court.courtName}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{court.sport.name} · Turnos de {court.slotDurationMinutes} min
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
|
||||||
{court.availableSlots.map((slot) => {
|
|
||||||
const isSelected =
|
|
||||||
selectedSlot?.courtId === court.courtId &&
|
|
||||||
selectedSlot.startTime === slot.startTime;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`${court.courtId}-${slot.startTime}`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedSlot({
|
|
||||||
courtId: court.courtId,
|
|
||||||
courtName: court.courtName,
|
|
||||||
sportId: court.sport.id,
|
|
||||||
sportName: court.sport.name,
|
|
||||||
startTime: slot.startTime,
|
|
||||||
endTime: slot.endTime,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className={`h-10 rounded-lg border text-sm transition-colors ${
|
|
||||||
isSelected
|
|
||||||
? 'border-primary bg-primary text-primary-foreground'
|
|
||||||
: 'border-border bg-card hover:bg-muted'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{slot.startTime}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{selectedSlot && (
|
|
||||||
<section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
|
|
||||||
<h2 className="text-sm font-medium">Confirmar turno</h2>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{selectedSlot.courtName} · {selectedSlot.sportName} · {selectedSlot.startTime} -{' '}
|
|
||||||
{selectedSlot.endTime}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form className="mt-4 space-y-3" onSubmit={handleSubmit(onSubmit)}>
|
|
||||||
<Field data-invalid={Boolean(errors.customerName)}>
|
|
||||||
<FieldLabel htmlFor="customerName">Nombre</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="customerName"
|
|
||||||
placeholder="Ej: Juan Perez"
|
|
||||||
aria-invalid={Boolean(errors.customerName)}
|
|
||||||
{...register('customerName')}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.customerName]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={Boolean(errors.customerPhone)}>
|
|
||||||
<FieldLabel htmlFor="customerPhone">Telefono</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="customerPhone"
|
|
||||||
type="tel"
|
|
||||||
placeholder="Ej: 3875551234"
|
|
||||||
aria-invalid={Boolean(errors.customerPhone)}
|
|
||||||
{...register('customerPhone')}
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.customerPhone]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{createBookingMutation.isError && (
|
|
||||||
<p className="text-sm text-destructive">
|
|
||||||
{extractMessage(createBookingMutation.error, 'No pudimos confirmar el turno.')}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{navigationError && <p className="text-sm text-destructive">{navigationError}</p>}
|
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={!isValid || isSubmitting}>
|
{availabilityQuery.isError && (
|
||||||
{createBookingMutation.isPending ? 'Confirmando...' : 'Confirmar turno'}
|
<p className="rounded-2xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||||
</Button>
|
{extractMessage(availabilityQuery.error, 'No pudimos cargar la disponibilidad.')}
|
||||||
</form>
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!availabilityQuery.isLoading &&
|
||||||
|
!availabilityQuery.isError &&
|
||||||
|
availabilityQuery.data?.sportSelectionRequired &&
|
||||||
|
!selectedSportId && (
|
||||||
|
<p className="rounded-2xl border border-border/70 bg-background px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
Seleccioná un deporte para ver los horarios disponibles.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!availabilityQuery.isLoading &&
|
||||||
|
!availabilityQuery.isError &&
|
||||||
|
availabilityQuery.data &&
|
||||||
|
(!availabilityQuery.data.sportSelectionRequired || selectedSportId) &&
|
||||||
|
visibleCourts.length === 0 && (
|
||||||
|
<p className="rounded-2xl border border-border/70 bg-background px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
No hay horarios disponibles para la fecha elegida.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{visibleCourts.map((court) => (
|
||||||
|
<article
|
||||||
|
key={court.courtId}
|
||||||
|
className="rounded-2xl border border-border/70 bg-background p-4 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-foreground sm:text-base">
|
||||||
|
{court.courtName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{court.sport.name} · Turnos de {court.slotDurationMinutes} min
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-border/70 bg-card px-3 py-1 text-[11px] font-medium text-muted-foreground">
|
||||||
|
{court.availableSlots.length} horarios
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
{court.availableSlots.map((slot) => {
|
||||||
|
const isSelected =
|
||||||
|
selectedSlot?.courtId === court.courtId &&
|
||||||
|
selectedSlot.startTime === slot.startTime;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${court.courtId}-${slot.startTime}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSlot({
|
||||||
|
courtId: court.courtId,
|
||||||
|
courtName: court.courtName,
|
||||||
|
sportId: court.sport.id,
|
||||||
|
sportName: court.sport.name,
|
||||||
|
startTime: slot.startTime,
|
||||||
|
endTime: slot.endTime,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={`h-11 rounded-xl border text-sm font-medium transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-primary bg-primary text-primary-foreground shadow-sm'
|
||||||
|
: 'border-border/70 bg-card hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-sm'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{slot.startTime}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
)}
|
|
||||||
|
<aside className="xl:sticky xl:top-24 h-fit space-y-6 self-start">
|
||||||
|
<section className="rounded-2xl border border-border/70 bg-card p-5 shadow-sm sm:p-6">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Resumen
|
||||||
|
</h2>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div className="rounded-2xl border border-border/70 bg-background p-4">
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Día</p>
|
||||||
|
<p className="mt-1 text-sm font-medium capitalize text-foreground">
|
||||||
|
{selectedDateLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-border/70 bg-background p-4">
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Horario
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-foreground">
|
||||||
|
{selectedSlot
|
||||||
|
? `${selectedSlot.startTime} - ${selectedSlot.endTime}`
|
||||||
|
: 'Todavía no elegiste un horario'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-border/70 bg-background p-4">
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Cancha
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-foreground">
|
||||||
|
{selectedSlot?.courtName ?? 'Seleccioná una cancha disponible'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedSlot ? (
|
||||||
|
<form className="mt-5 space-y-4" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className="rounded-2xl border border-primary/20 bg-primary/5 p-4 text-sm text-foreground">
|
||||||
|
<p className="font-medium">Turno seleccionado</p>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
{selectedSlot.courtName} · {selectedSlot.sportName} · {selectedSlot.startTime}{' '}
|
||||||
|
- {selectedSlot.endTime}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field data-invalid={Boolean(errors.customerName)}>
|
||||||
|
<FieldLabel htmlFor="customerName">Nombre</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="customerName"
|
||||||
|
placeholder="Ej: Juan Perez"
|
||||||
|
aria-invalid={Boolean(errors.customerName)}
|
||||||
|
{...register('customerName')}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.customerName]} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={Boolean(errors.customerPhone)}>
|
||||||
|
<FieldLabel htmlFor="customerPhone">Teléfono</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="customerPhone"
|
||||||
|
type="tel"
|
||||||
|
placeholder="Ej: 3875551234"
|
||||||
|
aria-invalid={Boolean(errors.customerPhone)}
|
||||||
|
{...register('customerPhone')}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.customerPhone]} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{createBookingMutation.isError && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{extractMessage(
|
||||||
|
createBookingMutation.error,
|
||||||
|
'No pudimos confirmar el turno.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{navigationError && <p className="text-sm text-destructive">{navigationError}</p>}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={!isValid || isSubmitting}>
|
||||||
|
{createBookingMutation.isPending ? 'Confirmando...' : 'Confirmar turno'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="mt-5 rounded-2xl border border-dashed border-border/70 bg-background p-4 text-sm text-muted-foreground">
|
||||||
|
Elegí un horario disponible para completar la reserva.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { apiClient } from '@/lib/api-client';
|
|||||||
import type { ComplexWithRole } from '@repo/api-contract';
|
import type { ComplexWithRole } from '@repo/api-contract';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { Building2, Loader2 } from 'lucide-react';
|
import { ArrowRight, Building2, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
export function SelectComplexPage() {
|
export function SelectComplexPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -30,7 +30,7 @@ export function SelectComplexPage() {
|
|||||||
if (complexesQuery.isLoading) {
|
if (complexesQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
<Loader2 className="h-9 w-9 animate-spin text-muted-foreground" />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -61,38 +61,93 @@ export function SelectComplexPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
<main className="min-h-screen px-4 py-4 sm:px-6 lg:px-8 lg:py-6">
|
||||||
<section className="w-full max-w-md space-y-6">
|
<div className="mx-auto grid min-h-[calc(100vh-2rem)] w-full max-w-6xl gap-6 lg:grid-cols-[0.9fr_1.1fr]">
|
||||||
<div className="space-y-2">
|
<section className="hidden overflow-hidden rounded-2xl border border-border/70 bg-card p-8 shadow-sm lg:flex lg:flex-col lg:justify-between">
|
||||||
<h1 className="text-2xl font-semibold">Seleccioná un complejo</h1>
|
<div className="space-y-6">
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-background/70 px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||||
Elegí el complejo con el que vas a trabajar.
|
<span className="size-2 rounded-full bg-primary" />
|
||||||
</p>
|
Cambio de contexto
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-muted-foreground">
|
||||||
|
Playzer
|
||||||
|
</p>
|
||||||
|
<h1 className="max-w-md text-4xl font-semibold tracking-tight">
|
||||||
|
Elegí el complejo con el que vas a operar.
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-md text-base leading-7 text-muted-foreground">
|
||||||
|
Cada complejo tiene su propio estado, su lista de canchas y su panel de reservas.
|
||||||
|
Seleccioná uno para entrar con todo el contexto listo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="grid gap-3">
|
||||||
{complexes.map((complex) => (
|
<div className="rounded-2xl border border-border/70 bg-background p-4">
|
||||||
<button
|
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
key={complex.id}
|
Qué cambia
|
||||||
type="button"
|
</p>
|
||||||
className="flex w-full items-center gap-3 rounded-lg border p-4 text-left transition-colors hover:bg-accent"
|
<p className="mt-2 text-sm text-foreground">
|
||||||
onClick={() => handleSelect(complex)}
|
Reservas, usuarios, configuración y disponibilidad se actualizan según el complejo
|
||||||
disabled={selectMutation.isPending}
|
seleccionado.
|
||||||
>
|
</p>
|
||||||
<Building2 className="h-5 w-5 text-muted-foreground" />
|
</div>
|
||||||
<div className="flex-1">
|
<div className="rounded-2xl border border-border/70 bg-background p-4">
|
||||||
<p className="font-medium">{complex.complexName}</p>
|
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Tip</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-foreground">
|
||||||
{complex.city ?? 'Sin ciudad'} · {complex.role}
|
Si administrás varios complejos, el menú superior te deja cambiar sin perder el
|
||||||
</p>
|
hilo.
|
||||||
</div>
|
</p>
|
||||||
{selectMutation.isPending && selectMutation.variables === complex.id && (
|
</div>
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
</div>
|
||||||
)}
|
</section>
|
||||||
</button>
|
|
||||||
))}
|
<section className="w-full rounded-2xl border border-border/70 bg-card p-6 shadow-sm sm:p-8">
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
</section>
|
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-muted-foreground">
|
||||||
|
Selección
|
||||||
|
</p>
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight">Seleccioná un complejo</h1>
|
||||||
|
<p className="max-w-md text-sm text-muted-foreground">
|
||||||
|
Elegí el espacio con el que vas a trabajar ahora mismo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
{complexes.map((complex) => (
|
||||||
|
<button
|
||||||
|
key={complex.id}
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-4 rounded-2xl border border-border/70 bg-background p-4 text-left transition-all hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-sm"
|
||||||
|
onClick={() => handleSelect(complex)}
|
||||||
|
disabled={selectMutation.isPending}
|
||||||
|
>
|
||||||
|
<div className="flex size-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||||
|
<Building2 className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium text-foreground">{complex.complexName}</p>
|
||||||
|
<span className="rounded-full border border-border/70 bg-card px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||||
|
{complex.role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{complex.city ?? 'Sin ciudad'} · {complex.complexSlug}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{selectMutation.isPending && selectMutation.variables === complex.id && (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{!selectMutation.isPending && (
|
||||||
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,46 +42,46 @@
|
|||||||
--radius-sm: calc(var(--radius) * 0.6);
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
--radius-md: calc(var(--radius) * 0.8);
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) * 1.4);
|
--radius-xl: calc(var(--radius) * 1.2);
|
||||||
--radius-2xl: calc(var(--radius) * 1.8);
|
--radius-2xl: calc(var(--radius) * 1.5);
|
||||||
--radius-3xl: calc(var(--radius) * 2.2);
|
--radius-3xl: calc(var(--radius) * 1.8);
|
||||||
--radius-4xl: calc(var(--radius) * 2.6);
|
--radius-4xl: calc(var(--radius) * 2.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Base colors (retain shadcn structure) */
|
/* Base colors (retaining the shadcn structure, but with a sports-club identity) */
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(0.985 0.01 145);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.16 0.02 145);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(0.995 0.005 145);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.16 0.02 145);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(0.995 0.005 145);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.16 0.02 145);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.42 0.11 145);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0.01 145);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.95 0.015 145);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.24 0.02 145);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.95 0.01 145);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.49 0.02 145);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.93 0.05 82);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.28 0.05 82);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.58 0.22 27);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.9 0.01 145);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.89 0.01 145);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.55 0.12 145);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--chart-1: oklch(0.87 0 0);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--chart-2: oklch(0.556 0 0);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--chart-3: oklch(0.439 0 0);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--chart-4: oklch(0.371 0 0);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--chart-5: oklch(0.269 0 0);
|
||||||
--radius: 0.625rem;
|
--radius: 0.5rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.99 0.005 145);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.16 0.02 145);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: oklch(0.42 0.11 145);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0.01 145);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.93 0.05 82);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.28 0.05 82);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.9 0.01 145);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.55 0.12 145);
|
||||||
|
|
||||||
/* Surface elevation (light mode: higher = lighter) */
|
/* Surface elevation (light mode: higher = lighter) */
|
||||||
--surface-base: oklch(1 0 0);
|
--surface-base: oklch(1 0 0);
|
||||||
@@ -120,37 +120,37 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.14 0.015 145);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.98 0.01 145);
|
||||||
--card: oklch(0.205 0 0);
|
--card: oklch(0.19 0.02 145);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.98 0.01 145);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.2 0.02 145);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.98 0.01 145);
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: oklch(0.76 0.11 145);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.15 0.02 145);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.24 0.02 145);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.98 0.01 145);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.24 0.02 145);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.72 0.02 145);
|
||||||
--accent: oklch(0.269 0 0);
|
--accent: oklch(0.28 0.05 82);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.98 0.01 82);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 12%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.72 0.08 145);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--chart-1: oklch(0.87 0 0);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--chart-2: oklch(0.556 0 0);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--chart-3: oklch(0.439 0 0);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--chart-4: oklch(0.371 0 0);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--chart-5: oklch(0.269 0 0);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.18 0.02 145);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.98 0.01 145);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.76 0.11 145);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.15 0.02 145);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.28 0.05 82);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.98 0.01 82);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--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 elevation (dark mode: higher = lighter) */
|
||||||
--surface-base: oklch(0.145 0 0);
|
--surface-base: oklch(0.145 0 0);
|
||||||
@@ -192,10 +192,36 @@
|
|||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
|
||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user