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