diff --git a/public/buses/ambato-express/bus-1.jpg b/public/buses/ambato-express/bus-1.jpg new file mode 100644 index 0000000..77adbcf Binary files /dev/null and b/public/buses/ambato-express/bus-1.jpg differ diff --git a/public/buses/cita-express/bus-1.jpg b/public/buses/cita-express/bus-1.jpg new file mode 100644 index 0000000..2737b2f Binary files /dev/null and b/public/buses/cita-express/bus-1.jpg differ diff --git a/public/buses/flota-pelileo/bus-1.jpg b/public/buses/flota-pelileo/bus-1.jpg new file mode 100644 index 0000000..f22d476 Binary files /dev/null and b/public/buses/flota-pelileo/bus-1.jpg differ diff --git a/public/buses/transportes-ambato/bus-1.jpg b/public/buses/transportes-ambato/bus-1.jpg new file mode 100644 index 0000000..1037735 Binary files /dev/null and b/public/buses/transportes-ambato/bus-1.jpg differ diff --git a/public/buses/transportes-banos/bus-1.jpg b/public/buses/transportes-banos/bus-1.jpg new file mode 100644 index 0000000..c401c59 Binary files /dev/null and b/public/buses/transportes-banos/bus-1.jpg differ diff --git a/src/app/api/boletos/verificar-qr/route.ts b/src/app/api/boletos/verificar-qr/route.ts index 5b8a35d..3da0a25 100644 --- a/src/app/api/boletos/verificar-qr/route.ts +++ b/src/app/api/boletos/verificar-qr/route.ts @@ -60,7 +60,13 @@ export async function POST(request: NextRequest) { }, { status: 200 }); } - if (boleto.estado !== 'COMPLETADO' && boleto.estado !== 'CONFIRMADO') { + const estadoNorm = (boleto.estado || '').toUpperCase(); + if ( + estadoNorm !== 'COMPLETADO' && + estadoNorm !== 'CONFIRMADO' && + estadoNorm !== 'PAGADO' && + estadoNorm !== 'EMITIDO' + ) { return NextResponse.json({ success: false, message: 'BOLETO INVÁLIDO (Estado no apto)' }, { status: 400 }); } diff --git a/src/app/busqueda/page.tsx b/src/app/busqueda/page.tsx index 56bdda0..046eea6 100644 --- a/src/app/busqueda/page.tsx +++ b/src/app/busqueda/page.tsx @@ -36,6 +36,8 @@ import { Card, CardContent } from '@/components/ui/Card'; import { Badge } from '@/components/ui/Badge'; import { Button } from '@/components/ui/Button'; import { SeatSelector } from '@/components/bus/SeatSelector'; +import { GaleriaFlota } from '@/components/horarios/GaleriaFlota'; +import { resolverSlugCooperativa } from '@/data/buses-galeria'; function BusquedaContent() { const router = useRouter(); @@ -272,6 +274,23 @@ function BusquedaContent() { loadData(); }, []); + // Si la URL trae un slug de cooperativa (ej. ?cooperativa=transportes-banos), resolver al ID real + useEffect(() => { + if (!coopFiltro || cooperativas.length === 0) return; + const existeExacto = cooperativas.some((c) => c.id === coopFiltro); + if (existeExacto) return; + + const slugBuscado = resolverSlugCooperativa(coopFiltro); + if (!slugBuscado) return; + + const candidatas = cooperativas.filter((c) => resolverSlugCooperativa(c) === slugBuscado); + const conFrecuencia = candidatas.find((c) => frecuencias.some((f) => f.cooperativaId === c.id)); + const elegida = conFrecuencia ?? candidatas[0]; + if (elegida && elegida.id !== coopFiltro) { + setCoopFiltro(elegida.id); + } + }, [coopFiltro, cooperativas, frecuencias]); + // Lista única de ciudades para autocompletado/selects const todasCiudades = useMemo(() => { return Array.from( @@ -344,6 +363,11 @@ function BusquedaContent() { // Método de pago y emisión const [metodoPago, setMetodoPago] = useState<'tarjeta' | 'transferencia' | 'efectivo'>('tarjeta'); const [comprobanteUrl, setComprobanteUrl] = useState(''); + const [comprobanteFile, setComprobanteFile] = useState(null); + const [cardNumber, setCardNumber] = useState(''); + const [cardHolder, setCardHolder] = useState(''); + const [cardExpiry, setCardExpiry] = useState(''); + const [cardCvv, setCardCvv] = useState(''); const [submitting, setSubmitting] = useState(false); const [boletoGenerado, setBoletoGenerado] = useState(null); const [errorMsg, setErrorMsg] = useState(null); @@ -503,6 +527,8 @@ function BusquedaContent() { setCalculoTramo(null); setErrorTramo(null); setCedulaInfo(null); + setComprobanteFile(null); + setComprobanteUrl(''); }; const paradaOrigenObj = secuenciaParadas.find((p) => p.id === selectedOrigenId); @@ -548,10 +574,85 @@ function BusquedaContent() { return; } + if (metodoPago === 'tarjeta') { + const cleanCard = cardNumber.replace(/\D/g, ''); + if (cleanCard.length < 13 || cleanCard.length > 19) { + setErrorMsg('Ingresa un número de tarjeta válido (16 dígitos).'); + return; + } + if (!cardHolder.trim()) { + setErrorMsg('Ingresa el nombre del titular de la tarjeta.'); + return; + } + if (!/^\d{2}\/\d{2}$/.test(cardExpiry.trim())) { + setErrorMsg('Ingresa la fecha de vencimiento en formato MM/AA (ej: 08/28).'); + return; + } + if (cardCvv.replace(/\D/g, '').length < 3) { + setErrorMsg('Ingresa el código de seguridad CVV (3 o 4 dígitos).'); + return; + } + } + + if (metodoPago === 'transferencia' && !comprobanteFile && !comprobanteUrl.trim()) { + setErrorMsg('Adjunta el archivo de tu orden de pago / comprobante o ingresa el número de referencia.'); + return; + } + setSubmitting(true); setErrorMsg(null); try { + let finalComprobanteUrl: string | null = comprobanteUrl.trim() || null; + + if (metodoPago === 'transferencia' && comprobanteFile) { + const allowedTypes = ['application/pdf', 'image/png', 'image/jpeg']; + if (!allowedTypes.includes(comprobanteFile.type)) { + setErrorMsg('Formato de comprobante no admitido. Sube un archivo PDF, PNG o JPG.'); + setSubmitting(false); + return; + } + if (comprobanteFile.size > 5 * 1024 * 1024) { + setErrorMsg('El archivo del comprobante supera los 5 MB permitidos.'); + setSubmitting(false); + return; + } + + const supabase = createClient(); + const ext = comprobanteFile.name.split('.').pop() || 'jpg'; + const tempRef = `bol-${Date.now()}`; + const storagePath = `${authUserId || 'publico'}/${tempRef}/${Date.now()}.${ext}`; + + const { error: uploadErr } = await supabase.storage + .from('comprobantes_pagos') + .upload(storagePath, comprobanteFile, { cacheControl: '3600', upsert: false }); + + if (!uploadErr) { + finalComprobanteUrl = storagePath; + } else if (comprobanteFile.type.startsWith('image/') && comprobanteFile.size <= 1500000) { + const dataUrl = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(typeof reader.result === 'string' ? reader.result : ''); + reader.onerror = () => resolve(''); + reader.readAsDataURL(comprobanteFile); + }); + finalComprobanteUrl = + dataUrl || + (comprobanteUrl.trim() + ? `${comprobanteUrl.trim()} (${comprobanteFile.name})` + : comprobanteFile.name); + } else { + finalComprobanteUrl = comprobanteUrl.trim() + ? `${comprobanteUrl.trim()} (${comprobanteFile.name})` + : comprobanteFile.name; + } + } + + const estadoInicial = + metodoPago === 'transferencia' + ? 'PENDIENTE_APROBACION' + : 'COMPLETADO'; + const nuevoBoleto = await dataService.createBoleto({ rutaId: selectedRuta!.id, pasajeroId: authUserId, @@ -560,8 +661,8 @@ function BusquedaContent() { descuento: desgloseTarifa.valorDescuento, tipoPasajero: desgloseTarifa.tipoBeneficiario, metodoPago, - comprobanteUrl: comprobanteUrl || null, - estado: metodoPago === 'transferencia' ? 'emitido' : 'pagado', + comprobanteUrl: finalComprobanteUrl, + estado: estadoInicial, vendidoPor: 'online_publico', vendidoPorChoferId: null, paradaOrigenId: selectedOrigenId || null, @@ -972,7 +1073,28 @@ function BusquedaContent() { Cargando itinerarios y paradas disponibles desde Supabase...

- ) : filteredFrecuencias.length === 0 ? ( + ) : ( + <> + {/* Galería de Flota de Buses por Cooperativa (Arriba del listado de frecuencias) */} + + + {/* Contador de resultados filtrados */} +
+ + Itinerarios y Horarios Disponibles + + + {filteredFrecuencias.length} de {frecuencias.length} frecuencia{frecuencias.length === 1 ? '' : 's'} + +
+ + {filteredFrecuencias.length === 0 ? ( /* ======================================================== */ /* ESTADO VACÍO ("Sin resultados para este filtro") */ /* ======================================================== */ @@ -1036,7 +1158,7 @@ function BusquedaContent() {
Hora de salida: - ⏰ {frec.hora} + {frec.hora}
@@ -1124,6 +1246,8 @@ function BusquedaContent() { ); })}
+ )} + )} @@ -1164,9 +1288,15 @@ function BusquedaContent() { ✓
-

¡Boleto Emitido Exitosamente!

+

+ {boletoGenerado.estado === 'PENDIENTE_APROBACION' + ? '¡Reserva y Comprobante Registrados!' + : '¡Boleto Emitido Exitosamente!'} +

- Presenta este código QR y tu cédula original al abordar la unidad. + {boletoGenerado.estado === 'PENDIENTE_APROBACION' + ? 'Tu comprobante fue enviado a ventanilla para validación. Una vez aprobado por el oficinista podrás descargar el QR en Mis Boletos.' + : 'Presenta este código QR y tu cédula original al abordar la unidad.'}

@@ -1566,18 +1696,173 @@ function BusquedaContent() { + {metodoPago === 'tarjeta' && ( +
+
+ + Pago con Tarjeta de Débito o Crédito + + + Aprobación inmediata + +
+ +
+ + { + const digits = e.target.value.replace(/\D/g, '').slice(0, 16); + const formatted = digits.replace(/(\d{4})(?=\d)/g, '$1 '); + setCardNumber(formatted); + }} + className="w-full text-xs border border-neutral-300 rounded-lg px-2.5 py-2 bg-white font-mono tracking-wider focus:ring-2 focus:ring-brand-500" + /> +
+ +
+
+ + setCardHolder(e.target.value)} + className="w-full text-xs border border-neutral-300 rounded-lg px-2.5 py-2 bg-white focus:ring-2 focus:ring-brand-500" + /> +
+ +
+ + { + const digits = e.target.value.replace(/\D/g, '').slice(0, 4); + const formatted = + digits.length >= 3 + ? `${digits.slice(0, 2)}/${digits.slice(2)}` + : digits; + setCardExpiry(formatted); + }} + className="w-full text-xs border border-neutral-300 rounded-lg px-2.5 py-2 bg-white font-mono focus:ring-2 focus:ring-brand-500" + /> +
+ +
+ + + setCardCvv(e.target.value.replace(/\D/g, '').slice(0, 4)) + } + className="w-full text-xs border border-neutral-300 rounded-lg px-2.5 py-2 bg-white font-mono focus:ring-2 focus:ring-brand-500" + /> +
+
+
+ )} + {metodoPago === 'transferencia' && ( -
-

Datos Bancarios para Transferencia:

-

Banco Pichincha - Cta. Corriente: 2100456789

-

Beneficiario: EcuRutas Interprovincial UTA (RUC: 1890001234001)

- setComprobanteUrl(e.target.value)} - className="mt-1 w-full text-xs border border-info-200 rounded px-2 py-1 bg-white" - /> +
+
+ + Transferencia o Depósito Bancario + + + Validación en ventanilla + +
+ +
+

+ Banco Pichincha · Cta. Corriente:{' '} + 2100456789 +

+

+ Beneficiario:{' '} + {getCoop(selectedFrecuencia.cooperativaId)?.nombre || + 'Cooperativa de Transportes'}{' '} + (RUC: {getCoop(selectedFrecuencia.cooperativaId)?.ruc || '1890001234001'}) +

+
+ +
+ + { + const f = e.target.files?.[0] || null; + setComprobanteFile(f); + }} + className="block w-full text-xs text-neutral-600 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-xs file:font-bold file:bg-brand-600 file:text-white hover:file:bg-brand-700 cursor-pointer bg-white border border-neutral-300 rounded-lg p-1" + /> + {comprobanteFile && ( +

+ ✔ Archivo adjunto: {comprobanteFile.name} ( + {(comprobanteFile.size / 1024).toFixed(1)} KB) +

+ )} +
+ +
+ + setComprobanteUrl(e.target.value)} + className="w-full text-xs border border-neutral-300 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-brand-500" + /> +
)} diff --git a/src/app/oficina/page.tsx b/src/app/oficina/page.tsx index c593809..bc8fce7 100644 --- a/src/app/oficina/page.tsx +++ b/src/app/oficina/page.tsx @@ -91,19 +91,24 @@ export default function OficinaPage() { setModalSuccess(null); if (boleto.comprobanteUrl) { - setIsSigningUrl(true); - try { - // URL firmada válida por 60 minutos (3600 segundos) - const { data, error } = await supabase.storage - .from('comprobantes_pagos') - .createSignedUrl(boleto.comprobanteUrl, 3600); - - if (error) throw new Error('No se pudo generar la URL del comprobante.'); - setSignedUrl(data.signedUrl); - } catch (err: unknown) { - setModalError(err instanceof Error ? err.message : 'Error al cargar la imagen.'); - } finally { - setIsSigningUrl(false); + if (boleto.comprobanteUrl.startsWith('data:') || boleto.comprobanteUrl.startsWith('http')) { + setSignedUrl(boleto.comprobanteUrl); + } else if (boleto.comprobanteUrl.includes('/')) { + setIsSigningUrl(true); + try { + // URL firmada válida por 60 minutos (3600 segundos) + const { data, error } = await supabase.storage + .from('comprobantes_pagos') + .createSignedUrl(boleto.comprobanteUrl, 3600); + + if (!error && data?.signedUrl) { + setSignedUrl(data.signedUrl); + } + } catch { + // Si falla la firma en Storage, se muestra la referencia textual en el panel + } finally { + setIsSigningUrl(false); + } } } }; @@ -283,12 +288,21 @@ export default function OficinaPage() {
- {/* Visor de Imagen */} + {/* Visor de Imagen / Comprobante */}
{isSigningUrl ? (

Cargando comprobante...

) : signedUrl ? ( Comprobante + ) : selectedBoleto.comprobanteUrl ? ( +
+ + Referencia de Comprobante / Orden de Pago + +

+ {selectedBoleto.comprobanteUrl} +

+
) : (

No hay comprobante disponible.

)} diff --git a/src/components/boletos/BoletoDigital.tsx b/src/components/boletos/BoletoDigital.tsx index df24d6d..8dffbb9 100644 --- a/src/components/boletos/BoletoDigital.tsx +++ b/src/components/boletos/BoletoDigital.tsx @@ -9,9 +9,15 @@ interface BoletoProps { export default function BoletoDigital({ boletoId, estado }: BoletoProps) { const [tokenQr, setTokenQr] = useState(''); + const estadoUpper = estado.toUpperCase(); + const esValido = + estadoUpper === 'COMPLETADO' || + estadoUpper === 'CONFIRMADO' || + estadoUpper === 'PAGADO' || + estadoUpper === 'EMBARCADO'; useEffect(() => { - if (estado === 'COMPLETADO') { + if (esValido) { fetch(`/api/boletos/${boletoId}/qr`) .then((res) => res.json()) .then((data) => { @@ -20,9 +26,9 @@ export default function BoletoDigital({ boletoId, estado }: BoletoProps) { } }); } - }, [boletoId, estado]); + }, [boletoId, esValido]); - if (estado !== 'COMPLETADO') { + if (!esValido) { return

El QR estará disponible cuando el boleto esté completado.

; } diff --git a/src/components/horarios/GaleriaFlota.tsx b/src/components/horarios/GaleriaFlota.tsx new file mode 100644 index 0000000..7a0b59f --- /dev/null +++ b/src/components/horarios/GaleriaFlota.tsx @@ -0,0 +1,509 @@ +'use client'; + +import React, { useState, useEffect, useRef, useMemo } from 'react'; +import Image from 'next/image'; +import type { CooperativaRow, BusRow, FrecuenciaRow } from '@/lib/types'; +import { + obtenerImagenesFlotaCooperativa, + resolverSlugCooperativa, + type ImagenFlotaBus, +} from '@/data/buses-galeria'; +import { Badge } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; + +export interface GaleriaFlotaProps { + cooperativas: CooperativaRow[]; + buses: BusRow[]; + frecuencias: FrecuenciaRow[]; + cooperativaSeleccionadaId: string; + onSelectCooperativa: (cooperativaId: string) => void; + urlsRemotasPorCooperativa?: Record; +} + +interface TarjetaFlotaExtendida { + imagen: ImagenFlotaBus; + resumenBus: string; + esVip: boolean; + asientosVip: number; +} + +function construirResumenBus(bus: BusRow | undefined, nombreCooperativa: string): { + texto: string; + esVip: boolean; + asientosVip: number; +} { + if (!bus) { + return { + texto: nombreCooperativa, + esVip: false, + asientosVip: 0, + }; + } + + const partes: string[] = []; + if (bus.chasis?.trim()) { + partes.push(`Chasis ${bus.chasis.trim()}`); + } else if (bus.marca?.trim()) { + partes.push(`Marca ${bus.marca.trim()}`); + } + + if (bus.carroceria?.trim()) { + partes.push(`Carrocería ${bus.carroceria.trim()}`); + } + + const vipCount = bus.asientosVIP ?? 0; + if (vipCount > 0) { + partes.push(`Servicio VIP (${vipCount} asientos)`); + } else { + partes.push('Servicio Normal'); + } + + return { + texto: partes.length > 0 ? partes.join(' · ') : nombreCooperativa, + esVip: vipCount > 0, + asientosVip: vipCount, + }; +} + +export function GaleriaFlota({ + cooperativas, + buses, + frecuencias, + cooperativaSeleccionadaId, + onSelectCooperativa, + urlsRemotasPorCooperativa, +}: GaleriaFlotaProps) { + const [loadedImages, setLoadedImages] = useState>({}); + const [lightboxIndex, setLightboxIndex] = useState(null); + const [isVisible, setIsVisible] = useState(true); + const modalRef = useRef(null); + const closeBtnRef = useRef(null); + + // Animación suave de 250ms al cambiar de cooperativa, respetando prefers-reduced-motion + useEffect(() => { + setIsVisible(false); + const timer = setTimeout(() => { + setIsVisible(true); + }, 30); + return () => clearTimeout(timer); + }, [cooperativaSeleccionadaId]); + + const cooperativaActiva = useMemo(() => { + if (!cooperativaSeleccionadaId) return null; + return ( + cooperativas.find((c) => c.id === cooperativaSeleccionadaId) || + cooperativas.find( + (c) => resolverSlugCooperativa(c) === resolverSlugCooperativa(cooperativaSeleccionadaId) + ) || + null + ); + }, [cooperativas, cooperativaSeleccionadaId]); + + // Agrupar cooperativas para la vista "Todas las cooperativas" evitando duplicar tarjetas idénticas + // (ej. "Flota Pelileo" y "Flota Pelileo C.A."), priorizando el registro que tenga frecuencias activas. + const cooperativasPortada = useMemo(() => { + const porSlug = new Map< + string, + { + cooperativa: CooperativaRow; + portada: ImagenFlotaBus | null; + resumenBus: string; + frecuenciasCount: number; + } + >(); + + for (const coop of cooperativas) { + const slug = resolverSlugCooperativa(coop) ?? coop.id; + const frecCount = frecuencias.filter((f) => f.cooperativaId === coop.id).length; + const existente = porSlug.get(slug); + + if (!existente || (frecCount > 0 && existente.frecuenciasCount === 0)) { + const urlsRemotas = urlsRemotasPorCooperativa?.[coop.id] ?? null; + const fotos = obtenerImagenesFlotaCooperativa(coop, urlsRemotas); + const busCoop = buses.find((b) => b.cooperativaId === coop.id); + const infoBus = construirResumenBus(busCoop, coop.nombre); + + porSlug.set(slug, { + cooperativa: coop, + portada: fotos[0] ?? null, + resumenBus: infoBus.texto, + frecuenciasCount: frecCount, + }); + } + } + + return Array.from(porSlug.values()); + }, [cooperativas, frecuencias, buses, urlsRemotasPorCooperativa]); + + // Fotos y datos de buses para la cooperativa seleccionada + const tarjetasActivas: TarjetaFlotaExtendida[] = useMemo(() => { + if (!cooperativaActiva) return []; + + const urlsRemotas = urlsRemotasPorCooperativa?.[cooperativaActiva.id] ?? null; + const imagenes = obtenerImagenesFlotaCooperativa(cooperativaActiva, urlsRemotas); + + const slugActivo = resolverSlugCooperativa(cooperativaActiva); + const idsHermanos = cooperativas + .filter((c) => resolverSlugCooperativa(c) === slugActivo) + .map((c) => c.id); + + const busesCoop = buses.filter( + (b) => b.cooperativaId === cooperativaActiva.id || idsHermanos.includes(b.cooperativaId) + ); + + return imagenes.map((img, idx) => { + const bus = busesCoop[idx] ?? busesCoop[0]; + const info = construirResumenBus(bus, cooperativaActiva.nombre); + return { + imagen: img, + resumenBus: info.texto, + esVip: info.esVip, + asientosVip: info.asientosVip, + }; + }); + }, [cooperativaActiva, cooperativas, buses, urlsRemotasPorCooperativa]); + + // Manejo de teclado y trampa de foco en el visor ampliado (Lightbox) + useEffect(() => { + if (lightboxIndex === null) return; + + closeBtnRef.current?.focus(); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + setLightboxIndex(null); + } else if (e.key === 'ArrowRight' && tarjetasActivas.length > 1) { + e.preventDefault(); + setLightboxIndex((prev) => (prev === null ? 0 : (prev + 1) % tarjetasActivas.length)); + } else if (e.key === 'ArrowLeft' && tarjetasActivas.length > 1) { + e.preventDefault(); + setLightboxIndex((prev) => + prev === null ? 0 : (prev - 1 + tarjetasActivas.length) % tarjetasActivas.length + ); + } else if (e.key === 'Tab' && modalRef.current) { + const focusables = modalRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + if (focusables.length === 0) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [lightboxIndex, tarjetasActivas.length]); + + const marcarImagenCargada = (src: string) => { + setLoadedImages((prev) => ({ ...prev, [src]: true })); + }; + + return ( +
+ {!cooperativaActiva ? ( + /* VISTA 1: "Todas las cooperativas" -> Franja de tarjetas por cooperativa */ +
+
+
+ + Unidades homologadas ANT + +

+ Flota por Cooperativa de Transporte +

+
+

+ Selecciona una cooperativa para filtrar sus horarios y ver su flota en detalle +

+
+ +
+ {cooperativasPortada.map(({ cooperativa, portada, resumenBus }) => ( + + ))} +
+
+ ) : ( + /* VISTA 2: Una cooperativa seleccionada -> Bloque "Flota de {nombre}" */ +
+
+
+
+ + Cooperativa Seleccionada + + Flota Autorizada +
+

+ Flota de {cooperativaActiva.nombre} +

+
+ +
+ +
+
+ + {tarjetasActivas.length === 0 ? ( +
+

+ Pronto verás aquí la flota de {cooperativaActiva.nombre} +

+

+ Los horarios y frecuencias de esta cooperativa están disponibles a continuación. +

+
+ ) : ( +
1 ? 'md:grid-cols-2' : 'md:grid-cols-2' + } gap-4 items-stretch`} + > + {tarjetasActivas.map((item, idx) => ( +
+ + +
+
+ + {item.resumenBus} + + {item.esVip && ( + + VIP ({item.asientosVip}) + + )} +
+

{item.imagen.descripcion}

+
+
+ ))} + + {/* Cuando hay 1 foto subida, mostramos junto a ella la ficha operativa de la cooperativa en escritorio */} + {tarjetasActivas.length === 1 && ( +
+
+ + Especificaciones de Operación + +

+ {cooperativaActiva.nombre} +

+

+ {tarjetasActivas[0].imagen.descripcion}. Todas las unidades cumplen con la revisión técnica vehicular y tarifas reguladas por la Agencia Nacional de Tránsito. +

+ +
+
+ Configuración de flota: + + {tarjetasActivas[0].resumenBus} + +
+ {cooperativaActiva.direccion && ( +
+ Oficina / Terminal: + + {cooperativaActiva.direccion} + +
+ )} + {cooperativaActiva.telefono && ( +
+ Contacto de boletería: + + {cooperativaActiva.telefono} + +
+ )} +
+
+ +
+ + Haz clic en la fotografía para abrir el visor ampliado + + +
+
+ )} +
+ )} +
+ )} + + {/* VISOR AMPLIADO (LIGHTBOX ACCESIBLE) */} + {lightboxIndex !== null && tarjetasActivas[lightboxIndex] && ( +
{ + if (e.target === e.currentTarget) setLightboxIndex(null); + }} + > +
+
+
+

+ {cooperativaActiva?.nombre} +

+

+ {tarjetasActivas[lightboxIndex].resumenBus} +

+
+ +
+ +
+ {tarjetasActivas[lightboxIndex].imagen.alt} + + {tarjetasActivas.length > 1 && ( + <> + + + + )} +
+ +
+ {tarjetasActivas[lightboxIndex].imagen.descripcion} + + Foto {lightboxIndex + 1} de {tarjetasActivas.length} · Presiona Esc para cerrar + +
+
+
+ )} +
+ ); +} diff --git a/src/data/buses-galeria.ts b/src/data/buses-galeria.ts new file mode 100644 index 0000000..5df5c5f --- /dev/null +++ b/src/data/buses-galeria.ts @@ -0,0 +1,194 @@ +import type { CooperativaRow } from '@/lib/types'; + +export type CooperativaFlotaSlug = + | 'cita-express' + | 'ambato-express' + | 'transportes-ambato' + | 'transportes-banos' + | 'flota-pelileo'; + +export interface ImagenFlotaBus { + src: string; + alt: string; + descripcion: string; +} + +export interface GaleriaCooperativaFlota { + slug: CooperativaFlotaSlug; + nombreOficial: string; + carpetaPublica: string; + aliases: string[]; + imagenes: ImagenFlotaBus[]; +} + +export const CATALOGO_GALERIA_FLOTA: Record = { + 'cita-express': { + slug: 'cita-express', + nombreOficial: 'CITA Express Interprovincial', + carpetaPublica: '/buses/cita-express', + aliases: [ + 'coop-003', + 'cita-express', + 'cita express interprovincial', + 'cita express', + 'cooperativa cita express', + ], + imagenes: [ + { + src: '/buses/cita-express/bus-1.jpg', + alt: 'Bus de CITA Express Interprovincial, vista lateral frontal', + descripcion: 'Unidad climatizada de servicio interprovincial directo y con escalas', + }, + ], + }, + 'ambato-express': { + slug: 'ambato-express', + nombreOficial: 'Cooperativa Ambato Express', + carpetaPublica: '/buses/ambato-express', + aliases: [ + 'coop-004', + 'ambato-express', + 'cooperativa ambato express', + 'ambato express', + ], + imagenes: [ + { + src: '/buses/ambato-express/bus-1.jpg', + alt: 'Bus de Cooperativa Ambato Express en terminal terrestre', + descripcion: 'Unidad interprovincial equipada con asientos estándar y servicio VIP', + }, + ], + }, + 'transportes-ambato': { + slug: 'transportes-ambato', + nombreOficial: 'Cooperativa de Transportes Ambato', + carpetaPublica: '/buses/transportes-ambato', + aliases: [ + 'transportes-ambato', + 'cooperativa de transportes ambato', + 'transportes ambato', + 'coop transportes ambato', + ], + imagenes: [ + { + src: '/buses/transportes-ambato/bus-1.jpg', + alt: 'Bus de Cooperativa de Transportes Ambato en ruta', + descripcion: 'Unidad climatizada autorizada para rutas interprovinciales de la Sierra y Costa', + }, + ], + }, + 'transportes-banos': { + slug: 'transportes-banos', + nombreOficial: 'Cooperativa de Transportes Baños', + carpetaPublica: '/buses/transportes-banos', + aliases: [ + 'coop-001', + 'transportes-banos', + 'cooperativa de transportes banos', + 'cooperativa de transportes baños', + 'transportes banos', + 'transportes baños', + ], + imagenes: [ + { + src: '/buses/transportes-banos/bus-1.jpg', + alt: 'Bus ejecutivo de Cooperativa de Transportes Baños, vista lateral', + descripcion: 'Unidad ejecutiva para conexión entre Baños, Ambato, Quito y Amazonía', + }, + ], + }, + 'flota-pelileo': { + slug: 'flota-pelileo', + nombreOficial: 'Flota Pelileo', + carpetaPublica: '/buses/flota-pelileo', + aliases: [ + 'coop-002', + 'flota-pelileo', + 'flota pelileo', + 'flota pelileo c.a.', + 'flota pelileo ca', + 'cooperativa flota pelileo', + ], + imagenes: [ + { + src: '/buses/flota-pelileo/bus-1.jpg', + alt: 'Bus de Flota Pelileo con compartimento de equipaje abierto', + descripcion: 'Unidad interprovincial con sistema de climatización Air Fresh y bodega amplia', + }, + ], + }, +}; + +function normalizarTexto(valor: string): string { + return valor + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[.,]/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Resuelve el slug de galería a partir del registro de Cooperativa o de un identificador/nombre. + * Mapea tanto "Flota Pelileo" como "Flota Pelileo C.A." a 'flota-pelileo'. + */ +export function resolverSlugCooperativa( + cooperativa: Pick | string | null | undefined +): CooperativaFlotaSlug | null { + if (!cooperativa) return null; + + const idTexto = typeof cooperativa === 'string' ? cooperativa : cooperativa.id; + const nombreTexto = typeof cooperativa === 'string' ? cooperativa : cooperativa.nombre; + + const idNorm = normalizarTexto(idTexto); + const nombreNorm = normalizarTexto(nombreTexto); + + for (const entrada of Object.values(CATALOGO_GALERIA_FLOTA)) { + const coincideAlias = entrada.aliases.some((alias) => { + const aliasNorm = normalizarTexto(alias); + return aliasNorm === idNorm || aliasNorm === nombreNorm; + }); + if (coincideAlias) return entrada.slug; + } + + if (nombreNorm.includes('cita express')) return 'cita-express'; + if (nombreNorm.includes('ambato express')) return 'ambato-express'; + if (nombreNorm.includes('transportes ambato')) return 'transportes-ambato'; + if (nombreNorm.includes('banos')) return 'transportes-banos'; + if (nombreNorm.includes('pelileo')) return 'flota-pelileo'; + + return null; +} + +/** + * Obtiene las imágenes de flota de una cooperativa. + * Permite inyectar URLs externas (ej. Supabase Storage) si la tabla o el servicio las provee en el futuro. + */ +export function obtenerImagenesFlotaCooperativa( + cooperativa: Pick | string | null | undefined, + urlsRemotasOpcionales?: string[] | null +): ImagenFlotaBus[] { + const nombreCoop = + typeof cooperativa === 'object' && cooperativa !== null + ? cooperativa.nombre + : typeof cooperativa === 'string' + ? cooperativa + : 'la cooperativa'; + + if (Array.isArray(urlsRemotasOpcionales) && urlsRemotasOpcionales.length > 0) { + return urlsRemotasOpcionales + .filter((u) => typeof u === 'string' && u.trim() !== '') + .slice(0, 2) + .map((url, idx) => ({ + src: url, + alt: `Bus ${idx + 1} de ${nombreCoop}`, + descripcion: `Unidad oficial de ${nombreCoop}`, + })); + } + + const slug = resolverSlugCooperativa(cooperativa); + if (!slug) return []; + + return CATALOGO_GALERIA_FLOTA[slug]?.imagenes ?? []; +}