diff --git a/apps/frontend/index.html b/apps/frontend/index.html index 3df409b..0c6bf41 100644 --- a/apps/frontend/index.html +++ b/apps/frontend/index.html @@ -2,11 +2,11 @@ - + PlayZer - +
diff --git a/apps/frontend/src/App.css b/apps/frontend/src/App.css index e87c16a..e69de29 100644 --- a/apps/frontend/src/App.css +++ b/apps/frontend/src/App.css @@ -1,182 +0,0 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) scale(0.8); - } -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ""; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } -} diff --git a/apps/frontend/src/assets/playzer-favicon-512-transparent.png b/apps/frontend/src/assets/playzer-favicon-512-transparent.png new file mode 100644 index 0000000..c4d6d05 Binary files /dev/null and b/apps/frontend/src/assets/playzer-favicon-512-transparent.png differ diff --git a/apps/frontend/src/assets/playzer-favicon-transparent.ico b/apps/frontend/src/assets/playzer-favicon-transparent.ico new file mode 100644 index 0000000..b8e278e Binary files /dev/null and b/apps/frontend/src/assets/playzer-favicon-transparent.ico differ diff --git a/apps/frontend/src/assets/playzer-favicon.ico b/apps/frontend/src/assets/playzer-favicon.ico new file mode 100644 index 0000000..4477a2d Binary files /dev/null and b/apps/frontend/src/assets/playzer-favicon.ico differ diff --git a/apps/frontend/src/assets/playzer-icon-p-transparent.png b/apps/frontend/src/assets/playzer-icon-p-transparent.png new file mode 100644 index 0000000..8bbdd61 Binary files /dev/null and b/apps/frontend/src/assets/playzer-icon-p-transparent.png differ diff --git a/apps/frontend/src/assets/playzer-icon-p-transparent.svg b/apps/frontend/src/assets/playzer-icon-p-transparent.svg new file mode 100644 index 0000000..e64166b --- /dev/null +++ b/apps/frontend/src/assets/playzer-icon-p-transparent.svg @@ -0,0 +1,4 @@ + + Playzer P icon + + diff --git a/apps/frontend/src/assets/playzer-icon-p.svg b/apps/frontend/src/assets/playzer-icon-p.svg new file mode 100644 index 0000000..7b16b70 --- /dev/null +++ b/apps/frontend/src/assets/playzer-icon-p.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/frontend/src/assets/playzer-logo-transparent.png b/apps/frontend/src/assets/playzer-logo-transparent.png new file mode 100644 index 0000000..3beb022 Binary files /dev/null and b/apps/frontend/src/assets/playzer-logo-transparent.png differ diff --git a/apps/frontend/src/assets/playzer-logo-transparent.svg b/apps/frontend/src/assets/playzer-logo-transparent.svg new file mode 100644 index 0000000..ad8ff04 --- /dev/null +++ b/apps/frontend/src/assets/playzer-logo-transparent.svg @@ -0,0 +1,4 @@ + + Playzer logo + + diff --git a/apps/frontend/src/assets/playzer-logo.svg b/apps/frontend/src/assets/playzer-logo.svg new file mode 100644 index 0000000..b6a8a3c --- /dev/null +++ b/apps/frontend/src/assets/playzer-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/frontend/src/components/header.tsx b/apps/frontend/src/components/header.tsx new file mode 100644 index 0000000..ccce9d3 --- /dev/null +++ b/apps/frontend/src/components/header.tsx @@ -0,0 +1,488 @@ +import { + CalendarDays, + Check, + Laptop, + LogOut, + Menu, + Moon, + Settings, + Sun, + User, + X, +} from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Link, useLocation, useNavigate } from '@tanstack/react-router'; + +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; +import { UserAvatar } from '@/components/user-avatar'; +import { apiClient } from '@/lib/api-client'; +import { useAuth } from '@/lib/auth'; +import { acceptStoredPendingInvite } from '@/lib/invitations'; +import { useCurrentComplexStore } from '@/lib/stores/current-complex-store'; +import { useTheme } from '@/lib/theme'; + +interface NavItemProps { + to: '/' | '/about'; + label: string; + isMobile?: boolean; + onClick?: () => void; +} + +function NavItem({ to, label, isMobile, onClick }: NavItemProps) { + const location = useLocation(); + const currentPath = location.pathname; + const isActive = to === '/' ? currentPath === '/' : currentPath.startsWith(to); + + return ( + + + + ); +} + +export function Header() { + const [open, setOpen] = useState(false); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { user, isAuthenticated, displayName, avatarUrl, signOut } = useAuth(); + const { theme, setTheme } = useTheme(); + const { currentComplexSlug, setCurrentComplex } = useCurrentComplexStore(); + const processedInviteForUser = useRef(null); + const myComplexesQuery = useQuery({ + queryKey: ['my-complexes'], + enabled: isAuthenticated, + queryFn: () => apiClient.complexes.listMine(), + }); + + useEffect(() => { + if (!isAuthenticated) { + processedInviteForUser.current = null; + return; + } + + const userKey = user?.email ?? displayName ?? 'authenticated-user'; + + if (processedInviteForUser.current === userKey) { + return; + } + + processedInviteForUser.current = userKey; + + void (async () => { + await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations); + await queryClient.invalidateQueries({ queryKey: ['my-complexes'] }); + })(); + }, [displayName, isAuthenticated, queryClient, user?.email]); + + const complexSlug = currentComplexSlug || myComplexesQuery.data?.[0]?.complexSlug; + const currentComplexName = useMemo(() => { + const complexes = myComplexesQuery.data ?? []; + if (complexes.length === 0) return 'Mi complejo'; + + const selected = complexes.find((complex) => complex.complexSlug === complexSlug); + + return selected?.complexName ?? complexes[0]?.complexName ?? 'Mi complejo'; + }, [complexSlug, myComplexesQuery.data]); + + const menuItems = [ + { label: 'Inicio', to: '/' }, + { label: 'About', to: '/about' }, + ] as const; + + const handleSelectComplex = async (complexId: string, nextSlug: string) => { + await apiClient.complexes.select({ complexId }); + setCurrentComplex(nextSlug); + await queryClient.invalidateQueries({ queryKey: ['current-complex'] }); + }; + + const handleSignOut = async () => { + await signOut(); + await navigate({ to: '/login' }); + }; + + const handleOpenBookingCreate = async () => { + if (location.pathname !== '/') { + await navigate({ to: '/' }); + } + + window.setTimeout(() => { + window.dispatchEvent(new Event('playzer:open-booking-create')); + }, 0); + }; + + return ( +
+
+ +
+ +
+ Playzer +
+ + + Playzer + + + + + +
+ +
+ +
+ + {isAuthenticated ? ( + + + + + + + +
+ + +
+ {displayName} + {user?.email} + + {currentComplexName} + +
+
+
+ + + + {myComplexesQuery.data && myComplexesQuery.data.length > 1 && ( + <> + + Cambiar complejo + + {myComplexesQuery.data.map((complex) => ( + { + void handleSelectComplex(complex.id, complex.complexSlug); + }} + > + + {complex.complexSlug === complexSlug && } + + {complex.complexName} + + ))} + + + )} + + + Apariencia + + setTheme(value as 'light' | 'dark' | 'system')} + > + + + Light + + + + Dark + + + + System + + + + + + { + void navigate({ to: '/profile' }); + }} + > + + Mi Perfil + + + {currentComplexSlug && ( + { + void navigate({ + to: '/complex/$slug/edit', + params: { slug: currentComplexSlug }, + }); + }} + > + + Configuración + + )} + + + + { + void handleSignOut(); + }} + > + + Cerrar sesión + +
+
+ ) : ( + + )} + + + + + + + +
+
+
+ + +
+

{displayName}

+

{user?.email}

+ {isAuthenticated && ( +

{currentComplexName}

+ )} +
+
+
+ + + + + +
+
+

+ Cuenta +

