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
+
+
+
+
+
+ Número de Tarjeta *
+
+ {
+ 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"
+ />
+
+
+
+
+ )}
+
{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'})
+
+
+
+
+
+ Subir Orden de Pago o Comprobante (PDF, PNG o JPG) *
+
+
{
+ 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)
+
+ )}
+
+
+
+
+ Número de comprobante / referencia (opcional si subes el archivo)
+
+ 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 ? (
+ ) : 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 }) => (
+
onSelectCooperativa(cooperativa.id)}
+ aria-label={`Ver flota y horarios de ${cooperativa.nombre}`}
+ className="group text-left bg-neutral-50 hover:bg-white rounded-xl border border-neutral-200 hover:border-brand-400 overflow-hidden transition-all shadow-2xs hover:shadow-md focus:outline-none focus:ring-2 focus:ring-brand-500 flex flex-col justify-between"
+ >
+
+ {portada ? (
+ <>
+ {!loadedImages[portada.src] && (
+
+ )}
+
marcarImagenCargada(portada.src)}
+ className={`object-cover transition-transform duration-300 motion-reduce:transition-none group-hover:scale-105 ${
+ loadedImages[portada.src] ? 'opacity-100' : 'opacity-0'
+ }`}
+ />
+ >
+ ) : (
+
+ Pronto verás aquí la flota de {cooperativa.nombre}
+
+ )}
+
+
+
+
+ {cooperativa.nombre}
+
+
{resumenBus}
+
+ Filtrar frecuencias ➔
+
+
+
+ ))}
+
+
+ ) : (
+ /* VISTA 2: Una cooperativa seleccionada -> Bloque "Flota de {nombre}" */
+
+
+
+
+
+ Cooperativa Seleccionada
+
+ Flota Autorizada
+
+
+ Flota de {cooperativaActiva.nombre}
+
+
+
+
+ onSelectCooperativa('')}
+ className="text-xs font-bold"
+ >
+ Ver todas las cooperativas
+
+
+
+
+ {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) => (
+
+
setLightboxIndex(idx)}
+ aria-label={`Ampliar fotografía de ${item.imagen.alt}`}
+ className="group relative w-full aspect-video bg-neutral-200 overflow-hidden focus:outline-none focus:ring-2 focus:ring-brand-500 text-left"
+ >
+ {!loadedImages[item.imagen.src] && (
+
+ )}
+ marcarImagenCargada(item.imagen.src)}
+ className={`object-cover transition-transform duration-300 motion-reduce:transition-none group-hover:scale-105 ${
+ loadedImages[item.imagen.src] ? 'opacity-100' : 'opacity-0'
+ }`}
+ />
+
+ Ampliar foto
+
+
+
+
+
+
+ {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
+
+ setLightboxIndex(0)}
+ className="font-bold text-brand-700 hover:text-brand-900 underline focus:outline-none focus:ring-2 focus:ring-brand-500 rounded px-1"
+ >
+ Ver en pantalla completa
+
+
+
+ )}
+
+ )}
+
+ )}
+
+ {/* VISOR AMPLIADO (LIGHTBOX ACCESIBLE) */}
+ {lightboxIndex !== null && tarjetasActivas[lightboxIndex] && (
+
{
+ if (e.target === e.currentTarget) setLightboxIndex(null);
+ }}
+ >
+
+
+
+
+ {cooperativaActiva?.nombre}
+
+
+ {tarjetasActivas[lightboxIndex].resumenBus}
+
+
+
setLightboxIndex(null)}
+ aria-label="Cerrar visor de imágenes"
+ className="w-8 h-8 rounded-lg bg-neutral-800 hover:bg-brand-600 text-white font-bold flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-brand-400"
+ >
+ ✕
+
+
+
+
+
+
+ {tarjetasActivas.length > 1 && (
+ <>
+
+ setLightboxIndex(
+ (lightboxIndex - 1 + tarjetasActivas.length) % tarjetasActivas.length
+ )
+ }
+ aria-label="Fotografía anterior"
+ className="absolute left-3 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-neutral-900/80 hover:bg-brand-600 text-white font-bold flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-brand-400"
+ >
+ ‹
+
+
+ setLightboxIndex((lightboxIndex + 1) % tarjetasActivas.length)
+ }
+ aria-label="Fotografía siguiente"
+ className="absolute right-3 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-neutral-900/80 hover:bg-brand-600 text-white font-bold flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-brand-400"
+ >
+ ›
+
+ >
+ )}
+
+
+
+ {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 ?? [];
+}