Compare commits

...
Author SHA1 Message Date
devops 3b748cc014 feat(ecommerce): cuentas de cliente reales (registro + login)
Conecta el dialogo 'Tu cuenta ARI' (antes solo ofrecia WhatsApp) al
Store API real de Medusa. Flujo verificado en vivo con un cliente de
prueba real antes de escribir el codigo: registro -> crear customer
-> login -> /store/customers/me, todo confirmado funcionando.

Detalle no documentado que se confirmo en vivo: el token que devuelve
/auth/customer/emailpass/register sirve para crear el customer via
POST /store/customers, pero no queda como sesion real -- hay que
loguear de nuevo (/auth/customer/emailpass) para obtener el token de
sesion. Mensajes de error tambien verificados: correo repetido -> 401
'Identity with email already exists'; credenciales invalidas -> 401
'Invalid email or password'.

Mismo patron arquitectonico que el carrito real (ADR 0009): rutas
puente /api/customer/* (MEDUSA_BACKEND_URL es DNS interno, no resuelve
desde el navegador), store de Zustand con sesion persistida y
resincronizada contra el servidor al rehidratar. WhatsApp se mantiene
como opcion secundaria, ya no es la unica salida.
2026-08-29 14:24:43 +00:00
devops 73f9424331 chore: release v1.0.211 [skip ci] 2026-08-29 05:23:55 +00:00
devops 0917c966cb Merge feat/checkout-departamentos-ciudades into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 19s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 7m19s
2026-08-29 05:16:20 +00:00
devops 61024b8f31 feat(ecommerce): selector Departamento/Ciudad con DIVIPOLA real en checkout
Reemplaza los inputs de texto libre de Ciudad/Departamento por dos
selects en cascada, poblados con el dataset oficial de DIVIPOLA (33
departamentos + 1123 municipios, fuente proyecto26/colombia,
verificado contra la codificacion real del DANE antes de incorporarlo
-- id 5001 = Medellin, departmentId 5 = Antioquia, etc.). Sin
dependencia npm nueva (JSON estatico + helper propio).

Se guarda el id DIVIPOLA seleccionado en el estado del formulario, no
solo el nombre -- EnvioClick (la integracion de envios real que
reemplazara la tarifa fija de este ADR) va a necesitar identificar la
ciudad sin ambiguedad, y las transportadoras colombianas referencian
ciudades por este codigo. El nombre derivado del id sigue siendo lo
que se manda a Medusa (shipping_address espera texto, no cambia el
contrato existente).
2026-08-29 05:16:18 +00:00
devops 7dc54a1d20 chore: release v1.0.210 [skip ci] 2026-08-29 04:20:02 +00:00
devops d05112ae65 Merge feat/checkout-direccion-envio into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 19s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 7m30s
2026-08-29 04:12:13 +00:00
devops bb2c0bbe85 feat(ecommerce): ADR 0009 - checkout con direccion y seleccion de envio
Siguiente corte tras el carrito real: pagina /checkout de 3 pasos
(direccion -> envio -> confirmar) contra el Store API real, usando la
shipping option que ya se verifico en produccion. Endpoints nuevos
(setShippingAddress, getShippingOptions, addShippingMethod) probados
en vivo con un carrito de prueba real antes de escribir el codigo --
direccion, opciones de envio y total ya confirmados correctos
(80.000 + 5.000 = 95.000).

El pago (PayU custom) sigue sin existir, asi que el ultimo paso sigue
siendo WhatsApp -- pero ahora el mensaje incluye direccion, metodo de
envio y total real en vez de solo la lista de productos. El boton del
CartDrawer pasa de abrir WhatsApp directo a llevar a /checkout.
2026-08-29 04:12:11 +00:00
devops cdab538cfb chore: release v1.0.209 [skip ci] 2026-08-29 03:53:43 +00:00
devops f124ae0bbd Merge fix/carrito-thumbnail-real into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 21s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 11m47s
2026-08-29 03:24:11 +00:00
devops b8ce9ded87 fix(ecommerce): carrito muestra la foto real del producto
Reportado en produccion: el carrito mostraba el logo generico de ARI
en vez de la foto del producto. Verificado en vivo: item.thumbnail
viene null en la respuesta de Medusa para todo el catalogo real
(confirmado tambien contra /store/products directamente -- el campo
thumbnail del producto es null en la fuente, no solo omitido en la
respuesta del carrito). El catalogo ya resuelve esto cayendo a
images[0] (mapProduct() en medusa.ts); la linea del carrito necesita
pedir esa relacion explicita via fields=*items.product.images (no
viene por defecto) y aplicar el mismo fallback.
2026-08-29 03:24:09 +00:00
devops b12dd848af chore: release v1.0.208 [skip ci] 2026-08-29 02:52:32 +00:00
devops d28ee4a49d Merge fix/checkout-carrito-real-medusa into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 19s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 9m42s
2026-08-29 02:42:32 +00:00
18 changed files with 1924 additions and 32 deletions

No files matched your search

@@ -0,0 +1,14 @@
import { setShippingAddress } from "@/lib/headless/providers/medusa-cart";
export async function POST(request: Request) {
const { cartId, ...address } = await request.json();
if (!cartId || !address.firstName || !address.address1 || !address.city || !address.phone || !address.email) {
return Response.json({ error: "Faltan campos obligatorios de la dirección" }, { status: 400 });
}
try {
const cart = await setShippingAddress(cartId, address);
return Response.json(cart);
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: 502 });
}
}
@@ -0,0 +1,14 @@
import { addShippingMethod } from "@/lib/headless/providers/medusa-cart";
export async function POST(request: Request) {
const { cartId, optionId } = await request.json();
if (!cartId || !optionId) {
return Response.json({ error: "cartId y optionId son obligatorios" }, { status: 400 });
}
try {
const cart = await addShippingMethod(cartId, optionId);
return Response.json(cart);
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: 502 });
}
}
@@ -0,0 +1,14 @@
import { getShippingOptions } from "@/lib/headless/providers/medusa-cart";
export async function GET(request: Request) {
const cartId = new URL(request.url).searchParams.get("cartId");
if (!cartId) {
return Response.json({ error: "cartId es obligatorio" }, { status: 400 });
}
try {
const options = await getShippingOptions(cartId);
return Response.json({ options });
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: 502 });
}
}
@@ -0,0 +1,14 @@
import { loginCustomer } from "@/lib/headless/providers/medusa-customer";
export async function POST(request: Request) {
const { email, password } = await request.json();
if (!email || !password) {
return Response.json({ error: "Correo y contraseña son obligatorios" }, { status: 400 });
}
try {
const customer = await loginCustomer(email, password);
return Response.json(customer);
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: 400 });
}
}
@@ -0,0 +1,14 @@
import { getCurrentCustomer } from "@/lib/headless/providers/medusa-customer";
export async function GET(request: Request) {
const token = new URL(request.url).searchParams.get("token");
if (!token) {
return Response.json({ error: "token es obligatorio" }, { status: 400 });
}
try {
const customer = await getCurrentCustomer(token);
return Response.json(customer);
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: 401 });
}
}
@@ -0,0 +1,14 @@
import { registerCustomer } from "@/lib/headless/providers/medusa-customer";
export async function POST(request: Request) {
const { email, password, firstName, lastName } = await request.json();
if (!email || !password || !firstName || !lastName) {
return Response.json({ error: "Faltan campos obligatorios" }, { status: 400 });
}
try {
const customer = await registerCustomer(email, password, firstName, lastName);
return Response.json(customer);
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: 400 });
}
}
@@ -0,0 +1,5 @@
import { CheckoutClient } from "@/components/checkout/checkout-client";
export default function CheckoutPage() {
return <CheckoutClient />;
}
@@ -0,0 +1,80 @@
"use client";
import Link from "next/link";
import { LogOut, MessageCircle, PackageCheck } from "lucide-react";
import { type FormEvent, useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useCustomerStore } from "@/store/customer-store";
type Mode = "login" | "register";
export function AccountDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const { customer, hasHydrated, loading, error, login, register, logout } = useCustomerStore();
const [mode, setMode] = useState<Mode>("login");
const [form, setForm] = useState({ email: "", password: "", firstName: "", lastName: "" });
async function submit(event: FormEvent) {
event.preventDefault();
const ok = mode === "login"
? await login(form.email, form.password)
: await register(form.email, form.password, form.firstName, form.lastName);
if (ok) setForm({ email: "", password: "", firstName: "", lastName: "" });
}
const isLoggedIn = hasHydrated && customer !== null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg p-7">
<div className="pr-10">
{isLoggedIn ? (
<>
<span className="text-xs font-black uppercase tracking-[.16em] text-ari-coral">Tu cuenta</span>
<h2 className="mt-3 text-3xl font-black">Hola, {customer.firstName ?? customer.email}</h2>
<p className="mt-3 text-sm leading-6 text-slate-600">{customer.email}</p>
<div className="mt-6 grid gap-3">
<Button asChild variant="outline"><Link href="/catalog" onClick={() => onOpenChange(false)}><PackageCheck />Ver mis opciones de compra</Link></Button>
<Button variant="ghost" onClick={logout}><LogOut className="size-4" />Cerrar sesión</Button>
</div>
</>
) : (
<>
<span className="text-xs font-black uppercase tracking-[.16em] text-ari-coral">Atención personalizada</span>
<h2 className="mt-3 text-3xl font-black">{mode === "login" ? "Inicia sesión" : "Crea tu cuenta"}</h2>
<form onSubmit={submit} className="mt-5 space-y-3">
{mode === "register" && (
<div className="grid gap-3 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 type="email" placeholder="Correo" required value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} />
<Input type="password" placeholder="Contraseña" required minLength={8} value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} />
{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 ? "Un momento…" : mode === "login" ? "Iniciar sesión" : "Crear cuenta"}
</Button>
</form>
<button
type="button"
className="mt-3 text-sm font-bold text-ari-marino hover:underline"
onClick={() => setMode(mode === "login" ? "register" : "login")}
>
{mode === "login" ? "¿No tienes cuenta? Regístrate" : "¿Ya tienes cuenta? Inicia sesión"}
</button>
<div className="mt-6 border-t border-slate-100 pt-5">
<Button asChild variant="outline" className="w-full">
<a href={process.env.NEXT_PUBLIC_WHATSAPP_URL ?? "https://wa.link/rvcqqc"} target="_blank" rel="noopener noreferrer">
<MessageCircle />Hablar con ARI
</a>
</Button>
</div>
</>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,6 +1,7 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { Minus, Plus, ShoppingBag, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
@@ -17,13 +18,6 @@ export function CartDrawer() {
const progress = pricedItems ? Math.min(100, (subtotal / FREE_SHIPPING_THRESHOLD) * 100) : 0;
const count = items.reduce((sum, item) => sum + item.quantity, 0);
const checkout = () => {
const lines = items.map((item) => `• ${item.quantity} x ${item.product.name}`).join("\n");
const message = `Hola Ari Shopping 👋\nQuiero consultar disponibilidad para:\n\n${lines}\n\nNombre:\nCiudad:\nForma de entrega:`;
void navigator.clipboard?.writeText(message);
window.open(process.env.NEXT_PUBLIC_WHATSAPP_URL ?? "https://wa.link/rvcqqc", "_blank", "noopener,noreferrer");
};
return (
<Sheet open={isOpen} onOpenChange={(open) => { if (!open) closeCart(); }}>
<SheetContent>
@@ -49,7 +43,15 @@ export function CartDrawer() {
<div className="space-y-4 border-t border-slate-100 p-6">
<div><div className="mb-2 flex justify-between text-xs font-bold text-slate-600"><span>{pricedItems ? "Progreso para envío gratis" : "Cotización personalizada"}</span><span>{pricedItems ? `${Math.round(progress)}%` : "Por confirmar"}</span></div><Progress value={progress} /></div>
<div className="flex items-center justify-between"><span className="font-bold text-slate-600">Subtotal</span><strong className="text-xl font-black">{pricedItems ? formatMoney(subtotal) : "Por confirmar"}</strong></div>
<Button variant="primary" size="lg" className="w-full" disabled={!items.length} onClick={checkout}>Continuar por WhatsApp</Button>
{/* Button asChild + disabled no funciona sobre un <a> (:disabled
es pseudo-clase de elementos de formulario, un Link nunca la
cumple) -- se ramifica en vez de confiar en el prop disabled
sobre un Link. */}
{items.length > 0 ? (
<Button asChild variant="primary" size="lg" className="w-full"><Link href="/checkout" onClick={closeCart}>Confirmar pedido</Link></Button>
) : (
<Button variant="primary" size="lg" className="w-full" disabled>Confirmar pedido</Button>
)}
<Button variant="ghost" className="w-full" disabled={!items.length} onClick={clearCart}>Vaciar carrito</Button>
</div>
</SheetContent>
@@ -0,0 +1,228 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { ArrowLeft, 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, siguiente corte tras el carrito real: direccion + seleccion de
// envio contra el Store API real (mismos endpoints ya verificados en vivo
// para las shipping options). El pago (PayU custom) todavia no existe --
// el ultimo paso sigue siendo WhatsApp, ahora con el pedido completo
// (direccion, envio, total real) en vez de solo la lista de productos.
interface ShippingOption {
id: string;
name: string;
amount: number;
}
type Step = "address" | "shipping" | "review";
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 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);
setStep("review");
} catch (submitError) {
setError((submitError as Error).message);
} finally {
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 === "review" && (
<div 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="text-sm text-slate-500">El pago en línea todavía no está disponible — confirmamos tu pedido por WhatsApp con estos datos.</p>
<Button variant="primary" size="lg" className="w-full" onClick={continueOnWhatsApp}><MessageCircle className="size-5" />Confirmar por WhatsApp</Button>
</div>
)}
</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 === "review" && <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 === "review" ? total : itemsSubtotal, currency)}</span></div>
</div>
</div>
</div>
);
}
@@ -2,11 +2,11 @@
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { ChevronDown, Heart, LayoutGrid, Menu, MessageCircle, PackageCheck, ShoppingCart, UserRound } from "lucide-react";
import { ChevronDown, Heart, LayoutGrid, Menu, ShoppingCart, UserRound } from "lucide-react";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { AccountDialog } from "@/components/account/account-dialog";
import { PredictiveSearch } from "@/components/search/predictive-search";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import type { Category, Product } from "@/lib/headless/types";
import { useCartStore } from "@/store/cart-store";
@@ -108,19 +108,7 @@ export function Navbar({ products, categories }: { products: Product[]; categori
</SheetContent>
</Sheet>
<Dialog open={accountOpen} onOpenChange={setAccountOpen}>
<DialogContent className="max-w-lg p-7">
<div className="pr-10">
<span className="text-xs font-black uppercase tracking-[.16em] text-ari-coral">Atención personalizada</span>
<h2 className="mt-3 text-3xl font-black">Tu cuenta ARI</h2>
<p className="mt-3 text-sm leading-6 text-slate-600">El inicio de sesión quedará conectado al backend headless. Mientras tanto, puedes gestionar pedidos y disponibilidad directamente por WhatsApp.</p>
<div className="mt-6 grid gap-3">
<Button asChild variant="primary" size="lg"><a href={process.env.NEXT_PUBLIC_WHATSAPP_URL ?? "https://wa.link/rvcqqc"} target="_blank" rel="noopener noreferrer"><MessageCircle />Hablar con ARI</a></Button>
<Button asChild variant="outline"><Link href="/catalog" onClick={() => setAccountOpen(false)}><PackageCheck />Ver mis opciones de compra</Link></Button>
</div>
</div>
</DialogContent>
</Dialog>
<AccountDialog open={accountOpen} onOpenChange={setAccountOpen} />
</header>
);
}
@@ -25,7 +25,7 @@ spec:
runAsUser: 1001
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.204
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.211
env:
- name: HEADLESS_PROVIDER
value: medusa
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,40 @@
{
"data": [
{ "id": 5, "name": "Antioquia" },
{ "id": 8, "name": "Atlántico" },
{ "id": 11, "name": "Bogotá D.C." },
{ "id": 13, "name": "Bolívar" },
{ "id": 15, "name": "Boyacá" },
{ "id": 17, "name": "Caldas" },
{ "id": 18, "name": "Caquetá" },
{ "id": 19, "name": "Cauca" },
{ "id": 20, "name": "Cesar" },
{ "id": 23, "name": "Córdoba" },
{ "id": 25, "name": "Cundinamarca" },
{ "id": 27, "name": "Chocó" },
{ "id": 41, "name": "Huila" },
{ "id": 44, "name": "La Guajira" },
{ "id": 47, "name": "Magdalena" },
{ "id": 50, "name": "Meta" },
{ "id": 52, "name": "Nariño" },
{ "id": 54, "name": "Norte de Santander" },
{ "id": 63, "name": "Quindío" },
{ "id": 66, "name": "Risaralda" },
{ "id": 68, "name": "Santander" },
{ "id": 70, "name": "Sucre" },
{ "id": 73, "name": "Tolima" },
{ "id": 76, "name": "Valle del Cauca" },
{ "id": 81, "name": "Arauca" },
{ "id": 85, "name": "Casanare" },
{ "id": 86, "name": "Putumayo" },
{
"id": 88,
"name": "Archipiélago de San Andrés, Providencia y Santa Catalina"
},
{ "id": 91, "name": "Amazonas" },
{ "id": 94, "name": "Guainía" },
{ "id": 95, "name": "Guaviare" },
{ "id": 97, "name": "Vaupés" },
{ "id": 99, "name": "Vichada" }
]
}
+32
View File
@@ -0,0 +1,32 @@
import departamentosData from "./co-departamentos.json";
import ciudadesData from "./co-ciudades.json";
// Dataset real de DIVIPOLA (Division Politico-Administrativa de Colombia,
// codigos oficiales del DANE) -- 33 departamentos + 1123 municipios,
// fuente: proyecto26/colombia (github.com/proyecto26/colombia), verificado
// contra la codificacion oficial antes de incorporarlo (ej. id 5001 =
// Medellin, departmentId 5 = Antioquia).
//
// Se usan los codigos (no solo los nombres) porque EnvioClick -- la
// integracion de envios real que reemplazara la tarifa fija de ADR 0009 --
// va a necesitar identificar la ciudad de forma inequivoca, y las
// transportadoras colombianas en general referencian ciudades por su
// codigo DIVIPOLA, no por texto libre.
export interface Departamento {
id: number;
name: string;
}
export interface Ciudad {
id: number;
name: string;
departmentId: number;
}
export const departamentos: Departamento[] = departamentosData.data;
export const ciudades: Ciudad[] = ciudadesData.data;
export function ciudadesPorDepartamento(departmentId: number): Ciudad[] {
return ciudades.filter((ciudad) => ciudad.departmentId === departmentId);
}
@@ -33,9 +33,27 @@ export interface CartSummary {
cartId: string;
currency: string;
subtotal: number;
shippingTotal: number;
total: number;
items: CartLine[];
}
export interface ShippingAddressInput {
firstName: string;
lastName: string;
address1: string;
city: string;
province?: string;
phone: string;
email: string;
}
export interface ShippingOption {
id: string;
name: string;
amount: number;
}
function backendUrl(): string {
const value = process.env.MEDUSA_BACKEND_URL;
if (!value) {
@@ -61,12 +79,15 @@ type MedusaLineItem = {
thumbnail?: string | null;
quantity: number;
unit_price?: number | null;
product?: { images?: Array<{ url?: string | null }> | null } | null;
};
type MedusaCart = {
id: string;
currency_code: string;
subtotal?: number | null;
shipping_total?: number | null;
total?: number | null;
items?: MedusaLineItem[] | null;
};
@@ -75,6 +96,8 @@ function mapCart(cart: MedusaCart): CartSummary {
cartId: cart.id,
currency: cart.currency_code,
subtotal: cart.subtotal ?? 0,
shippingTotal: cart.shipping_total ?? 0,
total: cart.total ?? cart.subtotal ?? 0,
items: (cart.items ?? []).map((item) => ({
lineItemId: item.id,
variantId: item.variant_id,
@@ -82,10 +105,13 @@ function mapCart(cart: MedusaCart): CartSummary {
product: {
id: item.product_id,
name: item.product_title || item.title || "Producto",
// La linea del carrito no trae el arreglo de imagenes del producto,
// solo este campo (a veces null) -- mismo fallback final que ya usa
// mapProduct() en medusa.ts.
thumbnail: item.thumbnail || "/images/brand/logo-ari.png",
// item.thumbnail viene null en la practica para todo el catalogo
// real (confirmado contra /store/products: product.thumbnail
// tambien es null ahi, incluso con fotos reales cargadas) -- el
// catalogo ya resuelve esto cayendo a images[0] (mapProduct() en
// medusa.ts), asi que la linea del carrito pide esa misma relacion
// explicita (no viene por defecto) y aplica el mismo fallback.
thumbnail: item.thumbnail || item.product?.images?.[0]?.url || "/images/brand/logo-ari.png",
price: typeof item.unit_price === "number" ? item.unit_price : null,
currency: cart.currency_code,
},
@@ -117,6 +143,11 @@ async function medusaCartFetch(
return response.json();
}
// *items.product.images no viene por defecto (verificado en vivo) y es
// lo unico que necesitamos ademas del set por defecto -- *items ya trae
// unit_price/product_title/variant_id/quantity sin pedirlos aparte.
const ITEMS_FIELDS = "fields=*items,*items.product.images";
export async function createCart(): Promise<CartSummary> {
const data = await medusaCartFetch("/store/carts", {
method: "POST",
@@ -126,7 +157,7 @@ export async function createCart(): Promise<CartSummary> {
}
export async function getCart(cartId: string): Promise<CartSummary> {
const data = await medusaCartFetch(`/store/carts/${cartId}`);
const data = await medusaCartFetch(`/store/carts/${cartId}?${ITEMS_FIELDS}`);
return mapCart(data.cart as MedusaCart);
}
@@ -135,7 +166,7 @@ export async function addLineItem(
variantId: string,
quantity: number,
): Promise<CartSummary> {
const data = await medusaCartFetch(`/store/carts/${cartId}/line-items`, {
const data = await medusaCartFetch(`/store/carts/${cartId}/line-items?${ITEMS_FIELDS}`, {
method: "POST",
body: { variant_id: variantId, quantity },
});
@@ -147,7 +178,7 @@ export async function updateLineItemQuantity(
lineItemId: string,
quantity: number,
): Promise<CartSummary> {
const data = await medusaCartFetch(`/store/carts/${cartId}/line-items/${lineItemId}`, {
const data = await medusaCartFetch(`/store/carts/${cartId}/line-items/${lineItemId}?${ITEMS_FIELDS}`, {
method: "POST",
body: { quantity },
});
@@ -155,10 +186,57 @@ export async function updateLineItemQuantity(
}
export async function removeLineItem(cartId: string, lineItemId: string): Promise<CartSummary> {
const data = await medusaCartFetch(`/store/carts/${cartId}/line-items/${lineItemId}`, {
const data = await medusaCartFetch(`/store/carts/${cartId}/line-items/${lineItemId}?${ITEMS_FIELDS}`, {
method: "DELETE",
});
// Forma distinta a las demas: el carrito actualizado viene en
// "parent", no en "cart" -- verificado en vivo, no documentado.
return mapCart(data.parent as MedusaCart);
}
export async function setShippingAddress(
cartId: string,
address: ShippingAddressInput,
): Promise<CartSummary> {
const data = await medusaCartFetch(`/store/carts/${cartId}?${ITEMS_FIELDS}`, {
method: "POST",
body: {
email: address.email,
shipping_address: {
first_name: address.firstName,
last_name: address.lastName,
address_1: address.address1,
city: address.city,
province: address.province || undefined,
country_code: "co",
phone: address.phone,
},
},
});
return mapCart(data.cart as MedusaCart);
}
type MedusaShippingOption = {
id: string;
name: string;
amount?: number | null;
calculated_price?: { calculated_amount?: number | null } | null;
};
export async function getShippingOptions(cartId: string): Promise<ShippingOption[]> {
const data = await medusaCartFetch(`/store/shipping-options?cart_id=${cartId}`);
const options = (data.shipping_options as MedusaShippingOption[] | undefined) ?? [];
return options.map((option) => ({
id: option.id,
name: option.name,
amount: option.calculated_price?.calculated_amount ?? option.amount ?? 0,
}));
}
export async function addShippingMethod(cartId: string, optionId: string): Promise<CartSummary> {
const data = await medusaCartFetch(`/store/carts/${cartId}/shipping-methods?${ITEMS_FIELDS}`, {
method: "POST",
body: { option_id: optionId },
});
return mapCart(data.cart as MedusaCart);
}
@@ -0,0 +1,117 @@
// Cuentas de cliente reales conectadas a Medusa (Store API), mismo patron
// que medusa-cart.ts -- server-side, nunca cacheado (auth es estado
// mutable por sesion, no catalogo).
//
// Flujo verificado en vivo (2026-08-29) antes de escribir este archivo,
// no asumido contra documentacion: registro (auth/customer/emailpass/
// register) da un token de registro; ese token crea el customer real
// (POST /store/customers); pero ese token de registro NO sirve como
// sesion del customer -- hay que loguear de nuevo para obtener el token
// de sesion real. Mensajes de error exactos confirmados: registro con
// correo repetido -> 401 "Identity with email already exists"; login con
// credenciales invalidas -> 401 "Invalid email or password".
export interface CustomerSummary {
token: string;
id: string;
email: string;
firstName: string | null;
lastName: string | null;
}
function backendUrl(): string {
const value = process.env.MEDUSA_BACKEND_URL;
if (!value) {
throw new Error("MEDUSA_BACKEND_URL no está configurado.");
}
return value.replace(/\/$/, "");
}
async function medusaAuthFetch(
path: string,
init?: { method?: string; body?: unknown; token?: string },
): Promise<Record<string, unknown>> {
const publishableKey = process.env.MEDUSA_PUBLISHABLE_KEY;
const response = await fetch(`${backendUrl()}${path}`, {
method: init?.method ?? "GET",
headers: {
"Content-Type": "application/json",
...(publishableKey ? { "x-publishable-api-key": publishableKey } : {}),
...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}),
},
body: init?.body ? JSON.stringify(init.body) : undefined,
cache: "no-store",
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const message = typeof data.message === "string" ? data.message : `Error ${response.status}`;
throw new Error(message);
}
return data;
}
type MedusaCustomer = {
id: string;
email: string;
first_name?: string | null;
last_name?: string | null;
};
export async function getCurrentCustomer(token: string): Promise<CustomerSummary> {
const data = await medusaAuthFetch("/store/customers/me", { token });
const customer = data.customer as MedusaCustomer;
return {
token,
id: customer.id,
email: customer.email,
firstName: customer.first_name ?? null,
lastName: customer.last_name ?? null,
};
}
export async function loginCustomer(email: string, password: string): Promise<CustomerSummary> {
let data: Record<string, unknown>;
try {
data = await medusaAuthFetch("/auth/customer/emailpass", {
method: "POST",
body: { email, password },
});
} catch (error) {
if ((error as Error).message === "Invalid email or password") {
throw new Error("Correo o contraseña incorrectos.");
}
throw error;
}
return getCurrentCustomer(data.token as string);
}
export async function registerCustomer(
email: string,
password: string,
firstName: string,
lastName: string,
): Promise<CustomerSummary> {
let data: Record<string, unknown>;
try {
data = await medusaAuthFetch("/auth/customer/emailpass/register", {
method: "POST",
body: { email, password },
});
} catch (error) {
if ((error as Error).message === "Identity with email already exists") {
throw new Error("Ese correo ya está registrado — inicia sesión en vez de crear una cuenta nueva.");
}
throw error;
}
await medusaAuthFetch("/store/customers", {
method: "POST",
token: data.token as string,
body: { email, first_name: firstName, last_name: lastName },
});
// El token de registro crea el customer pero no queda como sesion real
// -- se loguea de nuevo para obtener el token de sesion (confirmado en
// vivo, no documentado explicitamente).
return loginCustomer(email, password);
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
// Cuenta de cliente real conectada a Medusa (Store API, via
// /api/customer/*), mismo patron que cart-store.ts: token + datos basicos
// persistidos en localStorage, resincronizados contra el servidor al
// rehidratar (el token puede llevar dias, o haber sido revocado).
export interface Customer {
id: string;
email: string;
firstName: string | null;
lastName: string | null;
}
interface CustomerState {
token: string | null;
customer: Customer | null;
hasHydrated: boolean;
loading: boolean;
error: string | null;
setHasHydrated: (value: boolean) => void;
register: (email: string, password: string, firstName: string, lastName: string) => Promise<boolean>;
login: (email: string, password: string) => Promise<boolean>;
logout: () => void;
syncCustomer: () => Promise<void>;
}
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();
}
function toCustomer(data: Record<string, unknown>): Customer {
return {
id: data.id as string,
email: data.email as string,
firstName: (data.firstName as string | null) ?? null,
lastName: (data.lastName as string | null) ?? null,
};
}
export const useCustomerStore = create<CustomerState>()(
persist(
(set, get) => ({
token: null,
customer: null,
hasHydrated: false,
loading: false,
error: null,
setHasHydrated: (value) => set({ hasHydrated: value }),
register: async (email, password, firstName, lastName) => {
set({ loading: true, error: null });
const result = await postJson("/api/customer/register", { email, password, firstName, lastName });
if (result.error) {
set({ loading: false, error: result.error });
return false;
}
set({ loading: false, token: result.token, customer: toCustomer(result) });
return true;
},
login: async (email, password) => {
set({ loading: true, error: null });
const result = await postJson("/api/customer/login", { email, password });
if (result.error) {
set({ loading: false, error: result.error });
return false;
}
set({ loading: false, token: result.token, customer: toCustomer(result) });
return true;
},
logout: () => set({ token: null, customer: null, error: null }),
// El token guardado en localStorage puede llevar dias o haber sido
// revocado -- se refresca contra el servidor en vez de confiar en el
// ultimo snapshot local.
syncCustomer: async () => {
const token = get().token;
if (!token) return;
try {
const response = await fetch(`/api/customer/me?token=${encodeURIComponent(token)}`);
if (!response.ok) {
set({ token: null, customer: null });
return;
}
const result = await response.json();
if (result.id) set({ customer: toCustomer(result) });
} catch {
// Sin red: se queda con el ultimo estado local conocido.
}
},
}),
{
name: "ari-shopping-customer-v1",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ token: state.token, customer: state.customer }),
onRehydrateStorage: () => (state) => {
state?.setHasHydrated(true);
void state?.syncCustomer();
},
},
),
);