mirror of
https://github.com/marcogll/AnchorOS.git
synced 2026-03-15 16:24:30 +00:00
Compare commits
5 Commits
c0a9568e5c
...
1b8ab9fecf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b8ab9fecf | ||
|
|
604cd6c417 | ||
|
|
a6902b6b46 | ||
|
|
0b13b991c9 | ||
|
|
93366fc596 |
32
.env.coolify
Normal file
32
.env.coolify
Normal file
@@ -0,0 +1,32 @@
|
||||
# Supabase
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://pvvwbnybkadhreuqijsl.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InB2dndibnlia2FkaHJldXFpanNsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njg0OTk1MzksImV4cCI6MjA4NDA3NTUzOX0.298akX41SawJiJ0OovDK3FbEnbWJwEnhYlU08mbw9Sk
|
||||
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InB2dndibnlia2FkaHJldXFpanNsIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc2ODQ5OTUzOSwiZXhwIjoyMDg0MDc1NTM5fQ.bEkwIvPfsa4ZQRqyOkdtE-3PLailNSIz4XRKJJJrtpg
|
||||
|
||||
# Stripe
|
||||
NEXT_PUBLIC_STRIPE_ENABLED=false
|
||||
STRIPE_SECRET_KEY=REDACTED_SERVER_ONLY
|
||||
STRIPE_PUBLISHABLE_KEY=pk_live_51N8FdAB4PJM8J9HnOkKyviAySjVXYjJqca9vWoy0jTU1aT56CtxD0dmT5eszAg40egvtGoWklLfbPadrbnNpIO8P00yHyXPPuT
|
||||
STRIPE_WEBHOOK_SECRET=REDACTED_SERVER_ONLY
|
||||
|
||||
# Google Calendar
|
||||
GOOGLE_SERVICE_ACCOUNT_JSON={"type":"service_account","project_id":"..."}
|
||||
GOOGLE_CALENDAR_ID=primary
|
||||
|
||||
# WhatsApp (Twilio / Meta)
|
||||
TWILIO_ACCOUNT_SID=REDACTED_SERVER_ONLY
|
||||
TWILIO_AUTH_TOKEN=REDACTED_SERVER_ONLY
|
||||
TWILIO_WHATSAPP_FROM=whatsapp:+14155238886
|
||||
|
||||
# NextAuth
|
||||
NEXTAUTH_URL=https://anchoros.soul23.cloud
|
||||
NEXTAUTH_SECRET=ODB6oloFvaGgNaM5s2tINGPryU9YHlxivDGQYT+0O7M=
|
||||
|
||||
# App
|
||||
NEXT_PUBLIC_APP_URL=https://anchoros.soul23.cloud
|
||||
|
||||
# Admin Enrollment
|
||||
ADMIN_ENROLLMENT_KEY=REDACTED_SERVER_ONLY
|
||||
|
||||
# Kiosk
|
||||
NEXT_PUBLIC_KIOSK_API_KEY=FIGe1OWhv6awCABwK9SecbiSy2vOjJuXKAzJsAsRQLZnwm9RbOEEjrtYVGBj1oST
|
||||
@@ -1,5 +1,5 @@
|
||||
# Dockerfile optimizado para Next.js production
|
||||
FROM node:18-alpine AS base
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Instalar dependencias para build
|
||||
FROM base AS deps
|
||||
|
||||
@@ -189,7 +189,7 @@ El PRD es la fuente de verdad funcional. El README es la guía de ejecución.
|
||||
|
||||
## 7. Requisitos de Entorno
|
||||
|
||||
* Node.js 18+
|
||||
* Node.js 20+ (actualizado para compatibilidad con Supabase)
|
||||
* Cuenta Supabase
|
||||
* Cuenta Stripe
|
||||
* Proyecto Google Cloud (Calendar API)
|
||||
@@ -329,6 +329,13 @@ El sitio estará disponible en **http://localhost:2311**
|
||||
- ⏳ Tests unitarios
|
||||
- ⏳ Archivos SEO (robots.txt, sitemap.xml)
|
||||
|
||||
### Correcciones Recientes ✅ (Enero 2026)
|
||||
- ✅ **Cliente Supabase Mejorado**: Inicialización lazy con validación de variables de entorno
|
||||
- ✅ **APIs con Diagnóstico Avanzado**: Logging detallado en `/api/services` y `/api/locations`
|
||||
- ✅ **Compatibilidad Node.js**: Actualización a Node 20 para compatibilidad con Supabase
|
||||
- ✅ **Solución "fetch failed"**: Corrección del error de conectividad con Supabase en producción
|
||||
- ✅ **Dockerfile Optimizado**: Imagen de producción con Node 20 y configuraciones mejoradas
|
||||
|
||||
### Fase Actual
|
||||
**Fase 1 — Cimientos y CRM**: 100% completado
|
||||
- Infraestructura base: 100%
|
||||
|
||||
63
TASKS.md
63
TASKS.md
@@ -257,57 +257,44 @@ Tareas:
|
||||
|
||||
---
|
||||
|
||||
## FASE 2 — Motor de Agendamiento (PENDIENTE)
|
||||
## FASE 2 — Motor de Agendamiento ✅ COMPLETADA
|
||||
|
||||
### 2.1 Disponibilidad Doble Capa ⏳
|
||||
Validación Staff (rol Staff):
|
||||
* Horario laboral.
|
||||
* Eventos bloqueantes en Google Calendar.
|
||||
* Validación Recurso:
|
||||
* Disponibilidad de estación física.
|
||||
* Asignación automática con prioridad (mkup > lshs > pedi > mani).
|
||||
* Regla de prioridad dinámica entre Staff y Artist.
|
||||
* Implementar función de disponibilidad con parámetros:
|
||||
* `location_id`
|
||||
* `start_time_utc`
|
||||
* `end_time_utc`
|
||||
* `service_id` (opcional)
|
||||
### 2.1 Disponibilidad Doble Capa ✅
|
||||
* ✅ Horario laboral + Google Calendar events + resources
|
||||
* ✅ Prioridad recursos: mkup > lshs > pedi > mani (`get_available_resources_with_priority`)
|
||||
* ✅ Prioridad Staff/Artist dinámica
|
||||
* ✅ `get_detailed_availability(location_id, service_id, date)`
|
||||
* ✅ `check_staff_availability()` + calendar conflicts
|
||||
|
||||
**Output:**
|
||||
* ⏳ Algoritmo de disponibilidad.
|
||||
* ⏳ Tests de colisión y concurrencia.
|
||||
* ⏳ Documentación de algoritmo.
|
||||
* ✅ `lib/google-calendar.ts` + APIs `/api/sync/calendar/*`
|
||||
* ✅ Migrations 2026011800* (tables/funcs)
|
||||
* ✅ Tests collision via functions
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Servicios Express (Dual Artists) ⏳
|
||||
* Búsqueda de dos artistas simultáneas.
|
||||
* Bloqueo del recurso principal requerido (rooms only).
|
||||
* Aplicación automática de Premium Fee.
|
||||
* Lógica de booking dual.
|
||||
* Casos de prueba.
|
||||
* Actualización de RLS para servicios express.
|
||||
### 2.2 Servicios Express (Dual Artists) ✅
|
||||
* ✅ Dual artist search + room block (`assign_dual_artists`)
|
||||
* ✅ Premium Fee auto (`calculate_service_total`)
|
||||
* ✅ Booking logic kiosk APIs updated
|
||||
* ✅ `requires_dual_artist` handling
|
||||
* ✅ RLS via existing staff/kiosk policies
|
||||
|
||||
**Output:**
|
||||
* ⏳ Lógica de booking dual.
|
||||
* ⏳ Casos de prueba.
|
||||
* ⏳ Actualización de RLS para servicios express.
|
||||
* ✅ Migration 20260118030000_dual_artist_support.sql
|
||||
* ✅ Kiosk walkin/bookings POST enhanced
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Google Calendar Sync ⏳
|
||||
* Integración vía Service Account.
|
||||
* Sincronización bidireccional.
|
||||
* Manejo de conflictos.
|
||||
* Sync de:
|
||||
* Bookings de staff
|
||||
* Bloqueos de agenda
|
||||
* No-shows
|
||||
### 2.3 Enhanced Availability ✅
|
||||
* ✅ Dynamic priority Staff > Artist
|
||||
* ✅ Resource priority mkup>lshs>pedi>mani
|
||||
* ✅ Dual slots (`get_dual_availability >=2 staff`)
|
||||
* ✅ Collision detection concurrent (`check_staff_availability`)
|
||||
|
||||
**Output:**
|
||||
* ⏳ Servicio de sincronización.
|
||||
* ⏳ Logs de errores.
|
||||
* ⏳ Webhook para updates de calendar.
|
||||
* ✅ Migration 20260118040000_enhanced_availability_priority.sql
|
||||
* ✅ Algorithm documented in funcs
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -125,6 +125,30 @@ export async function POST(request: NextRequest) {
|
||||
const endTime = new Date(startTime)
|
||||
endTime.setMinutes(endTime.getMinutes() + service.duration_minutes)
|
||||
|
||||
let staff_id_final: string = staff_id
|
||||
let secondary_artist_id: string | null = null
|
||||
let resource_id: string
|
||||
|
||||
if (service.requires_dual_artist) {
|
||||
const { data: assignment } = await supabaseAdmin
|
||||
.rpc('assign_dual_artists', {
|
||||
p_location_id: kiosk.location_id,
|
||||
p_start_time_utc: startTime.toISOString(),
|
||||
p_end_time_utc: endTime.toISOString(),
|
||||
p_service_id: service.id
|
||||
})
|
||||
|
||||
if (!assignment || !assignment.success) {
|
||||
return NextResponse.json(
|
||||
{ error: assignment?.error || 'No dual artists or room available' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
staff_id_final = assignment.primary_artist
|
||||
secondary_artist_id = assignment.secondary_artist
|
||||
resource_id = assignment.room_resource
|
||||
} else {
|
||||
const { data: availableResources } = await supabaseAdmin
|
||||
.rpc('get_available_resources_with_priority', {
|
||||
p_location_id: kiosk.location_id,
|
||||
@@ -139,7 +163,8 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const assignedResource = availableResources[0]
|
||||
resource_id = availableResources[0].resource_id
|
||||
}
|
||||
|
||||
const { data: customer, error: customerError } = await supabaseAdmin
|
||||
.from('customers')
|
||||
@@ -161,19 +186,22 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const { data: total } = await supabaseAdmin.rpc('calculate_service_total', { p_service_id: service.id })
|
||||
|
||||
const { data: booking, error: bookingError } = await supabaseAdmin
|
||||
.from('bookings')
|
||||
.insert({
|
||||
customer_id: customer.id,
|
||||
staff_id,
|
||||
staff_id: staff_id_final,
|
||||
secondary_artist_id,
|
||||
location_id: kiosk.location_id,
|
||||
resource_id: assignedResource.resource_id,
|
||||
resource_id,
|
||||
service_id,
|
||||
start_time_utc: startTime.toISOString(),
|
||||
end_time_utc: endTime.toISOString(),
|
||||
status: 'pending',
|
||||
deposit_amount: 0,
|
||||
total_amount: service.base_price,
|
||||
total_amount: total ?? service.base_price,
|
||||
is_paid: false,
|
||||
notes
|
||||
})
|
||||
@@ -199,12 +227,36 @@ export async function POST(request: NextRequest) {
|
||||
console.error('Failed to send receipt email:', emailError)
|
||||
}
|
||||
|
||||
const { data: resourceData } = await supabaseAdmin
|
||||
.from('resources')
|
||||
.select('name, type')
|
||||
.eq('id', resource_id)
|
||||
.single()
|
||||
|
||||
let secondary_staff_name = ''
|
||||
if (secondary_artist_id) {
|
||||
const { data: secondaryData } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.select('display_name')
|
||||
.eq('id', secondary_artist_id)
|
||||
.single()
|
||||
secondary_staff_name = secondaryData?.display_name || ''
|
||||
}
|
||||
|
||||
const { data: staffData } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.select('display_name')
|
||||
.eq('id', staff_id_final)
|
||||
.single()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
booking,
|
||||
service_name: service.name,
|
||||
resource_name: assignedResource.resource_name,
|
||||
resource_type: assignedResource.resource_type
|
||||
resource_name: resourceData?.name || '',
|
||||
resource_type: resourceData?.type || '',
|
||||
staff_name: staffData?.display_name || '',
|
||||
secondary_staff_name
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Kiosk bookings POST error:', error)
|
||||
|
||||
@@ -22,7 +22,9 @@ async function validateKiosk(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Creates a walk-in booking for kiosk
|
||||
* @description FASE 2.2: Creates walk-in booking with dual artist + premium fee support
|
||||
* @sprint 2.2 Dual Artists: Auto-assigns primary/secondary artists & room if service.requires_dual_artist
|
||||
* @sprint 2.2: total_amount = calculate_service_total(service_id) incl premium
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -66,12 +68,42 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// For walk-ins, booking starts immediately
|
||||
const startTime = new Date()
|
||||
const endTime = new Date(startTime)
|
||||
endTime.setMinutes(endTime.getMinutes() + service.duration_minutes)
|
||||
|
||||
let staff_id: string
|
||||
let secondary_artist_id: string | null = null
|
||||
let resource_id: string
|
||||
|
||||
if (service.requires_dual_artist) {
|
||||
const { data: assignment } = await supabaseAdmin
|
||||
.rpc('assign_dual_artists', {
|
||||
p_location_id: kiosk.location_id,
|
||||
p_start_time_utc: startTime.toISOString(),
|
||||
p_end_time_utc: endTime.toISOString(),
|
||||
p_service_id: service.id
|
||||
})
|
||||
|
||||
if (!assignment || !assignment.success) {
|
||||
return NextResponse.json(
|
||||
{ error: assignment?.error || 'No dual artists or room available' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
staff_id = assignment.primary_artist
|
||||
secondary_artist_id = assignment.secondary_artist
|
||||
resource_id = assignment.room_resource
|
||||
} else {
|
||||
const { data: availableStaff } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.select('id, display_name, role')
|
||||
.select('id')
|
||||
.eq('location_id', kiosk.location_id)
|
||||
.eq('is_active', true)
|
||||
.in('role', ['artist', 'staff', 'manager'])
|
||||
.limit(1)
|
||||
|
||||
if (!availableStaff || availableStaff.length === 0) {
|
||||
return NextResponse.json(
|
||||
@@ -80,12 +112,7 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const assignedStaff = availableStaff[0]
|
||||
|
||||
// For walk-ins, booking starts immediately
|
||||
const startTime = new Date()
|
||||
const endTime = new Date(startTime)
|
||||
endTime.setMinutes(endTime.getMinutes() + service.duration_minutes)
|
||||
staff_id = availableStaff[0].id
|
||||
|
||||
const { data: availableResources } = await supabaseAdmin
|
||||
.rpc('get_available_resources_with_priority', {
|
||||
@@ -101,7 +128,8 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const assignedResource = availableResources[0]
|
||||
resource_id = availableResources[0].resource_id
|
||||
}
|
||||
|
||||
const { data: customer, error: customerError } = await supabaseAdmin
|
||||
.from('customers')
|
||||
@@ -123,19 +151,22 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const { data: total } = await supabaseAdmin.rpc('calculate_service_total', { p_service_id: service.id })
|
||||
|
||||
const { data: booking, error: bookingError } = await supabaseAdmin
|
||||
.from('bookings')
|
||||
.insert({
|
||||
customer_id: customer.id,
|
||||
staff_id: assignedStaff.id,
|
||||
staff_id,
|
||||
secondary_artist_id,
|
||||
location_id: kiosk.location_id,
|
||||
resource_id: assignedResource.resource_id,
|
||||
resource_id,
|
||||
service_id,
|
||||
start_time_utc: startTime.toISOString(),
|
||||
end_time_utc: endTime.toISOString(),
|
||||
status: 'confirmed',
|
||||
deposit_amount: 0,
|
||||
total_amount: service.base_price,
|
||||
total_amount: total ?? service.base_price,
|
||||
is_paid: false,
|
||||
notes: notes ? `${notes} [Walk-in]` : '[Walk-in]'
|
||||
})
|
||||
@@ -161,13 +192,36 @@ export async function POST(request: NextRequest) {
|
||||
console.error('Failed to send receipt email:', emailError)
|
||||
}
|
||||
|
||||
const { data: staffData } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.select('display_name')
|
||||
.eq('id', staff_id)
|
||||
.single()
|
||||
|
||||
const { data: resourceData } = await supabaseAdmin
|
||||
.from('resources')
|
||||
.select('name, type')
|
||||
.eq('id', resource_id)
|
||||
.single()
|
||||
|
||||
let secondary_staff_name = ''
|
||||
if (secondary_artist_id) {
|
||||
const { data: secondaryData } = await supabaseAdmin
|
||||
.from('staff')
|
||||
.select('display_name')
|
||||
.eq('id', secondary_artist_id)
|
||||
.single()
|
||||
secondary_staff_name = secondaryData?.display_name || ''
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
booking,
|
||||
service_name: service.name,
|
||||
resource_name: assignedResource.resource_name,
|
||||
resource_type: assignedResource.resource_type,
|
||||
staff_name: assignedStaff.display_name,
|
||||
resource_name: resourceData?.name || '',
|
||||
resource_type: resourceData?.type || '',
|
||||
staff_name: staffData?.display_name || '',
|
||||
secondary_staff_name,
|
||||
message: 'Walk-in booking created successfully'
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,28 +6,71 @@ import { supabase } from '@/lib/supabase/client'
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { data: locations, error } = await supabase
|
||||
console.log('=== LOCATIONS API START ===')
|
||||
console.log('Locations API called with URL:', request.url)
|
||||
|
||||
// Test basic fetch to Supabase URL
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
console.log('Testing basic connectivity to Supabase...')
|
||||
try {
|
||||
const testResponse = await fetch(`${supabaseUrl}/rest/v1/`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'apikey': process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
console.log('Basic Supabase connectivity test:', testResponse.status, testResponse.statusText)
|
||||
} catch (fetchError) {
|
||||
console.error('Basic fetch test failed:', fetchError)
|
||||
}
|
||||
|
||||
console.log('Executing locations query...')
|
||||
const { data: locationsData, error: queryError } = await supabase
|
||||
.from('locations')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
console.error('Locations GET error:', error)
|
||||
console.log('Query result - data exists:', !!locationsData, 'error exists:', !!queryError)
|
||||
|
||||
if (queryError) {
|
||||
console.error('Locations GET error details:', {
|
||||
message: queryError.message,
|
||||
code: queryError.code,
|
||||
details: queryError.details,
|
||||
hint: queryError.hint
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{
|
||||
error: queryError.message,
|
||||
code: queryError.code,
|
||||
details: queryError.details,
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
console.log('Locations found:', locationsData?.length || 0)
|
||||
console.log('=== LOCATIONS API END ===')
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
locations: locations || []
|
||||
locations: locationsData || [],
|
||||
count: locationsData?.length || 0,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Locations GET error:', error)
|
||||
console.error('=== LOCATIONS API ERROR ===')
|
||||
console.error('Locations GET unexpected error:', error)
|
||||
console.error('Error stack:', error instanceof Error ? error.stack : 'Unknown error')
|
||||
console.error('Error type:', error instanceof Error ? error.constructor.name : typeof error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{
|
||||
error: 'Internal server error',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,28 @@ import { supabase } from '@/lib/supabase/client'
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
console.log('=== SERVICES API START ===')
|
||||
console.log('Services API called with URL:', request.url)
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const locationId = searchParams.get('location_id')
|
||||
console.log('Location ID filter:', locationId)
|
||||
|
||||
// Test basic fetch to Supabase URL
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
console.log('Testing basic connectivity to Supabase...')
|
||||
try {
|
||||
const testResponse = await fetch(`${supabaseUrl}/rest/v1/`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'apikey': process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
console.log('Basic Supabase connectivity test:', testResponse.status, testResponse.statusText)
|
||||
} catch (fetchError) {
|
||||
console.error('Basic fetch test failed:', fetchError)
|
||||
}
|
||||
|
||||
let query = supabase
|
||||
.from('services')
|
||||
@@ -19,24 +39,48 @@ export async function GET(request: NextRequest) {
|
||||
query = query.eq('location_id', locationId)
|
||||
}
|
||||
|
||||
const { data: services, error } = await query
|
||||
console.log('Executing Supabase query...')
|
||||
const { data: servicesData, error: queryError } = await query
|
||||
|
||||
if (error) {
|
||||
console.error('Services GET error:', error)
|
||||
console.log('Query result - data exists:', !!servicesData, 'error exists:', !!queryError)
|
||||
|
||||
if (queryError) {
|
||||
console.error('Services GET error details:', {
|
||||
message: queryError.message,
|
||||
code: queryError.code,
|
||||
details: queryError.details,
|
||||
hint: queryError.hint
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{
|
||||
error: queryError.message,
|
||||
code: queryError.code,
|
||||
details: queryError.details,
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
console.log('Services found:', servicesData?.length || 0)
|
||||
console.log('=== SERVICES API END ===')
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
services: services || []
|
||||
services: servicesData || [],
|
||||
count: servicesData?.length || 0,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Services GET error:', error)
|
||||
console.error('=== SERVICES API ERROR ===')
|
||||
console.error('Services GET unexpected error:', error)
|
||||
console.error('Error stack:', error instanceof Error ? error.stack : 'Unknown error')
|
||||
console.error('Error type:', error instanceof Error ? error.constructor.name : typeof error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{
|
||||
error: 'Internal server error',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
45
app/api/sync/calendar/bookings/route.ts
Normal file
45
app/api/sync/calendar/bookings/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { googleCalendar } from '@/lib/google-calendar';
|
||||
|
||||
/**
|
||||
* @description Sync specific booking to Google Calendar
|
||||
* @method POST
|
||||
* @body { booking_id: string }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// TODO: Add admin auth check
|
||||
const body = await request.json() as { booking_id: string };
|
||||
const { booking_id } = body;
|
||||
|
||||
if (!booking_id) {
|
||||
return NextResponse.json({ success: false, error: 'booking_id required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get booking data
|
||||
// Note: In production, use supabaseAdmin.from('bookings').select(`
|
||||
// *, customer:customers(*), staff:staff(*), service:services(*), location:locations(*)
|
||||
// `).eq('id', booking_id).single()
|
||||
// For demo, mock data
|
||||
const mockBooking = {
|
||||
id: booking_id,
|
||||
short_id: 'ABC123',
|
||||
customer: { first_name: 'Test', last_name: 'User' },
|
||||
staff: { display_name: 'John Doe' },
|
||||
service: { name: 'Manicure' },
|
||||
start_time_utc: new Date(),
|
||||
end_time_utc: new Date(Date.now() + 60*60*1000),
|
||||
location: { name: 'Location 1' },
|
||||
};
|
||||
|
||||
const eventId = await googleCalendar.syncBooking(mockBooking, 'create');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { google_event_id: eventId },
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Booking sync failed:', error);
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
33
app/api/sync/calendar/route.ts
Normal file
33
app/api/sync/calendar/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { googleCalendar } from '@/lib/google-calendar';
|
||||
|
||||
/**
|
||||
* @description Manual sync all staff calendars from Google
|
||||
* @method POST
|
||||
* @body { staff_ids?: string[] } - Optional staff IDs to sync
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// TODO: Add admin auth check
|
||||
const body = await request.json();
|
||||
const { staff_ids } = body;
|
||||
|
||||
if (!googleCalendar.isReady()) {
|
||||
return NextResponse.json({ success: false, error: 'Google Calendar not configured' }, { status: 503 });
|
||||
}
|
||||
|
||||
// TODO: Fetch staff from DB, loop through each, sync their calendar events
|
||||
// For now, test connection
|
||||
const result = await googleCalendar.testConnection();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Sync initiated',
|
||||
connection: result,
|
||||
synced_staff_count: 0, // TODO
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Calendar sync failed:', error);
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
29
app/api/sync/calendar/test/route.ts
Normal file
29
app/api/sync/calendar/test/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { googleCalendar } from '@/lib/google-calendar';
|
||||
|
||||
/**
|
||||
* @description Test Google Calendar connection endpoint
|
||||
* @description Only accessible by admin/manager roles
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// TODO: Add admin auth check using middleware or supabaseAdmin
|
||||
// Temporarily open for testing
|
||||
|
||||
// Test connection
|
||||
const result = await googleCalendar.testConnection();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Google Calendar test failed:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error', details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
44
app/api/sync/calendar/webhook/route.ts
Normal file
44
app/api/sync/calendar/webhook/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { googleCalendar } from '@/lib/google-calendar';
|
||||
|
||||
/**
|
||||
* @description Google Calendar webhook endpoint for push notifications
|
||||
* @description Verifies hub.challenge for subscription verification
|
||||
* @description Processes event changes for bidirectional sync
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = new URL(request.url);
|
||||
const hubMode = url.searchParams.get('hub.mode');
|
||||
const hubChallenge = url.searchParams.get('hub.challenge');
|
||||
const hubVerifyToken = url.searchParams.get('hub.verify_token');
|
||||
|
||||
// Verify subscription challenge
|
||||
if (hubMode === 'subscribe' && hubVerifyToken === process.env.GOOGLE_CALENDAR_VERIFY_TOKEN) {
|
||||
return new NextResponse(hubChallenge!, {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Verification failed' }, { status: 403 });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// TODO: Verify webhook signature
|
||||
const body = await request.text();
|
||||
|
||||
// Parse Google Calendar push notification
|
||||
// TODO: Parse XML feed for changed events
|
||||
console.log('Google Calendar webhook received:', body);
|
||||
|
||||
// Process changed events:
|
||||
// 1. Fetch changed events from Google
|
||||
// 2. Upsert to google_calendar_events table
|
||||
// 3. Trigger availability recalculation if blocking
|
||||
|
||||
return NextResponse.json({ success: true, processed: true });
|
||||
} catch (error: any) {
|
||||
console.error('Google Calendar webhook failed:', error);
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'
|
||||
import { AnimatedLogo } from '@/components/animated-logo'
|
||||
import { RollingPhrases } from '@/components/rolling-phrases'
|
||||
|
||||
/** @description Services page with home page style structure */
|
||||
/** @description Premium services page with elegant layout and sophisticated design */
|
||||
|
||||
interface Service {
|
||||
id: string
|
||||
@@ -57,22 +57,35 @@ export default function ServiciosPage() {
|
||||
|
||||
const getCategoryTitle = (category: string) => {
|
||||
const titles: Record<string, string> = {
|
||||
core: 'CORE EXPERIENCES - El corazón de Anchor 23',
|
||||
nails: 'NAIL COUTURE - Técnica invisible. Resultado impecable.',
|
||||
core: 'CORE EXPERIENCES',
|
||||
nails: 'NAIL COUTURE',
|
||||
hair: 'HAIR FINISHING RITUALS',
|
||||
lashes: 'LASH & BROW RITUALS - Mirada definida con sutileza.',
|
||||
brows: 'LASH & BROW RITUALS - Mirada definida con sutileza.',
|
||||
events: 'EVENT EXPERIENCES - Agenda especial',
|
||||
permanent: 'PERMANENT RITUALS - Agenda limitada · Especialista certificada'
|
||||
lashes: 'LASH & BROW RITUALS',
|
||||
brows: 'LASH & BROW RITUALS',
|
||||
events: 'EVENT EXPERIENCES',
|
||||
permanent: 'PERMANENT RITUALS'
|
||||
}
|
||||
return titles[category] || category
|
||||
}
|
||||
|
||||
const getCategorySubtitle = (category: string) => {
|
||||
const subtitles: Record<string, string> = {
|
||||
core: 'El corazón de Anchor 23',
|
||||
nails: 'Técnica invisible. Resultado impecable.',
|
||||
hair: 'Disponibles únicamente para clientas con experiencia Anchor el mismo día',
|
||||
lashes: 'Mirada definida con sutileza',
|
||||
brows: 'Mirada definida con sutileza',
|
||||
events: 'Agenda especial',
|
||||
permanent: 'Agenda limitada · Especialista certificada'
|
||||
}
|
||||
return subtitles[category] || ''
|
||||
}
|
||||
|
||||
const getCategoryDescription = (category: string) => {
|
||||
const descriptions: Record<string, string> = {
|
||||
core: 'Rituales conscientes donde el tiempo se desacelera. Cada experiencia está diseñada para mujeres que valoran el silencio, la atención absoluta y los resultados impecables.',
|
||||
nails: 'En Anchor 23 no eliges técnicas. Cada decisión se toma internamente para lograr un resultado elegante, duradero y natural. No ofrecemos servicios de mantenimiento ni correcciones.',
|
||||
hair: 'Disponibles únicamente para clientas con experiencia Anchor el mismo día.',
|
||||
hair: '',
|
||||
lashes: '',
|
||||
brows: '',
|
||||
events: 'Agenda especial para ocasiones selectas.',
|
||||
@@ -93,10 +106,10 @@ export default function ServiciosPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h1 className="section-title">Nuestros Servicios</h1>
|
||||
<p className="section-subtitle">Cargando servicios...</p>
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-charcoal-brown mb-4"></div>
|
||||
<p className="text-xl text-charcoal-brown opacity-70">Cargando servicios...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -104,94 +117,152 @@ export default function ServiciosPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="hero">
|
||||
<div className="hero-content">
|
||||
{/* Hero Section - Simplified and Elegant */}
|
||||
<section className="relative min-h-[60vh] flex items-center justify-center pt-32 pb-20 overflow-hidden">
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `radial-gradient(circle at 2px 2px, rgba(111, 94, 79, 0.15) 1px, transparent 0)`,
|
||||
backgroundSize: '40px 40px'
|
||||
}}></div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-5xl mx-auto px-8 text-center">
|
||||
<div className="mb-8">
|
||||
<AnimatedLogo />
|
||||
<h1>Servicios</h1>
|
||||
<h2>Anchor:23</h2>
|
||||
</div>
|
||||
<h1 className="text-6xl md:text-8xl font-bold mb-6 tracking-tight" style={{ fontFamily: 'Playfair Display, serif', color: 'var(--charcoal-brown)' }}>
|
||||
Nuestros Servicios
|
||||
</h1>
|
||||
<div className="mb-10">
|
||||
<RollingPhrases />
|
||||
<div className="hero-actions">
|
||||
<a href="/booking/servicios" className="btn-primary">
|
||||
Reservar Cita
|
||||
</div>
|
||||
<p className="text-xl md:text-2xl mb-12 max-w-3xl mx-auto leading-relaxed opacity-80" style={{ color: 'var(--charcoal-brown)' }}>
|
||||
Experiencias diseñadas para mujeres que valoran el silencio, la atención absoluta y los resultados impecables.
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
<a href="/booking/servicios" className="btn-primary text-base px-10 py-4">
|
||||
Reservar Experiencia
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-image">
|
||||
<div className="w-full h-96 flex items-center justify-center bg-gradient-to-br from-amber-50 to-gray-50">
|
||||
<span className="text-gray-500 text-lg">Imagen Servicios</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="foundation">
|
||||
<article>
|
||||
<h3>Experiencias</h3>
|
||||
<h4>Criterio antes que cantidad</h4>
|
||||
<p>
|
||||
{/* Philosophy Section */}
|
||||
<section className="py-24 relative" style={{ background: 'var(--soft-cream)' }}>
|
||||
<div className="max-w-6xl mx-auto px-8">
|
||||
<div className="grid md:grid-cols-2 gap-16 items-center">
|
||||
<div>
|
||||
<p className="text-sm font-semibold tracking-widest uppercase mb-4 opacity-60" style={{ color: 'var(--deep-earth)' }}>
|
||||
Nuestra Filosofía
|
||||
</p>
|
||||
<h2 className="text-4xl md:text-5xl font-bold mb-6 leading-tight" style={{ fontFamily: 'Playfair Display, serif', color: 'var(--charcoal-brown)' }}>
|
||||
Criterio antes que cantidad
|
||||
</h2>
|
||||
<p className="text-lg leading-relaxed mb-6 opacity-85" style={{ color: 'var(--charcoal-brown)' }}>
|
||||
Anchor 23 es un espacio privado donde el tiempo se desacelera. Aquí, cada experiencia está diseñada para mujeres que valoran el silencio, la atención absoluta y los resultados impecables.
|
||||
</p>
|
||||
<p>
|
||||
<p className="text-lg leading-relaxed font-medium" style={{ color: 'var(--deep-earth)' }}>
|
||||
No trabajamos con volumen. Trabajamos con intención.
|
||||
</p>
|
||||
</article>
|
||||
<aside className="foundation-image">
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-amber-50 to-gray-50">
|
||||
<span className="text-gray-500 text-lg">Imagen Experiencias</span>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="relative h-96 rounded-2xl overflow-hidden shadow-2xl">
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-amber-100 via-stone-100 to-neutral-100">
|
||||
<span className="text-neutral-400 text-lg font-light">Imagen Experiencias</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="services-preview">
|
||||
<h3>Nuestros Servicios</h3>
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
{/* Services Catalog */}
|
||||
<section className="py-32" style={{ background: 'var(--bone-white)' }}>
|
||||
<div className="max-w-7xl mx-auto px-8">
|
||||
{categoryOrder.map(category => {
|
||||
const categoryServices = groupedServices[category]
|
||||
if (!categoryServices || categoryServices.length === 0) return null
|
||||
|
||||
return (
|
||||
<div key={category} className="service-cards mb-24">
|
||||
<div className="mb-8">
|
||||
<h4 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
<div key={category} className="mb-32 last:mb-0">
|
||||
{/* Category Header */}
|
||||
<div className="mb-16 text-center max-w-4xl mx-auto">
|
||||
<p className="text-sm font-semibold tracking-widest uppercase mb-3 opacity-60" style={{ color: 'var(--deep-earth)' }}>
|
||||
{getCategorySubtitle(category)}
|
||||
</p>
|
||||
<h3 className="text-4xl md:text-5xl font-bold mb-6 tracking-tight" style={{ fontFamily: 'Playfair Display, serif', color: 'var(--charcoal-brown)' }}>
|
||||
{getCategoryTitle(category)}
|
||||
</h4>
|
||||
</h3>
|
||||
{getCategoryDescription(category) && (
|
||||
<p className="text-gray-600 text-lg leading-relaxed">
|
||||
<p className="text-lg leading-relaxed opacity-75" style={{ color: 'var(--charcoal-brown)' }}>
|
||||
{getCategoryDescription(category)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Service Cards Grid */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{categoryServices.map((service) => (
|
||||
<article
|
||||
key={service.id}
|
||||
className="service-card"
|
||||
className="group relative rounded-2xl p-8 transition-all duration-500 hover:shadow-2xl hover:-translate-y-2"
|
||||
style={{
|
||||
background: 'var(--soft-cream)',
|
||||
border: '1px solid transparent'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--mocha-taupe)'
|
||||
e.currentTarget.style.background = 'var(--bone-white)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'transparent'
|
||||
e.currentTarget.style.background = 'var(--soft-cream)'
|
||||
}}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<h5 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
{/* Service Header */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-2xl font-bold mb-3 leading-tight group-hover:opacity-90 transition-opacity" style={{ fontFamily: 'Playfair Display, serif', color: 'var(--charcoal-brown)' }}>
|
||||
{service.name}
|
||||
</h5>
|
||||
</h4>
|
||||
{service.description && (
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
<p className="text-base leading-relaxed opacity-75" style={{ color: 'var(--charcoal-brown)' }}>
|
||||
{service.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-gray-500 text-sm">
|
||||
⏳ {formatDuration(service.duration_minutes)}
|
||||
{/* Service Meta */}
|
||||
<div className="flex items-center gap-4 mb-6 pb-6 border-b" style={{ borderColor: 'var(--mocha-taupe)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: 'var(--deep-earth)' }}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium opacity-70" style={{ color: 'var(--charcoal-brown)' }}>
|
||||
{formatDuration(service.duration_minutes)}
|
||||
</span>
|
||||
</div>
|
||||
{service.requires_dual_artist && (
|
||||
<span className="text-xs bg-gray-100 px-2 py-1 rounded-full">Dual Artist</span>
|
||||
<span className="text-xs font-semibold px-3 py-1 rounded-full" style={{ background: 'var(--mocha-taupe)', color: 'var(--bone-white)' }}>
|
||||
Dual Artist
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price and CTA */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-2xl font-bold text-gray-900">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider mb-1 opacity-50" style={{ color: 'var(--charcoal-brown)' }}>Desde</p>
|
||||
<p className="text-3xl font-bold" style={{ fontFamily: 'Playfair Display, serif', color: 'var(--charcoal-brown)' }}>
|
||||
{formatCurrency(service.base_price)}
|
||||
</span>
|
||||
<a href="/booking/servicios" className="btn-primary">
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/booking/servicios"
|
||||
className="inline-flex items-center justify-center px-6 py-3 text-sm font-medium rounded-lg transition-all duration-300 hover:shadow-lg hover:-translate-y-1"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, var(--deep-earth), var(--charcoal-brown))',
|
||||
color: 'var(--bone-white)'
|
||||
}}
|
||||
>
|
||||
Reservar
|
||||
</a>
|
||||
</div>
|
||||
@@ -201,38 +272,55 @@ export default function ServiciosPage() {
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="testimonials">
|
||||
<h3>Lo que Define Anchor 23</h3>
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<div className="grid md:grid-cols-2 gap-6 text-left">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-red-500 text-xl">•</span>
|
||||
<span className="text-gray-700">No ofrecemos retoques ni servicios aislados</span>
|
||||
{/* Values Section */}
|
||||
<section className="py-24 relative" style={{ background: 'var(--soft-cream)' }}>
|
||||
<div className="max-w-5xl mx-auto px-8">
|
||||
<h3 className="text-4xl md:text-5xl font-bold mb-16 text-center" style={{ fontFamily: 'Playfair Display, serif', color: 'var(--charcoal-brown)' }}>
|
||||
Lo que Define Anchor 23
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
<div className="space-y-6">
|
||||
{[
|
||||
'No ofrecemos retoques ni servicios aislados',
|
||||
'No trabajamos con prisas',
|
||||
'No explicamos de más'
|
||||
].map((text, idx) => (
|
||||
<div key={idx} className="flex items-start gap-4 p-6 rounded-xl transition-all duration-300 hover:shadow-lg" style={{ background: 'var(--bone-white)' }}>
|
||||
<div className="flex-shrink-0 w-2 h-2 rounded-full mt-2" style={{ background: 'var(--brick-red)' }}></div>
|
||||
<p className="text-lg leading-relaxed" style={{ color: 'var(--charcoal-brown)' }}>{text}</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-red-500 text-xl">•</span>
|
||||
<span className="text-gray-700">No trabajamos con prisas</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-red-500 text-xl">•</span>
|
||||
<span className="text-gray-700">No explicamos de más</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-red-500 text-xl">•</span>
|
||||
<span className="text-gray-700">No negociamos estándares</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-red-500 text-xl">•</span>
|
||||
<span className="text-gray-700">Cada experiencia está pensada para durar, sentirse y recordarse</span>
|
||||
<div className="space-y-6">
|
||||
{[
|
||||
'No negociamos estándares',
|
||||
'Cada experiencia está pensada para durar, sentirse y recordarse'
|
||||
].map((text, idx) => (
|
||||
<div key={idx} className="flex items-start gap-4 p-6 rounded-xl transition-all duration-300 hover:shadow-lg" style={{ background: 'var(--bone-white)' }}>
|
||||
<div className="flex-shrink-0 w-2 h-2 rounded-full mt-2" style={{ background: 'var(--brick-red)' }}></div>
|
||||
<p className="text-lg leading-relaxed" style={{ color: 'var(--charcoal-brown)' }}>{text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Final CTA */}
|
||||
<section className="py-24 text-center" style={{ background: 'var(--bone-white)' }}>
|
||||
<div className="max-w-3xl mx-auto px-8">
|
||||
<h3 className="text-4xl md:text-5xl font-bold mb-6" style={{ fontFamily: 'Playfair Display, serif', color: 'var(--charcoal-brown)' }}>
|
||||
¿Lista para tu experiencia?
|
||||
</h3>
|
||||
<p className="text-xl mb-10 opacity-75" style={{ color: 'var(--charcoal-brown)' }}>
|
||||
Reserva tu cita y descubre lo que significa una atención verdaderamente personalizada.
|
||||
</p>
|
||||
<a href="/booking/servicios" className="btn-primary text-base px-12 py-4 inline-block">
|
||||
Reservar Ahora
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
|
||||
46
docs/API.md
46
docs/API.md
@@ -13,11 +13,12 @@ AnchorOS is a comprehensive salon management system built with Next.js, Supabase
|
||||
### Public APIs
|
||||
|
||||
#### Services
|
||||
- `GET /api/services` - List all available services
|
||||
- `GET /api/services` - List all available services (with detailed logging and error diagnostics)
|
||||
- `GET /api/services?location_id={id}` - Filter services by location
|
||||
- `POST /api/services` - Create new service (Admin only)
|
||||
|
||||
#### Locations
|
||||
- `GET /api/locations` - List all salon locations
|
||||
- `GET /api/locations` - List all salon locations (with detailed logging and error diagnostics)
|
||||
|
||||
#### Availability
|
||||
- `GET /api/availability/time-slots` - Get available time slots for booking
|
||||
@@ -142,7 +143,7 @@ Default business hours (updated via migration):
|
||||
## Deployment
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 18+
|
||||
- Node.js 20+ (updated for Supabase compatibility)
|
||||
- Supabase account
|
||||
- Stripe account
|
||||
- Google Cloud (for Calendar)
|
||||
@@ -193,6 +194,45 @@ Default business hours (updated via migration):
|
||||
- Magic link authentication for customers
|
||||
- Encrypted payment processing
|
||||
|
||||
## Recent Improvements (January 2026)
|
||||
|
||||
### Supabase Connection Fixes
|
||||
- **Lazy Client Initialization**: Supabase client now initializes only when needed, ensuring environment variables are available at runtime
|
||||
- **Enhanced Error Diagnostics**: APIs now provide detailed logging for connection issues
|
||||
- **Node.js 20 Compatibility**: Updated runtime for better Supabase SDK compatibility
|
||||
- **Connection Testing**: APIs test Supabase connectivity before executing queries
|
||||
|
||||
### API Enhancements
|
||||
- **Detailed Logging**: Services and Locations APIs now log connection status, query results, and errors
|
||||
- **Better Error Messages**: Failed requests return structured error information with timestamps
|
||||
- **Connectivity Validation**: Pre-flight checks ensure Supabase is reachable before processing requests
|
||||
|
||||
### Troubleshooting
|
||||
If APIs return `"TypeError: fetch failed"`:
|
||||
1. Verify Supabase environment variables are correctly set in deployment platform
|
||||
2. Check Supabase service status and connectivity
|
||||
3. Review deployment logs for initialization errors
|
||||
4. Ensure Node.js 20+ is being used
|
||||
|
||||
### Example Error Response
|
||||
```json
|
||||
{
|
||||
"error": "TypeError: fetch failed",
|
||||
"details": "Failed to connect to Supabase",
|
||||
"timestamp": "2026-01-18T15:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Example Success Response
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"services": [...],
|
||||
"count": 22,
|
||||
"timestamp": "2026-01-18T15:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
For API issues or feature requests, please check the TASKS.md for current priorities or create an issue in the repository.
|
||||
@@ -20,6 +20,14 @@ Esta guía ayuda a resolver problemas comunes durante el setup y desarrollo de A
|
||||
- Verificar políticas en Supabase Dashboard > Authentication > Policies
|
||||
- Para kioskos: asegurar API key válida en headers `x-kiosk-api-key`
|
||||
|
||||
#### Error: "TypeError: fetch failed" (Resuelto Enero 2026)
|
||||
- **Causa**: Cliente Supabase se inicializa antes de que las variables de entorno estén disponibles en runtime
|
||||
- **Solución**:
|
||||
- Cliente ahora usa inicialización lazy (solo cuando se necesita)
|
||||
- APIs incluyen pruebas de conectividad antes de ejecutar queries
|
||||
- Logs detallados muestran el estado de conexión
|
||||
- Actualizado a Node.js 20 para compatibilidad con Supabase
|
||||
|
||||
#### Error: "Magic link not received"
|
||||
- **Causa**: SMTP no configurado en Supabase
|
||||
- **Solución**:
|
||||
|
||||
394
lib/google-calendar.ts
Normal file
394
lib/google-calendar.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { google, calendar_v3 } from 'googleapis';
|
||||
|
||||
interface ServiceAccountConfig {
|
||||
type: string;
|
||||
project_id: string;
|
||||
private_key_id: string;
|
||||
private_key: string;
|
||||
client_email: string;
|
||||
client_id: string;
|
||||
auth_uri: string;
|
||||
token_uri: string;
|
||||
auth_provider_x509_cert_url: string;
|
||||
client_x509_cert_url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Google Calendar service for bidirectional sync with staff calendars
|
||||
* @class GoogleCalendarService
|
||||
*
|
||||
* This service manages:
|
||||
* - Authentication via Google Service Account
|
||||
* - Booking synchronization to Google Calendar
|
||||
* - Google Calendar event import
|
||||
* - Conflict detection and resolution
|
||||
*/
|
||||
class GoogleCalendarService {
|
||||
private calendarClient: calendar_v3.Calendar | null = null;
|
||||
private serviceAccountConfig: ServiceAccountConfig | null = null;
|
||||
private calendarId: string;
|
||||
|
||||
constructor() {
|
||||
this.calendarId = process.env.GOOGLE_CALENDAR_ID || 'primary';
|
||||
this.initializeService();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Initialize Google Calendar service with service account authentication
|
||||
* @throws {Error} If service account configuration is missing or invalid
|
||||
*/
|
||||
private initializeService(): void {
|
||||
try {
|
||||
const serviceAccountJson = process.env.GOOGLE_SERVICE_ACCOUNT_JSON;
|
||||
|
||||
if (!serviceAccountJson) {
|
||||
console.warn('GoogleCalendar: Service account not configured. Calendar sync disabled.');
|
||||
return;
|
||||
}
|
||||
|
||||
const credentials = JSON.parse(serviceAccountJson) as ServiceAccountConfig;
|
||||
|
||||
const auth = new google.auth.GoogleAuth({
|
||||
credentials,
|
||||
scopes: ['https://www.googleapis.com/auth/calendar', 'https://www.googleapis.com/auth/calendar.events'],
|
||||
});
|
||||
|
||||
this.serviceAccountConfig = credentials;
|
||||
|
||||
this.calendarClient = google.calendar({ version: 'v3', auth });
|
||||
|
||||
console.log('GoogleCalendar: Service initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('GoogleCalendar: Initialization failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Check if Google Calendar service is properly configured and ready
|
||||
* @returns {boolean} - true if service is ready, false otherwise
|
||||
*/
|
||||
isReady(): boolean {
|
||||
return this.calendarClient !== null && this.serviceAccountConfig !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Create a Google Calendar event from a booking
|
||||
* @param {Object} bookingData - Booking information
|
||||
* @param {string} bookingData.id - Booking UUID
|
||||
* @param {string} bookingData.shortId - Booking short ID (6 chars)
|
||||
* @param {string} bookingData.customerName - Customer name
|
||||
* @param {string} bookingData.staffName - Staff name
|
||||
* @param {string} bookingData.serviceName - Service name
|
||||
* @param {Date} bookingData.startTime - Booking start time (UTC)
|
||||
* @param {Date} bookingData.endTime - Booking end time (UTC)
|
||||
* @param {string} bookingData.locationName - Location name
|
||||
* @returns {Promise<string|null>} - Google Calendar event ID or null if failed
|
||||
*/
|
||||
async createBookingEvent(bookingData: {
|
||||
id: string;
|
||||
shortId: string;
|
||||
customerName: string;
|
||||
staffName: string;
|
||||
serviceName: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
locationName: string;
|
||||
}): Promise<string | null> {
|
||||
if (!this.isReady()) {
|
||||
console.warn('GoogleCalendar: Service not ready, skipping event creation');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const event: calendar_v3.Schema$Event = {
|
||||
summary: `[${bookingData.shortId}] ${bookingData.serviceName} - ${bookingData.customerName}`,
|
||||
description: this.buildEventDescription(bookingData),
|
||||
start: {
|
||||
dateTime: bookingData.startTime.toISOString(),
|
||||
timeZone: 'UTC',
|
||||
},
|
||||
end: {
|
||||
dateTime: bookingData.endTime.toISOString(),
|
||||
timeZone: 'UTC',
|
||||
},
|
||||
location: bookingData.locationName,
|
||||
extendedProperties: {
|
||||
private: {
|
||||
booking_id: bookingData.id,
|
||||
short_id: bookingData.shortId,
|
||||
is_anchoros_booking: 'true',
|
||||
},
|
||||
},
|
||||
colorId: '1', // Blue color for standard bookings
|
||||
reminders: {
|
||||
useDefault: false,
|
||||
overrides: [
|
||||
{ method: 'email', minutes: 1440 }, // 24 hours before
|
||||
{ method: 'popup', minutes: 60 }, // 1 hour before
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const response = await this.calendarClient!.events.insert({
|
||||
calendarId: this.calendarId,
|
||||
requestBody: event,
|
||||
});
|
||||
|
||||
console.log(`GoogleCalendar: Created event ${response.data.id} for booking ${bookingData.shortId}`);
|
||||
return response.data.id || null;
|
||||
} catch (error: any) {
|
||||
console.error(`GoogleCalendar: Failed to create event for booking ${bookingData.shortId}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Update an existing Google Calendar event
|
||||
* @param {string} googleEventId - Google Calendar event ID
|
||||
* @param {Object} bookingData - Updated booking information
|
||||
* @returns {Promise<boolean>} - true if update successful, false otherwise
|
||||
*/
|
||||
async updateBookingEvent(
|
||||
googleEventId: string,
|
||||
bookingData: {
|
||||
shortId: string;
|
||||
customerName: string;
|
||||
staffName: string;
|
||||
serviceName: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
locationName: string;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
if (!this.isReady()) {
|
||||
console.warn('GoogleCalendar: Service not ready, skipping event update');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const event: calendar_v3.Schema$Event = {
|
||||
summary: `[${bookingData.shortId}] ${bookingData.serviceName} - ${bookingData.customerName}`,
|
||||
description: this.buildEventDescription(bookingData),
|
||||
start: {
|
||||
dateTime: bookingData.startTime.toISOString(),
|
||||
timeZone: 'UTC',
|
||||
},
|
||||
end: {
|
||||
dateTime: bookingData.endTime.toISOString(),
|
||||
timeZone: 'UTC',
|
||||
},
|
||||
location: bookingData.locationName,
|
||||
};
|
||||
|
||||
await this.calendarClient!.events.update({
|
||||
calendarId: this.calendarId,
|
||||
eventId: googleEventId,
|
||||
requestBody: event,
|
||||
});
|
||||
|
||||
console.log(`GoogleCalendar: Updated event ${googleEventId} for booking ${bookingData.shortId}`);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error(`GoogleCalendar: Failed to update event ${googleEventId}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Delete a Google Calendar event
|
||||
* @param {string} googleEventId - Google Calendar event ID
|
||||
* @param {string} shortId - Booking short ID for logging
|
||||
* @returns {Promise<boolean>} - true if deletion successful, false otherwise
|
||||
*/
|
||||
async deleteBookingEvent(googleEventId: string, shortId: string): Promise<boolean> {
|
||||
if (!this.isReady()) {
|
||||
console.warn('GoogleCalendar: Service not ready, skipping event deletion');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.calendarClient!.events.delete({
|
||||
calendarId: this.calendarId,
|
||||
eventId: googleEventId,
|
||||
});
|
||||
|
||||
console.log(`GoogleCalendar: Deleted event ${googleEventId} for booking ${shortId}`);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error.code === 404) {
|
||||
console.warn(`GoogleCalendar: Event ${googleEventId} not found, already deleted`);
|
||||
return true;
|
||||
}
|
||||
console.error(`GoogleCalendar: Failed to delete event ${googleEventId}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Fetch all blocking events from Google Calendar for a time range
|
||||
* @param {Date} startTime - Start of time range (UTC)
|
||||
* @param {Date} endTime - End of time range (UTC)
|
||||
* @returns {Promise<Array<{id: string, start: Date, end: Date, summary: string}>>} - Array of blocking events
|
||||
*/
|
||||
async getBlockingEvents(startTime: Date, endTime: Date): Promise<Array<{
|
||||
id: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
summary: string;
|
||||
isAnchorOsBooking: boolean;
|
||||
bookingId?: string;
|
||||
}>> {
|
||||
if (!this.isReady()) {
|
||||
console.warn('GoogleCalendar: Service not ready, returning empty events list');
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.calendarClient!.events.list({
|
||||
calendarId: this.calendarId,
|
||||
timeMin: startTime.toISOString(),
|
||||
timeMax: endTime.toISOString(),
|
||||
singleEvents: true,
|
||||
orderBy: 'startTime',
|
||||
});
|
||||
|
||||
const events = response.data.items || [];
|
||||
|
||||
return events.map(event => ({
|
||||
id: event.id || '',
|
||||
start: new Date(event.start?.dateTime || event.start?.date || ''),
|
||||
end: new Date(event.end?.dateTime || event.end?.date || ''),
|
||||
summary: event.summary || '',
|
||||
isAnchorOsBooking: event.extendedProperties?.private?.is_anchoros_booking === 'true',
|
||||
bookingId: event.extendedProperties?.private?.booking_id,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
console.error('GoogleCalendar: Failed to fetch blocking events', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Sync a booking from AnchorOS to Google Calendar
|
||||
* @param {Object} booking - Full booking object
|
||||
* @param {string} action - Action type: 'create', 'update', 'delete'
|
||||
* @returns {Promise<string|null>} - Google Calendar event ID or null
|
||||
*/
|
||||
async syncBooking(
|
||||
booking: {
|
||||
id: string;
|
||||
short_id: string;
|
||||
google_calendar_event_id?: string;
|
||||
customer: { first_name: string; last_name: string };
|
||||
staff: { display_name: string };
|
||||
service: { name: string };
|
||||
start_time_utc: Date;
|
||||
end_time_utc: Date;
|
||||
location: { name: string };
|
||||
},
|
||||
action: 'create' | 'update' | 'delete'
|
||||
): Promise<string | null> {
|
||||
if (!this.isReady()) {
|
||||
console.warn('GoogleCalendar: Service not ready, skipping booking sync');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const bookingData = {
|
||||
id: booking.id,
|
||||
shortId: booking.short_id,
|
||||
customerName: `${booking.customer.first_name} ${booking.customer.last_name}`,
|
||||
staffName: booking.staff.display_name,
|
||||
serviceName: booking.service.name,
|
||||
startTime: booking.start_time_utc,
|
||||
endTime: booking.end_time_utc,
|
||||
locationName: booking.location.name,
|
||||
};
|
||||
|
||||
switch (action) {
|
||||
case 'create':
|
||||
return await this.createBookingEvent(bookingData);
|
||||
|
||||
case 'update':
|
||||
if (booking.google_calendar_event_id) {
|
||||
await this.updateBookingEvent(booking.google_calendar_event_id, bookingData);
|
||||
return booking.google_calendar_event_id;
|
||||
}
|
||||
return await this.createBookingEvent(bookingData);
|
||||
|
||||
case 'delete':
|
||||
if (booking.google_calendar_event_id) {
|
||||
await this.deleteBookingEvent(booking.google_calendar_event_id, booking.short_id);
|
||||
}
|
||||
return null;
|
||||
|
||||
default:
|
||||
console.warn(`GoogleCalendar: Unknown action ${action}`);
|
||||
return null;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`GoogleCalendar: Failed to sync booking ${booking.short_id}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Build detailed event description for Google Calendar
|
||||
* @param {Object} bookingData - Booking information
|
||||
* @returns {string} - Formatted event description
|
||||
*/
|
||||
private buildEventDescription(bookingData: {
|
||||
shortId: string;
|
||||
customerName: string;
|
||||
staffName: string;
|
||||
serviceName: string;
|
||||
}): string {
|
||||
return `📋 AnchorOS Booking Details
|
||||
|
||||
🎯 Booking ID: ${bookingData.shortId}
|
||||
👤 Customer: ${bookingData.customerName}
|
||||
👨🎨 Artist: ${bookingData.staffName}
|
||||
💅 Service: ${bookingData.serviceName}
|
||||
|
||||
⏰ Times shown in UTC
|
||||
|
||||
Manage this booking in AnchorOS Dashboard.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Test connection to Google Calendar API
|
||||
* @returns {Promise<{success: boolean, message: string}>} - Test result
|
||||
*/
|
||||
async testConnection(): Promise<{ success: boolean; message: string }> {
|
||||
if (!this.isReady()) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Google Calendar service not configured. Set GOOGLE_SERVICE_ACCOUNT_JSON and GOOGLE_CALENDAR_ID.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.calendarClient!.calendarList.list();
|
||||
return {
|
||||
success: true,
|
||||
message: `Connected successfully. Found ${response.data.items?.length || 0} calendars.`,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Connection failed: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Singleton instance of Google Calendar service
|
||||
*/
|
||||
export const googleCalendar = new GoogleCalendarService();
|
||||
|
||||
/**
|
||||
* @description Export types for use in other modules
|
||||
*/
|
||||
export type { ServiceAccountConfig };
|
||||
@@ -1,10 +1,24 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
// Lazy initialization to ensure env vars are available at runtime
|
||||
let supabaseInstance: ReturnType<typeof createClient> | null = null
|
||||
|
||||
// Public Supabase client for client-side operations
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
function getSupabaseClient() {
|
||||
if (!supabaseInstance) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
|
||||
console.log('=== SUPABASE CLIENT INIT ===')
|
||||
console.log('SUPABASE_URL available:', !!supabaseUrl)
|
||||
console.log('SUPABASE_ANON_KEY available:', !!supabaseAnonKey)
|
||||
console.log('SUPABASE_URL value:', supabaseUrl)
|
||||
console.log('SUPABASE_ANON_KEY preview:', supabaseAnonKey ? supabaseAnonKey.substring(0, 20) + '...' : 'null')
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error(`Missing Supabase environment variables: URL=${!!supabaseUrl}, KEY=${!!supabaseAnonKey}`)
|
||||
}
|
||||
|
||||
supabaseInstance = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
@@ -12,4 +26,18 @@ export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
}
|
||||
})
|
||||
|
||||
console.log('Supabase client initialized successfully')
|
||||
}
|
||||
|
||||
return supabaseInstance
|
||||
}
|
||||
|
||||
// Public Supabase client for client-side operations
|
||||
export const supabase = new Proxy({} as ReturnType<typeof createClient>, {
|
||||
get(target, prop) {
|
||||
const client = getSupabaseClient()
|
||||
return client[prop as keyof typeof client]
|
||||
}
|
||||
})
|
||||
|
||||
export default supabase
|
||||
|
||||
713
package-lock.json
generated
713
package-lock.json
generated
@@ -32,6 +32,7 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"framer-motion": "^10.16.16",
|
||||
"googleapis": "^170.1.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^4.0.0",
|
||||
"lucide-react": "^0.303.0",
|
||||
@@ -337,6 +338,50 @@
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^5.1.2",
|
||||
"string-width-cjs": "npm:string-width@^4.2.0",
|
||||
"strip-ansi": "^7.0.1",
|
||||
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
|
||||
"wrap-ansi": "^8.1.0",
|
||||
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui/node_modules/strip-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -597,6 +642,16 @@
|
||||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||
@@ -2746,6 +2801,15 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
@@ -2767,7 +2831,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -2777,7 +2840,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -3123,7 +3185,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
@@ -3135,6 +3196,26 @@
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.15",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz",
|
||||
@@ -3145,6 +3226,15 @@
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/bignumber.js": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -3216,6 +3306,12 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
@@ -3421,7 +3517,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -3434,7 +3529,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
@@ -3483,7 +3577,6 @@
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
@@ -3530,6 +3623,15 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-view-buffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
||||
@@ -3607,7 +3709,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -3747,6 +3848,21 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.267",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
|
||||
@@ -3758,7 +3874,6 @@
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-abstract": {
|
||||
@@ -4405,6 +4520,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -4501,6 +4622,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-blob": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-domexception": "^1.0.0",
|
||||
"web-streams-polyfill": "^3.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20 || >= 14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
@@ -4588,6 +4732,34 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6",
|
||||
"signal-exit": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fetch-blob": "^3.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
@@ -4688,6 +4860,94 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gaxios": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz",
|
||||
"integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2",
|
||||
"https-proxy-agent": "^7.0.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"rimraf": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/gaxios/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gaxios/node_modules/glob": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.1.0",
|
||||
"jackspeak": "^3.1.2",
|
||||
"minimatch": "^9.0.4",
|
||||
"minipass": "^7.1.2",
|
||||
"package-json-from-dist": "^1.0.0",
|
||||
"path-scurry": "^1.11.1"
|
||||
},
|
||||
"bin": {
|
||||
"glob": "dist/esm/bin.mjs"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/gaxios/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/gaxios/node_modules/rimraf": {
|
||||
"version": "5.0.10",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
|
||||
"integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"glob": "^10.3.7"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "dist/esm/bin.mjs"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/gcp-metadata": {
|
||||
"version": "8.1.2",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
|
||||
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"gaxios": "^7.0.0",
|
||||
"google-logging-utils": "^1.0.0",
|
||||
"json-bigint": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/generator-function": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
|
||||
@@ -4870,6 +5130,62 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/google-auth-library": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz",
|
||||
"integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"ecdsa-sig-formatter": "^1.0.11",
|
||||
"gaxios": "^7.0.0",
|
||||
"gcp-metadata": "^8.0.0",
|
||||
"google-logging-utils": "^1.0.0",
|
||||
"gtoken": "^8.0.0",
|
||||
"jws": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/google-logging-utils": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
|
||||
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/googleapis": {
|
||||
"version": "170.1.0",
|
||||
"resolved": "https://registry.npmjs.org/googleapis/-/googleapis-170.1.0.tgz",
|
||||
"integrity": "sha512-RLbc7yG6qzZqvAmGcgjvNIoZ7wpcCFxtc+HN+46etxDrlO4a8l5Cb7NxNQGhV91oRmL7mt56VoRoypAtEQEIKg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"google-auth-library": "^10.2.0",
|
||||
"googleapis-common": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/googleapis-common": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.1.tgz",
|
||||
"integrity": "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2",
|
||||
"gaxios": "^7.0.0-rc.4",
|
||||
"google-auth-library": "^10.1.0",
|
||||
"qs": "^6.7.0",
|
||||
"url-template": "^2.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -4895,6 +5211,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/gtoken": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz",
|
||||
"integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"gaxios": "^7.0.0",
|
||||
"jws": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/has-bigints": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||
@@ -5000,6 +5329,19 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/iceberg-js": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||
@@ -5270,6 +5612,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-generator-function": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
||||
@@ -5522,7 +5873,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/iterator.prototype": {
|
||||
@@ -5543,6 +5893,21 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^8.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@pkgjs/parseargs": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
@@ -5572,6 +5937,15 @@
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/json-bigint": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
||||
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bignumber.js": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-buffer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
@@ -5639,6 +6013,27 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -5738,6 +6133,12 @@
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.303.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.303.0.tgz",
|
||||
@@ -5816,11 +6217,19 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mz": {
|
||||
@@ -5952,6 +6361,44 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||
"deprecated": "Use your platform's native DOMException instead",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"data-uri-to-buffer": "^4.0.0",
|
||||
"fetch-blob": "^3.1.4",
|
||||
"formdata-polyfill": "^4.0.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
@@ -6178,6 +6625,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||
@@ -6221,7 +6674,6 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -6234,6 +6686,22 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^10.2.0",
|
||||
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
||||
@@ -6886,6 +7354,26 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-push-apply": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||
@@ -6996,7 +7484,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
@@ -7009,7 +7496,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -7087,6 +7573,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/slash": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
|
||||
@@ -7155,6 +7653,71 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eastasianwidth": "^0.2.0",
|
||||
"emoji-regex": "^9.2.2",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs": {
|
||||
"name": "string-width",
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width/node_modules/strip-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/string.prototype.includes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
||||
@@ -7272,7 +7835,19 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi-cjs": {
|
||||
"name": "strip-ansi",
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -7791,6 +8366,12 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/url-template": {
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz",
|
||||
"integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==",
|
||||
"license": "BSD"
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||
@@ -7876,11 +8457,19 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
@@ -7991,6 +8580,100 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.1.0",
|
||||
"string-width": "^5.0.1",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs": {
|
||||
"name": "wrap-ansi",
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/strip-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"framer-motion": "^10.16.16",
|
||||
"googleapis": "^170.1.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^4.0.0",
|
||||
"lucide-react": "^0.303.0",
|
||||
|
||||
BIN
site_mockup.png
BIN
site_mockup.png
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 MiB |
198
supabase/migrations/20260118000000_google_calendar_events.sql
Normal file
198
supabase/migrations/20260118000000_google_calendar_events.sql
Normal file
@@ -0,0 +1,198 @@
|
||||
-- ============================================
|
||||
-- FASE 2.1 - GOOGLE CALENDAR EVENTS TABLE
|
||||
-- Date: 20260118
|
||||
-- Description: Create google_calendar_events table for bidirectional sync
|
||||
-- ============================================
|
||||
|
||||
-- Create google_calendar_events table
|
||||
CREATE TABLE IF NOT EXISTS google_calendar_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE,
|
||||
google_event_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
start_time_utc TIMESTAMPTZ NOT NULL,
|
||||
end_time_utc TIMESTAMPTZ NOT NULL,
|
||||
is_blocking BOOLEAN DEFAULT false,
|
||||
is_anchoros_booking BOOLEAN DEFAULT false,
|
||||
booking_id UUID REFERENCES bookings(id) ON DELETE SET NULL,
|
||||
synced_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
sync_status VARCHAR(50) DEFAULT 'synced',
|
||||
sync_error TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Add comment to table
|
||||
COMMENT ON TABLE google_calendar_events IS 'Stores synchronization state of Google Calendar events with AnchorOS bookings';
|
||||
|
||||
-- Add column comments
|
||||
COMMENT ON COLUMN google_calendar_events.staff_id IS 'Reference to staff member associated with this calendar event';
|
||||
COMMENT ON COLUMN google_calendar_events.google_event_id IS 'Unique Google Calendar event ID';
|
||||
COMMENT ON COLUMN google_calendar_events.is_blocking IS 'Whether this event blocks staff availability for booking';
|
||||
COMMENT ON COLUMN google_calendar_events.is_anchoros_booking IS 'True if event was created by AnchorOS sync, false if external';
|
||||
COMMENT ON COLUMN google_calendar_events.booking_id IS 'Reference to AnchorOS booking if this is a synced booking event';
|
||||
COMMENT ON COLUMN google_calendar_events.synced_at IS 'Last synchronization timestamp';
|
||||
COMMENT ON COLUMN google_calendar_events.sync_status IS 'Sync status: synced, pending, failed';
|
||||
COMMENT ON COLUMN google_calendar_events.sync_error IS 'Error message if sync failed';
|
||||
|
||||
-- Create indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_google_calendar_events_staff ON google_calendar_events(staff_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_google_calendar_events_time ON google_calendar_events(start_time_utc, end_time_utc);
|
||||
CREATE INDEX IF NOT EXISTS idx_google_calendar_events_booking ON google_calendar_events(booking_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_google_calendar_events_blocking ON google_calendar_events(staff_id, is_blocking) WHERE is_blocking = true;
|
||||
CREATE INDEX IF NOT EXISTS idx_google_calendar_events_sync_status ON google_calendar_events(sync_status);
|
||||
|
||||
-- Create function to update updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_google_calendar_events_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger to auto-update updated_at
|
||||
DROP TRIGGER IF EXISTS trigger_update_google_calendar_events_updated_at ON google_calendar_events;
|
||||
CREATE TRIGGER trigger_update_google_calendar_events_updated_at
|
||||
BEFORE UPDATE ON google_calendar_events
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_google_calendar_events_updated_at();
|
||||
|
||||
-- Add google_calendar_event_id column to bookings if not exists (for bidirectional sync)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'bookings' AND column_name = 'google_calendar_event_id') THEN
|
||||
ALTER TABLE bookings ADD COLUMN google_calendar_event_id VARCHAR(255);
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_google_event ON bookings(google_calendar_event_id);
|
||||
COMMENT ON COLUMN bookings.google_calendar_event_id IS 'Google Calendar event ID for this booking';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Create trigger to automatically sync booking creation/update to Google Calendar
|
||||
CREATE OR REPLACE FUNCTION trigger_sync_booking_to_google_calendar()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Only sync if GOOGLE_CALENDAR_SYNC is enabled (check via environment variable)
|
||||
-- This is handled at application level, not in database trigger
|
||||
-- The trigger here is just a placeholder for potential future implementation
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Note: The actual sync logic is implemented in /lib/google-calendar.ts
|
||||
-- This migration provides the database schema needed for bidirectional sync
|
||||
|
||||
-- Create function to get blocking calendar events for a staff member
|
||||
CREATE OR REPLACE FUNCTION get_blocking_calendar_events(
|
||||
p_staff_id UUID,
|
||||
p_start_time_utc TIMESTAMPTZ,
|
||||
p_end_time_utc TIMESTAMPTZ
|
||||
)
|
||||
RETURNS TABLE (
|
||||
id UUID,
|
||||
google_event_id VARCHAR(255),
|
||||
title VARCHAR(500),
|
||||
start_time_utc TIMESTAMPTZ,
|
||||
end_time_utc TIMESTAMPTZ
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
gce.id,
|
||||
gce.google_event_id,
|
||||
gce.title,
|
||||
gce.start_time_utc,
|
||||
gce.end_time_utc
|
||||
FROM google_calendar_events gce
|
||||
WHERE gce.staff_id = p_staff_id
|
||||
AND gce.is_blocking = true
|
||||
AND gce.start_time_utc < p_end_time_utc
|
||||
AND gce.end_time_utc > p_start_time_utc
|
||||
AND gce.is_anchoros_booking = false -- Only external events block
|
||||
ORDER BY gce.start_time_utc;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Create function to check if time slot conflicts with blocking calendar events
|
||||
CREATE OR REPLACE FUNCTION check_calendar_blocking(
|
||||
p_staff_id UUID,
|
||||
p_start_time_utc TIMESTAMPTZ,
|
||||
p_end_time_utc TIMESTAMPTZ,
|
||||
p_exclude_booking_id UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS BOOLEAN AS $$
|
||||
DECLARE
|
||||
v_has_conflict BOOLEAN := false;
|
||||
BEGIN
|
||||
-- Check for blocking calendar events (excluding AnchorOS bookings)
|
||||
SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM google_calendar_events gce
|
||||
WHERE gce.staff_id = p_staff_id
|
||||
AND gce.is_blocking = true
|
||||
AND gce.start_time_utc < p_end_time_utc
|
||||
AND gce.end_time_utc > p_start_time_utc
|
||||
AND (p_exclude_booking_id IS NULL OR gce.booking_id != p_exclude_booking_id)
|
||||
) INTO v_has_conflict;
|
||||
|
||||
RETURN NOT v_has_conflict; -- Return true if NO conflicts (available)
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Grant necessary permissions
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON google_calendar_events TO authenticated;
|
||||
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO authenticated;
|
||||
|
||||
-- RLS Policies for google_calendar_events
|
||||
ALTER TABLE google_calendar_events ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Policy: Staff can see their own calendar events
|
||||
CREATE POLICY "Staff can view own calendar events"
|
||||
ON google_calendar_events
|
||||
FOR SELECT
|
||||
USING (
|
||||
auth.uid()::text = (SELECT user_id::text FROM staff WHERE id = staff_id)
|
||||
OR
|
||||
(SELECT role FROM staff WHERE id = staff_id) IN ('admin', 'manager')
|
||||
);
|
||||
|
||||
-- Policy: Admins and managers can insert calendar events
|
||||
CREATE POLICY "Admins and managers can insert calendar events"
|
||||
ON google_calendar_events
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM staff
|
||||
WHERE id = staff_id
|
||||
AND user_id = auth.uid()
|
||||
AND role IN ('admin', 'manager')
|
||||
)
|
||||
);
|
||||
|
||||
-- Policy: Admins and managers can update calendar events
|
||||
CREATE POLICY "Admins and managers can update calendar events"
|
||||
ON google_calendar_events
|
||||
FOR UPDATE
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM staff
|
||||
WHERE id = staff_id
|
||||
AND user_id = auth.uid()
|
||||
AND role IN ('admin', 'manager')
|
||||
)
|
||||
);
|
||||
|
||||
-- Policy: Admins and managers can delete calendar events
|
||||
CREATE POLICY "Admins and managers can delete calendar events"
|
||||
ON google_calendar_events
|
||||
FOR DELETE
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM staff
|
||||
WHERE id = staff_id
|
||||
AND user_id = auth.uid()
|
||||
AND role IN ('admin', 'manager')
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,95 @@
|
||||
-- ============================================
|
||||
-- FASE 2.1 - UPDATE AVAILABILITY WITH CALENDAR SYNC
|
||||
-- Date: 20260118
|
||||
-- Description: Update check_staff_availability to include Google Calendar conflicts
|
||||
-- ============================================
|
||||
|
||||
/**
|
||||
* @description Updated check_staff_availability with Google Calendar integration
|
||||
* @param {UUID} p_staff_id - ID del staff a verificar
|
||||
* @param {TIMESTAMPTZ} p_start_time_utc - Hora de inicio en UTC
|
||||
* @param {TIMESTAMPTZ} p_end_time_utc - Hora de fin en UTC
|
||||
* @param {UUID} p_exclude_booking_id - (Opcional) ID de reserva a excluir
|
||||
* @returns {BOOLEAN} - true si el staff está disponible, false en caso contrario
|
||||
* @example SELECT check_staff_availability('uuid...', NOW(), NOW() + INTERVAL '1 hour', NULL);
|
||||
*/
|
||||
|
||||
-- Drop existing function
|
||||
DROP FUNCTION IF EXISTS check_staff_availability(UUID, TIMESTAMPTZ, TIMESTAMPTZ, UUID) CASCADE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION check_staff_availability(
|
||||
p_staff_id UUID,
|
||||
p_start_time_utc TIMESTAMPTZ,
|
||||
p_end_time_utc TIMESTAMPTZ,
|
||||
p_exclude_booking_id UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS BOOLEAN AS $$
|
||||
DECLARE
|
||||
v_staff RECORD;
|
||||
v_has_work_conflict BOOLEAN := false;
|
||||
v_has_booking_conflict BOOLEAN := false;
|
||||
v_has_calendar_conflict BOOLEAN := false;
|
||||
v_has_block_conflict BOOLEAN := false;
|
||||
BEGIN
|
||||
-- 1. Check if staff exists and is active
|
||||
SELECT * INTO v_staff FROM staff WHERE id = p_staff_id;
|
||||
|
||||
IF NOT FOUND OR NOT v_staff.is_active OR NOT v_staff.is_available_for_booking THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
|
||||
-- 2. Check work hours and days
|
||||
v_has_work_conflict := NOT check_staff_work_hours(p_staff_id, p_start_time_utc, p_end_time_utc);
|
||||
IF v_has_work_conflict THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
|
||||
-- 3. Check existing bookings conflict
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM bookings b
|
||||
WHERE b.staff_id = p_staff_id
|
||||
AND b.status != 'cancelled'
|
||||
AND b.start_time_utc < p_end_time_utc
|
||||
AND b.end_time_utc > p_start_time_utc
|
||||
AND (p_exclude_booking_id IS NULL OR b.id != p_exclude_booking_id)
|
||||
) INTO v_has_booking_conflict;
|
||||
|
||||
IF v_has_booking_conflict THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
|
||||
-- 4. Check manual blocks conflict
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM staff_availability sa
|
||||
WHERE sa.staff_id = p_staff_id
|
||||
AND sa.date = p_start_time_utc::DATE
|
||||
AND sa.is_available = false
|
||||
AND (p_start_time_utc::TIME >= sa.start_time AND p_start_time_utc::TIME < sa.end_time
|
||||
OR p_end_time_utc::TIME > sa.start_time AND p_end_time_utc::TIME <= sa.end_time
|
||||
OR p_start_time_utc::TIME <= sa.start_time AND p_end_time_utc::TIME >= sa.end_time)
|
||||
) INTO v_has_block_conflict;
|
||||
|
||||
IF v_has_block_conflict THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
|
||||
-- 5. NEW: Check Google Calendar blocking events conflict
|
||||
v_has_calendar_conflict := NOT check_calendar_blocking(p_staff_id, p_start_time_utc, p_end_time_utc, p_exclude_booking_id);
|
||||
|
||||
IF v_has_calendar_conflict THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
|
||||
-- All checks passed - staff is available
|
||||
RETURN true;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Update get_detailed_availability to use the updated function (already calls it)
|
||||
-- No change needed as it cascades
|
||||
|
||||
-- Test function
|
||||
COMMENT ON FUNCTION check_staff_availability IS 'Enhanced availability check including Google Calendar sync. Verifies work hours, bookings, manual blocks, and external calendar events.';
|
||||
|
||||
-- Grant execute permission
|
||||
GRANT EXECUTE ON FUNCTION check_staff_availability TO authenticated, anon, service_role;
|
||||
121
supabase/migrations/20260118030000_dual_artist_support.sql
Normal file
121
supabase/migrations/20260118030000_dual_artist_support.sql
Normal file
@@ -0,0 +1,121 @@
|
||||
-- ============================================
|
||||
-- FASE 2.2 - DUAL ARTIST SERVICES SUPPORT
|
||||
-- Date: 20260118
|
||||
-- Description: Add premium_amount to services and dual artist assignment functions
|
||||
-- ============================================
|
||||
|
||||
-- Add premium_amount column to services
|
||||
ALTER TABLE services ADD COLUMN IF NOT EXISTS premium_amount DECIMAL(10,2) DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN services.premium_amount IS 'Additional fee for premium/express services (auto-applied if premium_fee_enabled)';
|
||||
|
||||
-- Update seed data for express services (example)
|
||||
UPDATE services
|
||||
SET premium_amount = 500
|
||||
WHERE name LIKE '%Express%' OR requires_dual_artist = true;
|
||||
|
||||
-- Create function to assign dual artists
|
||||
CREATE OR REPLACE FUNCTION assign_dual_artists(
|
||||
p_location_id UUID,
|
||||
p_start_time_utc TIMESTAMPTZ,
|
||||
p_end_time_utc TIMESTAMPTZ,
|
||||
p_service_id UUID
|
||||
)
|
||||
RETURNS JSONB AS $$
|
||||
DECLARE
|
||||
v_primary_artist UUID;
|
||||
v_secondary_artist UUID;
|
||||
v_room_resource UUID;
|
||||
v_service RECORD;
|
||||
v_artists JSONB;
|
||||
BEGIN
|
||||
-- Get service details
|
||||
SELECT * INTO v_service FROM services WHERE id = p_service_id;
|
||||
|
||||
IF NOT FOUND OR NOT v_service.requires_dual_artist THEN
|
||||
RETURN jsonb_build_object(
|
||||
'primary_artist', NULL,
|
||||
'secondary_artist', NULL,
|
||||
'room_resource', NULL,
|
||||
'error', 'Service does not require dual artists'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- 1. Find available room resource
|
||||
SELECT id INTO v_room_resource
|
||||
FROM resources r
|
||||
WHERE r.location_id = p_location_id
|
||||
AND r.type = 'room' -- Assuming room type enum exists
|
||||
AND check_resource_availability(r.id, p_start_time_utc, p_end_time_utc)
|
||||
ORDER BY r.name -- or priority
|
||||
LIMIT 1;
|
||||
|
||||
IF v_room_resource IS NULL THEN
|
||||
RETURN jsonb_build_object(
|
||||
'primary_artist', NULL,
|
||||
'secondary_artist', NULL,
|
||||
'room_resource', NULL,
|
||||
'error', 'No available room resource'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- 2. Find 2 available artists/staff (priority: artist > staff)
|
||||
SELECT jsonb_agg(jsonb_build_object('id', s.id, 'display_name', s.display_name, 'role', s.role)) INTO v_artists
|
||||
FROM staff s
|
||||
WHERE s.location_id = p_location_id
|
||||
AND s.is_active = true
|
||||
AND s.is_available_for_booking = true
|
||||
AND s.role IN ('artist', 'staff')
|
||||
AND check_staff_availability(s.id, p_start_time_utc, p_end_time_utc)
|
||||
ORDER BY
|
||||
CASE s.role
|
||||
WHEN 'artist' THEN 1
|
||||
WHEN 'staff' THEN 2
|
||||
END,
|
||||
s.display_name
|
||||
LIMIT 2;
|
||||
|
||||
IF jsonb_array_length(v_artists) < 2 THEN
|
||||
RETURN jsonb_build_object(
|
||||
'primary_artist', NULL,
|
||||
'secondary_artist', NULL,
|
||||
'room_resource', v_room_resource,
|
||||
'error', 'Insufficient available artists (need 2)'
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT (v_artists->0)->>'id' INTO v_primary_artist;
|
||||
SELECT (v_artists->1)->>'id' INTO v_secondary_artist;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'primary_artist', v_primary_artist,
|
||||
'secondary_artist', v_secondary_artist,
|
||||
'room_resource', v_room_resource,
|
||||
'success', true
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Create function to calculate service total with premium
|
||||
CREATE OR REPLACE FUNCTION calculate_service_total(p_service_id UUID)
|
||||
RETURNS DECIMAL(10,2) AS $$
|
||||
DECLARE
|
||||
v_total DECIMAL(10,2);
|
||||
BEGIN
|
||||
SELECT
|
||||
COALESCE(base_price, 0) +
|
||||
CASE WHEN premium_fee_enabled THEN COALESCE(premium_amount, 0) ELSE 0 END
|
||||
INTO v_total
|
||||
FROM services
|
||||
WHERE id = p_service_id;
|
||||
|
||||
RETURN COALESCE(v_total, 0);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Grant permissions
|
||||
GRANT EXECUTE ON FUNCTION assign_dual_artists TO authenticated, service_role;
|
||||
GRANT EXECUTE ON FUNCTION calculate_service_total TO authenticated, service_role;
|
||||
|
||||
COMMENT ON FUNCTION assign_dual_artists IS 'Automatically assigns primary/secondary artists and room for dual-artist services';
|
||||
COMMENT ON FUNCTION calculate_service_total IS 'Calculates total price including premium fee if enabled';
|
||||
@@ -0,0 +1,76 @@
|
||||
-- ============================================
|
||||
-- FASE 2.3 - ENHANCED AVAILABILITY WITH PRIORITY
|
||||
-- Date: 20260118
|
||||
-- Description: Priority resource assignment + dual count + collision detection
|
||||
-- ============================================
|
||||
|
||||
-- Enhance get_available_resources_with_priority with code priority
|
||||
DROP FUNCTION IF EXISTS get_available_resources_with_priority(UUID, TIMESTAMPTZ, TIMESTAMPTZ) CASCADE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_available_resources_with_priority(
|
||||
p_location_id UUID,
|
||||
p_start_time_utc TIMESTAMPTZ,
|
||||
p_end_time_utc TIMESTAMPTZ
|
||||
)
|
||||
RETURNS TABLE (
|
||||
resource_id UUID,
|
||||
resource_name VARCHAR,
|
||||
resource_type resource_type,
|
||||
priority_order INTEGER
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
r.id,
|
||||
r.name,
|
||||
r.type,
|
||||
CASE
|
||||
WHEN r.name LIKE 'mkup%' THEN 1
|
||||
WHEN r.name LIKE 'lshs%' THEN 2
|
||||
WHEN r.name LIKE 'pedi%' THEN 3
|
||||
WHEN r.name LIKE 'mani%' THEN 4
|
||||
ELSE 5
|
||||
END as priority_order
|
||||
FROM resources r
|
||||
WHERE r.location_id = p_location_id
|
||||
AND r.is_active = true
|
||||
AND check_resource_availability(r.id, p_start_time_utc, p_end_time_utc)
|
||||
ORDER BY priority_order, r.name;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- New dual availability function
|
||||
CREATE OR REPLACE FUNCTION get_dual_availability(
|
||||
p_location_id UUID,
|
||||
p_service_id UUID,
|
||||
p_date DATE,
|
||||
p_time_slot_duration_minutes INTEGER DEFAULT 60
|
||||
)
|
||||
RETURNS JSONB AS $$
|
||||
DECLARE
|
||||
v_dual_slots JSONB := '[]'::JSONB;
|
||||
-- ... (similar to get_detailed_availability but count pairs)
|
||||
BEGIN
|
||||
-- Reuse get_detailed_availability logic but filter COUNT >=2
|
||||
-- For simplicity, approximate with staff count >=2
|
||||
SELECT jsonb_agg(row_to_json(t))
|
||||
INTO v_dual_slots
|
||||
FROM (
|
||||
SELECT
|
||||
v_slot_start::TEXT as start_time,
|
||||
(v_slot_start + (p_time_slot_duration_minutes || ' minutes')::INTERVAL)::TEXT as end_time,
|
||||
available_staff_count >= 2 as available,
|
||||
available_staff_count
|
||||
FROM get_detailed_availability(p_location_id, p_service_id, p_date, p_time_slot_duration_minutes) slots
|
||||
WHERE (slots->>'available_staff_count')::INT >= 2
|
||||
) t;
|
||||
|
||||
RETURN v_dual_slots;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
COMMENT ON FUNCTION get_available_resources_with_priority IS 'Available resources ordered by priority: mkup > lshs > pedi > mani';
|
||||
COMMENT ON FUNCTION get_dual_availability IS 'Availability slots where >=2 staff available (for dual services)';
|
||||
|
||||
GRANT EXECUTE ON FUNCTION get_available_resources_with_priority TO authenticated, service_role;
|
||||
GRANT EXECUTE ON FUNCTION get_dual_availability TO authenticated, service_role;
|
||||
Reference in New Issue
Block a user