feat(F-105): completed feature

This commit is contained in:
chattie
2026-08-21 08:06:31 +02:00
parent 9b42c506f3
commit 5458789634
13 changed files with 247 additions and 49 deletions

View File

@@ -11,6 +11,20 @@ interface Profile {
phone: string | null;
}
interface Preferences {
orderUpdates: boolean;
newsletter: boolean;
promos: boolean;
}
const DEFAULT_PREFS: Preferences = { orderUpdates: true, newsletter: false, promos: false };
const PREF_ITEMS: Array<{ key: keyof Preferences; title: string; desc: string }> = [
{ key: 'orderUpdates', title: 'Estado de mis pedidos', desc: 'Recibe un email cuando tu pedido cambie de estado (pagado, enviado, entregado...).' },
{ key: 'newsletter', title: 'Boletín de novedades', desc: 'Nuevos productos, recetas y consejos de vida saludable.' },
{ key: 'promos', title: 'Ofertas y promociones', desc: 'Avisos de descuentos y promociones especiales.' },
];
interface Address {
id: string;
userId: string;
@@ -196,6 +210,8 @@ export default function AccountPage() {
const [profileForm, setProfileForm] = useState({ displayName: '', phone: '' });
const [profileSaving, setProfileSaving] = useState(false);
const [profileError, setProfileError] = useState('');
const [prefs, setPrefs] = useState<Preferences>(DEFAULT_PREFS);
const [prefsMsg, setPrefsMsg] = useState('');
const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
const [passwordSaving, setPasswordSaving] = useState(false);
const [passwordMessage, setPasswordMessage] = useState('');
@@ -231,6 +247,8 @@ export default function AccountPage() {
if (!data) return;
setProfile(data);
setProfileForm({ displayName: data.displayName ?? '', phone: data.phone ?? '' });
const p = (data as Profile & { preferences?: Partial<Preferences> }).preferences;
if (p) setPrefs({ ...DEFAULT_PREFS, ...p });
})
.catch(() => setProfileError('No se pudieron cargar tus datos personales'));
}, [user]);
@@ -272,6 +290,26 @@ export default function AccountPage() {
} finally { setPasswordSaving(false); }
};
const togglePref = async (key: keyof Preferences) => {
if (!user) return;
const value = !prefs[key];
const prev = prefs;
setPrefs((p) => ({ ...p, [key]: value }));
setPrefsMsg('');
try {
const res = await fetch(`/api/users/${user.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ preferences: { [key]: value } }),
});
if (!res.ok) throw new Error();
setPrefsMsg('Preferencias actualizadas');
setTimeout(() => setPrefsMsg(''), 3000);
} catch {
setPrefs(prev);
setPrefsMsg('Error al guardar las preferencias');
}
};
const handleDelete = async (id: string) => {
if (!user || !confirm('¿Eliminar esta dirección?')) return;
try {
@@ -341,6 +379,42 @@ export default function AccountPage() {
</form>
</section>
{/* Preferences */}
<section className="rounded-xl border border-stone-200 bg-white p-5">
<h2 className="mb-1 text-lg font-semibold text-stone-900">Preferencias</h2>
<p className="mb-4 text-sm text-stone-500">Elige qué comunicaciones quieres recibir por email.</p>
{prefsMsg && (
<p className={`mb-3 text-sm ${prefsMsg.startsWith('Error') ? 'text-red-600' : 'text-green-700'}`}>{prefsMsg}</p>
)}
<div className="divide-y divide-stone-100">
{PREF_ITEMS.map((item) => (
<div
key={item.key}
role="switch"
aria-checked={prefs[item.key]}
tabIndex={0}
onClick={() => togglePref(item.key)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePref(item.key); } }}
className="flex items-start justify-between gap-4 py-3 cursor-pointer select-none"
>
<span>
<span className="block text-sm font-medium text-stone-800">{item.title}</span>
<span className="block text-xs text-stone-500 mt-0.5">{item.desc}</span>
</span>
<span
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
prefs[item.key] ? 'bg-[#2D6A4F]' : 'bg-stone-300'
}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
prefs[item.key] ? 'translate-x-6' : 'translate-x-1'
}`} />
</span>
</div>
))}
</div>
</section>
{/* Addresses */}
<section>
<div className="flex items-center justify-between mb-4">

View File

