Merge feat/auth-clientes-real into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 18s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 8m15s

This commit is contained in:
devops committed 2026-08-29 14:24:45 +00:00
commit bc530c98e6
7 files changed
+353 -15

No files matched your search

@@ -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,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>
);
}
@@ -2,11 +2,11 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation"; 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 { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { AccountDialog } from "@/components/account/account-dialog";
import { PredictiveSearch } from "@/components/search/predictive-search"; import { PredictiveSearch } from "@/components/search/predictive-search";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import type { Category, Product } from "@/lib/headless/types"; import type { Category, Product } from "@/lib/headless/types";
import { useCartStore } from "@/store/cart-store"; import { useCartStore } from "@/store/cart-store";
@@ -108,19 +108,7 @@ export function Navbar({ products, categories }: { products: Product[]; categori
</SheetContent> </SheetContent>
</Sheet> </Sheet>
<Dialog open={accountOpen} onOpenChange={setAccountOpen}> <AccountDialog 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>
</header> </header>
); );
} }
@@ -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();
},
},
),
);