feat: el cliente puede ver y modificar sus datos de contacto #6

Merged
jpbeldomenico merged 2 commits from feat/datos-de-contacto into main 2026-08-05 16:09:46 -03:00
5 changed files with 260 additions and 3 deletions
Showing only changes of commit 62dab5e025 - Show all commits
+84
View File
@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
Review

Importás verificarSesion desde auth-admin.ts para validar la cookie cliente_session. Verificá que esa función valide correctamente el rol de cliente y no solo la existencia de la sesión de admin. Si reutiliza lógica de admin, podría haber escalada de privilegios.

#ai-review-inline

Importás `verificarSesion` desde `auth-admin.ts` para validar la cookie `cliente_session`. Verificá que esa función valide correctamente el rol de cliente y no solo la existencia de la sesión de admin. Si reutiliza lógica de admin, podría haber escalada de privilegios. #ai-review-inline
import { aFormatoWhatsapp, aLocalArgentino, esLocalArgentinoValido } from "@/lib/telefono";
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// Datos de contacto del cliente logueado. El teléfono se devuelve en formato local
// (sin el +54 9) porque el formulario lo muestra como prefijo fijo.
export async function GET() {
const sesion = await verificarSesion("cliente_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const cliente = await prisma.cliente.findUnique({
where: { id: sesion.sub },
select: { nombre: true, apellido: true, email: true, telefono: true, telefonoNorm: true },
});
if (!cliente) return NextResponse.json({ error: "Cliente no encontrado" }, { status: 404 });
return NextResponse.json({
nombre: cliente.nombre,
apellido: cliente.apellido,
email: cliente.email ?? "",
telefono: aLocalArgentino(cliente.telefonoNorm ?? cliente.telefono ?? ""),
});
}
export async function PATCH(req: NextRequest) {
const sesion = await verificarSesion("cliente_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { nombre, apellido, email, telefono } = await req.json();
if (!nombre?.trim() || !apellido?.trim())
return NextResponse.json({ error: "Nombre y apellido son obligatorios" }, { status: 400 });
if (!email?.trim() || !EMAIL_RE.test(email.trim()))
return NextResponse.json({ error: "El email no es válido" }, { status: 400 });
if (!esLocalArgentinoValido(telefono ?? ""))
return NextResponse.json(
{ error: "El teléfono debe tener 10 dígitos, sin el 0 de la característica ni el 15" },
{ status: 400 }
);
const emailNuevo = email.trim().toLowerCase();
const telefonoNorm = aFormatoWhatsapp(telefono);
// El email es con lo que se entra al sistema y el teléfono es con lo que el bot
// reconoce al cliente: los dos tienen que seguir siendo únicos.
const emailTomado = await prisma.cliente.findFirst({
Review

Condición de carrera: la verificación de unicidad de email/teléfono y el update no son atómicos. Dos requests simultáneos pueden pasar la verificación y fallar en el update (o peor, si no hay constraint única en BD, duplicar datos). Usá una transacción de Prisma o confia en el constraint único y atrapá el error P2002.

#ai-review-inline

Condición de carrera: la verificación de unicidad de email/teléfono y el update no son atómicos. Dos requests simultáneos pueden pasar la verificación y fallar en el update (o peor, si no hay constraint única en BD, duplicar datos). Usá una transacción de Prisma o confia en el constraint único y atrapá el error P2002. #ai-review-inline
where: { email: emailNuevo, id: { not: sesion.sub } },
select: { id: true },
});
if (emailTomado)
return NextResponse.json({ error: "Ese email ya está registrado" }, { status: 409 });
const telefonoTomado = await prisma.cliente.findFirst({
where: { telefonoNorm, id: { not: sesion.sub } },
select: { id: true },
});
if (telefonoTomado)
return NextResponse.json({ error: "Ese teléfono ya está registrado" }, { status: 409 });
const cliente = await prisma.cliente.update({
where: { id: sesion.sub },
data: {
nombre: nombre.trim(),
apellido: apellido.trim(),
email: emailNuevo,
telefono: aLocalArgentino(telefonoNorm),
Outdated
Review

Guardás el teléfono en formato local en el campo telefono y en formato WhatsApp en telefonoNorm. El GET lee telefonoNorm primero y hace fallback a telefono. Si telefonoNorm existe, telefono es redundante. Confirmá que el schema tenga @@unique([telefonoNorm]) y @@unique([email]) para que la BD sea la fuente de verdad.

#ai-review-inline

Guardás el teléfono en formato local en el campo `telefono` y en formato WhatsApp en `telefonoNorm`. El GET lee `telefonoNorm` primero y hace fallback a `telefono`. Si `telefonoNorm` existe, `telefono` es redundante. Confirmá que el schema tenga `@@unique([telefonoNorm])` y `@@unique([email])` para que la BD sea la fuente de verdad. #ai-review-inline
telefonoNorm,
},
select: { nombre: true, apellido: true, email: true, telefonoNorm: true },
});
return NextResponse.json({
ok: true,
nombre: cliente.nombre,
apellido: cliente.apellido,
email: cliente.email ?? "",
telefono: aLocalArgentino(cliente.telefonoNorm ?? ""),
});
}
+8 -3
View File
@@ -68,9 +68,14 @@ export default function DashboardPage() {
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Mis autos</h1>
<button onClick={cerrarSesion} className="text-sm text-gray-200 hover:text-red-300 transition">
Salir
</button>
<div className="flex items-center gap-4">
<Link href="/cliente/perfil" className="text-sm text-gray-200 hover:text-white transition">
Mis datos
</Link>
<button onClick={cerrarSesion} className="text-sm text-gray-200 hover:text-red-300 transition">
Salir
</button>
</div>
</div>
{loading ? (
+157
View File
@@ -0,0 +1,157 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import Button from "@/components/ui/Button";
import Card from "@/components/ui/Card";
import Input from "@/components/ui/Input";
import { esLocalArgentinoValido } from "@/lib/telefono";
interface Perfil {
nombre: string;
apellido: string;
email: string;
telefono: string;
}
const VACIO: Perfil = { nombre: "", apellido: "", email: "", telefono: "" };
export default function PerfilPage() {
const router = useRouter();
const [perfil, setPerfil] = useState<Perfil>(VACIO);
const [form, setForm] = useState<Perfil>(VACIO);
const [loading, setLoading] = useState(true);
const [editando, setEditando] = useState(false);
const [guardando, setGuardando] = useState(false);
const [error, setError] = useState("");
const [telefonoError, setTelefonoError] = useState("");
const [guardado, setGuardado] = useState(false);
useEffect(() => {
fetch("/api/cliente/perfil")
Review

El fetch inicial no maneja errores de red: si falla la request (p.ej. sin conexión), el .then no se ejecuta y loading queda en true para siempre. Agregá .catch(() => { setLoading(false); setError('Error de conexión'); }) o usá try/catch con async/await.

  useEffect(() => {
    async function cargar() {
      try {
        const r = await fetch('/api/cliente/perfil');
        if (r.status === 401) { router.push('/cliente'); return; }
        const data = await r.json();
        setPerfil(data);
        setForm(data);
      } catch {
        setError('Error de conexión');
      } finally {
        setLoading(false);
      }
    }
    cargar();
  }, [router]);

#ai-review-inline

El fetch inicial no maneja errores de red: si falla la request (p.ej. sin conexión), el `.then` no se ejecuta y `loading` queda en `true` para siempre. Agregá `.catch(() => { setLoading(false); setError('Error de conexión'); })` o usá try/catch con async/await. ```suggestion useEffect(() => { async function cargar() { try { const r = await fetch('/api/cliente/perfil'); if (r.status === 401) { router.push('/cliente'); return; } const data = await r.json(); setPerfil(data); setForm(data); } catch { setError('Error de conexión'); } finally { setLoading(false); } } cargar(); }, [router]); ``` #ai-review-inline
.then((r) => {
if (r.status === 401) { router.push("/cliente"); return null; }
return r.json();
})
.then((data) => {
if (!data) return;
setPerfil(data);
setForm(data);
setLoading(false);
});
}, [router]);
function set(campo: keyof Perfil) {
return (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((f) => ({ ...f, [campo]: e.target.value }));
}
function handleTelefono(e: React.ChangeEvent<HTMLInputElement>) {
const soloDigitos = e.target.value.replace(/\D/g, "").slice(0, 10);
setForm((f) => ({ ...f, telefono: soloDigitos }));
if (soloDigitos.startsWith("0")) setTelefonoError("Sin el 0 de la característica");
else if (soloDigitos.startsWith("15")) setTelefonoError("Sin el 15, poné la característica");
else setTelefonoError("");
}
function cancelar() {
setForm(perfil);
setEditando(false);
setError("");
setTelefonoError("");
}
async function guardar(e: React.FormEvent) {
e.preventDefault();
if (!esLocalArgentinoValido(form.telefono)) {
setTelefonoError("Son 10 dígitos: característica sin el 0 y número sin el 15");
return;
}
setGuardando(true);
setError("");
const res = await fetch("/api/cliente/perfil", {
Review

El PATCH no maneja 401 (sesión expirada durante la edición). El usuario ve un error genérico y no sabe que debe reloguearse. Agregá if (res.status === 401) { router.push('/cliente'); return; } antes de leer res.json().

    const res = await fetch('/api/cliente/perfil', {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(form),
    });
    if (res.status === 401) { router.push('/cliente'); return; }
    const data = await res.json();

#ai-review-inline

El PATCH no maneja 401 (sesión expirada durante la edición). El usuario ve un error genérico y no sabe que debe reloguearse. Agregá `if (res.status === 401) { router.push('/cliente'); return; }` antes de leer `res.json()`. ```suggestion const res = await fetch('/api/cliente/perfil', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form), }); if (res.status === 401) { router.push('/cliente'); return; } const data = await res.json(); ``` #ai-review-inline
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const data = await res.json();
setGuardando(false);
if (!res.ok) { setError(data.error || "No se pudieron guardar los cambios"); return; }
setPerfil(data);
setForm(data);
setEditando(false);
setGuardado(true);
setTimeout(() => setGuardado(false), 4000);
}
if (loading) return <div className="text-center py-10 text-gray-200">Cargando...</div>;
const emailCambio = editando && form.email.trim().toLowerCase() !== perfil.email.toLowerCase();
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/cliente/dashboard"><Button variant="ghost" className="px-2">←</Button></Link>
<h1 className="text-xl sm:text-2xl font-bold text-white">Mis datos</h1>
</div>
<Card>
{!editando ? (
<div className="flex flex-col gap-4">
<Dato etiqueta="Nombre" valor={`${perfil.nombre} ${perfil.apellido}`} />
<Dato etiqueta="Email" valor={perfil.email || "—"} />
<Dato etiqueta="Teléfono" valor={perfil.telefono ? `+54 9 ${perfil.telefono}` : "—"} />
{guardado && <p className="text-sm text-green-600">✅ Datos actualizados</p>}
<Button onClick={() => setEditando(true)} className="self-start">
Modificar datos
</Button>
</div>
) : (
<form onSubmit={guardar} className="flex flex-col gap-4">
<div className="grid sm:grid-cols-2 gap-4">
<Input id="nombre" label="Nombre" value={form.nombre} onChange={set("nombre")} required />
<Input id="apellido" label="Apellido" value={form.apellido} onChange={set("apellido")} required />
</div>
<Input
id="email"
type="email"
label="Email"
value={form.email}
onChange={set("email")}
ayuda={emailCambio ? "Ojo: con este email vas a entrar de ahora en más." : undefined}
required
/>
<Input
id="telefono"
type="tel"
inputMode="numeric"
label="Teléfono (celular)"
prefijo="+54 9"
placeholder="1141984844"
maxLength={10}
ayuda="Es el celular con el que sacás turnos por WhatsApp."
error={telefonoError}
value={form.telefono}
onChange={handleTelefono}
required
/>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-2 justify-end">
<Button variant="secondary" type="button" onClick={cancelar}>Cancelar</Button>
<Button type="submit" loading={guardando}>Guardar cambios</Button>
</div>
</form>
)}
</Card>
</div>
);
}
function Dato({ etiqueta, valor }: { etiqueta: string; valor: string }) {
return (
<div>
<p className="text-sm text-gray-500">{etiqueta}</p>
<p className="font-medium text-gray-800">{valor}</p>
</div>
);
}
+10
View File
@@ -29,3 +29,13 @@ export function esLocalArgentinoValido(local: string): boolean {
export function aFormatoWhatsapp(local: string): string {
return PREFIJO_WHATSAPP_AR + normalizarTelefono(local);
}
/**
* Inverso del anterior: 5491141984844 → 1141984844. Sirve para mostrar el número
* en el formulario (donde el +54 9 es un prefijo fijo), venga de un alta web o de
* una por WhatsApp, que lo guarda con el prefijo puesto.
*/
export function aLocalArgentino(valor: string): string {
const d = normalizarTelefono(valor);
return d.startsWith(PREFIJO_WHATSAPP_AR) ? d.slice(PREFIJO_WHATSAPP_AR.length) : d;
}
+1
View File
@@ -19,6 +19,7 @@ export async function middleware(req: NextRequest) {
if (pathname.startsWith("/cliente/dashboard") ||
pathname.startsWith("/cliente/historial") ||
pathname.startsWith("/cliente/perfil") ||
pathname.startsWith("/cliente/turnos")) {
const ok = await verifyCookie(req, "cliente_session");
if (!ok) return NextResponse.redirect(new URL("/cliente", req.url));