socios-comerciales.skyballo.../src/routes/reserva.tsx

228 lines
8.6 KiB
TypeScript

import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
import { useDraft } from "@/lib/cotizador-draft";
import { ArrowLeft, ArrowRight, Check } from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { Paso1Extras } from "@/components/reserva/Paso1Extras";
import { Paso2Datos } from "@/components/reserva/Paso2Datos";
import { Paso3Pago } from "@/components/reserva/Paso3Pago";
import { Paso4Certificados } from "@/components/reserva/Paso4Certificados";
import { formatoMoneda } from "@/lib/catalogo";
import { notificar } from "@/lib/notificaciones";
import {
calcularReserva,
estadoInicialReserva,
extrasSeleccionados,
pasajeroVacio,
totalPasajeros,
validarPaso2,
type Certificado,
type PasajeroDatos,
type ReservaEstado,
} from "@/lib/reserva-flujo";
const PASOS = ["Pasajeros y extras", "Datos del vuelo", "Pago", "Certificados"];
export const Route = createFileRoute("/reserva")({
head: () => ({
meta: [
{ title: "Reservación en 4 pasos | Sky Balloons México" },
{
name: "description",
content:
"Flujo de reservación multietapas: elige pasajeros y extras, captura datos del vuelo, paga y personaliza los certificados de vuelo.",
},
{ property: "og:title", content: "Reservación en 4 pasos | Sky Balloons México" },
{
property: "og:description",
content: "Wizard de compra para socios comerciales: extras, datos de pasajeros, pago y certificados.",
},
{ property: "og:type", content: "website" },
{ name: "twitter:card", content: "summary_large_image" },
],
}),
component: ReservaWizard,
});
function ReservaWizard() {
const { draft, listo } = useDraft();
const [paso, setPaso] = useState(1);
const [estado, setEstado] = useState<ReservaEstado>(estadoInicialReserva);
const [finalizado, setFinalizado] = useState(false);
const [error, setError] = useState<string | null>(null);
const set = (parcial: Partial<ReservaEstado>) => setEstado((p) => ({ ...p, ...parcial }));
// Previsualiza los datos capturados en el cotizador (localStorage).
useEffect(() => {
if (!listo) return;
setEstado((p) => ({
...p,
adultos: draft.adultos,
ninos: draft.ninos,
fecha: draft.fecha || p.fecha,
fechaPorConfirmar: draft.fechaPorConfirmar,
}));
}, [listo]);
// Sincroniza la lista de pasajeros con los contadores del paso 1.
const cantidad = totalPasajeros(estado);
useEffect(() => {
setEstado((p) => {
if (p.pasajeros.length === cantidad) return p;
const pasajeros = Array.from({ length: cantidad }, (_, i) => p.pasajeros[i] ?? pasajeroVacio());
const certificados = Array.from({ length: cantidad }, (_, i) => p.certificados[i] ?? { nombre: "", frase: "" });
return { ...p, pasajeros, certificados };
});
}, [cantidad]);
const desglose = useMemo(() => calcularReserva(estado), [estado]);
const extras = extrasSeleccionados(estado.extras);
const setExtra = (id: string, cant: number) =>
setEstado((p) => ({ ...p, extras: { ...p.extras, [id]: cant } }));
const setPasajero = (i: number, parcial: Partial<PasajeroDatos>) =>
setEstado((p) => ({
...p,
pasajeros: p.pasajeros.map((x, idx) => (idx === i ? { ...x, ...parcial } : x)),
}));
const setCertificado = (i: number, parcial: Partial<Certificado>) =>
setEstado((p) => ({
...p,
certificados: p.certificados.map((x, idx) => (idx === i ? { ...x, ...parcial } : x)),
}));
const avanzar = () => {
setError(null);
if (paso === 1 && cantidad < 1) return setError("Agrega al menos un pasajero.");
if (paso === 2) {
const err = validarPaso2(estado);
if (err) return setError(err);
}
if (paso === 3 && !estado.pagoConfirmado) return setError("Confirma el pago para continuar.");
setPaso((p) => Math.min(4, p + 1));
};
const confirmarPago = () => {
set({ pagoConfirmado: true });
notificar("Pago registrado", `Total ${formatoMoneda(desglose.total)} · método ${estado.metodoPago}`);
setPaso(4);
};
const finalizar = () => {
setFinalizado(true);
notificar("¡Reservación finalizada!", `Pases emitidos para ${cantidad} pasajero(s).`);
notificar("Comisión del 10% acreditada", `Se acreditaron ${formatoMoneda(desglose.comision)} a tu cuenta.`);
};
return (
<AppShell title="Nueva reservación" subtitle="Vuelo Aventura 20% OFF · Teotihuacán">
{/* Indicador de pasos */}
<ol className="mb-6 flex items-center gap-2 overflow-x-auto pb-1">
{PASOS.map((label, i) => {
const n = i + 1;
const activo = paso === n;
const completo = paso > n;
return (
<li key={label} className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => n < paso && setPaso(n)}
className={`flex items-center gap-2 rounded-full border px-4 py-2 text-xs font-semibold transition-all duration-300 ${
activo
? "border-primary bg-primary text-primary-foreground shadow-soft"
: completo
? "border-success/40 bg-success/10 text-foreground"
: "border-border bg-card text-muted-foreground"
}`}
>
<span className="grid size-5 place-items-center rounded-full bg-background/30 text-[11px] font-bold">
{completo ? <Check className="size-3" /> : n}
</span>
{label}
</button>
{n < PASOS.length && <span className="h-px w-4 bg-border sm:w-8" />}
</li>
);
})}
</ol>
<div key={paso} className="animate-in fade-in-0 slide-in-from-bottom-3 duration-500">
{paso === 1 && (
<Paso1Extras
adultos={estado.adultos}
ninos={estado.ninos}
setAdultos={(v) => set({ adultos: v })}
setNinos={(v) => set({ ninos: v })}
extras={estado.extras}
setExtra={setExtra}
/>
)}
{paso === 2 && <Paso2Datos estado={estado} set={set} setPasajero={setPasajero} />}
{paso === 3 && (
<Paso3Pago
subtotal={desglose.subtotal}
impuestos={desglose.impuestos}
total={desglose.total}
metodoPago={estado.metodoPago}
setMetodo={(m) => set({ metodoPago: m })}
pagoConfirmado={estado.pagoConfirmado}
confirmarPago={confirmarPago}
/>
)}
{paso === 4 &&
(estado.pagoConfirmado ? (
<Paso4Certificados
pasajeros={estado.pasajeros}
certificados={estado.certificados}
setCertificado={setCertificado}
finalizar={finalizar}
finalizado={finalizado}
/>
) : (
<p className="rounded-3xl bg-card p-6 text-sm text-muted-foreground shadow-soft">
Este paso se habilita al confirmar el pago.
</p>
))}
</div>
{error && (
<p className="mt-4 rounded-2xl bg-destructive/10 px-4 py-3 text-sm font-medium text-destructive">{error}</p>
)}
{/* Barra de navegación del wizard */}
<div className="sticky bottom-20 z-20 mt-6 flex items-center justify-between gap-3 rounded-3xl bg-card/90 p-4 shadow-lift backdrop-blur lg:bottom-4">
<div className="min-w-0">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
{cantidad} pax · {extras.length} extras
</p>
<p className="truncate text-lg font-bold">{formatoMoneda(desglose.total)}</p>
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={() => {
setError(null);
setPaso((p) => Math.max(1, p - 1));
}}
disabled={paso === 1}
className="flex items-center gap-2 rounded-2xl border border-border px-4 py-3 text-sm font-semibold transition-all hover:-translate-y-0.5 active:scale-95 disabled:opacity-40"
>
<ArrowLeft className="size-4" /> Atrás
</button>
<button
type="button"
onClick={avanzar}
disabled={paso === 4}
className="flex items-center gap-2 rounded-2xl bg-primary px-5 py-3 text-sm font-bold text-primary-foreground transition-all hover:-translate-y-0.5 hover:shadow-lift active:scale-95 disabled:opacity-40"
>
Continuar <ArrowRight className="size-4" />
</button>
</div>
</div>
</AppShell>
);
}