feat(F-099): completed feature

This commit is contained in:
chattie
2026-08-21 07:29:07 +02:00
parent 3f1d08382f
commit 5177a851aa
41 changed files with 922 additions and 131 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 950 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

View File

@@ -4,7 +4,12 @@ import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
interface Profile {
id: string;
email: string;
displayName: string | null;
phone: string | null;
}
interface Address {
id: string;
@@ -62,8 +67,8 @@ function AddressForm({
setError('');
try {
const url = initial
? `${API}/users/${userId}/addresses/${initial.id}`
: `${API}/users/${userId}/addresses`;
? `/api/users/${userId}/addresses/${initial.id}`
: `/api/users/${userId}/addresses`;
const method = initial ? 'PATCH' : 'POST';
const res = await fetch(url, {
method,
@@ -187,6 +192,14 @@ export default function AccountPage() {
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<Address | null>(null);
const [msg, setMsg] = useState('');
const [profile, setProfile] = useState<Profile | null>(null);
const [profileForm, setProfileForm] = useState({ displayName: '', phone: '' });
const [profileSaving, setProfileSaving] = useState(false);
const [profileError, setProfileError] = useState('');
const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
const [passwordSaving, setPasswordSaving] = useState(false);
const [passwordMessage, setPasswordMessage] = useState('');
const [passwordError, setPasswordError] = useState('');
useEffect(() => {
if (!authLoading && !user) {
@@ -198,7 +211,7 @@ export default function AccountPage() {
if (!user) return;
setLoading(true);
try {
const res = await fetch(`${API}/users/${user.id}/addresses`, { credentials: 'include' });
const res = await fetch(`/api/users/${user.id}/addresses`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setAddresses(data.items ?? []);
@@ -211,16 +224,58 @@ export default function AccountPage() {
}, [user]);
useEffect(() => {
const timer = window.setTimeout(() => {
void load();
}, 0);
return () => window.clearTimeout(timer);
}, [load]);
if (!user) return;
fetch(`/api/users/${user.id}`, { credentials: 'include' })
.then((res) => res.ok ? res.json() : null)
.then((data: Profile | null) => {
if (!data) return;
setProfile(data);
setProfileForm({ displayName: data.displayName ?? '', phone: data.phone ?? '' });
})
.catch(() => setProfileError('No se pudieron cargar tus datos personales'));
}, [user]);
const saveProfile = async (event: React.FormEvent) => {
event.preventDefault();
if (!user) return;
setProfileSaving(true); setProfileError('');
try {
const res = await fetch(`/api/users/${user.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ displayName: profileForm.displayName, phone: profileForm.phone }),
});
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.error?.message ?? 'No se pudieron guardar los datos');
setProfile(data); setMsg('Datos personales actualizados');
} catch (error) {
setProfileError(error instanceof Error ? error.message : 'No se pudieron guardar los datos');
} finally { setProfileSaving(false); }
};
const changePassword = async (event: React.FormEvent) => {
event.preventDefault();
setPasswordError(''); setPasswordMessage('');
if (passwordForm.newPassword.length < 8) { setPasswordError('La nueva contraseña debe tener al menos 8 caracteres.'); return; }
if (passwordForm.newPassword !== passwordForm.confirmPassword) { setPasswordError('Las contraseñas nuevas no coinciden.'); return; }
setPasswordSaving(true);
try {
const res = await fetch('/api/auth/me/password', {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ currentPassword: passwordForm.currentPassword, newPassword: passwordForm.newPassword }),
});
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.error?.message ?? 'No se pudo cambiar la contraseña');
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
setPasswordMessage('Contraseña actualizada correctamente');
} catch (error) {
setPasswordError(error instanceof Error ? error.message : 'No se pudo cambiar la contraseña');
} finally { setPasswordSaving(false); }
};
const handleDelete = async (id: string) => {
if (!user || !confirm('¿Eliminar esta dirección?')) return;
try {
await fetch(`${API}/users/${user.id}/addresses/${id}`, { method: 'DELETE', credentials: 'include' });
await fetch(`/api/users/${user.id}/addresses/${id}`, { method: 'DELETE', credentials: 'include' });
setMsg('Dirección eliminada');
setTimeout(() => setMsg(''), 3000);
load();
@@ -247,6 +302,45 @@ export default function AccountPage() {
</div>
)}
{/* Personal data */}
<section className="rounded-xl border border-stone-200 bg-white p-5">
<h2 className="mb-4 text-lg font-semibold text-stone-900">Datos personales</h2>
<form onSubmit={saveProfile} className="grid gap-4 sm:grid-cols-2">
<label className="text-sm font-medium text-stone-700">Nombre
<input value={profileForm.displayName} onChange={(e) => setProfileForm(f => ({ ...f, displayName: e.target.value }))} required className="mt-1 w-full rounded-lg border border-stone-300 px-3 py-2 font-normal outline-none focus:ring-2 focus:ring-[#2D6A4F]" />
</label>
<label className="text-sm font-medium text-stone-700">Teléfono
<input value={profileForm.phone} onChange={(e) => setProfileForm(f => ({ ...f, phone: e.target.value }))} className="mt-1 w-full rounded-lg border border-stone-300 px-3 py-2 font-normal outline-none focus:ring-2 focus:ring-[#2D6A4F]" />
</label>
{profileError && <p className="sm:col-span-2 text-sm text-red-600">{profileError}</p>}
<button disabled={profileSaving} className="w-fit rounded-lg bg-[#2D6A4F] px-5 py-2 text-sm font-semibold text-white disabled:opacity-50">{profileSaving ? 'Guardando...' : 'Guardar datos'}</button>
</form>
</section>
{/* Password */}
<section className="rounded-xl border border-stone-200 bg-white p-5">
<div className="mb-4 flex items-center justify-between gap-4">
<h2 className="text-lg font-semibold text-stone-900">Contraseña</h2>
<a href="/auth/recuperar" className="text-sm text-[#2D6A4F] hover:underline">¿Has olvidado tu contraseña?</a>
</div>
<form onSubmit={changePassword} className="max-w-xl space-y-4">
<label className="block text-sm font-medium text-stone-700">Contraseña actual
<input type="password" required value={passwordForm.currentPassword} onChange={(e) => setPasswordForm(f => ({ ...f, currentPassword: e.target.value }))} className="mt-1 w-full rounded-lg border border-stone-300 px-3 py-2 font-normal outline-none focus:ring-2 focus:ring-[#2D6A4F]" />
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="text-sm font-medium text-stone-700">Nueva contraseña
<input type="password" minLength={8} required value={passwordForm.newPassword} onChange={(e) => setPasswordForm(f => ({ ...f, newPassword: e.target.value }))} className="mt-1 w-full rounded-lg border border-stone-300 px-3 py-2 font-normal outline-none focus:ring-2 focus:ring-[#2D6A4F]" />
</label>
<label className="text-sm font-medium text-stone-700">Repetir contraseña
<input type="password" minLength={8} required value={passwordForm.confirmPassword} onChange={(e) => setPasswordForm(f => ({ ...f, confirmPassword: e.target.value }))} className="mt-1 w-full rounded-lg border border-stone-300 px-3 py-2 font-normal outline-none focus:ring-2 focus:ring-[#2D6A4F]" />
</label>
</div>
{passwordError && <p className="text-sm text-red-600">{passwordError}</p>}
{passwordMessage && <p className="text-sm text-green-700">{passwordMessage}</p>}
<button disabled={passwordSaving} className="rounded-lg bg-[#2D6A4F] px-5 py-2 text-sm font-semibold text-white disabled:opacity-50">{passwordSaving ? 'Actualizando...' : 'Cambiar contraseña'}</button>
</form>
</section>
{/* Addresses */}
<section>
<div className="flex items-center justify-between mb-4">

View File

@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from 'next/server';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
export async function PATCH(request: NextRequest) {
try {
const response = await fetch(`${API}/auth/me/password`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Cookie: request.headers.get('cookie') ?? '' },
body: await request.text(),
});
const data = await response.json().catch(() => ({}));
return NextResponse.json(data, { status: response.status });
} catch {
return NextResponse.json({ error: { message: 'Error del servidor' } }, { status: 500 });
}
}

View File

@@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from 'next/server';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const backendRes = await fetch(`${API}/auth/password-reset/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await backendRes.json().catch(() => ({ error: { message: 'Error del servidor' } }));
return NextResponse.json(data, { status: backendRes.status });
} catch {
return NextResponse.json({ error: { message: 'Error del servidor' } }, { status: 500 });
}
}

View File

@@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from 'next/server';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const backendRes = await fetch(`${API}/auth/password-reset/request`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': request.headers.get('x-forwarded-for') ?? '' },
body: JSON.stringify(body),
});
const data = await backendRes.json().catch(() => ({ error: { message: 'Error del servidor' } }));
return NextResponse.json(data, { status: backendRes.status });
} catch {
return NextResponse.json({ error: { message: 'Error del servidor' } }, { status: 500 });
}
}

View File

@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
type Params = { params: Promise<{ id: string; addressId: string }> };
async function proxy(request: NextRequest, { params }: Params) {
const { id, addressId } = await params;
const response = await fetch(`${API}/users/${id}/addresses/${addressId}`, {
method: request.method,
headers: { Cookie: request.headers.get('cookie') ?? '' },
cache: 'no-store',
});
if (response.status === 204) return new NextResponse(null, { status: 204 });
const data = await response.json().catch(() => ({}));
return NextResponse.json(data, { status: response.status });
}
export async function PATCH(request: NextRequest, context: Params) { return proxy(request, context); }
export async function DELETE(request: NextRequest, context: Params) { return proxy(request, context); }

View File

@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
type Params = { params: Promise<{ id: string }> };
async function proxy(request: NextRequest, { params }: Params) {
const { id } = await params;
const body = request.method === 'PATCH' ? await request.text() : undefined;
const response = await fetch(`${API}/users/${id}`, {
method: request.method,
headers: { Cookie: request.headers.get('cookie') ?? '', ...(body ? { 'Content-Type': 'application/json' } : {}) },
...(body ? { body } : {}),
cache: 'no-store',
});
const data = await response.json().catch(() => ({}));
return NextResponse.json(data, { status: response.status });
}
export async function GET(request: NextRequest, context: Params) { return proxy(request, context); }
export async function PATCH(request: NextRequest, context: Params) { return proxy(request, context); }

View File

@@ -66,9 +66,10 @@ export default function LoginPage() {
>
{loading ? 'Entrando...' : 'Iniciar sesión'}
</button>
<p className="text-center text-sm text-gray-500">
¿No tienes cuenta? <Link href="/auth/register" className="text-[#70ad47] hover:underline font-medium">Créala aquí</Link>
</p>
<div className="flex items-center justify-between text-sm text-gray-500">
<Link href="/auth/recuperar" className="text-[#70ad47] hover:underline">¿Olvidaste tu contraseña?</Link>
<span>¿No tienes cuenta? <Link href="/auth/register" className="text-[#70ad47] hover:underline font-medium">Créala aquí</Link></span>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,55 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
export default function RecoverPage() {
const [email, setEmail] = useState('');
const [sent, setSent] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
setError('');
setLoading(true);
try {
const response = await fetch('/api/auth/password-reset/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!response.ok) {
const data = await response.json().catch(() => null);
setError(data?.error?.message ?? 'No se pudo solicitar el enlace.');
return;
}
setSent(true);
} catch {
setError('No se pudo conectar con el servidor.');
} finally {
setLoading(false);
}
};
return (
<main className="max-w-md mx-auto px-4 py-16">
<div className="rounded-2xl border border-gray-200 bg-white p-8 shadow-sm">
<h1 className="mb-2 text-2xl font-bold text-gray-900">Recuperar contraseña</h1>
{sent ? (
<p className="rounded-lg bg-green-50 px-4 py-3 text-sm text-green-700">Si existe una cuenta con ese email, recibirás un enlace de recuperación.</p>
) : (
<form onSubmit={submit} className="space-y-4">
<p className="text-sm text-gray-500">Te enviaremos un enlace para elegir una contraseña nueva.</p>
<label className="block text-sm font-medium text-gray-700">Email
<input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} className="mt-1 w-full rounded-xl border border-gray-300 px-4 py-3 outline-none focus:ring-2 focus:ring-[#70ad47]" />
</label>
{error && <p className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</p>}
<button disabled={loading} className="w-full rounded-xl bg-[#70ad47] py-3 font-semibold text-white hover:bg-[#5a9040] disabled:opacity-60">{loading ? 'Enviando...' : 'Enviar enlace'}</button>
</form>
)}
<Link href="/auth/login" className="mt-5 block text-center text-sm text-[#70ad47] hover:underline">Volver a iniciar sesión</Link>
</div>
</main>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import { Suspense, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
function ResetForm() {
const router = useRouter();
const params = useSearchParams();
const token = params.get('token') ?? '';
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const [done, setDone] = useState(false);
if (!token) return <Message>El enlace de recuperación no es válido.</Message>;
if (done) return <Message success>Contraseña actualizada. Redirigiendo al inicio de sesión...</Message>;
const submit = async (event: React.FormEvent) => {
event.preventDefault();
setError('');
if (password.length < 8) return setError('La contraseña debe tener al menos 8 caracteres.');
if (password !== confirm) return setError('Las contraseñas no coinciden.');
setSaving(true);
try {
const response = await fetch('/api/auth/password-reset/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
});
const data = await response.json().catch(() => null);
if (!response.ok) {
setError(data?.error?.message ?? data?.message ?? 'El enlace es inválido o ha caducado.');
return;
}
setDone(true);
window.setTimeout(() => router.push('/auth/login'), 2200);
} catch {
setError('No se pudo conectar con el servidor. Inténtalo de nuevo.');
} finally {
setSaving(false);
}
};
return (
<form onSubmit={submit} className="w-full max-w-md space-y-5 rounded-2xl border border-gray-200 bg-white p-8 shadow-sm">
<div>
<h1 className="text-2xl font-bold text-gray-900">Restablecer contraseña</h1>
<p className="mt-1 text-sm text-gray-500">Elige una contraseña nueva para tu cuenta.</p>
</div>
<label className="block text-sm font-medium text-gray-700">
Nueva contraseña
<input type="password" minLength={8} required value={password} onChange={(e) => setPassword(e.target.value)} className="mt-1 w-full rounded-xl border border-gray-300 px-4 py-3 outline-none focus:ring-2 focus:ring-[#70ad47]" />
</label>
<label className="block text-sm font-medium text-gray-700">
Repetir contraseña
<input type="password" minLength={8} required value={confirm} onChange={(e) => setConfirm(e.target.value)} className="mt-1 w-full rounded-xl border border-gray-300 px-4 py-3 outline-none focus:ring-2 focus:ring-[#70ad47]" />
</label>
{error && <p className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</p>}
<button disabled={saving} className="w-full rounded-xl bg-[#70ad47] py-3 font-semibold text-white hover:bg-[#5a9040] disabled:opacity-60">
{saving ? 'Guardando...' : 'Cambiar contraseña'}
</button>
</form>
);
}
function Message({ children, success = false }: { children: React.ReactNode; success?: boolean }) {
return <div className={`w-full max-w-md rounded-2xl border p-8 text-sm ${success ? 'border-green-200 bg-green-50 text-green-700' : 'border-gray-200 bg-white text-gray-700'}`}>{children}</div>;
}
export default function ResetPasswordPage() {
return <main className="flex min-h-[70vh] items-center justify-center bg-gray-50 px-4 py-12"><Suspense fallback={<Message>Cargando...</Message>}><ResetForm /></Suspense></main>;
}

View File

@@ -11,28 +11,38 @@ export default function UserMenu() {
<span className="text-sm text-gray-600 hidden sm:block">
{user.email}
</span>
<button
onClick={logout}
className="flex items-center gap-1.5 text-sm font-medium text-[#70ad47] hover:text-[#5a9040] transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-label="Cerrar sesión">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg>
Cerrar sesión
</button>
<div className="relative group">
<button
onClick={logout}
aria-label="Cerrar sesión"
className="flex items-center justify-center w-9 h-9 rounded-full text-[#70ad47] hover:bg-[#70ad47]/10 hover:text-[#5a9040] transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg>
</button>
<span role="tooltip" className="pointer-events-none absolute right-0 top-full z-10 mt-2 whitespace-nowrap rounded-md bg-gray-900 px-2 py-1 text-xs text-white opacity-0 shadow transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
Cerrar sesión
</span>
</div>
</div>
);
}
return (
<Link
href="/auth/login"
className="flex items-center gap-1.5 text-sm font-medium text-[#70ad47] hover:text-[#5a9040] transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-label="Iniciar sesión">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
Iniciar sesión
</Link>
<div className="relative group">
<Link
href="/auth/login"
aria-label="Iniciar sesión"
className="flex items-center justify-center w-9 h-9 rounded-full text-[#70ad47] hover:bg-[#70ad47]/10 hover:text-[#5a9040] transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
</Link>
<span role="tooltip" className="pointer-events-none absolute right-0 top-full z-10 mt-2 whitespace-nowrap rounded-md bg-gray-900 px-2 py-1 text-xs text-white opacity-0 shadow transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
Iniciar sesión
</span>
</div>
);
}