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); +}