@@ -11,6 +11,21 @@ export default function UserMenu() {
<span className="text-sm text-gray-600 hidden sm:block">
{user.email}
</span>
{/* Mi cuenta: gestión de datos, direcciones y preferencias */}
<div className="relative group">
<Link
href="/account"
aria-label="Mi cuenta"
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="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
</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">
Mi cuenta
</span>
</div>
<div className="relative group">
<button
onClick={logout}

View File

@@ -0,0 +1,15 @@
/**
* Adds a JSONB preferences column to users_profiles so customers can manage
* notification/marketing preferences from their account page.
* @param {import('node-pg-migrate').MigrationBuilder} pgm
*/
export const up = (pgm) => {
pgm.sql(`
ALTER TABLE users_profiles
ADD COLUMN IF NOT EXISTS preferences jsonb NOT NULL DEFAULT '{}'::jsonb
`);
};
export const down = (pgm) => {
pgm.sql(`ALTER TABLE users_profiles DROP COLUMN IF EXISTS preferences`);
};

View File

@@ -31,14 +31,25 @@ export interface UsersRoutesDeps {
const uuidParamSchema = z.object({ id: z.uuid() });
const addressIdParamSchema = z.object({ id: z.uuid(), addressId: z.uuid() });
const preferencesSchema = z
.object({
orderUpdates: z.boolean().optional(),
newsletter: z.boolean().optional(),
promos: z.boolean().optional(),
})
.strict();
const profilePatchSchema = z
.object({
displayName: z.string().min(1).max(200).optional(),
phone: z.string().min(1).max(50).optional(),
preferences: preferencesSchema.optional(),
})
.refine((value) => value.displayName !== undefined || value.phone !== undefined, {
message: 'At least one of displayName or phone is required',
});
.refine(
(value) =>
value.displayName !== undefined || value.phone !== undefined || value.preferences !== undefined,
{ message: 'At least one of displayName, phone or preferences is required' },
);
const newAddressSchema = z.object({
label: z.string().max(100).optional().nullable(),
@@ -115,7 +126,8 @@ export async function registerUsersRoutes(
if (!customer) {
throw new AppError(404, 'NOT_FOUND', 'Customer not found');
}
return reply.send(serializeCustomer(customer));
const profile = await profiles.findByUserId(id);
return reply.send({ ...serializeCustomer(customer), preferences: profile?.preferences ?? undefined });
});
const patchUserSchema: FastifySchema = {
@@ -240,6 +252,7 @@ function serializeProfile(profile: Profile) {
userId: profile.userId,
displayName: profile.displayName,
phone: profile.phone,
preferences: profile.preferences,
createdAt: profile.createdAt.toISOString(),
updatedAt: profile.updatedAt.toISOString(),
};

View File

@@ -6,14 +6,32 @@ export interface Profile {
userId: string;
displayName: string | null;
phone: string | null;
preferences: UserPreferences;
createdAt: Date;
updatedAt: Date;
}
/** Preferencias gestionadas por el cliente desde su cuenta. */
export interface UserPreferences {
/** Aviso por email de cambios de estado de pedidos. */
orderUpdates: boolean;
/** Boletín de novedades. */
newsletter: boolean;
/** Ofertas y promociones. */
promos: boolean;
}
export const DEFAULT_PREFERENCES: UserPreferences = {
orderUpdates: true,
newsletter: false,
promos: false,
};
/** Fields a profile update may set. Undefined = leave unchanged. */
export interface ProfilePatch {
displayName?: string | null;
phone?: string | null;
preferences?: Partial<UserPreferences>;
}
/** Joined identity_users + users_profiles row for admin customer listing. */

View File

@@ -10,12 +10,15 @@ import type {
CustomerSummary,
Profile,
ProfilePatch,
UserPreferences,
} from '../domain/profile.js';
import { DEFAULT_PREFERENCES } from '../domain/profile.js';
interface ProfileRow {
user_id: string;
display_name: string | null;
phone: string | null;
preferences: Partial<UserPreferences> | null;
created_at: Date;
updated_at: Date;
}
@@ -34,7 +37,7 @@ export class PgProfileRepository implements ProfileRepository {
async findByUserId(userId: string): Promise<Profile | undefined> {
const result = await this.pool.query<ProfileRow>(
`SELECT user_id, display_name, phone, created_at, updated_at
`SELECT user_id, display_name, phone, preferences, created_at, updated_at
FROM users_profiles WHERE user_id = $1`,
[userId],
);
@@ -59,6 +62,11 @@ export class PgProfileRepository implements ProfileRepository {
values.push(patch.phone);
setClauses.push(`phone = $${values.length}`);
}
if (patch.preferences !== undefined) {
// Merge parcial: solo cambian las claves enviadas.
values.push(JSON.stringify(patch.preferences));
setClauses.push(`preferences = COALESCE(preferences, '{}'::jsonb) || $${values.length}::jsonb`);
}
if (setClauses.length > 0) {
values.push(userId);
await this.pool.query(
@@ -77,7 +85,7 @@ export class PgProfileRepository implements ProfileRepository {
async list(): Promise<Profile[]> {
const result = await this.pool.query<ProfileRow>(
`SELECT user_id, display_name, phone, created_at, updated_at
`SELECT user_id, display_name, phone, preferences, created_at, updated_at
FROM users_profiles ORDER BY created_at`,
);
return result.rows.map(toProfile);
@@ -130,6 +138,7 @@ function toProfile(row: ProfileRow): Profile {
userId: row.user_id,
displayName: row.display_name,
phone: row.phone,
preferences: { ...DEFAULT_PREFERENCES, ...(row.preferences ?? {}) },
createdAt: row.created_at,
updatedAt: row.updated_at,
};