feat(FE-04): completed feature
This commit is contained in:
330
project/frontend/src/app/account/page.tsx
Normal file
330
project/frontend/src/app/account/page.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
'use client';
|
||||
|
||||
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 Address {
|
||||
id: string;
|
||||
userId: string;
|
||||
label: string | null;
|
||||
recipientName: string;
|
||||
street: string;
|
||||
city: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
isDefault: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const emptyForm = {
|
||||
label: '',
|
||||
recipientName: '',
|
||||
street: '',
|
||||
city: '',
|
||||
postalCode: '',
|
||||
country: 'España',
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
function AddressForm({
|
||||
initial,
|
||||
userId,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: {
|
||||
initial?: Address;
|
||||
userId: string;
|
||||
onSaved: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [form, setForm] = useState(initial ? {
|
||||
label: initial.label ?? '',
|
||||
recipientName: initial.recipientName,
|
||||
street: initial.street,
|
||||
city: initial.city,
|
||||
postalCode: initial.postalCode,
|
||||
country: initial.country,
|
||||
isDefault: initial.isDefault,
|
||||
} : { ...emptyForm });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const set = (field: string, value: string | boolean) =>
|
||||
setForm((f) => ({ ...f, [field]: value }));
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const url = initial
|
||||
? `${API}/users/${userId}/addresses/${initial.id}`
|
||||
: `${API}/users/${userId}/addresses`;
|
||||
const method = initial ? 'PATCH' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
...form,
|
||||
label: form.label || null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Error ${res.status}`);
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al guardar');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1">Etiqueta</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.label}
|
||||
onChange={(e) => set('label', e.target.value)}
|
||||
placeholder="Casa, Oficina..."
|
||||
className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1">Destinatario *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.recipientName}
|
||||
onChange={(e) => set('recipientName', e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1">Dirección *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.street}
|
||||
onChange={(e) => set('street', e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1">Ciudad *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.city}
|
||||
onChange={(e) => set('city', e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1">Código postal *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.postalCode}
|
||||
onChange={(e) => set('postalCode', e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1">País *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.country}
|
||||
onChange={(e) => set('country', e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-stone-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-stone-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isDefault}
|
||||
onChange={(e) => set('isDefault', e.target.checked)}
|
||||
className="rounded border-stone-300 text-[#2D6A4F] focus:ring-[#2D6A4F]"
|
||||
/>
|
||||
Dirección predeterminada
|
||||
</label>
|
||||
{error && <p className="text-sm text-red-600 bg-red-50 rounded-lg px-3 py-2">{error}</p>}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-5 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
{saving ? 'Guardando...' : initial ? 'Actualizar' : 'Añadir dirección'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-5 py-2 border border-stone-300 text-stone-600 text-sm rounded-lg hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccountPage() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [addresses, setAddresses] = useState<Address[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editing, setEditing] = useState<Address | null>(null);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
router.push('/auth/login');
|
||||
}
|
||||
}, [user, authLoading, router]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!user) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API}/users/${user.id}/addresses`, { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAddresses(data.items ?? []);
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
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' });
|
||||
setMsg('Dirección eliminada');
|
||||
setTimeout(() => setMsg(''), 3000);
|
||||
load();
|
||||
} catch {
|
||||
setMsg('Error al eliminar');
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading || !user) {
|
||||
return <div className="max-w-4xl mx-auto px-4 py-12 text-stone-400">Cargando...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-8 space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-stone-900">Mi cuenta</h1>
|
||||
<p className="text-sm text-stone-500 mt-1">{user.email}</p>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className={`p-3 rounded-lg text-sm ${msg.includes('Error') ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Addresses */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-stone-900">Direcciones</h2>
|
||||
<button
|
||||
onClick={() => { setShowForm(true); setEditing(null); }}
|
||||
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
+ Nueva dirección
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(showForm || editing) && (
|
||||
<div className="bg-white border border-stone-200 rounded-xl p-5 mb-4">
|
||||
<AddressForm
|
||||
initial={editing ?? undefined}
|
||||
userId={user.id}
|
||||
onSaved={() => {
|
||||
setShowForm(false);
|
||||
setEditing(null);
|
||||
setMsg(editing ? 'Dirección actualizada' : 'Dirección añadida');
|
||||
setTimeout(() => setMsg(''), 3000);
|
||||
load();
|
||||
}}
|
||||
onCancel={() => { setShowForm(false); setEditing(null); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-stone-400">Cargando direcciones...</div>
|
||||
) : addresses.length === 0 ? (
|
||||
<div className="bg-white border border-stone-200 rounded-xl p-8 text-center">
|
||||
<p className="text-3xl mb-2">📍</p>
|
||||
<p className="text-sm text-stone-500">No tienes direcciones guardadas</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{addresses.map((addr) => (
|
||||
<div
|
||||
key={addr.id}
|
||||
className={`bg-white border rounded-xl p-4 ${addr.isDefault ? 'border-[#2D6A4F] ring-1 ring-[#2D6A4F]/20' : 'border-stone-200'}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
{addr.label && (
|
||||
<span className="inline-flex px-2 py-0.5 bg-stone-100 text-stone-600 text-xs font-medium rounded-full mb-1">
|
||||
{addr.label}
|
||||
</span>
|
||||
)}
|
||||
{addr.isDefault && (
|
||||
<span className="inline-flex px-2 py-0.5 bg-[#2D6A4F]/10 text-[#2D6A4F] text-xs font-medium rounded-full mb-1 ml-1">
|
||||
Predeterminada
|
||||
</span>
|
||||
)}
|
||||
<p className="text-sm font-medium text-stone-900">{addr.recipientName}</p>
|
||||
<p className="text-sm text-stone-600">{addr.street}</p>
|
||||
<p className="text-sm text-stone-600">
|
||||
{addr.postalCode} {addr.city}, {addr.country}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setEditing(addr); setShowForm(false); }}
|
||||
className="text-xs text-[#2D6A4F] hover:underline"
|
||||
>
|
||||
Editar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(addr.id)}
|
||||
className="text-xs text-red-500 hover:underline"
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user