Files
soft_usite/resources/views/layouts/portal.html
T
Lizandro GuarnizoandClaude Sonnet 5 e2dab8117c fix(portal): el acceso a uMind Studio estaba oculto en móvil
Lo introduje yo al agregar el enlace: quedó con "hidden sm:inline-flex", así
que en un teléfono el cliente no tenía forma de entrar a Studio. Ahora el
enlace se ve siempre y lo que se oculta en pantallas chicas es solo el
texto, porque no entra al lado de la campana y el nombre de usuario — el
logo alcanza para reconocerlo y el acceso no puede faltar.

Del resto del portal en móvil: medido a 492px, ningún elemento desborda y
scrollWidth coincide con el ancho de la ventana. Las grillas del dashboard
ya tenían breakpoints y las pestañas del proyecto ya scrollean solas, así
que ahí no había nada que arreglar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 18:20:55 -05:00

558 lines
32 KiB
HTML

<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Portal de Clientes · U-site</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.5/dist/cdn.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/axios@1.6.7/dist/axios.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; }
body { font-family: 'Inter', system-ui, -apple-system, sans-serif; background: #f8fafc; }
[x-cloak] { display: none !important; }
/* Nada debe hacer scrollear la página entera de lado en un teléfono.
Las tablas anchas ya scrollean en su propio contenedor; esto es la
red por debajo para lo que se agregue después y se olvide el
contenedor: el desborde se contiene en vez de romper el layout. */
@media (max-width: 640px) {
body { overflow-x: hidden; }
table { max-width: 100%; }
pre, code { overflow-x: auto; max-width: 100%; }
}
:root { --brand: #8eb02f; --brand-dark: #6d8c24; }
</style>
</head>
<body class="min-h-screen flex flex-col">
<!-- Navbar -->
<header class="bg-white border-b border-slate-200 sticky top-0 z-30"
x-data="{
bellOpen: false,
notifs: [],
unread: 0,
async loadNotifs() {
try {
const r = await axios.get('/portal/mis-notifs?no_leidas=0');
this.notifs = r.data.items || [];
this.unread = r.data.unread || 0;
} catch {}
},
async markRead(id) {
await axios.put('/portal/mis-notifs/' + id + '/leida');
const n = this.notifs.find(x => x.ID === id);
if (n) { n.leida = true; this.unread = Math.max(0, this.unread - 1); }
},
async markAll() {
await axios.post('/portal/mis-notifs/marcar-todas');
this.notifs.forEach(n => n.leida = true);
this.unread = 0;
}
}"
x-init="loadNotifs(); setInterval(() => loadNotifs(), 30000)">
<div class="max-w-6xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
<!-- Logo -->
<a href="/portal/dashboard" class="flex items-center gap-2 font-bold text-slate-800 text-lg">
<span class="w-7 h-7 rounded-full flex items-center justify-center text-white text-xs font-bold" style="background:#8eb02f">U</span>
<span>Portal de Clientes</span>
</a>
<!-- uMind Studio: solo se muestra si este cliente tiene al menos un
espacio de uMind asignado. Se consulta el mismo endpoint con
alcance que usa el SPA, así nadie ve un enlace que lo llevaría a
una pantalla vacía. -->
<nav x-data="{ tieneUmind:false }"
x-init="fetch('/portal/umind/tenants')
.then(r => r.ok ? r.json() : {items:[]})
.then(d => tieneUmind = (d.items||[]).length > 0)
.catch(() => tieneUmind = false)"
class="ml-auto mr-3">
<a x-show="tieneUmind" x-cloak href="/portal/studio" title="uMind Studio"
class="inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 hover:text-slate-900 px-2 sm:px-3 py-1.5 rounded-lg hover:bg-slate-100 transition-colors">
<span class="w-5 h-5 rounded-md flex items-center justify-center text-white text-[10px] font-bold shrink-0" style="background:#8eb02f">uM</span>
<!-- En móvil queda solo el logo: el texto no entra al lado de la
campana y el nombre de usuario, pero el acceso no puede faltar. -->
<span class="hidden sm:inline">uMind Studio</span>
</a>
</nav>
<!-- Right: bell + user -->
<div class="flex items-center gap-3">
<!-- 🔔 Bell -->
<div class="relative">
<button @click="bellOpen = !bellOpen; if(bellOpen) loadNotifs()"
class="relative p-2 rounded-xl hover:bg-slate-100 transition-colors focus:outline-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"/>
</svg>
<span x-show="unread > 0"
class="absolute -top-0.5 -right-0.5 h-4 w-4 flex items-center justify-center rounded-full text-white text-[10px] font-bold"
style="background:#8eb02f"
x-text="unread > 9 ? '9+' : unread">
</span>
</button>
<!-- Dropdown -->
<div x-show="bellOpen" @click.outside="bellOpen = false"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 scale-95 -translate-y-1"
x-transition:enter-end="opacity-100 scale-100 translate-y-0"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 scale-100 translate-y-0"
x-transition:leave-end="opacity-0 scale-95 -translate-y-1"
class="absolute right-0 top-12 w-72 bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden z-50">
<div class="px-4 py-3 border-b border-slate-100 flex items-center justify-between">
<span class="text-sm font-semibold text-slate-800">Notificaciones</span>
<button x-show="unread > 0" @click="markAll()" class="text-xs text-slate-400 hover:text-slate-600">Marcar todas leídas</button>
</div>
<div class="max-h-64 overflow-y-auto divide-y divide-slate-100">
<template x-if="notifs.length === 0">
<div class="px-4 py-6 text-center text-slate-400 text-sm">Sin notificaciones</div>
</template>
<template x-for="n in notifs" :key="n.ID">
<a :href="n.url || '#'" @click="if(!n.leida) markRead(n.ID)"
class="flex items-start gap-3 px-4 py-3 hover:bg-slate-50 transition-colors"
:class="!n.leida ? 'bg-emerald-50/50' : ''">
<span class="text-lg leading-none mt-0.5" x-text="n.icono || '🔔'"></span>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-slate-800 truncate" x-text="n.titulo"></p>
<p class="text-xs text-slate-500 truncate mt-0.5" x-text="n.cuerpo"></p>
</div>
<div x-show="!n.leida" class="w-2 h-2 rounded-full flex-shrink-0 mt-1.5" style="background:#8eb02f"></div>
</a>
</template>
</div>
</div>
</div>
<!-- User name -->
<span class="text-sm text-slate-600 hidden sm:block">
{{ if .portalUser }}{{ .portalUser.Nombre }}{{ end }}
</span>
<!-- Mi cuenta -->
<div x-data="miCuenta()" x-init="init()">
<button @click="open=true"
class="inline-flex items-center gap-1.5 text-sm text-slate-600 hover:text-[#8eb02f] font-medium transition-colors focus:outline-none">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="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>
<span class="hidden sm:inline">Mi cuenta</span>
</button>
<!-- Slide panel -->
<div x-show="open" x-cloak class="fixed inset-0 z-50 flex justify-end">
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/40" @click="open=false"></div>
<!-- Panel -->
<div class="relative w-full max-w-md bg-white h-full shadow-2xl flex flex-col overflow-hidden"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="translate-x-full">
<!-- Header panel -->
<div class="flex items-center justify-between px-5 py-4 border-b border-slate-200 flex-shrink-0">
<h2 class="text-base font-semibold text-slate-800">Mi cuenta</h2>
<button @click="open=false" class="p-1.5 rounded-lg hover:bg-slate-100 transition-colors text-slate-400 hover:text-slate-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<!-- Tabs -->
<div class="flex border-b border-slate-200 px-5 gap-4 flex-shrink-0">
<button @click="tab='perfil'" class="py-3 text-sm font-medium border-b-2 -mb-px transition-colors"
:class="tab==='perfil' ? 'border-[#8eb02f] text-[#8eb02f]' : 'border-transparent text-slate-500 hover:text-slate-700'">
Información
</button>
<button @click="tab='password'" class="py-3 text-sm font-medium border-b-2 -mb-px transition-colors"
:class="tab==='password' ? 'border-[#8eb02f] text-[#8eb02f]' : 'border-transparent text-slate-500 hover:text-slate-700'">
Contraseña
</button>
<button @click="tab='telegram'" class="py-3 text-sm font-medium border-b-2 -mb-px transition-colors"
:class="tab==='telegram' ? 'border-[#8eb02f] text-[#8eb02f]' : 'border-transparent text-slate-500 hover:text-slate-700'">
Telegram
</button>
</div>
<!-- Body scrollable -->
<div class="flex-1 overflow-y-auto px-5 py-5">
<!-- Tab: Información personal -->
<div x-show="tab==='perfil'" class="space-y-4">
<p x-show="perfilMsg" x-text="perfilMsg"
:class="perfilOk ? 'text-green-600 bg-green-50 border border-green-200' : 'text-red-600 bg-red-50 border border-red-200'"
class="text-sm rounded-lg px-3 py-2"></p>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Nombre completo *</label>
<input x-model="perfil.nombre" type="text" placeholder="Tu nombre"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#8eb02f] focus:ring-2 focus:ring-[#8eb02f]/20">
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Correo electrónico *</label>
<input x-model="perfil.email" type="email" placeholder="correo@ejemplo.com"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#8eb02f] focus:ring-2 focus:ring-[#8eb02f]/20">
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Teléfono</label>
<div class="flex gap-2">
<select x-model="perfil.indicativo"
class="border border-slate-200 rounded-lg px-2 py-2 text-sm focus:outline-none focus:border-[#8eb02f] focus:ring-2 focus:ring-[#8eb02f]/20 w-32 flex-shrink-0">
<option value="">Indicativo</option>
<option value="+57">🇨🇴 +57</option>
<option value="+1">🇺🇸 +1</option>
<option value="+52">🇲🇽 +52</option>
<option value="+34">🇪🇸 +34</option>
<option value="+54">🇦🇷 +54</option>
<option value="+51">🇵🇪 +51</option>
<option value="+56">🇨🇱 +56</option>
<option value="+58">🇻🇪 +58</option>
<option value="+593">🇪🇨 +593</option>
<option value="+502">🇬🇹 +502</option>
<option value="+506">🇨🇷 +506</option>
<option value="+507">🇵🇦 +507</option>
<option value="+503">🇸🇻 +503</option>
<option value="+504">🇭🇳 +504</option>
<option value="+505">🇳🇮 +505</option>
<option value="+591">🇧🇴 +591</option>
<option value="+595">🇵🇾 +595</option>
<option value="+598">🇺🇾 +598</option>
<option value="+55">🇧🇷 +55</option>
<option value="+44">🇬🇧 +44</option>
<option value="+33">🇫🇷 +33</option>
<option value="+49">🇩🇪 +49</option>
<option value="+39">🇮🇹 +39</option>
<option value="+351">🇵🇹 +351</option>
<option value="+other">Otro</option>
</select>
<input x-model="perfil.telefono" type="tel" placeholder="Número de teléfono"
class="flex-1 border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#8eb02f] focus:ring-2 focus:ring-[#8eb02f]/20">
</div>
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">País</label>
<input x-model="perfil.pais" type="text" placeholder="Ej: Colombia"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#8eb02f] focus:ring-2 focus:ring-[#8eb02f]/20">
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Ciudad</label>
<input x-model="perfil.ciudad" type="text" placeholder="Ej: Bogotá"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#8eb02f] focus:ring-2 focus:ring-[#8eb02f]/20">
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">RUT / Documento legal</label>
<input x-model="perfil.documento" type="text" placeholder="Ej: NIT, RUT, CC, CUIT..."
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#8eb02f] focus:ring-2 focus:ring-[#8eb02f]/20">
<p class="text-xs text-slate-400 mt-1">Documento legal de tu país (NIT, RUT, Cédula, CUIT, RFC, etc.)</p>
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Empresa / Organización</label>
<input x-model="perfil.empresa" type="text" placeholder="Nombre de tu empresa"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#8eb02f] focus:ring-2 focus:ring-[#8eb02f]/20">
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Sitio Web</label>
<input x-model="perfil.sitio_web" type="url" placeholder="https://ejemplo.com"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#8eb02f] focus:ring-2 focus:ring-[#8eb02f]/20">
<p class="text-xs text-slate-400 mt-1">URL de tu sitio web (opcional).</p>
</div>
<!-- Documento RUT (archivo) -->
<div class="border border-slate-200 rounded-lg p-3 bg-slate-50">
<label class="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Documento RUT / Cámara de comercio (archivo)</label>
<div x-show="perfil.documento_rut_nombre" class="flex items-center gap-2 mb-2 text-sm text-slate-600">
<svg class="w-4 h-4 text-green-500 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span x-text="perfil.documento_rut_nombre" class="truncate"></span>
</div>
<p x-show="rutMsg" x-text="rutMsg"
:class="rutOk ? 'text-green-600' : 'text-red-600'"
class="text-xs mb-2"></p>
<input type="file" id="rutFileInput" accept=".pdf,.jpg,.jpeg,.png,.webp"
@change="subirRut($event)"
class="block w-full text-sm text-slate-500 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-xs file:font-semibold file:bg-[#8eb02f]/10 file:text-[#6d8c24] hover:file:bg-[#8eb02f]/20 cursor-pointer">
<p class="text-xs text-slate-400 mt-1">PDF, JPG o PNG · Máx. 10 MB</p>
</div>
</div>
<!-- Tab: Cambiar contraseña -->
<div x-show="tab==='password'" class="space-y-4">
<p x-show="passMsg" x-text="passMsg"
:class="passOk ? 'text-green-600 bg-green-50 border border-green-200' : 'text-red-600 bg-red-50 border border-red-200'"
class="text-sm rounded-lg px-3 py-2"></p>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Contraseña actual *</label>
<input x-model="pass.actual" type="password" placeholder="Tu contraseña actual"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#8eb02f] focus:ring-2 focus:ring-[#8eb02f]/20">
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Nueva contraseña *</label>
<input x-model="pass.nueva" type="password" placeholder="Mínimo 8 caracteres"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#8eb02f] focus:ring-2 focus:ring-[#8eb02f]/20">
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Confirmar nueva contraseña *</label>
<input x-model="pass.confirma" type="password" placeholder="Repite la nueva contraseña"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#8eb02f] focus:ring-2 focus:ring-[#8eb02f]/20">
</div>
</div>
<!-- Tab: Telegram -->
<div x-show="tab==='telegram'" class="space-y-4">
<!-- Estado actual -->
<div x-show="tg.linked" class="flex items-center gap-3 p-3 bg-green-50 border border-green-200 rounded-lg">
<svg class="w-5 h-5 text-green-500 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<div>
<p class="text-sm font-semibold text-green-800">Telegram vinculado</p>
<p class="text-xs text-green-600">Chat ID: <span class="font-mono" x-text="tg.chat_id"></span></p>
</div>
</div>
<div x-show="!tg.linked" class="flex items-center gap-3 p-3 bg-slate-50 border border-slate-200 rounded-lg">
<svg class="w-5 h-5 text-slate-400 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<p class="text-sm text-slate-500">Aún no has vinculado Telegram. Sigue los pasos para recibir notificaciones.</p>
</div>
<!-- Paso a paso vinculación -->
<div x-show="!tg.linked" class="space-y-4">
<div class="border border-slate-200 rounded-xl p-4 space-y-3">
<h3 class="text-sm font-semibold text-slate-700">Cómo vincular Telegram</h3>
<!-- Paso 1 -->
<div class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-[#8eb02f] text-white flex items-center justify-center text-xs font-bold">1</span>
<div class="flex-1">
<p class="text-sm text-slate-700 font-medium">Genera tu código</p>
<p class="text-xs text-slate-500 mb-2">Haz clic en el botón para obtener un código único de vinculación.</p>
<button @click="tgGenerarCodigo()" :disabled="tg.generando"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-[#8eb02f] hover:bg-[#6d8c24] text-white text-xs font-semibold rounded-lg transition-colors disabled:opacity-60">
<span x-show="!tg.generando">Generar código</span>
<span x-show="tg.generando">Generando…</span>
</button>
</div>
</div>
<!-- Código generado -->
<div x-show="tg.token" class="ml-9 p-3 bg-slate-800 rounded-lg">
<p class="text-xs text-slate-400 mb-1">Tu código de vinculación:</p>
<p class="text-2xl font-mono font-bold text-white tracking-widest" x-text="tg.token"></p>
</div>
<!-- Paso 2 -->
<div x-show="tg.token" class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-[#8eb02f] text-white flex items-center justify-center text-xs font-bold">2</span>
<div class="flex-1">
<p class="text-sm text-slate-700 font-medium">Envía el código al bot</p>
<p class="text-xs text-slate-500 mb-1">Abre el bot en Telegram y envía el siguiente mensaje:</p>
<div class="flex items-center gap-2 p-2 bg-slate-100 rounded-lg">
<code class="text-sm font-mono text-slate-800" x-text="'/vincular ' + tg.token"></code>
<button @click="navigator.clipboard?.writeText('/vincular '+tg.token)"
class="ml-auto text-slate-400 hover:text-slate-600 text-xs" title="Copiar">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</button>
</div>
<template x-if="tg.bot_username">
<a :href="tg.bot_link" target="_blank"
class="inline-flex items-center gap-1 mt-2 text-xs font-semibold text-[#8eb02f] hover:underline">
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.372 0 0 5.372 0 12s5.372 12 12 12 12-5.372 12-12S18.628 0 12 0zm5.894 8.221l-1.97 9.28c-.145.658-.537.818-1.084.508l-3-2.21-1.447 1.394c-.16.16-.295.295-.605.295l.213-3.053 5.56-5.023c.242-.213-.054-.333-.373-.12l-6.871 4.326-2.962-.924c-.643-.204-.657-.643.136-.953l11.57-4.461c.537-.194 1.006.131.833.941z"/></svg>
Abrir @<span x-text="tg.bot_username"></span>
</a>
</template>
</div>
</div>
<!-- Paso 3 -->
<div x-show="tg.token" class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-[#8eb02f] text-white flex items-center justify-center text-xs font-bold">3</span>
<div class="flex-1">
<p class="text-sm text-slate-700 font-medium">Verifica la vinculación</p>
<p class="text-xs text-slate-500 mb-2">Después de enviar el mensaje, haz clic en verificar.</p>
<button @click="tgVerificar()" :disabled="tg.verificando"
class="inline-flex items-center gap-1.5 px-3 py-1.5 border border-[#8eb02f] text-[#8eb02f] text-xs font-semibold rounded-lg transition-colors hover:bg-[#8eb02f]/10 disabled:opacity-60">
<span x-show="!tg.verificando">Verificar vinculación</span>
<span x-show="tg.verificando">Verificando…</span>
</button>
<p x-show="tg.verMsg" x-text="tg.verMsg"
:class="tg.verOk ? 'text-green-600' : 'text-red-500'"
class="text-xs mt-2"></p>
</div>
</div>
</div>
</div>
</div>
</div><!-- /body -->
<!-- Footer con botón guardar -->
<div class="flex-shrink-0 px-5 py-4 border-t border-slate-200 bg-slate-50">
<template x-if="tab==='perfil'">
<button @click="guardarPerfil()" :disabled="saving"
class="w-full bg-[#8eb02f] hover:bg-[#6d8c24] text-white font-semibold py-2.5 rounded-lg text-sm transition-colors disabled:opacity-60">
<span x-show="!saving">Guardar cambios</span>
<span x-show="saving">Guardando…</span>
</button>
</template>
<template x-if="tab==='password'">
<button @click="cambiarPassword()" :disabled="saving"
class="w-full bg-[#8eb02f] hover:bg-[#6d8c24] text-white font-semibold py-2.5 rounded-lg text-sm transition-colors disabled:opacity-60">
<span x-show="!saving">Cambiar contraseña</span>
<span x-show="saving">Procesando…</span>
</button>
</template>
<template x-if="tab==='telegram'">
<p class="text-xs text-slate-400 text-center">Las notificaciones de Telegram son enviadas por nuestro bot.</p>
</template>
</div>
</div><!-- /panel -->
</div><!-- /slide panel -->
</div><!-- /x-data miCuenta -->
<a href="/portal/logout" class="text-sm text-red-500 hover:text-red-700 font-medium transition-colors">
Cerrar sesión
</a>
</div>
</div>
</header>
<!-- Content -->
<main class="flex-1 max-w-6xl mx-auto w-full px-4 sm:px-6 py-8">
{{embed}}
</main>
<!-- Footer -->
<footer class="border-t border-slate-200 py-4 text-center text-xs text-slate-400">
Powered by U-site &mdash; &copy; 2025
</footer>
<script>
function miCuenta() {
return {
open: false,
tab: 'perfil',
saving: false,
perfil: { nombre:'', email:'', telefono:'', indicativo:'', pais:'', ciudad:'', documento:'', empresa:'', sitio_web:'', documento_rut_file:'', documento_rut_nombre:'' },
perfilMsg: '', perfilOk: false,
pass: { actual:'', nueva:'', confirma:'' },
passMsg: '', passOk: false,
rutMsg: '', rutOk: false,
tg: { linked: false, chat_id: '', token: '', bot_username: '', bot_link: '', generando: false, verificando: false, verMsg: '', verOk: false },
async init() {
try {
const r = await axios.get('/portal/mi-perfil');
this.perfil = r.data;
} catch(e) {}
try {
const s = await axios.get('/portal/mi-perfil/telegram-status');
this.tg.linked = s.data.linked;
this.tg.chat_id = s.data.chat_id;
} catch(e) {}
},
async guardarPerfil() {
this.perfilMsg = '';
if (!this.perfil.nombre.trim()) { this.perfilMsg = 'El nombre es requerido.'; this.perfilOk = false; return; }
if (!this.perfil.email.trim()) { this.perfilMsg = 'El email es requerido.'; this.perfilOk = false; return; }
this.saving = true;
try {
await axios.put('/portal/mi-perfil', this.perfil);
this.perfilMsg = '✅ Información actualizada correctamente.';
this.perfilOk = true;
} catch(e) {
this.perfilMsg = e.response?.data?.error || 'Error al guardar.';
this.perfilOk = false;
} finally { this.saving = false; }
},
async cambiarPassword() {
this.passMsg = '';
if (!this.pass.actual) { this.passMsg = 'Ingresa tu contraseña actual.'; this.passOk = false; return; }
if (this.pass.nueva.length < 8) { this.passMsg = 'La nueva contraseña debe tener al menos 8 caracteres.'; this.passOk = false; return; }
if (this.pass.nueva !== this.pass.confirma) { this.passMsg = 'Las contraseñas no coinciden.'; this.passOk = false; return; }
this.saving = true;
try {
await axios.put('/portal/mi-perfil/password', this.pass);
this.passMsg = '✅ Contraseña actualizada correctamente.';
this.passOk = true;
this.pass = { actual:'', nueva:'', confirma:'' };
} catch(e) {
this.passMsg = e.response?.data?.error || 'Error al cambiar contraseña.';
this.passOk = false;
} finally { this.saving = false; }
},
async subirRut(event) {
const file = event.target.files[0];
if (!file) return;
this.rutMsg = '';
const fd = new FormData();
fd.append('rut_file', file);
try {
const r = await axios.post('/portal/mi-perfil/rut-file', fd, { headers: { 'Content-Type': 'multipart/form-data' } });
this.perfil.documento_rut_nombre = r.data.nombre;
this.rutMsg = '✅ Archivo subido correctamente.';
this.rutOk = true;
} catch(e) {
this.rutMsg = e.response?.data?.error || 'Error al subir archivo.';
this.rutOk = false;
}
event.target.value = '';
},
async tgGenerarCodigo() {
this.tg.generando = true;
this.tg.token = '';
this.tg.verMsg = '';
try {
const r = await axios.post('/portal/mi-perfil/telegram-init');
this.tg.token = r.data.token;
this.tg.bot_username = r.data.bot_username || '';
this.tg.bot_link = r.data.bot_link || '';
} catch(e) {
this.tg.verMsg = 'No se pudo generar el código. Intenta de nuevo.';
this.tg.verOk = false;
} finally { this.tg.generando = false; }
},
async tgVerificar() {
this.tg.verificando = true;
this.tg.verMsg = '';
try {
const r = await axios.post('/portal/mi-perfil/telegram-validar');
if (r.data.ok) {
this.tg.linked = true;
this.tg.chat_id = r.data.chat_id;
this.tg.token = '';
this.tg.verMsg = '✅ ¡Telegram vinculado correctamente!';
this.tg.verOk = true;
} else {
this.tg.verMsg = r.data.error || 'No se encontró el mensaje. Envía /vincular ' + this.tg.token + ' al bot.';
this.tg.verOk = false;
}
} catch(e) {
this.tg.verMsg = e.response?.data?.error || 'Error al verificar. Intenta de nuevo.';
this.tg.verOk = false;
} finally { this.tg.verificando = false; }
},
}
}
</script>
</body>
</html>