From bb2c0bbe8518abf6655f18686db1ebd7ba6b797e Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Sat, 29 Aug 2026 04:12:11 +0000 Subject: [PATCH] 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. --- .../ecommerce/app/api/cart/address/route.ts | 14 ++ .../app/api/cart/shipping-method/route.ts | 14 ++ .../app/api/cart/shipping-options/route.ts | 14 ++ workloads/ecommerce/app/checkout/page.tsx | 5 + .../ecommerce/components/cart/cart-drawer.tsx | 18 +- .../components/checkout/checkout-client.tsx | 184 ++++++++++++++++++ .../lib/headless/providers/medusa-cart.ts | 69 +++++++ 7 files changed, 310 insertions(+), 8 deletions(-) create mode 100644 workloads/ecommerce/app/api/cart/address/route.ts create mode 100644 workloads/ecommerce/app/api/cart/shipping-method/route.ts create mode 100644 workloads/ecommerce/app/api/cart/shipping-options/route.ts create mode 100644 workloads/ecommerce/app/checkout/page.tsx create mode 100644 workloads/ecommerce/components/checkout/checkout-client.tsx diff --git a/workloads/ecommerce/app/api/cart/address/route.ts b/workloads/ecommerce/app/api/cart/address/route.ts new file mode 100644 index 0000000..18ea6b9 --- /dev/null +++ b/workloads/ecommerce/app/api/cart/address/route.ts @@ -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 }); + } +} diff --git a/workloads/ecommerce/app/api/cart/shipping-method/route.ts b/workloads/ecommerce/app/api/cart/shipping-method/route.ts new file mode 100644 index 0000000..1083594 --- /dev/null +++ b/workloads/ecommerce/app/api/cart/shipping-method/route.ts @@ -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 }); + } +} diff --git a/workloads/ecommerce/app/api/cart/shipping-options/route.ts b/workloads/ecommerce/app/api/cart/shipping-options/route.ts new file mode 100644 index 0000000..eaa08e5 --- /dev/null +++ b/workloads/ecommerce/app/api/cart/shipping-options/route.ts @@ -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 }); + } +} diff --git a/workloads/ecommerce/app/checkout/page.tsx b/workloads/ecommerce/app/checkout/page.tsx new file mode 100644 index 0000000..875e38d --- /dev/null +++ b/workloads/ecommerce/app/checkout/page.tsx @@ -0,0 +1,5 @@ +import { CheckoutClient } from "@/components/checkout/checkout-client"; + +export default function CheckoutPage() { + return ; +} diff --git a/workloads/ecommerce/components/cart/cart-drawer.tsx b/workloads/ecommerce/components/cart/cart-drawer.tsx index e8c20b8..b370db3 100644 --- a/workloads/ecommerce/components/cart/cart-drawer.tsx +++ b/workloads/ecommerce/components/cart/cart-drawer.tsx @@ -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 ( { if (!open) closeCart(); }}> @@ -49,7 +43,15 @@ export function CartDrawer() {
{pricedItems ? "Progreso para envío gratis" : "Cotización personalizada"}{pricedItems ? `${Math.round(progress)}%` : "Por confirmar"}
Subtotal{pricedItems ? formatMoney(subtotal) : "Por confirmar"}
- + {/* Button asChild + disabled no funciona sobre un (: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 ? ( + + ) : ( + + )}
diff --git a/workloads/ecommerce/components/checkout/checkout-client.tsx b/workloads/ecommerce/components/checkout/checkout-client.tsx new file mode 100644 index 0000000..ee6db1d --- /dev/null +++ b/workloads/ecommerce/components/checkout/checkout-client.tsx @@ -0,0 +1,184 @@ +"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 { formatMoney } from "@/lib/utils"; +import { useCartStore } from "@/store/cart-store"; + +// 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("address"); + const [form, setForm] = useState({ firstName: "", lastName: "", address1: "", city: "", province: "", phone: "", email: "" }); + const [shippingOptions, setShippingOptions] = useState([]); + const [selectedOptionId, setSelectedOptionId] = useState(null); + const [shippingTotal, setShippingTotal] = useState(0); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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) return; + setLoading(true); + setError(null); + try { + const cart = await postJson("/api/cart/address", { cartId, ...form }); + 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}, ${form.city}${form.province ? `, ${form.province}` : ""}\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 ( +
+

Tu carrito está vacío.

+ +
+ ); + } + + return ( +
+
+ Seguir comprando +

Confirmar pedido

+ + {step === "address" && ( +
+
+ setForm({ ...form, firstName: event.target.value })} /> + setForm({ ...form, lastName: event.target.value })} /> +
+ setForm({ ...form, address1: event.target.value })} /> +
+ setForm({ ...form, city: event.target.value })} /> + setForm({ ...form, province: event.target.value })} /> +
+
+ setForm({ ...form, phone: event.target.value })} /> + setForm({ ...form, email: event.target.value })} /> +
+ {error &&

{error}

} + +
+ )} + + {step === "shipping" && ( +
+

{form.address1}, {form.city}{form.province ? `, ${form.province}` : ""}

+
+ {shippingOptions.map((option) => ( + + ))} + {shippingOptions.length === 0 &&

No hay opciones de envío disponibles para esta dirección.

} +
+ {error &&

{error}

} + +
+ )} + + {step === "review" && ( +
+
+

Entrega

+

{form.firstName} {form.lastName}

+

{form.address1}, {form.city}{form.province ? `, ${form.province}` : ""}

+

{form.phone}

+
+

El pago en línea todavía no está disponible — confirmamos tu pedido por WhatsApp con estos datos.

+ +
+ )} +
+ +
+

Tu pedido

+
+ {items.map((item) => ( +
+
{item.product.name}
+

{item.product.name}

Cantidad: {item.quantity}

+

{formatMoney((item.product.price ?? 0) * item.quantity, item.product.currency)}

+
+ ))} +
+
+
Subtotal{formatMoney(itemsSubtotal, currency)}
+ {step === "review" &&
Envío{formatMoney(shippingTotal, currency)}
} +
Total{formatMoney(step === "review" ? total : itemsSubtotal, currency)}
+
+
+
+ ); +} diff --git a/workloads/ecommerce/lib/headless/providers/medusa-cart.ts b/workloads/ecommerce/lib/headless/providers/medusa-cart.ts index 693420a..46bbc42 100644 --- a/workloads/ecommerce/lib/headless/providers/medusa-cart.ts +++ b/workloads/ecommerce/lib/headless/providers/medusa-cart.ts @@ -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) { @@ -68,6 +86,8 @@ type MedusaCart = { id: string; currency_code: string; subtotal?: number | null; + shipping_total?: number | null; + total?: number | null; items?: MedusaLineItem[] | null; }; @@ -76,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, @@ -171,3 +193,50 @@ export async function removeLineItem(cartId: string, lineItemId: string): Promis // "parent", no en "cart" -- verificado en vivo, no documentado. return mapCart(data.parent as MedusaCart); } + +export async function setShippingAddress( + cartId: string, + address: ShippingAddressInput, +): Promise { + 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 { + 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 { + const data = await medusaCartFetch(`/store/carts/${cartId}/shipping-methods?${ITEMS_FIELDS}`, { + method: "POST", + body: { option_id: optionId }, + }); + return mapCart(data.cart as MedusaCart); +}