228 lines
8.6 KiB
TypeScript
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>
|
|
);
|
|
}
|