PayU LatAm (integrado en el commit anterior) resultó inviable: Rapyd compró ese negocio completo en LatAm/África en 2023, y el registro real del usuario quedó en la plataforma de Rapyd (board.rapyd.net, credenciales access_key/secret_key incompatibles con el módulo ya construido, API completamente distinta). Se evaluó reconstruir contra Rapyd, pero el usuario comparó costos para su volumen real (~8 ventas/mes) y encontró que ePayco cobra menos en PSE. Se investigó ePayco en vivo: es 100% de Davivienda desde 2024, acepta liquidación a cualquier banco colombiano (no exige Bancolombia/Nequi como Wompi) y da tarifa preferencial a cuentas Davivienda -- el usuario ya tiene una. Mejor encaje de negocio y más barato que PayU/Rapyd. Confirmado como elección definitiva. - src/modules/epayco-pse/ reemplaza a src/modules/payu-pse/: mismo contrato AbstractPaymentProvider (initiatePayment/authorizePayment "pending_authorization"/getWebhookActionAndData con firma SHA256 verificada), pero envuelve el SDK oficial `epayco-sdk-node` en vez de llamar la API cruda -- leyendo el código fuente del SDK (no la documentación pública, que resultó desactualizada en más de un campo) se confirmó que el protocolo real de ePayco para PSE es bastante más particular de lo que sugieren los ejemplos: cada campo del payload se cifra con AES usando la private key del comercio, más un login previo que da un bearer token que también viaja en la misma request. Reimplementar esto a mano tenía mucho más riesgo de error que delegar en el SDK mantenido por el propio proveedor. - Corregido en el camino: el método real para listar bancos es `bank.getBanks()`, no `pseBank()` (así aparecía en un resumen de búsqueda, pero no existe en el SDK real -- confirmado leyendo node_modules/epayco-sdk-node/lib/resources/bank.js directamente). El campo real para la URL de confirmación es `method_confirmation` (con guión bajo en medio), no `metodoconfirmacion` como mostraba la documentación pública -- confirmado contra tests/bank.js del propio paquete. - retrievePayment sí se implementó esta vez (endpoint de validación de ePayco documentado); refundPayment sigue sin implementar -- ese endpoint no quedó verificado en vivo, tira error explícito en vez de fingir éxito. - Frontend: el step de pago recupera el selector de banco + tipo de persona + documento (a diferencia del diseño que se había planeado para Rapyd, donde esos datos se recolectaban en la página del propio Rapyd/SafetyPay) -- ePayco sí necesita esos campos de nuestro lado, como PayU. - Dos puntos quedan sin verificar contra un sandbox real (anotados en el código con qué hacer si fallan): el nombre exacto del campo con la URL del banco en la respuesta de bank.create() (initiatePayment tira un error con el payload completo si no matchea ningún candidato probado), y si p_cust_id_cliente/p_key del webhook son iguales a las API keys o valores aparte del dashboard de ePayco. Verificado: build real de Medusa con las 7 env vars EPAYCO_* dummy, smoke test del wrapper del SDK cargando y la verificación de firma dando el resultado esperado contra un caso conocido, typecheck+lint+ build completo del frontend. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
314 lines
16 KiB
TypeScript
314 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { ArrowLeft, Landmark, MapPin, MessageCircle, Truck } from "lucide-react";
|
|
import { type FormEvent, useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { ciudadesPorDepartamento, departamentos } from "@/lib/data/colombia";
|
|
import { formatMoney } from "@/lib/utils";
|
|
import { useCartStore } from "@/store/cart-store";
|
|
|
|
const SELECT_CLASS =
|
|
"h-12 w-full rounded-full border border-slate-200 bg-white px-5 text-sm text-slate-950 outline-none transition focus:border-cyan-500 focus:ring-4 focus:ring-cyan-300/30 disabled:opacity-50";
|
|
|
|
// ADR 0009: direccion + envio + pago real, todo contra el Store API real.
|
|
// El pago es ePayco/PSE (modulo custom del backend, ver
|
|
// workloads/commerce-backend/src/modules/epayco-pse) -- redirige al banco
|
|
// del comprador, la confirmacion real llega despues via webhook async
|
|
// (nunca por esta pantalla). WhatsApp queda como respaldo manual si la
|
|
// iniciacion del pago falla, ya no como camino principal.
|
|
|
|
interface ShippingOption {
|
|
id: string;
|
|
name: string;
|
|
amount: number;
|
|
}
|
|
|
|
interface PseBank {
|
|
pseCode: string;
|
|
description: string;
|
|
}
|
|
|
|
type Step = "address" | "shipping" | "payment";
|
|
|
|
async function postJson(url: string, body: unknown) {
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return response.json();
|
|
}
|
|
|
|
export function CheckoutClient() {
|
|
const { cartId, items, hasHydrated } = useCartStore();
|
|
const [step, setStep] = useState<Step>("address");
|
|
const [form, setForm] = useState({
|
|
firstName: "",
|
|
lastName: "",
|
|
address1: "",
|
|
// Se guarda el id DIVIPOLA seleccionado, no el texto libre -- ver
|
|
// lib/data/colombia.ts. El nombre real se deriva de este id para
|
|
// mandarlo a Medusa (que solo espera texto en shipping_address).
|
|
departmentId: null as number | null,
|
|
cityId: null as number | null,
|
|
phone: "",
|
|
email: "",
|
|
});
|
|
const availableCities = form.departmentId ? ciudadesPorDepartamento(form.departmentId) : [];
|
|
const selectedDepartamento = departamentos.find((departamento) => departamento.id === form.departmentId);
|
|
const selectedCiudad = availableCities.find((ciudad) => ciudad.id === form.cityId);
|
|
const [shippingOptions, setShippingOptions] = useState<ShippingOption[]>([]);
|
|
const [selectedOptionId, setSelectedOptionId] = useState<string | null>(null);
|
|
const [shippingTotal, setShippingTotal] = useState(0);
|
|
const [total, setTotal] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [banks, setBanks] = useState<PseBank[]>([]);
|
|
const [bankCode, setBankCode] = useState("");
|
|
const [userType, setUserType] = useState<"0" | "1">("0");
|
|
const [docType, setDocType] = useState<"CC" | "CE" | "NIT" | "TI" | "PP">("CC");
|
|
const [docNumber, setDocNumber] = useState("");
|
|
|
|
const itemsSubtotal = items.reduce((sum, item) => sum + (item.product.price ?? 0) * item.quantity, 0);
|
|
const currency = items[0]?.product.currency;
|
|
|
|
async function submitAddress(event: FormEvent) {
|
|
event.preventDefault();
|
|
if (!cartId || !selectedCiudad || !selectedDepartamento) return;
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const cart = await postJson("/api/cart/address", {
|
|
cartId,
|
|
firstName: form.firstName,
|
|
lastName: form.lastName,
|
|
address1: form.address1,
|
|
city: selectedCiudad.name,
|
|
province: selectedDepartamento.name,
|
|
phone: form.phone,
|
|
email: form.email,
|
|
});
|
|
if (cart.error) throw new Error(cart.error);
|
|
const response = await fetch(`/api/cart/shipping-options?cartId=${cartId}`);
|
|
const data = await response.json();
|
|
const options: ShippingOption[] = data.options ?? [];
|
|
setShippingOptions(options);
|
|
setSelectedOptionId(options[0]?.id ?? null);
|
|
setStep("shipping");
|
|
} catch (submitError) {
|
|
setError((submitError as Error).message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function submitShipping() {
|
|
if (!cartId || !selectedOptionId) return;
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const cart = await postJson("/api/cart/shipping-method", { cartId, optionId: selectedOptionId });
|
|
if (cart.error) throw new Error(cart.error);
|
|
setShippingTotal(cart.shippingTotal ?? 0);
|
|
setTotal(cart.total ?? itemsSubtotal);
|
|
const banksResponse = await fetch("/api/cart/payment/banks");
|
|
const banksData = await banksResponse.json();
|
|
const bankList: PseBank[] = banksData.banks ?? [];
|
|
setBanks(bankList);
|
|
setBankCode(bankList[0]?.pseCode ?? "");
|
|
setStep("payment");
|
|
} catch (submitError) {
|
|
setError((submitError as Error).message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function submitPayment(event: FormEvent) {
|
|
event.preventDefault();
|
|
if (!cartId || !bankCode || !docNumber || !selectedCiudad || !selectedDepartamento) return;
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const session = await postJson("/api/cart/payment/session", {
|
|
cartId,
|
|
bankCode,
|
|
userType,
|
|
docType,
|
|
docNumber,
|
|
firstName: form.firstName,
|
|
lastName: form.lastName,
|
|
email: form.email,
|
|
phone: form.phone,
|
|
city: selectedCiudad.name,
|
|
});
|
|
if (session.error) throw new Error(session.error);
|
|
const completion = await postJson("/api/cart/complete", { cartId });
|
|
if (completion.error) throw new Error(completion.error);
|
|
// Navegacion completa, no iframe -- asi lo exige ePayco para el
|
|
// flujo de PSE. La confirmacion real llega despues via webhook,
|
|
// esta pagina nunca vuelve a cargar sola.
|
|
window.location.href = session.bankUrl;
|
|
} catch (submitError) {
|
|
setError((submitError as Error).message);
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
function continueOnWhatsApp() {
|
|
const lines = items.map((item) => `• ${item.quantity} x ${item.product.name}`).join("\n");
|
|
const message = `Hola Ari Shopping 👋\nQuiero confirmar mi pedido:\n\n${lines}\n\nSubtotal: ${formatMoney(itemsSubtotal, currency)}\nEnvío: ${formatMoney(shippingTotal, currency)}\nTotal: ${formatMoney(total, currency)}\n\nNombre: ${form.firstName} ${form.lastName}\nDirección: ${form.address1}, ${selectedCiudad?.name ?? ""}${selectedDepartamento ? `, ${selectedDepartamento.name}` : ""}\nTeléfono: ${form.phone}`;
|
|
void navigator.clipboard?.writeText(message);
|
|
window.open(process.env.NEXT_PUBLIC_WHATSAPP_URL ?? "https://wa.link/rvcqqc", "_blank", "noopener,noreferrer");
|
|
}
|
|
|
|
if (!hasHydrated) return null;
|
|
|
|
if (items.length === 0) {
|
|
return (
|
|
<div className="mx-auto max-w-lg px-4 py-24 text-center">
|
|
<p className="text-lg font-bold">Tu carrito está vacío.</p>
|
|
<Button asChild variant="primary" className="mt-6"><Link href="/catalog">Ver catálogo</Link></Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto grid max-w-5xl gap-10 px-4 py-10 lg:grid-cols-[1fr_.8fr] lg:py-16">
|
|
<div>
|
|
<Link href="/" className="mb-6 inline-flex items-center gap-1.5 text-sm font-bold text-slate-500 hover:text-ari-marino"><ArrowLeft className="size-4" />Seguir comprando</Link>
|
|
<h1 className="text-3xl font-black tracking-tight sm:text-4xl">Confirmar pedido</h1>
|
|
|
|
{step === "address" && (
|
|
<form onSubmit={submitAddress} className="mt-8 space-y-4">
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<Input placeholder="Nombre" required value={form.firstName} onChange={(event) => setForm({ ...form, firstName: event.target.value })} />
|
|
<Input placeholder="Apellido" required value={form.lastName} onChange={(event) => setForm({ ...form, lastName: event.target.value })} />
|
|
</div>
|
|
<Input placeholder="Dirección" required value={form.address1} onChange={(event) => setForm({ ...form, address1: event.target.value })} />
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<select
|
|
required
|
|
value={form.departmentId ?? ""}
|
|
onChange={(event) => setForm({ ...form, departmentId: Number(event.target.value) || null, cityId: null })}
|
|
className={SELECT_CLASS}
|
|
>
|
|
<option value="" disabled>Departamento</option>
|
|
{departamentos.map((departamento) => <option key={departamento.id} value={departamento.id}>{departamento.name}</option>)}
|
|
</select>
|
|
<select
|
|
required
|
|
disabled={!form.departmentId}
|
|
value={form.cityId ?? ""}
|
|
onChange={(event) => setForm({ ...form, cityId: Number(event.target.value) || null })}
|
|
className={SELECT_CLASS}
|
|
>
|
|
<option value="" disabled>Ciudad</option>
|
|
{availableCities.map((ciudad) => <option key={ciudad.id} value={ciudad.id}>{ciudad.name}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<Input placeholder="Teléfono" required value={form.phone} onChange={(event) => setForm({ ...form, phone: event.target.value })} />
|
|
<Input type="email" placeholder="Correo" required value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} />
|
|
</div>
|
|
{error && <p className="text-sm font-bold text-rose-600">{error}</p>}
|
|
<Button type="submit" variant="primary" size="lg" className="w-full" disabled={loading}>{loading ? "Guardando…" : "Continuar a envío"}</Button>
|
|
</form>
|
|
)}
|
|
|
|
{step === "shipping" && (
|
|
<div className="mt-8 space-y-4">
|
|
<p className="flex items-center gap-2 text-sm font-bold text-slate-500"><MapPin className="size-4" />{form.address1}, {selectedCiudad?.name}{selectedDepartamento ? `, ${selectedDepartamento.name}` : ""}</p>
|
|
<div className="space-y-3">
|
|
{shippingOptions.map((option) => (
|
|
<label key={option.id} className={`flex cursor-pointer items-center justify-between rounded-2xl border-2 p-4 transition ${selectedOptionId === option.id ? "border-ari-marino bg-ari-marino/5" : "border-slate-200"}`}>
|
|
<span className="flex items-center gap-3 font-bold"><Truck className="size-5 text-ari-marino" />{option.name}</span>
|
|
<span className="flex items-center gap-3">
|
|
<span className="font-black">{formatMoney(option.amount, currency)}</span>
|
|
<input type="radio" name="shipping" checked={selectedOptionId === option.id} onChange={() => setSelectedOptionId(option.id)} />
|
|
</span>
|
|
</label>
|
|
))}
|
|
{shippingOptions.length === 0 && <p className="text-sm text-slate-500">No hay opciones de envío disponibles para esta dirección.</p>}
|
|
</div>
|
|
{error && <p className="text-sm font-bold text-rose-600">{error}</p>}
|
|
<Button variant="primary" size="lg" className="w-full" disabled={loading || !selectedOptionId} onClick={submitShipping}>{loading ? "Guardando…" : "Continuar"}</Button>
|
|
</div>
|
|
)}
|
|
|
|
{step === "payment" && (
|
|
<form onSubmit={submitPayment} className="mt-8 space-y-4">
|
|
<div className="rounded-2xl border border-slate-200 p-5">
|
|
<p className="text-xs font-black uppercase tracking-wide text-slate-400">Entrega</p>
|
|
<p className="mt-1 font-bold">{form.firstName} {form.lastName}</p>
|
|
<p className="text-sm text-slate-500">{form.address1}, {selectedCiudad?.name}{selectedDepartamento ? `, ${selectedDepartamento.name}` : ""}</p>
|
|
<p className="text-sm text-slate-500">{form.phone}</p>
|
|
</div>
|
|
|
|
<p className="flex items-center gap-2 text-sm font-bold text-slate-700"><Landmark className="size-4" />Paga con PSE (transferencia desde tu banco)</p>
|
|
|
|
{banks.length > 0 ? (
|
|
<select required value={bankCode} onChange={(event) => setBankCode(event.target.value)} className={SELECT_CLASS}>
|
|
{banks.map((bank) => <option key={bank.pseCode} value={bank.pseCode}>{bank.description}</option>)}
|
|
</select>
|
|
) : (
|
|
<p className="rounded-2xl bg-yellow-50 p-3 text-xs font-bold leading-5 text-yellow-800">No pudimos cargar la lista de bancos — probá de nuevo en un momento, o confirmá tu pedido por WhatsApp abajo.</p>
|
|
)}
|
|
|
|
<div className="flex gap-4 text-sm font-bold">
|
|
<label className="flex items-center gap-2"><input type="radio" name="userType" checked={userType === "0"} onChange={() => setUserType("0")} />Persona natural</label>
|
|
<label className="flex items-center gap-2"><input type="radio" name="userType" checked={userType === "1"} onChange={() => setUserType("1")} />Persona jurídica</label>
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<select
|
|
required
|
|
value={docType}
|
|
onChange={(event) => setDocType(event.target.value as typeof docType)}
|
|
className={SELECT_CLASS}
|
|
>
|
|
<option value="CC">Cédula de ciudadanía</option>
|
|
<option value="CE">Cédula de extranjería</option>
|
|
<option value="TI">Tarjeta de identidad</option>
|
|
<option value="PP">Pasaporte</option>
|
|
<option value="NIT">NIT</option>
|
|
</select>
|
|
<Input placeholder="Número de documento" required value={docNumber} onChange={(event) => setDocNumber(event.target.value)} />
|
|
</div>
|
|
|
|
{error && <p className="text-sm font-bold text-rose-600">{error}</p>}
|
|
|
|
<Button type="submit" variant="primary" size="lg" className="w-full" disabled={loading || banks.length === 0}>{loading ? "Redirigiendo a tu banco…" : "Pagar con PSE"}</Button>
|
|
|
|
<div className="flex items-center gap-3 text-xs font-bold uppercase tracking-wide text-slate-400">
|
|
<span className="h-px flex-1 bg-slate-200" />¿Prefieres otra vía?<span className="h-px flex-1 bg-slate-200" />
|
|
</div>
|
|
<Button type="button" variant="outline" size="lg" className="w-full" onClick={continueOnWhatsApp}><MessageCircle className="size-5" />Confirmar por WhatsApp</Button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
|
|
<div className="h-fit rounded-3xl border border-slate-200 p-6">
|
|
<h2 className="font-black">Tu pedido</h2>
|
|
<div className="mt-4 space-y-3">
|
|
{items.map((item) => (
|
|
<div key={item.lineItemId} className="flex items-center gap-3">
|
|
<div className="relative size-14 shrink-0 overflow-hidden rounded-xl bg-slate-100"><Image src={item.product.thumbnail} alt={item.product.name} fill sizes="56px" className="object-cover" /></div>
|
|
<div className="flex-1"><p className="text-sm font-bold leading-5">{item.product.name}</p><p className="text-xs text-slate-500">Cantidad: {item.quantity}</p></div>
|
|
<p className="text-sm font-black">{formatMoney((item.product.price ?? 0) * item.quantity, item.product.currency)}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="mt-5 space-y-2 border-t border-slate-100 pt-4 text-sm">
|
|
<div className="flex justify-between"><span className="text-slate-500">Subtotal</span><span className="font-bold">{formatMoney(itemsSubtotal, currency)}</span></div>
|
|
{step === "payment" && <div className="flex justify-between"><span className="text-slate-500">Envío</span><span className="font-bold">{formatMoney(shippingTotal, currency)}</span></div>}
|
|
<div className="flex justify-between text-base"><span className="font-black">Total</span><span className="font-black">{formatMoney(step === "payment" ? total : itemsSubtotal, currency)}</span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|