Compare commits

..

27 Commits

Author SHA1 Message Date
Ivan
a713369e03 fix: Settings courts list not loading and status display
- Handle Courts API returning array directly (not wrapped in data property)
- Map pricePerHour to hourlyRate for frontend compatibility
- Handle uppercase DB status values (AVAILABLE, MAINTENANCE, CLOSED)
- Send pricePerHour field when creating/updating courts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 03:52:08 +00:00
Ivan
7d0d6d32f1 fix: Live Courts data structure and status naming
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:26:21 +00:00
Ivan
da8a730867 fix: handle isOpenPlay and map form values to Prisma enums in court API
- Add isOpenPlay field to POST and PUT routes
- Accept both hourlyRate and pricePerHour (form vs API naming)
- Map lowercase type/status from form to uppercase Prisma enums

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:21:36 +00:00
Ivan
296491d0b9 feat: add production start script with tunnel URL config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:07:14 +00:00
Ivan
a882c8698d feat: update sidebar nav, add open play toggle, mark courts 5-6 as open play
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 03:55:13 +00:00
Ivan
0753edb275 feat: redesign clients page as CRM with membership tracking
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 03:55:02 +00:00
Ivan
e87b1a5df4 feat: add Live Courts page with real-time court status
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 03:54:45 +00:00
Ivan
09518c5335 feat: add court session and live courts API routes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 03:54:38 +00:00
Ivan
f521eeb698 feat: add CourtSession model and isOpenPlay field to Court
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 03:44:54 +00:00
Ivan
08cdad3a4e docs: add Live Courts + CRM Clients design document
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 03:41:49 +00:00
Ivan
4127485dea fix: support 6-column grid layout in booking calendar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 00:38:58 +00:00
Ivan
25b1495bb0 feat: update seed data for Cabo Pickleball Club
- Organization: Cabo Pickleball Club, Mazatlan timezone
- Single site: Corridor Courts, Cabo San Lucas
- 6 outdoor courts at 300 MXN/person
- Admin: ivan@horuxfin.com
- 5 membership plans: Day Pass, 10-Day, 10-Morning, Monthly, Family
- Pickleball products replacing padel items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:29:16 +00:00
Ivan
d3419a8cc5 feat: translate API error messages to English
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:24:54 +00:00
Ivan
3aeda8c2fb feat: translate settings page to English
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:24:52 +00:00
Ivan
0498844b4f feat: translate reports page to English
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:24:50 +00:00
Ivan
407744d00f feat: translate memberships page and components to English
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:24:48 +00:00
Ivan
13bd84a0b5 feat: translate clients/players page to English
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:22:57 +00:00
Ivan
3e65974727 feat: translate bookings page and components to English
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:22:55 +00:00
Ivan
0fb27b1825 feat: translate dashboard page and components to English
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:22:53 +00:00
Ivan
55676f59bd feat: translate auth and layout components to English
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:22:40 +00:00
Ivan
ec48ff8405 feat: rebrand to Cabo Pickleball Club with English UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:13:08 +00:00
Ivan
f905c0dfbe feat: update color palette to Cabo blue (#2990EA) and amber accent
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:13:06 +00:00
Ivan
18066f150f docs: add Cabo Pickleball Club implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 20:55:48 +00:00
Ivan
5185b65618 docs: add Cabo Pickleball Club adaptation design document
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:07:40 +00:00
Ivan
45ceeba9e3 feat: rebrand application from Padel Pro to SmashPoint
Complete rename across all layers: UI branding, package names
(@smashpoint/web, @smashpoint/shared), infrastructure (Docker,
DB config), seed data, documentation, and logo/favicon.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 02:46:29 +00:00
Ivan
242b8bad3d fix: dashboard updates when switching sites
- Added SiteContext for global site selection state
- Updated admin layout with SiteProvider
- Updated SiteSwitcher to use shared context
- Dashboard now refetches data when site changes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 08:41:02 +00:00
Ivan
51ecb1b231 feat: add settings and reports pages
- Add settings page with organization, sites, courts, and users tabs
- Add reports page with revenue charts and statistics
- Add users API endpoint
- Add sites/[id] API endpoint for CRUD operations
- Add tabs UI component
- Fix sites API to return isActive field

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 08:27:32 +00:00
69 changed files with 5153 additions and 873 deletions

View File

@@ -1,11 +1,11 @@
# Dockerfile para Padel Pro # Dockerfile para SmashPoint
# Multi-stage build para optimizar el tamano de la imagen # Multi-stage build para optimizar el tamano de la imagen
# ============================================ # ============================================
# Stage 1: Dependencias # Stage 1: Dependencias
# ============================================ # ============================================
FROM node:20-alpine AS deps FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat openssl
WORKDIR /app WORKDIR /app
@@ -24,7 +24,7 @@ RUN pnpm install --frozen-lockfile
# Stage 2: Builder # Stage 2: Builder
# ============================================ # ============================================
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat openssl
WORKDIR /app WORKDIR /app
@@ -50,6 +50,7 @@ RUN pnpm build
# Stage 3: Runner (Produccion) # Stage 3: Runner (Produccion)
# ============================================ # ============================================
FROM node:20-alpine AS runner FROM node:20-alpine AS runner
RUN apk add --no-cache openssl
WORKDIR /app WORKDIR /app
@@ -61,17 +62,15 @@ ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
# Copiar archivos necesarios para produccion # Copiar archivos de Next.js standalone (incluye node_modules necesarios)
COPY --from=builder /app/apps/web/public ./apps/web/public
# Copiar archivos de Next.js standalone
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
# Crear public folder
RUN mkdir -p ./apps/web/public && chown nextjs:nodejs ./apps/web/public
# Copiar schema de Prisma para migraciones # Copiar schema de Prisma para migraciones
COPY --from=builder /app/apps/web/prisma ./apps/web/prisma COPY --from=builder /app/apps/web/prisma ./apps/web/prisma
COPY --from=builder /app/apps/web/node_modules/.prisma ./apps/web/node_modules/.prisma
COPY --from=builder /app/apps/web/node_modules/@prisma ./apps/web/node_modules/@prisma
# Cambiar a usuario no-root # Cambiar a usuario no-root
USER nextjs USER nextjs
@@ -83,4 +82,4 @@ ENV PORT 3000
ENV HOSTNAME "0.0.0.0" ENV HOSTNAME "0.0.0.0"
# Comando de inicio # Comando de inicio
CMD ["node", "apps/web/server.js"] CMD ["node", "server.js"]

View File

@@ -1,4 +1,4 @@
# Padel Pro # SmashPoint
Sistema integral de gestion para clubes de padel. Una solucion moderna y completa para administrar reservas, ventas, torneos, membresias y multiples sedes. Sistema integral de gestion para clubes de padel. Una solucion moderna y completa para administrar reservas, ventas, torneos, membresias y multiples sedes.
@@ -69,8 +69,8 @@ Sistema integral de gestion para clubes de padel. Una solucion moderna y complet
```bash ```bash
# Clonar el repositorio # Clonar el repositorio
git clone https://github.com/tu-organizacion/padel-pro.git git clone https://github.com/tu-organizacion/smashpoint.git
cd padel-pro cd smashpoint
# Instalar dependencias # Instalar dependencias
pnpm install pnpm install
@@ -99,7 +99,7 @@ La aplicacion estara disponible en `http://localhost:3000`
## Estructura del Proyecto ## Estructura del Proyecto
``` ```
padel-pro/ smashpoint/
├── apps/ ├── apps/
│ └── web/ # Aplicacion Next.js principal │ └── web/ # Aplicacion Next.js principal
│ ├── app/ │ ├── app/
@@ -137,7 +137,7 @@ Crear un archivo `.env` en `apps/web/` con las siguientes variables:
```env ```env
# Base de datos # Base de datos
DATABASE_URL="postgresql://usuario:password@localhost:5432/padel_pro?schema=public" DATABASE_URL="postgresql://usuario:password@localhost:5432/smashpoint_db?schema=public"
# NextAuth # NextAuth
NEXTAUTH_SECRET="tu-clave-secreta-aqui" NEXTAUTH_SECRET="tu-clave-secreta-aqui"
@@ -211,8 +211,8 @@ Despues de ejecutar el seed, puedes acceder con:
| Usuario | Password | Rol | | Usuario | Password | Rol |
|---------|----------|-----| |---------|----------|-----|
| `admin@padelpro.com` | `admin123` | Super Admin | | `admin@smashpoint.com` | `admin123` | Super Admin |
| `recepcion@padelpro.com` | `recepcion123` | Recepcionista | | `recepcion@smashpoint.com` | `recepcion123` | Recepcionista |
> **IMPORTANTE:** Cambiar estas credenciales inmediatamente en entornos de produccion. > **IMPORTANTE:** Cambiar estas credenciales inmediatamente en entornos de produccion.
@@ -254,7 +254,7 @@ Este proyecto esta bajo la Licencia MIT. Ver el archivo [LICENSE](LICENSE) para
``` ```
MIT License MIT License
Copyright (c) 2024 Padel Pro Copyright (c) 2024 SmashPoint
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,4 +1,4 @@
DATABASE_URL="postgresql://user:password@localhost:5432/padel_pro?schema=public" DATABASE_URL="postgresql://user:password@localhost:5432/smashpoint_db?schema=public"
NEXTAUTH_SECRET="your-secret-key-here" NEXTAUTH_SECRET="your-secret-key-here"
NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_URL="http://localhost:3000"
NEXT_PUBLIC_APP_URL="http://localhost:3000" NEXT_PUBLIC_APP_URL="http://localhost:3000"

View File

@@ -29,9 +29,9 @@ export default function BookingsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-bold text-primary-800">Reservas</h1> <h1 className="text-2xl font-bold text-primary-800">Bookings</h1>
<p className="mt-2 text-primary-600"> <p className="mt-2 text-primary-600">
Gestiona las reservas de canchas. Selecciona un horario para crear o ver una reserva. Manage court bookings. Select a time slot to create or view a booking.
</p> </p>
</div> </div>

View File

@@ -1,14 +1,33 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ClientTable } from "@/components/clients/client-table";
import { ClientForm } from "@/components/clients/client-form"; import { ClientForm } from "@/components/clients/client-form";
import { ClientDetailDialog } from "@/components/clients/client-detail-dialog"; import { ClientDetailDialog } from "@/components/clients/client-detail-dialog";
import { AssignMembershipDialog } from "@/components/memberships/assign-membership-dialog"; import { AssignMembershipDialog } from "@/components/memberships/assign-membership-dialog";
import { StatCard, StatCardSkeleton } from "@/components/dashboard/stat-card"; import { StatCardSkeleton } from "@/components/dashboard/stat-card";
import { cn, formatDate } from "@/lib/utils";
import {
Users,
CreditCard,
AlertTriangle,
UserX,
Search,
Eye,
Phone,
Mail,
Calendar,
Plus,
ChevronLeft,
ChevronRight,
X,
} from "lucide-react";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface Client { interface Client {
id: string; id: string;
@@ -44,6 +63,7 @@ interface Client {
totalSpent: number; totalSpent: number;
balance: number; balance: number;
}; };
lastBookingDate?: string | null;
} }
interface ClientsResponse { interface ClientsResponse {
@@ -65,23 +85,107 @@ interface MembershipPlan {
discountPercent: number | string | null; discountPercent: number | string | null;
} }
const membershipFilters = [ // ---------------------------------------------------------------------------
{ value: "", label: "Todos" }, // Helpers
{ value: "with", label: "Con membresia" }, // ---------------------------------------------------------------------------
{ value: "without", label: "Sin membresia" },
]; type MembershipStatus = "active" | "expiring" | "expired" | "none";
function getMembershipStatus(client: Client): MembershipStatus {
const membership = client.memberships?.[0];
if (!membership) return "none";
if (membership.status !== "ACTIVE") {
// Check if it truly expired vs just inactive
const end = new Date(membership.endDate);
if (end < new Date()) return "expired";
return "none";
}
const end = new Date(membership.endDate);
const now = new Date();
if (end < now) return "expired";
const daysUntilExpiry = Math.ceil(
(end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
);
if (daysUntilExpiry <= 30) return "expiring";
return "active";
}
function getStatusBadge(status: MembershipStatus) {
switch (status) {
case "active":
return {
label: "Active",
className: "bg-green-100 text-green-700 border-green-300",
};
case "expiring":
return {
label: "Expiring Soon",
className: "bg-amber-100 text-amber-700 border-amber-300",
};
case "expired":
return {
label: "Expired",
className: "bg-red-100 text-red-700 border-red-300",
};
case "none":
default:
return {
label: "None",
className: "bg-gray-100 text-gray-600 border-gray-200",
};
}
}
function getInitials(firstName: string, lastName: string) {
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
}
function formatShortDate(dateStr: string): string {
const d = new Date(dateStr);
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
// ---------------------------------------------------------------------------
// Filter options
// ---------------------------------------------------------------------------
const FILTER_OPTIONS = [
{ value: "all", label: "All" },
{ value: "active", label: "Active Members" },
{ value: "expiring", label: "Expiring Soon" },
{ value: "expired", label: "Expired" },
{ value: "none", label: "No Membership" },
] as const;
type FilterValue = (typeof FILTER_OPTIONS)[number]["value"];
const ITEMS_PER_PAGE = 10; const ITEMS_PER_PAGE = 10;
// ---------------------------------------------------------------------------
// Page Component
// ---------------------------------------------------------------------------
export default function ClientsPage() { export default function ClientsPage() {
// Clients state // Data state
const [clients, setClients] = useState<Client[]>([]); const [clients, setClients] = useState<Client[]>([]);
const [loadingClients, setLoadingClients] = useState(true); const [loadingClients, setLoadingClients] = useState(true);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [membershipFilter, setMembershipFilter] = useState(""); const [filterValue, setFilterValue] = useState<FilterValue>("all");
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [totalClients, setTotalClients] = useState(0); const [totalClients, setTotalClients] = useState(0);
// Stats state
const [allClientsForStats, setAllClientsForStats] = useState<Client[]>([]);
const [loadingStats, setLoadingStats] = useState(true);
// Modal state // Modal state
const [showCreateForm, setShowCreateForm] = useState(false); const [showCreateForm, setShowCreateForm] = useState(false);
const [editingClient, setEditingClient] = useState<Client | null>(null); const [editingClient, setEditingClient] = useState<Client | null>(null);
@@ -89,20 +193,36 @@ export default function ClientsPage() {
const [showAssignMembership, setShowAssignMembership] = useState(false); const [showAssignMembership, setShowAssignMembership] = useState(false);
const [formLoading, setFormLoading] = useState(false); const [formLoading, setFormLoading] = useState(false);
// Stats state
const [stats, setStats] = useState({
totalClients: 0,
withMembership: 0,
newThisMonth: 0,
});
const [loadingStats, setLoadingStats] = useState(true);
// Membership plans for assignment dialog // Membership plans for assignment dialog
const [membershipPlans, setMembershipPlans] = useState<MembershipPlan[]>([]); const [membershipPlans, setMembershipPlans] = useState<MembershipPlan[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Fetch clients // ---------------------------------------------------------------------------
// Derived stats
// ---------------------------------------------------------------------------
const stats = useMemo(() => {
const total = allClientsForStats.length;
let activeMemberships = 0;
let expiringThisMonth = 0;
let noMembership = 0;
for (const c of allClientsForStats) {
const status = getMembershipStatus(c);
if (status === "active") activeMemberships++;
if (status === "expiring") expiringThisMonth++;
if (status === "none") noMembership++;
}
return { total, activeMemberships, expiringThisMonth, noMembership };
}, [allClientsForStats]);
// ---------------------------------------------------------------------------
// Fetchers
// ---------------------------------------------------------------------------
const fetchClients = useCallback(async () => { const fetchClients = useCallback(async () => {
setLoadingClients(true); setLoadingClients(true);
try { try {
@@ -112,69 +232,35 @@ export default function ClientsPage() {
params.append("offset", ((currentPage - 1) * ITEMS_PER_PAGE).toString()); params.append("offset", ((currentPage - 1) * ITEMS_PER_PAGE).toString());
const response = await fetch(`/api/clients?${params.toString()}`); const response = await fetch(`/api/clients?${params.toString()}`);
if (!response.ok) throw new Error("Error al cargar clientes"); if (!response.ok) throw new Error("Error loading clients");
const data: ClientsResponse = await response.json(); const data: ClientsResponse = await response.json();
// Filter by membership status client-side for simplicity // Apply membership filter client-side
let filteredData = data.data; let filtered = data.data;
if (membershipFilter === "with") { if (filterValue !== "all") {
filteredData = data.data.filter( filtered = data.data.filter(
(c) => (c) => getMembershipStatus(c) === filterValue
c.memberships &&
c.memberships.length > 0 &&
c.memberships[0].status === "ACTIVE"
);
} else if (membershipFilter === "without") {
filteredData = data.data.filter(
(c) =>
!c.memberships ||
c.memberships.length === 0 ||
c.memberships[0].status !== "ACTIVE"
); );
} }
setClients(filteredData); setClients(filtered);
setTotalClients(data.pagination.total); setTotalClients(data.pagination.total);
} catch (err) { } catch (err) {
console.error("Error fetching clients:", err); console.error("Error fetching clients:", err);
setError(err instanceof Error ? err.message : "Error desconocido"); setError(err instanceof Error ? err.message : "Unknown error");
} finally { } finally {
setLoadingClients(false); setLoadingClients(false);
} }
}, [searchQuery, currentPage, membershipFilter]); }, [searchQuery, currentPage, filterValue]);
// Fetch stats
const fetchStats = useCallback(async () => { const fetchStats = useCallback(async () => {
setLoadingStats(true); setLoadingStats(true);
try { try {
// Fetch all clients to calculate stats
const response = await fetch("/api/clients?limit=1000"); const response = await fetch("/api/clients?limit=1000");
if (!response.ok) throw new Error("Error al cargar estadisticas"); if (!response.ok) throw new Error("Error loading statistics");
const data: ClientsResponse = await response.json(); const data: ClientsResponse = await response.json();
const allClients = data.data; setAllClientsForStats(data.data);
// Calculate stats
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const withMembership = allClients.filter(
(c) =>
c.memberships &&
c.memberships.length > 0 &&
c.memberships[0].status === "ACTIVE"
).length;
const newThisMonth = allClients.filter(
(c) => new Date(c.createdAt) >= startOfMonth
).length;
setStats({
totalClients: data.pagination.total,
withMembership,
newThisMonth,
});
} catch (err) { } catch (err) {
console.error("Error fetching stats:", err); console.error("Error fetching stats:", err);
} finally { } finally {
@@ -182,31 +268,37 @@ export default function ClientsPage() {
} }
}, []); }, []);
// Fetch membership plans
const fetchMembershipPlans = useCallback(async () => { const fetchMembershipPlans = useCallback(async () => {
try { try {
const response = await fetch("/api/membership-plans"); const response = await fetch("/api/membership-plans");
if (!response.ok) throw new Error("Error al cargar planes"); if (!response.ok) throw new Error("Error loading plans");
const data = await response.json(); const data = await response.json();
setMembershipPlans(data.filter((p: MembershipPlan & { isActive?: boolean }) => p.isActive !== false)); setMembershipPlans(
data.filter(
(p: MembershipPlan & { isActive?: boolean }) => p.isActive !== false
)
);
} catch (err) { } catch (err) {
console.error("Error fetching membership plans:", err); console.error("Error fetching membership plans:", err);
} }
}, []); }, []);
// Fetch client details
const fetchClientDetails = async (clientId: string) => { const fetchClientDetails = async (clientId: string) => {
try { try {
const response = await fetch(`/api/clients/${clientId}`); const response = await fetch(`/api/clients/${clientId}`);
if (!response.ok) throw new Error("Error al cargar detalles del cliente"); if (!response.ok) throw new Error("Error loading client details");
const data = await response.json(); const data = await response.json();
setSelectedClient(data); setSelectedClient(data);
} catch (err) { } catch (err) {
console.error("Error fetching client details:", err); console.error("Error fetching client details:", err);
setError(err instanceof Error ? err.message : "Error desconocido"); setError(err instanceof Error ? err.message : "Unknown error");
} }
}; };
// ---------------------------------------------------------------------------
// Effects
// ---------------------------------------------------------------------------
useEffect(() => { useEffect(() => {
fetchClients(); fetchClients();
fetchStats(); fetchStats();
@@ -224,9 +316,12 @@ export default function ClientsPage() {
useEffect(() => { useEffect(() => {
setCurrentPage(1); setCurrentPage(1);
}, [debouncedSearch, membershipFilter]); }, [debouncedSearch, filterValue]);
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
// Handle create client
const handleCreateClient = async (data: { const handleCreateClient = async (data: {
firstName: string; firstName: string;
lastName: string; lastName: string;
@@ -241,12 +336,10 @@ export default function ClientsPage() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
throw new Error(errorData.error || "Error al crear cliente"); throw new Error(errorData.error || "Error creating client");
} }
setShowCreateForm(false); setShowCreateForm(false);
await Promise.all([fetchClients(), fetchStats()]); await Promise.all([fetchClients(), fetchStats()]);
} catch (err) { } catch (err) {
@@ -256,7 +349,6 @@ export default function ClientsPage() {
} }
}; };
// Handle update client
const handleUpdateClient = async (data: { const handleUpdateClient = async (data: {
firstName: string; firstName: string;
lastName: string; lastName: string;
@@ -265,7 +357,6 @@ export default function ClientsPage() {
avatar?: string; avatar?: string;
}) => { }) => {
if (!editingClient) return; if (!editingClient) return;
setFormLoading(true); setFormLoading(true);
try { try {
const response = await fetch(`/api/clients/${editingClient.id}`, { const response = await fetch(`/api/clients/${editingClient.id}`, {
@@ -273,16 +364,12 @@ export default function ClientsPage() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
throw new Error(errorData.error || "Error al actualizar cliente"); throw new Error(errorData.error || "Error updating client");
} }
setEditingClient(null); setEditingClient(null);
await fetchClients(); await fetchClients();
// Update selected client if viewing details
if (selectedClient?.id === editingClient.id) { if (selectedClient?.id === editingClient.id) {
await fetchClientDetails(editingClient.id); await fetchClientDetails(editingClient.id);
} }
@@ -293,34 +380,29 @@ export default function ClientsPage() {
} }
}; };
// Handle delete client
const handleDeleteClient = async (client: Client) => { const handleDeleteClient = async (client: Client) => {
if ( if (
!confirm( !confirm(
`¿Estas seguro de desactivar a ${client.firstName} ${client.lastName}?` `Are you sure you want to deactivate ${client.firstName} ${client.lastName}?`
) )
) { ) {
return; return;
} }
try { try {
const response = await fetch(`/api/clients/${client.id}`, { const response = await fetch(`/api/clients/${client.id}`, {
method: "DELETE", method: "DELETE",
}); });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
throw new Error(errorData.error || "Error al desactivar cliente"); throw new Error(errorData.error || "Error deactivating client");
} }
await Promise.all([fetchClients(), fetchStats()]); await Promise.all([fetchClients(), fetchStats()]);
} catch (err) { } catch (err) {
console.error("Error deleting client:", err); console.error("Error deleting client:", err);
setError(err instanceof Error ? err.message : "Error desconocido"); setError(err instanceof Error ? err.message : "Unknown error");
} }
}; };
// Handle assign membership
const handleAssignMembership = async (data: { const handleAssignMembership = async (data: {
clientId: string; clientId: string;
planId: string; planId: string;
@@ -334,16 +416,12 @@ export default function ClientsPage() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
throw new Error(errorData.error || "Error al asignar membresia"); throw new Error(errorData.error || "Error assigning membership");
} }
setShowAssignMembership(false); setShowAssignMembership(false);
await Promise.all([fetchClients(), fetchStats()]); await Promise.all([fetchClients(), fetchStats()]);
// Update selected client if viewing details
if (selectedClient) { if (selectedClient) {
await fetchClientDetails(selectedClient.id); await fetchClientDetails(selectedClient.id);
} }
@@ -354,39 +432,112 @@ export default function ClientsPage() {
} }
}; };
// Handle row click to view details
const handleRowClick = (client: Client) => { const handleRowClick = (client: Client) => {
fetchClientDetails(client.id); fetchClientDetails(client.id);
}; };
// Calculate pagination // Pagination
const totalPages = Math.ceil(totalClients / ITEMS_PER_PAGE); const totalPages = Math.ceil(totalClients / ITEMS_PER_PAGE);
// ---------------------------------------------------------------------------
// Render: Stat Card (inline for CRM style)
// ---------------------------------------------------------------------------
function CRMStatCard({
title,
value,
icon,
iconBg,
iconColor,
}: {
title: string;
value: number;
icon: React.ReactNode;
iconBg: string;
iconColor: string;
}) {
return (
<Card className="hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-center gap-4">
<div
className={cn(
"flex-shrink-0 w-12 h-12 rounded-lg flex items-center justify-center",
iconBg,
iconColor
)}
>
{icon}
</div>
<div>
<p className="text-sm font-medium text-primary-500">{title}</p>
<p className="text-2xl font-bold text-primary-800">{value}</p>
</div>
</div>
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// Render: Table skeleton
// ---------------------------------------------------------------------------
function TableSkeleton() {
return (
<>
{[...Array(5)].map((_, i) => (
<tr key={i} className="animate-pulse">
<td className="px-4 py-4">
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-full bg-primary-100" />
<div className="h-4 w-28 bg-primary-100 rounded" />
</div>
</td>
<td className="px-4 py-4">
<div className="space-y-1.5">
<div className="h-3 w-28 bg-primary-100 rounded" />
<div className="h-3 w-36 bg-primary-100 rounded" />
</div>
</td>
<td className="px-4 py-4">
<div className="h-4 w-20 bg-primary-100 rounded" />
</td>
<td className="px-4 py-4">
<div className="h-6 w-24 bg-primary-100 rounded-full" />
</td>
<td className="px-4 py-4">
<div className="h-4 w-24 bg-primary-100 rounded" />
</td>
<td className="px-4 py-4">
<div className="h-4 w-16 bg-primary-100 rounded" />
</td>
<td className="px-4 py-4">
<div className="h-8 w-16 bg-primary-100 rounded" />
</td>
</tr>
))}
</>
);
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-primary-800">Clientes</h1> <h1 className="text-2xl font-bold text-primary-800">Clients</h1>
<p className="mt-1 text-primary-600"> <p className="mt-1 text-primary-600">
Gestiona los clientes de tu centro Manage your club members and memberships
</p> </p>
</div> </div>
<Button onClick={() => setShowCreateForm(true)}> <Button onClick={() => setShowCreateForm(true)}>
<svg <Plus className="w-5 h-5 mr-2" />
className="w-5 h-5 mr-2" New Client
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
/>
</svg>
Nuevo Cliente
</Button> </Button>
</div> </div>
@@ -394,103 +545,56 @@ export default function ClientsPage() {
{error && ( {error && (
<div className="rounded-md bg-red-50 border border-red-200 p-4"> <div className="rounded-md bg-red-50 border border-red-200 p-4">
<div className="flex items-center"> <div className="flex items-center">
<svg <AlertTriangle className="h-5 w-5 text-red-400" />
className="h-5 w-5 text-red-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
<p className="ml-3 text-sm text-red-700">{error}</p> <p className="ml-3 text-sm text-red-700">{error}</p>
<button <button
onClick={() => setError(null)} onClick={() => setError(null)}
className="ml-auto text-red-500 hover:text-red-700" className="ml-auto text-red-500 hover:text-red-700"
> >
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> <X className="h-5 w-5" />
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button> </button>
</div> </div>
</div> </div>
)} )}
{/* Stats Cards */} {/* Stats Row */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{loadingStats ? ( {loadingStats ? (
<> <>
<StatCardSkeleton /> <StatCardSkeleton />
<StatCardSkeleton /> <StatCardSkeleton />
<StatCardSkeleton /> <StatCardSkeleton />
<StatCardSkeleton />
</> </>
) : ( ) : (
<> <>
<StatCard <CRMStatCard
title="Total Clientes" title="Total Clients"
value={stats.totalClients} value={stats.total}
color="primary" icon={<Users className="w-6 h-6" />}
icon={ iconBg="bg-primary-100"
<svg iconColor="text-primary-600"
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
}
/> />
<StatCard <CRMStatCard
title="Con Membresia" title="Active Memberships"
value={stats.withMembership} value={stats.activeMemberships}
color="accent" icon={<CreditCard className="w-6 h-6" />}
icon={ iconBg="bg-green-100"
<svg iconColor="text-green-600"
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"
/>
</svg>
}
/> />
<StatCard <CRMStatCard
title="Nuevos Este Mes" title="Expiring This Month"
value={stats.newThisMonth} value={stats.expiringThisMonth}
color="green" icon={<AlertTriangle className="w-6 h-6" />}
icon={ iconBg="bg-amber-100"
<svg iconColor="text-amber-600"
className="w-6 h-6" />
fill="none" <CRMStatCard
stroke="currentColor" title="No Membership"
viewBox="0 0 24 24" value={stats.noMembership}
> icon={<UserX className="w-6 h-6" />}
<path iconBg="bg-gray-100"
strokeLinecap="round" iconColor="text-gray-500"
strokeLinejoin="round"
strokeWidth={2}
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
/>
</svg>
}
/> />
</> </>
)} )}
@@ -501,46 +605,237 @@ export default function ClientsPage() {
<CardContent className="pt-4"> <CardContent className="pt-4">
<div className="flex flex-col sm:flex-row gap-4"> <div className="flex flex-col sm:flex-row gap-4">
{/* Search */} {/* Search */}
<div className="flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-primary-400" />
<Input <Input
type="text" type="text"
placeholder="Buscar por nombre, email o telefono..." placeholder="Search by name, email or phone..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full" className="pl-9 w-full"
/> />
</div> </div>
{/* Membership Filter */} {/* Membership Filter Dropdown */}
<div className="flex gap-2 overflow-x-auto pb-2 sm:pb-0"> <select
{membershipFilters.map((filter) => ( value={filterValue}
<Button onChange={(e) => setFilterValue(e.target.value as FilterValue)}
key={filter.value} className="flex h-10 rounded-md border border-primary-200 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 min-w-[180px]"
variant={membershipFilter === filter.value ? "default" : "outline"} >
size="sm" {FILTER_OPTIONS.map((opt) => (
onClick={() => setMembershipFilter(filter.value)} <option key={opt.value} value={opt.value}>
className="whitespace-nowrap" {opt.label}
> </option>
{filter.label}
</Button>
))} ))}
</div> </select>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Clients Table */} {/* Clients Table */}
<Card> <Card>
<ClientTable <div className="overflow-x-auto">
clients={clients} <table className="w-full">
onRowClick={handleRowClick} <thead>
onEdit={(client) => setEditingClient(client)} <tr className="border-b border-primary-200 bg-primary-50">
onDelete={handleDeleteClient} <th className="px-4 py-3 text-left text-xs font-semibold text-primary-600 uppercase tracking-wider">
isLoading={loadingClients} Name
currentPage={currentPage} </th>
totalPages={totalPages} <th className="px-4 py-3 text-left text-xs font-semibold text-primary-600 uppercase tracking-wider">
onPageChange={setCurrentPage} Contact
/> </th>
<th className="px-4 py-3 text-left text-xs font-semibold text-primary-600 uppercase tracking-wider">
Membership
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-primary-600 uppercase tracking-wider">
Status
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-primary-600 uppercase tracking-wider">
Expires
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-primary-600 uppercase tracking-wider">
Last Visit
</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-primary-600 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-primary-100">
{loadingClients ? (
<TableSkeleton />
) : clients.length === 0 ? (
<tr>
<td colSpan={7}>
<div className="flex flex-col items-center justify-center py-16 text-primary-500">
<Users className="w-12 h-12 mb-3 text-primary-300" />
<p className="font-medium">No clients found</p>
<p className="text-sm mt-1">
{searchQuery || filterValue !== "all"
? "Try adjusting your search or filters"
: "Add your first client to get started"}
</p>
</div>
</td>
</tr>
) : (
clients.map((client) => {
const membership = client.memberships?.[0];
const mStatus = getMembershipStatus(client);
const badge = getStatusBadge(mStatus);
// Determine last visit from lastBookingDate or fallback
const lastVisit = client.lastBookingDate || null;
return (
<tr
key={client.id}
className="hover:bg-primary-50 transition-colors cursor-pointer"
onClick={() => handleRowClick(client)}
>
{/* Name */}
<td className="px-4 py-3">
<div className="flex items-center gap-3">
{client.avatar ? (
<img
src={client.avatar}
alt={`${client.firstName} ${client.lastName}`}
className="h-9 w-9 rounded-full object-cover flex-shrink-0"
/>
) : (
<div className="h-9 w-9 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
<span className="text-sm font-semibold text-primary-600">
{getInitials(client.firstName, client.lastName)}
</span>
</div>
)}
<span className="font-medium text-primary-800 whitespace-nowrap">
{client.firstName} {client.lastName}
</span>
</div>
</td>
{/* Contact */}
<td className="px-4 py-3">
<div className="space-y-0.5">
{client.phone && (
<div className="flex items-center gap-1.5 text-sm text-primary-600">
<Phone className="w-3.5 h-3.5 text-primary-400" />
{client.phone}
</div>
)}
{client.email && (
<div className="flex items-center gap-1.5 text-sm text-primary-500">
<Mail className="w-3.5 h-3.5 text-primary-400" />
<span className="truncate max-w-[180px]">
{client.email}
</span>
</div>
)}
{!client.phone && !client.email && (
<span className="text-sm text-primary-400">
</span>
)}
</div>
</td>
{/* Membership Plan */}
<td className="px-4 py-3 text-sm text-primary-700">
{membership && membership.status === "ACTIVE"
? membership.plan.name
: membership && mStatus === "expired"
? membership.plan.name
: "None"}
</td>
{/* Status Badge */}
<td className="px-4 py-3">
<span
className={cn(
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border",
badge.className
)}
>
{badge.label}
</span>
</td>
{/* Expires */}
<td className="px-4 py-3 text-sm text-primary-600">
{membership &&
(membership.status === "ACTIVE" ||
mStatus === "expired") ? (
<div className="flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5 text-primary-400" />
{formatShortDate(membership.endDate)}
</div>
) : (
<span className="text-primary-400"></span>
)}
</td>
{/* Last Visit */}
<td className="px-4 py-3 text-sm text-primary-600">
{lastVisit ? (
formatShortDate(lastVisit)
) : (
<span className="text-primary-400">Never</span>
)}
</td>
{/* Actions */}
<td className="px-4 py-3 text-right">
<div
className="flex items-center justify-end gap-2"
onClick={(e) => e.stopPropagation()}
>
<Button
variant="outline"
size="sm"
onClick={() => handleRowClick(client)}
>
<Eye className="w-4 h-4 mr-1" />
View
</Button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-primary-200 bg-primary-50">
<div className="text-sm text-primary-600">
Page {currentPage} of {totalPages}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage <= 1}
>
<ChevronLeft className="w-4 h-4 mr-1" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage >= totalPages}
>
Next
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
</div>
</div>
)}
</Card> </Card>
{/* Create Client Form Modal */} {/* Create Client Form Modal */}

View File

@@ -1,12 +1,13 @@
"use client"; "use client";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useEffect, useState } from "react"; import { useEffect, useState, useCallback } from "react";
import { formatCurrency, formatDate } from "@/lib/utils"; import { formatCurrency, formatDate } from "@/lib/utils";
import { StatCard, StatCardSkeleton } from "@/components/dashboard/stat-card"; import { StatCard, StatCardSkeleton } from "@/components/dashboard/stat-card";
import { OccupancyChart, OccupancyChartSkeleton } from "@/components/dashboard/occupancy-chart"; import { OccupancyChart, OccupancyChartSkeleton } from "@/components/dashboard/occupancy-chart";
import { RecentBookings, RecentBookingsSkeleton } from "@/components/dashboard/recent-bookings"; import { RecentBookings, RecentBookingsSkeleton } from "@/components/dashboard/recent-bookings";
import { QuickActions } from "@/components/dashboard/quick-actions"; import { QuickActions } from "@/components/dashboard/quick-actions";
import { useSite } from "@/contexts/site-context";
interface DashboardStats { interface DashboardStats {
todayBookings: number; todayBookings: number;
@@ -49,36 +50,41 @@ interface DashboardData {
export default function DashboardPage() { export default function DashboardPage() {
const { data: session } = useSession(); const { data: session } = useSession();
const { selectedSiteId, selectedSite } = useSite();
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null); const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { const fetchDashboardData = useCallback(async () => {
async function fetchDashboardData() { try {
try { setIsLoading(true);
setIsLoading(true); setError(null);
setError(null);
const response = await fetch("/api/dashboard/stats"); const url = selectedSiteId
? `/api/dashboard/stats?siteId=${selectedSiteId}`
: "/api/dashboard/stats";
if (!response.ok) { const response = await fetch(url);
throw new Error("Error al cargar los datos del dashboard");
}
const data = await response.json(); if (!response.ok) {
setDashboardData(data); throw new Error("Error loading dashboard data");
} catch (err) {
console.error("Dashboard fetch error:", err);
setError(err instanceof Error ? err.message : "Error desconocido");
} finally {
setIsLoading(false);
} }
const data = await response.json();
setDashboardData(data);
} catch (err) {
console.error("Dashboard fetch error:", err);
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setIsLoading(false);
} }
}, [selectedSiteId]);
useEffect(() => {
fetchDashboardData(); fetchDashboardData();
}, []); }, [fetchDashboardData]);
const userName = session?.user?.name?.split(" ")[0] || "Usuario"; const userName = session?.user?.name?.split(" ")[0] || "User";
const today = new Date(); const today = new Date();
return ( return (
@@ -87,16 +93,16 @@ export default function DashboardPage() {
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4"> <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-primary-800"> <h1 className="text-2xl font-bold text-primary-800">
Bienvenido, {userName} Welcome, {userName}
</h1> </h1>
<p className="text-primary-500 mt-1"> <p className="text-primary-500 mt-1">
{formatDate(today)} - Panel de administracion {formatDate(today)} - Admin panel
</p> </p>
</div> </div>
{session?.user?.siteName && ( {selectedSite && (
<div className="flex items-center gap-2 px-4 py-2 bg-primary-50 rounded-lg"> <div className="flex items-center gap-2 px-4 py-2 bg-accent/10 rounded-lg border border-accent/20">
<svg <svg
className="w-5 h-5 text-primary-500" className="w-5 h-5 text-accent"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -114,8 +120,8 @@ export default function DashboardPage() {
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/> />
</svg> </svg>
<span className="text-sm font-medium text-primary-700"> <span className="text-sm font-medium text-accent-700">
{session.user.siteName} Showing: {selectedSite.name}
</span> </span>
</div> </div>
)} )}
@@ -155,7 +161,7 @@ export default function DashboardPage() {
) : dashboardData ? ( ) : dashboardData ? (
<> <>
<StatCard <StatCard
title="Reservas Hoy" title="Today's Bookings"
value={dashboardData.stats.todayBookings} value={dashboardData.stats.todayBookings}
color="blue" color="blue"
icon={ icon={
@@ -175,7 +181,7 @@ export default function DashboardPage() {
} }
/> />
<StatCard <StatCard
title="Ingresos Hoy" title="Today's Revenue"
value={formatCurrency(dashboardData.stats.todayRevenue)} value={formatCurrency(dashboardData.stats.todayRevenue)}
color="green" color="green"
icon={ icon={
@@ -195,7 +201,7 @@ export default function DashboardPage() {
} }
/> />
<StatCard <StatCard
title="Ocupacion" title="Occupancy"
value={`${dashboardData.stats.occupancyRate}%`} value={`${dashboardData.stats.occupancyRate}%`}
color="purple" color="purple"
icon={ icon={
@@ -215,7 +221,7 @@ export default function DashboardPage() {
} }
/> />
<StatCard <StatCard
title="Miembros Activos" title="Active Members"
value={dashboardData.stats.activeMembers} value={dashboardData.stats.activeMembers}
color="accent" color="accent"
icon={ icon={
@@ -242,7 +248,7 @@ export default function DashboardPage() {
{!isLoading && dashboardData && ( {!isLoading && dashboardData && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<StatCard <StatCard
title="Reservas Pendientes" title="Pending Bookings"
value={dashboardData.stats.pendingBookings} value={dashboardData.stats.pendingBookings}
color="orange" color="orange"
icon={ icon={
@@ -262,7 +268,7 @@ export default function DashboardPage() {
} }
/> />
<StatCard <StatCard
title="Torneos Proximos" title="Upcoming Events"
value={dashboardData.stats.upcomingTournaments} value={dashboardData.stats.upcomingTournaments}
color="primary" color="primary"
icon={ icon={

View File

@@ -1,4 +1,5 @@
import { AuthProvider } from '@/components/providers/auth-provider'; import { AuthProvider } from '@/components/providers/auth-provider';
import { SiteProvider } from '@/contexts/site-context';
import { Sidebar } from '@/components/layout/sidebar'; import { Sidebar } from '@/components/layout/sidebar';
import { Header } from '@/components/layout/header'; import { Header } from '@/components/layout/header';
@@ -9,13 +10,15 @@ export default function AdminLayout({
}) { }) {
return ( return (
<AuthProvider> <AuthProvider>
<div className="min-h-screen bg-primary-50"> <SiteProvider>
<Sidebar /> <div className="min-h-screen bg-primary-50">
<div className="pl-64"> <Sidebar />
<Header /> <div className="pl-64">
<main className="p-6">{children}</main> <Header />
<main className="p-6">{children}</main>
</div>
</div> </div>
</div> </SiteProvider>
</AuthProvider> </AuthProvider>
); );
} }

View File

@@ -0,0 +1,589 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { useSite } from "@/contexts/site-context";
import {
Users,
UserPlus,
Clock,
RefreshCw,
MapPin,
X,
Search,
} from "lucide-react";
// --- Types ---
interface Player {
id: string;
firstName?: string;
lastName?: string;
walkInName?: string;
checkedInAt: string;
sessionId: string;
}
interface Court {
id: string;
name: string;
type: "INDOOR" | "OUTDOOR";
isOpenPlay: boolean;
status: "available" | "active" | "booked" | "open_play";
players: Player[];
upcomingBooking?: {
startTime: string;
clientName: string;
};
}
interface ClientResult {
id: string;
firstName: string;
lastName: string;
}
// --- Helpers ---
function getStatusConfig(court: Court) {
if (court.status === "active") {
return {
dotColor: "bg-primary-500",
text: `Active (${court.players.length} player${court.players.length !== 1 ? "s" : ""})`,
bgColor: "bg-primary-50",
borderColor: "border-primary-200",
textColor: "text-primary-500",
};
}
if (court.status === "open_play" && court.players.length === 0) {
return {
dotColor: "bg-amber-500",
text: "Open Play",
bgColor: "bg-amber-50",
borderColor: "border-amber-200",
textColor: "text-amber-500",
};
}
if (court.status === "open_play" && court.players.length > 0) {
return {
dotColor: "bg-amber-500",
text: `Open Play (${court.players.length} player${court.players.length !== 1 ? "s" : ""})`,
bgColor: "bg-amber-50",
borderColor: "border-amber-200",
textColor: "text-amber-500",
};
}
if (court.status === "booked") {
return {
dotColor: "bg-purple-500",
text: "Booked",
bgColor: "bg-purple-50",
borderColor: "border-purple-200",
textColor: "text-purple-500",
};
}
return {
dotColor: "bg-green-500",
text: "Available",
bgColor: "bg-green-50",
borderColor: "border-green-200",
textColor: "text-green-500",
};
}
function formatTime(dateStr: string) {
return new Date(dateStr).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
function playerName(player: Player) {
if (player.walkInName) return player.walkInName;
return [player.firstName, player.lastName].filter(Boolean).join(" ") || "Unknown";
}
function playerInitials(player: Player) {
const name = playerName(player);
const parts = name.split(" ");
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return name.slice(0, 2).toUpperCase();
}
// --- Main Page ---
export default function LiveCourtsPage() {
const { selectedSiteId } = useSite();
const [courts, setCourts] = useState<Court[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [checkInCourtId, setCheckInCourtId] = useState<string | null>(null);
const [endSessionCourtId, setEndSessionCourtId] = useState<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
// --- Data fetching ---
const fetchCourts = useCallback(async () => {
try {
setError(null);
const url = selectedSiteId
? `/api/live?siteId=${selectedSiteId}`
: "/api/live";
const response = await fetch(url);
if (!response.ok) throw new Error("Failed to load court data");
const data = await response.json();
setCourts(Array.isArray(data) ? data : data.courts ?? data.data ?? []);
setLastUpdated(new Date());
} catch (err) {
console.error("Live courts fetch error:", err);
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setIsLoading(false);
}
}, [selectedSiteId]);
useEffect(() => {
fetchCourts();
intervalRef.current = setInterval(fetchCourts, 30000);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [fetchCourts]);
// --- End session handler ---
const handleEndSession = async (court: Court) => {
try {
await Promise.all(
court.players.map((p) =>
fetch(`/api/court-sessions/${p.sessionId}`, { method: "PUT" })
)
);
setEndSessionCourtId(null);
fetchCourts();
} catch (err) {
console.error("End session error:", err);
}
};
// --- Render ---
const checkInCourt = courts.find((c) => c.id === checkInCourtId) ?? null;
const endSessionCourt = courts.find((c) => c.id === endSessionCourtId) ?? null;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-primary-800">Live Courts</h1>
<p className="text-primary-500 mt-1">
{lastUpdated
? `Last updated: ${lastUpdated.toLocaleTimeString()}`
: "Loading..."}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => fetchCourts()}
disabled={isLoading}
>
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
{/* Error */}
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
)}
{/* Loading skeleton */}
{isLoading && courts.length === 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader className="pb-3">
<div className="animate-pulse space-y-2">
<div className="h-5 bg-primary-100 rounded w-32" />
<div className="h-4 bg-primary-100 rounded w-20" />
</div>
</CardHeader>
<CardContent>
<div className="animate-pulse space-y-3">
<div className="h-4 bg-primary-100 rounded w-full" />
<div className="h-8 bg-primary-100 rounded w-24" />
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Court grid */}
{!isLoading && courts.length === 0 && !error && (
<div className="text-center py-12 text-primary-500">
<MapPin className="w-12 h-12 mx-auto mb-4 opacity-40" />
<p className="text-lg font-medium">No courts found</p>
<p className="text-sm mt-1">Courts will appear here once configured.</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{courts.map((court) => {
const cfg = getStatusConfig(court);
const earliestCheckIn = court.players.length > 0
? court.players.reduce((earliest, p) =>
new Date(p.checkedInAt) < new Date(earliest.checkedInAt) ? p : earliest
)
: null;
return (
<Card key={court.id} className={`${cfg.borderColor} border`}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold">
{court.name}
</CardTitle>
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-primary-100 text-primary-600">
{court.type}
</span>
{court.isOpenPlay && (
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-amber-100 text-amber-700">
Open Play
</span>
)}
</div>
</div>
{/* Status indicator */}
<div className="flex items-center gap-2 mt-1">
<span className={`inline-block w-2.5 h-2.5 rounded-full ${cfg.dotColor}`} />
<span className={`text-sm font-medium ${cfg.textColor}`}>
{cfg.text}
</span>
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* Upcoming booking info for booked courts */}
{court.status === "booked" && court.upcomingBooking && (
<div className="text-sm text-purple-600 bg-purple-50 rounded-md px-3 py-2">
<Clock className="w-3.5 h-3.5 inline mr-1" />
{court.upcomingBooking.clientName} at{" "}
{formatTime(court.upcomingBooking.startTime)}
</div>
)}
{/* Player list */}
{court.players.length > 0 && (
<div className="space-y-2">
{court.players.map((player) => (
<div
key={player.id}
className="flex items-center gap-2 text-sm"
>
<div className="w-7 h-7 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center text-xs font-semibold shrink-0">
{playerInitials(player)}
</div>
<span className="text-primary-700 truncate">
{playerName(player)}
</span>
</div>
))}
</div>
)}
{/* Time since first check-in */}
{earliestCheckIn && (
<div className="flex items-center gap-1.5 text-xs text-primary-400">
<Clock className="w-3.5 h-3.5" />
Since {formatTime(earliestCheckIn.checkedInAt)}
</div>
)}
{/* Actions */}
<div className="flex flex-wrap gap-2 pt-1">
{(court.status === "available" ||
court.status === "active" ||
court.status === "open_play") && (
<Button
variant="outline"
size="sm"
onClick={() => setCheckInCourtId(court.id)}
>
<UserPlus className="w-4 h-4 mr-1" />
Check In
</Button>
)}
{(court.status === "active" || (court.status === "open_play" && court.players.length > 0)) && (
<Button
variant="outline"
size="sm"
className="text-red-600 border-red-200 hover:bg-red-50"
onClick={() => setEndSessionCourtId(court.id)}
>
<X className="w-4 h-4 mr-1" />
End Session
</Button>
)}
{court.isOpenPlay && (
<Button variant="outline" size="sm">
<Users className="w-4 h-4 mr-1" />
Schedule Group
</Button>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
{/* Check In Modal */}
{checkInCourt && (
<CheckInModal
court={checkInCourt}
onClose={() => setCheckInCourtId(null)}
onSuccess={() => {
setCheckInCourtId(null);
fetchCourts();
}}
/>
)}
{/* End Session Confirm Dialog */}
{endSessionCourt && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-lg shadow-xl max-w-sm w-full mx-4 p-6 space-y-4">
<h2 className="text-lg font-semibold text-primary-800">
End Session
</h2>
<p className="text-sm text-primary-600">
End session on <strong>{endSessionCourt.name}</strong>? This will
check out all players.
</p>
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setEndSessionCourtId(null)}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleEndSession(endSessionCourt)}
>
End Session
</Button>
</div>
</div>
</div>
)}
</div>
);
}
// --- Check In Modal Component ---
function CheckInModal({
court,
onClose,
onSuccess,
}: {
court: Court;
onClose: () => void;
onSuccess: () => void;
}) {
const [mode, setMode] = useState<"search" | "walkin">("search");
const [searchQuery, setSearchQuery] = useState("");
const [walkInName, setWalkInName] = useState("");
const [clients, setClients] = useState<ClientResult[]>([]);
const [selectedClient, setSelectedClient] = useState<ClientResult | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const debounceRef = useRef<NodeJS.Timeout | null>(null);
// Debounced client search
useEffect(() => {
if (searchQuery.length < 2) {
setClients([]);
return;
}
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
setIsSearching(true);
try {
const res = await fetch(
`/api/clients?search=${encodeURIComponent(searchQuery)}`
);
if (res.ok) {
const data = await res.json();
setClients(data.data ?? data ?? []);
}
} catch {
console.error("Client search failed");
} finally {
setIsSearching(false);
}
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [searchQuery]);
const handleSubmit = async () => {
setIsSubmitting(true);
try {
const body: Record<string, string> = { courtId: court.id };
if (mode === "search" && selectedClient) {
body.clientId = selectedClient.id;
} else if (mode === "walkin" && walkInName.trim()) {
body.walkInName = walkInName.trim();
} else {
return;
}
const res = await fetch("/api/court-sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error("Check-in failed");
onSuccess();
} catch (err) {
console.error("Check-in error:", err);
} finally {
setIsSubmitting(false);
}
};
const canSubmit =
(mode === "search" && selectedClient !== null) ||
(mode === "walkin" && walkInName.trim().length > 0);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 p-6 space-y-4">
{/* Modal header */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-primary-800">
Check In &mdash; {court.name}
</h2>
<button
onClick={onClose}
className="text-primary-400 hover:text-primary-600"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Mode toggle */}
<div className="flex rounded-lg border border-primary-200 overflow-hidden">
<button
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
mode === "search"
? "bg-primary-100 text-primary-800"
: "text-primary-500 hover:bg-primary-50"
}`}
onClick={() => setMode("search")}
>
<Search className="w-4 h-4 inline mr-1" />
Find Client
</button>
<button
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
mode === "walkin"
? "bg-primary-100 text-primary-800"
: "text-primary-500 hover:bg-primary-50"
}`}
onClick={() => setMode("walkin")}
>
<UserPlus className="w-4 h-4 inline mr-1" />
Walk-in
</button>
</div>
{/* Search mode */}
{mode === "search" && (
<div className="space-y-3">
<Input
placeholder="Search by name..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setSelectedClient(null);
}}
autoFocus
/>
{isSearching && (
<p className="text-sm text-primary-400">Searching...</p>
)}
{clients.length > 0 && (
<div className="max-h-40 overflow-y-auto border border-primary-200 rounded-md divide-y divide-primary-100">
{clients.map((client) => (
<button
key={client.id}
className={`w-full text-left px-3 py-2 text-sm hover:bg-primary-50 transition-colors ${
selectedClient?.id === client.id
? "bg-primary-100 font-medium"
: ""
}`}
onClick={() => setSelectedClient(client)}
>
{client.firstName} {client.lastName}
</button>
))}
</div>
)}
{searchQuery.length >= 2 &&
!isSearching &&
clients.length === 0 && (
<p className="text-sm text-primary-400">No clients found.</p>
)}
{selectedClient && (
<div className="flex items-center gap-2 text-sm bg-primary-50 rounded-md px-3 py-2">
<span className="font-medium text-primary-700">
Selected: {selectedClient.firstName} {selectedClient.lastName}
</span>
</div>
)}
</div>
)}
{/* Walk-in mode */}
{mode === "walkin" && (
<Input
placeholder="Enter walk-in name..."
value={walkInName}
onChange={(e) => setWalkInName(e.target.value)}
autoFocus
/>
)}
{/* Submit */}
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" size="sm" onClick={onClose}>
Cancel
</Button>
<Button
size="sm"
disabled={!canSubmit || isSubmitting}
onClick={handleSubmit}
>
{isSubmitting ? "Checking in..." : "Check In"}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -68,10 +68,10 @@ interface MembershipsResponse {
} }
const statusFilters = [ const statusFilters = [
{ value: "", label: "Todos" }, { value: "", label: "All" },
{ value: "ACTIVE", label: "Activas" }, { value: "ACTIVE", label: "Active" },
{ value: "EXPIRED", label: "Expiradas" }, { value: "EXPIRED", label: "Expired" },
{ value: "CANCELLED", label: "Canceladas" }, { value: "CANCELLED", label: "Cancelled" },
]; ];
export default function MembershipsPage() { export default function MembershipsPage() {
@@ -104,12 +104,12 @@ export default function MembershipsPage() {
setLoadingPlans(true); setLoadingPlans(true);
try { try {
const response = await fetch("/api/membership-plans?includeInactive=true"); const response = await fetch("/api/membership-plans?includeInactive=true");
if (!response.ok) throw new Error("Error al cargar planes"); if (!response.ok) throw new Error("Error loading plans");
const data = await response.json(); const data = await response.json();
setPlans(data); setPlans(data);
} catch (err) { } catch (err) {
console.error("Error fetching plans:", err); console.error("Error fetching plans:", err);
setError(err instanceof Error ? err.message : "Error desconocido"); setError(err instanceof Error ? err.message : "Unknown error");
} finally { } finally {
setLoadingPlans(false); setLoadingPlans(false);
} }
@@ -125,7 +125,7 @@ export default function MembershipsPage() {
if (searchQuery) params.append("search", searchQuery); if (searchQuery) params.append("search", searchQuery);
const response = await fetch(`/api/memberships?${params.toString()}`); const response = await fetch(`/api/memberships?${params.toString()}`);
if (!response.ok) throw new Error("Error al cargar membresias"); if (!response.ok) throw new Error("Error loading memberships");
const data: MembershipsResponse = await response.json(); const data: MembershipsResponse = await response.json();
setMemberships(data.data); setMemberships(data.data);
@@ -138,7 +138,7 @@ export default function MembershipsPage() {
}); });
} catch (err) { } catch (err) {
console.error("Error fetching memberships:", err); console.error("Error fetching memberships:", err);
setError(err instanceof Error ? err.message : "Error desconocido"); setError(err instanceof Error ? err.message : "Unknown error");
} finally { } finally {
setLoadingMemberships(false); setLoadingMemberships(false);
} }
@@ -209,7 +209,7 @@ export default function MembershipsPage() {
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
throw new Error(errorData.error || "Error al guardar plan"); throw new Error(errorData.error || "Error saving plan");
} }
setShowPlanForm(false); setShowPlanForm(false);
@@ -224,7 +224,7 @@ export default function MembershipsPage() {
// Handle plan deletion // Handle plan deletion
const handleDeletePlan = async (plan: MembershipPlan) => { const handleDeletePlan = async (plan: MembershipPlan) => {
if (!confirm(`¿Estas seguro de eliminar el plan "${plan.name}"? Esta accion lo desactivara.`)) { if (!confirm(`Are you sure you want to delete the plan "${plan.name}"? This action will deactivate it.`)) {
return; return;
} }
@@ -235,13 +235,13 @@ export default function MembershipsPage() {
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
throw new Error(errorData.error || "Error al eliminar plan"); throw new Error(errorData.error || "Error deleting plan");
} }
await fetchPlans(); await fetchPlans();
} catch (err) { } catch (err) {
console.error("Error deleting plan:", err); console.error("Error deleting plan:", err);
setError(err instanceof Error ? err.message : "Error desconocido"); setError(err instanceof Error ? err.message : "Unknown error");
} }
}; };
@@ -262,7 +262,7 @@ export default function MembershipsPage() {
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
throw new Error(errorData.error || "Error al asignar membresia"); throw new Error(errorData.error || "Error assigning membership");
} }
setShowAssignDialog(false); setShowAssignDialog(false);
@@ -295,19 +295,19 @@ export default function MembershipsPage() {
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
throw new Error(errorData.error || "Error al renovar membresia"); throw new Error(errorData.error || "Error renewing membership");
} }
await Promise.all([fetchMemberships(), fetchPlans()]); await Promise.all([fetchMemberships(), fetchPlans()]);
} catch (err) { } catch (err) {
console.error("Error renewing membership:", err); console.error("Error renewing membership:", err);
setError(err instanceof Error ? err.message : "Error desconocido"); setError(err instanceof Error ? err.message : "Unknown error");
} }
}; };
// Handle membership cancellation // Handle membership cancellation
const handleCancelMembership = async (membership: Membership) => { const handleCancelMembership = async (membership: Membership) => {
if (!confirm(`¿Estas seguro de cancelar la membresia de ${membership.client.firstName} ${membership.client.lastName}?`)) { if (!confirm(`Are you sure you want to cancel the membership for ${membership.client.firstName} ${membership.client.lastName}?`)) {
return; return;
} }
@@ -318,13 +318,13 @@ export default function MembershipsPage() {
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
throw new Error(errorData.error || "Error al cancelar membresia"); throw new Error(errorData.error || "Error cancelling membership");
} }
await Promise.all([fetchMemberships(), fetchPlans()]); await Promise.all([fetchMemberships(), fetchPlans()]);
} catch (err) { } catch (err) {
console.error("Error cancelling membership:", err); console.error("Error cancelling membership:", err);
setError(err instanceof Error ? err.message : "Error desconocido"); setError(err instanceof Error ? err.message : "Unknown error");
} }
}; };
@@ -336,9 +336,9 @@ export default function MembershipsPage() {
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-primary-800">Membresias</h1> <h1 className="text-2xl font-bold text-primary-800">Memberships</h1>
<p className="mt-1 text-primary-600"> <p className="mt-1 text-primary-600">
Gestiona planes y membresias de tus clientes Manage plans and memberships for your players
</p> </p>
</div> </div>
</div> </div>
@@ -374,7 +374,7 @@ export default function MembershipsPage() {
</svg> </svg>
</div> </div>
<div> <div>
<p className="text-sm text-primary-500">Membresias Activas</p> <p className="text-sm text-primary-500">Active Memberships</p>
<p className="text-2xl font-bold text-primary-800">{stats.totalActive}</p> <p className="text-2xl font-bold text-primary-800">{stats.totalActive}</p>
</div> </div>
</div> </div>
@@ -396,7 +396,7 @@ export default function MembershipsPage() {
</svg> </svg>
</div> </div>
<div> <div>
<p className="text-sm text-primary-500">Por Expirar</p> <p className="text-sm text-primary-500">Expiring Soon</p>
<p className="text-2xl font-bold text-primary-800">{stats.expiringSoon}</p> <p className="text-2xl font-bold text-primary-800">{stats.expiringSoon}</p>
</div> </div>
</div> </div>
@@ -412,7 +412,7 @@ export default function MembershipsPage() {
</svg> </svg>
</div> </div>
<div> <div>
<p className="text-sm text-primary-500">Planes Activos</p> <p className="text-sm text-primary-500">Active Plans</p>
<p className="text-2xl font-bold text-primary-800">{activePlans.length}</p> <p className="text-2xl font-bold text-primary-800">{activePlans.length}</p>
</div> </div>
</div> </div>
@@ -428,7 +428,7 @@ export default function MembershipsPage() {
</svg> </svg>
</div> </div>
<div> <div>
<p className="text-sm text-primary-500">Total Suscriptores</p> <p className="text-sm text-primary-500">Total Subscribers</p>
<p className="text-2xl font-bold text-primary-800"> <p className="text-2xl font-bold text-primary-800">
{plans.reduce((sum, p) => sum + p.subscriberCount, 0)} {plans.reduce((sum, p) => sum + p.subscriberCount, 0)}
</p> </p>
@@ -441,12 +441,12 @@ export default function MembershipsPage() {
{/* Plans Section */} {/* Plans Section */}
<section> <section>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-primary-800">Planes de Membresia</h2> <h2 className="text-xl font-semibold text-primary-800">Membership Plans</h2>
<Button onClick={() => setShowPlanForm(true)}> <Button onClick={() => setShowPlanForm(true)}>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg> </svg>
Nuevo Plan New Plan
</Button> </Button>
</div> </div>
@@ -454,7 +454,7 @@ export default function MembershipsPage() {
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div>
<p className="text-primary-500">Cargando planes...</p> <p className="text-primary-500">Loading plans...</p>
</div> </div>
</div> </div>
) : plans.length === 0 ? ( ) : plans.length === 0 ? (
@@ -468,10 +468,10 @@ export default function MembershipsPage() {
> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg> </svg>
<p className="font-medium text-primary-600">No hay planes</p> <p className="font-medium text-primary-600">No plans</p>
<p className="text-sm text-primary-500 mt-1">Crea tu primer plan de membresia</p> <p className="text-sm text-primary-500 mt-1">Create your first membership plan</p>
<Button className="mt-4" onClick={() => setShowPlanForm(true)}> <Button className="mt-4" onClick={() => setShowPlanForm(true)}>
Crear Plan Create Plan
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -496,12 +496,12 @@ export default function MembershipsPage() {
{/* Memberships Section */} {/* Memberships Section */}
<section> <section>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<h2 className="text-xl font-semibold text-primary-800">Membresias</h2> <h2 className="text-xl font-semibold text-primary-800">Memberships</h2>
<Button variant="accent" onClick={() => setShowAssignDialog(true)}> <Button variant="accent" onClick={() => setShowAssignDialog(true)}>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg> </svg>
Asignar Membresia Assign Membership
</Button> </Button>
</div> </div>
@@ -513,7 +513,7 @@ export default function MembershipsPage() {
<div className="flex-1"> <div className="flex-1">
<Input <Input
type="text" type="text"
placeholder="Buscar por nombre de cliente..." placeholder="Search by player name..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full" className="w-full"
@@ -527,7 +527,7 @@ export default function MembershipsPage() {
onChange={(e) => setPlanFilter(e.target.value)} onChange={(e) => setPlanFilter(e.target.value)}
className="flex h-10 w-full rounded-md border border-primary-200 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2" className="flex h-10 w-full rounded-md border border-primary-200 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
> >
<option value="">Todos los planes</option> <option value="">All plans</option>
{activePlans.map((plan) => ( {activePlans.map((plan) => (
<option key={plan.id} value={plan.id}> <option key={plan.id} value={plan.id}>
{plan.name} {plan.name}

View File

@@ -0,0 +1,427 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
BarChart3,
TrendingUp,
TrendingDown,
Calendar,
DollarSign,
Users,
Clock,
Download,
Filter,
} from "lucide-react";
interface ReportStats {
totalRevenue: number;
totalBookings: number;
totalClients: number;
avgOccupancy: number;
revenueChange: number;
bookingsChange: number;
clientsChange: number;
occupancyChange: number;
}
interface DailyRevenue {
date: string;
bookings: number;
sales: number;
total: number;
}
interface TopProduct {
name: string;
quantity: number;
revenue: number;
}
interface CourtStats {
name: string;
site: string;
bookings: number;
revenue: number;
occupancy: number;
}
export default function ReportsPage() {
const [dateRange, setDateRange] = useState("month");
const [selectedSite, setSelectedSite] = useState("all");
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<ReportStats>({
totalRevenue: 0,
totalBookings: 0,
totalClients: 0,
avgOccupancy: 0,
revenueChange: 0,
bookingsChange: 0,
clientsChange: 0,
occupancyChange: 0,
});
const [dailyRevenue, setDailyRevenue] = useState<DailyRevenue[]>([]);
const [topProducts, setTopProducts] = useState<TopProduct[]>([]);
const [courtStats, setCourtStats] = useState<CourtStats[]>([]);
useEffect(() => {
fetchReportData();
}, [dateRange, selectedSite]);
const fetchReportData = async () => {
setLoading(true);
// Simulated data - in production, this would come from API
await new Promise((resolve) => setTimeout(resolve, 500));
setStats({
totalRevenue: 125840,
totalBookings: 342,
totalClients: 156,
avgOccupancy: 68,
revenueChange: 12.5,
bookingsChange: 8.3,
clientsChange: 15.2,
occupancyChange: -2.1,
});
setDailyRevenue([
{ date: "Mon", bookings: 4200, sales: 1800, total: 6000 },
{ date: "Tue", bookings: 3800, sales: 1200, total: 5000 },
{ date: "Wed", bookings: 4500, sales: 2100, total: 6600 },
{ date: "Thu", bookings: 5200, sales: 1900, total: 7100 },
{ date: "Fri", bookings: 6800, sales: 3200, total: 10000 },
{ date: "Sat", bookings: 8500, sales: 4100, total: 12600 },
{ date: "Sun", bookings: 7200, sales: 3500, total: 10700 },
]);
setTopProducts([
{ name: "Water", quantity: 245, revenue: 4900 },
{ name: "Gatorade", quantity: 180, revenue: 6300 },
{ name: "Beer", quantity: 156, revenue: 7020 },
{ name: "Pickleballs", quantity: 42, revenue: 7560 },
{ name: "Paddle Rental", quantity: 38, revenue: 3800 },
]);
setCourtStats([
{ name: "Court 1", site: "North Site", bookings: 68, revenue: 20400, occupancy: 72 },
{ name: "Court 2", site: "North Site", bookings: 54, revenue: 16200, occupancy: 58 },
{ name: "Court 1", site: "Central Site", bookings: 72, revenue: 21600, occupancy: 76 },
{ name: "Court 2", site: "Central Site", bookings: 61, revenue: 18300, occupancy: 65 },
{ name: "Court 1", site: "South Site", bookings: 48, revenue: 14400, occupancy: 51 },
{ name: "Court 2", site: "South Site", bookings: 39, revenue: 11700, occupancy: 42 },
]);
setLoading(false);
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
}).format(amount);
};
const maxRevenue = Math.max(...dailyRevenue.map((d) => d.total));
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-primary-800">Reports</h1>
<p className="text-primary-600">Business analysis and statistics</p>
</div>
<div className="flex gap-3">
<select
value={dateRange}
onChange={(e) => setDateRange(e.target.value)}
className="rounded-lg border border-primary-200 bg-white px-3 py-2 text-sm focus:border-primary focus:outline-none"
>
<option value="week">Last week</option>
<option value="month">Last month</option>
<option value="quarter">Last quarter</option>
<option value="year">Last year</option>
</select>
<Button variant="outline" className="gap-2">
<Download className="h-4 w-4" />
Export
</Button>
</div>
</div>
{/* KPI Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Total Revenue"
value={formatCurrency(stats.totalRevenue)}
change={stats.revenueChange}
icon={DollarSign}
loading={loading}
/>
<StatCard
title="Bookings"
value={stats.totalBookings.toString()}
change={stats.bookingsChange}
icon={Calendar}
loading={loading}
/>
<StatCard
title="Active Players"
value={stats.totalClients.toString()}
change={stats.clientsChange}
icon={Users}
loading={loading}
/>
<StatCard
title="Average Occupancy"
value={`${stats.avgOccupancy}%`}
change={stats.occupancyChange}
icon={Clock}
loading={loading}
/>
</div>
{/* Charts Row */}
<div className="grid gap-6 lg:grid-cols-2">
{/* Revenue Chart */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-primary" />
Revenue by Day
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="h-64 animate-pulse bg-primary-50 rounded" />
) : (
<div className="space-y-4">
{dailyRevenue.map((day) => (
<div key={day.date} className="flex items-center gap-4">
<span className="w-10 text-sm text-primary-600">{day.date}</span>
<div className="flex-1 flex items-center gap-2">
<div className="flex-1 h-8 bg-primary-50 rounded-lg overflow-hidden flex">
<div
className="h-full bg-primary transition-all"
style={{ width: `${(day.bookings / maxRevenue) * 100}%` }}
/>
<div
className="h-full bg-accent transition-all"
style={{ width: `${(day.sales / maxRevenue) * 100}%` }}
/>
</div>
<span className="w-20 text-right text-sm font-medium text-primary-800">
{formatCurrency(day.total)}
</span>
</div>
</div>
))}
<div className="flex items-center gap-4 pt-2 border-t">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-primary" />
<span className="text-xs text-primary-600">Bookings</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-accent" />
<span className="text-xs text-primary-600">Sales</span>
</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* Top Products */}
<Card>
<CardHeader>
<CardTitle>Top Selling Products</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="animate-pulse flex items-center gap-4">
<div className="h-4 bg-primary-100 rounded w-1/4" />
<div className="flex-1 h-4 bg-primary-100 rounded" />
<div className="h-4 bg-primary-100 rounded w-16" />
</div>
))}
</div>
) : (
<div className="space-y-4">
{topProducts.map((product, index) => (
<div key={product.name} className="flex items-center gap-4">
<span className="w-6 h-6 rounded-full bg-primary-100 text-primary-700 text-xs font-medium flex items-center justify-center">
{index + 1}
</span>
<div className="flex-1">
<p className="font-medium text-primary-800">{product.name}</p>
<p className="text-xs text-primary-500">{product.quantity} units</p>
</div>
<span className="font-semibold text-primary-800">
{formatCurrency(product.revenue)}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
{/* Courts Performance */}
<Card>
<CardHeader>
<CardTitle>Court Performance</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="animate-pulse space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-12 bg-primary-50 rounded" />
))}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-primary-100">
<th className="text-left py-3 px-4 text-sm font-medium text-primary-700">Court</th>
<th className="text-left py-3 px-4 text-sm font-medium text-primary-700">Site</th>
<th className="text-center py-3 px-4 text-sm font-medium text-primary-700">Bookings</th>
<th className="text-right py-3 px-4 text-sm font-medium text-primary-700">Revenue</th>
<th className="text-center py-3 px-4 text-sm font-medium text-primary-700">Occupancy</th>
</tr>
</thead>
<tbody>
{courtStats.map((court, index) => (
<tr key={index} className="border-b border-primary-50 hover:bg-primary-50/50">
<td className="py-3 px-4 font-medium text-primary-800">{court.name}</td>
<td className="py-3 px-4 text-primary-600">{court.site}</td>
<td className="py-3 px-4 text-center text-primary-600">{court.bookings}</td>
<td className="py-3 px-4 text-right font-medium text-primary-800">
{formatCurrency(court.revenue)}
</td>
<td className="py-3 px-4">
<div className="flex items-center justify-center gap-2">
<div className="w-24 h-2 bg-primary-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
court.occupancy >= 70
? "bg-accent"
: court.occupancy >= 50
? "bg-amber-500"
: "bg-red-400"
}`}
style={{ width: `${court.occupancy}%` }}
/>
</div>
<span className="text-sm text-primary-600 w-10">{court.occupancy}%</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Summary Cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Best Day</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-primary-800">Saturday</p>
<p className="text-sm text-primary-600">
{formatCurrency(12600)} in average revenue
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Peak Hour</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-primary-800">18:00 - 20:00</p>
<p className="text-sm text-primary-600">
85% occupancy during this time slot
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Average Ticket</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-primary-800">{formatCurrency(368)}</p>
<p className="text-sm text-primary-600">
Per visit (booking + consumption)
</p>
</CardContent>
</Card>
</div>
</div>
);
}
// Stat Card Component
function StatCard({
title,
value,
change,
icon: Icon,
loading,
}: {
title: string;
value: string;
change: number;
icon: React.ComponentType<{ className?: string }>;
loading: boolean;
}) {
const isPositive = change >= 0;
if (loading) {
return (
<Card className="animate-pulse">
<CardContent className="p-6">
<div className="h-4 bg-primary-100 rounded w-1/2 mb-2" />
<div className="h-8 bg-primary-100 rounded w-3/4 mb-2" />
<div className="h-4 bg-primary-100 rounded w-1/3" />
</CardContent>
</Card>
);
}
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-primary-600">{title}</span>
<Icon className="h-5 w-5 text-primary-400" />
</div>
<p className="text-2xl font-bold text-primary-800">{value}</p>
<div className="flex items-center gap-1 mt-1">
{isPositive ? (
<TrendingUp className="h-4 w-4 text-accent" />
) : (
<TrendingDown className="h-4 w-4 text-red-500" />
)}
<span className={`text-sm ${isPositive ? "text-accent" : "text-red-500"}`}>
{isPositive ? "+" : ""}
{change}%
</span>
<span className="text-xs text-primary-500">vs previous period</span>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,840 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Building2,
MapPin,
Users,
Clock,
DollarSign,
Save,
Plus,
Pencil,
Trash2,
X,
} from "lucide-react";
interface Site {
id: string;
name: string;
address: string;
phone: string | null;
openTime: string;
closeTime: string;
isActive: boolean;
}
interface Court {
id: string;
name: string;
type: string;
hourlyRate: number;
peakHourlyRate: number | null;
status: string;
siteId: string;
site?: { name: string };
isOpenPlay?: boolean;
}
interface User {
id: string;
email: string;
firstName: string;
lastName: string;
role: string;
isActive: boolean;
site?: { name: string } | null;
}
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState("organization");
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
// Organization state
const [orgName, setOrgName] = useState("SmashPoint Demo");
const [orgEmail, setOrgEmail] = useState("info@smashpoint.com");
const [orgPhone, setOrgPhone] = useState("+52 555 123 4567");
const [currency, setCurrency] = useState("MXN");
const [timezone, setTimezone] = useState("America/Mexico_City");
// Sites state
const [sites, setSites] = useState<Site[]>([]);
const [loadingSites, setLoadingSites] = useState(true);
const [editingSite, setEditingSite] = useState<Site | null>(null);
const [showSiteForm, setShowSiteForm] = useState(false);
// Courts state
const [courts, setCourts] = useState<Court[]>([]);
const [loadingCourts, setLoadingCourts] = useState(true);
const [editingCourt, setEditingCourt] = useState<Court | null>(null);
const [showCourtForm, setShowCourtForm] = useState(false);
// Users state
const [users, setUsers] = useState<User[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true);
useEffect(() => {
fetchSites();
fetchCourts();
fetchUsers();
}, []);
const fetchSites = async () => {
try {
const res = await fetch("/api/sites");
if (res.ok) {
const data = await res.json();
setSites(data.data || []);
}
} catch (error) {
console.error("Error fetching sites:", error);
} finally {
setLoadingSites(false);
}
};
const fetchCourts = async () => {
try {
const res = await fetch("/api/courts");
if (res.ok) {
const data = await res.json();
// Courts API returns array directly, map pricePerHour to hourlyRate for frontend
const courtsArray = Array.isArray(data) ? data : data.data || [];
setCourts(courtsArray.map((c: Record<string, unknown>) => ({
...c,
hourlyRate: c.pricePerHour ?? c.hourlyRate,
})));
}
} catch (error) {
console.error("Error fetching courts:", error);
} finally {
setLoadingCourts(false);
}
};
const fetchUsers = async () => {
try {
const res = await fetch("/api/users");
if (res.ok) {
const data = await res.json();
setUsers(data.data || []);
}
} catch (error) {
console.error("Error fetching users:", error);
} finally {
setLoadingUsers(false);
}
};
const handleSaveOrganization = async () => {
setLoading(true);
// Simulate save
await new Promise((resolve) => setTimeout(resolve, 500));
setMessage({ type: "success", text: "Settings saved successfully" });
setLoading(false);
setTimeout(() => setMessage(null), 3000);
};
const handleSaveSite = async (site: Partial<Site>) => {
setLoading(true);
try {
const method = editingSite ? "PUT" : "POST";
const url = editingSite ? `/api/sites/${editingSite.id}` : "/api/sites";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(site),
});
if (res.ok) {
setMessage({ type: "success", text: editingSite ? "Site updated" : "Site created" });
fetchSites();
setShowSiteForm(false);
setEditingSite(null);
} else {
setMessage({ type: "error", text: "Error saving site" });
}
} catch (error) {
setMessage({ type: "error", text: "Connection error" });
} finally {
setLoading(false);
setTimeout(() => setMessage(null), 3000);
}
};
const handleSaveCourt = async (court: Partial<Court>) => {
setLoading(true);
try {
const method = editingCourt ? "PUT" : "POST";
const url = editingCourt ? `/api/courts/${editingCourt.id}` : "/api/courts";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(court),
});
if (res.ok) {
setMessage({ type: "success", text: editingCourt ? "Court updated" : "Court created" });
fetchCourts();
setShowCourtForm(false);
setEditingCourt(null);
} else {
setMessage({ type: "error", text: "Error saving court" });
}
} catch (error) {
setMessage({ type: "error", text: "Connection error" });
} finally {
setLoading(false);
setTimeout(() => setMessage(null), 3000);
}
};
const handleDeleteCourt = async (courtId: string) => {
if (!confirm("Are you sure you want to delete this court?")) return;
try {
const res = await fetch(`/api/courts/${courtId}`, { method: "DELETE" });
if (res.ok) {
setMessage({ type: "success", text: "Court deleted" });
fetchCourts();
} else {
setMessage({ type: "error", text: "Error deleting court" });
}
} catch (error) {
setMessage({ type: "error", text: "Connection error" });
}
setTimeout(() => setMessage(null), 3000);
};
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-primary-800">Settings</h1>
<p className="text-primary-600">Manage system settings</p>
</div>
{/* Message */}
{message && (
<div
className={`p-4 rounded-lg ${
message.type === "success"
? "bg-accent/10 text-accent-700 border border-accent/20"
: "bg-red-50 text-red-700 border border-red-200"
}`}
>
{message.text}
</div>
)}
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-4 lg:w-auto lg:inline-grid">
<TabsTrigger value="organization" className="gap-2">
<Building2 className="h-4 w-4" />
<span className="hidden sm:inline">Organization</span>
</TabsTrigger>
<TabsTrigger value="sites" className="gap-2">
<MapPin className="h-4 w-4" />
<span className="hidden sm:inline">Sites</span>
</TabsTrigger>
<TabsTrigger value="courts" className="gap-2">
<Clock className="h-4 w-4" />
<span className="hidden sm:inline">Courts</span>
</TabsTrigger>
<TabsTrigger value="users" className="gap-2">
<Users className="h-4 w-4" />
<span className="hidden sm:inline">Users</span>
</TabsTrigger>
</TabsList>
{/* Organization Tab */}
<TabsContent value="organization" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Organization Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">
Organization name
</label>
<Input
value={orgName}
onChange={(e) => setOrgName(e.target.value)}
placeholder="Name"
/>
</div>
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">
Contact email
</label>
<Input
type="email"
value={orgEmail}
onChange={(e) => setOrgEmail(e.target.value)}
placeholder="email@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">
Phone
</label>
<Input
value={orgPhone}
onChange={(e) => setOrgPhone(e.target.value)}
placeholder="+52 555 123 4567"
/>
</div>
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">
Currency
</label>
<select
value={currency}
onChange={(e) => setCurrency(e.target.value)}
className="w-full rounded-lg border border-primary-200 bg-white px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="MXN">MXN - Mexican Peso</option>
<option value="USD">USD - US Dollar</option>
<option value="EUR">EUR - Euro</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">
Timezone
</label>
<select
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
className="w-full rounded-lg border border-primary-200 bg-white px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="America/Mexico_City">Mexico City</option>
<option value="America/Monterrey">Monterrey</option>
<option value="America/Tijuana">Tijuana</option>
<option value="America/Cancun">Cancún</option>
</select>
</div>
</div>
<div className="pt-4">
<Button onClick={handleSaveOrganization} disabled={loading}>
<Save className="h-4 w-4 mr-2" />
{loading ? "Saving..." : "Save changes"}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Booking Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">
Default duration (minutes)
</label>
<Input type="number" defaultValue={60} min={30} step={30} />
</div>
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">
Minimum notice (hours)
</label>
<Input type="number" defaultValue={2} min={0} />
</div>
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">
Maximum advance (days)
</label>
<Input type="number" defaultValue={14} min={1} />
</div>
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">
Cancellation window (hours)
</label>
<Input type="number" defaultValue={24} min={0} />
</div>
</div>
<div className="pt-4">
<Button onClick={handleSaveOrganization} disabled={loading}>
<Save className="h-4 w-4 mr-2" />
{loading ? "Saving..." : "Save changes"}
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Sites Tab */}
<TabsContent value="sites" className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold text-primary-800">Sites</h2>
<Button onClick={() => { setEditingSite(null); setShowSiteForm(true); }}>
<Plus className="h-4 w-4 mr-2" />
New Site
</Button>
</div>
{loadingSites ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="p-6">
<div className="h-6 bg-primary-100 rounded w-3/4 mb-2" />
<div className="h-4 bg-primary-100 rounded w-full mb-2" />
<div className="h-4 bg-primary-100 rounded w-1/2" />
</CardContent>
</Card>
))}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{sites.map((site) => (
<Card key={site.id} className="hover:shadow-md transition-shadow">
<CardContent className="p-6">
<div className="flex justify-between items-start mb-3">
<h3 className="font-semibold text-primary-800">{site.name}</h3>
<div className="flex gap-1">
<button
onClick={() => { setEditingSite(site); setShowSiteForm(true); }}
className="p-1 text-primary-500 hover:text-primary-700"
>
<Pencil className="h-4 w-4" />
</button>
</div>
</div>
<p className="text-sm text-primary-600 mb-2">{site.address}</p>
<p className="text-sm text-primary-500">
{site.openTime} - {site.closeTime}
</p>
{site.phone && (
<p className="text-sm text-primary-500 mt-1">{site.phone}</p>
)}
<div className="mt-3">
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
site.isActive
? "bg-accent/10 text-accent-700"
: "bg-gray-100 text-gray-600"
}`}
>
{site.isActive ? "Active" : "Inactive"}
</span>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Site Form Modal */}
{showSiteForm && (
<SiteFormModal
site={editingSite}
onSave={handleSaveSite}
onClose={() => { setShowSiteForm(false); setEditingSite(null); }}
loading={loading}
/>
)}
</TabsContent>
{/* Courts Tab */}
<TabsContent value="courts" className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold text-primary-800">Courts</h2>
<Button onClick={() => { setEditingCourt(null); setShowCourtForm(true); }}>
<Plus className="h-4 w-4 mr-2" />
New Court
</Button>
</div>
{loadingCourts ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="animate-pulse bg-white p-4 rounded-lg border">
<div className="h-5 bg-primary-100 rounded w-1/4 mb-2" />
<div className="h-4 bg-primary-100 rounded w-1/2" />
</div>
))}
</div>
) : (
<Card>
<CardContent className="p-0">
<table className="w-full">
<thead className="bg-primary-50 border-b border-primary-100">
<tr>
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Court</th>
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Site</th>
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Type</th>
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Price/hour</th>
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Status</th>
<th className="text-right px-4 py-3 text-sm font-medium text-primary-700">Actions</th>
</tr>
</thead>
<tbody>
{courts.map((court) => (
<tr key={court.id} className="border-b border-primary-50 hover:bg-primary-50/50">
<td className="px-4 py-3 font-medium text-primary-800">
{court.name}
{court.isOpenPlay && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700">
Open Play
</span>
)}
</td>
<td className="px-4 py-3 text-primary-600">{court.site?.name || "-"}</td>
<td className="px-4 py-3 text-primary-600 capitalize">{court.type}</td>
<td className="px-4 py-3 text-primary-600">${court.hourlyRate}</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
["active", "AVAILABLE"].includes(court.status)
? "bg-accent/10 text-accent-700"
: ["maintenance", "MAINTENANCE"].includes(court.status)
? "bg-amber-100 text-amber-700"
: "bg-gray-100 text-gray-600"
}`}
>
{["active", "AVAILABLE"].includes(court.status) ? "Active" : ["maintenance", "MAINTENANCE"].includes(court.status) ? "Maintenance" : "Inactive"}
</span>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => { setEditingCourt(court); setShowCourtForm(true); }}
className="p-1 text-primary-500 hover:text-primary-700 mr-1"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => handleDeleteCourt(court.id)}
className="p-1 text-red-500 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)}
{/* Court Form Modal */}
{showCourtForm && (
<CourtFormModal
court={editingCourt}
sites={sites}
onSave={handleSaveCourt}
onClose={() => { setShowCourtForm(false); setEditingCourt(null); }}
loading={loading}
/>
)}
</TabsContent>
{/* Users Tab */}
<TabsContent value="users" className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold text-primary-800">Users</h2>
<Button>
<Plus className="h-4 w-4 mr-2" />
New User
</Button>
</div>
{loadingUsers ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="animate-pulse bg-white p-4 rounded-lg border">
<div className="h-5 bg-primary-100 rounded w-1/4 mb-2" />
<div className="h-4 bg-primary-100 rounded w-1/2" />
</div>
))}
</div>
) : (
<Card>
<CardContent className="p-0">
<table className="w-full">
<thead className="bg-primary-50 border-b border-primary-100">
<tr>
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">User</th>
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Email</th>
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Role</th>
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Site</th>
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Status</th>
<th className="text-right px-4 py-3 text-sm font-medium text-primary-700">Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b border-primary-50 hover:bg-primary-50/50">
<td className="px-4 py-3 font-medium text-primary-800">
{user.firstName} {user.lastName}
</td>
<td className="px-4 py-3 text-primary-600">{user.email}</td>
<td className="px-4 py-3">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-primary-100 text-primary-700">
{user.role === "super_admin" ? "Super Admin" :
user.role === "site_admin" ? "Site Admin" :
user.role === "staff" ? "Staff" : user.role}
</span>
</td>
<td className="px-4 py-3 text-primary-600">{user.site?.name || "All"}</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
user.isActive
? "bg-accent/10 text-accent-700"
: "bg-gray-100 text-gray-600"
}`}
>
{user.isActive ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3 text-right">
<button className="p-1 text-primary-500 hover:text-primary-700">
<Pencil className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
</div>
);
}
// Site Form Modal Component
function SiteFormModal({
site,
onSave,
onClose,
loading,
}: {
site: Site | null;
onSave: (site: Partial<Site>) => void;
onClose: () => void;
loading: boolean;
}) {
const [name, setName] = useState(site?.name || "");
const [address, setAddress] = useState(site?.address || "");
const [phone, setPhone] = useState(site?.phone || "");
const [openTime, setOpenTime] = useState(site?.openTime || "08:00");
const [closeTime, setCloseTime] = useState(site?.closeTime || "22:00");
const [isActive, setIsActive] = useState(site?.isActive ?? true);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({ name, address, phone, openTime, closeTime, isActive });
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md mx-4">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="text-lg font-semibold text-primary-800">
{site ? "Edit Site" : "New Site"}
</h3>
<button onClick={onClose} className="text-primary-500 hover:text-primary-700">
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">Name</label>
<Input value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">Address</label>
<Input value={address} onChange={(e) => setAddress(e.target.value)} required />
</div>
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">Phone</label>
<Input value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">Opening time</label>
<Input type="time" value={openTime} onChange={(e) => setOpenTime(e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">Closing time</label>
<Input type="time" value={closeTime} onChange={(e) => setCloseTime(e.target.value)} />
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isActive"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="rounded border-primary-300"
/>
<label htmlFor="isActive" className="text-sm text-primary-700">Site active</label>
</div>
<div className="flex gap-3 pt-4">
<Button type="button" variant="outline" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button type="submit" disabled={loading} className="flex-1">
{loading ? "Saving..." : "Save"}
</Button>
</div>
</form>
</div>
</div>
);
}
// Court Form Modal Component
function CourtFormModal({
court,
sites,
onSave,
onClose,
loading,
}: {
court: Court | null;
sites: Site[];
onSave: (court: Partial<Court>) => void;
onClose: () => void;
loading: boolean;
}) {
const [name, setName] = useState(court?.name || "");
const [siteId, setSiteId] = useState(court?.siteId || sites[0]?.id || "");
const [type, setType] = useState(court?.type || "indoor");
const [hourlyRate, setHourlyRate] = useState(court?.hourlyRate?.toString() || "300");
const [peakHourlyRate, setPeakHourlyRate] = useState(court?.peakHourlyRate?.toString() || "");
const [status, setStatus] = useState(court?.status || "active");
const [isOpenPlay, setIsOpenPlay] = useState(court?.isOpenPlay ?? false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
name,
siteId,
type,
hourlyRate: parseFloat(hourlyRate),
pricePerHour: parseFloat(hourlyRate),
peakHourlyRate: peakHourlyRate ? parseFloat(peakHourlyRate) : null,
status,
isOpenPlay,
} as Partial<Court> & { pricePerHour?: number });
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md mx-4">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="text-lg font-semibold text-primary-800">
{court ? "Edit Court" : "New Court"}
</h3>
<button onClick={onClose} className="text-primary-500 hover:text-primary-700">
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">Name</label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Court 1" required />
</div>
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">Site</label>
<select
value={siteId}
onChange={(e) => setSiteId(e.target.value)}
className="w-full rounded-lg border border-primary-200 bg-white px-3 py-2 text-sm"
required
>
{sites.map((site) => (
<option key={site.id} value={site.id}>{site.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">Type</label>
<select
value={type}
onChange={(e) => setType(e.target.value)}
className="w-full rounded-lg border border-primary-200 bg-white px-3 py-2 text-sm"
>
<option value="indoor">Indoor</option>
<option value="outdoor">Outdoor</option>
<option value="covered">Covered</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">Price/hour</label>
<Input
type="number"
value={hourlyRate}
onChange={(e) => setHourlyRate(e.target.value)}
min="0"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">Peak hour price</label>
<Input
type="number"
value={peakHourlyRate}
onChange={(e) => setPeakHourlyRate(e.target.value)}
min="0"
placeholder="Optional"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">Status</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="w-full rounded-lg border border-primary-200 bg-white px-3 py-2 text-sm"
>
<option value="active">Active</option>
<option value="maintenance">Maintenance</option>
<option value="inactive">Inactive</option>
</select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isOpenPlay"
checked={isOpenPlay}
onChange={(e) => setIsOpenPlay(e.target.checked)}
className="rounded border-primary-300"
/>
<label htmlFor="isOpenPlay" className="text-sm text-primary-700">
Open Play Court (free, for group scheduling)
</label>
</div>
<div className="flex gap-3 pt-4">
<Button type="button" variant="outline" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button type="submit" disabled={loading} className="flex-1">
{loading ? "Saving..." : "Save"}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -11,33 +11,25 @@ function LoginContent() {
<div className="max-w-md text-center"> <div className="max-w-md text-center">
{/* Logo */} {/* Logo */}
<div className="mb-8 flex justify-center"> <div className="mb-8 flex justify-center">
<div className="w-24 h-24 bg-white/10 backdrop-blur-sm rounded-2xl flex items-center justify-center border border-white/20"> <div className="w-24 h-24 bg-primary/20 backdrop-blur-sm rounded-2xl flex items-center justify-center border border-primary-300/30">
<svg <svg viewBox="0 0 100 100" className="w-16 h-16 text-white" fill="none">
viewBox="0 0 100 100" {/* Lightning bolt / smash icon */}
className="w-16 h-16 text-white" <path d="M55 10L20 55h25l-10 35L70 45H45l10-35z" fill="currentColor" />
fill="currentColor" {/* Impact sparks */}
> <circle cx="78" cy="18" r="4" fill="currentColor" opacity="0.8" />
{/* Padel racket stylized icon */} <circle cx="85" cy="28" r="2.5" fill="currentColor" opacity="0.6" />
<ellipse cx="50" cy="40" rx="28" ry="35" fill="none" stroke="currentColor" strokeWidth="4" /> <circle cx="72" cy="10" r="2" fill="currentColor" opacity="0.5" />
<line x1="50" y1="75" x2="50" y2="95" stroke="currentColor" strokeWidth="6" strokeLinecap="round" />
<circle cx="35" cy="30" r="3" />
<circle cx="50" cy="25" r="3" />
<circle cx="65" cy="30" r="3" />
<circle cx="35" cy="45" r="3" />
<circle cx="50" cy="40" r="3" />
<circle cx="65" cy="45" r="3" />
<circle cx="42" cy="55" r="3" />
<circle cx="58" cy="55" r="3" />
</svg> </svg>
</div> </div>
</div> </div>
{/* Title */} {/* Title */}
<h1 className="text-4xl font-bold mb-4">Padel Pro</h1> <h1 className="text-4xl font-bold mb-4">Cabo Pickleball Club</h1>
<p className="text-sm text-primary-300 mb-2">Powered by SmashPoint</p>
{/* Tagline */} {/* Tagline */}
<p className="text-xl text-primary-200 mb-8"> <p className="text-xl text-primary-200 mb-8">
Sistema de Gestion para Clubes de Padel Court Management System
</p> </p>
{/* Features */} {/* Features */}
@@ -58,8 +50,8 @@ function LoginContent() {
</svg> </svg>
</div> </div>
<div> <div>
<p className="font-medium">Gestion de Reservas</p> <p className="font-medium">Court Bookings</p>
<p className="text-sm text-primary-300">Administra tus canchas y horarios</p> <p className="text-sm text-primary-300">Manage your courts and schedules</p>
</div> </div>
</div> </div>
@@ -75,8 +67,8 @@ function LoginContent() {
</svg> </svg>
</div> </div>
<div> <div>
<p className="font-medium">Control de Clientes</p> <p className="font-medium">Player Management</p>
<p className="text-sm text-primary-300">Membresias y perfiles completos</p> <p className="text-sm text-primary-300">Memberships and player profiles</p>
</div> </div>
</div> </div>
@@ -92,8 +84,8 @@ function LoginContent() {
</svg> </svg>
</div> </div>
<div> <div>
<p className="font-medium">Reportes y Estadisticas</p> <p className="font-medium">Reports & Analytics</p>
<p className="text-sm text-primary-300">Analiza el rendimiento de tu club</p> <p className="text-sm text-primary-300">Analyze your club's performance</p>
</div> </div>
</div> </div>
</div> </div>
@@ -104,33 +96,23 @@ function LoginContent() {
<div className="w-full lg:w-1/2 flex flex-col justify-center items-center p-6 lg:p-12"> <div className="w-full lg:w-1/2 flex flex-col justify-center items-center p-6 lg:p-12">
{/* Mobile Logo */} {/* Mobile Logo */}
<div className="lg:hidden mb-8 text-center text-white"> <div className="lg:hidden mb-8 text-center text-white">
<div className="w-16 h-16 mx-auto mb-4 bg-white/10 backdrop-blur-sm rounded-xl flex items-center justify-center border border-white/20"> <div className="w-16 h-16 mx-auto mb-4 bg-primary/20 backdrop-blur-sm rounded-xl flex items-center justify-center border border-primary-300/30">
<svg <svg viewBox="0 0 100 100" className="w-10 h-10 text-white" fill="none">
viewBox="0 0 100 100" <path d="M55 10L20 55h25l-10 35L70 45H45l10-35z" fill="currentColor" />
className="w-10 h-10 text-white" <circle cx="78" cy="18" r="4" fill="currentColor" opacity="0.8" />
fill="currentColor" <circle cx="85" cy="28" r="2.5" fill="currentColor" opacity="0.6" />
> <circle cx="72" cy="10" r="2" fill="currentColor" opacity="0.5" />
<ellipse cx="50" cy="40" rx="28" ry="35" fill="none" stroke="currentColor" strokeWidth="4" />
<line x1="50" y1="75" x2="50" y2="95" stroke="currentColor" strokeWidth="6" strokeLinecap="round" />
<circle cx="35" cy="30" r="3" />
<circle cx="50" cy="25" r="3" />
<circle cx="65" cy="30" r="3" />
<circle cx="35" cy="45" r="3" />
<circle cx="50" cy="40" r="3" />
<circle cx="65" cy="45" r="3" />
<circle cx="42" cy="55" r="3" />
<circle cx="58" cy="55" r="3" />
</svg> </svg>
</div> </div>
<h1 className="text-2xl font-bold">Padel Pro</h1> <h1 className="text-2xl font-bold">Cabo Pickleball Club</h1>
<p className="text-sm text-primary-200 mt-1">Sistema de Gestion para Clubes de Padel</p> <p className="text-sm text-primary-200 mt-1">Court Management System</p>
</div> </div>
<LoginForm /> <LoginForm />
{/* Footer */} {/* Footer */}
<p className="mt-8 text-center text-sm text-primary-300"> <p className="mt-8 text-center text-sm text-primary-300">
&copy; {new Date().getFullYear()} Padel Pro. Todos los derechos reservados. &copy; {new Date().getFullYear()} SmashPoint. All rights reserved.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -12,7 +12,7 @@ interface RouteContext {
// Validation schema for payment // Validation schema for payment
const paymentSchema = z.object({ const paymentSchema = z.object({
paymentType: z.enum(['CASH', 'CARD', 'TRANSFER', 'MEMBERSHIP', 'FREE']), paymentType: z.enum(['CASH', 'CARD', 'TRANSFER', 'MEMBERSHIP', 'FREE']),
amount: z.number().positive('El monto debe ser mayor a 0').optional(), amount: z.number().positive('Amount must be greater than 0').optional(),
reference: z.string().max(100).optional(), reference: z.string().max(100).optional(),
notes: z.string().max(500).optional(), notes: z.string().max(500).optional(),
cashRegisterId: z.string().uuid().optional(), cashRegisterId: z.string().uuid().optional(),
@@ -28,7 +28,7 @@ export async function POST(
if (!session?.user) { if (!session?.user) {
return NextResponse.json( return NextResponse.json(
{ error: 'No autorizado' }, { error: 'Unauthorized' },
{ status: 401 } { status: 401 }
); );
} }
@@ -51,7 +51,7 @@ export async function POST(
if (!existingBooking) { if (!existingBooking) {
return NextResponse.json( return NextResponse.json(
{ error: 'Reserva no encontrada' }, { error: 'Booking not found' },
{ status: 404 } { status: 404 }
); );
} }
@@ -59,7 +59,7 @@ export async function POST(
// If user is SITE_ADMIN, verify they have access to this site // If user is SITE_ADMIN, verify they have access to this site
if (session.user.role === 'SITE_ADMIN' && session.user.siteId !== existingBooking.siteId) { if (session.user.role === 'SITE_ADMIN' && session.user.siteId !== existingBooking.siteId) {
return NextResponse.json( return NextResponse.json(
{ error: 'No tiene permiso para procesar pagos en esta reserva' }, { error: 'You do not have permission to process payments for this booking' },
{ status: 403 } { status: 403 }
); );
} }
@@ -67,7 +67,7 @@ export async function POST(
// Check if booking is already cancelled // Check if booking is already cancelled
if (existingBooking.status === 'CANCELLED') { if (existingBooking.status === 'CANCELLED') {
return NextResponse.json( return NextResponse.json(
{ error: 'No se puede procesar el pago de una reserva cancelada' }, { error: 'Cannot process payment for a cancelled booking' },
{ status: 400 } { status: 400 }
); );
} }
@@ -81,7 +81,7 @@ export async function POST(
if (totalPaid >= totalPrice) { if (totalPaid >= totalPrice) {
return NextResponse.json( return NextResponse.json(
{ error: 'La reserva ya está completamente pagada' }, { error: 'The booking is already fully paid' },
{ status: 400 } { status: 400 }
); );
} }
@@ -93,7 +93,7 @@ export async function POST(
if (!validationResult.success) { if (!validationResult.success) {
return NextResponse.json( return NextResponse.json(
{ {
error: 'Datos de pago inválidos', error: 'Invalid payment data',
details: validationResult.error.flatten().fieldErrors, details: validationResult.error.flatten().fieldErrors,
}, },
{ status: 400 } { status: 400 }
@@ -108,7 +108,7 @@ export async function POST(
if (paymentAmount <= 0) { if (paymentAmount <= 0) {
return NextResponse.json( return NextResponse.json(
{ error: 'El monto del pago debe ser mayor a 0' }, { error: 'Payment amount must be greater than 0' },
{ status: 400 } { status: 400 }
); );
} }
@@ -125,7 +125,7 @@ export async function POST(
if (!cashRegister) { if (!cashRegister) {
return NextResponse.json( return NextResponse.json(
{ error: 'Caja registradora no encontrada o no está abierta' }, { error: 'Cash register not found or is not open' },
{ status: 400 } { status: 400 }
); );
} }
@@ -211,8 +211,8 @@ export async function POST(
return NextResponse.json({ return NextResponse.json({
message: result.isFullyPaid message: result.isFullyPaid
? 'Pago completado. La reserva ha sido confirmada.' ? 'Payment completed. The booking has been confirmed.'
: 'Pago parcial registrado exitosamente.', : 'Partial payment recorded successfully.',
booking: result.booking, booking: result.booking,
payment: result.payment, payment: result.payment,
remainingAmount: Math.max(0, totalPrice - (totalPaid + paymentAmount)), remainingAmount: Math.max(0, totalPrice - (totalPaid + paymentAmount)),
@@ -220,7 +220,7 @@ export async function POST(
} catch (error) { } catch (error) {
console.error('Error processing payment:', error); console.error('Error processing payment:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Error al procesar el pago' }, { error: 'Error processing payment' },
{ status: 500 } { status: 500 }
); );
} }

View File

@@ -26,7 +26,7 @@ export async function GET(
if (!session?.user) { if (!session?.user) {
return NextResponse.json( return NextResponse.json(
{ error: 'No autorizado' }, { error: 'Unauthorized' },
{ status: 401 } { status: 401 }
); );
} }
@@ -99,7 +99,7 @@ export async function GET(
if (!booking) { if (!booking) {
return NextResponse.json( return NextResponse.json(
{ error: 'Reserva no encontrada' }, { error: 'Booking not found' },
{ status: 404 } { status: 404 }
); );
} }
@@ -108,7 +108,7 @@ export async function GET(
} catch (error) { } catch (error) {
console.error('Error fetching booking:', error); console.error('Error fetching booking:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Error al obtener la reserva' }, { error: 'Error fetching booking' },
{ status: 500 } { status: 500 }
); );
} }
@@ -124,7 +124,7 @@ export async function PUT(
if (!session?.user) { if (!session?.user) {
return NextResponse.json( return NextResponse.json(
{ error: 'No autorizado' }, { error: 'Unauthorized' },
{ status: 401 } { status: 401 }
); );
} }
@@ -146,7 +146,7 @@ export async function PUT(
if (!existingBooking) { if (!existingBooking) {
return NextResponse.json( return NextResponse.json(
{ error: 'Reserva no encontrada' }, { error: 'Booking not found' },
{ status: 404 } { status: 404 }
); );
} }
@@ -154,7 +154,7 @@ export async function PUT(
// If user is SITE_ADMIN, verify they have access to this site // If user is SITE_ADMIN, verify they have access to this site
if (session.user.role === 'SITE_ADMIN' && session.user.siteId !== existingBooking.siteId) { if (session.user.role === 'SITE_ADMIN' && session.user.siteId !== existingBooking.siteId) {
return NextResponse.json( return NextResponse.json(
{ error: 'No tiene permiso para modificar esta reserva' }, { error: 'You do not have permission to modify this booking' },
{ status: 403 } { status: 403 }
); );
} }
@@ -166,7 +166,7 @@ export async function PUT(
if (!validationResult.success) { if (!validationResult.success) {
return NextResponse.json( return NextResponse.json(
{ {
error: 'Datos de actualización inválidos', error: 'Invalid update data',
details: validationResult.error.flatten().fieldErrors, details: validationResult.error.flatten().fieldErrors,
}, },
{ status: 400 } { status: 400 }
@@ -239,7 +239,7 @@ export async function PUT(
} catch (error) { } catch (error) {
console.error('Error updating booking:', error); console.error('Error updating booking:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Error al actualizar la reserva' }, { error: 'Error updating booking' },
{ status: 500 } { status: 500 }
); );
} }
@@ -255,7 +255,7 @@ export async function DELETE(
if (!session?.user) { if (!session?.user) {
return NextResponse.json( return NextResponse.json(
{ error: 'No autorizado' }, { error: 'Unauthorized' },
{ status: 401 } { status: 401 }
); );
} }
@@ -277,7 +277,7 @@ export async function DELETE(
if (!existingBooking) { if (!existingBooking) {
return NextResponse.json( return NextResponse.json(
{ error: 'Reserva no encontrada' }, { error: 'Booking not found' },
{ status: 404 } { status: 404 }
); );
} }
@@ -285,7 +285,7 @@ export async function DELETE(
// If user is SITE_ADMIN, verify they have access to this site // If user is SITE_ADMIN, verify they have access to this site
if (session.user.role === 'SITE_ADMIN' && session.user.siteId !== existingBooking.siteId) { if (session.user.role === 'SITE_ADMIN' && session.user.siteId !== existingBooking.siteId) {
return NextResponse.json( return NextResponse.json(
{ error: 'No tiene permiso para cancelar esta reserva' }, { error: 'You do not have permission to cancel this booking' },
{ status: 403 } { status: 403 }
); );
} }
@@ -294,7 +294,7 @@ export async function DELETE(
const hasPayments = existingBooking.payments.length > 0; const hasPayments = existingBooking.payments.length > 0;
// Parse optional cancel reason from query params or body // Parse optional cancel reason from query params or body
let cancelReason = 'Cancelada por el administrador'; let cancelReason = 'Cancelled by administrator';
try { try {
const body = await request.json(); const body = await request.json();
if (body.cancelReason) { if (body.cancelReason) {
@@ -316,9 +316,9 @@ export async function DELETE(
}); });
return NextResponse.json({ return NextResponse.json({
message: 'Reserva cancelada exitosamente', message: 'Booking cancelled successfully',
booking, booking,
note: 'La reserva tiene pagos asociados, por lo que fue cancelada en lugar de eliminada', note: 'The booking has associated payments, so it was cancelled instead of deleted',
}); });
} else { } else {
// If no payments, allow hard delete for pending bookings only // If no payments, allow hard delete for pending bookings only
@@ -328,7 +328,7 @@ export async function DELETE(
}); });
return NextResponse.json({ return NextResponse.json({
message: 'Reserva eliminada exitosamente', message: 'Booking deleted successfully',
}); });
} else { } else {
// For non-pending bookings, soft delete // For non-pending bookings, soft delete
@@ -342,7 +342,7 @@ export async function DELETE(
}); });
return NextResponse.json({ return NextResponse.json({
message: 'Reserva cancelada exitosamente', message: 'Booking cancelled successfully',
booking, booking,
}); });
} }
@@ -350,7 +350,7 @@ export async function DELETE(
} catch (error) { } catch (error) {
console.error('Error deleting booking:', error); console.error('Error deleting booking:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Error al cancelar la reserva' }, { error: 'Error cancelling booking' },
{ status: 500 } { status: 500 }
); );
} }

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth'; import { authOptions } from '@/lib/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { createBookingSchema } from '@padel-pro/shared'; import { createBookingSchema } from '@smashpoint/shared';
import { Decimal } from '@prisma/client/runtime/library'; import { Decimal } from '@prisma/client/runtime/library';
// Helper function to check if a time is premium (after 18:00 or weekend) // Helper function to check if a time is premium (after 18:00 or weekend)
@@ -20,7 +20,7 @@ export async function GET(request: NextRequest) {
if (!session?.user) { if (!session?.user) {
return NextResponse.json( return NextResponse.json(
{ error: 'No autorizado' }, { error: 'Unauthorized' },
{ status: 401 } { status: 401 }
); );
} }
@@ -138,7 +138,7 @@ export async function GET(request: NextRequest) {
} catch (error) { } catch (error) {
console.error('Error fetching bookings:', error); console.error('Error fetching bookings:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Error al obtener las reservas' }, { error: 'Error fetching bookings' },
{ status: 500 } { status: 500 }
); );
} }
@@ -151,7 +151,7 @@ export async function POST(request: NextRequest) {
if (!session?.user) { if (!session?.user) {
return NextResponse.json( return NextResponse.json(
{ error: 'No autorizado' }, { error: 'Unauthorized' },
{ status: 401 } { status: 401 }
); );
} }
@@ -163,7 +163,7 @@ export async function POST(request: NextRequest) {
if (!validationResult.success) { if (!validationResult.success) {
return NextResponse.json( return NextResponse.json(
{ {
error: 'Datos de reserva inválidos', error: 'Invalid booking data',
details: validationResult.error.flatten().fieldErrors, details: validationResult.error.flatten().fieldErrors,
}, },
{ status: 400 } { status: 400 }
@@ -193,14 +193,14 @@ export async function POST(request: NextRequest) {
if (!court) { if (!court) {
return NextResponse.json( return NextResponse.json(
{ error: 'Cancha no encontrada o no pertenece a su organización' }, { error: 'Court not found or does not belong to your organization' },
{ status: 404 } { status: 404 }
); );
} }
if (court.status !== 'AVAILABLE' || !court.isActive) { if (court.status !== 'AVAILABLE' || !court.isActive) {
return NextResponse.json( return NextResponse.json(
{ error: 'La cancha no está disponible para reservas' }, { error: 'The court is not available for bookings' },
{ status: 400 } { status: 400 }
); );
} }
@@ -232,7 +232,7 @@ export async function POST(request: NextRequest) {
if (!client) { if (!client) {
return NextResponse.json( return NextResponse.json(
{ error: 'Cliente no encontrado o no pertenece a su organización' }, { error: 'Client not found or does not belong to your organization' },
{ status: 404 } { status: 404 }
); );
} }
@@ -269,7 +269,7 @@ export async function POST(request: NextRequest) {
if (existingBooking) { if (existingBooking) {
return NextResponse.json( return NextResponse.json(
{ error: 'Ya existe una reserva en ese horario. Por favor, seleccione otro horario.' }, { error: 'A booking already exists for that time slot. Please select another time.' },
{ status: 409 } { status: 409 }
); );
} }
@@ -391,7 +391,7 @@ export async function POST(request: NextRequest) {
} catch (error) { } catch (error) {
console.error('Error creating booking:', error); console.error('Error creating booking:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Error al crear la reserva' }, { error: 'Error creating booking' },
{ status: 500 } { status: 500 }
); );
} }

View File

@@ -10,11 +10,11 @@ interface RouteContext {
// Validation schema for updating client // Validation schema for updating client
const updateClientSchema = z.object({ const updateClientSchema = z.object({
firstName: z.string().min(1, 'El nombre es requerido').optional(), firstName: z.string().min(1, 'First name is required').optional(),
lastName: z.string().min(1, 'El apellido es requerido').optional(), lastName: z.string().min(1, 'Last name is required').optional(),
email: z.string().email('Email invalido').nullable().optional(), email: z.string().email('Invalid email').nullable().optional(),
phone: z.string().nullable().optional(), phone: z.string().nullable().optional(),
avatar: z.string().url('URL invalida').nullable().optional(), avatar: z.string().url('Invalid URL').nullable().optional(),
dateOfBirth: z.string().nullable().optional(), dateOfBirth: z.string().nullable().optional(),
address: z.string().nullable().optional(), address: z.string().nullable().optional(),
notes: z.string().nullable().optional(), notes: z.string().nullable().optional(),
@@ -32,7 +32,7 @@ export async function GET(
if (!session?.user) { if (!session?.user) {
return NextResponse.json( return NextResponse.json(
{ error: 'No autorizado' }, { error: 'Unauthorized' },
{ status: 401 } { status: 401 }
); );
} }
@@ -79,7 +79,7 @@ export async function GET(
if (!client) { if (!client) {
return NextResponse.json( return NextResponse.json(
{ error: 'Cliente no encontrado' }, { error: 'Client not found' },
{ status: 404 } { status: 404 }
); );
} }
@@ -122,7 +122,7 @@ export async function GET(
} catch (error) { } catch (error) {
console.error('Error fetching client:', error); console.error('Error fetching client:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Error al obtener el cliente' }, { error: 'Error fetching client' },
{ status: 500 } { status: 500 }
); );
} }
@@ -138,7 +138,7 @@ export async function PUT(
if (!session?.user) { if (!session?.user) {
return NextResponse.json( return NextResponse.json(
{ error: 'No autorizado' }, { error: 'Unauthorized' },
{ status: 401 } { status: 401 }
); );
} }
@@ -155,7 +155,7 @@ export async function PUT(
if (!existingClient) { if (!existingClient) {
return NextResponse.json( return NextResponse.json(
{ error: 'Cliente no encontrado' }, { error: 'Client not found' },
{ status: 404 } { status: 404 }
); );
} }
@@ -167,7 +167,7 @@ export async function PUT(
if (!validationResult.success) { if (!validationResult.success) {
return NextResponse.json( return NextResponse.json(
{ {
error: 'Datos de actualizacion invalidos', error: 'Invalid update data',
details: validationResult.error.flatten().fieldErrors, details: validationResult.error.flatten().fieldErrors,
}, },
{ status: 400 } { status: 400 }
@@ -201,7 +201,7 @@ export async function PUT(
if (emailExists) { if (emailExists) {
return NextResponse.json( return NextResponse.json(
{ error: 'Ya existe un cliente con este email' }, { error: 'A client with this email already exists' },
{ status: 409 } { status: 409 }
); );
} }
@@ -262,13 +262,13 @@ export async function PUT(
// Check for unique constraint violation // Check for unique constraint violation
if (error instanceof Error && error.message.includes('Unique constraint')) { if (error instanceof Error && error.message.includes('Unique constraint')) {
return NextResponse.json( return NextResponse.json(
{ error: 'Ya existe un cliente con este email o DNI' }, { error: 'A client with this email or ID number already exists' },
{ status: 409 } { status: 409 }
); );
} }
return NextResponse.json( return NextResponse.json(
{ error: 'Error al actualizar el cliente' }, { error: 'Error updating client' },
{ status: 500 } { status: 500 }
); );
} }
@@ -284,7 +284,7 @@ export async function DELETE(
if (!session?.user) { if (!session?.user) {
return NextResponse.json( return NextResponse.json(
{ error: 'No autorizado' }, { error: 'Unauthorized' },
{ status: 401 } { status: 401 }
); );
} }
@@ -318,7 +318,7 @@ export async function DELETE(
if (!existingClient) { if (!existingClient) {
return NextResponse.json( return NextResponse.json(
{ error: 'Cliente no encontrado' }, { error: 'Client not found' },
{ status: 404 } { status: 404 }
); );
} }
@@ -327,7 +327,7 @@ export async function DELETE(
if (existingClient.memberships.length > 0) { if (existingClient.memberships.length > 0) {
return NextResponse.json( return NextResponse.json(
{ {
error: 'No se puede desactivar un cliente con membresia activa', error: 'Cannot deactivate a client with an active membership',
details: { details: {
activeMemberships: existingClient.memberships.length, activeMemberships: existingClient.memberships.length,
}, },
@@ -340,7 +340,7 @@ export async function DELETE(
if (existingClient.bookings.length > 0) { if (existingClient.bookings.length > 0) {
return NextResponse.json( return NextResponse.json(
{ {
error: 'No se puede desactivar un cliente con reservas pendientes', error: 'Cannot deactivate a client with pending bookings',
details: { details: {
pendingBookings: existingClient.bookings.length, pendingBookings: existingClient.bookings.length,
}, },
@@ -364,13 +364,13 @@ export async function DELETE(
}); });
return NextResponse.json({ return NextResponse.json({
message: 'Cliente desactivado exitosamente', message: 'Client deactivated successfully',
client, client,
}); });
} catch (error) { } catch (error) {
console.error('Error deleting client:', error); console.error('Error deleting client:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Error al desactivar el cliente' }, { error: 'Error deactivating client' },
{ status: 500 } { status: 500 }
); );
} }

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth'; import { authOptions } from '@/lib/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { createClientSchema } from '@padel-pro/shared'; import { createClientSchema } from '@smashpoint/shared';
// GET /api/clients - List/search clients // GET /api/clients - List/search clients
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@@ -11,7 +11,7 @@ export async function GET(request: NextRequest) {
if (!session?.user) { if (!session?.user) {
return NextResponse.json( return NextResponse.json(
{ error: 'No autorizado' }, { error: 'Unauthorized' },
{ status: 401 } { status: 401 }
); );
} }
@@ -123,7 +123,7 @@ export async function GET(request: NextRequest) {
} catch (error) { } catch (error) {
console.error('Error fetching clients:', error); console.error('Error fetching clients:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Error al obtener los clientes' }, { error: 'Error fetching clients' },
{ status: 500 } { status: 500 }
); );
} }
@@ -136,7 +136,7 @@ export async function POST(request: NextRequest) {
if (!session?.user) { if (!session?.user) {
return NextResponse.json( return NextResponse.json(
{ error: 'No autorizado' }, { error: 'Unauthorized' },
{ status: 401 } { status: 401 }
); );
} }
@@ -148,7 +148,7 @@ export async function POST(request: NextRequest) {
if (!validationResult.success) { if (!validationResult.success) {
return NextResponse.json( return NextResponse.json(
{ {
error: 'Datos del cliente inválidos', error: 'Invalid client data',
details: validationResult.error.flatten().fieldErrors, details: validationResult.error.flatten().fieldErrors,
}, },
{ status: 400 } { status: 400 }
@@ -181,7 +181,7 @@ export async function POST(request: NextRequest) {
if (existingClient) { if (existingClient) {
return NextResponse.json( return NextResponse.json(
{ error: 'Ya existe un cliente con este correo electrónico en su organización' }, { error: 'A client with this email already exists in your organization' },
{ status: 409 } { status: 409 }
); );
} }
@@ -224,13 +224,13 @@ export async function POST(request: NextRequest) {
// Check for unique constraint violation // Check for unique constraint violation
if (error instanceof Error && error.message.includes('Unique constraint')) { if (error instanceof Error && error.message.includes('Unique constraint')) {
return NextResponse.json( return NextResponse.json(
{ error: 'Ya existe un cliente con este correo electrónico o DNI' }, { error: 'A client with this email or ID number already exists' },
{ status: 409 } { status: 409 }
); );
} }
return NextResponse.json( return NextResponse.json(
{ error: 'Error al crear el cliente' }, { error: 'Error creating client' },
{ status: 500 } { status: 500 }
); );
} }

View File

@@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { db } from '@/lib/db';
interface RouteContext {
params: Promise<{ id: string }>;
}
// PUT /api/court-sessions/[id] - End a court session
export async function PUT(
request: NextRequest,
context: RouteContext
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const { id } = await context.params;
// Verify session exists and belongs to user's organization
const existingSession = await db.courtSession.findFirst({
where: {
id,
court: {
site: {
organizationId: session.user.organizationId,
},
},
},
});
if (!existingSession) {
return NextResponse.json(
{ error: 'Court session not found' },
{ status: 404 }
);
}
if (!existingSession.isActive) {
return NextResponse.json(
{ error: 'Court session is already ended' },
{ status: 400 }
);
}
// End the session
const updatedSession = await db.courtSession.update({
where: { id },
data: {
isActive: false,
endTime: new Date(),
},
include: {
court: { select: { id: true, name: true, type: true, isOpenPlay: true, siteId: true } },
client: { select: { id: true, firstName: true, lastName: true, phone: true } },
},
});
return NextResponse.json(updatedSession);
} catch (error) {
console.error('Error ending court session:', error);
return NextResponse.json(
{ error: 'Error ending court session' },
{ status: 500 }
);
}
}
// DELETE /api/court-sessions/[id] - Remove a court session entirely
export async function DELETE(
request: NextRequest,
context: RouteContext
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const { id } = await context.params;
// Verify session exists and belongs to user's organization
const existingSession = await db.courtSession.findFirst({
where: {
id,
court: {
site: {
organizationId: session.user.organizationId,
},
},
},
});
if (!existingSession) {
return NextResponse.json(
{ error: 'Court session not found' },
{ status: 404 }
);
}
await db.courtSession.delete({
where: { id },
});
return NextResponse.json({ message: 'Court session deleted successfully' });
} catch (error) {
console.error('Error deleting court session:', error);
return NextResponse.json(
{ error: 'Error deleting court session' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,159 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { db } from '@/lib/db';
// GET /api/court-sessions - List all active court sessions
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const { searchParams } = new URL(request.url);
const courtId = searchParams.get('courtId');
const siteId = searchParams.get('siteId');
// Build where clause
const whereClause: {
isActive: boolean;
courtId?: string;
court?: { siteId?: string; site: { organizationId: string } };
} = {
isActive: true,
court: {
site: {
organizationId: session.user.organizationId,
},
},
};
if (courtId) {
whereClause.courtId = courtId;
}
if (siteId) {
whereClause.court = {
...whereClause.court!,
siteId,
};
} else if (session.user.siteId) {
whereClause.court = {
...whereClause.court!,
siteId: session.user.siteId,
};
}
const sessions = await db.courtSession.findMany({
where: whereClause,
include: {
court: { select: { id: true, name: true, type: true, isOpenPlay: true, siteId: true } },
client: { select: { id: true, firstName: true, lastName: true, phone: true } },
},
orderBy: { startTime: 'desc' },
});
return NextResponse.json(sessions);
} catch (error) {
console.error('Error fetching court sessions:', error);
return NextResponse.json(
{ error: 'Error fetching court sessions' },
{ status: 500 }
);
}
}
// POST /api/court-sessions - Check in a player to a court
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const body = await request.json();
const { courtId, clientId, walkInName, notes } = body;
// Validate required fields
if (!courtId) {
return NextResponse.json(
{ error: 'courtId is required' },
{ status: 400 }
);
}
// Must have either clientId or walkInName
if (!clientId && !walkInName) {
return NextResponse.json(
{ error: 'Either clientId or walkInName is required' },
{ status: 400 }
);
}
// Verify court exists and belongs to user's organization
const court = await db.court.findFirst({
where: {
id: courtId,
site: {
organizationId: session.user.organizationId,
},
},
});
if (!court) {
return NextResponse.json(
{ error: 'Court not found or does not belong to your organization' },
{ status: 404 }
);
}
// If clientId is provided, verify client exists
if (clientId) {
const client = await db.client.findFirst({
where: {
id: clientId,
organizationId: session.user.organizationId,
},
});
if (!client) {
return NextResponse.json(
{ error: 'Client not found or does not belong to your organization' },
{ status: 404 }
);
}
}
// Create the court session
const courtSession = await db.courtSession.create({
data: {
courtId,
clientId: clientId || null,
walkInName: walkInName || null,
notes: notes || null,
isActive: true,
},
include: {
court: { select: { id: true, name: true, type: true, isOpenPlay: true, siteId: true } },
client: { select: { id: true, firstName: true, lastName: true, phone: true } },
},
});
return NextResponse.json(courtSession, { status: 201 });
} catch (error) {
console.error('Error creating court session:', error);
return NextResponse.json(
{ error: 'Error creating court session' },
{ status: 500 }
);
}
}

View File

@@ -125,23 +125,34 @@ export async function PUT(
type, type,
status, status,
pricePerHour, pricePerHour,
hourlyRate,
description, description,
features, features,
displayOrder, displayOrder,
isActive, isActive,
isOpenPlay,
} = body; } = body;
const price = pricePerHour ?? hourlyRate;
// Map lowercase form values to Prisma enum values
const typeMap: Record<string, string> = { indoor: 'INDOOR', outdoor: 'OUTDOOR', covered: 'COVERED' };
const statusMap: Record<string, string> = { active: 'AVAILABLE', maintenance: 'MAINTENANCE', inactive: 'CLOSED' };
const mappedType = type ? (typeMap[type.toLowerCase()] || type) : undefined;
const mappedStatus = status ? (statusMap[status.toLowerCase()] || status) : undefined;
const court = await db.court.update({ const court = await db.court.update({
where: { id }, where: { id },
data: { data: {
...(name !== undefined && { name }), ...(name !== undefined && { name }),
...(type !== undefined && { type }), ...(mappedType !== undefined && { type: mappedType }),
...(status !== undefined && { status }), ...(mappedStatus !== undefined && { status: mappedStatus }),
...(pricePerHour !== undefined && { pricePerHour }), ...(price !== undefined && { pricePerHour: price }),
...(description !== undefined && { description }), ...(description !== undefined && { description }),
...(features !== undefined && { features }), ...(features !== undefined && { features }),
...(displayOrder !== undefined && { displayOrder }), ...(displayOrder !== undefined && { displayOrder }),
...(isActive !== undefined && { isActive }), ...(isActive !== undefined && { isActive }),
...(isOpenPlay !== undefined && { isOpenPlay }),
}, },
include: { include: {
site: { site: {

View File

@@ -97,14 +97,24 @@ export async function POST(request: NextRequest) {
type, type,
status, status,
pricePerHour, pricePerHour,
hourlyRate,
description, description,
features, features,
displayOrder, displayOrder,
isActive, isActive,
isOpenPlay,
} = body; } = body;
const price = pricePerHour ?? hourlyRate;
// Map lowercase form values to Prisma enum values
const typeMap: Record<string, string> = { indoor: 'INDOOR', outdoor: 'OUTDOOR', covered: 'COVERED' };
const statusMap: Record<string, string> = { active: 'AVAILABLE', maintenance: 'MAINTENANCE', inactive: 'CLOSED' };
const mappedType = typeMap[type?.toLowerCase()] || type || 'INDOOR';
const mappedStatus = statusMap[status?.toLowerCase()] || status || 'AVAILABLE';
// Validate required fields // Validate required fields
if (!siteId || !name || pricePerHour === undefined) { if (!siteId || !name || price === undefined) {
return NextResponse.json( return NextResponse.json(
{ error: 'Missing required fields: siteId, name, pricePerHour' }, { error: 'Missing required fields: siteId, name, pricePerHour' },
{ status: 400 } { status: 400 }
@@ -138,12 +148,13 @@ export async function POST(request: NextRequest) {
data: { data: {
siteId, siteId,
name, name,
type: type || 'INDOOR', type: mappedType,
status: status || 'AVAILABLE', status: mappedStatus,
pricePerHour, pricePerHour: price,
description: description || null, description: description || null,
features: features || [], features: features || [],
displayOrder: displayOrder ?? 0, displayOrder: displayOrder ?? 0,
isOpenPlay: isOpenPlay ?? false,
isActive: isActive ?? true, isActive: isActive ?? true,
}, },
include: { include: {

View File

@@ -10,7 +10,7 @@ export async function GET(request: NextRequest) {
if (!session?.user) { if (!session?.user) {
return NextResponse.json( return NextResponse.json(
{ error: 'No autorizado' }, { error: 'Unauthorized' },
{ status: 401 } { status: 401 }
); );
} }
@@ -315,7 +315,7 @@ export async function GET(request: NextRequest) {
} catch (error) { } catch (error) {
console.error('Error fetching dashboard stats:', error); console.error('Error fetching dashboard stats:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Error al obtener estadísticas del dashboard' }, { error: 'Error fetching dashboard statistics' },
{ status: 500 } { status: 500 }
); );
} }

View File

@@ -0,0 +1,117 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { db } from '@/lib/db';
// GET /api/live - Get complete live court status
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const { searchParams } = new URL(request.url);
const siteId = searchParams.get('siteId');
// Build where clause for courts
const courtWhere: {
isActive: boolean;
site: { organizationId: string; id?: string };
} = {
isActive: true,
site: {
organizationId: session.user.organizationId,
},
};
if (siteId) {
courtWhere.site.id = siteId;
} else if (session.user.siteId) {
courtWhere.site.id = session.user.siteId;
}
const now = new Date();
const endOfHour = new Date(now.getTime() + 30 * 60 * 1000); // 30 minutes from now
// Get all courts with their active sessions AND current bookings
const courts = await db.court.findMany({
where: courtWhere,
include: {
sessions: {
where: { isActive: true },
include: {
client: { select: { id: true, firstName: true, lastName: true, phone: true } },
},
},
bookings: {
where: {
startTime: { lte: endOfHour },
endTime: { gte: now },
status: { in: ['CONFIRMED', 'PENDING'] },
},
include: {
client: { select: { id: true, firstName: true, lastName: true } },
},
},
site: { select: { id: true, name: true } },
},
orderBy: { displayOrder: 'asc' },
});
// Compute status for each court and transform to frontend shape
const courtsWithStatus = courts.map((court) => {
let status: 'available' | 'active' | 'open_play' | 'booked';
if (court.sessions.length > 0) {
status = court.isOpenPlay ? 'open_play' : 'active';
} else if (court.isOpenPlay) {
status = 'open_play';
} else if (court.bookings.length > 0) {
status = 'booked';
} else {
status = 'available';
}
// Transform sessions to players array for frontend
const players = court.sessions.map((session) => ({
id: session.client?.id || session.id,
firstName: session.client?.firstName,
lastName: session.client?.lastName,
walkInName: session.walkInName,
checkedInAt: session.startTime.toISOString(),
sessionId: session.id,
}));
// Get upcoming booking info
const upcomingBooking = court.bookings.length > 0 ? {
startTime: court.bookings[0].startTime.toISOString(),
clientName: court.bookings[0].client
? `${court.bookings[0].client.firstName} ${court.bookings[0].client.lastName}`
: 'Walk-in',
} : undefined;
return {
id: court.id,
name: court.name,
type: court.type,
isOpenPlay: court.isOpenPlay,
status,
players,
upcomingBooking,
};
});
return NextResponse.json(courtsWithStatus);
} catch (error) {
console.error('Error fetching live court status:', error);
return NextResponse.json(
{ error: 'Error fetching live court status' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,153 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { db } from '@/lib/db';
// GET /api/sites/[id] - Get a single site
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const site = await db.site.findFirst({
where: {
id: params.id,
organizationId: session.user.organizationId,
},
include: {
courts: {
where: { isActive: true },
select: {
id: true,
name: true,
type: true,
status: true,
pricePerHour: true,
},
},
},
});
if (!site) {
return NextResponse.json({ error: 'Site not found' }, { status: 404 });
}
return NextResponse.json({ data: site });
} catch (error) {
console.error('Error fetching site:', error);
return NextResponse.json(
{ error: 'Error fetching site' },
{ status: 500 }
);
}
}
// PUT /api/sites/[id] - Update a site
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!['super_admin', 'site_admin'].includes(session.user.role)) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
const body = await request.json();
const { name, address, phone, openTime, closeTime, isActive } = body;
// Verify site belongs to organization
const existingSite = await db.site.findFirst({
where: {
id: params.id,
organizationId: session.user.organizationId,
},
});
if (!existingSite) {
return NextResponse.json({ error: 'Site not found' }, { status: 404 });
}
const updateData: any = {};
if (name !== undefined) {
updateData.name = name;
updateData.slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
if (address !== undefined) updateData.address = address;
if (phone !== undefined) updateData.phone = phone;
if (openTime !== undefined) updateData.openTime = openTime;
if (closeTime !== undefined) updateData.closeTime = closeTime;
if (isActive !== undefined) updateData.isActive = isActive;
const site = await db.site.update({
where: { id: params.id },
data: updateData,
});
return NextResponse.json({ data: site });
} catch (error) {
console.error('Error updating site:', error);
return NextResponse.json(
{ error: 'Error updating site' },
{ status: 500 }
);
}
}
// DELETE /api/sites/[id] - Delete a site (soft delete)
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (session.user.role !== 'super_admin') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
// Verify site belongs to organization
const existingSite = await db.site.findFirst({
where: {
id: params.id,
organizationId: session.user.organizationId,
},
});
if (!existingSite) {
return NextResponse.json({ error: 'Site not found' }, { status: 404 });
}
// Soft delete
await db.site.update({
where: { id: params.id },
data: { isActive: false },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting site:', error);
return NextResponse.json(
{ error: 'Error deleting site' },
{ status: 500 }
);
}
}

View File

@@ -18,7 +18,6 @@ export async function GET(request: NextRequest) {
const sites = await db.site.findMany({ const sites = await db.site.findMany({
where: { where: {
organizationId: session.user.organizationId, organizationId: session.user.organizationId,
isActive: true,
}, },
select: { select: {
id: true, id: true,
@@ -30,6 +29,7 @@ export async function GET(request: NextRequest) {
timezone: true, timezone: true,
openTime: true, openTime: true,
closeTime: true, closeTime: true,
isActive: true,
_count: { _count: {
select: { select: {
courts: { courts: {
@@ -56,10 +56,11 @@ export async function GET(request: NextRequest) {
timezone: site.timezone, timezone: site.timezone,
openTime: site.openTime, openTime: site.openTime,
closeTime: site.closeTime, closeTime: site.closeTime,
isActive: site.isActive,
courtCount: site._count.courts, courtCount: site._count.courts,
})); }));
return NextResponse.json(transformedSites); return NextResponse.json({ data: transformedSites });
} catch (error) { } catch (error) {
console.error('Error fetching sites:', error); console.error('Error fetching sites:', error);
return NextResponse.json( return NextResponse.json(
@@ -68,3 +69,55 @@ export async function GET(request: NextRequest) {
); );
} }
} }
// POST /api/sites - Create a new site
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!['super_admin', 'site_admin'].includes(session.user.role)) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
const body = await request.json();
const { name, address, phone, openTime, closeTime, isActive } = body;
if (!name || !address) {
return NextResponse.json(
{ error: 'Name and address are required' },
{ status: 400 }
);
}
// Generate slug from name
const slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
const site = await db.site.create({
data: {
name,
slug,
address,
phone: phone || null,
openTime: openTime || '08:00',
closeTime: closeTime || '22:00',
isActive: isActive ?? true,
organizationId: session.user.organizationId,
},
});
return NextResponse.json({ data: site }, { status: 201 });
} catch (error) {
console.error('Error creating site:', error);
return NextResponse.json(
{ error: 'Error creating site' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,136 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { db } from "@/lib/db";
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const users = await db.user.findMany({
where: {
organizationId: session.user.organizationId,
},
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
isActive: true,
createdAt: true,
sites: {
select: {
id: true,
name: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
// Transform to match the expected format
const transformedUsers = users.map((user) => ({
...user,
site: user.sites.length > 0 ? user.sites[0] : null,
}));
return NextResponse.json({ data: transformedUsers });
} catch (error) {
console.error("Error fetching users:", error);
return NextResponse.json(
{ error: "Error fetching users" },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only super_admin and site_admin can create users
if (!["super_admin", "site_admin"].includes(session.user.role)) {
return NextResponse.json({ error: "Insufficient permissions" }, { status: 403 });
}
const body = await request.json();
const { email, password, firstName, lastName, role, siteId } = body;
if (!email || !password || !firstName || !lastName || !role) {
return NextResponse.json(
{ error: "Missing required fields" },
{ status: 400 }
);
}
// Check if user already exists
const existingUser = await db.user.findFirst({
where: {
organizationId: session.user.organizationId,
email,
},
});
if (existingUser) {
return NextResponse.json(
{ error: "This email is already registered" },
{ status: 400 }
);
}
// Hash password
const bcrypt = require("bcryptjs");
const hashedPassword = await bcrypt.hash(password, 10);
const user = await db.user.create({
data: {
email,
password: hashedPassword,
firstName,
lastName,
role,
organizationId: session.user.organizationId,
siteIds: siteId ? [siteId] : [],
isActive: true,
},
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
isActive: true,
sites: {
select: {
id: true,
name: true,
},
},
},
});
return NextResponse.json({
data: {
...user,
site: user.sites.length > 0 ? user.sites[0] : null,
},
}, { status: 201 });
} catch (error) {
console.error("Error creating user:", error);
return NextResponse.json(
{ error: "Error creating user" },
{ status: 500 }
);
}
}

6
apps/web/app/icon.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#2990EA"/>
<path d="M17.5 3L6.5 17h8l-3 12L22.5 15H14.5l3-12z" fill="white"/>
<circle cx="25" cy="6" r="1.5" fill="white" opacity="0.8"/>
<circle cx="27" cy="9" r="1" fill="white" opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 326 B

View File

@@ -5,10 +5,10 @@ import "./globals.css";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Padel Pro", title: "Cabo Pickleball Club | SmashPoint",
description: "Sistema de Gestión para Clubes de Pádel", description: "Court Management System for Cabo Pickleball Club",
keywords: ["padel", "club", "reservas", "gestión", "deportes"], keywords: ["pickleball", "cabo", "courts", "bookings", "club"],
authors: [{ name: "Padel Pro Team" }], authors: [{ name: "SmashPoint" }],
}; };
export default function RootLayout({ export default function RootLayout({
@@ -17,7 +17,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="es"> <html lang="en">
<body className={inter.className}>{children}</body> <body className={inter.className}>{children}</body>
</html> </html>
); );

View File

@@ -4,11 +4,23 @@ export default function Home() {
return ( return (
<main className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100"> <main className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100">
<div className="text-center space-y-8 px-4"> <div className="text-center space-y-8 px-4">
{/* Logo */}
<div className="flex justify-center">
<div className="w-20 h-20 bg-primary rounded-2xl flex items-center justify-center shadow-lg">
<svg viewBox="0 0 40 40" className="w-12 h-12 text-white" fill="none">
<path d="M22 4L8 22h10l-4 14L28 18H18l4-14z" fill="currentColor" />
<circle cx="32" cy="8" r="2" fill="currentColor" opacity="0.8" />
<circle cx="35" cy="12" r="1.2" fill="currentColor" opacity="0.6" />
<circle cx="30" cy="4" r="1" fill="currentColor" opacity="0.5" />
</svg>
</div>
</div>
<h1 className="text-5xl md:text-6xl font-bold text-primary-800"> <h1 className="text-5xl md:text-6xl font-bold text-primary-800">
Padel Pro Cabo Pickleball Club
</h1> </h1>
<p className="text-sm text-primary-400 -mt-4">Powered by SmashPoint</p>
<p className="text-xl md:text-2xl text-primary-600 max-w-2xl mx-auto"> <p className="text-xl md:text-2xl text-primary-600 max-w-2xl mx-auto">
Sistema de Gestion para Clubes de Padel Court Management System
</p> </p>
<div className="flex flex-col sm:flex-row gap-4 justify-center mt-8"> <div className="flex flex-col sm:flex-row gap-4 justify-center mt-8">
<Link <Link
@@ -18,10 +30,10 @@ export default function Home() {
Dashboard Dashboard
</Link> </Link>
<Link <Link
href="/reservas" href="/bookings"
className="px-8 py-3 bg-accent-500 text-white font-semibold rounded-lg hover:bg-accent-600 transition-colors duration-200 shadow-lg hover:shadow-xl" className="px-8 py-3 bg-accent-500 text-white font-semibold rounded-lg hover:bg-accent-600 transition-colors duration-200 shadow-lg hover:shadow-xl"
> >
Reservas Book a Court
</Link> </Link>
</div> </div>
</div> </div>

View File

@@ -29,15 +29,15 @@ export function LoginForm({ className }: LoginFormProps) {
const newErrors: { email?: string; password?: string } = {}; const newErrors: { email?: string; password?: string } = {};
if (!email) { if (!email) {
newErrors.email = 'El correo electrónico es requerido'; newErrors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
newErrors.email = 'Ingresa un correo electrónico válido'; newErrors.email = 'Enter a valid email address';
} }
if (!password) { if (!password) {
newErrors.password = 'La contraseña es requerida'; newErrors.password = 'Password is required';
} else if (password.length < 6) { } else if (password.length < 6) {
newErrors.password = 'La contraseña debe tener al menos 6 caracteres'; newErrors.password = 'Password must be at least 6 characters';
} }
setErrors(newErrors); setErrors(newErrors);
@@ -62,13 +62,13 @@ export function LoginForm({ className }: LoginFormProps) {
}); });
if (result?.error) { if (result?.error) {
setError('Credenciales inválidas. Por favor, verifica tu correo y contraseña.'); setError('Invalid credentials. Please check your email and password.');
} else { } else {
router.push(callbackUrl); router.push(callbackUrl);
router.refresh(); router.refresh();
} }
} catch (err) { } catch (err) {
setError('Ocurrió un error al iniciar sesión. Por favor, intenta de nuevo.'); setError('An error occurred while signing in. Please try again.');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -77,9 +77,9 @@ export function LoginForm({ className }: LoginFormProps) {
return ( return (
<Card className={cn('w-full max-w-md', className)}> <Card className={cn('w-full max-w-md', className)}>
<CardHeader className="space-y-1 text-center"> <CardHeader className="space-y-1 text-center">
<CardTitle className="text-2xl font-bold">Iniciar Sesión</CardTitle> <CardTitle className="text-2xl font-bold">Sign In</CardTitle>
<CardDescription> <CardDescription>
Ingresa tus credenciales para acceder al sistema Enter your credentials to access the system
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -106,12 +106,12 @@ export function LoginForm({ className }: LoginFormProps) {
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium text-primary-700"> <label htmlFor="email" className="text-sm font-medium text-primary-700">
Correo Electrónico Email
</label> </label>
<Input <Input
id="email" id="email"
type="email" type="email"
placeholder="correo@ejemplo.com" placeholder="email@example.com"
value={email} value={email}
onChange={(e) => { onChange={(e) => {
setEmail(e.target.value); setEmail(e.target.value);
@@ -129,7 +129,7 @@ export function LoginForm({ className }: LoginFormProps) {
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium text-primary-700"> <label htmlFor="password" className="text-sm font-medium text-primary-700">
Contraseña Password
</label> </label>
<div className="relative"> <div className="relative">
<Input <Input
@@ -199,13 +199,13 @@ export function LoginForm({ className }: LoginFormProps) {
onChange={(e) => setRememberMe(e.target.checked)} onChange={(e) => setRememberMe(e.target.checked)}
className="h-4 w-4 rounded border-primary-300 text-primary focus:ring-primary-500" className="h-4 w-4 rounded border-primary-300 text-primary focus:ring-primary-500"
/> />
<span className="text-sm text-primary-600">Recordarme</span> <span className="text-sm text-primary-600">Remember me</span>
</label> </label>
<a <a
href="#" href="#"
className="text-sm text-primary-600 hover:text-primary-800 hover:underline" className="text-sm text-primary-600 hover:text-primary-800 hover:underline"
> >
¿Olvidaste tu contraseña? Forgot your password?
</a> </a>
</div> </div>
@@ -237,10 +237,10 @@ export function LoginForm({ className }: LoginFormProps) {
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/> />
</svg> </svg>
Iniciando sesión... Signing in...
</div> </div>
) : ( ) : (
'Iniciar Sesión' 'Sign In'
)} )}
</Button> </Button>
</form> </form>

View File

@@ -94,13 +94,13 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
const url = siteId ? `/api/courts?siteId=${siteId}` : "/api/courts"; const url = siteId ? `/api/courts?siteId=${siteId}` : "/api/courts";
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
throw new Error("Error al cargar las canchas"); throw new Error("Error loading courts");
} }
const data = await response.json(); const data = await response.json();
setCourts(data); setCourts(data);
return data as Court[]; return data as Court[];
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Error desconocido"); setError(err instanceof Error ? err.message : "Unknown error");
return []; return [];
} }
}, [siteId]); }, [siteId]);
@@ -113,7 +113,7 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
`/api/courts/${courtId}/availability?date=${dateStr}` `/api/courts/${courtId}/availability?date=${dateStr}`
); );
if (!response.ok) { if (!response.ok) {
throw new Error(`Error al cargar disponibilidad`); throw new Error(`Error loading availability`);
} }
return (await response.json()) as CourtAvailability; return (await response.json()) as CourtAvailability;
} catch (err) { } catch (err) {
@@ -224,7 +224,7 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
fetchCourts(); fetchCourts();
}} }}
> >
Reintentar Retry
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -238,7 +238,7 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
<Card> <Card>
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-lg">Calendario</CardTitle> <CardTitle className="text-lg">Calendar</CardTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={goToPrevDay}> <Button variant="outline" size="sm" onClick={goToPrevDay}>
<svg <svg
@@ -260,7 +260,7 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
size="sm" size="sm"
onClick={goToToday} onClick={goToToday}
> >
Hoy Today
</Button> </Button>
<Button variant="outline" size="sm" onClick={goToNextDay}> <Button variant="outline" size="sm" onClick={goToNextDay}>
<svg <svg
@@ -286,12 +286,12 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
<div className="flex items-center justify-center p-12"> <div className="flex items-center justify-center p-12">
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary-200 border-t-primary-600" /> <div className="h-8 w-8 animate-spin rounded-full border-4 border-primary-200 border-t-primary-600" />
<p className="text-sm text-primary-500">Cargando disponibilidad...</p> <p className="text-sm text-primary-500">Loading availability...</p>
</div> </div>
</div> </div>
) : courts.length === 0 ? ( ) : courts.length === 0 ? (
<div className="p-6 text-center text-primary-500"> <div className="p-6 text-center text-primary-500">
<p>No hay canchas disponibles.</p> <p>No courts available.</p>
</div> </div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -304,8 +304,10 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
courts.length === 2 && "grid-cols-2", courts.length === 2 && "grid-cols-2",
courts.length === 3 && "grid-cols-3", courts.length === 3 && "grid-cols-3",
courts.length === 4 && "grid-cols-4", courts.length === 4 && "grid-cols-4",
courts.length >= 5 && "grid-cols-5" courts.length === 5 && "grid-cols-5",
courts.length >= 6 && "grid-cols-6"
)} )}
style={courts.length >= 5 ? { minWidth: `${courts.length * 150}px` } : undefined}
> >
{courts.map((court) => ( {courts.map((court) => (
<div <div
@@ -316,7 +318,7 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
{court.name} {court.name}
</h3> </h3>
<p className="text-xs text-primary-500 mt-1"> <p className="text-xs text-primary-500 mt-1">
{court.type === "INDOOR" ? "Interior" : "Exterior"} {court.type === "INDOOR" ? "Indoor" : "Outdoor"}
</p> </p>
</div> </div>
))} ))}
@@ -333,8 +335,10 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
courts.length === 2 && "grid-cols-2", courts.length === 2 && "grid-cols-2",
courts.length === 3 && "grid-cols-3", courts.length === 3 && "grid-cols-3",
courts.length === 4 && "grid-cols-4", courts.length === 4 && "grid-cols-4",
courts.length >= 5 && "grid-cols-5" courts.length === 5 && "grid-cols-5",
courts.length >= 6 && "grid-cols-6"
)} )}
style={courts.length >= 5 ? { minWidth: `${courts.length * 150}px` } : undefined}
> >
{courts.map((court) => { {courts.map((court) => {
const courtAvail = availability.get(court.id); const courtAvail = availability.get(court.id);
@@ -347,7 +351,7 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
className="border-r border-primary-200 last:border-r-0 p-2" className="border-r border-primary-200 last:border-r-0 p-2"
> >
<div className="rounded-md border border-primary-200 bg-primary-50 p-3 text-center text-xs text-primary-400"> <div className="rounded-md border border-primary-200 bg-primary-50 p-3 text-center text-xs text-primary-400">
No disponible Not available
</div> </div>
</div> </div>
); );
@@ -373,7 +377,7 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
{timeSlots.length === 0 && ( {timeSlots.length === 0 && (
<div className="p-6 text-center text-primary-500"> <div className="p-6 text-center text-primary-500">
<p>No hay horarios disponibles para este día.</p> <p>No time slots available for this day.</p>
</div> </div>
)} )}
</div> </div>

View File

@@ -104,12 +104,12 @@ export function BookingDialog({
try { try {
const response = await fetch(`/api/bookings/${slot.bookingId}`); const response = await fetch(`/api/bookings/${slot.bookingId}`);
if (!response.ok) { if (!response.ok) {
throw new Error("Error al cargar la reserva"); throw new Error("Error loading booking");
} }
const data = await response.json(); const data = await response.json();
setBooking(data); setBooking(data);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Error desconocido"); setError(err instanceof Error ? err.message : "Unknown error");
} finally { } finally {
setLoadingBookingInfo(false); setLoadingBookingInfo(false);
} }
@@ -128,7 +128,7 @@ export function BookingDialog({
`/api/clients?search=${encodeURIComponent(search)}&limit=10` `/api/clients?search=${encodeURIComponent(search)}&limit=10`
); );
if (!response.ok) { if (!response.ok) {
throw new Error("Error al buscar clientes"); throw new Error("Error searching players");
} }
const data: ClientsResponse = await response.json(); const data: ClientsResponse = await response.json();
setClients(data.data); setClients(data.data);
@@ -184,13 +184,13 @@ export function BookingDialog({
if (!response.ok) { if (!response.ok) {
const data = await response.json(); const data = await response.json();
throw new Error(data.error || "Error al crear la reserva"); throw new Error(data.error || "Error creating booking");
} }
onBookingCreated?.(); onBookingCreated?.();
onClose(); onClose();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Error al crear la reserva"); setError(err instanceof Error ? err.message : "Error creating booking");
} finally { } finally {
setCreatingBooking(false); setCreatingBooking(false);
} }
@@ -210,20 +210,20 @@ export function BookingDialog({
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
cancelReason: "Cancelada por el administrador", cancelReason: "Cancelled by administrator",
}), }),
}); });
if (!response.ok) { if (!response.ok) {
const data = await response.json(); const data = await response.json();
throw new Error(data.error || "Error al cancelar la reserva"); throw new Error(data.error || "Error cancelling booking");
} }
onBookingCancelled?.(); onBookingCancelled?.();
onClose(); onClose();
} catch (err) { } catch (err) {
setError( setError(
err instanceof Error ? err.message : "Error al cancelar la reserva" err instanceof Error ? err.message : "Error cancelling booking"
); );
} finally { } finally {
setCancellingBooking(false); setCancellingBooking(false);
@@ -246,7 +246,7 @@ export function BookingDialog({
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-lg"> <CardTitle className="text-lg">
{slot.available ? "Nueva Reserva" : "Detalle de Reserva"} {slot.available ? "New Booking" : "Booking Details"}
</CardTitle> </CardTitle>
<button <button
onClick={onClose} onClick={onClose}
@@ -269,16 +269,16 @@ export function BookingDialog({
</div> </div>
<div className="text-sm text-primary-600 space-y-1 mt-2"> <div className="text-sm text-primary-600 space-y-1 mt-2">
<p> <p>
<span className="font-medium">Cancha:</span> {slot.courtName} <span className="font-medium">Court:</span> {slot.courtName}
</p> </p>
<p> <p>
<span className="font-medium">Fecha:</span> {formatDate(date)} <span className="font-medium">Date:</span> {formatDate(date)}
</p> </p>
<p> <p>
<span className="font-medium">Hora:</span> {formatTime(slotDate)} <span className="font-medium">Time:</span> {formatTime(slotDate)}
</p> </p>
<p> <p>
<span className="font-medium">Precio:</span>{" "} <span className="font-medium">Price:</span>{" "}
{formatCurrency(slot.price)} {formatCurrency(slot.price)}
</p> </p>
</div> </div>
@@ -296,11 +296,11 @@ export function BookingDialog({
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-primary-700 mb-2"> <label className="block text-sm font-medium text-primary-700 mb-2">
Buscar Cliente Search Player
</label> </label>
<Input <Input
type="text" type="text"
placeholder="Nombre, email o telefono..." placeholder="Name, email or phone..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
autoFocus autoFocus
@@ -317,7 +317,7 @@ export function BookingDialog({
{!loadingClients && searchQuery.length >= 2 && clients.length === 0 && ( {!loadingClients && searchQuery.length >= 2 && clients.length === 0 && (
<p className="text-sm text-primary-500 text-center py-4"> <p className="text-sm text-primary-500 text-center py-4">
No se encontraron clientes. No players found.
</p> </p>
)} )}
@@ -341,7 +341,7 @@ export function BookingDialog({
{client.firstName} {client.lastName} {client.firstName} {client.lastName}
</p> </p>
<p className="text-xs text-primary-500"> <p className="text-xs text-primary-500">
{client.email || client.phone || "Sin contacto"} {client.email || client.phone || "No contact"}
</p> </p>
</div> </div>
{client.memberships.length > 0 && ( {client.memberships.length > 0 && (
@@ -358,18 +358,18 @@ export function BookingDialog({
{selectedClient && ( {selectedClient && (
<div className="mt-4 rounded-md border border-accent-200 bg-accent-50 p-3"> <div className="mt-4 rounded-md border border-accent-200 bg-accent-50 p-3">
<p className="text-sm font-medium text-accent-800"> <p className="text-sm font-medium text-accent-800">
Cliente seleccionado: Selected player:
</p> </p>
<p className="text-sm text-accent-700"> <p className="text-sm text-accent-700">
{selectedClient.firstName} {selectedClient.lastName} {selectedClient.firstName} {selectedClient.lastName}
</p> </p>
{selectedClient.memberships.length > 0 && ( {selectedClient.memberships.length > 0 && (
<p className="text-xs text-accent-600 mt-1"> <p className="text-xs text-accent-600 mt-1">
Membresia: {selectedClient.memberships[0].plan.name} Membership: {selectedClient.memberships[0].plan.name}
{selectedClient.memberships[0].remainingHours !== null && {selectedClient.memberships[0].remainingHours !== null &&
selectedClient.memberships[0].remainingHours > 0 && ( selectedClient.memberships[0].remainingHours > 0 && (
<span className="ml-2"> <span className="ml-2">
({selectedClient.memberships[0].remainingHours}h restantes) ({selectedClient.memberships[0].remainingHours}h remaining)
</span> </span>
)} )}
</p> </p>
@@ -392,7 +392,7 @@ export function BookingDialog({
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-md border border-primary-200 bg-primary-50 p-4 space-y-3"> <div className="rounded-md border border-primary-200 bg-primary-50 p-4 space-y-3">
<div> <div>
<p className="text-xs text-primary-500">Cliente</p> <p className="text-xs text-primary-500">Player</p>
<p className="font-medium text-primary-800"> <p className="font-medium text-primary-800">
{booking.client.firstName} {booking.client.lastName} {booking.client.firstName} {booking.client.lastName}
</p> </p>
@@ -410,7 +410,7 @@ export function BookingDialog({
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<p className="text-xs text-primary-500">Estado</p> <p className="text-xs text-primary-500">Status</p>
<p <p
className={cn( className={cn(
"text-sm font-medium", "text-sm font-medium",
@@ -419,21 +419,21 @@ export function BookingDialog({
booking.status === "CANCELLED" && "text-red-600" booking.status === "CANCELLED" && "text-red-600"
)} )}
> >
{booking.status === "CONFIRMED" && "Confirmada"} {booking.status === "CONFIRMED" && "Confirmed"}
{booking.status === "PENDING" && "Pendiente"} {booking.status === "PENDING" && "Pending"}
{booking.status === "CANCELLED" && "Cancelada"} {booking.status === "CANCELLED" && "Cancelled"}
{booking.status === "COMPLETED" && "Completada"} {booking.status === "COMPLETED" && "Completed"}
{booking.status === "NO_SHOW" && "No asistio"} {booking.status === "NO_SHOW" && "No Show"}
</p> </p>
</div> </div>
<div> <div>
<p className="text-xs text-primary-500">Tipo de Pago</p> <p className="text-xs text-primary-500">Payment Type</p>
<p className="text-sm text-primary-800"> <p className="text-sm text-primary-800">
{booking.paymentType === "CASH" && "Efectivo"} {booking.paymentType === "CASH" && "Cash"}
{booking.paymentType === "CARD" && "Tarjeta"} {booking.paymentType === "CARD" && "Card"}
{booking.paymentType === "TRANSFER" && "Transferencia"} {booking.paymentType === "TRANSFER" && "Transfer"}
{booking.paymentType === "MEMBERSHIP" && "Membresia"} {booking.paymentType === "MEMBERSHIP" && "Membership"}
{booking.paymentType === "FREE" && "Gratuito"} {booking.paymentType === "FREE" && "Free"}
</p> </p>
</div> </div>
</div> </div>
@@ -450,7 +450,7 @@ export function BookingDialog({
{!loadingBookingInfo && !booking && ( {!loadingBookingInfo && !booking && (
<div className="text-center py-4 text-primary-500"> <div className="text-center py-4 text-primary-500">
<p>No se pudo cargar la informacion de la reserva.</p> <p>Could not load booking information.</p>
</div> </div>
)} )}
</div> </div>
@@ -460,7 +460,7 @@ export function BookingDialog({
<CardFooter className="border-t border-primary-200 bg-primary-50 pt-4"> <CardFooter className="border-t border-primary-200 bg-primary-50 pt-4">
<div className="flex w-full gap-3"> <div className="flex w-full gap-3">
<Button variant="outline" onClick={onClose} className="flex-1"> <Button variant="outline" onClick={onClose} className="flex-1">
Cerrar Close
</Button> </Button>
{slot.available && ( {slot.available && (
@@ -473,10 +473,10 @@ export function BookingDialog({
{creatingBooking ? ( {creatingBooking ? (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" /> <div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
Creando... Creating...
</span> </span>
) : ( ) : (
"Crear Reserva" "Create Booking"
)} )}
</Button> </Button>
)} )}
@@ -491,10 +491,10 @@ export function BookingDialog({
{cancellingBooking ? ( {cancellingBooking ? (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" /> <div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
Cancelando... Cancelling...
</span> </span>
) : ( ) : (
"Cancelar Reserva" "Cancel Booking"
)} )}
</Button> </Button>
)} )}

View File

@@ -12,22 +12,32 @@ interface Client {
phone: string | null; phone: string | null;
avatar?: string | null; avatar?: string | null;
level: string | null; level: string | null;
notes: string | null;
isActive: boolean; isActive: boolean;
createdAt: string; createdAt: string;
memberships?: Array<{ memberships?: Array<{
id: string; id: string;
status: string; status: string;
remainingHours: number | null; startDate: string;
endDate: string; endDate: string;
remainingHours: number | null;
plan: { plan: {
id: string; id: string;
name: string; name: string;
price: number | string;
durationMonths: number;
courtHours: number | null;
discountPercent: number | string | null; discountPercent: number | string | null;
}; };
}>; }>;
_count?: { _count?: {
bookings: number; bookings: number;
}; };
stats?: {
totalBookings: number;
totalSpent: number;
balance: number;
};
} }
interface ClientTableProps { interface ClientTableProps {

View File

@@ -39,7 +39,7 @@ export function OccupancyChart({ data, isLoading = false }: OccupancyChartProps)
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/> />
</svg> </svg>
Ocupacion de Canchas Court Occupancy
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -57,7 +57,7 @@ export function OccupancyChart({ data, isLoading = false }: OccupancyChartProps)
d="M20 12H4M12 20V4" d="M20 12H4M12 20V4"
/> />
</svg> </svg>
<p className="text-sm">No hay canchas configuradas</p> <p className="text-sm">No courts configured</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -89,7 +89,7 @@ export function OccupancyChart({ data, isLoading = false }: OccupancyChartProps)
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/> />
</svg> </svg>
Ocupacion de Canchas Court Occupancy
</CardTitle> </CardTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
@@ -147,10 +147,10 @@ export function OccupancyChart({ data, isLoading = false }: OccupancyChartProps)
: "text-primary-500" : "text-primary-500"
)} )}
> >
{court.occupancyPercent}% ocupado {court.occupancyPercent}% booked
</span> </span>
<span className="text-xs text-green-600"> <span className="text-xs text-green-600">
{court.availableHours - court.bookedHours}h disponible {court.availableHours - court.bookedHours}h available
</span> </span>
</div> </div>
</div> </div>
@@ -161,11 +161,11 @@ export function OccupancyChart({ data, isLoading = false }: OccupancyChartProps)
<div className="flex items-center justify-center gap-6 mt-6 pt-4 border-t border-primary-100"> <div className="flex items-center justify-center gap-6 mt-6 pt-4 border-t border-primary-100">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-400"></div> <div className="w-3 h-3 rounded-full bg-blue-400"></div>
<span className="text-xs text-primary-500">Ocupado</span> <span className="text-xs text-primary-500">Booked</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-100"></div> <div className="w-3 h-3 rounded-full bg-green-100"></div>
<span className="text-xs text-primary-500">Disponible</span> <span className="text-xs text-primary-500">Available</span>
</div> </div>
</div> </div>
</CardContent> </CardContent>

View File

@@ -14,7 +14,7 @@ interface QuickAction {
const quickActions: QuickAction[] = [ const quickActions: QuickAction[] = [
{ {
label: "Nueva Reserva", label: "New Booking",
href: "/bookings", href: "/bookings",
icon: ( icon: (
<svg <svg
@@ -32,10 +32,10 @@ const quickActions: QuickAction[] = [
</svg> </svg>
), ),
color: "bg-blue-500 hover:bg-blue-600", color: "bg-blue-500 hover:bg-blue-600",
description: "Crear una nueva reserva de cancha", description: "Create a new court booking",
}, },
{ {
label: "Abrir Caja", label: "Open Register",
href: "/pos", href: "/pos",
icon: ( icon: (
<svg <svg
@@ -53,10 +53,10 @@ const quickActions: QuickAction[] = [
</svg> </svg>
), ),
color: "bg-green-500 hover:bg-green-600", color: "bg-green-500 hover:bg-green-600",
description: "Iniciar turno de caja registradora", description: "Start cash register shift",
}, },
{ {
label: "Nueva Venta", label: "New Sale",
href: "/pos", href: "/pos",
icon: ( icon: (
<svg <svg
@@ -74,10 +74,10 @@ const quickActions: QuickAction[] = [
</svg> </svg>
), ),
color: "bg-purple-500 hover:bg-purple-600", color: "bg-purple-500 hover:bg-purple-600",
description: "Registrar venta en el punto de venta", description: "Record a point of sale transaction",
}, },
{ {
label: "Registrar Cliente", label: "Register Player",
href: "/clients", href: "/clients",
icon: ( icon: (
<svg <svg
@@ -95,7 +95,7 @@ const quickActions: QuickAction[] = [
</svg> </svg>
), ),
color: "bg-orange-500 hover:bg-orange-600", color: "bg-orange-500 hover:bg-orange-600",
description: "Agregar un nuevo cliente al sistema", description: "Add a new player to the system",
}, },
]; ];
@@ -117,7 +117,7 @@ export function QuickActions() {
d="M13 10V3L4 14h7v7l9-11h-7z" d="M13 10V3L4 14h7v7l9-11h-7z"
/> />
</svg> </svg>
Acciones Rapidas Quick Actions
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>

View File

@@ -27,23 +27,23 @@ interface RecentBookingsProps {
const statusConfig: Record<string, { label: string; className: string }> = { const statusConfig: Record<string, { label: string; className: string }> = {
PENDING: { PENDING: {
label: "Pendiente", label: "Pending",
className: "bg-yellow-100 text-yellow-700", className: "bg-yellow-100 text-yellow-700",
}, },
CONFIRMED: { CONFIRMED: {
label: "Confirmada", label: "Confirmed",
className: "bg-blue-100 text-blue-700", className: "bg-blue-100 text-blue-700",
}, },
COMPLETED: { COMPLETED: {
label: "Completada", label: "Completed",
className: "bg-green-100 text-green-700", className: "bg-green-100 text-green-700",
}, },
CANCELLED: { CANCELLED: {
label: "Cancelada", label: "Cancelled",
className: "bg-red-100 text-red-700", className: "bg-red-100 text-red-700",
}, },
NO_SHOW: { NO_SHOW: {
label: "No asistio", label: "No Show",
className: "bg-gray-100 text-gray-700", className: "bg-gray-100 text-gray-700",
}, },
}; };
@@ -71,11 +71,11 @@ export function RecentBookings({ bookings, isLoading = false }: RecentBookingsPr
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/> />
</svg> </svg>
Reservas de Hoy Today's Bookings
</CardTitle> </CardTitle>
<Link href="/bookings"> <Link href="/bookings">
<Button variant="ghost" size="sm" className="text-sm"> <Button variant="ghost" size="sm" className="text-sm">
Ver todas View all
<svg <svg
className="w-4 h-4 ml-1" className="w-4 h-4 ml-1"
fill="none" fill="none"
@@ -109,7 +109,7 @@ export function RecentBookings({ bookings, isLoading = false }: RecentBookingsPr
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/> />
</svg> </svg>
<p className="text-sm">No hay reservas para hoy</p> <p className="text-sm">No bookings for today</p>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
@@ -139,7 +139,7 @@ export function RecentBookings({ bookings, isLoading = false }: RecentBookingsPr
{/* Details */} {/* Details */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-primary-800 truncate"> <p className="text-sm font-medium text-primary-800 truncate">
{booking.client?.name || "Sin cliente"} {booking.client?.name || "Walk-in"}
</p> </p>
<p className="text-xs text-primary-500 truncate"> <p className="text-xs text-primary-500 truncate">
{booking.court.name} {booking.court.name}

View File

@@ -98,7 +98,7 @@ export function StatCard({ title, value, icon, trend, color = "primary" }: StatC
{trend.isPositive ? "+" : ""} {trend.isPositive ? "+" : ""}
{trend.value}% {trend.value}%
</span> </span>
<span className="text-xs text-primary-400 ml-1">vs ayer</span> <span className="text-xs text-primary-400 ml-1">vs yesterday</span>
</div> </div>
)} )}
</div> </div>

View File

@@ -26,10 +26,10 @@ export function Header() {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="text-right"> <div className="text-right">
<p className="text-sm font-medium text-primary-800">{session?.user?.name || 'Usuario'}</p> <p className="text-sm font-medium text-primary-800">{session?.user?.name || 'User'}</p>
<p className="text-xs text-primary-500">{displayRole}</p> <p className="text-xs text-primary-500">{displayRole}</p>
</div> </div>
<Button variant="ghost" size="icon" onClick={handleLogout} title="Cerrar sesión"> <Button variant="ghost" size="icon" onClick={handleLogout} title="Log out">
<LogOut className="h-5 w-5" /> <LogOut className="h-5 w-5" />
</Button> </Button>
</div> </div>

View File

@@ -4,10 +4,8 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { import {
LayoutDashboard, LayoutDashboard,
Calendar, Radio,
Trophy, UserCircle,
ShoppingCart,
Users,
CreditCard, CreditCard,
BarChart3, BarChart3,
Settings, Settings,
@@ -22,13 +20,11 @@ interface NavItem {
const navItems: NavItem[] = [ const navItems: NavItem[] = [
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, { label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ label: 'Reservas', href: '/bookings', icon: Calendar }, { label: 'Live Courts', href: '/live', icon: Radio },
{ label: 'Torneos', href: '/tournaments', icon: Trophy }, { label: 'Clients', href: '/clients', icon: UserCircle },
{ label: 'Ventas', href: '/pos', icon: ShoppingCart }, { label: 'Memberships', href: '/memberships', icon: CreditCard },
{ label: 'Clientes', href: '/clients', icon: Users }, { label: 'Reports', href: '/reports', icon: BarChart3 },
{ label: 'Membresías', href: '/memberships', icon: CreditCard }, { label: 'Settings', href: '/settings', icon: Settings },
{ label: 'Reportes', href: '/reports', icon: BarChart3 },
{ label: 'Configuración', href: '/settings', icon: Settings },
]; ];
export function Sidebar() { export function Sidebar() {
@@ -38,10 +34,17 @@ export function Sidebar() {
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r border-primary-200 bg-white"> <aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r border-primary-200 bg-white">
{/* Logo Section */} {/* Logo Section */}
<div className="flex h-16 items-center gap-3 border-b border-primary-200 px-6"> <div className="flex h-16 items-center gap-3 border-b border-primary-200 px-6">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary text-white font-bold text-lg"> <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
P <svg viewBox="0 0 40 40" className="w-7 h-7 text-white" fill="none">
{/* Lightning bolt / smash icon */}
<path d="M22 4L8 22h10l-4 14L28 18H18l4-14z" fill="currentColor" />
{/* Impact spark */}
<circle cx="32" cy="8" r="2" fill="currentColor" opacity="0.8" />
<circle cx="35" cy="12" r="1.2" fill="currentColor" opacity="0.6" />
<circle cx="30" cy="4" r="1" fill="currentColor" opacity="0.5" />
</svg>
</div> </div>
<span className="text-xl font-semibold text-primary-800">Padel Pro</span> <span className="text-xl font-semibold text-primary-800">Cabo Pickleball</span>
</div> </div>
{/* Navigation */} {/* Navigation */}

View File

@@ -4,40 +4,16 @@ import { useState, useEffect, useRef } from 'react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { MapPin, ChevronDown, Check } from 'lucide-react'; import { MapPin, ChevronDown, Check } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useSite } from '@/contexts/site-context';
interface Site {
id: string;
name: string;
}
export function SiteSwitcher() { export function SiteSwitcher() {
const { data: session } = useSession(); const { data: session } = useSession();
const [sites, setSites] = useState<Site[]>([]); const { sites, selectedSiteId, selectedSite, setSelectedSiteId, isLoading } = useSite();
const [selectedSiteId, setSelectedSiteId] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const isSuperAdmin = session?.user?.role === 'SUPER_ADMIN'; const isSuperAdmin = session?.user?.role === 'SUPER_ADMIN';
useEffect(() => {
async function fetchSites() {
try {
const response = await fetch('/api/sites');
if (response.ok) {
const data = await response.json();
setSites(data.data || []);
}
} catch (error) {
console.error('Failed to fetch sites:', error);
} finally {
setIsLoading(false);
}
}
fetchSites();
}, []);
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
@@ -49,7 +25,6 @@ export function SiteSwitcher() {
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
const selectedSite = sites.find((site) => site.id === selectedSiteId);
const displayName = selectedSiteId ? selectedSite?.name : 'Todas las sedes'; const displayName = selectedSiteId ? selectedSite?.name : 'Todas las sedes';
// For non-SUPER_ADMIN users, just show their assigned site // For non-SUPER_ADMIN users, just show their assigned site

View File

@@ -57,7 +57,7 @@ export function PlanCard({ plan, onEdit, onDelete, isAdmin = false }: PlanCardPr
? "bg-accent-100 text-accent-700" ? "bg-accent-100 text-accent-700"
: "bg-primary-100 text-primary-600" : "bg-primary-100 text-primary-600"
)}> )}>
{plan.subscriberCount} {plan.subscriberCount === 1 ? "suscriptor" : "suscriptores"} {plan.subscriberCount} {plan.subscriberCount === 1 ? "subscriber" : "subscribers"}
</span> </span>
</div> </div>
{plan.description && ( {plan.description && (
@@ -72,7 +72,7 @@ export function PlanCard({ plan, onEdit, onDelete, isAdmin = false }: PlanCardPr
{formatCurrency(price)} {formatCurrency(price)}
</div> </div>
<div className="text-sm text-primary-500"> <div className="text-sm text-primary-500">
/{plan.durationMonths} {plan.durationMonths === 1 ? "mes" : "meses"} /{plan.durationMonths} {plan.durationMonths === 1 ? "month" : "months"}
</div> </div>
</div> </div>
@@ -87,8 +87,8 @@ export function PlanCard({ plan, onEdit, onDelete, isAdmin = false }: PlanCardPr
</svg> </svg>
</div> </div>
<div> <div>
<p className="font-medium text-primary-800">{freeHours} horas gratis</p> <p className="font-medium text-primary-800">{freeHours} free hours</p>
<p className="text-xs text-primary-500">de cancha al mes</p> <p className="text-xs text-primary-500">of court time per month</p>
</div> </div>
</div> </div>
)} )}
@@ -102,8 +102,8 @@ export function PlanCard({ plan, onEdit, onDelete, isAdmin = false }: PlanCardPr
</svg> </svg>
</div> </div>
<div> <div>
<p className="font-medium text-primary-800">{discountPercent}% descuento</p> <p className="font-medium text-primary-800">{discountPercent}% discount</p>
<p className="text-xs text-primary-500">en reservas adicionales</p> <p className="text-xs text-primary-500">on additional bookings</p>
</div> </div>
</div> </div>
)} )}
@@ -117,8 +117,8 @@ export function PlanCard({ plan, onEdit, onDelete, isAdmin = false }: PlanCardPr
</svg> </svg>
</div> </div>
<div> <div>
<p className="font-medium text-primary-800">{storeDiscount}% descuento</p> <p className="font-medium text-primary-800">{storeDiscount}% discount</p>
<p className="text-xs text-primary-500">en tienda</p> <p className="text-xs text-primary-500">in store</p>
</div> </div>
</div> </div>
)} )}
@@ -126,7 +126,7 @@ export function PlanCard({ plan, onEdit, onDelete, isAdmin = false }: PlanCardPr
{/* Other Benefits */} {/* Other Benefits */}
{otherBenefits.length > 0 && ( {otherBenefits.length > 0 && (
<div className="pt-2 border-t border-primary-100"> <div className="pt-2 border-t border-primary-100">
<p className="text-xs font-medium text-primary-600 mb-2">Beneficios adicionales:</p> <p className="text-xs font-medium text-primary-600 mb-2">Additional benefits:</p>
<ul className="space-y-1"> <ul className="space-y-1">
{otherBenefits.map((benefit, index) => ( {otherBenefits.map((benefit, index) => (
<li key={index} className="flex items-start gap-2 text-sm text-primary-700"> <li key={index} className="flex items-start gap-2 text-sm text-primary-700">
@@ -153,7 +153,7 @@ export function PlanCard({ plan, onEdit, onDelete, isAdmin = false }: PlanCardPr
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg> </svg>
Editar Edit
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -164,7 +164,7 @@ export function PlanCard({ plan, onEdit, onDelete, isAdmin = false }: PlanCardPr
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg> </svg>
Eliminar Delete
</Button> </Button>
</CardFooter> </CardFooter>
)} )}

View File

@@ -41,10 +41,10 @@ interface PlanFormProps {
} }
const durationOptions = [ const durationOptions = [
{ value: 1, label: "1 mes" }, { value: 1, label: "1 month" },
{ value: 3, label: "3 meses" }, { value: 3, label: "3 months" },
{ value: 6, label: "6 meses" }, { value: 6, label: "6 months" },
{ value: 12, label: "12 meses" }, { value: 12, label: "12 months" },
]; ];
export function PlanForm({ export function PlanForm({
@@ -107,19 +107,19 @@ export function PlanForm({
const newErrors: Record<string, string> = {}; const newErrors: Record<string, string> = {};
if (!formData.name.trim()) { if (!formData.name.trim()) {
newErrors.name = "El nombre es requerido"; newErrors.name = "Name is required";
} }
if (formData.price <= 0) { if (formData.price <= 0) {
newErrors.price = "El precio debe ser mayor a 0"; newErrors.price = "Price must be greater than 0";
} }
if (formData.bookingDiscount < 0 || formData.bookingDiscount > 100) { if (formData.bookingDiscount < 0 || formData.bookingDiscount > 100) {
newErrors.bookingDiscount = "El descuento debe estar entre 0 y 100"; newErrors.bookingDiscount = "Discount must be between 0 and 100";
} }
if (formData.storeDiscount < 0 || formData.storeDiscount > 100) { if (formData.storeDiscount < 0 || formData.storeDiscount > 100) {
newErrors.storeDiscount = "El descuento debe estar entre 0 y 100"; newErrors.storeDiscount = "Discount must be between 0 and 100";
} }
if (formData.freeHours < 0) { if (formData.freeHours < 0) {
newErrors.freeHours = "Las horas gratis no pueden ser negativas"; newErrors.freeHours = "Free hours cannot be negative";
} }
setErrors(newErrors); setErrors(newErrors);
@@ -137,20 +137,20 @@ export function PlanForm({
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
{mode === "create" ? "Nuevo Plan de Membresia" : "Editar Plan"} {mode === "create" ? "New Membership Plan" : "Edit Plan"}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Name */} {/* Name */}
<div> <div>
<label className="block text-sm font-medium text-primary-700 mb-1"> <label className="block text-sm font-medium text-primary-700 mb-1">
Nombre del Plan * Plan Name *
</label> </label>
<Input <Input
name="name" name="name"
value={formData.name} value={formData.name}
onChange={handleChange} onChange={handleChange}
placeholder="Ej: Plan Premium" placeholder="E.g.: Premium Plan"
className={errors.name ? "border-red-500" : ""} className={errors.name ? "border-red-500" : ""}
/> />
{errors.name && ( {errors.name && (
@@ -161,13 +161,13 @@ export function PlanForm({
{/* Description */} {/* Description */}
<div> <div>
<label className="block text-sm font-medium text-primary-700 mb-1"> <label className="block text-sm font-medium text-primary-700 mb-1">
Descripcion Description
</label> </label>
<textarea <textarea
name="description" name="description"
value={formData.description} value={formData.description}
onChange={handleChange} onChange={handleChange}
placeholder="Descripcion del plan..." placeholder="Plan description..."
rows={2} rows={2}
className="flex w-full rounded-md border border-primary-200 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-primary-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2" className="flex w-full rounded-md border border-primary-200 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-primary-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
/> />
@@ -177,7 +177,7 @@ export function PlanForm({
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-primary-700 mb-1"> <label className="block text-sm font-medium text-primary-700 mb-1">
Precio * Price *
</label> </label>
<Input <Input
type="number" type="number"
@@ -194,7 +194,7 @@ export function PlanForm({
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-primary-700 mb-1"> <label className="block text-sm font-medium text-primary-700 mb-1">
Duracion Duration
</label> </label>
<select <select
name="durationMonths" name="durationMonths"
@@ -213,12 +213,12 @@ export function PlanForm({
{/* Benefits Section */} {/* Benefits Section */}
<div className="border-t border-primary-200 pt-4 mt-4"> <div className="border-t border-primary-200 pt-4 mt-4">
<h4 className="text-sm font-semibold text-primary-800 mb-3">Beneficios</h4> <h4 className="text-sm font-semibold text-primary-800 mb-3">Benefits</h4>
{/* Free Hours */} {/* Free Hours */}
<div className="mb-4"> <div className="mb-4">
<label className="block text-sm font-medium text-primary-700 mb-1"> <label className="block text-sm font-medium text-primary-700 mb-1">
Horas Gratis de Cancha (por mes) Free Court Hours (per month)
</label> </label>
<Input <Input
type="number" type="number"
@@ -238,7 +238,7 @@ export function PlanForm({
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div> <div>
<label className="block text-sm font-medium text-primary-700 mb-1"> <label className="block text-sm font-medium text-primary-700 mb-1">
Descuento en Reservas (%) Booking Discount (%)
</label> </label>
<Input <Input
type="number" type="number"
@@ -255,7 +255,7 @@ export function PlanForm({
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-primary-700 mb-1"> <label className="block text-sm font-medium text-primary-700 mb-1">
Descuento en Tienda (%) Store Discount (%)
</label> </label>
<Input <Input
type="number" type="number"
@@ -275,36 +275,36 @@ export function PlanForm({
{/* Extra Benefits */} {/* Extra Benefits */}
<div> <div>
<label className="block text-sm font-medium text-primary-700 mb-1"> <label className="block text-sm font-medium text-primary-700 mb-1">
Beneficios Adicionales Additional Benefits
</label> </label>
<textarea <textarea
name="extraBenefits" name="extraBenefits"
value={formData.extraBenefits} value={formData.extraBenefits}
onChange={handleChange} onChange={handleChange}
placeholder="Un beneficio por linea&#10;Ej: Acceso a vestidores VIP&#10;Invitacion a eventos exclusivos" placeholder="One benefit per line&#10;E.g.: Access to VIP locker rooms&#10;Invitation to exclusive events"
rows={4} rows={4}
className="flex w-full rounded-md border border-primary-200 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-primary-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2" className="flex w-full rounded-md border border-primary-200 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-primary-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
/> />
<p className="text-xs text-primary-500 mt-1"> <p className="text-xs text-primary-500 mt-1">
Escribe un beneficio por linea Write one benefit per line
</p> </p>
</div> </div>
</div> </div>
</CardContent> </CardContent>
<CardFooter className="flex justify-end gap-3 border-t border-primary-200 bg-primary-50 pt-4"> <CardFooter className="flex justify-end gap-3 border-t border-primary-200 bg-primary-50 pt-4">
<Button type="button" variant="outline" onClick={onCancel}> <Button type="button" variant="outline" onClick={onCancel}>
Cancelar Cancel
</Button> </Button>
<Button type="submit" disabled={isLoading}> <Button type="submit" disabled={isLoading}>
{isLoading ? ( {isLoading ? (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" /> <div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
Guardando... Saving...
</span> </span>
) : mode === "create" ? ( ) : mode === "create" ? (
"Crear Plan" "Create Plan"
) : ( ) : (
"Guardar Cambios" "Save Changes"
)} )}
</Button> </Button>
</CardFooter> </CardFooter>

View File

@@ -0,0 +1,54 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-lg bg-primary-100 p-1 text-primary-600",
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-primary-800 data-[state=active]:shadow-sm",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-4 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2",
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,66 @@
"use client";
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
interface Site {
id: string;
name: string;
}
interface SiteContextType {
sites: Site[];
selectedSiteId: string | null;
selectedSite: Site | null;
setSelectedSiteId: (siteId: string | null) => void;
isLoading: boolean;
}
const SiteContext = createContext<SiteContextType | undefined>(undefined);
export function SiteProvider({ children }: { children: ReactNode }) {
const [sites, setSites] = useState<Site[]>([]);
const [selectedSiteId, setSelectedSiteId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
async function fetchSites() {
try {
const response = await fetch("/api/sites");
if (response.ok) {
const data = await response.json();
setSites(data.data || []);
}
} catch (error) {
console.error("Failed to fetch sites:", error);
} finally {
setIsLoading(false);
}
}
fetchSites();
}, []);
const selectedSite = sites.find((site) => site.id === selectedSiteId) || null;
return (
<SiteContext.Provider
value={{
sites,
selectedSiteId,
selectedSite,
setSelectedSiteId,
isLoading,
}}
>
{children}
</SiteContext.Provider>
);
}
export function useSite() {
const context = useContext(SiteContext);
if (context === undefined) {
throw new Error("useSite must be used within a SiteProvider");
}
return context;
}

5
apps/web/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@@ -1,6 +1,7 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
transpilePackages: ["@padel-pro/shared"], output: "standalone",
transpilePackages: ["@smashpoint/shared"],
images: { images: {
remotePatterns: [ remotePatterns: [
{ protocol: "https", hostname: "res.cloudinary.com" }, { protocol: "https", hostname: "res.cloudinary.com" },

View File

@@ -1,11 +1,11 @@
{ {
"name": "@padel-pro/web", "name": "@smashpoint/web",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@padel-pro/web", "name": "@smashpoint/web",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@prisma/client": "^5.10.0", "@prisma/client": "^5.10.0",

View File

@@ -1,5 +1,5 @@
{ {
"name": "@padel-pro/web", "name": "@smashpoint/web",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -14,7 +14,7 @@
"db:seed": "tsx prisma/seed.ts" "db:seed": "tsx prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@padel-pro/shared": "*", "@smashpoint/shared": "*",
"@prisma/client": "^5.10.0", "@prisma/client": "^5.10.0",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.0.6",

View File

@@ -144,13 +144,15 @@ model Court {
description String? description String?
features String[] @default([]) features String[] @default([])
displayOrder Int @default(0) displayOrder Int @default(0)
isOpenPlay Boolean @default(false)
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade) site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
bookings Booking[] bookings Booking[]
matches Match[] matches Match[]
sessions CourtSession[]
@@index([siteId]) @@index([siteId])
@@index([status]) @@index([status])
@@ -215,6 +217,7 @@ model Client {
payments Payment[] payments Payment[]
sales Sale[] sales Sale[]
inscriptions TournamentInscription[] inscriptions TournamentInscription[]
courtSessions CourtSession[]
@@unique([organizationId, email]) @@unique([organizationId, email])
@@unique([organizationId, dni]) @@unique([organizationId, dni])
@@ -544,3 +547,28 @@ model Match {
@@index([round, position]) @@index([round, position])
@@index([scheduledAt]) @@index([scheduledAt])
} }
// =============================================================================
// COURT SESSIONS (Live Player Tracking)
// =============================================================================
model CourtSession {
id String @id @default(cuid())
courtId String
clientId String?
walkInName String?
startTime DateTime @default(now())
endTime DateTime?
isActive Boolean @default(true)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
court Court @relation(fields: [courtId], references: [id], onDelete: Cascade)
client Client? @relation(fields: [clientId], references: [id], onDelete: SetNull)
@@index([courtId])
@@index([clientId])
@@index([isActive])
@@index([startTime])
}

View File

@@ -40,12 +40,12 @@ async function main() {
const organization = await prisma.organization.create({ const organization = await prisma.organization.create({
data: { data: {
name: 'Padel Pro Demo', name: 'Cabo Pickleball Club',
slug: 'padel-pro-demo', slug: 'cabo-pickleball-club',
settings: { settings: {
currency: 'MXN', currency: 'MXN',
timezone: 'America/Mexico_City', timezone: 'America/Mazatlan',
language: 'es', language: 'en',
}, },
}, },
}); });
@@ -56,39 +56,19 @@ async function main() {
// ============================================================================= // =============================================================================
// SITES // SITES
// ============================================================================= // =============================================================================
console.log('Creating sites...'); console.log('Creating site...');
const sitesData = [ const sitesData = [
{ {
name: 'Sede Norte', name: 'Corridor Courts',
slug: 'sede-norte', slug: 'corridor-courts',
address: 'Av. Universidad 1000, Col. Del Valle', address: 'Corridor area, Cabo San Lucas, BCS',
phone: '+52 55 1234 5678', phone: '+52-624-151-5455',
email: 'norte@padelpro.com', email: 'topdogcabo@yahoo.com',
timezone: 'America/Mexico_City', timezone: 'America/Mazatlan',
openTime: '07:00', openTime: '07:00',
closeTime: '23:00',
},
{
name: 'Sede Sur',
slug: 'sede-sur',
address: 'Av. Insurgentes 2000, Col. Roma',
phone: '+52 55 2345 6789',
email: 'sur@padelpro.com',
timezone: 'America/Mexico_City',
openTime: '08:00',
closeTime: '22:00', closeTime: '22:00',
}, },
{
name: 'Sede Centro',
slug: 'sede-centro',
address: 'Calle Reforma 500, Centro Historico',
phone: '+52 55 3456 7890',
email: 'centro@padelpro.com',
timezone: 'America/Mexico_City',
openTime: '06:00',
closeTime: '24:00',
},
]; ];
const sites = await Promise.all( const sites = await Promise.all(
@@ -107,44 +87,28 @@ async function main() {
console.log(''); console.log('');
// ============================================================================= // =============================================================================
// COURTS (2 per site) // COURTS (6 outdoor courts)
// ============================================================================= // =============================================================================
console.log('Creating courts...'); console.log('Creating courts...');
const courts: { id: string; name: string; siteId: string }[] = []; const courts: { id: string; name: string; siteId: string }[] = [];
for (const site of sites) { for (let i = 1; i <= 6; i++) {
const courtData = [ const created = await prisma.court.create({
{ data: {
name: 'Cancha 1', siteId: sites[0].id,
type: CourtType.INDOOR, name: `Court ${i}`,
type: CourtType.OUTDOOR,
status: CourtStatus.AVAILABLE, status: CourtStatus.AVAILABLE,
pricePerHour: 350, pricePerHour: 300,
description: 'Cancha techada con iluminacion LED', isOpenPlay: i >= 5,
features: ['Iluminacion LED', 'Techada', 'Cristal panoramico'], description: 'Outdoor court with night lighting',
displayOrder: 1, features: ['Night lighting', 'Court dividers'],
displayOrder: i,
}, },
{ });
name: 'Cancha 2', courts.push(created);
type: CourtType.INDOOR, console.log(` Created court: ${created.name}`);
status: CourtStatus.AVAILABLE,
pricePerHour: 450,
description: 'Cancha premium con aire acondicionado',
features: ['Iluminacion LED', 'Techada', 'Aire acondicionado', 'Cristal panoramico', 'Premium'],
displayOrder: 2,
},
];
for (const court of courtData) {
const created = await prisma.court.create({
data: {
siteId: site.id,
...court,
},
});
courts.push(created);
console.log(` Created court: ${site.name} - ${created.name}`);
}
} }
console.log(''); console.log('');
@@ -152,17 +116,17 @@ async function main() {
// ============================================================================= // =============================================================================
// ADMIN USER (SUPER_ADMIN) // ADMIN USER (SUPER_ADMIN)
// ============================================================================= // =============================================================================
console.log('Creating admin users...'); console.log('Creating admin user...');
const hashedPassword = await bcrypt.hash('admin123', 10); const hashedPassword = await bcrypt.hash('Aasi940812', 10);
const adminUser = await prisma.user.create({ const adminUser = await prisma.user.create({
data: { data: {
organizationId: organization.id, organizationId: organization.id,
email: 'admin@padelpro.com', email: 'ivan@horuxfin.com',
password: hashedPassword, password: hashedPassword,
firstName: 'Administrador', firstName: 'Ivan',
lastName: 'Sistema', lastName: 'Admin',
role: UserRole.SUPER_ADMIN, role: UserRole.SUPER_ADMIN,
phone: '+52 55 9999 0000', phone: '+52 55 9999 0000',
siteIds: sites.map(s => s.id), siteIds: sites.map(s => s.id),
@@ -171,41 +135,6 @@ async function main() {
console.log(` Created super admin: ${adminUser.email}`); console.log(` Created super admin: ${adminUser.email}`);
// =============================================================================
// SITE ADMINS (one per site)
// =============================================================================
const siteAdminsData = [
{ email: 'norte@padelpro.com', firstName: 'Carlos', lastName: 'Rodriguez', site: sites[0] },
{ email: 'sur@padelpro.com', firstName: 'Maria', lastName: 'Gonzalez', site: sites[1] },
{ email: 'centro@padelpro.com', firstName: 'Luis', lastName: 'Hernandez', site: sites[2] },
];
for (const adminData of siteAdminsData) {
const siteAdmin = await prisma.user.create({
data: {
organizationId: organization.id,
email: adminData.email,
password: hashedPassword,
firstName: adminData.firstName,
lastName: adminData.lastName,
role: UserRole.SITE_ADMIN,
siteIds: [adminData.site.id],
},
});
// Connect user to site
await prisma.site.update({
where: { id: adminData.site.id },
data: {
users: {
connect: { id: siteAdmin.id },
},
},
});
console.log(` Created site admin: ${siteAdmin.email} (${adminData.site.name})`);
}
console.log(''); console.log('');
// ============================================================================= // =============================================================================
@@ -214,10 +143,10 @@ async function main() {
console.log('Creating product categories...'); console.log('Creating product categories...');
const categoriesData = [ const categoriesData = [
{ name: 'Bebidas', description: 'Bebidas y refrescos', displayOrder: 1 }, { name: 'Drinks', description: 'Beverages and refreshments', displayOrder: 1 },
{ name: 'Snacks', description: 'Botanas y snacks', displayOrder: 2 }, { name: 'Snacks', description: 'Snacks and light food', displayOrder: 2 },
{ name: 'Equipamiento', description: 'Equipo y accesorios de padel', displayOrder: 3 }, { name: 'Equipment', description: 'Pickleball equipment and accessories', displayOrder: 3 },
{ name: 'Alquiler', description: 'Articulos en renta', displayOrder: 4 }, { name: 'Rental', description: 'Rental items', displayOrder: 4 },
]; ];
const categories: { id: string; name: string }[] = []; const categories: { id: string; name: string }[] = [];
@@ -236,28 +165,24 @@ async function main() {
console.log(''); console.log('');
// ============================================================================= // =============================================================================
// PRODUCTS (for organization, shown in Sede Norte initially) // PRODUCTS
// ============================================================================= // =============================================================================
console.log('Creating products...'); console.log('Creating products...');
const bebidasCategory = categories.find(c => c.name === 'Bebidas'); const drinksCategory = categories.find(c => c.name === 'Drinks');
const snacksCategory = categories.find(c => c.name === 'Snacks'); const snacksCategory = categories.find(c => c.name === 'Snacks');
const equipamientoCategory = categories.find(c => c.name === 'Equipamiento'); const equipmentCategory = categories.find(c => c.name === 'Equipment');
const alquilerCategory = categories.find(c => c.name === 'Alquiler'); const rentalCategory = categories.find(c => c.name === 'Rental');
const productsData = [ const productsData = [
// Bebidas { name: 'Water', description: 'Natural water 600ml', price: 20, costPrice: 8, stock: 100, categoryId: drinksCategory?.id, sku: 'DRK-001' },
{ name: 'Agua', description: 'Agua natural 600ml', price: 20, costPrice: 8, stock: 100, categoryId: bebidasCategory?.id, sku: 'BEB-001' }, { name: 'Gatorade', description: 'Sports drink 500ml', price: 35, costPrice: 18, stock: 50, categoryId: drinksCategory?.id, sku: 'DRK-002' },
{ name: 'Gatorade', description: 'Bebida deportiva 500ml', price: 35, costPrice: 18, stock: 50, categoryId: bebidasCategory?.id, sku: 'BEB-002' }, { name: 'Beer', description: 'Craft beer 355ml', price: 45, costPrice: 22, stock: 48, categoryId: drinksCategory?.id, sku: 'DRK-003' },
{ name: 'Cerveza', description: 'Cerveza artesanal 355ml', price: 45, costPrice: 22, stock: 48, categoryId: bebidasCategory?.id, sku: 'BEB-003' }, { name: 'Chips', description: 'Potato chips 45g', price: 25, costPrice: 12, stock: 30, categoryId: snacksCategory?.id, sku: 'SNK-001' },
// Snacks { name: 'Energy Bar', description: 'Protein bar 50g', price: 30, costPrice: 15, stock: 25, categoryId: snacksCategory?.id, sku: 'SNK-002' },
{ name: 'Papas', description: 'Papas fritas 45g', price: 25, costPrice: 12, stock: 30, categoryId: snacksCategory?.id, sku: 'SNK-001' }, { name: 'Pickleballs', description: 'Franklin X-40 Outdoor (3 pack)', price: 180, costPrice: 90, stock: 20, categoryId: equipmentCategory?.id, sku: 'EQP-001' },
{ name: 'Barra energetica', description: 'Barra de proteina 50g', price: 30, costPrice: 15, stock: 25, categoryId: snacksCategory?.id, sku: 'SNK-002' }, { name: 'Paddle Grip', description: 'Replacement grip', price: 50, costPrice: 25, stock: 40, categoryId: equipmentCategory?.id, sku: 'EQP-002' },
// Equipamiento { name: 'Paddle Rental', description: 'Pickleball paddle rental (per session)', price: 100, costPrice: 0, stock: 10, categoryId: rentalCategory?.id, sku: 'RNT-001', trackStock: false },
{ name: 'Pelotas HEAD', description: 'Tubo de 3 pelotas HEAD Pro', price: 180, costPrice: 90, stock: 20, categoryId: equipamientoCategory?.id, sku: 'EQP-001' },
{ name: 'Grip', description: 'Overgrip Wilson Pro', price: 50, costPrice: 25, stock: 40, categoryId: equipamientoCategory?.id, sku: 'EQP-002' },
// Alquiler
{ name: 'Raqueta alquiler', description: 'Raqueta de padel (por hora)', price: 100, costPrice: 0, stock: 10, categoryId: alquilerCategory?.id, sku: 'ALQ-001', trackStock: false },
]; ];
for (const productData of productsData) { for (const productData of productsData) {
@@ -279,31 +204,49 @@ async function main() {
const membershipPlansData = [ const membershipPlansData = [
{ {
name: 'Basico', name: 'Day Pass',
description: 'Plan basico mensual con beneficios esenciales', description: 'Single day access to all courts',
price: 499, price: 300,
durationMonths: 1, durationMonths: 1,
courtHours: 2, courtHours: 0,
discountPercent: 10, discountPercent: 0,
benefits: ['2 horas gratis de cancha al mes', '10% descuento en reservas', '5% descuento en tienda'], benefits: ['Full day access', 'All courts', 'Night play included'],
}, },
{ {
name: 'Premium', name: '10-Day Pass',
description: 'Plan premium con mayores beneficios', description: '10 visits, any time of day',
price: 899, price: 2500,
durationMonths: 1, durationMonths: 3,
courtHours: 5,
discountPercent: 20,
benefits: ['5 horas gratis de cancha al mes', '20% descuento en reservas', '10% descuento en tienda', 'Acceso prioritario a torneos'],
},
{
name: 'VIP',
description: 'Plan VIP con todos los beneficios',
price: 1499,
durationMonths: 1,
courtHours: 10, courtHours: 10,
discountPercent: 15,
benefits: ['10 day passes', 'Valid any time', 'Save vs single day pass'],
},
{
name: '10-Morning Pass',
description: '10 morning sessions (7am-12pm)',
price: 2000,
durationMonths: 3,
courtHours: 10,
discountPercent: 10,
benefits: ['10 morning passes', '7:00 AM - 12:00 PM only', 'Best value for morning players'],
},
{
name: 'Monthly Individual',
description: 'Unlimited monthly access for one player',
price: 4000,
durationMonths: 1,
courtHours: 30,
discountPercent: 25,
benefits: ['Unlimited court access', 'Priority booking', 'All time slots'],
},
{
name: 'Monthly Family',
description: 'Unlimited monthly access for up to 4 family members',
price: 6500,
durationMonths: 1,
courtHours: 60,
discountPercent: 30, discountPercent: 30,
benefits: ['10 horas gratis de cancha al mes', '30% descuento en reservas', '15% descuento en tienda', 'Acceso prioritario a torneos', 'Invitados con descuento', 'Casillero incluido'], benefits: ['Up to 4 family members', 'Unlimited court access', 'Priority booking', 'All time slots'],
}, },
]; ];
@@ -317,7 +260,7 @@ async function main() {
}, },
}); });
membershipPlans.push(plan); membershipPlans.push(plan);
console.log(` Created membership plan: ${plan.name} - $${plan.price}/mes`); console.log(` Created membership plan: ${plan.name} - $${plan.price}`);
} }
console.log(''); console.log('');
@@ -386,31 +329,31 @@ async function main() {
console.log(''); console.log('');
// ============================================================================= // =============================================================================
// MEMBERSHIP FOR ONE CLIENT (Maria Garcia with Premium) // MEMBERSHIP FOR ONE CLIENT (Maria Garcia with Monthly Individual)
// ============================================================================= // =============================================================================
console.log('Creating sample membership...'); console.log('Creating sample membership...');
const premiumPlan = membershipPlans.find(p => p.name === 'Premium'); const monthlyPlan = membershipPlans.find(p => p.name === 'Monthly Individual');
const mariaClient = clients.find(c => c.firstName === 'Maria'); const mariaClient = clients.find(c => c.firstName === 'Maria');
if (premiumPlan && mariaClient) { if (monthlyPlan && mariaClient) {
const startDate = new Date(); const startDate = new Date();
const endDate = new Date(); const endDate = new Date();
endDate.setMonth(endDate.getMonth() + 1); endDate.setMonth(endDate.getMonth() + 1);
const membership = await prisma.membership.create({ const membership = await prisma.membership.create({
data: { data: {
planId: premiumPlan.id, planId: monthlyPlan.id,
clientId: mariaClient.id, clientId: mariaClient.id,
startDate, startDate,
endDate, endDate,
status: MembershipStatus.ACTIVE, status: MembershipStatus.ACTIVE,
remainingHours: premiumPlan.courtHours, remainingHours: monthlyPlan.courtHours,
autoRenew: true, autoRenew: true,
}, },
}); });
console.log(` Created Premium membership for ${mariaClient.firstName} ${mariaClient.lastName}`); console.log(` Created Monthly Individual membership for ${mariaClient.firstName} ${mariaClient.lastName}`);
} }
console.log(''); console.log('');
@@ -424,9 +367,9 @@ async function main() {
console.log(''); console.log('');
console.log('Summary:'); console.log('Summary:');
console.log(` - 1 Organization: ${organization.name}`); console.log(` - 1 Organization: ${organization.name}`);
console.log(` - ${sites.length} Sites`); console.log(` - ${sites.length} Site`);
console.log(` - ${courts.length} Courts (${courts.length / sites.length} per site)`); console.log(` - ${courts.length} Courts`);
console.log(` - 4 Users (1 super admin + 3 site admins)`); console.log(` - 1 Admin user`);
console.log(` - ${categories.length} Product Categories`); console.log(` - ${categories.length} Product Categories`);
console.log(` - ${productsData.length} Products`); console.log(` - ${productsData.length} Products`);
console.log(` - ${membershipPlans.length} Membership Plans`); console.log(` - ${membershipPlans.length} Membership Plans`);
@@ -434,8 +377,7 @@ async function main() {
console.log(` - 1 Active Membership`); console.log(` - 1 Active Membership`);
console.log(''); console.log('');
console.log('Login credentials:'); console.log('Login credentials:');
console.log(' Super Admin: admin@padelpro.com / admin123'); console.log(' Admin: ivan@horuxfin.com / Aasi940812');
console.log(' Site Admins: norte@padelpro.com, sur@padelpro.com, centro@padelpro.com / admin123');
console.log(''); console.log('');
} }

0
apps/web/public/.gitkeep Normal file
View File

BIN
apps/web/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -10,30 +10,30 @@ const config: Config = {
extend: { extend: {
colors: { colors: {
primary: { primary: {
50: "#E6EBF2", 50: "#E8F4FD",
100: "#C2D1E3", 100: "#C5E3FA",
200: "#9BB4D1", 200: "#9DCEF6",
300: "#7497BF", 300: "#75B9F2",
400: "#5781B2", 400: "#4DA4EE",
500: "#3A6BA5", 500: "#2990EA",
600: "#2E5A8E", 600: "#2177C8",
700: "#244977", 700: "#195DA6",
800: "#1E3A5F", 800: "#124484",
900: "#152A47", 900: "#0B2B62",
DEFAULT: "#1E3A5F", DEFAULT: "#2990EA",
}, },
accent: { accent: {
50: "#EEFBF3", 50: "#FEF7EC",
100: "#D4F5E0", 100: "#FDEACC",
200: "#A9EBBC", 200: "#FBD89D",
300: "#7EE19A", 300: "#F9C66E",
400: "#53D778", 400: "#F7B43F",
500: "#22C55E", 500: "#F59E0B",
600: "#1CA04C", 600: "#D48509",
700: "#167A3A", 700: "#A36807",
800: "#105528", 800: "#724A05",
900: "#0A2F16", 900: "#412B03",
DEFAULT: "#22C55E", DEFAULT: "#F59E0B",
}, },
}, },
fontFamily: { fontFamily: {

View File

@@ -4,38 +4,38 @@ services:
# Base de datos PostgreSQL # Base de datos PostgreSQL
db: db:
image: postgres:16-alpine image: postgres:16-alpine
container_name: padel-pro-db container_name: smashpoint-db
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: ${POSTGRES_USER:-padel_user} POSTGRES_USER: ${POSTGRES_USER:-padel_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-padel_password} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-padel_password}
POSTGRES_DB: ${POSTGRES_DB:-padel_pro} POSTGRES_DB: ${POSTGRES_DB:-smashpoint_db}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql:ro - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql:ro
ports: ports:
- "5432:5432" - "5432:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-padel_user} -d ${POSTGRES_DB:-padel_pro}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-padel_user} -d ${POSTGRES_DB:-smashpoint_db}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
networks: networks:
- padel-network - smashpoint-network
# Aplicacion Web Next.js # Aplicacion Web Next.js
web: web:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: padel-pro-web container_name: smashpoint-web
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
environment: environment:
NODE_ENV: production NODE_ENV: production
DATABASE_URL: postgresql://${POSTGRES_USER:-padel_user}:${POSTGRES_PASSWORD:-padel_password}@db:5432/${POSTGRES_DB:-padel_pro}?schema=public DATABASE_URL: postgresql://${POSTGRES_USER:-padel_user}:${POSTGRES_PASSWORD:-padel_password}@db:5432/${POSTGRES_DB:-smashpoint_db}?schema=public
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000} NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000} NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
@@ -50,10 +50,10 @@ services:
retries: 3 retries: 3
start_period: 40s start_period: 40s
networks: networks:
- padel-network - smashpoint-network
networks: networks:
padel-network: smashpoint-network:
driver: bridge driver: bridge
volumes: volumes:

View File

@@ -1,6 +1,6 @@
# API Reference - Padel Pro # API Reference - SmashPoint
Documentacion completa de los endpoints REST de la API de Padel Pro. Documentacion completa de los endpoints REST de la API de SmashPoint.
## Informacion General ## Informacion General

View File

@@ -1,6 +1,6 @@
# Guia de Despliegue - Padel Pro # Guia de Despliegue - SmashPoint
Esta guia cubre el proceso completo para desplegar Padel Pro en un entorno de produccion. Esta guia cubre el proceso completo para desplegar SmashPoint en un entorno de produccion.
## Tabla de Contenidos ## Tabla de Contenidos
@@ -70,8 +70,8 @@ pnpm --version # 8.x.x
```bash ```bash
cd /var/www cd /var/www
git clone https://github.com/tu-organizacion/padel-pro.git git clone https://github.com/tu-organizacion/smashpoint.git
cd padel-pro cd smashpoint
``` ```
### 4. Configurar Variables de Entorno ### 4. Configurar Variables de Entorno
@@ -84,7 +84,7 @@ Editar el archivo `.env`:
```env ```env
# Base de datos - Produccion # Base de datos - Produccion
DATABASE_URL="postgresql://padel_user:PASSWORD_SEGURO@localhost:5432/padel_pro?schema=public" DATABASE_URL="postgresql://smashpoint_user:PASSWORD_SEGURO@localhost:5432/smashpoint_db?schema=public"
# NextAuth - IMPORTANTE: Generar clave unica # NextAuth - IMPORTANTE: Generar clave unica
NEXTAUTH_SECRET="$(openssl rand -base64 32)" NEXTAUTH_SECRET="$(openssl rand -base64 32)"
@@ -135,13 +135,13 @@ sudo -u postgres psql
```sql ```sql
-- Crear usuario -- Crear usuario
CREATE USER padel_user WITH PASSWORD 'PASSWORD_SEGURO'; CREATE USER smashpoint_user WITH PASSWORD 'PASSWORD_SEGURO';
-- Crear base de datos -- Crear base de datos
CREATE DATABASE padel_pro OWNER padel_user; CREATE DATABASE smashpoint_db OWNER smashpoint_user;
-- Otorgar permisos -- Otorgar permisos
GRANT ALL PRIVILEGES ON DATABASE padel_pro TO padel_user; GRANT ALL PRIVILEGES ON DATABASE smashpoint_db TO smashpoint_user;
-- Salir -- Salir
\q \q
@@ -159,10 +159,10 @@ Editar `/etc/postgresql/16/main/pg_hba.conf`:
```conf ```conf
# Conexion local # Conexion local
local all padel_user md5 local all smashpoint_user md5
# Conexion remota (si es necesario) # Conexion remota (si es necesario)
host padel_pro padel_user 192.168.1.0/24 md5 host smashpoint_db smashpoint_user 192.168.1.0/24 md5
``` ```
Reiniciar PostgreSQL: Reiniciar PostgreSQL:
@@ -174,7 +174,7 @@ sudo systemctl restart postgresql
### 4. Ejecutar Migraciones ### 4. Ejecutar Migraciones
```bash ```bash
cd /var/www/padel-pro cd /var/www/smashpoint
pnpm db:generate pnpm db:generate
pnpm db:push pnpm db:push
``` ```
@@ -204,8 +204,8 @@ Crear `ecosystem.config.js` en la raiz del proyecto:
module.exports = { module.exports = {
apps: [ apps: [
{ {
name: 'padel-pro', name: 'smashpoint',
cwd: '/var/www/padel-pro/apps/web', cwd: '/var/www/smashpoint/apps/web',
script: 'node_modules/next/dist/bin/next', script: 'node_modules/next/dist/bin/next',
args: 'start', args: 'start',
instances: 'max', instances: 'max',
@@ -220,8 +220,8 @@ module.exports = {
}, },
// Configuracion de logs // Configuracion de logs
log_date_format: 'YYYY-MM-DD HH:mm:ss Z', log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
error_file: '/var/log/padel-pro/error.log', error_file: '/var/log/smashpoint/error.log',
out_file: '/var/log/padel-pro/out.log', out_file: '/var/log/smashpoint/out.log',
merge_logs: true, merge_logs: true,
// Reinicio automatico // Reinicio automatico
max_memory_restart: '1G', max_memory_restart: '1G',
@@ -234,8 +234,8 @@ module.exports = {
### 3. Crear Directorio de Logs ### 3. Crear Directorio de Logs
```bash ```bash
sudo mkdir -p /var/log/padel-pro sudo mkdir -p /var/log/smashpoint
sudo chown $USER:$USER /var/log/padel-pro sudo chown $USER:$USER /var/log/smashpoint
``` ```
### 4. Iniciar la Aplicacion ### 4. Iniciar la Aplicacion
@@ -258,16 +258,16 @@ pm2 save
pm2 status pm2 status
# Ver logs # Ver logs
pm2 logs padel-pro pm2 logs smashpoint
# Reiniciar # Reiniciar
pm2 restart padel-pro pm2 restart smashpoint
# Recargar sin downtime # Recargar sin downtime
pm2 reload padel-pro pm2 reload smashpoint
# Detener # Detener
pm2 stop padel-pro pm2 stop smashpoint
# Monitoreo # Monitoreo
pm2 monit pm2 monit
@@ -285,7 +285,7 @@ sudo apt install nginx
### 2. Crear Configuracion del Sitio ### 2. Crear Configuracion del Sitio
Crear `/etc/nginx/sites-available/padel-pro`: Crear `/etc/nginx/sites-available/smashpoint`:
```nginx ```nginx
# Redirigir HTTP a HTTPS # Redirigir HTTP a HTTPS
@@ -326,8 +326,8 @@ server {
add_header Strict-Transport-Security "max-age=63072000" always; add_header Strict-Transport-Security "max-age=63072000" always;
# Logs # Logs
access_log /var/log/nginx/padel-pro.access.log; access_log /var/log/nginx/smashpoint.access.log;
error_log /var/log/nginx/padel-pro.error.log; error_log /var/log/nginx/smashpoint.error.log;
# Tamano maximo de subida # Tamano maximo de subida
client_max_body_size 10M; client_max_body_size 10M;
@@ -362,7 +362,7 @@ server {
# Archivos estaticos # Archivos estaticos
location /static { location /static {
alias /var/www/padel-pro/apps/web/public; alias /var/www/smashpoint/apps/web/public;
expires 30d; expires 30d;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
} }
@@ -372,7 +372,7 @@ server {
### 3. Habilitar el Sitio ### 3. Habilitar el Sitio
```bash ```bash
sudo ln -s /etc/nginx/sites-available/padel-pro /etc/nginx/sites-enabled/ sudo ln -s /etc/nginx/sites-available/smashpoint /etc/nginx/sites-enabled/
# Verificar configuracion # Verificar configuracion
sudo nginx -t sudo nginx -t
@@ -430,10 +430,10 @@ El archivo `docker-compose.yml` ya esta incluido en el proyecto. Ubicado en `/ro
Crear `.env` en la raiz del proyecto: Crear `.env` en la raiz del proyecto:
```env ```env
POSTGRES_USER=padel_user POSTGRES_USER=smashpoint_user
POSTGRES_PASSWORD=PASSWORD_SEGURO POSTGRES_PASSWORD=PASSWORD_SEGURO
POSTGRES_DB=padel_pro POSTGRES_DB=smashpoint_db
DATABASE_URL=postgresql://padel_user:PASSWORD_SEGURO@db:5432/padel_pro?schema=public DATABASE_URL=postgresql://smashpoint_user:PASSWORD_SEGURO@db:5432/smashpoint_db?schema=public
NEXTAUTH_SECRET=tu-clave-secreta-generada NEXTAUTH_SECRET=tu-clave-secreta-generada
NEXTAUTH_URL=https://tudominio.com NEXTAUTH_URL=https://tudominio.com
NEXT_PUBLIC_APP_URL=https://tudominio.com NEXT_PUBLIC_APP_URL=https://tudominio.com
@@ -474,7 +474,7 @@ docker compose ps
docker compose exec web sh docker compose exec web sh
# Backup de base de datos # Backup de base de datos
docker compose exec db pg_dump -U padel_user padel_pro > backup.sql docker compose exec db pg_dump -U smashpoint_user smashpoint_db > backup.sql
``` ```
--- ---
@@ -497,14 +497,14 @@ Crear script `/opt/scripts/backup-padel.sh`:
```bash ```bash
#!/bin/bash #!/bin/bash
BACKUP_DIR="/var/backups/padel-pro" BACKUP_DIR="/var/backups/smashpoint"
DATE=$(date +%Y%m%d_%H%M%S) DATE=$(date +%Y%m%d_%H%M%S)
FILENAME="padel_pro_$DATE.sql.gz" FILENAME="smashpoint_db_$DATE.sql.gz"
mkdir -p $BACKUP_DIR mkdir -p $BACKUP_DIR
# Crear backup # Crear backup
pg_dump -U padel_user padel_pro | gzip > "$BACKUP_DIR/$FILENAME" pg_dump -U smashpoint_user smashpoint_db | gzip > "$BACKUP_DIR/$FILENAME"
# Eliminar backups antiguos (mantener 7 dias) # Eliminar backups antiguos (mantener 7 dias)
find $BACKUP_DIR -name "*.sql.gz" -mtime +7 -delete find $BACKUP_DIR -name "*.sql.gz" -mtime +7 -delete
@@ -526,7 +526,7 @@ crontab -e
### Actualizaciones ### Actualizaciones
```bash ```bash
cd /var/www/padel-pro cd /var/www/smashpoint
# Obtener cambios # Obtener cambios
git pull origin main git pull origin main
@@ -541,17 +541,17 @@ pnpm build
pnpm db:push pnpm db:push
# Reiniciar aplicacion # Reiniciar aplicacion
pm2 reload padel-pro pm2 reload smashpoint
``` ```
### Logs ### Logs
```bash ```bash
# Logs de la aplicacion # Logs de la aplicacion
pm2 logs padel-pro --lines 100 pm2 logs smashpoint --lines 100
# Logs de Nginx # Logs de Nginx
tail -f /var/log/nginx/padel-pro.error.log tail -f /var/log/nginx/smashpoint.error.log
# Logs de PostgreSQL # Logs de PostgreSQL
tail -f /var/log/postgresql/postgresql-16-main.log tail -f /var/log/postgresql/postgresql-16-main.log
@@ -569,7 +569,7 @@ RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" $HEALTH_URL)
if [ $RESPONSE != "200" ]; then if [ $RESPONSE != "200" ]; then
echo "ERROR: Health check failed with status $RESPONSE" echo "ERROR: Health check failed with status $RESPONSE"
# Enviar alerta (email, Slack, etc.) # Enviar alerta (email, Slack, etc.)
pm2 restart padel-pro pm2 restart smashpoint
fi fi
``` ```
@@ -611,13 +611,13 @@ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
```bash ```bash
# Verificar logs # Verificar logs
pm2 logs padel-pro --err pm2 logs smashpoint --err
# Verificar puertos # Verificar puertos
netstat -tlnp | grep 3000 netstat -tlnp | grep 3000
# Verificar conexion a DB # Verificar conexion a DB
psql -U padel_user -h localhost padel_pro -c "SELECT 1" psql -U smashpoint_user -h localhost smashpoint_db -c "SELECT 1"
``` ```
### Error de conexion a base de datos ### Error de conexion a base de datos
@@ -630,7 +630,7 @@ sudo systemctl status postgresql
tail -f /var/log/postgresql/postgresql-16-main.log tail -f /var/log/postgresql/postgresql-16-main.log
# Probar conexion # Probar conexion
psql "postgresql://padel_user:PASSWORD@localhost:5432/padel_pro" psql "postgresql://smashpoint_user:PASSWORD@localhost:5432/smashpoint_db"
``` ```
### Nginx devuelve 502 ### Nginx devuelve 502
@@ -643,7 +643,7 @@ pm2 status
sudo nginx -t sudo nginx -t
# Ver logs # Ver logs
tail -f /var/log/nginx/padel-pro.error.log tail -f /var/log/nginx/smashpoint.error.log
``` ```
--- ---

View File

@@ -1,4 +1,4 @@
# Padel Pro - Documento de Diseño # SmashPoint - Documento de Diseño
**Fecha:** 2026-02-01 **Fecha:** 2026-02-01
**Estado:** Aprobado **Estado:** Aprobado
@@ -59,7 +59,7 @@ Sistema integral de gestión para cadena de clubes de pádel con múltiples sede
``` ```
┌────────────────────────────────────────────────────────────┐ ┌────────────────────────────────────────────────────────────┐
PADEL PRO - MÓDULOS │ SMASHPOINT - MÓDULOS │
├──────────────┬──────────────┬──────────────┬───────────────┤ ├──────────────┬──────────────┬──────────────┬───────────────┤
│ RESERVAS │ TORNEOS │ POS │ MEMBRESÍAS │ │ RESERVAS │ TORNEOS │ POS │ MEMBRESÍAS │
│ │ │ │ │ │ │ │ │ │
@@ -465,7 +465,7 @@ enum MatchStatus {
## Estructura del Proyecto ## Estructura del Proyecto
``` ```
padel-pro/ smashpoint/
├── apps/ ├── apps/
│ ├── web/ # Next.js (Admin + API) │ ├── web/ # Next.js (Admin + API)
│ │ ├── app/ │ │ ├── app/

View File

@@ -1,4 +1,4 @@
# Padel Pro - Plan de Implementación # SmashPoint - Plan de Implementación
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
@@ -71,7 +71,7 @@ Create `turbo.json`:
```json ```json
{ {
"name": "padel-pro", "name": "smashpoint",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "turbo dev", "dev": "turbo dev",
@@ -165,7 +165,7 @@ git commit -m "chore: initialize monorepo with Turborepo and pnpm"
Create `apps/web/package.json`: Create `apps/web/package.json`:
```json ```json
{ {
"name": "@padel-pro/web", "name": "@smashpoint/web",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -179,7 +179,7 @@ Create `apps/web/package.json`:
"next": "14.2.0", "next": "14.2.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"@padel-pro/shared": "workspace:*" "@smashpoint/shared": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.11.0", "@types/node": "^20.11.0",
@@ -199,7 +199,7 @@ Create `apps/web/next.config.js`:
```javascript ```javascript
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
transpilePackages: ["@padel-pro/shared"], transpilePackages: ["@smashpoint/shared"],
images: { images: {
remotePatterns: [ remotePatterns: [
{ {
@@ -363,7 +363,7 @@ import "./globals.css";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Padel Pro - Sistema de Gestión", title: "SmashPoint - Sistema de Gestión",
description: "Sistema integral de gestión para clubes de pádel", description: "Sistema integral de gestión para clubes de pádel",
}; };
@@ -389,7 +389,7 @@ export default function Home() {
<main className="flex min-h-screen flex-col items-center justify-center p-24"> <main className="flex min-h-screen flex-col items-center justify-center p-24">
<div className="text-center"> <div className="text-center">
<h1 className="text-4xl font-bold text-primary mb-4"> <h1 className="text-4xl font-bold text-primary mb-4">
Padel Pro SmashPoint
</h1> </h1>
<p className="text-xl text-slate-600"> <p className="text-xl text-slate-600">
Sistema de Gestión para Clubes de Pádel Sistema de Gestión para Clubes de Pádel
@@ -431,7 +431,7 @@ git commit -m "feat(web): add Next.js 14 app with Tailwind CSS"
Create `packages/shared/package.json`: Create `packages/shared/package.json`:
```json ```json
{ {
"name": "@padel-pro/shared", "name": "@smashpoint/shared",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"main": "./src/index.ts", "main": "./src/index.ts",
@@ -1154,7 +1154,7 @@ enum MatchStatus {
Create `apps/web/.env.example`: Create `apps/web/.env.example`:
```env ```env
# Database # Database
DATABASE_URL="postgresql://user:password@localhost:5432/padel_pro?schema=public" DATABASE_URL="postgresql://user:password@localhost:5432/smashpoint_db?schema=public"
# Auth # Auth
NEXTAUTH_SECRET="your-secret-key-here" NEXTAUTH_SECRET="your-secret-key-here"
@@ -1737,7 +1737,7 @@ export function Sidebar() {
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-white font-bold"> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-white font-bold">
P P
</div> </div>
<span className="text-xl font-bold text-primary">Padel Pro</span> <span className="text-xl font-bold text-primary">SmashPoint</span>
</Link> </Link>
</div> </div>
@@ -2116,7 +2116,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { createBookingSchema } from "@padel-pro/shared"; import { createBookingSchema } from "@smashpoint/shared";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);

View File

@@ -0,0 +1,99 @@
# SmashPoint Adaptation: Cabo Pickleball Club - Design Document
## Overview
Adapt the SmashPoint padel club management system for Cabo Pickleball Club, a 6-court outdoor pickleball facility in the Corridor area of Cabo San Lucas, BCS, Mexico.
## Scope
### Branding & Visual Identity
- **Client name:** "Cabo Pickleball Club"
- **Platform:** "SmashPoint" (shown as "Powered by SmashPoint")
- **Login/landing:** "Cabo Pickleball Club" with "Powered by SmashPoint" subtitle
- **Sidebar:** "Cabo Pickleball" with SmashPoint logo in new blue
- **Browser tab:** "Cabo Pickleball Club | SmashPoint"
- **Primary color:** `#2990EA` (Cabo blue, replacing `#1E3A5F`)
- **Accent color:** `#F59E0B` (amber/golden, beach/sun vibe)
- **Font:** Inter (unchanged)
### Language: English Default
Direct string replacement (no i18n framework). All UI text from Spanish to English:
- Reservas → Bookings
- Canchas → Courts
- Clientes → Players
- Membresías → Memberships
- Reportes → Reports
- Configuración → Settings
- All form labels, buttons, error messages, tooltips → English
### Sport: Padel → Pickleball
- "padel" / "pádel" → "pickleball"
- "cancha" → "court"
- "raqueta" → "paddle"
- "pelotas" → "pickleballs"
- Court types INDOOR/OUTDOOR/COVERED remain valid for pickleball
### Features: Slim Down
**Keep & Adapt:**
- Dashboard (stats, occupancy, revenue)
- Bookings (court reservations, 300 MXN/person)
- Clients → renamed "Players"
- Memberships (day passes, multi-day passes, monthly plans)
- Reports (revenue, occupancy analytics)
- Settings (site/court configuration)
**Remove from navigation (hide, don't delete code):**
- Tournaments
- POS (Ventas)
### Seed Data
**Organization:**
- Name: Cabo Pickleball Club
- Slug: cabo-pickleball-club
- Currency: MXN
- Timezone: America/Mazatlan
**Site:**
- Name: Corridor Courts
- Address: Corridor area, Cabo San Lucas, BCS
- Hours: 07:00 - 22:00
- Phone: +52-624-151-5455
- Email: topdogcabo@yahoo.com
**Courts (6):**
- Court 1 through Court 6
- Type: OUTDOOR
- Price: 300 MXN per person
- Features: Night lighting, court dividers
**Membership Plans:**
| Plan | Price (MXN) | Details |
|------|------------|---------|
| Day Pass | 300 | Single day access |
| 10-Day Pass | 2,500 | 10 visits, any time |
| 10-Morning Pass | 2,000 | 10 morning sessions (7am-12pm) |
| Monthly Individual | 4,000 | Monthly unlimited |
| Monthly Family | 6,500 | Monthly unlimited, up to 4 members |
**Promotions (noted in plan descriptions):**
- Mon-Sat: -100 MXN off day pass
- Wednesday Ladies Day: -150 MXN off
**Admin Account:**
- Email: ivan@horuxfin.com
- Password: Aasi940812
- Role: SUPER_ADMIN
### Unchanged
- API routes structure
- Database schema (Prisma models)
- Auth system (NextAuth + JWT)
- Component architecture
- Docker/deployment config
- Package names (@smashpoint/*)

View File

@@ -0,0 +1,672 @@
# Cabo Pickleball Club Adaptation - Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Adapt SmashPoint from a Spanish-language padel club system to an English-language pickleball club system branded for Cabo Pickleball Club.
**Architecture:** Direct string replacement across ~30 files. No structural changes to components, API routes, or database schema. Hide unused features (Tournaments, POS) by removing sidebar links. Update color palette and seed data.
**Tech Stack:** Next.js 14, TailwindCSS, Prisma, TypeScript (all unchanged)
---
### Task 1: Update Color Palette
**Files:**
- Modify: `apps/web/tailwind.config.ts`
**Step 1: Replace primary color scale**
Change primary from dark navy (#1E3A5F) to Cabo blue (#2990EA). Change accent from green (#22C55E) to amber (#F59E0B).
Replace the entire `colors` object in tailwind.config.ts with:
```typescript
primary: {
50: "#E8F4FD",
100: "#C5E3FA",
200: "#9DCEF6",
300: "#75B9F2",
400: "#4DA4EE",
500: "#2990EA",
600: "#2177C8",
700: "#195DA6",
800: "#124484",
900: "#0B2B62",
DEFAULT: "#2990EA",
},
accent: {
50: "#FEF7EC",
100: "#FDEACC",
200: "#FBD89D",
300: "#F9C66E",
400: "#F7B43F",
500: "#F59E0B",
600: "#D48509",
700: "#A36807",
800: "#724A05",
900: "#412B03",
DEFAULT: "#F59E0B",
},
```
**Step 2: Build to verify colors compile**
Run: `cd /root/Padel && pnpm build 2>&1 | tail -5`
Expected: Build succeeds
**Step 3: Commit**
```bash
git add apps/web/tailwind.config.ts
git commit -m "feat: update color palette to Cabo blue (#2990EA) and amber accent"
```
---
### Task 2: Update Branding (Landing, Login, Sidebar, Metadata)
**Files:**
- Modify: `apps/web/app/layout.tsx`
- Modify: `apps/web/app/page.tsx`
- Modify: `apps/web/app/(auth)/login/page.tsx`
- Modify: `apps/web/components/layout/sidebar.tsx`
- Modify: `apps/web/app/icon.svg`
**Step 1: Update root layout metadata**
In `apps/web/app/layout.tsx`:
- title: `"Cabo Pickleball Club | SmashPoint"`
- description: `"Court Management System for Cabo Pickleball Club"`
- keywords: `["pickleball", "cabo", "courts", "bookings", "club"]`
- authors: `[{ name: "SmashPoint" }]`
- Change `<html lang="es">` to `<html lang="en">`
**Step 2: Update landing page**
In `apps/web/app/page.tsx`:
- Logo container: change `bg-amber-500` to `bg-primary`
- h1: `"Cabo Pickleball Club"` (instead of "SmashPoint")
- Add subtitle: `<p className="text-sm text-primary-400">Powered by SmashPoint</p>`
- Tagline: `"Court Management System"` (replacing Spanish)
- Change "Reservas" button text to `"Book a Court"` and href to `/bookings`
**Step 3: Update login page**
In `apps/web/app/(auth)/login/page.tsx`:
- Logo containers: change `bg-amber-500/20` and `border-amber-400/30` to `bg-primary/20` and `border-primary-300/30`
- SVG fill colors: change `#FBBF24` to `currentColor` and add `className="text-white"`
- Desktop h1: `"Cabo Pickleball Club"`
- Add after h1: `<p className="text-sm text-primary-300 mb-2">Powered by SmashPoint</p>`
- Desktop tagline: `"Court Management System"`
- Feature 1: `"Court Bookings"` / `"Manage your courts and schedules"`
- Feature 2: `"Player Management"` / `"Memberships and player profiles"`
- Feature 3: `"Reports & Analytics"` / `"Analyze your club's performance"`
- Mobile h1: `"Cabo Pickleball Club"`
- Mobile tagline: `"Court Management System"`
- Footer: `"© {year} SmashPoint. All rights reserved."`
**Step 4: Update sidebar**
In `apps/web/components/layout/sidebar.tsx`:
- Logo container: change `bg-amber-500` to `bg-primary`
- Brand text: `"Cabo Pickleball"` (instead of "SmashPoint")
- Remove Tournaments entry: `{ label: 'Torneos', href: '/tournaments', icon: Trophy }`
- Remove POS entry: `{ label: 'Ventas', href: '/pos', icon: ShoppingCart }`
- Remove Trophy and ShoppingCart imports from lucide-react
- Translate remaining labels:
- `'Reservas'``'Bookings'`
- `'Clientes'``'Players'`
- `'Membresías'``'Memberships'`
- `'Reportes'``'Reports'`
- `'Configuración'``'Settings'`
**Step 5: Update favicon**
In `apps/web/app/icon.svg`:
- Change `fill="#F59E0B"` to `fill="#2990EA"` (rect background)
**Step 6: Commit**
```bash
git add apps/web/app/layout.tsx apps/web/app/page.tsx apps/web/app/\(auth\)/login/page.tsx apps/web/components/layout/sidebar.tsx apps/web/app/icon.svg
git commit -m "feat: rebrand to Cabo Pickleball Club with English UI"
```
---
### Task 3: Translate Auth & Layout Components
**Files:**
- Modify: `apps/web/components/auth/login-form.tsx`
- Modify: `apps/web/components/layout/header.tsx`
**Step 1: Translate login-form.tsx**
All Spanish strings → English:
- `'El correo electrónico es requerido'``'Email is required'`
- `'Ingresa un correo electrónico válido'``'Enter a valid email address'`
- `'La contraseña es requerida'``'Password is required'`
- `'La contraseña debe tener al menos 6 caracteres'``'Password must be at least 6 characters'`
- `'Credenciales inválidas...'``'Invalid credentials. Please check your email and password.'`
- `'Ocurrió un error al iniciar sesión...'``'An error occurred while signing in. Please try again.'`
- `Iniciar Sesión` (heading) → `Sign In`
- `Ingresa tus credenciales para acceder al sistema``Enter your credentials to access the system`
- `Correo Electrónico``Email`
- `"correo@ejemplo.com"``"email@example.com"`
- `Contraseña``Password`
- `Recordarme``Remember me`
- `¿Olvidaste tu contraseña?``Forgot your password?`
- `Iniciando sesión...``Signing in...`
- Button: `'Iniciar Sesión'``'Sign In'`
**Step 2: Translate header.tsx**
- `'Usuario'``'User'`
- `"Cerrar sesión"``"Log out"`
**Step 3: Commit**
```bash
git add apps/web/components/auth/login-form.tsx apps/web/components/layout/header.tsx
git commit -m "feat: translate auth and layout components to English"
```
---
### Task 4: Translate Dashboard Page & Components
**Files:**
- Modify: `apps/web/app/(admin)/dashboard/page.tsx`
- Modify: `apps/web/components/dashboard/quick-actions.tsx`
- Modify: `apps/web/components/dashboard/occupancy-chart.tsx`
- Modify: `apps/web/components/dashboard/recent-bookings.tsx`
- Modify: `apps/web/components/dashboard/stat-card.tsx`
**Step 1: Translate dashboard/page.tsx**
- `"Error al cargar los datos del dashboard"``"Error loading dashboard data"`
- `"Error desconocido"``"Unknown error"`
- `"Usuario"``"User"`
- `` `Bienvenido, ${userName}` `` → `` `Welcome, ${userName}` ``
- `Panel de administracion``Admin panel`
- `` `Mostrando: ${selectedSite.name}` `` → `` `Showing: ${selectedSite.name}` ``
- `"Reservas Hoy"``"Today's Bookings"`
- `"Ingresos Hoy"``"Today's Revenue"`
- `"Ocupacion"``"Occupancy"`
- `"Miembros Activos"``"Active Members"`
- `"Reservas Pendientes"``"Pending Bookings"`
- `"Torneos Proximos"``"Upcoming Events"` (generic since tournaments hidden)
**Step 2: Translate quick-actions.tsx**
- `"Nueva Reserva"``"New Booking"`
- `"Crear una nueva reserva de cancha"``"Create a new court booking"`
- `"Abrir Caja"``"Open Register"`
- `"Iniciar turno de caja registradora"``"Start cash register shift"`
- `"Nueva Venta"``"New Sale"`
- `"Registrar venta en el punto de venta"``"Record a point of sale transaction"`
- `"Registrar Cliente"``"Register Player"`
- `"Agregar un nuevo cliente al sistema"``"Add a new player to the system"`
- `Acciones Rapidas``Quick Actions`
**Step 3: Translate occupancy-chart.tsx**
- `Ocupacion de Canchas``Court Occupancy` (appears twice)
- `No hay canchas configuradas``No courts configured`
- `{court.occupancyPercent}% ocupado``{court.occupancyPercent}% booked`
- `disponible``available`
- `Ocupado``Booked`
- `Disponible``Available`
**Step 4: Translate recent-bookings.tsx**
- `"Pendiente"``"Pending"`
- `"Confirmada"``"Confirmed"`
- `"Completada"``"Completed"`
- `"Cancelada"``"Cancelled"`
- `"No asistio"``"No Show"`
- `Reservas de Hoy``Today's Bookings`
- `Ver todas``View all`
- `No hay reservas para hoy``No bookings for today`
- `"Sin cliente"``"Walk-in"`
**Step 5: Translate stat-card.tsx**
- `vs ayer``vs yesterday`
**Step 6: Commit**
```bash
git add apps/web/app/\(admin\)/dashboard/page.tsx apps/web/components/dashboard/
git commit -m "feat: translate dashboard page and components to English"
```
---
### Task 5: Translate Bookings Page & Components
**Files:**
- Modify: `apps/web/app/(admin)/bookings/page.tsx`
- Modify: `apps/web/components/bookings/booking-calendar.tsx`
- Modify: `apps/web/components/bookings/booking-dialog.tsx`
**Step 1: Translate bookings/page.tsx**
- `Reservas``Bookings`
- `Gestiona las reservas de canchas...``Manage court bookings. Select a time slot to create or view a booking.`
**Step 2: Translate booking-calendar.tsx**
All Spanish strings → English (12 strings):
- Error messages: `"Error al cargar..."``"Error loading..."`
- `Reintentar``Retry`
- `Calendario``Calendar`
- `Hoy``Today`
- `Cargando disponibilidad...``Loading availability...`
- `No hay canchas disponibles.``No courts available.`
- `"Interior"``"Indoor"`, `"Exterior"``"Outdoor"`
- `No disponible``Not available`
- `No hay horarios disponibles para este día.``No time slots available for this day.`
**Step 3: Translate booking-dialog.tsx**
All Spanish strings → English (35 strings):
- Form labels: `Buscar Cliente``Search Player`, `Cliente seleccionado:``Selected player:`
- Status labels: `"Confirmada"``"Confirmed"`, `"Pendiente"``"Pending"`, etc.
- Payment types: `"Efectivo"``"Cash"`, `"Tarjeta"``"Card"`, `"Transferencia"``"Transfer"`, `"Membresia"``"Membership"`, `"Gratuito"``"Free"`
- Field labels: `Cancha:``Court:`, `Fecha:``Date:`, `Hora:``Time:`, `Precio:``Price:`
- Buttons: `"Crear Reserva"``"Create Booking"`, `"Cancelar Reserva"``"Cancel Booking"`
- Error messages: all `"Error al..."``"Error..."`
- Placeholders: `"Nombre, email o telefono..."``"Name, email or phone..."`
- Note: Change all instances of "Cliente" to "Player" in this file
**Step 4: Commit**
```bash
git add apps/web/app/\(admin\)/bookings/page.tsx apps/web/components/bookings/
git commit -m "feat: translate bookings page and components to English"
```
---
### Task 6: Translate Clients Page (rename to Players)
**Files:**
- Modify: `apps/web/app/(admin)/clients/page.tsx`
**Step 1: Translate all strings**
17 Spanish strings → English. Key translations:
- `Clientes``Players`
- `Gestiona los clientes de tu centro``Manage your club's players`
- `Nuevo Cliente``New Player`
- `"Total Clientes"``"Total Players"`
- `"Con Membresia"``"With Membership"`
- `"Nuevos Este Mes"``"New This Month"`
- `"Buscar por nombre, email o telefono..."``"Search by name, email or phone..."`
- `"Todos"``"All"`, `"Con membresia"``"With membership"`, `"Sin membresia"``"Without membership"`
- All error messages: translate from Spanish to English
- Confirmation dialog: translate to English
**Step 2: Commit**
```bash
git add apps/web/app/\(admin\)/clients/page.tsx
git commit -m "feat: translate clients/players page to English"
```
---
### Task 7: Translate Memberships Page & Components
**Files:**
- Modify: `apps/web/app/(admin)/memberships/page.tsx`
- Modify: `apps/web/components/memberships/plan-card.tsx`
- Modify: `apps/web/components/memberships/plan-form.tsx`
**Step 1: Translate memberships/page.tsx (30 strings)**
- `Membresias``Memberships`
- `Gestiona planes y membresias de tus clientes``Manage plans and memberships for your players`
- Status filters: `"Todos"``"All"`, `"Activas"``"Active"`, `"Expiradas"``"Expired"`, `"Canceladas"``"Cancelled"`
- Stats: `Membresias Activas``Active Memberships`, `Por Expirar``Expiring Soon`, `Planes Activos``Active Plans`, `Total Suscriptores``Total Subscribers`
- `Planes de Membresia``Membership Plans`
- `Nuevo Plan``New Plan`
- `Cargando planes...``Loading plans...`
- `No hay planes``No plans`, `Crea tu primer plan de membresia``Create your first membership plan`
- `Crear Plan``Create Plan`
- `Asignar Membresia``Assign Membership`
- `"Buscar por nombre de cliente..."``"Search by player name..."`
- `"Todos los planes"``"All plans"`
- All error messages and confirmation dialogs → English
**Step 2: Translate plan-card.tsx (11 strings)**
- `suscriptor` / `suscriptores``subscriber` / `subscribers`
- `mes` / `meses``month` / `months`
- `horas gratis``free hours`
- `de cancha al mes``of court time per month`
- `descuento``discount`
- `en reservas adicionales``on additional bookings`
- `en tienda``in store`
- `Beneficios adicionales:``Additional benefits:`
- `Editar``Edit`, `Eliminar``Delete`
**Step 3: Translate plan-form.tsx (25 strings)**
- Duration labels: `"1 mes"``"1 month"`, `"3 meses"``"3 months"`, etc.
- Validation: all Spanish error messages → English
- Form title: `"Nuevo Plan de Membresia"``"New Membership Plan"`, `"Editar Plan"``"Edit Plan"`
- Labels: `Nombre del Plan *``Plan Name *`, `Descripcion``Description`, `Precio *``Price *`, `Duracion``Duration`
- Section headers: `Beneficios``Benefits`, `Horas Gratis de Cancha (por mes)``Free Court Hours (per month)`, `Descuento en Reservas (%)``Booking Discount (%)`, `Descuento en Tienda (%)``Store Discount (%)`
- Buttons: `Cancelar``Cancel`, `Guardando...``Saving...`, `"Crear Plan"``"Create Plan"`, `"Guardar Cambios"``"Save Changes"`
- Placeholders: translate to English equivalents
**Step 4: Commit**
```bash
git add apps/web/app/\(admin\)/memberships/page.tsx apps/web/components/memberships/
git commit -m "feat: translate memberships page and components to English"
```
---
### Task 8: Translate Reports Page
**Files:**
- Modify: `apps/web/app/(admin)/reports/page.tsx`
**Step 1: Translate all strings (28 strings)**
- `Reportes``Reports`
- `Análisis y estadísticas del negocio``Business analysis and statistics`
- Period filters: `"Última semana"``"Last week"`, `"Último mes"``"Last month"`, `"Último trimestre"``"Last quarter"`, `"Último año"``"Last year"`
- `Exportar``Export`
- KPIs: `"Ingresos Totales"``"Total Revenue"`, `"Reservas"``"Bookings"`, `"Clientes Activos"``"Active Players"`, `"Ocupación Promedio"``"Average Occupancy"`
- Charts: `Ingresos por Día``Revenue by Day`, `Reservas``Bookings`, `Ventas``Sales`
- `Productos Más Vendidos``Top Selling Products`
- `unidades``units`
- `Rendimiento por Cancha``Court Performance`
- Table headers: `Cancha``Court`, `Sede``Site`, `Reservas``Bookings`, `Ingresos``Revenue`, `Ocupación``Occupancy`
- Day names: `"Lun"``"Mon"`, `"Mar"``"Tue"`, `"Mié"``"Wed"`, `"Jue"``"Thu"`, `"Vie"``"Fri"`, `"Sáb"``"Sat"`, `"Dom"``"Sun"`
- Insights: `Mejor Día``Best Day`, `Sábado``Saturday`, `en ingresos promedio``in average revenue`, `Hora Pico``Peak Hour`, `Ticket Promedio``Average Ticket`
- `vs período anterior``vs previous period`
- Rename "Cancha" to "Court" in mock data court names (lines 110-115)
- Rename "Raqueta alquiler" to "Paddle Rental" in mock products (line 106)
- Rename "Pelotas HEAD" to "Pickleballs" in mock products (line 105)
**Step 2: Commit**
```bash
git add apps/web/app/\(admin\)/reports/page.tsx
git commit -m "feat: translate reports page to English"
```
---
### Task 9: Translate Settings Page
**Files:**
- Modify: `apps/web/app/(admin)/settings/page.tsx`
**Step 1: Translate all Spanish strings**
Key translations (the file has ~60 Spanish strings):
- `Configuración``Settings`
- `Administra la configuración del sistema``Manage system settings`
- `Configuración guardada correctamente``Settings saved successfully`
- Tab labels: `Organización``Organization`, `Sedes``Sites`, `Canchas``Courts`, `Usuarios``Users`
- Organization form: `Nombre de la organización``Organization name`, `Email de contacto``Contact email`, `Teléfono``Phone`, `Moneda``Currency`, `Zona horaria``Timezone`
- Currency options: `"MXN - Peso Mexicano"`, `"USD - Dólar"`, `"EUR - Euro"``"MXN - Mexican Peso"`, `"USD - US Dollar"`, `"EUR - Euro"`
- Timezone options: `"Ciudad de México"``"Mexico City"`, etc.
- Booking config: `Duración por defecto (minutos)``Default duration (minutes)`, `Anticipación mínima (horas)``Minimum notice (hours)`, `Anticipación máxima (días)``Maximum advance (days)`, `Horas para cancelar``Cancellation window (hours)`
- Buttons: `Guardar cambios``Save changes`, `Guardando...``Saving...`
- Sites section: `Sedes``Sites`, `Nueva Sede``New Site`, `Activa` / `Inactiva``Active` / `Inactive`
- Courts section: `Canchas``Courts`, `Nueva Cancha``New Court`, `Cancha``Court`, `Sede``Site`, `Tipo``Type`, `Precio/hora``Price/hour`, `Estado``Status`, `Acciones``Actions`
- Court types: `Indoor` stays, `Outdoor` stays, `"Techada"``"Covered"`
- Court status: `"Activa"``"Active"`, `"Mantenimiento"``"Maintenance"`, `"Inactiva"``"Inactive"`
- Users section: `Usuarios``Users`, `Nuevo Usuario``New User`, `Usuario``User`, `Rol``Role`, `"Super Admin"` stays, `"Admin Sede"``"Site Admin"`, `"Staff"` stays
- Messages: `"Cancha actualizada"``"Court updated"`, `"Cancha creada"``"Court created"`, `"Cancha eliminada"``"Court deleted"`, etc.
- Site form: `"Editar Sede"``"Edit Site"`, `"Nueva Sede"``"New Site"`, `Nombre``Name`, `Dirección``Address`, `Teléfono``Phone`, `Hora apertura``Opening time`, `Hora cierre``Closing time`, `Sede activa``Site active`
- Court form: `"Editar Cancha"``"Edit Court"`, `"Nueva Cancha"``"New Court"`, `Precio hora pico``Peak hour price`
- All `Cancelar``Cancel`, `Guardar``Save`, `Guardando...``Saving...`
- Error/success: `"Sede actualizada"``"Site updated"`, `"Sede creada"``"Site created"`, `"Error al guardar..."``"Error saving..."`, `"Error de conexión"``"Connection error"`
- Confirmation: `"¿Estás seguro de eliminar esta cancha?"``"Are you sure you want to delete this court?"`
- `"Todas"``"All"` (for site assignment)
- `"Activo"` / `"Inactivo"``"Active"` / `"Inactive"` (user status)
**Step 2: Commit**
```bash
git add apps/web/app/\(admin\)/settings/page.tsx
git commit -m "feat: translate settings page to English"
```
---
### Task 10: Translate API Error Messages
**Files:**
- Modify: `apps/web/app/api/bookings/route.ts`
- Modify: `apps/web/app/api/clients/route.ts`
- Check and modify any other API routes with Spanish strings
**Step 1: Translate bookings/route.ts**
- `'No autorizado'``'Unauthorized'`
- `'Error al obtener las reservas'``'Error fetching bookings'`
- `'Datos de reserva inválidos'``'Invalid booking data'`
- `'Cancha no encontrada o no pertenece a su organización'``'Court not found or does not belong to your organization'`
- `'La cancha no está disponible para reservas'``'The court is not available for bookings'`
- `'Cliente no encontrado o no pertenece a su organización'``'Client not found or does not belong to your organization'`
- `'Ya existe una reserva en ese horario...'``'A booking already exists for that time slot. Please select another time.'`
- `'Error al crear la reserva'``'Error creating booking'`
**Step 2: Scan and translate all other API routes**
Search for Spanish strings in all files under `apps/web/app/api/` and translate them.
Run: `grep -rn "'" apps/web/app/api/ | grep -i "[áéíóúñ]\|Error al\|No autorizado\|no encontrad"` to find remaining Spanish.
**Step 3: Commit**
```bash
git add apps/web/app/api/
git commit -m "feat: translate API error messages to English"
```
---
### Task 11: Update Seed Data for Cabo Pickleball
**Files:**
- Modify: `apps/web/prisma/seed.ts`
**Step 1: Update organization**
```typescript
name: 'Cabo Pickleball Club',
slug: 'cabo-pickleball-club',
settings: {
currency: 'MXN',
timezone: 'America/Mazatlan',
language: 'en',
},
```
**Step 2: Update site (single site instead of 3)**
Replace the 3 sites with 1:
```typescript
const sitesData = [
{
name: 'Corridor Courts',
slug: 'corridor-courts',
address: 'Corridor area, Cabo San Lucas, BCS',
phone: '+52-624-151-5455',
email: 'topdogcabo@yahoo.com',
timezone: 'America/Mazatlan',
openTime: '07:00',
closeTime: '22:00',
},
];
```
**Step 3: Update courts (6 outdoor courts)**
Replace the 2-per-site pattern with 6 courts for the single site:
```typescript
const courtData = [
{ name: 'Court 1', type: CourtType.OUTDOOR, status: CourtStatus.AVAILABLE, pricePerHour: 300, description: 'Outdoor court with night lighting', features: ['Night lighting', 'Court dividers'], displayOrder: 1 },
{ name: 'Court 2', type: CourtType.OUTDOOR, status: CourtStatus.AVAILABLE, pricePerHour: 300, description: 'Outdoor court with night lighting', features: ['Night lighting', 'Court dividers'], displayOrder: 2 },
{ name: 'Court 3', type: CourtType.OUTDOOR, status: CourtStatus.AVAILABLE, pricePerHour: 300, description: 'Outdoor court with night lighting', features: ['Night lighting', 'Court dividers'], displayOrder: 3 },
{ name: 'Court 4', type: CourtType.OUTDOOR, status: CourtStatus.AVAILABLE, pricePerHour: 300, description: 'Outdoor court with night lighting', features: ['Night lighting', 'Court dividers'], displayOrder: 4 },
{ name: 'Court 5', type: CourtType.OUTDOOR, status: CourtStatus.AVAILABLE, pricePerHour: 300, description: 'Outdoor court with night lighting', features: ['Night lighting', 'Court dividers'], displayOrder: 5 },
{ name: 'Court 6', type: CourtType.OUTDOOR, status: CourtStatus.AVAILABLE, pricePerHour: 300, description: 'Outdoor court with night lighting', features: ['Night lighting', 'Court dividers'], displayOrder: 6 },
];
```
**Step 4: Update admin user**
```typescript
email: 'ivan@horuxfin.com',
password: await bcrypt.hash('Aasi940812', 10),
```
Remove the site admin users (single-site operation).
**Step 5: Update product categories and products**
Change to pickleball-relevant items:
- Category: `'Equipment'``'Pickleball equipment and accessories'`
- Products: `'Pickleballs'` (Franklin X-40), `'Paddle Rental'`, `'Paddle Grip'`
- Category: `'Drinks'` stays but translate names to English
- Remove `'Alquiler'` category (merge rental into Equipment)
**Step 6: Update membership plans**
```typescript
const membershipPlansData = [
{
name: 'Day Pass',
description: 'Single day access to all courts',
price: 300,
durationMonths: 1,
courtHours: 0,
discountPercent: 0,
benefits: ['Full day access', 'All courts', 'Night play included'],
},
{
name: '10-Day Pass',
description: '10 visits, any time of day',
price: 2500,
durationMonths: 3,
courtHours: 10,
discountPercent: 15,
benefits: ['10 day passes', 'Valid any time', 'Save vs single day pass'],
},
{
name: '10-Morning Pass',
description: '10 morning sessions (7am-12pm)',
price: 2000,
durationMonths: 3,
courtHours: 10,
discountPercent: 10,
benefits: ['10 morning passes', '7:00 AM - 12:00 PM only', 'Best value for morning players'],
},
{
name: 'Monthly Individual',
description: 'Unlimited monthly access for one player',
price: 4000,
durationMonths: 1,
courtHours: 30,
discountPercent: 25,
benefits: ['Unlimited court access', 'Priority booking', 'All time slots'],
},
{
name: 'Monthly Family',
description: 'Unlimited monthly access for up to 4 family members',
price: 6500,
durationMonths: 1,
courtHours: 60,
discountPercent: 30,
benefits: ['Up to 4 family members', 'Unlimited court access', 'Priority booking', 'All time slots'],
},
];
```
**Step 7: Update seed summary output**
Change all console.log messages to English and update credential display:
```
Login credentials:
Admin: ivan@horuxfin.com / Aasi940812
```
**Step 8: Commit**
```bash
git add apps/web/prisma/seed.ts
git commit -m "feat: update seed data for Cabo Pickleball Club"
```
---
### Task 12: Sport Terminology Sweep
**Files:**
- All `.tsx`, `.ts` files containing "padel", "pádel", "cancha", "raqueta", "pelota"
**Step 1: Global search and replace**
Run targeted searches and replace remaining sport terms:
- `grep -rn "padel\|pádel\|Padel\|Pádel" apps/web/ --include="*.tsx" --include="*.ts"` — replace with "pickleball"
- `grep -rn "cancha" apps/web/ --include="*.tsx" --include="*.ts"` — replace with "court" (should already be done in earlier tasks)
- `grep -rn "raqueta\|Raqueta" apps/web/ --include="*.tsx" --include="*.ts"` — replace with "paddle"
- `grep -rn "pelota\|Pelota" apps/web/ --include="*.tsx" --include="*.ts"` — replace with "pickleball ball" or "pickleballs"
**Step 2: Verify no Spanish sport terms remain**
Run: `grep -rni "padel\|cancha\|raqueta\|pelota" apps/web/ --include="*.tsx" --include="*.ts"`
Expected: No matches (or only in comments/prisma generated code)
**Step 3: Commit if any changes**
```bash
git add -A
git commit -m "feat: replace all padel terminology with pickleball"
```
---
### Task 13: Build, Verify & Final Commit
**Step 1: Clean build**
```bash
rm -rf apps/web/.next .turbo
pnpm build
```
Expected: Build succeeds with 0 errors.
**Step 2: Verify no Spanish remains in user-facing code**
Run: `grep -rni "[áéíóúñ]" apps/web/app/ apps/web/components/ --include="*.tsx" --include="*.ts" | grep -v node_modules | grep -v ".next"`
Review any remaining Spanish strings and translate.
**Step 3: Restart server and verify**
```bash
fuser -k 3000/tcp 2>/dev/null
sleep 2
cd apps/web && npx next start --port 3000 &
```
**Step 4: Push**
```bash
git push origin main
```

View File

@@ -0,0 +1,58 @@
# Live Courts + CRM Clients - Design Document
## Overview
Consolidate Bookings + Players into a "Live Courts" real-time status board. Replace the current Players page with a CRM-style Clients page focused on memberships, expirations, and visit history.
## Navigation
Before: Dashboard | Bookings | Players | Memberships | Reports | Settings
After: Dashboard | Live Courts | Clients | Memberships | Reports | Settings
## Live Courts Page (/live)
Real-time dashboard showing all 6 courts. 3x2 grid of court cards.
### Court States
- Available (green) — empty, can check in players
- Active (blue) — players on court, shows player list
- Open Play (amber) — dedicated free courts, group scheduling
- Booked (purple) — upcoming booking in next 30 min
### Actions
- Check In — add player (search existing or walk-in name)
- End Session — clear all players
- Schedule Group (open play only) — name/note + time, no cost
### Auto-populate
Bookings for current time auto-show as active players.
## Open Play Courts
Settings > Courts toggle: "Open Play Court" (boolean).
- Amber badge on Live Courts
- No pricing on bookings
- Group scheduling: name/note + time slot, no client/payment
## Clients CRM Page (/clients)
### Stats Row
Total Clients | Active Memberships | Expiring This Month | No Membership
### Table Columns
Name | Phone | Email | Membership | Status | Expires | Last Visit | Actions
### Features
- Membership status badges (Active=green, Expiring=amber, Expired=red, None=gray)
- Filters: All / Active Members / Expiring Soon / Expired / No Membership
- Search by name, email, phone
- Client detail modal with membership + visit history
## Schema Changes
Court model: add `isOpenPlay Boolean @default(false)`
New CourtSession model:
- id, courtId, clientId (optional), walkInName (optional)
- startTime, endTime, isActive
- Relations to Court and Client

115
package-lock.json generated Normal file
View File

@@ -0,0 +1,115 @@
{
"name": "smashpoint",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "smashpoint",
"devDependencies": {
"turbo": "^2.0.0"
}
},
"node_modules/turbo": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/turbo/-/turbo-2.8.1.tgz",
"integrity": "sha512-pbSMlRflA0RAuk/0jnAt8pzOYh1+sKaT8nVtcs75OFGVWD0evleQRmKtHJJV42QOhaC3Hx9mUUSOom/irasbjA==",
"dev": true,
"license": "MIT",
"bin": {
"turbo": "bin/turbo"
},
"optionalDependencies": {
"turbo-darwin-64": "2.8.1",
"turbo-darwin-arm64": "2.8.1",
"turbo-linux-64": "2.8.1",
"turbo-linux-arm64": "2.8.1",
"turbo-windows-64": "2.8.1",
"turbo-windows-arm64": "2.8.1"
}
},
"node_modules/turbo-darwin-64": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/turbo-darwin-64/-/turbo-darwin-64-2.8.1.tgz",
"integrity": "sha512-FQ6Uqxty/H1Nvn1dpBe8KUlMRclTuiyNSc1PCeDL/ad7M9ykpWutB51YpMpf9ibTA32M6wLdIRf+D96W6hDAtQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/turbo-darwin-arm64": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/turbo-darwin-arm64/-/turbo-darwin-arm64-2.8.1.tgz",
"integrity": "sha512-4bCcEpGP2/aSXmeN2gl5SuAmS1q5ykjubnFvSoXjQoCKtDOV+vc4CTl/DduZzUUutCVUWXjl8OyfIQ+DGCaV4A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/turbo-linux-64": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/turbo-linux-64/-/turbo-linux-64-2.8.1.tgz",
"integrity": "sha512-m99JRlWlEgXPR7mkThAbKh6jbTmWSOXM/c6rt8yd4Uxh0+wjq7+DYcQbead6aoOqmCP9akswZ8EXIv1ogKBblg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/turbo-linux-arm64": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/turbo-linux-arm64/-/turbo-linux-arm64-2.8.1.tgz",
"integrity": "sha512-AsPlza3AsavJdl2o7FE67qyv0aLfmT1XwFQGzvwpoAO6Bj7S4a03tpUchZKNuGjNAkKVProQRFnB7PgUAScFXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/turbo-windows-64": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/turbo-windows-64/-/turbo-windows-64-2.8.1.tgz",
"integrity": "sha512-GdqNO6bYShRsr79B+2G/2ssjLEp9uBTvLBJSWRtRCiac/SEmv8T6RYv9hu+h5oGbFALtnKNp6BQBw78RJURsPw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/turbo-windows-arm64": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/turbo-windows-arm64/-/turbo-windows-arm64-2.8.1.tgz",
"integrity": "sha512-n40E6IpkzrShRo3yMdRpgnn1/sAbGC6tZXwyNu8fe9RsufeD7KBiaoRSvw8xLyqV3pd2yoTL2rdCXq24MnTCWA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
}
}
}

View File

@@ -1,5 +1,5 @@
{ {
"name": "padel-pro", "name": "smashpoint",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "turbo dev", "dev": "turbo dev",

View File

@@ -1,11 +1,11 @@
{ {
"name": "@padel-pro/shared", "name": "@smashpoint/shared",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@padel-pro/shared", "name": "@smashpoint/shared",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"zod": "^3.22.4" "zod": "^3.22.4"

View File

@@ -1,5 +1,5 @@
{ {
"name": "@padel-pro/shared", "name": "@smashpoint/shared",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"main": "./src/index.ts", "main": "./src/index.ts",

303
pnpm-lock.yaml generated
View File

@@ -14,7 +14,7 @@ importers:
apps/web: apps/web:
dependencies: dependencies:
'@padel-pro/shared': '@smashpoint/shared':
specifier: '*' specifier: '*'
version: link:../../packages/shared version: link:../../packages/shared
'@prisma/client': '@prisma/client':
@@ -95,7 +95,10 @@ importers:
version: 5.22.0 version: 5.22.0
tailwindcss: tailwindcss:
specifier: ^3.4.1 specifier: ^3.4.1
version: 3.4.19 version: 3.4.19(tsx@4.21.0)
tsx:
specifier: ^4.7.0
version: 4.21.0
typescript: typescript:
specifier: ^5.3.3 specifier: ^5.3.3
version: 5.9.3 version: 5.9.3
@@ -122,6 +125,240 @@ packages:
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
dev: false dev: false
/@esbuild/aix-ppc64@0.27.2:
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
requiresBuild: true
dev: true
optional: true
/@esbuild/android-arm64@0.27.2:
resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/android-arm@0.27.2:
resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/android-x64@0.27.2:
resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/darwin-arm64@0.27.2:
resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@esbuild/darwin-x64@0.27.2:
resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@esbuild/freebsd-arm64@0.27.2:
resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/freebsd-x64@0.27.2:
resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-arm64@0.27.2:
resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-arm@0.27.2:
resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-ia32@0.27.2:
resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-loong64@0.27.2:
resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-mips64el@0.27.2:
resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-ppc64@0.27.2:
resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-riscv64@0.27.2:
resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-s390x@0.27.2:
resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-x64@0.27.2:
resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/netbsd-arm64@0.27.2:
resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/netbsd-x64@0.27.2:
resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/openbsd-arm64@0.27.2:
resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/openbsd-x64@0.27.2:
resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/openharmony-arm64@0.27.2:
resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
requiresBuild: true
dev: true
optional: true
/@esbuild/sunos-x64@0.27.2:
resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-arm64@0.27.2:
resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-ia32@0.27.2:
resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-x64@0.27.2:
resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@floating-ui/core@1.7.4: /@floating-ui/core@1.7.4:
resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==}
dependencies: dependencies:
@@ -1189,6 +1426,40 @@ packages:
resolution: {integrity: sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==} resolution: {integrity: sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==}
dev: true dev: true
/esbuild@0.27.2:
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
engines: {node: '>=18'}
hasBin: true
requiresBuild: true
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.2
'@esbuild/android-arm': 0.27.2
'@esbuild/android-arm64': 0.27.2
'@esbuild/android-x64': 0.27.2
'@esbuild/darwin-arm64': 0.27.2
'@esbuild/darwin-x64': 0.27.2
'@esbuild/freebsd-arm64': 0.27.2
'@esbuild/freebsd-x64': 0.27.2
'@esbuild/linux-arm': 0.27.2
'@esbuild/linux-arm64': 0.27.2
'@esbuild/linux-ia32': 0.27.2
'@esbuild/linux-loong64': 0.27.2
'@esbuild/linux-mips64el': 0.27.2
'@esbuild/linux-ppc64': 0.27.2
'@esbuild/linux-riscv64': 0.27.2
'@esbuild/linux-s390x': 0.27.2
'@esbuild/linux-x64': 0.27.2
'@esbuild/netbsd-arm64': 0.27.2
'@esbuild/netbsd-x64': 0.27.2
'@esbuild/openbsd-arm64': 0.27.2
'@esbuild/openbsd-x64': 0.27.2
'@esbuild/openharmony-arm64': 0.27.2
'@esbuild/sunos-x64': 0.27.2
'@esbuild/win32-arm64': 0.27.2
'@esbuild/win32-ia32': 0.27.2
'@esbuild/win32-x64': 0.27.2
dev: true
/escalade@3.2.0: /escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -1250,6 +1521,12 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
dev: false dev: false
/get-tsconfig@4.13.1:
resolution: {integrity: sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==}
dependencies:
resolve-pkg-maps: 1.0.0
dev: true
/glob-parent@5.1.2: /glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -1538,7 +1815,7 @@ packages:
postcss: 8.5.6 postcss: 8.5.6
dev: true dev: true
/postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6): /postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0):
resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
peerDependencies: peerDependencies:
@@ -1559,6 +1836,7 @@ packages:
jiti: 1.21.7 jiti: 1.21.7
lilconfig: 3.1.3 lilconfig: 3.1.3
postcss: 8.5.6 postcss: 8.5.6
tsx: 4.21.0
dev: true dev: true
/postcss-nested@6.2.0(postcss@8.5.6): /postcss-nested@6.2.0(postcss@8.5.6):
@@ -1713,6 +1991,10 @@ packages:
picomatch: 2.3.1 picomatch: 2.3.1
dev: true dev: true
/resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
dev: true
/resolve@1.22.11: /resolve@1.22.11:
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1789,7 +2071,7 @@ packages:
resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==}
dev: false dev: false
/tailwindcss@3.4.19: /tailwindcss@3.4.19(tsx@4.21.0):
resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
hasBin: true hasBin: true
@@ -1811,7 +2093,7 @@ packages:
postcss: 8.5.6 postcss: 8.5.6
postcss-import: 15.1.0(postcss@8.5.6) postcss-import: 15.1.0(postcss@8.5.6)
postcss-js: 4.1.0(postcss@8.5.6) postcss-js: 4.1.0(postcss@8.5.6)
postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6) postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)
postcss-nested: 6.2.0(postcss@8.5.6) postcss-nested: 6.2.0(postcss@8.5.6)
postcss-selector-parser: 6.1.2 postcss-selector-parser: 6.1.2
resolve: 1.22.11 resolve: 1.22.11
@@ -1857,6 +2139,17 @@ packages:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
dev: false dev: false
/tsx@4.21.0:
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
engines: {node: '>=18.0.0'}
hasBin: true
dependencies:
esbuild: 0.27.2
get-tsconfig: 4.13.1
optionalDependencies:
fsevents: 2.3.3
dev: true
/turbo-darwin-64@2.8.1: /turbo-darwin-64@2.8.1:
resolution: {integrity: sha512-FQ6Uqxty/H1Nvn1dpBe8KUlMRclTuiyNSc1PCeDL/ad7M9ykpWutB51YpMpf9ibTA32M6wLdIRf+D96W6hDAtQ==} resolution: {integrity: sha512-FQ6Uqxty/H1Nvn1dpBe8KUlMRclTuiyNSc1PCeDL/ad7M9ykpWutB51YpMpf9ibTA32M6wLdIRf+D96W6hDAtQ==}
cpu: [x64] cpu: [x64]

8
scripts/init-db.sql Normal file
View File

@@ -0,0 +1,8 @@
-- SmashPoint Database Initialization
-- This script runs when PostgreSQL container starts for the first time
-- Enable UUID extension if needed
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Grant privileges
GRANT ALL PRIVILEGES ON DATABASE smashpoint_db TO padel;

24
scripts/start.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/bin/bash
# SmashPoint - Cabo Pickleball Club
# Production server start script
set -e
# Load environment variables
export NEXTAUTH_URL="https://smashpoint.consultoria-as.com"
export NEXT_PUBLIC_APP_URL="https://smashpoint.consultoria-as.com"
export NEXTAUTH_SECRET="xApk6WiZYJZwUpKk6ZlyHoseXqsCSnTmRDqzDdmtRVY="
APP_DIR="/root/Padel/apps/web"
PORT=3000
# Kill any existing server on the port
fuser -k $PORT/tcp 2>/dev/null || true
sleep 2
cd "$APP_DIR"
echo "Starting SmashPoint on port $PORT..."
echo "URL: $NEXTAUTH_URL"
npx next start --port $PORT

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://turbo.build/schema.json", "$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"], "globalDependencies": ["**/.env.*local"],
"pipeline": { "tasks": {
"build": { "build": {
"dependsOn": ["^build"], "dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"] "outputs": [".next/**", "!.next/cache/**", "dist/**"]