Compare commits

..

14 Commits

Author SHA1 Message Date
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
34 changed files with 1352 additions and 638 deletions

View File

@@ -29,9 +29,9 @@ export default function BookingsPage() {
return (
<div className="space-y-6">
<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">
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>
</div>

View File

@@ -66,9 +66,9 @@ interface MembershipPlan {
}
const membershipFilters = [
{ value: "", label: "Todos" },
{ value: "with", label: "Con membresia" },
{ value: "without", label: "Sin membresia" },
{ value: "", label: "All" },
{ value: "with", label: "With membership" },
{ value: "without", label: "Without membership" },
];
const ITEMS_PER_PAGE = 10;
@@ -112,7 +112,7 @@ export default function ClientsPage() {
params.append("offset", ((currentPage - 1) * ITEMS_PER_PAGE).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 players");
const data: ClientsResponse = await response.json();
@@ -138,7 +138,7 @@ export default function ClientsPage() {
setTotalClients(data.pagination.total);
} catch (err) {
console.error("Error fetching clients:", err);
setError(err instanceof Error ? err.message : "Error desconocido");
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoadingClients(false);
}
@@ -150,7 +150,7 @@ export default function ClientsPage() {
try {
// Fetch all clients to calculate stats
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 allClients = data.data;
@@ -186,7 +186,7 @@ export default function ClientsPage() {
const fetchMembershipPlans = useCallback(async () => {
try {
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();
setMembershipPlans(data.filter((p: MembershipPlan & { isActive?: boolean }) => p.isActive !== false));
} catch (err) {
@@ -198,12 +198,12 @@ export default function ClientsPage() {
const fetchClientDetails = async (clientId: string) => {
try {
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 player details");
const data = await response.json();
setSelectedClient(data);
} catch (err) {
console.error("Error fetching client details:", err);
setError(err instanceof Error ? err.message : "Error desconocido");
setError(err instanceof Error ? err.message : "Unknown error");
}
};
@@ -244,7 +244,7 @@ export default function ClientsPage() {
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Error al crear cliente");
throw new Error(errorData.error || "Error creating player");
}
setShowCreateForm(false);
@@ -276,7 +276,7 @@ export default function ClientsPage() {
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Error al actualizar cliente");
throw new Error(errorData.error || "Error updating player");
}
setEditingClient(null);
@@ -297,7 +297,7 @@ export default function ClientsPage() {
const handleDeleteClient = async (client: Client) => {
if (
!confirm(
`¿Estas seguro de desactivar a ${client.firstName} ${client.lastName}?`
`Are you sure you want to deactivate ${client.firstName} ${client.lastName}?`
)
) {
return;
@@ -310,13 +310,13 @@ export default function ClientsPage() {
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Error al desactivar cliente");
throw new Error(errorData.error || "Error deactivating player");
}
await Promise.all([fetchClients(), fetchStats()]);
} catch (err) {
console.error("Error deleting client:", err);
setError(err instanceof Error ? err.message : "Error desconocido");
setError(err instanceof Error ? err.message : "Unknown error");
}
};
@@ -337,7 +337,7 @@ export default function ClientsPage() {
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Error al asignar membresia");
throw new Error(errorData.error || "Error assigning membership");
}
setShowAssignMembership(false);
@@ -367,9 +367,9 @@ export default function ClientsPage() {
{/* 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">Clientes</h1>
<h1 className="text-2xl font-bold text-primary-800">Players</h1>
<p className="mt-1 text-primary-600">
Gestiona los clientes de tu centro
Manage your club's players
</p>
</div>
<Button onClick={() => setShowCreateForm(true)}>
@@ -386,7 +386,7 @@ export default function ClientsPage() {
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
New Player
</Button>
</div>
@@ -433,7 +433,7 @@ export default function ClientsPage() {
) : (
<>
<StatCard
title="Total Clientes"
title="Total Players"
value={stats.totalClients}
color="primary"
icon={
@@ -453,7 +453,7 @@ export default function ClientsPage() {
}
/>
<StatCard
title="Con Membresia"
title="With Membership"
value={stats.withMembership}
color="accent"
icon={
@@ -473,7 +473,7 @@ export default function ClientsPage() {
}
/>
<StatCard
title="Nuevos Este Mes"
title="New This Month"
value={stats.newThisMonth}
color="green"
icon={
@@ -504,7 +504,7 @@ export default function ClientsPage() {
<div className="flex-1">
<Input
type="text"
placeholder="Buscar por nombre, email o telefono..."
placeholder="Search by name, email or phone..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full"

View File

@@ -67,14 +67,14 @@ export default function DashboardPage() {
const response = await fetch(url);
if (!response.ok) {
throw new Error("Error al cargar los datos del dashboard");
throw new Error("Error loading dashboard data");
}
const data = await response.json();
setDashboardData(data);
} catch (err) {
console.error("Dashboard fetch error:", err);
setError(err instanceof Error ? err.message : "Error desconocido");
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setIsLoading(false);
}
@@ -84,7 +84,7 @@ export default function DashboardPage() {
fetchDashboardData();
}, [fetchDashboardData]);
const userName = session?.user?.name?.split(" ")[0] || "Usuario";
const userName = session?.user?.name?.split(" ")[0] || "User";
const today = new Date();
return (
@@ -93,10 +93,10 @@ export default function DashboardPage() {
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-primary-800">
Bienvenido, {userName}
Welcome, {userName}
</h1>
<p className="text-primary-500 mt-1">
{formatDate(today)} - Panel de administracion
{formatDate(today)} - Admin panel
</p>
</div>
{selectedSite && (
@@ -121,7 +121,7 @@ export default function DashboardPage() {
/>
</svg>
<span className="text-sm font-medium text-accent-700">
Mostrando: {selectedSite.name}
Showing: {selectedSite.name}
</span>
</div>
)}
@@ -161,7 +161,7 @@ export default function DashboardPage() {
) : dashboardData ? (
<>
<StatCard
title="Reservas Hoy"
title="Today's Bookings"
value={dashboardData.stats.todayBookings}
color="blue"
icon={
@@ -181,7 +181,7 @@ export default function DashboardPage() {
}
/>
<StatCard
title="Ingresos Hoy"
title="Today's Revenue"
value={formatCurrency(dashboardData.stats.todayRevenue)}
color="green"
icon={
@@ -201,7 +201,7 @@ export default function DashboardPage() {
}
/>
<StatCard
title="Ocupacion"
title="Occupancy"
value={`${dashboardData.stats.occupancyRate}%`}
color="purple"
icon={
@@ -221,7 +221,7 @@ export default function DashboardPage() {
}
/>
<StatCard
title="Miembros Activos"
title="Active Members"
value={dashboardData.stats.activeMembers}
color="accent"
icon={
@@ -248,7 +248,7 @@ export default function DashboardPage() {
{!isLoading && dashboardData && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<StatCard
title="Reservas Pendientes"
title="Pending Bookings"
value={dashboardData.stats.pendingBookings}
color="orange"
icon={
@@ -268,7 +268,7 @@ export default function DashboardPage() {
}
/>
<StatCard
title="Torneos Proximos"
title="Upcoming Events"
value={dashboardData.stats.upcomingTournaments}
color="primary"
icon={

View File

@@ -68,10 +68,10 @@ interface MembershipsResponse {
}
const statusFilters = [
{ value: "", label: "Todos" },
{ value: "ACTIVE", label: "Activas" },
{ value: "EXPIRED", label: "Expiradas" },
{ value: "CANCELLED", label: "Canceladas" },
{ value: "", label: "All" },
{ value: "ACTIVE", label: "Active" },
{ value: "EXPIRED", label: "Expired" },
{ value: "CANCELLED", label: "Cancelled" },
];
export default function MembershipsPage() {
@@ -104,12 +104,12 @@ export default function MembershipsPage() {
setLoadingPlans(true);
try {
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();
setPlans(data);
} catch (err) {
console.error("Error fetching plans:", err);
setError(err instanceof Error ? err.message : "Error desconocido");
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoadingPlans(false);
}
@@ -125,7 +125,7 @@ export default function MembershipsPage() {
if (searchQuery) params.append("search", searchQuery);
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();
setMemberships(data.data);
@@ -138,7 +138,7 @@ export default function MembershipsPage() {
});
} catch (err) {
console.error("Error fetching memberships:", err);
setError(err instanceof Error ? err.message : "Error desconocido");
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoadingMemberships(false);
}
@@ -209,7 +209,7 @@ export default function MembershipsPage() {
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Error al guardar plan");
throw new Error(errorData.error || "Error saving plan");
}
setShowPlanForm(false);
@@ -224,7 +224,7 @@ export default function MembershipsPage() {
// Handle plan deletion
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;
}
@@ -235,13 +235,13 @@ export default function MembershipsPage() {
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Error al eliminar plan");
throw new Error(errorData.error || "Error deleting plan");
}
await fetchPlans();
} catch (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) {
const errorData = await response.json();
throw new Error(errorData.error || "Error al asignar membresia");
throw new Error(errorData.error || "Error assigning membership");
}
setShowAssignDialog(false);
@@ -295,19 +295,19 @@ export default function MembershipsPage() {
if (!response.ok) {
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()]);
} catch (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
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;
}
@@ -318,13 +318,13 @@ export default function MembershipsPage() {
if (!response.ok) {
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()]);
} catch (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 */}
<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">Membresias</h1>
<h1 className="text-2xl font-bold text-primary-800">Memberships</h1>
<p className="mt-1 text-primary-600">
Gestiona planes y membresias de tus clientes
Manage plans and memberships for your players
</p>
</div>
</div>
@@ -374,7 +374,7 @@ export default function MembershipsPage() {
</svg>
</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>
</div>
</div>
@@ -396,7 +396,7 @@ export default function MembershipsPage() {
</svg>
</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>
</div>
</div>
@@ -412,7 +412,7 @@ export default function MembershipsPage() {
</svg>
</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>
</div>
</div>
@@ -428,7 +428,7 @@ export default function MembershipsPage() {
</svg>
</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">
{plans.reduce((sum, p) => sum + p.subscriberCount, 0)}
</p>
@@ -441,12 +441,12 @@ export default function MembershipsPage() {
{/* Plans Section */}
<section>
<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)}>
<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" />
</svg>
Nuevo Plan
New Plan
</Button>
</div>
@@ -454,7 +454,7 @@ export default function MembershipsPage() {
<div className="flex items-center justify-center py-12">
<div className="text-center">
<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>
) : 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" />
</svg>
<p className="font-medium text-primary-600">No hay planes</p>
<p className="text-sm text-primary-500 mt-1">Crea tu primer plan de membresia</p>
<p className="font-medium text-primary-600">No plans</p>
<p className="text-sm text-primary-500 mt-1">Create your first membership plan</p>
<Button className="mt-4" onClick={() => setShowPlanForm(true)}>
Crear Plan
Create Plan
</Button>
</CardContent>
</Card>
@@ -496,12 +496,12 @@ export default function MembershipsPage() {
{/* Memberships Section */}
<section>
<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)}>
<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" />
</svg>
Asignar Membresia
Assign Membership
</Button>
</div>
@@ -513,7 +513,7 @@ export default function MembershipsPage() {
<div className="flex-1">
<Input
type="text"
placeholder="Buscar por nombre de cliente..."
placeholder="Search by player name..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full"
@@ -527,7 +527,7 @@ export default function MembershipsPage() {
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"
>
<option value="">Todos los planes</option>
<option value="">All plans</option>
{activePlans.map((plan) => (
<option key={plan.id} value={plan.id}>
{plan.name}

View File

@@ -89,39 +89,39 @@ export default function ReportsPage() {
});
setDailyRevenue([
{ date: "Lun", bookings: 4200, sales: 1800, total: 6000 },
{ date: "Mar", bookings: 3800, sales: 1200, total: 5000 },
{ date: "Mié", bookings: 4500, sales: 2100, total: 6600 },
{ date: "Jue", bookings: 5200, sales: 1900, total: 7100 },
{ date: "Vie", bookings: 6800, sales: 3200, total: 10000 },
{ date: "Sáb", bookings: 8500, sales: 4100, total: 12600 },
{ date: "Dom", bookings: 7200, sales: 3500, total: 10700 },
{ 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: "Agua", quantity: 245, revenue: 4900 },
{ name: "Water", quantity: 245, revenue: 4900 },
{ name: "Gatorade", quantity: 180, revenue: 6300 },
{ name: "Cerveza", quantity: 156, revenue: 7020 },
{ name: "Pelotas HEAD", quantity: 42, revenue: 7560 },
{ name: "Raqueta alquiler", quantity: 38, revenue: 3800 },
{ name: "Beer", quantity: 156, revenue: 7020 },
{ name: "Pickleballs", quantity: 42, revenue: 7560 },
{ name: "Paddle Rental", quantity: 38, revenue: 3800 },
]);
setCourtStats([
{ name: "Cancha 1", site: "Sede Norte", bookings: 68, revenue: 20400, occupancy: 72 },
{ name: "Cancha 2", site: "Sede Norte", bookings: 54, revenue: 16200, occupancy: 58 },
{ name: "Cancha 1", site: "Sede Centro", bookings: 72, revenue: 21600, occupancy: 76 },
{ name: "Cancha 2", site: "Sede Centro", bookings: 61, revenue: 18300, occupancy: 65 },
{ name: "Cancha 1", site: "Sede Sur", bookings: 48, revenue: 14400, occupancy: 51 },
{ name: "Cancha 2", site: "Sede Sur", bookings: 39, revenue: 11700, occupancy: 42 },
{ 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("es-MX", {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "MXN",
currency: "USD",
minimumFractionDigits: 0,
}).format(amount);
};
@@ -133,8 +133,8 @@ export default function ReportsPage() {
{/* 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">Reportes</h1>
<p className="text-primary-600">Análisis y estasticas del negocio</p>
<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
@@ -142,14 +142,14 @@ export default function ReportsPage() {
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">Última semana</option>
<option value="month">Último mes</option>
<option value="quarter">Último trimestre</option>
<option value="year">Último año</option>
<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" />
Exportar
Export
</Button>
</div>
</div>
@@ -157,28 +157,28 @@ export default function ReportsPage() {
{/* KPI Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Ingresos Totales"
title="Total Revenue"
value={formatCurrency(stats.totalRevenue)}
change={stats.revenueChange}
icon={DollarSign}
loading={loading}
/>
<StatCard
title="Reservas"
title="Bookings"
value={stats.totalBookings.toString()}
change={stats.bookingsChange}
icon={Calendar}
loading={loading}
/>
<StatCard
title="Clientes Activos"
title="Active Players"
value={stats.totalClients.toString()}
change={stats.clientsChange}
icon={Users}
loading={loading}
/>
<StatCard
title="Ocupación Promedio"
title="Average Occupancy"
value={`${stats.avgOccupancy}%`}
change={stats.occupancyChange}
icon={Clock}
@@ -193,7 +193,7 @@ export default function ReportsPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-primary" />
Ingresos por Día
Revenue by Day
</CardTitle>
</CardHeader>
<CardContent>
@@ -224,11 +224,11 @@ export default function ReportsPage() {
<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">Reservas</span>
<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">Ventas</span>
<span className="text-xs text-primary-600">Sales</span>
</div>
</div>
</div>
@@ -239,7 +239,7 @@ export default function ReportsPage() {
{/* Top Products */}
<Card>
<CardHeader>
<CardTitle>Productos Más Vendidos</CardTitle>
<CardTitle>Top Selling Products</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
@@ -261,7 +261,7 @@ export default function ReportsPage() {
</span>
<div className="flex-1">
<p className="font-medium text-primary-800">{product.name}</p>
<p className="text-xs text-primary-500">{product.quantity} unidades</p>
<p className="text-xs text-primary-500">{product.quantity} units</p>
</div>
<span className="font-semibold text-primary-800">
{formatCurrency(product.revenue)}
@@ -277,7 +277,7 @@ export default function ReportsPage() {
{/* Courts Performance */}
<Card>
<CardHeader>
<CardTitle>Rendimiento por Cancha</CardTitle>
<CardTitle>Court Performance</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
@@ -291,11 +291,11 @@ export default function ReportsPage() {
<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">Cancha</th>
<th className="text-left py-3 px-4 text-sm font-medium text-primary-700">Sede</th>
<th className="text-center py-3 px-4 text-sm font-medium text-primary-700">Reservas</th>
<th className="text-right py-3 px-4 text-sm font-medium text-primary-700">Ingresos</th>
<th className="text-center py-3 px-4 text-sm font-medium text-primary-700">Ocupación</th>
<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>
@@ -337,34 +337,34 @@ export default function ReportsPage() {
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Mejor Día</CardTitle>
<CardTitle className="text-base">Best Day</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-primary-800">Sábado</p>
<p className="text-2xl font-bold text-primary-800">Saturday</p>
<p className="text-sm text-primary-600">
{formatCurrency(12600)} en ingresos promedio
{formatCurrency(12600)} in average revenue
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Hora Pico</CardTitle>
<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% de ocupación en este horario
85% occupancy during this time slot
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Ticket Promedio</CardTitle>
<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">
Por visita (reserva + consumo)
Per visit (booking + consumption)
</p>
</CardContent>
</Card>
@@ -419,7 +419,7 @@ function StatCard({
{isPositive ? "+" : ""}
{change}%
</span>
<span className="text-xs text-primary-500">vs período anterior</span>
<span className="text-xs text-primary-500">vs previous period</span>
</div>
</CardContent>
</Card>

View File

@@ -129,7 +129,7 @@ export default function SettingsPage() {
setLoading(true);
// Simulate save
await new Promise((resolve) => setTimeout(resolve, 500));
setMessage({ type: "success", text: "Configuración guardada correctamente" });
setMessage({ type: "success", text: "Settings saved successfully" });
setLoading(false);
setTimeout(() => setMessage(null), 3000);
};
@@ -147,15 +147,15 @@ export default function SettingsPage() {
});
if (res.ok) {
setMessage({ type: "success", text: editingSite ? "Sede actualizada" : "Sede creada" });
setMessage({ type: "success", text: editingSite ? "Site updated" : "Site created" });
fetchSites();
setShowSiteForm(false);
setEditingSite(null);
} else {
setMessage({ type: "error", text: "Error al guardar la sede" });
setMessage({ type: "error", text: "Error saving site" });
}
} catch (error) {
setMessage({ type: "error", text: "Error de conexión" });
setMessage({ type: "error", text: "Connection error" });
} finally {
setLoading(false);
setTimeout(() => setMessage(null), 3000);
@@ -175,15 +175,15 @@ export default function SettingsPage() {
});
if (res.ok) {
setMessage({ type: "success", text: editingCourt ? "Cancha actualizada" : "Cancha creada" });
setMessage({ type: "success", text: editingCourt ? "Court updated" : "Court created" });
fetchCourts();
setShowCourtForm(false);
setEditingCourt(null);
} else {
setMessage({ type: "error", text: "Error al guardar la cancha" });
setMessage({ type: "error", text: "Error saving court" });
}
} catch (error) {
setMessage({ type: "error", text: "Error de conexión" });
setMessage({ type: "error", text: "Connection error" });
} finally {
setLoading(false);
setTimeout(() => setMessage(null), 3000);
@@ -191,18 +191,18 @@ export default function SettingsPage() {
};
const handleDeleteCourt = async (courtId: string) => {
if (!confirm("¿Estás seguro de eliminar esta cancha?")) return;
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: "Cancha eliminada" });
setMessage({ type: "success", text: "Court deleted" });
fetchCourts();
} else {
setMessage({ type: "error", text: "Error al eliminar la cancha" });
setMessage({ type: "error", text: "Error deleting court" });
}
} catch (error) {
setMessage({ type: "error", text: "Error de conexión" });
setMessage({ type: "error", text: "Connection error" });
}
setTimeout(() => setMessage(null), 3000);
};
@@ -211,8 +211,8 @@ export default function SettingsPage() {
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-primary-800">Configuración</h1>
<p className="text-primary-600">Administra la configuración del sistema</p>
<h1 className="text-2xl font-bold text-primary-800">Settings</h1>
<p className="text-primary-600">Manage system settings</p>
</div>
{/* Message */}
@@ -233,19 +233,19 @@ export default function SettingsPage() {
<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">Organización</span>
<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">Sedes</span>
<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">Canchas</span>
<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">Usuarios</span>
<span className="hidden sm:inline">Users</span>
</TabsTrigger>
</TabsList>
@@ -253,34 +253,34 @@ export default function SettingsPage() {
<TabsContent value="organization" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Información de la Organización</CardTitle>
<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">
Nombre de la organización
Organization name
</label>
<Input
value={orgName}
onChange={(e) => setOrgName(e.target.value)}
placeholder="Nombre"
placeholder="Name"
/>
</div>
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">
Email de contacto
Contact email
</label>
<Input
type="email"
value={orgEmail}
onChange={(e) => setOrgEmail(e.target.value)}
placeholder="email@ejemplo.com"
placeholder="email@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">
Teléfono
Phone
</label>
<Input
value={orgPhone}
@@ -290,28 +290,28 @@ export default function SettingsPage() {
</div>
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">
Moneda
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 - Peso Mexicano</option>
<option value="USD">USD - lar</option>
<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">
Zona horaria
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">Ciudad de México</option>
<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>
@@ -322,7 +322,7 @@ export default function SettingsPage() {
<div className="pt-4">
<Button onClick={handleSaveOrganization} disabled={loading}>
<Save className="h-4 w-4 mr-2" />
{loading ? "Guardando..." : "Guardar cambios"}
{loading ? "Saving..." : "Save changes"}
</Button>
</div>
</CardContent>
@@ -330,31 +330,31 @@ export default function SettingsPage() {
<Card>
<CardHeader>
<CardTitle>Configuración de Reservas</CardTitle>
<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">
Duración por defecto (minutos)
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">
Anticipación mínima (horas)
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">
Anticipación máxima (días)
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">
Horas para cancelar
Cancellation window (hours)
</label>
<Input type="number" defaultValue={24} min={0} />
</div>
@@ -363,7 +363,7 @@ export default function SettingsPage() {
<div className="pt-4">
<Button onClick={handleSaveOrganization} disabled={loading}>
<Save className="h-4 w-4 mr-2" />
{loading ? "Guardando..." : "Guardar cambios"}
{loading ? "Saving..." : "Save changes"}
</Button>
</div>
</CardContent>
@@ -373,10 +373,10 @@ export default function SettingsPage() {
{/* 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">Sedes</h2>
<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" />
Nueva Sede
New Site
</Button>
</div>
@@ -423,7 +423,7 @@ export default function SettingsPage() {
: "bg-gray-100 text-gray-600"
}`}
>
{site.isActive ? "Activa" : "Inactiva"}
{site.isActive ? "Active" : "Inactive"}
</span>
</div>
</CardContent>
@@ -446,10 +446,10 @@ export default function SettingsPage() {
{/* 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">Canchas</h2>
<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" />
Nueva Cancha
New Court
</Button>
</div>
@@ -468,12 +468,12 @@ export default function SettingsPage() {
<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">Cancha</th>
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Sede</th>
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Tipo</th>
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Precio/hora</th>
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Estado</th>
<th className="text-right px-4 py-3 text-sm font-medium text-primary-700">Acciones</th>
<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>
@@ -493,7 +493,7 @@ export default function SettingsPage() {
: "bg-gray-100 text-gray-600"
}`}
>
{court.status === "active" ? "Activa" : court.status === "maintenance" ? "Mantenimiento" : "Inactiva"}
{court.status === "active" ? "Active" : court.status === "maintenance" ? "Maintenance" : "Inactive"}
</span>
</td>
<td className="px-4 py-3 text-right">
@@ -533,10 +533,10 @@ export default function SettingsPage() {
{/* 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">Usuarios</h2>
<h2 className="text-lg font-semibold text-primary-800">Users</h2>
<Button>
<Plus className="h-4 w-4 mr-2" />
Nuevo Usuario
New User
</Button>
</div>
@@ -555,12 +555,12 @@ export default function SettingsPage() {
<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">Usuario</th>
<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">Rol</th>
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Sede</th>
<th className="text-left px-4 py-3 text-sm font-medium text-primary-700">Estado</th>
<th className="text-right px-4 py-3 text-sm font-medium text-primary-700">Acciones</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>
@@ -573,11 +573,11 @@ export default function SettingsPage() {
<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" ? "Admin Sede" :
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 || "Todas"}</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 ${
@@ -586,7 +586,7 @@ export default function SettingsPage() {
: "bg-gray-100 text-gray-600"
}`}
>
{user.isActive ? "Activo" : "Inactivo"}
{user.isActive ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3 text-right">
@@ -636,7 +636,7 @@ function SiteFormModal({
<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 ? "Editar Sede" : "Nueva Sede"}
{site ? "Edit Site" : "New Site"}
</h3>
<button onClick={onClose} className="text-primary-500 hover:text-primary-700">
<X className="h-5 w-5" />
@@ -644,24 +644,24 @@ function SiteFormModal({
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">Nombre</label>
<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">Dirección</label>
<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">Teléfono</label>
<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">Hora apertura</label>
<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">Hora cierre</label>
<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>
@@ -673,14 +673,14 @@ function SiteFormModal({
onChange={(e) => setIsActive(e.target.checked)}
className="rounded border-primary-300"
/>
<label htmlFor="isActive" className="text-sm text-primary-700">Sede activa</label>
<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">
Cancelar
Cancel
</Button>
<Button type="submit" disabled={loading} className="flex-1">
{loading ? "Guardando..." : "Guardar"}
{loading ? "Saving..." : "Save"}
</Button>
</div>
</form>
@@ -727,7 +727,7 @@ function CourtFormModal({
<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 ? "Editar Cancha" : "Nueva Cancha"}
{court ? "Edit Court" : "New Court"}
</h3>
<button onClick={onClose} className="text-primary-500 hover:text-primary-700">
<X className="h-5 w-5" />
@@ -735,11 +735,11 @@ function CourtFormModal({
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">Nombre</label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Cancha 1" required />
<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">Sede</label>
<label className="block text-sm font-medium text-primary-700 mb-1">Site</label>
<select
value={siteId}
onChange={(e) => setSiteId(e.target.value)}
@@ -752,7 +752,7 @@ function CourtFormModal({
</select>
</div>
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">Tipo</label>
<label className="block text-sm font-medium text-primary-700 mb-1">Type</label>
<select
value={type}
onChange={(e) => setType(e.target.value)}
@@ -760,12 +760,12 @@ function CourtFormModal({
>
<option value="indoor">Indoor</option>
<option value="outdoor">Outdoor</option>
<option value="covered">Techada</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">Precio/hora</label>
<label className="block text-sm font-medium text-primary-700 mb-1">Price/hour</label>
<Input
type="number"
value={hourlyRate}
@@ -775,34 +775,34 @@ function CourtFormModal({
/>
</div>
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">Precio hora pico</label>
<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="Opcional"
placeholder="Optional"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">Estado</label>
<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">Activa</option>
<option value="maintenance">Mantenimiento</option>
<option value="inactive">Inactiva</option>
<option value="active">Active</option>
<option value="maintenance">Maintenance</option>
<option value="inactive">Inactive</option>
</select>
</div>
<div className="flex gap-3 pt-4">
<Button type="button" variant="outline" onClick={onClose} className="flex-1">
Cancelar
Cancel
</Button>
<Button type="submit" disabled={loading} className="flex-1">
{loading ? "Guardando..." : "Guardar"}
{loading ? "Saving..." : "Save"}
</Button>
</div>
</form>

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ export async function GET(request: NextRequest) {
if (!session?.user) {
return NextResponse.json(
{ error: 'No autorizado' },
{ error: 'Unauthorized' },
{ status: 401 }
);
}
@@ -138,7 +138,7 @@ export async function GET(request: NextRequest) {
} catch (error) {
console.error('Error fetching bookings:', error);
return NextResponse.json(
{ error: 'Error al obtener las reservas' },
{ error: 'Error fetching bookings' },
{ status: 500 }
);
}
@@ -151,7 +151,7 @@ export async function POST(request: NextRequest) {
if (!session?.user) {
return NextResponse.json(
{ error: 'No autorizado' },
{ error: 'Unauthorized' },
{ status: 401 }
);
}
@@ -163,7 +163,7 @@ export async function POST(request: NextRequest) {
if (!validationResult.success) {
return NextResponse.json(
{
error: 'Datos de reserva inválidos',
error: 'Invalid booking data',
details: validationResult.error.flatten().fieldErrors,
},
{ status: 400 }
@@ -193,14 +193,14 @@ export async function POST(request: NextRequest) {
if (!court) {
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 }
);
}
if (court.status !== 'AVAILABLE' || !court.isActive) {
return NextResponse.json(
{ error: 'La cancha no está disponible para reservas' },
{ error: 'The court is not available for bookings' },
{ status: 400 }
);
}
@@ -232,7 +232,7 @@ export async function POST(request: NextRequest) {
if (!client) {
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 }
);
}
@@ -269,7 +269,7 @@ export async function POST(request: NextRequest) {
if (existingBooking) {
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 }
);
}
@@ -391,7 +391,7 @@ export async function POST(request: NextRequest) {
} catch (error) {
console.error('Error creating booking:', error);
return NextResponse.json(
{ error: 'Error al crear la reserva' },
{ error: 'Error creating booking' },
{ status: 500 }
);
}

View File

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

View File

@@ -11,7 +11,7 @@ export async function GET(request: NextRequest) {
if (!session?.user) {
return NextResponse.json(
{ error: 'No autorizado' },
{ error: 'Unauthorized' },
{ status: 401 }
);
}
@@ -123,7 +123,7 @@ export async function GET(request: NextRequest) {
} catch (error) {
console.error('Error fetching clients:', error);
return NextResponse.json(
{ error: 'Error al obtener los clientes' },
{ error: 'Error fetching clients' },
{ status: 500 }
);
}
@@ -136,7 +136,7 @@ export async function POST(request: NextRequest) {
if (!session?.user) {
return NextResponse.json(
{ error: 'No autorizado' },
{ error: 'Unauthorized' },
{ status: 401 }
);
}
@@ -148,7 +148,7 @@ export async function POST(request: NextRequest) {
if (!validationResult.success) {
return NextResponse.json(
{
error: 'Datos del cliente inválidos',
error: 'Invalid client data',
details: validationResult.error.flatten().fieldErrors,
},
{ status: 400 }
@@ -181,7 +181,7 @@ export async function POST(request: NextRequest) {
if (existingClient) {
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 }
);
}
@@ -224,13 +224,13 @@ export async function POST(request: NextRequest) {
// Check for unique constraint violation
if (error instanceof Error && error.message.includes('Unique constraint')) {
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 }
);
}
return NextResponse.json(
{ error: 'Error al crear el cliente' },
{ error: 'Error creating client' },
{ status: 500 }
);
}

View File

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

View File

@@ -35,14 +35,14 @@ export async function GET(
});
if (!site) {
return NextResponse.json({ error: 'Sede no encontrada' }, { status: 404 });
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 al obtener sede' },
{ error: 'Error fetching site' },
{ status: 500 }
);
}
@@ -61,7 +61,7 @@ export async function PUT(
}
if (!['super_admin', 'site_admin'].includes(session.user.role)) {
return NextResponse.json({ error: 'Sin permisos' }, { status: 403 });
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
const body = await request.json();
@@ -76,7 +76,7 @@ export async function PUT(
});
if (!existingSite) {
return NextResponse.json({ error: 'Sede no encontrada' }, { status: 404 });
return NextResponse.json({ error: 'Site not found' }, { status: 404 });
}
const updateData: any = {};
@@ -102,7 +102,7 @@ export async function PUT(
} catch (error) {
console.error('Error updating site:', error);
return NextResponse.json(
{ error: 'Error al actualizar sede' },
{ error: 'Error updating site' },
{ status: 500 }
);
}
@@ -121,7 +121,7 @@ export async function DELETE(
}
if (session.user.role !== 'super_admin') {
return NextResponse.json({ error: 'Sin permisos' }, { status: 403 });
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
// Verify site belongs to organization
@@ -133,7 +133,7 @@ export async function DELETE(
});
if (!existingSite) {
return NextResponse.json({ error: 'Sede no encontrada' }, { status: 404 });
return NextResponse.json({ error: 'Site not found' }, { status: 404 });
}
// Soft delete
@@ -146,7 +146,7 @@ export async function DELETE(
} catch (error) {
console.error('Error deleting site:', error);
return NextResponse.json(
{ error: 'Error al eliminar sede' },
{ error: 'Error deleting site' },
{ status: 500 }
);
}

View File

@@ -80,7 +80,7 @@ export async function POST(request: NextRequest) {
}
if (!['super_admin', 'site_admin'].includes(session.user.role)) {
return NextResponse.json({ error: 'Sin permisos' }, { status: 403 });
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
const body = await request.json();
@@ -88,7 +88,7 @@ export async function POST(request: NextRequest) {
if (!name || !address) {
return NextResponse.json(
{ error: 'Nombre y dirección son requeridos' },
{ error: 'Name and address are required' },
{ status: 400 }
);
}
@@ -116,7 +116,7 @@ export async function POST(request: NextRequest) {
} catch (error) {
console.error('Error creating site:', error);
return NextResponse.json(
{ error: 'Error al crear sede' },
{ error: 'Error creating site' },
{ status: 500 }
);
}

View File

@@ -8,7 +8,7 @@ export async function GET(request: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const users = await db.user.findMany({
@@ -45,7 +45,7 @@ export async function GET(request: NextRequest) {
} catch (error) {
console.error("Error fetching users:", error);
return NextResponse.json(
{ error: "Error al obtener usuarios" },
{ error: "Error fetching users" },
{ status: 500 }
);
}
@@ -56,12 +56,12 @@ export async function POST(request: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
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: "Sin permisos" }, { status: 403 });
return NextResponse.json({ error: "Insufficient permissions" }, { status: 403 });
}
const body = await request.json();
@@ -69,7 +69,7 @@ export async function POST(request: NextRequest) {
if (!email || !password || !firstName || !lastName || !role) {
return NextResponse.json(
{ error: "Faltan campos requeridos" },
{ error: "Missing required fields" },
{ status: 400 }
);
}
@@ -84,7 +84,7 @@ export async function POST(request: NextRequest) {
if (existingUser) {
return NextResponse.json(
{ error: "El email ya está registrado" },
{ error: "This email is already registered" },
{ status: 400 }
);
}
@@ -129,7 +129,7 @@ export async function POST(request: NextRequest) {
} catch (error) {
console.error("Error creating user:", error);
return NextResponse.json(
{ error: "Error al crear usuario" },
{ error: "Error creating user" },
{ status: 500 }
);
}

View File

@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#F59E0B"/>
<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"/>

Before

Width:  |  Height:  |  Size: 326 B

After

Width:  |  Height:  |  Size: 326 B

View File

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

View File

@@ -6,7 +6,7 @@ export default function Home() {
<div className="text-center space-y-8 px-4">
{/* Logo */}
<div className="flex justify-center">
<div className="w-20 h-20 bg-amber-500 rounded-2xl flex items-center justify-center shadow-lg">
<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" />
@@ -16,10 +16,11 @@ export default function Home() {
</div>
</div>
<h1 className="text-5xl md:text-6xl font-bold text-primary-800">
SmashPoint
Cabo Pickleball Club
</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">
Sistema de Gestion para Clubes de Padel
Court Management System
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center mt-8">
<Link
@@ -29,10 +30,10 @@ export default function Home() {
Dashboard
</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"
>
Reservas
Book a Court
</Link>
</div>
</div>

View File

@@ -29,15 +29,15 @@ export function LoginForm({ className }: LoginFormProps) {
const newErrors: { email?: string; password?: string } = {};
if (!email) {
newErrors.email = 'El correo electrónico es requerido';
newErrors.email = 'Email is required';
} 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) {
newErrors.password = 'La contraseña es requerida';
newErrors.password = 'Password is required';
} 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);
@@ -62,13 +62,13 @@ export function LoginForm({ className }: LoginFormProps) {
});
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 {
router.push(callbackUrl);
router.refresh();
}
} 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 {
setIsLoading(false);
}
@@ -77,9 +77,9 @@ export function LoginForm({ className }: LoginFormProps) {
return (
<Card className={cn('w-full max-w-md', className)}>
<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>
Ingresa tus credenciales para acceder al sistema
Enter your credentials to access the system
</CardDescription>
</CardHeader>
<CardContent>
@@ -106,12 +106,12 @@ export function LoginForm({ className }: LoginFormProps) {
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium text-primary-700">
Correo Electrónico
Email
</label>
<Input
id="email"
type="email"
placeholder="correo@ejemplo.com"
placeholder="email@example.com"
value={email}
onChange={(e) => {
setEmail(e.target.value);
@@ -129,7 +129,7 @@ export function LoginForm({ className }: LoginFormProps) {
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium text-primary-700">
Contraseña
Password
</label>
<div className="relative">
<Input
@@ -199,13 +199,13 @@ export function LoginForm({ className }: LoginFormProps) {
onChange={(e) => setRememberMe(e.target.checked)}
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>
<a
href="#"
className="text-sm text-primary-600 hover:text-primary-800 hover:underline"
>
¿Olvidaste tu contraseña?
Forgot your password?
</a>
</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"
/>
</svg>
Iniciando sesión...
Signing in...
</div>
) : (
'Iniciar Sesión'
'Sign In'
)}
</Button>
</form>

View File

@@ -94,13 +94,13 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
const url = siteId ? `/api/courts?siteId=${siteId}` : "/api/courts";
const response = await fetch(url);
if (!response.ok) {
throw new Error("Error al cargar las canchas");
throw new Error("Error loading courts");
}
const data = await response.json();
setCourts(data);
return data as Court[];
} catch (err) {
setError(err instanceof Error ? err.message : "Error desconocido");
setError(err instanceof Error ? err.message : "Unknown error");
return [];
}
}, [siteId]);
@@ -113,7 +113,7 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
`/api/courts/${courtId}/availability?date=${dateStr}`
);
if (!response.ok) {
throw new Error(`Error al cargar disponibilidad`);
throw new Error(`Error loading availability`);
}
return (await response.json()) as CourtAvailability;
} catch (err) {
@@ -224,7 +224,7 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
fetchCourts();
}}
>
Reintentar
Retry
</Button>
</div>
</CardContent>
@@ -238,7 +238,7 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
<Card>
<CardHeader className="pb-4">
<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">
<Button variant="outline" size="sm" onClick={goToPrevDay}>
<svg
@@ -260,7 +260,7 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
size="sm"
onClick={goToToday}
>
Hoy
Today
</Button>
<Button variant="outline" size="sm" onClick={goToNextDay}>
<svg
@@ -286,12 +286,12 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
<div className="flex items-center justify-center p-12">
<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" />
<p className="text-sm text-primary-500">Cargando disponibilidad...</p>
<p className="text-sm text-primary-500">Loading availability...</p>
</div>
</div>
) : courts.length === 0 ? (
<div className="p-6 text-center text-primary-500">
<p>No hay canchas disponibles.</p>
<p>No courts available.</p>
</div>
) : (
<div className="overflow-x-auto">
@@ -304,8 +304,10 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
courts.length === 2 && "grid-cols-2",
courts.length === 3 && "grid-cols-3",
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) => (
<div
@@ -316,7 +318,7 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
{court.name}
</h3>
<p className="text-xs text-primary-500 mt-1">
{court.type === "INDOOR" ? "Interior" : "Exterior"}
{court.type === "INDOOR" ? "Indoor" : "Outdoor"}
</p>
</div>
))}
@@ -333,8 +335,10 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
courts.length === 2 && "grid-cols-2",
courts.length === 3 && "grid-cols-3",
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) => {
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"
>
<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>
);
@@ -373,7 +377,7 @@ export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
{timeSlots.length === 0 && (
<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>

View File

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

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"
/>
</svg>
Ocupacion de Canchas
Court Occupancy
</CardTitle>
</CardHeader>
<CardContent>
@@ -57,7 +57,7 @@ export function OccupancyChart({ data, isLoading = false }: OccupancyChartProps)
d="M20 12H4M12 20V4"
/>
</svg>
<p className="text-sm">No hay canchas configuradas</p>
<p className="text-sm">No courts configured</p>
</div>
</CardContent>
</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"
/>
</svg>
Ocupacion de Canchas
Court Occupancy
</CardTitle>
<div className="flex items-center gap-2">
<span
@@ -147,10 +147,10 @@ export function OccupancyChart({ data, isLoading = false }: OccupancyChartProps)
: "text-primary-500"
)}
>
{court.occupancyPercent}% ocupado
{court.occupancyPercent}% booked
</span>
<span className="text-xs text-green-600">
{court.availableHours - court.bookedHours}h disponible
{court.availableHours - court.bookedHours}h available
</span>
</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 gap-2">
<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 className="flex items-center gap-2">
<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>
</CardContent>

View File

@@ -14,7 +14,7 @@ interface QuickAction {
const quickActions: QuickAction[] = [
{
label: "Nueva Reserva",
label: "New Booking",
href: "/bookings",
icon: (
<svg
@@ -32,10 +32,10 @@ const quickActions: QuickAction[] = [
</svg>
),
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",
icon: (
<svg
@@ -53,10 +53,10 @@ const quickActions: QuickAction[] = [
</svg>
),
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",
icon: (
<svg
@@ -74,10 +74,10 @@ const quickActions: QuickAction[] = [
</svg>
),
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",
icon: (
<svg
@@ -95,7 +95,7 @@ const quickActions: QuickAction[] = [
</svg>
),
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"
/>
</svg>
Acciones Rapidas
Quick Actions
</CardTitle>
</CardHeader>
<CardContent>

View File

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

View File

@@ -98,7 +98,7 @@ export function StatCard({ title, value, icon, trend, color = "primary" }: StatC
{trend.isPositive ? "+" : ""}
{trend.value}%
</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>

View File

@@ -26,10 +26,10 @@ export function Header() {
<div className="flex items-center gap-4">
<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>
</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" />
</Button>
</div>

View File

@@ -5,8 +5,6 @@ import { usePathname } from 'next/navigation';
import {
LayoutDashboard,
Calendar,
Trophy,
ShoppingCart,
Users,
CreditCard,
BarChart3,
@@ -22,13 +20,11 @@ interface NavItem {
const navItems: NavItem[] = [
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ label: 'Reservas', href: '/bookings', icon: Calendar },
{ label: 'Torneos', href: '/tournaments', icon: Trophy },
{ label: 'Ventas', href: '/pos', icon: ShoppingCart },
{ label: 'Clientes', href: '/clients', icon: Users },
{ label: 'Membresías', href: '/memberships', icon: CreditCard },
{ label: 'Reportes', href: '/reports', icon: BarChart3 },
{ label: 'Configuración', href: '/settings', icon: Settings },
{ label: 'Bookings', href: '/bookings', icon: Calendar },
{ label: 'Players', href: '/clients', icon: Users },
{ label: 'Memberships', href: '/memberships', icon: CreditCard },
{ label: 'Reports', href: '/reports', icon: BarChart3 },
{ label: 'Settings', href: '/settings', icon: Settings },
];
export function Sidebar() {
@@ -38,7 +34,7 @@ export function Sidebar() {
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r border-primary-200 bg-white">
{/* Logo Section */}
<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-amber-500">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
<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" />
@@ -48,7 +44,7 @@ export function Sidebar() {
<circle cx="30" cy="4" r="1" fill="currentColor" opacity="0.5" />
</svg>
</div>
<span className="text-xl font-semibold text-primary-800">SmashPoint</span>
<span className="text-xl font-semibold text-primary-800">Cabo Pickleball</span>
</div>
{/* Navigation */}

View File

@@ -57,7 +57,7 @@ export function PlanCard({ plan, onEdit, onDelete, isAdmin = false }: PlanCardPr
? "bg-accent-100 text-accent-700"
: "bg-primary-100 text-primary-600"
)}>
{plan.subscriberCount} {plan.subscriberCount === 1 ? "suscriptor" : "suscriptores"}
{plan.subscriberCount} {plan.subscriberCount === 1 ? "subscriber" : "subscribers"}
</span>
</div>
{plan.description && (
@@ -72,7 +72,7 @@ export function PlanCard({ plan, onEdit, onDelete, isAdmin = false }: PlanCardPr
{formatCurrency(price)}
</div>
<div className="text-sm text-primary-500">
/{plan.durationMonths} {plan.durationMonths === 1 ? "mes" : "meses"}
/{plan.durationMonths} {plan.durationMonths === 1 ? "month" : "months"}
</div>
</div>
@@ -87,8 +87,8 @@ export function PlanCard({ plan, onEdit, onDelete, isAdmin = false }: PlanCardPr
</svg>
</div>
<div>
<p className="font-medium text-primary-800">{freeHours} horas gratis</p>
<p className="text-xs text-primary-500">de cancha al mes</p>
<p className="font-medium text-primary-800">{freeHours} free hours</p>
<p className="text-xs text-primary-500">of court time per month</p>
</div>
</div>
)}
@@ -102,8 +102,8 @@ export function PlanCard({ plan, onEdit, onDelete, isAdmin = false }: PlanCardPr
</svg>
</div>
<div>
<p className="font-medium text-primary-800">{discountPercent}% descuento</p>
<p className="text-xs text-primary-500">en reservas adicionales</p>
<p className="font-medium text-primary-800">{discountPercent}% discount</p>
<p className="text-xs text-primary-500">on additional bookings</p>
</div>
</div>
)}
@@ -117,8 +117,8 @@ export function PlanCard({ plan, onEdit, onDelete, isAdmin = false }: PlanCardPr
</svg>
</div>
<div>
<p className="font-medium text-primary-800">{storeDiscount}% descuento</p>
<p className="text-xs text-primary-500">en tienda</p>
<p className="font-medium text-primary-800">{storeDiscount}% discount</p>
<p className="text-xs text-primary-500">in store</p>
</div>
</div>
)}
@@ -126,7 +126,7 @@ export function PlanCard({ plan, onEdit, onDelete, isAdmin = false }: PlanCardPr
{/* Other Benefits */}
{otherBenefits.length > 0 && (
<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">
{otherBenefits.map((benefit, index) => (
<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">
<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>
Editar
Edit
</Button>
<Button
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">
<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>
Eliminar
Delete
</Button>
</CardFooter>
)}

View File

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

View File

@@ -40,12 +40,12 @@ async function main() {
const organization = await prisma.organization.create({
data: {
name: 'SmashPoint Demo',
slug: 'smashpoint-demo',
name: 'Cabo Pickleball Club',
slug: 'cabo-pickleball-club',
settings: {
currency: 'MXN',
timezone: 'America/Mexico_City',
language: 'es',
timezone: 'America/Mazatlan',
language: 'en',
},
},
});
@@ -56,39 +56,19 @@ async function main() {
// =============================================================================
// SITES
// =============================================================================
console.log('Creating sites...');
console.log('Creating site...');
const sitesData = [
{
name: 'Sede Norte',
slug: 'sede-norte',
address: 'Av. Universidad 1000, Col. Del Valle',
phone: '+52 55 1234 5678',
email: 'norte@smashpoint.com',
timezone: 'America/Mexico_City',
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: '23:00',
},
{
name: 'Sede Sur',
slug: 'sede-sur',
address: 'Av. Insurgentes 2000, Col. Roma',
phone: '+52 55 2345 6789',
email: 'sur@smashpoint.com',
timezone: 'America/Mexico_City',
openTime: '08:00',
closeTime: '22:00',
},
{
name: 'Sede Centro',
slug: 'sede-centro',
address: 'Calle Reforma 500, Centro Historico',
phone: '+52 55 3456 7890',
email: 'centro@smashpoint.com',
timezone: 'America/Mexico_City',
openTime: '06:00',
closeTime: '24:00',
},
];
const sites = await Promise.all(
@@ -107,44 +87,27 @@ async function main() {
console.log('');
// =============================================================================
// COURTS (2 per site)
// COURTS (6 outdoor courts)
// =============================================================================
console.log('Creating courts...');
const courts: { id: string; name: string; siteId: string }[] = [];
for (const site of sites) {
const courtData = [
{
name: 'Cancha 1',
type: CourtType.INDOOR,
status: CourtStatus.AVAILABLE,
pricePerHour: 350,
description: 'Cancha techada con iluminacion LED',
features: ['Iluminacion LED', 'Techada', 'Cristal panoramico'],
displayOrder: 1,
},
{
name: 'Cancha 2',
type: CourtType.INDOOR,
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) {
for (let i = 1; i <= 6; i++) {
const created = await prisma.court.create({
data: {
siteId: site.id,
...court,
siteId: sites[0].id,
name: `Court ${i}`,
type: CourtType.OUTDOOR,
status: CourtStatus.AVAILABLE,
pricePerHour: 300,
description: 'Outdoor court with night lighting',
features: ['Night lighting', 'Court dividers'],
displayOrder: i,
},
});
courts.push(created);
console.log(` Created court: ${site.name} - ${created.name}`);
}
console.log(` Created court: ${created.name}`);
}
console.log('');
@@ -152,17 +115,17 @@ async function main() {
// =============================================================================
// 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({
data: {
organizationId: organization.id,
email: 'admin@smashpoint.com',
email: 'ivan@horuxfin.com',
password: hashedPassword,
firstName: 'Administrador',
lastName: 'Sistema',
firstName: 'Ivan',
lastName: 'Admin',
role: UserRole.SUPER_ADMIN,
phone: '+52 55 9999 0000',
siteIds: sites.map(s => s.id),
@@ -171,41 +134,6 @@ async function main() {
console.log(` Created super admin: ${adminUser.email}`);
// =============================================================================
// SITE ADMINS (one per site)
// =============================================================================
const siteAdminsData = [
{ email: 'norte@smashpoint.com', firstName: 'Carlos', lastName: 'Rodriguez', site: sites[0] },
{ email: 'sur@smashpoint.com', firstName: 'Maria', lastName: 'Gonzalez', site: sites[1] },
{ email: 'centro@smashpoint.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('');
// =============================================================================
@@ -214,10 +142,10 @@ async function main() {
console.log('Creating product categories...');
const categoriesData = [
{ name: 'Bebidas', description: 'Bebidas y refrescos', displayOrder: 1 },
{ name: 'Snacks', description: 'Botanas y snacks', displayOrder: 2 },
{ name: 'Equipamiento', description: 'Equipo y accesorios de padel', displayOrder: 3 },
{ name: 'Alquiler', description: 'Articulos en renta', displayOrder: 4 },
{ name: 'Drinks', description: 'Beverages and refreshments', displayOrder: 1 },
{ name: 'Snacks', description: 'Snacks and light food', displayOrder: 2 },
{ name: 'Equipment', description: 'Pickleball equipment and accessories', displayOrder: 3 },
{ name: 'Rental', description: 'Rental items', displayOrder: 4 },
];
const categories: { id: string; name: string }[] = [];
@@ -236,28 +164,24 @@ async function main() {
console.log('');
// =============================================================================
// PRODUCTS (for organization, shown in Sede Norte initially)
// 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 equipamientoCategory = categories.find(c => c.name === 'Equipamiento');
const alquilerCategory = categories.find(c => c.name === 'Alquiler');
const equipmentCategory = categories.find(c => c.name === 'Equipment');
const rentalCategory = categories.find(c => c.name === 'Rental');
const productsData = [
// Bebidas
{ name: 'Agua', description: 'Agua natural 600ml', price: 20, costPrice: 8, stock: 100, categoryId: bebidasCategory?.id, sku: 'BEB-001' },
{ name: 'Gatorade', description: 'Bebida deportiva 500ml', price: 35, costPrice: 18, stock: 50, categoryId: bebidasCategory?.id, sku: 'BEB-002' },
{ name: 'Cerveza', description: 'Cerveza artesanal 355ml', price: 45, costPrice: 22, stock: 48, categoryId: bebidasCategory?.id, sku: 'BEB-003' },
// Snacks
{ name: 'Papas', description: 'Papas fritas 45g', price: 25, costPrice: 12, stock: 30, categoryId: snacksCategory?.id, sku: 'SNK-001' },
{ name: 'Barra energetica', description: 'Barra de proteina 50g', price: 30, costPrice: 15, stock: 25, categoryId: snacksCategory?.id, sku: 'SNK-002' },
// Equipamiento
{ 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 },
{ name: 'Water', description: 'Natural water 600ml', price: 20, costPrice: 8, stock: 100, categoryId: drinksCategory?.id, sku: 'DRK-001' },
{ name: 'Gatorade', description: 'Sports drink 500ml', price: 35, costPrice: 18, stock: 50, categoryId: drinksCategory?.id, sku: 'DRK-002' },
{ name: 'Beer', description: 'Craft beer 355ml', price: 45, costPrice: 22, stock: 48, categoryId: drinksCategory?.id, sku: 'DRK-003' },
{ name: 'Chips', description: 'Potato chips 45g', price: 25, costPrice: 12, stock: 30, categoryId: snacksCategory?.id, sku: 'SNK-001' },
{ name: 'Energy Bar', description: 'Protein bar 50g', price: 30, costPrice: 15, stock: 25, categoryId: snacksCategory?.id, sku: 'SNK-002' },
{ name: 'Pickleballs', description: 'Franklin X-40 Outdoor (3 pack)', price: 180, costPrice: 90, stock: 20, categoryId: equipmentCategory?.id, sku: 'EQP-001' },
{ name: 'Paddle Grip', description: 'Replacement grip', price: 50, costPrice: 25, stock: 40, categoryId: equipmentCategory?.id, sku: 'EQP-002' },
{ name: 'Paddle Rental', description: 'Pickleball paddle rental (per session)', price: 100, costPrice: 0, stock: 10, categoryId: rentalCategory?.id, sku: 'RNT-001', trackStock: false },
];
for (const productData of productsData) {
@@ -279,31 +203,49 @@ async function main() {
const membershipPlansData = [
{
name: 'Basico',
description: 'Plan basico mensual con beneficios esenciales',
price: 499,
name: 'Day Pass',
description: 'Single day access to all courts',
price: 300,
durationMonths: 1,
courtHours: 2,
discountPercent: 10,
benefits: ['2 horas gratis de cancha al mes', '10% descuento en reservas', '5% descuento en tienda'],
courtHours: 0,
discountPercent: 0,
benefits: ['Full day access', 'All courts', 'Night play included'],
},
{
name: 'Premium',
description: 'Plan premium con mayores beneficios',
price: 899,
durationMonths: 1,
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,
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: ['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 +259,7 @@ async function main() {
},
});
membershipPlans.push(plan);
console.log(` Created membership plan: ${plan.name} - $${plan.price}/mes`);
console.log(` Created membership plan: ${plan.name} - $${plan.price}`);
}
console.log('');
@@ -386,31 +328,31 @@ async function main() {
console.log('');
// =============================================================================
// MEMBERSHIP FOR ONE CLIENT (Maria Garcia with Premium)
// MEMBERSHIP FOR ONE CLIENT (Maria Garcia with Monthly Individual)
// =============================================================================
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');
if (premiumPlan && mariaClient) {
if (monthlyPlan && mariaClient) {
const startDate = new Date();
const endDate = new Date();
endDate.setMonth(endDate.getMonth() + 1);
const membership = await prisma.membership.create({
data: {
planId: premiumPlan.id,
planId: monthlyPlan.id,
clientId: mariaClient.id,
startDate,
endDate,
status: MembershipStatus.ACTIVE,
remainingHours: premiumPlan.courtHours,
remainingHours: monthlyPlan.courtHours,
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('');
@@ -424,9 +366,9 @@ async function main() {
console.log('');
console.log('Summary:');
console.log(` - 1 Organization: ${organization.name}`);
console.log(` - ${sites.length} Sites`);
console.log(` - ${courts.length} Courts (${courts.length / sites.length} per site)`);
console.log(` - 4 Users (1 super admin + 3 site admins)`);
console.log(` - ${sites.length} Site`);
console.log(` - ${courts.length} Courts`);
console.log(` - 1 Admin user`);
console.log(` - ${categories.length} Product Categories`);
console.log(` - ${productsData.length} Products`);
console.log(` - ${membershipPlans.length} Membership Plans`);
@@ -434,8 +376,7 @@ async function main() {
console.log(` - 1 Active Membership`);
console.log('');
console.log('Login credentials:');
console.log(' Super Admin: admin@smashpoint.com / admin123');
console.log(' Site Admins: norte@smashpoint.com, sur@smashpoint.com, centro@smashpoint.com / admin123');
console.log(' Admin: ivan@horuxfin.com / Aasi940812');
console.log('');
}

View File

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

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
```