From 3b748cc0142259141ad0fc01d91d75d3ca8a233b Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Sat, 29 Aug 2026 14:24:43 +0000 Subject: [PATCH] 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. --- .../ecommerce/app/api/customer/login/route.ts | 14 +++ .../ecommerce/app/api/customer/me/route.ts | 14 +++ .../app/api/customer/register/route.ts | 14 +++ .../components/account/account-dialog.tsx | 80 ++++++++++++ .../ecommerce/components/layout/navbar.tsx | 18 +-- .../lib/headless/providers/medusa-customer.ts | 117 ++++++++++++++++++ workloads/ecommerce/store/customer-store.ts | 111 +++++++++++++++++ 7 files changed, 353 insertions(+), 15 deletions(-) create mode 100644 workloads/ecommerce/app/api/customer/login/route.ts create mode 100644 workloads/ecommerce/app/api/customer/me/route.ts create mode 100644 workloads/ecommerce/app/api/customer/register/route.ts create mode 100644 workloads/ecommerce/components/account/account-dialog.tsx create mode 100644 workloads/ecommerce/lib/headless/providers/medusa-customer.ts create mode 100644 workloads/ecommerce/store/customer-store.ts diff --git a/workloads/ecommerce/app/api/customer/login/route.ts b/workloads/ecommerce/app/api/customer/login/route.ts new file mode 100644 index 0000000..9cf716c --- /dev/null +++ b/workloads/ecommerce/app/api/customer/login/route.ts @@ -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 }); + } +} diff --git a/workloads/ecommerce/app/api/customer/me/route.ts b/workloads/ecommerce/app/api/customer/me/route.ts new file mode 100644 index 0000000..cea726b --- /dev/null +++ b/workloads/ecommerce/app/api/customer/me/route.ts @@ -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 }); + } +} diff --git a/workloads/ecommerce/app/api/customer/register/route.ts b/workloads/ecommerce/app/api/customer/register/route.ts new file mode 100644 index 0000000..0387550 --- /dev/null +++ b/workloads/ecommerce/app/api/customer/register/route.ts @@ -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 }); + } +} diff --git a/workloads/ecommerce/components/account/account-dialog.tsx b/workloads/ecommerce/components/account/account-dialog.tsx new file mode 100644 index 0000000..8ae128d --- /dev/null +++ b/workloads/ecommerce/components/account/account-dialog.tsx @@ -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("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 ( + + +
+ {isLoggedIn ? ( + <> + Tu cuenta +

Hola, {customer.firstName ?? customer.email}

+

{customer.email}

+
+ + +
+ + ) : ( + <> + Atención personalizada +

{mode === "login" ? "Inicia sesión" : "Crea tu cuenta"}

+
+ {mode === "register" && ( +
+ setForm({ ...form, firstName: event.target.value })} /> + setForm({ ...form, lastName: event.target.value })} /> +
+ )} + setForm({ ...form, email: event.target.value })} /> + setForm({ ...form, password: event.target.value })} /> + {error &&

{error}

} + +
+ + + + )} +
+
+
+ ); +} diff --git a/workloads/ecommerce/components/layout/navbar.tsx b/workloads/ecommerce/components/layout/navbar.tsx index 3c3b23a..227fb11 100644 --- a/workloads/ecommerce/components/layout/navbar.tsx +++ b/workloads/ecommerce/components/layout/navbar.tsx @@ -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 - - -
- Atención personalizada -

Tu cuenta ARI

-

El inicio de sesión quedará conectado al backend headless. Mientras tanto, puedes gestionar pedidos y disponibilidad directamente por WhatsApp.

-
- - -
-
-
-
+ ); } diff --git a/workloads/ecommerce/lib/headless/providers/medusa-customer.ts b/workloads/ecommerce/lib/headless/providers/medusa-customer.ts new file mode 100644 index 0000000..4713af8 --- /dev/null +++ b/workloads/ecommerce/lib/headless/providers/medusa-customer.ts @@ -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> { + 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 { + 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 { + let data: Record; + 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 { + let data: Record; + 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); +} diff --git a/workloads/ecommerce/store/customer-store.ts b/workloads/ecommerce/store/customer-store.ts new file mode 100644 index 0000000..d0f2e1e --- /dev/null +++ b/workloads/ecommerce/store/customer-store.ts @@ -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; + login: (email: string, password: string) => Promise; + logout: () => void; + syncCustomer: () => Promise; +} + +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): 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()( + 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(); + }, + }, + ), +);