feat: restore global settings with proper fields in settings page
Restores: version Android/iOS, horario soporte, email/telefono soporte, modo mantenimiento. Organizado en secciones: Ajustes generales, Integraciones (Maps), Documentos legales. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
3e1632e776
commit
e7afbf6d7b
+198
-92
@@ -4,48 +4,59 @@ import { useEffect, useState, useCallback } from 'react';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Save, ExternalLink, Copy, FileText, Shield, Map, Eye, EyeOff, CheckCircle2, XCircle } from 'lucide-react';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
Save, ExternalLink, Copy, FileText, Shield, Map,
|
||||||
|
Eye, EyeOff, CheckCircle2, XCircle, Clock, Smartphone, Phone, Mail,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
||||||
const PUBLIC_BASE = API_BASE.replace('/api/v1', '');
|
|
||||||
|
interface GlobalSettings {
|
||||||
|
app_version_android?: string;
|
||||||
|
app_version_ios?: string;
|
||||||
|
support_schedule?: string;
|
||||||
|
support_email?: string;
|
||||||
|
support_phone?: string;
|
||||||
|
maintenance_mode?: boolean;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
const POLICY_ITEMS = [
|
const POLICY_ITEMS = [
|
||||||
{
|
{ key: 'privacy', label: 'Política de Privacidad', icon: Shield, description: 'Cómo se recopilan, usan y protegen los datos personales.' },
|
||||||
key: 'privacy',
|
{ key: 'terms', label: 'Términos y Condiciones', icon: FileText, description: 'Condiciones de uso de la plataforma ProsApp.' },
|
||||||
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() {
|
export default function SettingsPage() {
|
||||||
const [policies, setPolicies] = useState<Record<string, string>>({ privacy: '', terms: '' });
|
|
||||||
const [saving, setSaving] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Maps key state
|
// Global
|
||||||
|
const [global, setGlobal] = useState<GlobalSettings>({});
|
||||||
|
const [savingGlobal, setSavingGlobal] = useState(false);
|
||||||
|
|
||||||
|
// Maps
|
||||||
const [mapsConfigured, setMapsConfigured] = useState(false);
|
const [mapsConfigured, setMapsConfigured] = useState(false);
|
||||||
const [mapsKey, setMapsKey] = useState('');
|
const [mapsKey, setMapsKey] = useState('');
|
||||||
const [showMapsKey, setShowMapsKey] = useState(false);
|
const [showMapsKey, setShowMapsKey] = useState(false);
|
||||||
const [savingMaps, setSavingMaps] = useState(false);
|
const [savingMaps, setSavingMaps] = useState(false);
|
||||||
|
|
||||||
|
// Policies
|
||||||
|
const [policies, setPolicies] = useState<Record<string, string>>({ privacy: '', terms: '' });
|
||||||
|
const [savingPolicy, setSavingPolicy] = useState<string | null>(null);
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all([
|
Promise.all([
|
||||||
|
api.get<GlobalSettings>('/settings'),
|
||||||
|
api.get<{ configured: boolean }>('/settings/maps-key'),
|
||||||
api.get<{ privacy: string | null; terms: string | null }>('/settings/policies'),
|
api.get<{ privacy: string | null; terms: string | null }>('/settings/policies'),
|
||||||
api.get<{ configured: boolean; api_key: string }>('/settings/maps-key'),
|
|
||||||
])
|
])
|
||||||
.then(([policiesData, mapsData]) => {
|
.then(([globalData, mapsData, policiesData]) => {
|
||||||
setPolicies({ privacy: policiesData.privacy || '', terms: policiesData.terms || '' });
|
setGlobal(globalData || {});
|
||||||
setMapsConfigured(mapsData.configured);
|
setMapsConfigured(mapsData.configured);
|
||||||
|
setPolicies({ privacy: policiesData.privacy || '', terms: policiesData.terms || '' });
|
||||||
})
|
})
|
||||||
.catch(() => toast.error('Error al cargar configuración'))
|
.catch(() => toast.error('Error al cargar configuración'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
@@ -53,6 +64,18 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const saveGlobal = async () => {
|
||||||
|
setSavingGlobal(true);
|
||||||
|
try {
|
||||||
|
await api.patch('/settings', global);
|
||||||
|
toast.success('Configuración guardada');
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.message || 'Error al guardar');
|
||||||
|
} finally {
|
||||||
|
setSavingGlobal(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const saveMapsKey = async () => {
|
const saveMapsKey = async () => {
|
||||||
if (!mapsKey.trim()) return toast.error('Ingresa una API Key');
|
if (!mapsKey.trim()) return toast.error('Ingresa una API Key');
|
||||||
setSavingMaps(true);
|
setSavingMaps(true);
|
||||||
@@ -69,82 +92,176 @@ export default function SettingsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const savePolicy = async (key: string) => {
|
const savePolicy = async (key: string) => {
|
||||||
setSaving(key);
|
setSavingPolicy(key);
|
||||||
try {
|
try {
|
||||||
await api.patch(`/settings/policies/${key}`, { content: policies[key] });
|
await api.patch(`/settings/policies/${key}`, { content: policies[key] });
|
||||||
toast.success('Política guardada');
|
toast.success('Política guardada');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(e?.message || 'Error al guardar');
|
toast.error(e?.message || 'Error al guardar');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(null);
|
setSavingPolicy(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyLink = (key: string) => {
|
const copyLink = (key: string) => {
|
||||||
const url = `${API_BASE}/settings/policy/${key}`;
|
navigator.clipboard.writeText(`${API_BASE}/settings/policy/${key}`);
|
||||||
navigator.clipboard.writeText(url);
|
|
||||||
toast.success('Enlace copiado');
|
toast.success('Enlace copiado');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setG = (field: keyof GlobalSettings, value: any) =>
|
||||||
|
setGlobal((g) => ({ ...g, [field]: value }));
|
||||||
|
|
||||||
if (loading) return <div className="flex items-center justify-center py-16 text-muted-foreground">Cargando...</div>;
|
if (loading) return <div className="flex items-center justify-center py-16 text-muted-foreground">Cargando...</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Configuración</h1>
|
<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>
|
<p className="text-muted-foreground text-sm mt-1">Ajustes globales, integraciones y documentos legales de ProsApp.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Google Maps */}
|
{/* ── Ajustes globales ── */}
|
||||||
<Card>
|
<section className="space-y-4">
|
||||||
<CardHeader>
|
<h2 className="text-lg font-semibold">Ajustes generales</h2>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
|
||||||
<Map size={18} className="text-muted-foreground" />
|
|
||||||
Google Maps API Key
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Requerida para mostrar mapas y detectar la ubicación en la app web y móvil.
|
|
||||||
Habilita <strong>Maps JavaScript API</strong> y <strong>Geocoding API</strong> en Google Cloud Console.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{mapsConfigured ? (
|
|
||||||
<><CheckCircle2 className="text-green-500 h-5 w-5" /><span className="text-sm font-medium">Configurada</span></>
|
|
||||||
) : (
|
|
||||||
<><XCircle className="text-destructive h-5 w-5" /><span className="text-sm font-medium text-destructive">No configurada</span></>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Input
|
|
||||||
type={showMapsKey ? 'text' : 'password'}
|
|
||||||
placeholder="AIzaSy..."
|
|
||||||
value={mapsKey}
|
|
||||||
onChange={(e) => setMapsKey(e.target.value)}
|
|
||||||
className="pr-10 font-mono"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => setShowMapsKey(!showMapsKey)}
|
|
||||||
>
|
|
||||||
{showMapsKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<Button onClick={saveMapsKey} disabled={savingMaps || !mapsKey.trim()}>
|
|
||||||
<Save className="mr-1 h-4 w-4" />
|
|
||||||
{savingMaps ? 'Guardando...' : 'Guardar'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
La app web carga la key desde el backend al abrir el mapa. Agrega restricción HTTP en Google Cloud: <code className="bg-muted px-1 rounded">https://app.prosapp.co/*</code>
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Policies */}
|
<Card>
|
||||||
<div className="space-y-6">
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Smartphone size={18} className="text-muted-foreground" />
|
||||||
|
Versión de la app
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium">Android</label>
|
||||||
|
<Input
|
||||||
|
placeholder="1.0.0"
|
||||||
|
value={global.app_version_android || ''}
|
||||||
|
onChange={(e) => setG('app_version_android', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium">iOS</label>
|
||||||
|
<Input
|
||||||
|
placeholder="1.0.0"
|
||||||
|
value={global.app_version_ios || ''}
|
||||||
|
onChange={(e) => setG('app_version_ios', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Clock size={18} className="text-muted-foreground" />
|
||||||
|
Soporte
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium">Horario de atención</label>
|
||||||
|
<Input
|
||||||
|
placeholder="Lunes a viernes 8am – 6pm"
|
||||||
|
value={global.support_schedule || ''}
|
||||||
|
onChange={(e) => setG('support_schedule', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium flex items-center gap-1"><Mail size={13} /> Email</label>
|
||||||
|
<Input
|
||||||
|
placeholder="soporte@prosapp.co"
|
||||||
|
value={global.support_email || ''}
|
||||||
|
onChange={(e) => setG('support_email', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium flex items-center gap-1"><Phone size={13} /> Teléfono</label>
|
||||||
|
<Input
|
||||||
|
placeholder="+57 300 000 0000"
|
||||||
|
value={global.support_phone || ''}
|
||||||
|
onChange={(e) => setG('support_phone', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Modo mantenimiento</CardTitle>
|
||||||
|
<CardDescription>Cuando está activo, la app muestra un mensaje de mantenimiento a los usuarios.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setG('maintenance_mode', !global.maintenance_mode)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${global.maintenance_mode ? 'bg-destructive' : 'bg-muted-foreground/30'}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${global.maintenance_mode ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||||
|
</button>
|
||||||
|
<Badge variant={global.maintenance_mode ? 'destructive' : 'secondary'}>
|
||||||
|
{global.maintenance_mode ? 'Activo' : 'Inactivo'}
|
||||||
|
</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Button onClick={saveGlobal} disabled={savingGlobal}>
|
||||||
|
<Save className="mr-1 h-4 w-4" />
|
||||||
|
{savingGlobal ? 'Guardando...' : 'Guardar ajustes generales'}
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Google Maps ── */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">Integraciones</h2>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Map size={18} className="text-muted-foreground" />
|
||||||
|
Google Maps API Key
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Requerida para mostrar mapas y detectar ubicación. Habilita <strong>Maps JavaScript API</strong> y <strong>Geocoding API</strong> en Google Cloud Console.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{mapsConfigured
|
||||||
|
? <><CheckCircle2 className="text-green-500 h-5 w-5" /><span className="text-sm font-medium">Configurada</span></>
|
||||||
|
: <><XCircle className="text-destructive h-5 w-5" /><span className="text-sm font-medium text-destructive">No configurada</span></>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
type={showMapsKey ? 'text' : 'password'}
|
||||||
|
placeholder="AIzaSy..."
|
||||||
|
value={mapsKey}
|
||||||
|
onChange={(e) => setMapsKey(e.target.value)}
|
||||||
|
className="pr-10 font-mono"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setShowMapsKey(!showMapsKey)}
|
||||||
|
>
|
||||||
|
{showMapsKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Button onClick={saveMapsKey} disabled={savingMaps || !mapsKey.trim()}>
|
||||||
|
<Save className="mr-1 h-4 w-4" />
|
||||||
|
{savingMaps ? 'Guardando...' : 'Guardar'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Restricción HTTP recomendada: <code className="bg-muted px-1 rounded">https://app.prosapp.co/*</code>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Documentos legales ── */}
|
||||||
|
<section className="space-y-4">
|
||||||
<h2 className="text-lg font-semibold">Documentos legales</h2>
|
<h2 className="text-lg font-semibold">Documentos legales</h2>
|
||||||
{POLICY_ITEMS.map(({ key, label, icon: Icon, description }) => {
|
{POLICY_ITEMS.map(({ key, label, icon: Icon, description }) => {
|
||||||
const publicUrl = `${API_BASE}/settings/policy/${key}`;
|
const publicUrl = `${API_BASE}/settings/policy/${key}`;
|
||||||
@@ -159,25 +276,18 @@ export default function SettingsPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<textarea
|
<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"
|
className="w-full min-h-[220px] 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 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.`}
|
placeholder={`Escribe el contenido de ${label.toLowerCase()}...`}
|
||||||
value={policies[key]}
|
value={policies[key]}
|
||||||
onChange={(e) => setPolicies((p) => ({ ...p, [key]: e.target.value }))}
|
onChange={(e) => setPolicies((p) => ({ ...p, [key]: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Button
|
<Button onClick={() => savePolicy(key)} disabled={savingPolicy === key}>
|
||||||
onClick={() => savePolicy(key)}
|
|
||||||
disabled={saving === key}
|
|
||||||
>
|
|
||||||
<Save className="mr-1 h-4 w-4" />
|
<Save className="mr-1 h-4 w-4" />
|
||||||
{saving === key ? 'Guardando...' : 'Guardar'}
|
{savingPolicy === key ? 'Guardando...' : 'Guardar'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
<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">
|
<code className="text-xs bg-muted px-2 py-1.5 rounded border truncate flex-1 min-w-0">{publicUrl}</code>
|
||||||
{publicUrl}
|
|
||||||
</code>
|
|
||||||
<Button size="icon" variant="outline" className="h-8 w-8 shrink-0" onClick={() => copyLink(key)}>
|
<Button size="icon" variant="outline" className="h-8 w-8 shrink-0" onClick={() => copyLink(key)}>
|
||||||
<Copy size={14} />
|
<Copy size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -188,15 +298,11 @@ export default function SettingsPage() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user