+
+ + +
+
+
+
+
+ +
+
+ ); +} diff --git a/apps/frontend/src/components/layout.tsx b/apps/frontend/src/components/layout.tsx new file mode 100644 index 0000000..7f46592 --- /dev/null +++ b/apps/frontend/src/components/layout.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react'; +import { Header } from './header'; + +interface LayoutProps { + children: ReactNode; +} + +export function Layout({ children }: LayoutProps) { + return ( +
+ +
+
+ {children} +
+
+ ); +} + +function Background() { + return ( +
+
+ +
+
+
+ +
+ +
+
+ ); +} diff --git a/apps/frontend/src/components/ui/calendar.tsx b/apps/frontend/src/components/ui/calendar.tsx index 5f8ad29..fb6ac8b 100644 --- a/apps/frontend/src/components/ui/calendar.tsx +++ b/apps/frontend/src/components/ui/calendar.tsx @@ -4,12 +4,14 @@ import { DayPicker } from 'react-day-picker'; import { buttonVariants } from '@/components/ui/button'; import { cn } from '@/lib/utils'; +import { es } from 'date-fns/locale'; export type CalendarProps = React.ComponentProps; function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) { return ( ) { + return ; +} + +function SheetTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function SheetClose({ ...props }: React.ComponentProps) { + return ; +} + +function SheetPortal({ ...props }: React.ComponentProps) { + return ; +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SheetContent({ + className, + children, + side = 'right', + showCloseButton = true, + ...props +}: React.ComponentProps & { + side?: 'top' | 'right' | 'bottom' | 'left'; + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + )} + + + ); +} + +function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function SheetTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; diff --git a/apps/frontend/src/features/booking/booking-provider.tsx b/apps/frontend/src/features/booking/booking-provider.tsx new file mode 100644 index 0000000..8781102 --- /dev/null +++ b/apps/frontend/src/features/booking/booking-provider.tsx @@ -0,0 +1,500 @@ +import { ApiClientError, apiClient } from '@/lib/api-client'; +import type { AdminBooking, ComplexWithRole, Court } from '@repo/api-contract'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + type ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import type { + BookingCourtSchedule, + BookingSlotDraft, + BookingStatusFilter, + BookingSummary, + BookingTimeRange, + BookingTimelineSegment, + BookingViewMode, +} from './booking.types'; +import { + addDaysIso, + formatBookingDate, + getDayOfWeek, + getNowTime, + isTodayIso, + minutesToTime, + timeToMinutes, + toIsoDateLocal, +} from './lib/booking-time'; + +interface BookingProviderProps { + children: ReactNode; + complex: ComplexWithRole; +} + +interface CreateManualBookingPayload { + courtId: string; + date: string; + startTime: string; + customerName: string; + customerPhone: string; +} + +interface BookingContextValue { + complex: ComplexWithRole; + courts: Court[]; + bookings: AdminBooking[]; + schedules: BookingCourtSchedule[]; + selectedDate: string; + selectedSportId: string; + selectedStatus: BookingStatusFilter; + viewMode: BookingViewMode; + visibleTimeRange: BookingTimeRange; + timelineHours: string[]; + summary: BookingSummary; + currentTime: string | null; + selectedSlot: BookingSlotDraft | null; + selectedSegment: BookingTimelineSegment | null; + isLoading: boolean; + isError: boolean; + errorMessage: string | null; + isCreateBookingOpen: boolean; + createBookingError: string | null; + isCreatingBooking: boolean; + bookingToolsOpen: boolean; + setSelectedDate: (date: string) => void; + moveSelectedDate: (amount: number) => void; + setSelectedSportId: (sportId: string) => void; + setSelectedStatus: (status: BookingStatusFilter) => void; + setViewMode: (mode: BookingViewMode) => void; + setVisibleTimeRange: (range: BookingTimeRange) => void; + openCreateBooking: (draft?: Partial) => void; + closeCreateBooking: () => void; + createBooking: (payload: CreateManualBookingPayload) => Promise; + updateBookingStatus: (bookingId: string, status: 'CANCELLED' | 'COMPLETED' | 'NOSHOW') => void; + exportDayReport: () => void; + openBookingTools: (segment?: BookingTimelineSegment) => void; + closeBookingTools: () => void; +} + +const BookingContext = createContext(null); + +const DEFAULT_TIME_RANGE: BookingTimeRange = { + start: '08:00', + end: '22:00', +}; + +function extractMessage(error: unknown, fallback: string) { + if (error instanceof ApiClientError) { + return error.message || fallback; + } + + if (error instanceof Error) { + return error.message || fallback; + } + + return fallback; +} + +function makeTimelineHours(range: BookingTimeRange) { + const start = timeToMinutes(range.start); + const end = timeToMinutes(range.end); + const hours: string[] = []; + + for (let minute = start; minute <= end; minute += 60) { + hours.push(minutesToTime(minute)); + } + + return hours; +} + +function isActiveBooking(booking: AdminBooking) { + return booking.status === 'CONFIRMED' || booking.status === 'COMPLETED'; +} + +function getCourtBookings(court: Court, bookings: AdminBooking[], selectedDate: string) { + return bookings + .filter( + (booking) => + booking.courtId === court.id && booking.date === selectedDate && isActiveBooking(booking) + ) + .sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime)); +} + +function overlapsBooking(start: number, end: number, booking: AdminBooking) { + const bookingStart = timeToMinutes(booking.startTime); + const bookingEnd = timeToMinutes(booking.endTime); + + return start < bookingEnd && end > bookingStart; +} + +function buildSegmentsForCourt( + court: Court, + bookings: AdminBooking[], + selectedDate: string, + visibleRange: BookingTimeRange +) { + const dayOfWeek = getDayOfWeek(selectedDate); + const rangeStart = timeToMinutes(visibleRange.start); + const rangeEnd = timeToMinutes(visibleRange.end); + const courtBookings = getCourtBookings(court, bookings, selectedDate); + const segments: BookingTimelineSegment[] = []; + const slotDuration = court.slotDurationMinutes; + + const availabilityRanges = court.availability + .filter((range) => range.dayOfWeek === dayOfWeek) + .map((range) => ({ + start: Math.max(timeToMinutes(range.startTime), rangeStart), + end: Math.min(timeToMinutes(range.endTime), rangeEnd), + })) + .filter((range) => range.start < range.end) + .sort((a, b) => a.start - b.start); + + for (const booking of courtBookings) { + const bookingStart = timeToMinutes(booking.startTime); + const bookingEnd = timeToMinutes(booking.endTime); + + if (bookingEnd > rangeStart && bookingStart < rangeEnd) { + segments.push({ + id: booking.id, + courtId: court.id, + status: 'reserved', + startTime: booking.startTime, + endTime: booking.endTime, + startMinutes: Math.max(bookingStart, rangeStart), + endMinutes: Math.min(bookingEnd, rangeEnd), + booking, + }); + } + } + + for (const availability of availabilityRanges) { + for ( + let slotStart = availability.start; + slotStart + slotDuration <= availability.end; + slotStart += slotDuration + ) { + const slotEnd = slotStart + slotDuration; + const isBooked = courtBookings.some((booking) => + overlapsBooking(slotStart, slotEnd, booking) + ); + + if (isBooked) continue; + + const slotStartTime = minutesToTime(slotStart); + const slotEndTime = minutesToTime(slotEnd); + + segments.push({ + id: `${court.id}-free-${slotStartTime}-${slotEndTime}`, + courtId: court.id, + status: 'free', + startTime: slotStartTime, + endTime: slotEndTime, + startMinutes: slotStart, + endMinutes: slotEnd, + }); + } + } + + segments.sort((a, b) => a.startMinutes - b.startMinutes); + + return segments; +} + +function getSummary(schedules: BookingCourtSchedule[]): BookingSummary { + const freeSlots = schedules.reduce((total, schedule) => total + schedule.metrics.free, 0); + const reservedSlots = schedules.reduce((total, schedule) => total + schedule.metrics.reserved, 0); + const maintenanceSlots = schedules.reduce( + (total, schedule) => total + schedule.metrics.maintenance, + 0 + ); + const totalSegments = freeSlots + reservedSlots + maintenanceSlots || 1; + + return { + totalReservations: reservedSlots + maintenanceSlots + freeSlots, + freeSlots, + reservedSlots, + maintenanceSlots, + freePercent: Math.round((freeSlots / totalSegments) * 100), + reservedPercent: Math.round((reservedSlots / totalSegments) * 100), + maintenancePercent: Math.round((maintenanceSlots / totalSegments) * 100), + }; +} + +export function BookingProvider({ children, complex }: BookingProviderProps) { + const queryClient = useQueryClient(); + const todayIso = useMemo(() => toIsoDateLocal(new Date()), []); + const [selectedDate, setSelectedDate] = useState(todayIso); + const [selectedSportId, setSelectedSportId] = useState('all'); + const [selectedStatus, setSelectedStatus] = useState('all'); + const [viewMode, setViewMode] = useState('panel'); + const [visibleTimeRange, setVisibleTimeRange] = useState(DEFAULT_TIME_RANGE); + const [selectedSlot, setSelectedSlot] = useState(null); + const [isCreateBookingOpen, setIsCreateBookingOpen] = useState(false); + const [selectedSegment, setSelectedSegment] = useState(null); + const [bookingToolsOpen, setBookingToolsOpen] = useState(false); + + const courtsQuery = useQuery({ + queryKey: ['courts', complex.id], + queryFn: () => apiClient.courts.listByComplex(complex.id), + }); + + const bookingsQuery = useQuery({ + queryKey: ['admin-bookings', complex.id, selectedDate], + queryFn: () => + apiClient.adminBookings.listByComplex(complex.id, { + fromDate: selectedDate, + }), + }); + + const bookings = useMemo( + () => (bookingsQuery.data?.bookings ?? []).filter((booking) => booking.date === selectedDate), + [bookingsQuery.data?.bookings, selectedDate] + ); + + const courts = useMemo(() => courtsQuery.data?.sort((a, b) => a.name.localeCompare(b.name)) ?? [], [courtsQuery.data]); + + const filteredCourts = useMemo(() => { + if (selectedSportId === 'all') return courts; + return courts.filter((court) => court.sportId === selectedSportId); + }, [courts, selectedSportId]); + + const schedules = useMemo(() => { + return filteredCourts + .map((court) => { + const segments = buildSegmentsForCourt(court, bookings, selectedDate, visibleTimeRange); + const metrics = { + free: segments.filter((segment) => segment.status === 'free').length, + reserved: segments.filter((segment) => segment.status === 'reserved').length, + maintenance: segments.filter((segment) => segment.status === 'maintenance').length, + }; + + return { court, segments, metrics }; + }) + .filter((schedule) => { + if (selectedStatus === 'all') return true; + return schedule.segments.some((segment) => segment.status === selectedStatus); + }); + }, [bookings, filteredCourts, selectedDate, selectedStatus, visibleTimeRange]); + + const summary = useMemo(() => getSummary(schedules), [schedules]); + const timelineHours = useMemo(() => makeTimelineHours(visibleTimeRange), [visibleTimeRange]); + + const [now, setNow] = useState(() => getNowTime()); + useEffect(() => { + const interval = setInterval(() => { + setNow(getNowTime()); + }, 10 * 1000); + + return () => clearInterval(interval); + }, []); + + const currentTime = useMemo(() => { + + if (!isTodayIso(selectedDate)) return null; + const nowMinutes = timeToMinutes(now); + const start = timeToMinutes(visibleTimeRange.start); + const end = timeToMinutes(visibleTimeRange.end); + + if (nowMinutes < start || nowMinutes > end) return null; + return now; + }, [selectedDate, visibleTimeRange, now]); + + const createBookingMutation = useMutation({ + mutationFn: (payload: CreateManualBookingPayload) => + apiClient.adminBookings.create(complex.id, { + date: payload.date, + courtId: payload.courtId, + startTime: payload.startTime, + customerName: payload.customerName, + customerPhone: payload.customerPhone, + }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] }); + await queryClient.invalidateQueries({ queryKey: ['manual-booking-availability'] }); + setIsCreateBookingOpen(false); + setSelectedSlot(null); + }, + }); + + const updateStatusMutation = useMutation({ + mutationFn: (payload: { bookingId: string; status: 'CANCELLED' | 'COMPLETED' | 'NOSHOW' }) => + apiClient.adminBookings.updateStatus(payload.bookingId, { status: payload.status }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] }); + }, + }); + + const openCreateBooking = useCallback( + (draft?: Partial) => { + setSelectedSlot({ + date: draft?.date ?? selectedDate, + courtId: draft?.courtId, + startTime: draft?.startTime, + sportId: draft?.sportId, + }); + setIsCreateBookingOpen(true); + }, + [selectedDate] + ); + + const openBookingTools = useCallback( + (segment?: BookingTimelineSegment) => { + setSelectedSegment(segment ?? null); + setBookingToolsOpen(true); + console.log(JSON.stringify(segment, null, 2)) + }, [selectedDate]); + + const closeBookingTools = () => setBookingToolsOpen(false) + + useEffect(() => { + const handleOpenCreateBooking = () => { + openCreateBooking(); + }; + + window.addEventListener('playzer:open-booking-create', handleOpenCreateBooking); + + return () => { + window.removeEventListener('playzer:open-booking-create', handleOpenCreateBooking); + }; + }, [openCreateBooking]); + + const closeCreateBooking = useCallback(() => { + setIsCreateBookingOpen(false); + setSelectedSlot(null); + createBookingMutation.reset(); + }, [createBookingMutation]); + + const createBooking = useCallback( + async (payload: CreateManualBookingPayload) => { + await createBookingMutation.mutateAsync(payload); + }, + [createBookingMutation] + ); + + const updateBookingStatus = useCallback( + (bookingId: string, status: 'CANCELLED' | 'COMPLETED' | 'NOSHOW') => { + updateStatusMutation.mutate({ bookingId, status }); + }, + [updateStatusMutation] + ); + + const exportDayReport = useCallback(() => { + const rows = [ + ['Fecha', 'Cancha', 'Deporte', 'Inicio', 'Fin', 'Cliente', 'Telefono', 'Estado'], + ...bookings.map((booking) => [ + booking.date, + booking.courtName, + booking.sport.name, + booking.startTime, + booking.endTime, + booking.customerName, + booking.customerPhone, + booking.status, + ]), + ]; + const csv = rows.map((row) => row.map((cell) => `"${cell}"`).join(',')).join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `reservas-${complex.complexSlug}-${selectedDate}.csv`; + link.click(); + URL.revokeObjectURL(url); + }, [bookings, complex.complexSlug, selectedDate]); + + const value = useMemo( + () => ({ + complex, + courts, + bookings, + schedules, + selectedDate, + selectedSportId, + selectedStatus, + viewMode, + visibleTimeRange, + timelineHours, + summary, + currentTime, + selectedSlot, + isLoading: courtsQuery.isLoading || bookingsQuery.isLoading, + isError: courtsQuery.isError || bookingsQuery.isError, + errorMessage: + courtsQuery.isError || bookingsQuery.isError + ? extractMessage( + courtsQuery.error ?? bookingsQuery.error, + 'No pudimos cargar el panel de reservas.' + ) + : null, + isCreateBookingOpen, + createBookingError: createBookingMutation.isError + ? extractMessage(createBookingMutation.error, 'No pudimos crear la reserva.') + : null, + isCreatingBooking: createBookingMutation.isPending, + setSelectedDate, + moveSelectedDate: (amount) => setSelectedDate((date) => addDaysIso(date, amount)), + setSelectedSportId, + setSelectedStatus, + setViewMode, + setVisibleTimeRange, + openCreateBooking, + closeCreateBooking, + createBooking, + updateBookingStatus, + exportDayReport, + bookingToolsOpen, + openBookingTools, + closeBookingTools, + selectedSegment + }), + [ + bookings, + bookingsQuery.error, + bookingsQuery.isError, + bookingsQuery.isLoading, + closeCreateBooking, + complex, + courts, + courtsQuery.error, + courtsQuery.isError, + courtsQuery.isLoading, + createBooking, + createBookingMutation.error, + createBookingMutation.isError, + createBookingMutation.isPending, + currentTime, + exportDayReport, + isCreateBookingOpen, + openCreateBooking, + schedules, + selectedDate, + selectedSlot, + selectedSportId, + selectedStatus, + summary, + timelineHours, + updateBookingStatus, + viewMode, + visibleTimeRange, + bookingToolsOpen + ] + ); + + return {children}; +} + +export function useBooking() { + const context = useContext(BookingContext); + + if (!context) { + throw new Error('useBooking must be used within BookingProvider'); + } + + return context; +} + +export { formatBookingDate }; diff --git a/apps/frontend/src/features/booking/booking.tsx b/apps/frontend/src/features/booking/booking.tsx new file mode 100644 index 0000000..004f07d --- /dev/null +++ b/apps/frontend/src/features/booking/booking.tsx @@ -0,0 +1,30 @@ +import type { ComplexWithRole } from '@repo/api-contract'; +import { BookingProvider } from './booking-provider'; +import { BookingCreateDialog } from './components/booking-create-dialog'; +import { BookingDaySummary, BookingQuickActions } from './components/booking-day-summary'; +import { BookingHeader } from './components/booking-header'; +import { BookingTimeline } from './components/booking-timeline'; +import { BookingToolbar } from './components/booking-toolbar'; +import { BookingToolsDialog } from './components/booking-tools-dialog'; + +interface BookingProps { + complex: ComplexWithRole; +} + +export function Booking({ complex }: BookingProps) { + return ( + +
+ + + +
+ + +
+
+ + +
+ ); +} diff --git a/apps/frontend/src/features/booking/booking.types.ts b/apps/frontend/src/features/booking/booking.types.ts new file mode 100644 index 0000000..a933d99 --- /dev/null +++ b/apps/frontend/src/features/booking/booking.types.ts @@ -0,0 +1,50 @@ +import type { AdminBooking, Court } from '@repo/api-contract'; + +export type BookingViewMode = 'panel' | 'status'; +export type BookingStatusFilter = 'all' | 'free' | 'reserved' | 'maintenance'; +export type BookingSegmentStatus = 'free' | 'reserved' | 'maintenance'; + +export interface BookingTimeRange { + start: string; + end: string; +} + +export interface BookingSummary { + totalReservations: number; + freeSlots: number; + reservedSlots: number; + maintenanceSlots: number; + freePercent: number; + reservedPercent: number; + maintenancePercent: number; +} + +export interface BookingCourtMetrics { + free: number; + reserved: number; + maintenance: number; +} + +export interface BookingTimelineSegment { + id: string; + courtId: string; + status: BookingSegmentStatus; + startTime: string; + endTime: string; + startMinutes: number; + endMinutes: number; + booking?: AdminBooking; +} + +export interface BookingCourtSchedule { + court: Court; + segments: BookingTimelineSegment[]; + metrics: BookingCourtMetrics; +} + +export interface BookingSlotDraft { + courtId?: string; + date: string; + startTime?: string; + sportId?: string; +} diff --git a/apps/frontend/src/features/booking/components/booking-create-dialog.tsx b/apps/frontend/src/features/booking/components/booking-create-dialog.tsx new file mode 100644 index 0000000..4e180bc --- /dev/null +++ b/apps/frontend/src/features/booking/components/booking-create-dialog.tsx @@ -0,0 +1,285 @@ +import { Button } from '@/components/ui/button'; +import { DatePicker } from '@/components/ui/date-picker'; +import { Field, FieldError, FieldLabel } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { + ResponsiveDialog, + ResponsiveDialogClose, + ResponsiveDialogContent, + ResponsiveDialogDescription, + ResponsiveDialogFooter, + ResponsiveDialogHeader, + ResponsiveDialogTitle, +} from '@/components/ui/responsive-dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useEffect, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { useBooking } from '../booking-provider'; +import { + fromIsoDateLocal, + getDayOfWeek, + minutesToTime, + timeToMinutes, + toIsoDateLocal, +} from '../lib/booking-time'; + +const bookingFormSchema = z.object({ + customerName: z + .string() + .trim() + .min(2, 'Ingresa un nombre válido.') + .max(120, 'El nombre no puede superar los 120 caracteres.'), + customerPhone: z + .string() + .trim() + .min(6, 'Ingresa un telefono válido.') + .max(30, 'El telefono no puede superar los 30 caracteres.'), +}); + +type BookingForm = z.infer; + +export function BookingCreateDialog() { + const { + courts, + bookings, + selectedDate, + selectedSlot, + isCreateBookingOpen, + createBookingError, + isCreatingBooking, + closeCreateBooking, + createBooking, + } = useBooking(); + + const [date, setDate] = useState(selectedSlot?.date ?? selectedDate); + const [sportId, setSportId] = useState(selectedSlot?.sportId ?? 'all'); + const [courtId, setCourtId] = useState(selectedSlot?.courtId ?? ''); + const [startTime, setStartTime] = useState(selectedSlot?.startTime ?? ''); + + const { + register, + handleSubmit, + reset, + formState: { errors, isValid }, + } = useForm({ + resolver: zodResolver(bookingFormSchema), + mode: 'onChange', + defaultValues: { + customerName: '', + customerPhone: '', + }, + }); + + useEffect(() => { + if (!isCreateBookingOpen) return; + + setDate(selectedSlot?.date ?? selectedDate); + setSportId(selectedSlot?.sportId ?? 'all'); + setCourtId(selectedSlot?.courtId ?? ''); + setStartTime(selectedSlot?.startTime ?? ''); + reset(); + }, [isCreateBookingOpen, reset, selectedDate, selectedSlot]); + + const sports = [...new Map(courts.map((court) => [court.sport.id, court.sport])).values()]; + const filteredCourts = useMemo(() => { + if (sportId === 'all') return courts; + return courts.filter((court) => court.sportId === sportId); + }, [courts, sportId]); + const selectedCourt = courts.find((court) => court.id === courtId); + const availableStartTimes = useMemo(() => { + if (!selectedCourt) return []; + + const times: string[] = []; + const dayOfWeek = getDayOfWeek(date); + const dayAvailability = selectedCourt.availability + .filter((range) => range.dayOfWeek === dayOfWeek) + .sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime)); + const courtBookings = bookings.filter( + (booking) => + booking.date === date && + booking.courtId === selectedCourt.id && + booking.status !== 'CANCELLED' + ); + + for (const range of dayAvailability) { + const start = timeToMinutes(range.startTime); + const end = timeToMinutes(range.endTime); + + for ( + let minute = start; + minute + selectedCourt.slotDurationMinutes <= end; + minute += selectedCourt.slotDurationMinutes + ) { + const slotEnd = minute + selectedCourt.slotDurationMinutes; + const isBooked = courtBookings.some((booking) => { + const bookingStart = timeToMinutes(booking.startTime); + const bookingEnd = timeToMinutes(booking.endTime); + return minute < bookingEnd && slotEnd > bookingStart; + }); + + if (!isBooked) { + times.push(minutesToTime(minute)); + } + } + } + + return times; + }, [bookings, date, selectedCourt]); + + useEffect(() => { + if (startTime && !availableStartTimes.includes(startTime)) { + setStartTime(''); + } + }, [availableStartTimes, startTime]); + + const onSubmit = async (values: BookingForm) => { + await createBooking({ + courtId, + date, + startTime, + customerName: values.customerName, + customerPhone: values.customerPhone, + }); + }; + + return ( + !open && closeCreateBooking()} + > + event.preventDefault()} + > + + Nueva reserva + + Crea un turno para atención telefónica o mostrador. + + + +
+
+ + Fecha + { + setDate(nextDate ? toIsoDateLocal(nextDate) : selectedDate); + }} + /> + + + + Deporte + + + + + Cancha + + + + + Horario + + +
+ + + Nombre + + + + + + Teléfono + + + + + {createBookingError &&

{createBookingError}

} +
+ + + + + + + +
+
+ ); +} diff --git a/apps/frontend/src/features/booking/components/booking-day-summary.tsx b/apps/frontend/src/features/booking/components/booking-day-summary.tsx new file mode 100644 index 0000000..939446b --- /dev/null +++ b/apps/frontend/src/features/booking/components/booking-day-summary.tsx @@ -0,0 +1,118 @@ +import { CalendarDays, Download, Drill, ShieldCheck, UsersRound } from 'lucide-react'; +import { useBooking } from '../booking-provider'; +import { formatBookingDate, toIsoDateLocal } from '../lib/booking-time'; + +export function BookingDaySummary() { + const { selectedDate, summary } = useBooking(); + + return ( +
+
+

Resumen del día

+

+ {formatBookingDate(selectedDate, { year: 'numeric' })} +

+
+ +
+ + + + +
+
+ ); +} + +interface SummaryCardProps { + label: string; + value: number; + detail?: string; + icon: typeof CalendarDays; + className: string; +} + +function SummaryCard({ label, value, detail, icon: Icon, className }: SummaryCardProps) { + return ( +
+
+
+ +
+
+

{label}

+

{value}

+ {detail &&

{detail}

} +
+
+
+ ); +} + +export function BookingQuickActions() { + const { selectedDate, selectedStatus, setSelectedDate, setSelectedStatus, exportDayReport } = + useBooking(); + const todayIso = toIsoDateLocal(new Date()); + const isViewingTodayReservations = selectedDate === todayIso && selectedStatus === 'reserved'; + const isViewingMaintenance = selectedStatus === 'maintenance'; + + return ( +
+

Acciones rápidas

+
+ + + +
+
+ ); +} diff --git a/apps/frontend/src/features/booking/components/booking-header.tsx b/apps/frontend/src/features/booking/components/booking-header.tsx new file mode 100644 index 0000000..67ec4cc --- /dev/null +++ b/apps/frontend/src/features/booking/components/booking-header.tsx @@ -0,0 +1,18 @@ +import { useBooking } from '../booking-provider'; + +export function BookingHeader() { + const { complex } = useBooking(); + + return ( +
+
+

+ Panel de Reservas +

+

+ Gestiona las canchas de {complex.complexName} +

+
+
+ ); +} diff --git a/apps/frontend/src/features/booking/components/booking-status-legend.tsx b/apps/frontend/src/features/booking/components/booking-status-legend.tsx new file mode 100644 index 0000000..889e512 --- /dev/null +++ b/apps/frontend/src/features/booking/components/booking-status-legend.tsx @@ -0,0 +1,18 @@ +const items = [ + { label: 'Libre', className: 'bg-primary' }, + { label: 'Reservado', className: 'bg-reserved' }, + { label: 'Mantenimiento', className: 'bg-maintenance' }, +]; + +export function BookingStatusLegend() { + return ( +
+ {items.map((item) => ( + + + {item.label} + + ))} +
+ ); +} diff --git a/apps/frontend/src/features/booking/components/booking-timeline.tsx b/apps/frontend/src/features/booking/components/booking-timeline.tsx new file mode 100644 index 0000000..372fa94 --- /dev/null +++ b/apps/frontend/src/features/booking/components/booking-timeline.tsx @@ -0,0 +1,304 @@ +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { ChevronLeft, ChevronRight, Dumbbell, Plus, Users } from 'lucide-react'; +import { useBooking } from '../booking-provider'; +import type { BookingCourtSchedule, BookingTimelineSegment } from '../booking.types'; +import { timeToMinutes } from '../lib/booking-time'; +import { BookingStatusLegend } from './booking-status-legend'; + +const courtInfoWidth = 220; +const hourWidth = 104; + +export function BookingTimeline() { + const { + schedules, + timelineHours, + visibleTimeRange, + currentTime, + isLoading, + isError, + errorMessage, + } = useBooking(); + + const rangeStart = timeToMinutes(visibleTimeRange.start); + const rangeEnd = timeToMinutes(visibleTimeRange.end); + const totalMinutes = rangeEnd - rangeStart; + const gridWidth = Math.max((timelineHours.length - 1) * hourWidth, 760); + const timelineMarks = timelineHours.map((hour) => ({ + hour, + left: ((timeToMinutes(hour) - rangeStart) / totalMinutes) * 100, + })); + + return ( +
+
+ +
+ + +
+
+ + {isLoading && ( +
Cargando reservas...
+ )} + + {isError && !isLoading && ( +
{errorMessage}
+ )} + + {!isLoading && !isError && schedules.length === 0 && ( +
+ No hay canchas para los filtros seleccionados. +
+ )} + + {!isLoading && !isError && schedules.length > 0 && ( +
+
+ + +
+ {schedules.map((schedule) => ( + + ))} +
+ + {currentTime && ( + + )} +
+
+ )} +
+ ); +} + +interface TimelineHeaderProps { + timelineMarks: Array<{ hour: string; left: number }>; + courtInfoWidth: number; + gridWidth: number; +} + +function TimelineHeader({ timelineMarks, courtInfoWidth, gridWidth }: TimelineHeaderProps) { + return ( +
+
+
+ {timelineMarks.map((mark) => ( +
+ {mark.hour} +
+ ))} +
+
+ ); +} + +interface BookingCourtRowProps { + schedule: BookingCourtSchedule; + courtInfoWidth: number; + gridWidth: number; + rangeStart: number; + totalMinutes: number; + timelineMarks: Array<{ hour: string; left: number }>; +} + +function BookingCourtRow({ + schedule, + courtInfoWidth, + gridWidth, + rangeStart, + totalMinutes, + timelineMarks, +}: BookingCourtRowProps) { + return ( +
+ +
+
+ {timelineMarks.map((mark) => ( + + ))} +
+ +
+ {schedule.segments.map((segment) => ( + + ))} +
+
+
+ ); +} + +function BookingCourtInfo({ schedule }: { schedule: BookingCourtSchedule }) { + return ( +
+
+ +
+
+

{schedule.court.name}

+

{schedule.court.sport.name}

+
+ + + {schedule.metrics.free} + + + + {schedule.metrics.reserved} + + + + {schedule.metrics.maintenance} + +
+
+
+ ); +} + +interface BookingSlotBlockProps { + segment: BookingTimelineSegment; + schedule: BookingCourtSchedule; + rangeStart: number; + totalMinutes: number; +} + +function BookingSlotBlock({ segment, schedule, rangeStart, totalMinutes }: BookingSlotBlockProps) { + const { openCreateBooking, updateBookingStatus, openBookingTools } = useBooking(); + const left = ((segment.startMinutes - rangeStart) / totalMinutes) * 100; + const width = ((segment.endMinutes - segment.startMinutes) / totalMinutes) * 100; + const isFree = segment.status === 'free'; + + return ( + + ); +} + +interface CurrentTimeIndicatorProps { + time: string; + courtInfoWidth: number; + gridWidth: number; + rangeStart: number; + totalMinutes: number; +} + +function CurrentTimeIndicator({ + time, + courtInfoWidth, + gridWidth, + rangeStart, + totalMinutes, +}: CurrentTimeIndicatorProps) { + const left = courtInfoWidth + ((timeToMinutes(time) - rangeStart) / totalMinutes) * gridWidth; + + return ( +
+
+ {time} +
+
+
+ ); +} diff --git a/apps/frontend/src/features/booking/components/booking-toolbar.tsx b/apps/frontend/src/features/booking/components/booking-toolbar.tsx new file mode 100644 index 0000000..d63893c --- /dev/null +++ b/apps/frontend/src/features/booking/components/booking-toolbar.tsx @@ -0,0 +1,144 @@ +import { Button } from '@/components/ui/button'; +import { DatePicker } from '@/components/ui/date-picker'; +import { Field, FieldLabel } from '@/components/ui/field'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { ChevronLeft, ChevronRight, Clock, SlidersHorizontal } from 'lucide-react'; +import { useBooking } from '../booking-provider'; +import type { BookingStatusFilter } from '../booking.types'; +import { fromIsoDateLocal, toIsoDateLocal } from '../lib/booking-time'; + +const timeRangeOptions = [ + { label: '08:00 - 22:00', start: '08:00', end: '22:00' }, + { label: '06:00 - 00:00', start: '06:00', end: '23:59' }, + { label: '08:00 - 14:00', start: '08:00', end: '14:00' }, + { label: '14:00 - 22:00', start: '14:00', end: '22:00' }, +]; + +const statusOptions: Array<{ value: BookingStatusFilter; label: string }> = [ + { value: 'all', label: 'Todos' }, + { value: 'free', label: 'Libre' }, + { value: 'reserved', label: 'Reservado' }, + { value: 'maintenance', label: 'Mantenimiento' }, +]; + +export function BookingToolbar() { + const { + courts, + selectedDate, + selectedSportId, + selectedStatus, + visibleTimeRange, + setSelectedDate, + moveSelectedDate, + setSelectedSportId, + setSelectedStatus, + setVisibleTimeRange, + } = useBooking(); + + const sports = [...new Map(courts.map((court) => [court.sport.id, court.sport])).values()]; + const activeRangeValue = `${visibleTimeRange.start}-${visibleTimeRange.end}`; + + return ( +
+
+ + Deporte + + + + + Estado + + + + + Fecha +
+ + + setSelectedDate(date ? toIsoDateLocal(date) : toIsoDateLocal(new Date())) + } + /> + +
+
+ + + Hora + + + + +
+
+ ); +} diff --git a/apps/frontend/src/features/booking/components/booking-tools-dialog.tsx b/apps/frontend/src/features/booking/components/booking-tools-dialog.tsx new file mode 100644 index 0000000..debaf9d --- /dev/null +++ b/apps/frontend/src/features/booking/components/booking-tools-dialog.tsx @@ -0,0 +1,303 @@ +import { ResponsiveDialog, ResponsiveDialogClose, ResponsiveDialogContent, ResponsiveDialogDescription, ResponsiveDialogFooter, ResponsiveDialogHeader, ResponsiveDialogTitle } from "@/components/ui/responsive-dialog"; +import { useBooking } from "../booking-provider"; +import { Button } from "@/components/ui/button"; +import { + CheckCircle2, + Clock, + MapPin, + MessageCircle, + Phone, + User, + XCircle, + AlertTriangle, + BadgeCheck, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Separator } from "@/components/ui/separator"; + +function formatDate(date: string | undefined) { + if (!date) return ""; + + const formatted = new Intl.DateTimeFormat("es-AR", { + weekday: "long", + day: "numeric", + month: "long", + }).format(new Date(`${date}T00:00:00`)); + + return formatted + .split(" ") + .map((word, index) => { + if (index === 0 || index === 3) { + return word.charAt(0).toUpperCase() + word.slice(1); + } + return word; + }) + .join(" "); +} + +const statusConfig: Record = { + + CONFIRMED: { + label: "Reservada", + className: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30", + }, + COMPLETED: { + label: "Completada", + className: "bg-sky-500/15 text-sky-400 border-sky-500/30", + }, + CANCELLED: { + label: "Cancelada", + className: "bg-red-500/15 text-red-400 border-red-500/30", + }, + NOSHOW: { + label: "No show", + className: "bg-amber-500/15 text-amber-400 border-amber-500/30", + }, + +}; + +export function BookingToolsDialog() { + const { + selectedSegment, + bookingToolsOpen, + closeBookingTools, + updateBookingStatus, + } = useBooking(); + + + const status = statusConfig[selectedSegment?.booking?.status ?? "CONFIRMED"] ?? { + label: selectedSegment?.booking?.status, + className: "bg-slate-500/15 text-slate-300 border-slate-500/30", + }; + + const sendWhatsappReminder = () => { + const phone = selectedSegment?.booking?.customerPhone; + const message = ` + Hola ${selectedSegment?.booking?.customerName}. \nTe recordamos que tenés una reserva para el día *${formatDate(selectedSegment?.booking?.date)} de ${selectedSegment?.booking?.startTime} a ${selectedSegment?.booking?.endTime} en la cancha ${selectedSegment?.booking?.courtName} (${selectedSegment?.booking?.sport?.name})*. ¡Te esperamos! \nSi no vas a poder asistir, por favor avisanos para que otros clientes puedan reservar. Gracias.\n\n*Equipo de ${selectedSegment?.booking?.complexName}*`; + + const url = `https://wa.me/${phone}?text=${encodeURIComponent(message)}`; + window.open(url, '_blank'); + } + + return ( + !open && closeBookingTools()} + > + event.preventDefault()} + > + + Detalles de la reserva + + Administra la reserva y su estado. + + + +
+
+ } + label="Cancha" + value={selectedSegment?.booking?.courtName ?? "-"} + helper={selectedSegment?.booking?.sport?.name} + withDivider + /> + + } + label="Estado actual" + value={status.label} + valueClassName="text-emerald-400 uppercase dark:text-emerald-500" + withDivider + /> + + } + label="Cliente" + value={selectedSegment?.booking?.customerName ?? "-"} + /> + + } + label="Fecha" + value={`${formatDate(selectedSegment?.booking?.date)}`} + withDivider + className="pt-10" + /> + + } + label="Hora" + value={`${selectedSegment?.booking?.startTime} a ${selectedSegment?.booking?.endTime}`} + withDivider + className="pt-10" + /> + + } + label="Teléfono" + value={selectedSegment?.booking?.customerPhone ?? "-"} + className="pt-10" + /> + +
+ +
+ + + +
+
+

