feat: full locations CRUD + legal policies in settings

Backend:
- CRUD completo para countries, regions y cities
- GET /settings/policy/:key — página HTML pública para políticas
- GET/PATCH /settings/policies/:key — admin endpoints protegidos

Admin:
- /locations: árbol interactivo País → Región → Ciudad con add/edit/delete
- /settings: editor de Política de Privacidad y Términos con enlace público copiable
- Sidebar: Ciudades → Ubicaciones

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-06-27 07:45:53 -05:00
co-authored by Claude Sonnet 4.6
parent 5c740420e9
commit 39c4301d1b
9 changed files with 604 additions and 196 deletions
+96 -50
View File
@@ -2,79 +2,125 @@
import { useEffect, useState, useCallback } from 'react';
import { api } from '@/lib/api';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from 'sonner';
import { Save } from 'lucide-react';
import { Save, ExternalLink, Copy, FileText, Shield } from 'lucide-react';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
const PUBLIC_BASE = API_BASE.replace('/api/v1', '');
const POLICY_ITEMS = [
{
key: 'privacy',
label: 'Política de Privacidad',
icon: Shield,
description: 'Cómo se recopilan, usan y protegen los datos personales de los usuarios.',
},
{
key: 'terms',
label: 'Términos y Condiciones',
icon: FileText,
description: 'Condiciones de uso de la plataforma ProsApp.',
},
];
export default function SettingsPage() {
const [settings, setSettings] = useState<Record<string, any> | null>(null);
const [editValue, setEditValue] = useState('');
const [policies, setPolicies] = useState<Record<string, string>>({ privacy: '', terms: '' });
const [saving, setSaving] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
setLoading(true);
setError(null);
api.get<Record<string, any>>('/settings')
.then((data) => {
setSettings(data);
setEditValue(JSON.stringify(data, null, 2));
})
.catch(() => setError('Error al cargar configuración'))
api.get<{ privacy: string | null; terms: string | null }>('/settings/policies')
.then((data) => setPolicies({ privacy: data.privacy || '', terms: data.terms || '' }))
.catch(() => toast.error('Error al cargar políticas'))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const save = async () => {
setSaving(true);
const savePolicy = async (key: string) => {
setSaving(key);
try {
const parsed = JSON.parse(editValue);
await api.patch('/settings', parsed);
setSettings(parsed);
toast.success('Configuración guardada');
await api.patch(`/settings/policies/${key}`, { content: policies[key] });
toast.success('Política guardada');
} catch (e: any) {
toast.error(e?.message || 'Error al guardar');
} finally {
setSaving(false);
setSaving(null);
}
};
if (loading) {
return <div className="flex items-center justify-center py-16 text-muted-foreground">Cargando...</div>;
}
const copyLink = (key: string) => {
const url = `${API_BASE}/settings/policy/${key}`;
navigator.clipboard.writeText(url);
toast.success('Enlace copiado');
};
if (error) {
return (
<div className="flex flex-col items-center justify-center py-16 text-destructive">
<p>{error}</p>
<Button variant="outline" size="sm" onClick={load} className="mt-2">Reintentar</Button>
</div>
);
}
if (loading) return <div className="flex items-center justify-center py-16 text-muted-foreground">Cargando...</div>;
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold">Configuración</h1>
<Card>
<CardHeader>
<CardTitle>Configuración global (JSON)</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<textarea
className="flex min-h-[300px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
/>
<Button onClick={save} disabled={saving}>
<Save className="mr-1 h-4 w-4" />
{saving ? 'Guardando...' : 'Guardar'}
</Button>
</CardContent>
</Card>
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold">Configuración</h1>
<p className="text-muted-foreground text-sm mt-1">Gestiona las políticas legales y ajustes globales de ProsApp.</p>
</div>
{/* Policies */}
<div className="space-y-6">
<h2 className="text-lg font-semibold">Documentos legales</h2>
{POLICY_ITEMS.map(({ key, label, icon: Icon, description }) => {
const publicUrl = `${API_BASE}/settings/policy/${key}`;
return (
<Card key={key}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Icon size={18} className="text-muted-foreground" />
{label}
</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<textarea
className="w-full min-h-[260px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 font-mono resize-y"
placeholder={`Escribe aquí el contenido de ${label.toLowerCase()}...\n\nPuedes usar texto plano. Se mostrará con formato en la página pública.`}
value={policies[key]}
onChange={(e) => setPolicies((p) => ({ ...p, [key]: e.target.value }))}
/>
<div className="flex items-center gap-2 flex-wrap">
<Button
onClick={() => savePolicy(key)}
disabled={saving === key}
>
<Save className="mr-1 h-4 w-4" />
{saving === key ? 'Guardando...' : 'Guardar'}
</Button>
<div className="flex items-center gap-1 flex-1 min-w-0">
<code className="text-xs bg-muted px-2 py-1.5 rounded border truncate flex-1 min-w-0">
{publicUrl}
</code>
<Button size="icon" variant="outline" className="h-8 w-8 shrink-0" onClick={() => copyLink(key)}>
<Copy size={14} />
</Button>
<a href={publicUrl} target="_blank" rel="noopener noreferrer">
<Button size="icon" variant="outline" className="h-8 w-8 shrink-0">
<ExternalLink size={14} />
</Button>
</a>
</div>
</div>
<p className="text-xs text-muted-foreground">
Enlace público para compartir con usuarios o incluir en la app mobile/web.
</p>
</CardContent>
</Card>
);
})}
</div>
</div>
);
}