Acciones disponibles

+

+ Elegí qué hacer según lo que ocurrió con el cliente. +

+
+
+ } + title="Completar reserva" + description="El cliente se presentó para usar la cancha." + className="border-emerald-500/40 bg-emerald-500/10 text-emerald-600 text-lg hover:bg-emerald-500/15 hover:text-esmerald-800" + onClick={() => updateBookingStatus(selectedSegment?.booking?.id!, 'COMPLETED')} + /> + + } + title="Cancelar reserva" + description="El cliente avisó que no va a venir. La cancha vuelve a estar disponible." + className="border-red-500/40 bg-red-500/10 text-red-600 text-lg hover:bg-red-500/15 hover:text-red-800" + onClick={() => updateBookingStatus(selectedSegment?.booking?.id!, 'CANCELLED')} + /> + + } + title="Enviar recordatorio" + description="Enviar un mensaje por WhatsApp al cliente." + className="border-sky-500/40 bg-sky-500/10 text-sky-600 text-lg hover:bg-sky-500/15 hover:text-sky-800" + onClick={sendWhatsappReminder} + /> + + } + title="Marcar como No Show" + description="El cliente no vino y no canceló." + className="border-amber-500/40 bg-amber-500/10 text-amber-600 text-lg hover:bg-amber-500/15 hover:text-amber-800" + onClick={() => updateBookingStatus(selectedSegment?.booking?.id!, 'NOSHOW')} + /> +
+
+ + + + + + +
+
+ ); +} + +function DetailItem({ + icon, + label, + value, + helper, + valueClassName, + withDivider, + className, +}: { + icon: React.ReactNode; + label: string; + value: string; + helper?: string; + valueClassName?: string; + withDivider?: boolean; + className?: string; +}) { + + return ( +
+ {withDivider && ( +
+ )} + +
+ {icon} +
+
+

+ {label} +

+

+ {value} +

+ {helper && ( +

+ {helper} +

+ )} +
+
+ + ); +} + +function ActionButton({ + icon, + title, + description, + className, + onClick +}: { + icon: React.ReactNode; + title: string; + description: string; + className: string; + onClick?: () => void; +}) { + return ( + + ); + +} \ No newline at end of file diff --git a/apps/frontend/src/features/booking/lib/booking-time.ts b/apps/frontend/src/features/booking/lib/booking-time.ts new file mode 100644 index 0000000..1b34009 --- /dev/null +++ b/apps/frontend/src/features/booking/lib/booking-time.ts @@ -0,0 +1,66 @@ +import type { DayOfWeek } from '@repo/api-contract'; + +export function toIsoDateLocal(date: Date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +export function fromIsoDateLocal(dateIso: string): Date | undefined { + const [year, month, day] = dateIso.split('-').map(Number); + if (!year || !month || !day) return undefined; + return new Date(year, month - 1, day); +} + +export function timeToMinutes(time: string) { + const [hours, minutes] = time.split(':').map(Number); + return hours * 60 + minutes; +} + +export function minutesToTime(minutes: number) { + const hours = Math.floor(minutes / 60); + const remainder = minutes % 60; + return `${String(hours).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`; +} + +export function addDaysIso(dateIso: string, amount: number) { + const date = fromIsoDateLocal(dateIso) ?? new Date(); + date.setDate(date.getDate() + amount); + return toIsoDateLocal(date); +} + +export function isTodayIso(dateIso: string) { + return dateIso === toIsoDateLocal(new Date()); +} + +export function formatBookingDate(dateIso: string, options?: Intl.DateTimeFormatOptions) { + const date = fromIsoDateLocal(dateIso); + if (!date) return dateIso; + + return new Intl.DateTimeFormat('es-AR', { + weekday: 'long', + day: '2-digit', + month: 'long', + year: options?.year, + ...options, + }).format(date); +} + +export function getDayOfWeek(dateIso: string): DayOfWeek { + const date = fromIsoDateLocal(dateIso) ?? new Date(); + const day = date.getDay(); + + if (day === 0) return 'SUNDAY'; + if (day === 1) return 'MONDAY'; + if (day === 2) return 'TUESDAY'; + if (day === 3) return 'WEDNESDAY'; + if (day === 4) return 'THURSDAY'; + if (day === 5) return 'FRIDAY'; + return 'SATURDAY'; +} + +export function getNowTime() { + const now = new Date(); + return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; +} diff --git a/apps/frontend/src/features/home/home-page.tsx b/apps/frontend/src/features/home/home-page.tsx index f4aec80..29b7bac 100644 --- a/apps/frontend/src/features/home/home-page.tsx +++ b/apps/frontend/src/features/home/home-page.tsx @@ -1,62 +1,9 @@ -import { Button } from '@/components/ui/button'; -import { DatePicker } from '@/components/ui/date-picker'; -import { Field, FieldError, FieldLabel } from '@/components/ui/field'; -import { Input } from '@/components/ui/input'; -import { - ResponsiveDialog, - ResponsiveDialogClose, - ResponsiveDialogContent, - ResponsiveDialogDescription, - ResponsiveDialogFooter, - ResponsiveDialogHeader, - ResponsiveDialogTitle, - ResponsiveDialogTrigger, -} from '@/components/ui/responsive-dialog'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; +import { Booking } from '@/features/booking/booking'; import { ApiClientError, apiClient } from '@/lib/api-client'; import { useCurrentComplexStore } from '@/lib/stores/current-complex-store'; -import { zodResolver } from '@hookform/resolvers/zod'; -import type { AdminBooking } from '@repo/api-contract'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; -import { Check, Copy } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; - -const manualBookingSchema = z.object({ - customerName: z - .string() - .trim() - .min(2, 'Ingresa un nombre válido.') - .max(120, 'El nombre no puede superar los 120 caracteres.'), - customerPhone: z - .string() - .trim() - .min(6, 'Ingresa un telefono válido.') - .max(30, 'El telefono no puede superar los 30 caracteres.'), -}); - -type ManualBookingForm = z.infer; - -function toIsoDateLocal(date: Date) { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; -} - -function fromIsoDateLocal(dateIso: string): Date | undefined { - const [year, month, day] = dateIso.split('-').map(Number); - if (!year || !month || !day) return undefined; - return new Date(year, month - 1, day); -} +import { useEffect } from 'react'; function extractMessage(error: unknown, fallback: string) { if (error instanceof ApiClientError) { @@ -66,71 +13,9 @@ function extractMessage(error: unknown, fallback: string) { return fallback; } -function formatDateLabel(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); -} - -function getEffectiveStatus( - booking: AdminBooking -): 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW' { - if (booking.status !== 'CONFIRMED') { - return booking.status; - } - - // Check if booking is past end time and still confirmed - const now = new Date(); - const bookingDateTime = new Date(`${booking.date}T${booking.endTime}:00`); - - if (now > bookingDateTime) { - return 'NO_SHOW'; - } - - return 'CONFIRMED'; -} - -function effectiveStatusLabel(status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW') { - if (status === 'NO_SHOW') return 'No show'; - if (status === 'COMPLETED') return 'Cumplida'; - if (status === 'CANCELLED') return 'Cancelada'; - return 'Confirmada'; -} - -function effectiveStatusClassName(status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW') { - if (status === 'NO_SHOW') { - return 'border-orange-200 bg-orange-50 text-orange-700'; - } - if (status === 'COMPLETED') { - return 'border-emerald-200 bg-emerald-50 text-emerald-700'; - } - - if (status === 'CANCELLED') { - return 'border-rose-200 bg-rose-50 text-rose-700'; - } - - return 'border-sky-200 bg-sky-50 text-sky-700'; -} - export function HomePage() { const queryClient = useQueryClient(); const navigate = useNavigate(); - const todayIso = useMemo(() => toIsoDateLocal(new Date()), []); - - const [fromDate, setFromDate] = useState(todayIso); - const [manualDate, setManualDate] = useState(todayIso); - const [selectedSportId, setSelectedSportId] = useState(); - const [selectedCourtId, setSelectedCourtId] = useState(''); - const [selectedStartTime, setSelectedStartTime] = useState(''); - const [isManualBookingOpen, setIsManualBookingOpen] = useState(false); - const [urlCopied, setUrlCopied] = useState(false); const currentComplexQuery = useQuery({ queryKey: ['current-complex'], @@ -152,6 +37,7 @@ export function HomePage() { queryClient.invalidateQueries({ queryKey: ['admin-bookings'] }); } }); + return unsubscribe; }, [queryClient]); @@ -191,7 +77,6 @@ export function HomePage() { if (!selectedComplex?.id) return; const channel = `complex-${selectedComplex.id}`; - let eventSource: EventSource | null = null; let reconnectTimeout: ReturnType | null = null; let reconnectAttempts = 0; @@ -199,27 +84,32 @@ export function HomePage() { let lastEventId: string | null = null; let pollInterval: ReturnType | null = null; + const startPolling = () => { + if (pollInterval) return; + + pollInterval = setInterval(() => { + void queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex.id] }); + }, 10000); + }; + const connect = () => { if (eventSource) { eventSource.close(); } if (reconnectAttempts >= maxReconnectAttempts) { - console.log('[SSE] Max reconnect attempts reached, switching to polling'); startPolling(); return; } - const url = `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/events/${encodeURIComponent(channel)}`; + const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'; + const url = `${baseUrl}/api/events/${encodeURIComponent(channel)}`; eventSource = new EventSource(url); eventSource.onerror = () => { if (eventSource?.readyState === EventSource.CLOSED) { reconnectAttempts++; - console.log( - `[SSE] Connection closed, retry ${reconnectAttempts}/${maxReconnectAttempts}` - ); reconnectTimeout = setTimeout(() => { connect(); }, 2000); @@ -239,15 +129,6 @@ export function HomePage() { }); }; - const startPolling = () => { - if (pollInterval) return; - - console.log('[SSE] Starting polling fallback'); - pollInterval = setInterval(() => { - void queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex.id] }); - }, 10000); - }; - connect(); return () => { @@ -261,526 +142,21 @@ export function HomePage() { }; }, [selectedComplex?.id, queryClient]); - const bookingsQuery = useQuery({ - queryKey: ['admin-bookings', selectedComplex?.id, fromDate], - enabled: Boolean(selectedComplex?.id), - queryFn: () => - apiClient.adminBookings.listByComplex(selectedComplex?.id as string, { - fromDate, - }), - }); - - const manualAvailabilityQuery = useQuery({ - queryKey: [ - 'manual-booking-availability', - selectedComplex?.complexSlug, - manualDate, - selectedSportId, - ], - enabled: Boolean(selectedComplex?.complexSlug && manualDate), - queryFn: () => - apiClient.publicBookings.getAvailability(selectedComplex?.complexSlug as string, { - date: manualDate, - ...(selectedSportId ? { sportId: selectedSportId } : {}), - }), - }); - - useEffect(() => { - const availability = manualAvailabilityQuery.data; - - if (!availability) return; - - if (!availability.sportSelectionRequired) { - if (availability.sports[0] && !selectedSportId) { - setSelectedSportId(availability.sports[0].id); - } - } else if ( - selectedSportId && - !availability.sports.some((sport) => sport.id === selectedSportId) - ) { - setSelectedSportId(undefined); - } - }, [manualAvailabilityQuery.data, selectedSportId]); - - useEffect(() => { - if (!manualAvailabilityQuery.data) return; - - if ( - selectedCourtId && - !manualAvailabilityQuery.data.courts.some((court) => court.courtId === selectedCourtId) - ) { - setSelectedCourtId(''); - setSelectedStartTime(''); - } - }, [manualAvailabilityQuery.data, selectedCourtId]); - - const selectedCourt = useMemo(() => { - return manualAvailabilityQuery.data?.courts.find((court) => court.courtId === selectedCourtId); - }, [manualAvailabilityQuery.data?.courts, selectedCourtId]); - - useEffect(() => { - if (!selectedCourt) { - setSelectedStartTime(''); - return; - } - - if (!selectedCourt.availableSlots.some((slot) => slot.startTime === selectedStartTime)) { - setSelectedStartTime(''); - } - }, [selectedCourt, selectedStartTime]); - - const groupedBookings = useMemo(() => { - const groups = new Map(); - - for (const booking of bookingsQuery.data?.bookings ?? []) { - const current = groups.get(booking.date) ?? []; - current.push(booking); - groups.set(booking.date, current); - } - - return [...groups.entries()]; - }, [bookingsQuery.data?.bookings]); - - const updateStatusMutation = useMutation({ - mutationFn: (payload: { bookingId: string; status: 'CANCELLED' | 'COMPLETED' }) => - apiClient.adminBookings.updateStatus(payload.bookingId, { status: payload.status }), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] }); - void queryClient.invalidateQueries({ - queryKey: ['manual-booking-availability', selectedComplex?.complexSlug], - }); - }, - }); - - const { - register, - handleSubmit, - reset, - formState: { errors, isValid }, - } = useForm({ - resolver: zodResolver(manualBookingSchema), - mode: 'onChange', - defaultValues: { - customerName: '', - customerPhone: '', - }, - }); - - const createManualBookingMutation = useMutation({ - mutationFn: async (values: ManualBookingForm) => { - if (!selectedComplex?.id) { - throw new Error('No se encontró el complejo actual.'); - } - - if (!selectedCourtId || !selectedStartTime) { - throw new Error('Debes seleccionar cancha y horario.'); - } - - return apiClient.adminBookings.create(selectedComplex.id, { - date: manualDate, - courtId: selectedCourtId, - startTime: selectedStartTime, - customerName: values.customerName, - customerPhone: values.customerPhone, - }); - }, - onSuccess: async () => { - reset(); - setSelectedCourtId(''); - setSelectedStartTime(''); - setIsManualBookingOpen(false); - - await queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] }); - await queryClient.invalidateQueries({ - queryKey: ['manual-booking-availability', selectedComplex?.complexSlug], - }); - }, - }); - - const onSubmitManualBooking = async (values: ManualBookingForm) => { - await createManualBookingMutation.mutateAsync(values); - }; - - const publicBookingUrl = selectedComplex - ? `${window.location.origin}/${selectedComplex.complexSlug}/booking` - : ''; - - const copyToClipboard = async () => { - await navigator.clipboard.writeText(publicBookingUrl); - setUrlCopied(true); - setTimeout(() => { - setUrlCopied(false); - }, 3000); - }; - if (myComplexesQuery.isLoading) { - return ( -
-

Cargando panel...

-
- ); + return

Cargando panel...

; } if (myComplexesQuery.isError) { return ( -
-

- {extractMessage(myComplexesQuery.error, 'No pudimos cargar tus complejos.')} -

-
+

+ {extractMessage(myComplexesQuery.error, 'No pudimos cargar tus complejos.')} +

); } if (!selectedComplex) { - return ( -
-

No tienes complejos asignados todavía.

-
- ); + return

No tienes complejos asignados todavía.

; } - return ( -
-
-
-
-

Panel de reservas

-

- Gestiona turnos del día y futuros para {selectedComplex.complexName}. -

-
- - {publicBookingUrl} - - -
-
- { - setIsManualBookingOpen(open); - if (!open) { - reset(); - setManualDate(todayIso); - setSelectedSportId(undefined); - setSelectedCourtId(''); - setSelectedStartTime(''); - } - }} - > - - - - e.preventDefault()} - > - - Reserva manual - - Crea un turno para atención telefónica o mostrador. - - - -
-
- - Fecha - { - const isoDate = date ? toIsoDateLocal(date) : todayIso; - setManualDate(isoDate); - setSelectedCourtId(''); - setSelectedStartTime(''); - }} - minDate={new Date()} - placeholder="Selecciona una fecha" - /> - - - {manualAvailabilityQuery.data?.sportSelectionRequired && ( - - Deporte - - - )} - - - Cancha - - - - - Horario - - -
- - {manualAvailabilityQuery.isLoading && ( -

Cargando horarios disponibles...

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

- {extractMessage( - manualAvailabilityQuery.error, - 'No pudimos cargar disponibilidad para la reserva manual.' - )} -

- )} - - - Nombre - - - - - - Teléfono - - - - - {createManualBookingMutation.isError && ( -

- {extractMessage( - createManualBookingMutation.error, - 'No pudimos crear la reserva manual.' - )} -

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

Cargando reservas...

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

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

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

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

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

{formatDateLabel(date)}

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

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

-

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

-
- -
- - {effectiveStatusLabel(effectiveStatus)} - - - {canManage && ( - <> - - - - )} -
-
- ); - })} -
-
- ))} -
- - {updateStatusMutation.isError && ( -

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

- )} -
-
- ); + return ; } diff --git a/apps/frontend/src/features/layout/root-layout.tsx b/apps/frontend/src/features/layout/root-layout.tsx deleted file mode 100644 index 7bd98ec..0000000 --- a/apps/frontend/src/features/layout/root-layout.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { UserAvatar } from '@/components/user-avatar'; -import { apiClient } from '@/lib/api-client'; -import { useAuth } from '@/lib/auth'; -import { acceptStoredPendingInvite } from '@/lib/invitations'; -import { useCurrentComplexStore } from '@/lib/stores/current-complex-store'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { Link, Outlet, useNavigate } from '@tanstack/react-router'; -import { LogOut, Menu, UserRound } from 'lucide-react'; -import { useEffect, useMemo, useRef } from 'react'; -import { ThemeSwitcher } from './theme-switcher'; - -export function RootLayout() { - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const { user, isAuthenticated, displayName, avatarUrl, signOut } = useAuth(); - const { currentComplexSlug, setCurrentComplex } = useCurrentComplexStore(); - const processedInviteForUser = useRef(null); - const myComplexesQuery = useQuery({ - queryKey: ['my-complexes'], - enabled: isAuthenticated, - queryFn: () => apiClient.complexes.listMine(), - }); - - useEffect(() => { - if (!isAuthenticated) { - processedInviteForUser.current = null; - return; - } - - const userKey = user?.email ?? displayName ?? 'authenticated-user'; - - if (processedInviteForUser.current === userKey) { - return; - } - - processedInviteForUser.current = userKey; - - void (async () => { - await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations); - await queryClient.invalidateQueries({ queryKey: ['my-complexes'] }); - })(); - }, [displayName, isAuthenticated, queryClient, user?.email]); - - const complexSlug = currentComplexSlug || myComplexesQuery.data?.[0]?.complexSlug; - - const currentComplexName = useMemo(() => { - const complexes = myComplexesQuery.data ?? []; - if (complexes.length === 0) return 'Mi complejo'; - - const selected = complexes.find((complex) => complex.complexSlug === complexSlug); - - return selected?.complexName ?? complexes[0]?.complexName ?? 'Mi complejo'; - }, [complexSlug, myComplexesQuery.data]); - - const handleSignOut = async () => { - await signOut(); - await navigate({ to: '/login' }); - }; - - return ( -
-
-
-

{currentComplexName}

- -
- -
- - - {!isAuthenticated && ( - - )} - - {isAuthenticated && ( - - - - - - Mi cuenta - - {myComplexesQuery.data && myComplexesQuery.data.length > 1 && ( - <> - - Cambiar complejo - - {myComplexesQuery.data.map((complex) => ( - { - await apiClient.complexes.select({ complexId: complex.id }); - setCurrentComplex(complex.complexSlug); - }} - > - {complex.complexSlug === complexSlug && } - {complex.complexName} - - ))} - - - )} - { - void navigate({ to: '/profile' }); - }} - > - - Profile - - { - void handleSignOut(); - }} - > - - Sign out - - - - )} - - - - - - - Navegación - - { - void navigate({ to: '/' }); - }} - > - Home - - { - void navigate({ to: '/about' }); - }} - > - About - - {isAuthenticated && currentComplexSlug && ( - { - void navigate({ - to: '/complex/$slug/edit', - params: { slug: currentComplexSlug }, - }); - }} - > - Configuración - - )} - - - {isAuthenticated ? ( - <> - { - void navigate({ to: '/profile' }); - }} - > - - Profile - - { - void handleSignOut(); - }} - > - - Sign out - - - ) : ( - { - void navigate({ to: '/login' }); - }} - > - Login - - )} - - -
-
-
- -
-
- ); -} diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css index e49f238..328efd3 100644 --- a/apps/frontend/src/index.css +++ b/apps/frontend/src/index.css @@ -1,44 +1,80 @@ @import "tailwindcss"; @import "tw-animate-css"; @import "shadcn/tailwind.css"; -@import "@fontsource-variable/geist"; +/* @import "@fontsource-variable/inter"; */ @custom-variant dark (&:is(.dark *)); +@source "../../../apps/**/*.{ts,tsx}"; +@source "../../../components/**/*.{ts,tsx}"; +@source "../**/*.{ts,tsx}"; + +html, +body, +#root { + width: 100%; + min-height: 100%; + margin: 0; +} + @theme inline { --font-heading: var(--font-sans); - --font-sans: "Geist Variable", sans-serif; - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); - --color-foreground: var(--foreground); + --color-background: var(--background); + --color-foreground: var(--foreground); + + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + + --color-reserved: var(--reserved); + --color-reserved-foreground: var(--reserved-foreground); + + --color-maintenance: var(--maintenance); + --color-maintenance-foreground: var(--maintenance-foreground); + + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --radius-sm: calc(var(--radius) * 0.6); --radius-md: calc(var(--radius) * 0.8); --radius-lg: var(--radius); @@ -46,85 +82,166 @@ --radius-2xl: calc(var(--radius) * 1.8); --radius-3xl: calc(var(--radius) * 2.2); --radius-4xl: calc(var(--radius) * 2.6); + + --font-sans: "Inter Variable", sans-serif; } :root { - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); + --background: oklch(0.985 0.004 240); + --foreground: oklch(0.18 0.012 245); + --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); + --card-foreground: oklch(0.18 0.012 245); + --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --popover-foreground: oklch(0.18 0.012 245); + + --primary: oklch(0.63 0.19 158); + --primary-foreground: oklch(0.985 0.004 160); + + --secondary: oklch(0.94 0.01 240); + --secondary-foreground: oklch(0.28 0.018 245); + + --muted: oklch(0.95 0.008 240); + --muted-foreground: oklch(0.52 0.018 245); + + --accent: oklch(0.93 0.035 158); + --accent-foreground: oklch(0.34 0.12 158); + + --destructive: oklch(0.62 0.22 25); + --destructive-foreground: oklch(0.98 0.01 25); + + --success: oklch(0.63 0.19 158); + --success-foreground: oklch(0.985 0.004 160); + + --reserved: oklch(0.58 0.19 255); + --reserved-foreground: oklch(0.985 0.01 255); + + --maintenance: oklch(0.64 0.22 25); + --maintenance-foreground: oklch(0.985 0.01 25); + + --warning: oklch(0.78 0.16 75); + --warning-foreground: oklch(0.18 0.04 75); + + --border: oklch(0.86 0.01 245 / 65%); + --input: oklch(0.86 0.01 245 / 75%); + --ring: oklch(0.63 0.19 158); + + --chart-1: oklch(0.63 0.19 158); + --chart-2: oklch(0.58 0.19 255); + --chart-3: oklch(0.64 0.22 25); + --chart-4: oklch(0.78 0.16 75); + --chart-5: oklch(0.68 0.16 205); + + --radius: 0.875rem; + + --sidebar: oklch(0.98 0.004 240); + --sidebar-foreground: oklch(0.18 0.012 245); + --sidebar-primary: oklch(0.63 0.19 158); + --sidebar-primary-foreground: oklch(0.985 0.004 160); + --sidebar-accent: oklch(0.94 0.01 240); + --sidebar-accent-foreground: oklch(0.28 0.018 245); + --sidebar-border: oklch(0.86 0.01 245 / 65%); + --sidebar-ring: oklch(0.63 0.19 158); } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --background: #081118; + --foreground: oklch(0.96 0.006 245); + + --card: oklch(0.19 0.022 240); + --card-foreground: oklch(0.96 0.006 245); + + --popover: oklch(0.18 0.022 240); + --popover-foreground: oklch(0.96 0.006 245); + + --primary: oklch(0.67 0.21 158); + --primary-foreground: oklch(0.12 0.02 158); + + --secondary: oklch(0.235 0.024 240); + --secondary-foreground: oklch(0.9 0.008 245); + + --muted: oklch(0.24 0.022 240); + --muted-foreground: oklch(0.68 0.018 245); + + --accent: oklch(0.28 0.06 158); + --accent-foreground: oklch(0.9 0.04 158); + + --destructive: oklch(0.66 0.23 25); + --destructive-foreground: oklch(0.98 0.01 25); + + --success: oklch(0.67 0.21 158); + --success-foreground: oklch(0.12 0.02 158); + + --reserved: oklch(0.61 0.2 255); + --reserved-foreground: oklch(0.97 0.012 255); + + --maintenance: oklch(0.66 0.23 25); + --maintenance-foreground: oklch(0.98 0.01 25); + + --warning: oklch(0.78 0.17 75); + --warning-foreground: oklch(0.18 0.04 75); + + --border: oklch(1 0 0 / 11%); + --input: oklch(1 0 0 / 14%); + --ring: oklch(0.67 0.21 158); + + --chart-1: oklch(0.67 0.21 158); + --chart-2: oklch(0.61 0.2 255); + --chart-3: oklch(0.66 0.23 25); + --chart-4: oklch(0.78 0.17 75); + --chart-5: oklch(0.7 0.16 205); + + --sidebar: oklch(0.165 0.02 240); + --sidebar-foreground: oklch(0.96 0.006 245); + --sidebar-primary: oklch(0.67 0.21 158); + --sidebar-primary-foreground: oklch(0.12 0.02 158); + --sidebar-accent: oklch(0.235 0.024 240); + --sidebar-accent-foreground: oklch(0.9 0.008 245); + --sidebar-border: oklch(1 0 0 / 11%); + --sidebar-ring: oklch(0.67 0.21 158); } @layer base { * { @apply border-border outline-ring/50; } + body { - @apply bg-background text-foreground; + @apply bg-background text-foreground antialiased; } - html { - @apply font-sans; + + button:not(:disabled), + [role="button"]:not(:disabled) { + cursor: pointer; + } +} + +@layer utilities { + .booking-scrollbar { + scrollbar-color: oklch(0.67 0.21 158 / 62%) oklch(1 0 0 / 8%); + scrollbar-width: thin; + } + + .booking-scrollbar::-webkit-scrollbar { + height: 12px; + } + + .booking-scrollbar::-webkit-scrollbar-track { + background: oklch(1 0 0 / 6%); + border-radius: 999px; + } + + .booking-scrollbar::-webkit-scrollbar-thumb { + background: linear-gradient(90deg, oklch(0.67 0.21 158 / 72%), oklch(0.61 0.2 255 / 58%)); + border: 3px solid transparent; + border-radius: 999px; + background-clip: padding-box; + } + + .booking-scrollbar::-webkit-scrollbar-thumb:hover { + background: linear-gradient(90deg, oklch(0.67 0.21 158 / 88%), oklch(0.61 0.2 255 / 72%)); + border: 3px solid transparent; + background-clip: padding-box; } } diff --git a/apps/frontend/src/lib/theme.tsx b/apps/frontend/src/lib/theme.tsx index 8b39396..30e3b61 100644 --- a/apps/frontend/src/lib/theme.tsx +++ b/apps/frontend/src/lib/theme.tsx @@ -1,6 +1,7 @@ import { type PropsWithChildren, createContext, + useCallback, useContext, useEffect, useMemo, @@ -14,6 +15,7 @@ type ThemeContextValue = { theme: Theme; resolvedTheme: ResolvedTheme; setTheme: (theme: Theme) => void; + toggleTheme: () => void; }; const THEME_STORAGE_KEY = 'theme'; @@ -27,6 +29,18 @@ function applyResolvedTheme(theme: ResolvedTheme) { document.documentElement.classList.toggle('dark', theme === 'dark'); } +function isEditableElement(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) return false; + + const tagName = target.tagName.toLowerCase(); + return ( + tagName === 'input' || + tagName === 'textarea' || + tagName === 'select' || + target.isContentEditable + ); +} + export function ThemeProvider({ children }: PropsWithChildren) { const [theme, setThemeState] = useState('system'); const [resolvedTheme, setResolvedTheme] = useState('light'); @@ -54,18 +68,37 @@ export function ThemeProvider({ children }: PropsWithChildren) { return () => mediaQuery.removeEventListener('change', updateTheme); }, [theme]); - const setTheme = (nextTheme: Theme) => { + const setTheme = useCallback((nextTheme: Theme) => { setThemeState(nextTheme); localStorage.setItem(THEME_STORAGE_KEY, nextTheme); - }; + }, []); + + const toggleTheme = useCallback(() => { + setTheme(resolvedTheme === 'dark' ? 'light' : 'dark'); + }, [resolvedTheme, setTheme]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (isEditableElement(event.target)) return; + if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return; + if (event.key.toLowerCase() !== 'd') return; + + event.preventDefault(); + toggleTheme(); + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [toggleTheme]); const value = useMemo( () => ({ theme, resolvedTheme, setTheme, + toggleTheme, }), - [theme, resolvedTheme] + [theme, resolvedTheme, setTheme, toggleTheme] ); return {children}; diff --git a/apps/frontend/src/routes/__root.tsx b/apps/frontend/src/routes/__root.tsx index 986e608..fba4c84 100644 --- a/apps/frontend/src/routes/__root.tsx +++ b/apps/frontend/src/routes/__root.tsx @@ -1,3 +1,4 @@ +import { Layout } from '@/components/layout'; import { NotFoundPage } from '@/features/not-found/not-found-page'; import { ServerErrorPage } from '@/features/server-error/server-error-page'; import type { ApiClient } from '@/lib/api-client'; @@ -10,7 +11,17 @@ type RouterContext = { }; export const Route = createRootRouteWithContext()({ - component: Outlet, + component: RootRoute, errorComponent: ServerErrorPage, notFoundComponent: NotFoundPage, }); + +function RootRoute() { + return ( + +
+ +
+
+ ); +} diff --git a/apps/frontend/src/routes/_app/route.tsx b/apps/frontend/src/routes/_app/route.tsx index ed8b452..f3724a9 100644 --- a/apps/frontend/src/routes/_app/route.tsx +++ b/apps/frontend/src/routes/_app/route.tsx @@ -1,6 +1,6 @@ -import { RootLayout } from '@/features/layout/root-layout'; import { createFileRoute } from '@tanstack/react-router'; +import { Outlet } from '@tanstack/react-router'; export const Route = createFileRoute('/_app')({ - component: RootLayout, + component: Outlet, }); diff --git a/packages/api-contract/src/admin-booking.ts b/packages/api-contract/src/admin-booking.ts index 12fd5ab..dd41dd2 100644 --- a/packages/api-contract/src/admin-booking.ts +++ b/packages/api-contract/src/admin-booking.ts @@ -55,7 +55,7 @@ export const createAdminBookingSchema = z.object({ }) export const updateAdminBookingStatusSchema = z.object({ - status: z.enum(['CANCELLED', 'COMPLETED']), + status: z.enum(['CANCELLED', 'COMPLETED', 'NOSHOW']), }) export type BookingStatus = z.infer