1/* 2 * Portal privado Current. 3 * 4 * Esta capa NO define un diseno nuevo: materializa en HTML estatico el shell 5 * observable de las plantillas header_privado.tpl/footer_privado.tpl de Alpha 6 * 26 y conecta ese contrato con el motor Go. Las paginas de cada paso solo 7 * aportan su contenido. 8 */ 9(function () { 10 "use strict"; 11 12 var LANGS = ["es", "en", "fr"]; 13 // Encargo vigente (2026-09-05): mismo recorrido en ES/EN/FR. Las paginas 14 // montan su contenido DESPUES de start/checkuser; una cookie de otra persona 15 // no debe decidir el idioma del cuerpo antes de conocer la sesion. 16 var ACTIVE_LANGS = ["es", "en", "fr"]; 17 var IDS = { es: 1, en: 2, fr: 3 }; 18 var NAMES = { es: "Español", en: "English", fr: "Français" }; 19 var ROUTES = { 20 0: "/resumen.html", 21 1: "/rueda-de-la-vida.html", 22 2: "/que-quiero-en-mi-vida.html", 23 3: "/que-quiero-cambiar.html" 24 }; 25 // Sin prefijo de idioma en la ruta: cada dominio sirve SU lengua en la raiz. 26 // Con `/fr/...` cada clic se comia un 301 (.fr/fr/... -> .fr/...), y con 27 // `/en/` la portada inglesa daba otro. Medido antes de quitarlo. 28 var PUBLIC_ROUTES = { 29 es: { home: "/", help: "/preguntas-frecuentes.html", privacy: "/politica-de-privacidad.html", legal: "/aviso-legal.html" }, 30 en: { home: "/", help: "/frequently-asked-questions.html", privacy: "/privacy-policy.html", legal: "/legal-notice.html" }, 31 fr: { home: "/", help: "/questions-frequentes.html", privacy: "/politique-de-confidentialite.html", legal: "/mentions-legales.html" } 32 }; 33 var COPY = { 34 es: { 35 skip: "Saltar al contenido", summary: "Resumen", wheel: "Cómo es mi vida", 36 wants: "Qué quiero en mi vida", changes: "Qué quiero cambiar", account: "Cuenta", 37 myAccount: "Mi cuenta", report: "Informe de bienestar", admin: "Administración", 38 logout: "Salir", openAccount: "Abrir menú de cuenta", nav: "Recorrido de Happiness Play", 39 help: "Ayuda", privacy: "Privacidad", legal: "Aviso legal", more: "Más", 40 languages: "Cambiar idioma", needWheel: "Guarda tu Rueda de la Vida y podrás empezar a seleccionar lo que quieres en la vida.", 41 needCondition: "Primero selecciona qué quieres en tu vida. Después podrás convertirlo en cambios concretos.", unsaved: "Hay cambios sin guardar. ¿Quieres salir de esta pantalla?", 42 cancel: "Cancelar", confirm: "Confirmar", tourNext: "Siguiente", 43 tourBack: "Anterior", tourSkip: "Salir de la guÃa", tourAction: "Pulsa el elemento señalado para continuar", 44 guideTitle: "GuÃa paso a paso", activateGuide: "Activar guÃa paso a paso", leaveGuide: "Salir de la guÃa", guideFinish: "Finalizar", 45 guideSaveFailed: "No se ha podido guardar tu avance. Inténtalo de nuevo.", 46 protocolError: "La respuesta del servidor no es válida.", copied: "Copiado al portapapeles.", 47 shareFailed: "No se pudo compartir ni copiar.", p1_comparte_fecha: "Fecha de esta rueda: {fecha}.", 48 p2_subir: "Subir", p2_bajar: "Bajar", p2_mover_a: "Mover a", 49 p2_factor_no_disponible: "Esa opción ya no está disponible. Vuelve a elegirla en el catálogo.", 50 p3_compartido: "Cambio compartido.", p3_fecha_pasada: "La fecha del cambio no puede ser anterior a hoy.", 51 p3_descartar_q: "¿Descartar este cambio?", p3_descartar_p: "Lo que has escrito no se guardará.", p3_seguir: "Seguir editando", 52 p3_mas_cambios: "Más cambios", p3_tour_areas_t: "Tus ocho áreas", p3_tour_areas_p: "Elige un área para ver sus condiciones y sus cambios.", 53 p3_tour_cond_t: "Elige una condición", p3_tour_cond_p: "Pulsa una condición para convertirla en una acción concreta.", 54 p3_paso_elegir: "Paso 1 de 2", p3_paso_plan: "Paso 2 de 2", 55 p3_crear_simple: "Crea un cambio", p3_escribir_propio: "Escribir mi propio cambio", 56 p3_plan_t: "Ponle fecha a tu cambio", p3_elegir_p: "Elige un cambio del catálogo que te ayude a conseguir lo que quieres:", 57 p3_escribir_intro: "Escribe con tus palabras el cambio que te ayude a conseguir lo que quieres:", 58 p3_catalogo: "Elegir del catálogo", p3_busca_cambio: "Busca un cambio", 59 p3_busca_ej: "Ej.: descansar mejor, ahorrar, pasar tiempo en familia", 60 p3_busca_vacio: "Escribe una acción o añade un detalle, por ejemplo: «ahorrar» o «dormir mejor».", 61 p3_reintentar: "Volver a intentar", p3_cargando: "Buscando propuestas para tiâ¦", 62 p3_rama_cerrar: "Cerrar opciones: ", p3_rama_abrir: "Ver opciones: ", 63 p3_tu_cambio: "Tu cambio", p3_cambiar_accion: "Cambiar la acción", 64 p3_plan_p: "Elige una fecha que te resulte posible. Podrás ajustarla más adelante.", 65 p3_celebrar_opcional: "¿Cómo te gustarÃa celebrarlo? (Opcional)", p3_continuar: "Continuar", 66 p3_fecha_necesaria: "Elige una fecha para tu cambio.", 67 p3_tour_editor_t: "Define tu cambio", p3_tour_editor_p: "Primero elige o escribe una acción. Después podrás ponerle fecha y, si quieres, una forma de celebrarla.", 68 p3_tour_lista_t: "Tus cambios en marcha", p3_tour_lista_p: "Aquà puedes revisarlos, compartirlos y marcarlos como cumplidos.", 69 offline: "No hay conexión a internet. Comprueba la conexión y vuelve a intentarlo.", 70 offlineSaved: "Sin conexión. Este es tu último informe, guardado en este tel
70éfono el {fecha}.", 71 offlineNone: "Sin conexión. Cuando vuelvas a tener conexión, abre tu Informe de Bienestar Personal una vez y podrás verlo también sin internet.", 72 offlineWheel: "Sin conexión puedes hacer tu Rueda de la Vida: se guarda en este teléfono y se sube a tu cuenta, con su fecha, en cuanto vuelva la conexión.", 73 offlineWheelBtn: "Hacer mi Rueda de la Vida" 74 }, 75 en: { 76 skip: "Skip to content", summary: "Summary", wheel: "How my life is", 77 wants: "What I want in my life", changes: "What I want to change", account: "Account", 78 myAccount: "My account", report: "Wellbeing report", admin: "Administration", 79 logout: "Sign out", openAccount: "Open account menu", nav: "Happiness Play journey", 80 help: "Help", privacy: "Privacy", legal: "Legal notice", more: "More", 81 languages: "Change language", needWheel: "Save your Wheel of Life to start choosing what you want in life.", 82 needCondition: "First choose what you want in life. Then you can turn it into concrete changes.", unsaved: "There are unsaved changes. Do you want to leave this page?", 83 cancel: "Cancel", confirm: "Confirm", tourNext: "Next", 84 tourBack: "Back", tourSkip: "Leave the guide", tourAction: "Select the highlighted item to continue", 85 guideTitle: "Step-by-step guide", activateGuide: "Start the step-by-step guide", leaveGuide: "Leave the guide", guideFinish: "Finish", 86 guideSaveFailed: "Your progress could not be saved. Please try again.", 87 protocolError: "The server response is invalid.", copied: "Copied to the clipboard.", 88 shareFailed: "It could not be shared or copied.", p1_comparte_fecha: "Date of this wheel: {fecha}.", 89 p2_subir: "Move up", p2_bajar: "Move down", p2_mover_a: "Move to", 90 p2_factor_no_disponible: "That option is no longer available. Choose it again from the catalogue.", 91 p3_compartido: "Change shared.", p3_fecha_pasada: "The change date cannot be earlier than today.", 92 p3_descartar_q: "Discard this change?", p3_descartar_p: "What you have written will not be saved.", p3_seguir: "Keep editing", 93 p3_mas_cambios: "More changes", p3_tour_areas_t: "Your eight areas", p3_tour_areas_p: "Choose an area to see its conditions and changes.", 94 p3_tour_cond_t: "Choose a condition", p3_tour_cond_p: "Select a condition to turn it into a concrete action.", 95 p3_paso_elegir: "Step 1 of 2", p3_paso_plan: "Step 2 of 2", 96 p3_crear_simple: "Create a change", p3_escribir_propio: "Write my own change", 97 p3_plan_t: "Set a date for your change", p3_elegir_p: "Choose a change from the catalogue to help you achieve what you want:", 98 p3_escribir_intro: "Write in your own words the change that will help you achieve what you want:", 99 p3_catalogo: "Choose from the catalogue", p3_busca_cambio: "Find a change", 100 p3_busca_ej: "E.g. sleep better, save money, spend time with family", 101 p3_busca_vacio: "Describe an action or add a detail, for example: âsave moneyâ or âsleep betterâ.", 102 p3_reintentar: "Try again", p3_cargando: "Finding suggestions for youâ¦", 103 p3_rama_cerrar: "Hide options: ", p3_rama_abrir: "Show options: ", 104 p3_tu_cambio: "Your change", p3_cambiar_accion: "Change the action", 105 p3_plan_p: "Choose a date that feels achievable. You can adjust it later.", 106 p3_celebrar_opcional: "How would you like to celebrate? (Optional)", p3_continuar: "Continue", 107 p3_fecha_necesaria: "Choose a date for your change.", 108 p3_tour_editor_t: "Define your change", p3_tour_editor_p: "First choose or write an action. Then set a date and, if you like, a way to celebrate it.", 109 p3_tour_lista_t: "Your active changes", p3_tour_lista_p: "Here you can review, share and mark them as completed.", 110 offline: "There is no internet connection. Check your connection and try again.", 111 offlineSaved: "No connection. This is your latest report, saved on this phone on {fecha}.", 112 offlineNone: "No connection. When you are back online, open your Personal Wellbeing Report once and you will be able to view it offline too.", 113 offlineWheel: "Offline, you can still do your Wheel of Life: it is saved on this phone and uploaded to your account, with its date, as soon as you are back online.", 114 offlineWheelBtn: "Do my Wheel of Life" 115 }, 116 fr: { 117 skip: "Aller au contenu", summary: "Résumé", wheel: "Comment est ma vie", 118 wants: "Ce que je veux dans ma vie", changes: "Ce que je veux changer", account: "Compte", 119 myAccount: "Mon compte", report: "Rapport de bien-être", admin: "Administration", 120 logout: "Se déconnecter", openAccount: "Ouvrir le menu du compte", nav: "Parcours Happiness Play", 121 help: "Aide", privacy: "Confidentialité", legal: "Mentions légales", more: "Plus", 122 languages: "Changer de langue", needWheel: "Enregistrez votre Roue de la Vie pour commencer à choisir ce que vous voulez dans la vie.", 123 needCondition: "Commencez par choisir ce que vous voulez dans la vie. Vous pourrez ensuite le transformer en changements concrets.", unsaved: "Des modifications ne sont pas enregistrées. Voulez-vous quitter cette page ?", 124 cancel: "Annuler", confirm: "Confirmer", tourNext: "Suivant", 125 tourBack: "Précédent", tourSkip: "Quitter le guide", tourAction: "Sélectionnez lâélément indiqué pour continuer", 126 guideTitle: "Guide pas à pas", activateGuide: "Activer le guide pas à pas", leaveGuide: "Quitter le guide", guideFinish: "Terminer", 127 guideSaveFailed: "La progression nâa pas pu être enregistrée. Réessayez.", 128 protocolError: "La réponse du serveur nâest pas valide.",
128copied: "Copié dans le presse-papiers.", 129 shareFailed: "Impossible de partager ou de copier.", p1_comparte_fecha: "Date de cette roue : {fecha}.", 130 p2_subir: "Monter", p2_bajar: "Descendre", p2_mover_a: "Déplacer vers", 131 p2_factor_no_disponible: "Cette option nâest plus disponible. Choisissez-la à nouveau dans le catalogue.", 132 p3_compartido: "Changement partagé.", p3_fecha_pasada: "La date du changement ne peut pas être antérieure à aujourdâhui.", 133 p3_descartar_q: "Abandonner ce changement ?", p3_descartar_p: "Ce que vous avez écrit ne sera pas enregistré.", p3_seguir: "Continuer à modifier", 134 p3_mas_cambios: "Plus de changements", p3_tour_areas_t: "Vos huit domaines", p3_tour_areas_p: "Choisissez un domaine pour voir ses conditions et ses changements.", 135 p3_tour_cond_t: "Choisissez une condition", p3_tour_cond_p: "Sélectionnez une condition pour la transformer en action concrète.", 136 p3_paso_elegir: "Ãtape 1 sur 2", p3_paso_plan: "Ãtape 2 sur 2", 137 p3_crear_simple: "Créez un changement", p3_escribir_propio: "Ãcrire mon propre changement", 138 p3_plan_t: "Fixez une date pour votre changement", p3_elegir_p: "Choisissez un changement dans le catalogue pour vous aider à obtenir ce que vous souhaitez :", 139 p3_escribir_intro: "Ãcrivez avec vos mots le changement qui vous aidera à obtenir ce que vous souhaitez :", 140 p3_catalogo: "Choisir dans le catalogue", p3_busca_cambio: "Recherchez un changement", 141 p3_busca_ej: "Ex. : mieux dormir, épargner, passer du temps en famille", 142 p3_busca_vacio: "Décrivez une action ou ajoutez un détail, par exemple : « économiser » ou « mieux dormir ».", 143 p3_reintentar: "Réessayer", p3_cargando: "Recherche de propositions pour vousâ¦", 144 p3_rama_cerrar: "Masquer les options : ", p3_rama_abrir: "Voir les options : ", 145 p3_tu_cambio: "Votre changement", p3_cambiar_accion: "Modifier lâaction", 146 p3_plan_p: "Choisissez une date qui vous semble réaliste. Vous pourrez la modifier plus tard.", 147 p3_celebrar_opcional: "Comment aimeriez-vous le célébrer ? (Facultatif)", p3_continuar: "Continuer", 148 p3_fecha_necesaria: "Choisissez une date pour votre changement.", 149 p3_tour_editor_t: "Définissez votre changement", p3_tour_editor_p: "Choisissez ou écrivez dâabord une action. Fixez ensuite une date et, si vous le souhaitez, une façon de la célébrer.", 150 p3_tour_lista_t: "Vos changements en cours", p3_tour_lista_p: "Vous pouvez ici les revoir, les partager et les marquer comme accomplis.", 151 offline: "Il nây a pas de connexion internet. Vérifiez votre connexion et réessayez.", 152 offlineSaved: "Pas de connexion. Voici votre dernier rapport, enregistré sur ce téléphone le {fecha}.", 153 offlineNone: "Pas de connexion. Quand vous aurez de nouveau une connexion, ouvrez une fois votre Rapport de bien-être personnel : vous pourrez ensuite le consulter hors ligne.", 154 offlineWheel: "Hors connexion, vous pouvez faire votre Roue de la Vie : elle est enregistrée sur ce téléphone et envoyée sur votre compte, avec sa date, dès que la connexion revient.", 155 offlineWheelBtn: "Faire ma Roue de la Vie" 156 } 157 }; 158 var ICONS = { 159 summary: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.6" y="3.6" width="7" height="7" rx="1.9"/><rect x="13.4" y="3.6" width="7" height="7" rx="1.9"/><rect x="3.6" y="13.4" width="7" height="7" rx="1.9"/><rect x="13.4" y="13.4" width="7" height="7" rx="1.9"/></svg>', 160 wheel: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8.6"/><path d="M12 3.4v17.2M3.4 12h17.2M5.9 5.9l12.2 12.2M18.1 5.9L5.9 18.1"/></svg>', 161 wants: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8.6"/><circle cx="12" cy="12" r="4.4"/><circle cx="12" cy="12" r="1.15" fill="currentColor" stroke="none"/></svg>', 162 changes: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3.6 19.8h4.3v-4.3h4.3v-4.3h4.3V6.9h3.9"/><path d="M17.2 3.6l3.2 3.3-3.2 3.3"/></svg>', 163 account: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8.4" r="3.8"/><path d="M4.7 20.1c0-3.6 3.3-6 7.3-6s7.3 2.4 7.3 6"/></svg>', 164 lock: '<svg class="hp-nav2-badge" viewBox="0 0 24 24" aria-hidden="true"><rect x="4.8" y="10.4" width="14.4" height="9.6" rx="2.2"/><path d="M8.2 10.4V7.6a3.8 3.8 0 0 1 7.6 0v2.8"/></svg>' 165 }; 166 167 function escapeHTML(value) { 168 return String(value == null ? "" : value).replace(/[&<>"']/g, function (ch) { 169 return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch]; 170 }); 171 } 172 173 // El idioma del usuario autenticado, tal y como lo devuelve checkuser. Lo 174 // fija `start` en cuanto sabe quien entra, y vale "" mientras no hay sesion. 175 var idiomaDeLaPersona = ""; 176 177 // EL DOMINIO MANDA: la consulta ya no decide el idioma. Se conserva la 178 // funcion para no tocar a quien la llama, pero no mira la URL. 179 function askedLanguage() { 180 return ""; 181 } 182 183 // EL DOMINIO MANDA (Pedro, 07/09/2026). Cada lengua en su sitio, y la URL no 184 // puede mentir sobre lo que se esta viendo. Cada familia de dominios lleva 185 // sus tres; `redirige` dice si ya se puede mandar ahi a alguien. 186 var FAMILIAS = [ 187 // redirige: FALSE. El desvio automatico entre dominios expulsa a la 188 // persona: las cookies son por dominio, asi que al pasar de .es a .com 189 // se pierden el pase Y la sesion, y quien estaba dentro aparece en el 190 // cartel de invitacion. Medido en produccion antes de dejarlo puesto. 191 // La regla «manda el dominio» se mantiene; lo que no se puede es mover 192 // a nadie de dominio mientras la sesion no cruce. 193 { nombre: "happinessplay", redirige: false, hosts: { 194 es: "www.happinessplay.es", en: "www.happinessplay.com", fr: "www.happinessplay.fr" }, 195 idiomas: { 196 "happinessplay.es": "es", "www.happinessplay.es": "es", 197 "happinessplay.com": "en", "www.happinessplay.com": "en", 198 "happinessplay.fr": "fr", "www.happinessplay.fr": "fr" } }, 199 // La beta. Los subdominios existen en el DNS (hay comodin) pero hasta que 200 // tengan certificado y bloque propio NO se manda ahi a nadie: se veria un 201 // aviso de certificado. Cuando esten, `redirige` pasa a true. 202 { nombre: "gpsdevida", redirige: false, hosts: { 203 es: "gpsdevida.com", en: "en.gpsdevida.com", fr: "fr.gpsdevida.com" }, 204 idiomas: { 205 "gpsdevida.com": "es", "www.gpsdevida.com": "es", "es.gpsdevida.com": "es", 206 "en.gpsdevida.com": "en", "fr.gpsdevida.com": "fr" } } 207 ]; 208
209 function familiaDelHost(host) { 210 host = String(host || location.hostname).toLowerCase(); 211 for (var i = 0; i < FAMILIAS.length; i++) { 212 if (FAMILIAS[i].idiomas[host]) return FAMILIAS[i]; 213 } 214 return null; 215 } 216 217 function requestedLanguage() { 218 if (ACTIVE_LANGS.length === 1) return ACTIVE_LANGS[0]; 219 // NO se mira el ?lang=, ni la cookie, ni el idioma de la persona: eso 220 // permitia estar en .es leyendo en ingles. Y como el host se sabe nada mas 221 // cargar --sin esperar a checkuser-- cabecera y cuerpo ya no pueden salir 222 // en idiomas distintos, que era el fallo que se perseguia pagina por pagina. 223 var f = familiaDelHost(); 224 var host = String(location.hostname).toLowerCase(); 225 return (f && f.idiomas[host]) || "es"; 226 } 227 228 function protocolError() { 229 var copy = COPY[requestedLanguage()] || COPY.es; 230 return copy.protocolError; 231 } 232 233 function body(data, csrf) { 234 var b = new URLSearchParams({ data: JSON.stringify(data || {}) }); 235 if (csrf) b.set("csrf", csrf); 236 return b; 237 } 238 239 async function post(url, data, csrf) { 240 var response = await fetch(url, { 241 method: "POST", credentials: "same-origin", 242 headers: { "Content-Type": "application/x-www-form-urlencoded" }, 243 body: body(data, csrf) 244 }); 245 var text = await response.text(); 246 try { return JSON.parse(text); } 247 catch (_) { return { errno: 2, errstr: protocolError(), data: null }; } 248 } 249 250 async function op(name, data, csrf) { 251 var b = body(data, csrf); 252 b.set("op", name); 253 var response = await fetch("/dispatcher", { 254 method: "POST", credentials: "same-origin", 255 headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: b 256 }); 257 var text = await response.text(); 258 try { return JSON.parse(text); } 259 catch (_) { return { errno: 2, errstr: protocolError(), data: null }; } 260 } 261 262 function failed(value) { return !value || Number(value.errno) !== 0; } 263 264 /* LA APP DE ANDROID SIN CONEXION (02/10/2026). Pedro: «La app tiene que ser capaz de abrirse sin Internet y 265 capaz de ver tu último informe».
266 - La app entra por app.html con utm_medium=app. acceso.html y registro-felicidad.html lo apuntan en 267 sessionStorage («hp-app»), que dura lo que la pestaña de la app, porque al entrar se pasa a rutas limpias. 268 - Solo en la app se registra el service worker (/sw.js) y se guarda en el teléfono la copia del último 269 informe («hp-ultimo-informe», la escribe informe-bienestar-personal.html): en un ordenador compartido no 270 debe quedar nada. La copia se borra al salir, al pedir la baja y en cuanto entra otra persona. 271 - El service worker no puede leer localStorage: la caché «hp-datos» le dice si hay copia, para que su 272 pantalla «sin conexión» no ofrezca un informe que no existe. */ 273 var COPIA_INFORME = "hp-ultimo-informe"; 274 var CACHE_DATOS = "hp-datos"; 275 var MARCA_COPIA = "/__hp/informe-guardado"; 276 var INFORME = "/informe-bienestar-personal.html"; 277 278 function esApp() { 279 try { 280 if (sessionStorage.getItem("hp-app") === "1") return true; 281 var desdeApp = new URLSearchParams(location.search).get("utm_medium") === "app" || 282 String(document.referrer).indexOf("android-app://es.happinessplay") === 0 || 283 Boolean(window.matchMedia && window.matchMedia("(display-mode: standalone)").matches); 284 if (desdeApp) sessionStorage.setItem("hp-app", "1"); 285 return desdeApp; 286 } catch (_) { return false; } 287 } 288 289 function registraServiceWorker() { 290 if (!("serviceWorker" in navigator) || !esApp()) return; 291 navigator.serviceWorker.register("/sw.js").catch(function () { /* sin él la app sigue igual con conexión */ }); 292 } 293 294 function marcaCopia(hay) { 295 try { 296 if (!window.caches) return; 297 (hay ? caches.open(CACHE_DATOS).then(function (c) { return c.put(MARCA_COPIA, new Response("1")); }) 298 : caches.delete(CACHE_DATOS)).catch(function () {}); 299 } catch (_) { /* caché no disponible */ } 300 } 301 302 // Del usuario solo se guarda lo que pintan la cabecera y el informe. 303 function guardaInforme(datos) { 304 var u = (window.HPPortal && window.HPPortal.user) || {}; 305 if (!esApp() || !u.id_usuario || window.HPPortal.offline) return false; 306 var usuario = {}; 307 ["id_usuario", "id_cliente", "nombre", "apellidos", "mail", "email", "num_ruedas", "paso_guiado", 308 "cliente_logo", "codigo_idioma"].forEach(function (k) { if (u[k] != null) usuario[k] = u[k]; }); 309 try { 310 localStorage.setItem(COPIA_INFORME, JSON.stringify({ v: 1, guardado: new Date().toISOString(), 311 usuario: usuario, datos: datos })); 312 } catch (_) { return false; } 313 marcaCopia(true); 314 return true; 315 } 316 317 function informeGuardado() { 318 try { 319 var copia = JSON.parse(localStorage.getItem(COPIA_INFORME) || "null"); 320 return copia && copia.v === 1 && copia.usuario && copia.usuario.id_usuario && copia.datos ? copia : null; 321 } catch (_) { return null; } 322 } 323 324 function borraInforme() { 325 try { localStorage.removeItem(COPIA_INFORME); } catch (_) { /* nada que borrar */ } 326 marcaCopia(false); 327 } 328 329 /* LA RUEDA SIN CONEXION (Pedro, 02/10/2026: «Y si no hay conexión, debe dejar al menos hacer la rueda de la 330 vida»). Se hace en la página pública de cada idioma, que sw.js guarda para abrirse sin red; js/rueda-pendiente.js 331 la deja en el teléfono y, al volver la conexión, start() la sube a la cuenta con su fecha. */ 332 var RUEDA_SIN_CONEXION = { es: "/tu-rueda-de-la-vida.html", en: "/your-wheel-of-life.html", fr: "/votre-roue-de-la-vie.html" }; 333 var RUEDA_PENDIENTE = "hp-rueda-pendiente"; 334 335 // Sin conexión y sin copia: un aviso en el sitio del contenido, con la rueda y su botón para volver a probar. 336 function pintaSinConexion(texto, lang) { 337 var copy = COPY[lang] || COPY.es; 338 var main = document.querySelector("main") || document.body; 339 var boton = "display:flex;align-items:center;justify-content:center;width:100%;min-height:48px;margin-top:.6rem;" + 340 "padding:.6rem 1.4rem;border-radius:.75rem;font:inherit;font-weight:750;text-decoration:none;cursor:pointer;"; 341 // La rueda solo en la app: fuera de ella no hay service worker que guarde la página para abrirla sin red. 342 var rueda = esApp() ? '<p style="margin:0 0 .4rem">' + escapeHTML(copy.offlineWheel) + '</p><a data-hp-rueda href="' + 343 (RUEDA_SIN_CONEXION[lang] || RUEDA_SIN_CONEXION.es) + '" style="' + boton + 'border:0;background:#075ea8;color:#fff">' + 344 escapeHTML(copy.offlineWheelBtn) + '</a>' : ''; 345 main.removeAttribute("v-cloak"); 346 main.innerHTML = '<section role="status" style="max-width:34rem;margin:3rem auto;padding:1.4rem;background:#fff;' + 347 'border:1px solid #dce6ef;border-radius:1rem;text-align:center"><p style="margin:0 0 1rem">' + escapeHTML(texto) + 348 '</p>' + rueda + '<button type="button" data-hp-reintentar style="' + boton + (rueda 349 ? 'border:1px solid #9bbbd7;background:#fff;color:#075ea8">' : 'border:0;background:#075ea8;color:#fff">') + 350 escapeHTML(copy.p3_reintentar) + '</button></section>'; 351 main.querySelector("[data-hp-reintentar]").addEventListener("click", function () { location.reload(); }); 352 } 353 354 // Con sesión y conexión: la rueda hecha sin red (o la que trae el APK en #hp-rueda=â¦) sube a la cuenta con su 355 // fecha. El módulo solo se carga si hay algo que subir. 356 function cargaRuedaPendiente() { 357 var hay = String(location.hash).indexOf("hp-rueda=") >= 0; 358 try { hay = hay || localStorage.getItem(RUEDA_PENDIENTE) !== null; } catch (_) { /* sin almacenamiento */ } 359 if (!hay) return Promise.resolve(null); 360 if (window.HP_ruedaPendiente) return Promise.resolve(window.HP_ruedaPendiente); 361 return new Promise(function (listo) { 362 var s = document.createElement("script"); 363 s.src = "/js/rueda-pendiente.js?v=20261002a"; 364 s.onload = function () { listo(window.HP_ruedaPendiente || null); }; 365 s.onerror = function () { listo(null); }; 366 document.head.appendChild(s); 367 }); 368 } 369 370 async function subeRuedaPendiente(user) { 371 try { 372 var modulo = await cargaRuedaPendiente(); 373 if (!modulo) return null; 374 // Sin respuesta en 8 s, la página sigue: la rueda queda en el teléfono y se sube en la siguiente. 375 return await Promise.race([modulo.sube(op, user, user.csrf), 376 new Promise(function (listo) { setTimeout(function () { listo(null); }, 8000); })]); 377 } catch (_) { return null; } 378 } 379 380 // El arranque sin conexión: la cabecera con lo guardado y, como página, solo el informe. 381 async function arrancaSinConexion(copia, active, lang) { 382 if (location.pathname !== INFORME) { 383 location.replace(route(INFORME, lang)); 384 throw new Error("sin conexion: al informe guardado"); 385 } 386 try { await loadDictionary(lang); } catch (_) { /* sin diccionario: los respaldos de COPY y de cada página */ } 387 var user = Object.assign({}, copia.usuario, { logged: 1, csrf: "", has_avatar: 0, is: {}, 388 onboarding_estado: "unguided", onboarding_guiado: 0 }); 389 window.HP_CSRF = ""; 390 window.HPPortal.lang = lang; window.HPPortal.langID = IDS[lang]; window.HPPortal.user = user; 391 window.HPPortal.offline = copia; 392 renderShell(user, active, lang); 393 return user; 394 } 395 396 function ensureOnboardingStyle() { 397 if (document.querySelector('link[data-hp-onboarding-style]')) return; 398 var link = document.createElement("link"); 399 link.rel = "stylesheet"; 400 link.href = "/assets/onboarding.css?v=20260910d"; 401 link.setAttribute("data-hp-onboarding-style", ""); 402 document.head.appendChild(link); 403 } 404 405 async function loadDictionary(lang) { 406 document.documentElement.lang = lang; 407 var old = document.querySelector("script[data-hp-dictionary]"); 408 if (old) old.remove(); 409 await new Promise(function (resolve, reject) { 410 var script = document.createElement("script"); 411 script.src = "/js/idioma-" + lang + ".js?v=20260919a"; 412 script.dataset.hpDictionary = lang; 413 script.onload = resolve; 414 script.onerror = function () { reject(new Error("No se pudo cargar el idioma " + lang + ".")); }; 415 document.head.appendChild(script); 416 }); 417 return window.HP_L || {}; 418 } 419 420 function dictionary(key, fallback) { 421 var value = (window.HP_L || {})[key]; 422 if (typeof value === "string" && value.length) return value; 423 value = (COPY[requestedLanguage()] || COPY.es)[key]; 424 return typeof value === "string" && value.length ? value : (fallback || ""); 425 } 426 427 function route(path, lang) { 428 // El idioma ya no viaja en la URL: lo dice el dominio. Se sigue aceptando 429 // el argumento `lang` para no tocar las 30 llamadas que lo pasan, pero se 430 // ignora a proposito. Si se quiere cambiar de idioma, se cambia de dominio. 431 void lang; 432 var q = new URLSearchParams(location.search); 433 q.delete("idioma"); q.delete("lang"); 434 var query = q.toString(); 435 return path + (query ? "?" + query : ""); 436 } 437 438 function publicRoute(kind, lang) { 439 var routes = PUBLIC_ROUTES[lang] || PUBLIC_ROUTES.es; 440 return routes[kind] || routes.home; 441 } 442 443 function publicContextRoute(kind, lang) { 444 var target = new URL(publicRoute(kind, lang), location.origin); 445 target.searchParams.set("portal", "1"); 446 // El idioma NO viaja en la URL: lo dice el dominio. Estos tres enlaces 447 // --ayuda, privacidad, aviso legal-- eran los ultimos que lo llevaban. 448 void lang; 449 return target.pathname + "?" + target.searchParams.toString(); 450 } 451 452 function legacyCopy(text) { 453 var field = document.createElement("textarea"); 454 field.value = text; field.setAttribute("readonly", ""); 455 field.style.position = "fixed"; field.style.opacity = "0"; 456 document.body.appendChild(field); field.select(); 457 var copied = document.execCommand("copy"); field.remove(); 458 if (!copied) throw new Error("copy"); 459 } 460 461 async function copyText(text) { 462 if (navigator.clipboard && navigator.clipboard.writeText) { 463 await navigator.clipboard.writeText(text); 464 } else { 465 legacyCopy(text); 466 } 467 return "copied"; 468 } 469 470 /* Windows anuncia Web Share en escritorio aunque el selector del sistema 471 pueda no cargar ningun destino. En escritorio la accion predecible es 472 copiar; en movil se conserva el selector nativo y, si falla, se copia. */ 473 async function shareText(text) {
474 var value = String(text || "").trim(); 475 if (!value) throw new Error("empty share"); 476 var mobile = innerWidth <= 820 || (window.matchMedia && window.matchMedia("(pointer: coarse)").matches); 477 if (mobile && navigator.share) { 478 try { await navigator.share({ text: value }); return "shared"; } 479 catch (error) { if (error && error.name === "AbortError") return "cancelled"; } 480 } 481 return copyText(value); 482 } 483 484 function navItem(step, key, label, current, enabled, reason, lang) { 485 if (!enabled) { 486 return '<span class="hp-nav2-it bloq" role="link" tabindex="0" aria-disabled="true" title="' + 487 escapeHTML(reason) + '">' + ICONS[key] + '<span>' + escapeHTML(label) + "</span>" + ICONS.lock + "</span>"; 488 } 489 return '<a class="hp-nav2-it' + (current === step ? " activa" : "") + '" href="' + route(ROUTES[step], lang) + '"' + 490 (current === step ? ' aria-current="page"' : "") + ">" + ICONS[key] + "<span>" + escapeHTML(label) + "</span></a>"; 491 } 492 493 function languageSelector(lang, copy) { 494 // FUERA del portal interno (Pedro, 07/09/2026). El idioma es de la 495 // persona y se cambia en Mi cuenta. Teniendolo tambien aqui habia dos 496 // sitios donde decidirlo: las pastillas metian ?lang= en la URL, la URL 497 // ganaba a la persona, y la pagina acababa pidiendo el catalogo en un 498 // idioma distinto al de su propia interfaz. 499 return ""; 500 /* eslint-disable no-unreachable */ 501 if (ACTIVE_LANGS.length < 2) return ""; 502 return '<nav class="hp-language-pills" aria-label="' + escapeHTML(copy.languages) + '">' + ACTIVE_LANGS.map(function (code) { 503 return '<a href="' + route(location.pathname, code) + '" data-hp-language="' + code + '" lang="' + code + '"' + 504 (code === lang ? ' class="act" aria-current="page"' : "") + ">" + NAMES[code] + "</a>"; 505 }).join("") + "</nav>"; 506 } 507 508 function rememberLanguage(lang) { 509 document.cookie = "hp_idioma=" + lang + ";path=/;max-age=31536000;samesite=lax"; 510 // La persistencia de la preferencia no es la navegacion. Si la red tarda o 511 // falla, el enlace real del pie debe seguir cambiando de pagina e idioma. 512 var request = body({ idioma: lang }); 513 request.set("op", "setidioma"); 514 fetch("/dispatcher", { 515 method: "POST", credentials: "same-origin", keepalive: true, 516 headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: request 517 }).catch(function () {}); 518 } 519 520 function accountNavItem(step, label, current, enabled, reason, lang) { 521 if (!enabled) { 522 return '<span class="hp-account-nav-link bloq" role="link" aria-disabled="true" title="' + 523 escapeHTML(reason) + '">' + escapeHTML(label) + "</span>"; 524 } 525 return '<a class="hp-account-nav-link' + (current === step ? " activa" : "") + '" href="' + 526 route(ROUTES[step], lang) + '"' + (current === step ? ' aria-current="page"' : "") + ">" + 527 escapeHTML(label) + "</a>"; 528 } 529 530 // Qué pasos están abiertos. Lo decide el motor (checkuser: num_ruedas y paso_guiado); aquà solo se lee. 531 function pasosAbiertos(user) { 532 var wheels = Number(user.num_ruedas || 0); 533 var guided = Number(user.paso_guiado || 0); 534 return { summary: wheels > 0 && guided === 0, wants: wheels > 0 && (!guided || guided >= 2), 535 changes: wheels > 0 && (!guided || guided >= 3), guided: guided }; 536 } 537 538 function pasosNav(user, active, lang, copy) { 539 var p = pasosAbiertos(user); 540 return navItem(0, "summary", copy.summary, active, p.summary, p.guided ? copy.needCondition : copy.needWheel, lang) + 541 navItem(1, "wheel", copy.wheel, active, true, "", lang) + 542 navItem(2, "wants", copy.wants, active, p.wants, copy.needWheel, lang) + 543 navItem(3, "changes", copy.changes, active, p.changes, p.guided ? copy.needCondition : copy.needWheel, lang); 544 } 545 546 function pasosCuenta(user, active, lang, copy) { 547 var p = pasosAbiertos(user); 548 return accountNavItem(0, copy.summary, active, p.summary, p.guided ? copy.needCondition : copy.needWheel, lang) + 549 accountNavItem(1, copy.wheel, active, true, "", lang) + 550 accountNavItem(2, copy.wants, active, p.wants, copy.needWheel, lang) + 551 accountNavItem(3, copy.changes, active, p.changes, p.guided ? copy.needCondition : copy.needWheel, lang); 552 } 553 554 // La página de la cabecera pintada; refrescaPasos la necesita para volver a pintar sus pasos. 555 var cabecera = null; 556 557 /* LOS PASOS SE ABREN AL GUARDAR (Pedro, 02/10/2026, en la app: «no me deja pasar al paso 2»). La cabecera se 558 pinta una vez, con el checkuser del arranque. Al guardar la primera rueda, la primera condición o el primer 559 cambio, el motor abre el paso siguiente, pero el candado de arriba seguÃa puesto hasta cambiar de página, y 560 al tocarlo decÃa «Guarda tu Rueda de la Vida» con la rueda ya guardada. Ca
560da paso llama aquà después de 561 guardar: con los datos de checkuser si ya los tiene (la rueda los pide al recargar) o sin ellos, y entonces 562 se piden, salvo que no quede ningún paso cerrado. Solo se vuelve a pintar si algo se ha abierto o cerrado. */ 563 function refrescaPasos(datos) { 564 var portal = window.HPPortal; 565 var user = portal.user || {}; 566 var mount = document.querySelector("[data-hp-private-header]"); 567 if (!cabecera || !mount || portal.offline) return Promise.resolve(false); 568 if (!datos && Number(user.num_ruedas) > 0 && !Number(user.paso_guiado)) return Promise.resolve(false); 569 var pide = datos ? Promise.resolve({ errno: 0, data: datos }) : op("checkuser", {}); 570 return pide.then(function (j) { 571 if (failed(j) || !j.data || Number(j.data.logged) !== 1) return false; 572 var antes = JSON.stringify(pasosAbiertos(user)); 573 user.num_ruedas = j.data.num_ruedas; 574 user.paso_guiado = j.data.paso_guiado; 575 if (JSON.stringify(pasosAbiertos(user)) === antes) return false; 576 var copy = COPY[portal.lang] || COPY.es; 577 var pasos = mount.querySelector(".hp-nav2-steps"); 578 var cuenta = mount.querySelector(".hp-account-mobile-nav"); 579 if (pasos) pasos.innerHTML = pasosNav(user, cabecera.activa, portal.lang, copy); 580 if (cuenta) cuenta.innerHTML = pasosCuenta(user, cabecera.activa, portal.lang, copy); 581 return true; 582 }).catch(function () { return false; }); 583 } 584
585 function renderShell(user, active, lang) { 586 var copy = COPY[lang] || COPY.es; 587 var mount = document.querySelector("[data-hp-private-header]"); 588 cabecera = { activa: active }; 589 var rights = user.is || {}; 590 var canAdmin = Number(rights.RESELLERBASIC) === 1 || Number(rights.RESELLERPREMIUM) === 1 || Number(rights.SUPERADMIN) === 1; 591 var display = user.nombre || user.mail || user.email || ""; 592 var mail = user.mail || user.email || ""; 593 var avatar = Number(user.has_avatar) === 1 594 ? '<img src="/mi-avatar.html?v=' + encodeURIComponent(user.avatar_v || "") + '" alt="" width="34" height="34">' 595 : ICONS.account; 596 /* Alpha 26 usa el logotipo del cliente como fondo del enlace y reserva 597 icono-felicidad.png para el tramo compacto (992-1231 px). No se usa una 598 marca inventada: el nombre viene de checkuser y solo se acepta un 599 nombre de fichero, nunca una ruta. */ 600 var logo = /^[A-Za-z0-9._-]+$/.test(user.cliente_logo || "") ? user.cliente_logo : ""; 601 var brandStyle = logo ? ' style="background-image:url("/images/ico/' + escapeHTML(logo) + '")"' : ""; 602 mount.innerHTML = '<a class="hp-saltar" href="#hp-contenido">' + escapeHTML(copy.skip) + '</a>' + 603 '<nav class="nav profile_head_nav"><div class="container hp-nav-fila">' + 604 '<a class="brand hp-nav-brand" href="' + publicRoute("home", lang) + '"' + brandStyle + '><img class="hp-brand-compact" src="/images/ico/icono-felicidad.png" alt="Happiness Play" width="48" height="48"><span>Happiness Play</span></a>' + 605 '<div class="hp-nav2"><div class="hp-nav2-steps">' + 606 pasosNav(user, active, lang, copy) + 607 '</div></div><div class="hp-account"><button type="button" class="hp-nav2-it hp-nav2-cuenta' + (active === "account" ? " activa" : "") + '"' + (active === "account" ? ' aria-current="page"' : '') + ' aria-haspopup="true" aria-expanded="false" aria-controls="hp-account-menu" title="' + escapeHTML(copy.openAccount) + '">' + avatar + '<span>' + escapeHTML(copy.account) + '</span></button>' + 608 '<div class="hp-account-menu" id="hp-account-menu" hidden><div class="hp-account-id"><strong>' + escapeHTML(display) + '</strong>' + 609 (display && mail && display !== mail ? "<span>" + escapeHTML(mail) + "</span>" : "") + '</div>' + 610 '<nav class="hp-account-mobile-nav" aria-label="' + escapeHTML(copy.nav) + '">' + 611 pasosCuenta(user, active, lang, copy) + '</nav>' + 612 '<a href="' + route("/mi-cuenta.html", lang) + '">' + escapeHTML(copy.myAccount) + '</a>' + 613 '<a href="' + route("/informe-bienestar-personal.html", lang) + '">' + escapeHTML(copy.report) + '</a>' + 614 (canAdmin ? '<a href="/admin/">' + escapeHTML(copy.admin) + '</a>' : "") + 615 '<button type="button" data-hp-logout>' + escapeHTML(copy.logout) + "</button></div></div></div></nav>"; 616 617 var footer = document.querySelector("[data-hp-private-footer]"); 618 var helpURL = publicContextRoute("help", lang); 619 var privacyURL = publicContextRoute("privacy", lang); 620 var legalURL = publicContextRoute("legal", lang); 621 footer.innerHTML = '<footer class="footer hpf-mini"><div class="in"><span class="copy">© 2026 Happiness Play</span>' + 622 '<nav class="links" aria-label="' + escapeHTML(copy.help) + '"><a href="' + helpURL + '">' + escapeHTML(copy.help) + '</a><a href="' + privacyURL + '">' + escapeHTML(copy.privacy) + '</a><a href="' + legalURL + '">' + escapeHTML(copy.legal) + '</a></nav>' + 623 languageSelector(lang, copy) + '<details class="more"><summary>' + escapeHTML(copy.more) + '</summary><div class="menu"><a href="' + helpURL + '">' + escapeHTML(copy.help) + '</a><a href="' + privacyURL + '">' + escapeHTML(copy.privacy) + '</a><a href="' + legalURL + '">' + escapeHTML(copy.legal) + '</a>' + languageSelector(lang, copy) + "</div></details></div></footer>"; 624 625 if (!document.getElementById("hp-confirm-dialog")) { 626 document.body.insertAdjacentHTML("beforeend", '<dialog class="hp-dialog" id="hp-confirm-dialog"><div class="hp-dialog__body"><h2></h2><p></p><div class="hp-dialog__actions"><button type="button" class="hp-button hp-button--secondary" data-hp-confirm-no></button><button type="button" class="hp-button hp-button--danger" data-hp-confirm-yes></button></div></div></dialog>'); 627 } 628 629 var accountButton = mount.querySelector(".hp-nav2-cuenta"); 630 var accountMenu = mount.querySelector(".hp-account-menu"); 631 accountButton.addEventListener("click", function () { 632 accountMenu.hidden = !accountMenu.hidden; 633 accountButton.setAttribute("aria-expanded", accountMenu.hidden ? "false" : "true"); 634 }); 635 document.addEventListener("click", function (event) { 636 if (!event.target.closest(".hp-account") && !accountMenu.hidden) { 637 accountMenu.hidden = true; accountButton.setAttribute("aria-expanded", "false"); 638 } 639 }); 640 document.addEventListener("keydown", function (event) { 641 if (event.key === "Escape" && !accountMenu.hidden) { 642 accountMenu.hidden = true; accountButton.setAttribute("aria-expanded", "false"); accountButton.focus(); 643 } 644 }); 645 mount.querySelector("[data-hp-logout]").addEventListener("click", async function () { 646 manualGuide(user, false); 647 // Al salir no queda en el teléfono la copia del informe, tampoco si no hay conexión para cerrar la sesión. 648 borraInforme();
649 try { await op("dologout", { csrf: user.csrf || "" }, user.csrf || ""); } 650 catch (_) { /* sin conexión */ } 651 location.assign(route("/acceso.html", lang)); 652 }); 653 footer.querySelectorAll("[data-hp-language]").forEach(function (link) { 654 // No se cancela el enlace: la URL cambia de inmediato aunque setidioma 655 // no conteste. La llamada solo recuerda la preferencia para otros usos. 656 link.addEventListener("click", function () { rememberLanguage(link.dataset.hpLanguage); }); 657 }); 658 } 659 660 /* El globo de ayuda (title/data-tip/data-hp-tip) es unico para toda la web y vive en 661 /assets/globo.js, con el diseno del globo de la rueda. Hasta el 19/09/2026 aqui habia 662 otro (installTooltips, azul) que ademas solo se instalaba al terminar start(): antes 663 de eso, y en las paginas sin portal.js, salia el gris nativo del navegador. */ 664 665 function confirmDialog(options) { 666 options = options || {}; 667 var copy = COPY[window.HPPortal.lang] || COPY.es; 668 return new Promise(function (resolve) { 669 var previous = document.activeElement; 670 var dialog = document.getElementById("hp-confirm-dialog"); 671 dialog.querySelector("h2").textContent = options.title || copy.confirm; 672 dialog.querySelector("p").textContent = options.message || ""; 673 var yes = dialog.querySelector("[data-hp-confirm-yes]"); 674 var no = dialog.querySelector("[data-hp-confirm-no]"); 675 yes.textContent = options.confirm || copy.confirm; no.textContent = options.cancel || copy.cancel; 676 function finish(value) { 677 yes.onclick = null; no.onclick = null; dialog.close(); 678 if (previous && typeof previous.focus === "function") previous.focus(); 679 resolve(value); 680 } 681 yes.onclick = function () { finish(true); }; no.onclick = function () { finish(false); }; 682 dialog.oncancel = function (event) { event.preventDefault(); finish(false); }; 683 dialog.showModal(); no.focus(); 684 }); 685 } 686 687 // MEMORIA POR GUIA. Habia UN solo interruptor --onboarding_estado-- para las 688 // TRES guias del recorrido, y solo la de Resumen lo apagaba (complete:true). 689 // Terminar la guia de la rueda no dejaba rastro fuera de la pestana: al 690 // volver a la pagina, checkuser seguia diciendo "guided_active" y arrancaba 691 // otra vez en 1/3. Aqui se apunta, por persona y por guia, cual se termino. 692 // Va en localStorage y no en sessionStorage porque el recuerdo tiene que 693 // sobrevivir a cerrar la pestana, no solo a cambiar de pagina. 694 function guiaAlmacen() { 695 var user = (window.HPPortal && window.HPPortal.user) || {}; 696 // La identidad se toma de lo primero que haya. No se exigen id_cliente Y 697 // id_usuario --como hace manualGuide-- porque si checkuser no los trajera, 698 // el recuerdo no se escribiria NUNCA y el arreglo seria mudo: pareceria 699 // aplicado y la guia seguiria saliendo. 700 var quien = user.id_usuario || user.mail || user.email || ""; 701 if (!quien) return ""; 702 return "hp-guias-hechas:" + (user.id_cliente || "0") + ":" + quien; 703 } 704 function guiaHecha(id, hecha) { 705 var almacen = guiaAlmacen(); 706 if (!almacen || !id) return false; 707 try { 708 var lista = (localStorage.getItem(almacen) || "").split("|").filter(Boolean); 709 if (hecha === true && lista.indexOf(id) < 0) { 710 lista.push(id); 711 localStorage.setItem(almacen, lista.join("|")); 712 } 713 return lista.indexOf(id) >= 0; 714 } catch (_) { return false; } 715 } 716 717 function setOnboardingPreference(estado) { 718 // El olvido va AQUI y no en activateTutorial porque este es el unico punto 719 // por el que pasan TODAS las reactivaciones: la del boton de la cuenta --que 720 // puede llamar a HPPortal.setOnboarding en vez de a activateTutorial, las dos 721 // estan publicadas-- y el respaldo de start() para las altas "unseen". Si el 722 // recuerdo sobreviviera a una reactivacion, la persona pediria la guia y no 723 // veria NINGUNA. 724 if (estado === "guided_active") { 725 var almacen = guiaAlmacen(); 726 if (almacen) { try { localStorage.removeItem(almacen); } catch (_) {} } 727 } 728 return op("setonboardingpreference", { estado: estado }, window.HPPortal.user.csrf || ""); 729 } 730 731 async function activateTutorial() { 732 var result = await setOnboardingPreference("guided_active"); 733 if (failed(result)) return false;
734 manualGuide(window.HPPortal.user, true); 735 location.assign(route("/rueda-de-la-vida.html", window.HPPortal.lang)); 736 return true; 737 } 738 739 // Current: ayuda compacta anclada al elemento que explica. No usa modal, 740 // sombreado ni posicion fija: la rueda sigue siendo la pantalla principal. 741 var activeGuide = null; 742 function inlineGuide(options) { 743 if (activeGuide) activeGuide.close(); 744 options = options || {}; 745 var steps = (options.steps || []).filter(function (step) { return step && step.selector; }); 746 // Cada pagina tiene SU guia. La identifica su ruta: no lleva prefijo de 747 // idioma --el idioma lo dice el dominio-- asi que no hay que tocar ninguna 748 // de las tres llamadas a HPPortal.tour que ya existen. 749 var guiaID = options.id || location.pathname; 750 if (!steps.length || window.HPPortal.user.onboarding_estado !== "guided_active" || guiaHecha(guiaID)) { 751 return { close: function () {} }; 752 } 753 var copy = COPY[window.HPPortal.lang] || COPY.es; 754 var main = document.querySelector("main"); 755 if (!main) return { close: function () {} }; 756 var old = document.querySelector(".hp-guide-band"); 757 if (old) old.remove(); 758 var band = document.createElement("section"); 759 band.className = "hp-guide-band"; 760 band.setAttribute("role", "region"); 761 band.setAttribute("aria-live", "polite"); 762 band.setAttribute("aria-label", copy.guideTitle); 763 band.innerHTML = '<div class="hp-guide-head"><span class="hp-guide-kicker"><span class="hp-guide-dots"></span><span class="hp-guide-count"></span></span>' + 764 '<button type="button" class="hp-guide-exit"></button></div><h2></h2><p></p>' + 765 '<div class="hp-guide-action" hidden></div><div class="hp-guide-buttons">' + 766 '<button type="button" class="hp-guide-back"></button><button type="button" class="hp-guide-next"></button></div>'; 767 var index = 0; 768 var current = null; 769 var actionHandler = null; 770 var closed = false; 771 var generation = 0; 772 var leaving = false; 773 var back = band.querySelector(".hp-guide-back"); 774 var next = band.querySelector(".hp-guide-next"); 775 var exitButton = band.querySelector(".hp-guide-exit"); 776 back.textContent = copy.tourBack; 777 next.textContent = copy.tourNext; 778 exitButton.textContent = copy.leaveGuide; 779 var dots = band.querySelector(".hp-guide-dots"); 780 dots.innerHTML = steps.map(function () { return "<i></i>"; }).join(""); 781 // LA TARJETA VA EN EL BODY, ANCLADA AL ELEMENTO (10/09/2026). Dentro del 782 // elemento salia abajo a la derecha, tapando la rueda y lejos del titulo. 783 // Ahora se coloca ENCIMA del elemento (o debajo si arriba no cabe), con 784 // flecha y un foco que atenua el resto de la pagina. La pagina sigue viva. 785 var spot = document.createElement("div"); 786 spot.className = "hp-guide-spot"; 787 spot.setAttribute("aria-hidden", "true"); 788 document.body.appendChild(spot); 789 document.body.appendChild(band); 790 var movil = function () { return window.matchMedia("(max-width:760px)").matches; }; 791 // La cabecera privada es fija: nada de la tarjeta puede quedar debajo. 792 function cabecera() { 793 var nav = document.querySelector(".profile_head_nav"); 794 var el = nav; 795 while (el && el !== document.body) { 796 var pos = getComputedStyle(el).position; 797 if (pos === "fixed" || pos === "sticky") return Math.max(0, el.getBoundingClientRect().bottom); 798 el = el.parentElement; 799 } 800 return 0; 801 } 802 // DONDE VA LA TARJETA (10/09/2026, segunda vuelta con Pedro). 803 // 804 // La primera version la ponia siempre ENCIMA del elemento y, si no cabia 805 // entre la cabecera y el, empujaba la pagina con un relleno. Con la rueda 806 // --el primer bloque de la pagina y el mas alto-- la tarjeta acababa 807 // pegada al borde superior, saliendose de la pantalla y tapando el titulo. 808 // Pedro: «parece que se salen mucho de la pantalla, ¿no es mejor meterlo 809 // dentro?». Y en el tercer paso, sobre la barra de guardar, «se ve 810 // perfecto». De ahi la regla: 811 // 812 // 1. Si el elemento es lo bastante grande para alojar la tarjeta (la 813 // rueda, el area de trabajo del paso 2, el tablero del 3), la 814 // tarjeta va DENTRO, arriba a la izquierda, sin flecha: el foco ya 815 // dice de que se habla y no se tapa nada de fuera. 816 // 2. Si es pequeno (la barra de guardar, el riel de areas, un boton), 817 // va encima si cabe y debajo si no. Nunca se empuja la pagina. 818 // 3. Pase lo que pase, la tarjeta queda entera dentro de la ventana y 819 // por debajo de la cabecera fija. 820 var DENTRO = 16;
820 // margen interior cuando la tarjeta va dentro del elemento 821 function cabeDentro(r, bw, bh) { 822 return r.width >= bw + DENTRO * 2 && r.height >= bh + DENTRO * 2 + 40; 823 } 824 function coloca() { 825 if (closed || !current) return; 826 var r = current.getBoundingClientRect(); 827 var sx = window.pageXOffset, sy = window.pageYOffset; 828 var pad = 8; 829 spot.style.top = (r.top + sy - pad) + "px"; 830 spot.style.left = (r.left + sx - pad) + "px"; 831 spot.style.width = (r.width + pad * 2) + "px"; 832 spot.style.height = (r.height + pad * 2) + "px"; 833 if (movil()) { band.removeAttribute("data-side"); band.style.top = ""; band.style.left = ""; return; } 834 var bw = band.offsetWidth, bh = band.offsetHeight, gap = 18; 835 var vw = document.documentElement.clientWidth, vh = window.innerHeight; 836 var cab = cabecera(); 837 var top, left, lado; 838 if (cabeDentro(r, bw, bh)) { 839 lado = "inside"; 840 top = r.top + sy + DENTRO; 841 left = r.left + sx + DENTRO; 842 // Si el borde superior del elemento queda fuera de la ventana (o bajo 843 // la cabecera), la tarjeta baja hasta lo primero que se ve. 844 top = Math.max(top, sy + cab + 12); 845 top = Math.min(top, r.bottom + sy - bh - DENTRO); 846 } else { 847 var arriba = r.top - bh - gap >= cab + 12; 848 var abajo = vh - r.bottom - bh - gap >= 12; 849 lado = arriba ? "top" : (abajo ? "bottom" : "top"); 850 top = lado === "top" ? r.top + sy - bh - gap : r.bottom + sy + gap; 851 left = r.left + sx + r.width / 2 - bw / 2; 852 var flecha = Math.max(24, Math.min(bw - 24, r.left + sx + r.width / 2 - left)); 853 band.style.setProperty("--hp-guide-arrow", flecha + "px"); 854 } 855 // Entera en la ventana, siempre. 856 top = Math.max(sy + cab + 12, Math.min(top, sy + vh - bh - 12)); 857 left = Math.max(sx + 12, Math.min(left, sx + vw - bw - 12)); 858 band.setAttribute("data-side", lado); 859 band.style.top = top + "px"; 860 band.style.left = left + "px"; 861 } 862 var pendiente = false; 863 function recoloca() { 864 if (pendiente) return; 865 pendiente = true; 866 requestAnimationFrame(function () { pendiente = false; coloca(); }); 867 } 868 window.addEventListener("resize", recoloca); 869 window.addEventListener("scroll", recoloca, true); 870 function detach() { 871 if (current) current.classList.remove("hp-guide-target", "hp-guide-anchor"); 872 if (current && actionHandler) current.removeEventListener("click", actionHandler, true); 873 current = null; 874 actionHandler = null; 875 } 876 function close() { 877 if (closed) return; 878 closed = true; 879 generation++; 880 detach(); 881 window.removeEventListener("resize", recoloca); 882 window.removeEventListener("scroll", recoloca, true); 883 spot.remove(); 884 band.remove(); 885 } 886 function find(selector, tries, turn) { 887 return new Promise(function (resolve) { 888 function check() { 889 if (closed || turn !== generation) { resolve(null); return; } 890 var hit = document.querySelector(selector); 891 if (hit || tries-- <= 0) resolve(hit); else setTimeout(check, 100); 892 } 893 check(); 894 }); 895 } 896 async function show(nextIndex) { 897 if (closed || leaving) return; 898 var turn = ++generation; 899 back.disabled = true; next.disabled = true; 900 if (nextIndex >= steps.length) { 901 if (options.complete) { 902 var completed; 903 try { completed = await setOnboardingPreference("guided_completed"); } 904 catch (_) { completed = { errno: 1 }; } 905 if (closed || turn !== generation) return; 906 if (failed(completed)) { 907 back.disabled = index === 0; next.disabled = false; 908 var message = band.querySelector(".hp-guide-action"); 909 message.hidden = false; 910 message.textContent = copy.guideSaveFailed; 911 return; 912 } 913 window.HPPortal.user.onboarding_estado = "guided_completed"; 914 manualGuide(window.HPPortal.user, false); 915 } 916 // Se ha llegado al final de ESTA guia. Se apunta aqui --y no en el 917 // camino de «no encuentro el elemento», que tambien cierra-- para que 918 // solo cuente como terminada la que se ha recorrido entera. 919 guiaHecha(guiaID, true); 920 close(); 921 return; 922 } 923 detach(); 924 index = Math.max(0, nextIndex); 925 var step = steps[index]; 926 if (typeof step.before === "function") await step.before(); 927 if (closed || turn !== generation) return; 928 var found = await find(step.selector, 20, turn); 929 if (closed || turn !== generation) return; 930 if (!found) { 931 if (index + 1 < steps.length) await show(index + 1); 932 else close(); // Sin elemento final no se certifica una guia completada. 933 return; 934 } 935 current = found; 936 current.classList.add("hp-guide-target"); 937 // Con puntos de progreso, en la tarjeta basta el «1 / 3»; el nombre de la 938 // guia va en el aria-label y no rompe la linea en dos. 939 band.querySelector(".hp-guide-count").textContent = (index + 1) + " / " + steps.length; 940 Array.prototype.forEach.call(dots.children, function (dot, i) { dot.classList.toggle("is-on", i === index); }); 941 band.classList.remove("is-on"); 942 band.querySelector("h2").textContent = step.title || ""; 943 band.querySelector("p").textContent = step.text || ""; 944 var action = band.querySelector(".hp-guide-action"); 945 action.hidden = !step.action;
946 action.textContent = step.action ? copy.tourAction : ""; 947 back.disabled = index === 0; 948 next.disabled = false; 949 next.hidden = !!step.action; 950 next.textContent = options.complete && index === steps.length - 1 ? copy.guideFinish : copy.tourNext; 951 // Se desplaza la pagina para que el elemento se vea: si la tarjeta va 952 // dentro, con el borde del elemento justo bajo la cabecera; si va 953 // encima, con sitio para ella. Ya no se empuja la pagina: si arriba no 954 // cabe, coloca() la pone debajo. 955 var suave = window.matchMedia("(prefers-reduced-motion:reduce)").matches ? "auto" : "smooth"; 956 if (!movil()) { 957 var rr = current.getBoundingClientRect(); 958 var hueco = cabeDentro(rr, band.offsetWidth, band.offsetHeight) ? 12 : (12 + band.offsetHeight + 18 + 6); 959 var deseado = rr.top + window.pageYOffset - (cabecera() + hueco); 960 try { window.scrollTo({ top: Math.max(0, deseado), behavior: suave }); } catch (_) { window.scrollTo(0, Math.max(0, deseado)); } 961 } else { 962 try { current.scrollIntoView({ block: "start", behavior: suave }); } catch (_) { current.scrollIntoView(); } 963 } 964 coloca(); 965 setTimeout(function () { if (!closed && turn === generation) { coloca(); band.classList.add("is-on"); band.querySelector("h2").focus && band.setAttribute("tabindex", "-1"); } }, 60); 966 setTimeout(function () { if (!closed && turn === generation) coloca(); }, 420); 967 if (step.action) { 968 actionHandler = function (event) { 969 if (band.contains(event.target)) return; 970 setTimeout(function () { if (!closed && turn === generation) show(index + 1); }, 80); 971 }; 972 current.addEventListener("click", actionHandler, true); 973 } 974 } 975 back.onclick = function () { show(index - 1); }; 976 next.onclick = function () { show(index + 1); }; 977 exitButton.onclick = async function () { 978 if (leaving || closed) return; 979 leaving = true; 980 var exitTurn = ++generation; 981 exitButton.disabled = true; 982 var result; 983 try { result = await setOnboardingPreference("unguided"); } 984 catch (_) { result = { errno: 1 }; } 985 if (closed || exitTurn !== generation) return; 986 if (!failed(result)) { 987 window.HPPortal.user.onboarding_estado = "unguided"; 988 manualGuide(window.HPPortal.user, false); 989 close(); 990 } else { 991 leaving = false; 992 exitButton.disabled = false; 993 show(index); 994 } 995 }; 996 show(0); 997 activeGuide = { close: close }; 998 return activeGuide; 999 } 1000 1001 async function start(active) { 1002 ensureOnboardingStyle(); 1003 var lang = requestedLanguage(); 1004 document.documentElement.lang = lang; 1005 if (document.body) document.body.dataset.hpLang = lang; 1006 // PRIMERO QUIEN ERES, DESPUES EN QUE IDIOMA. Antes el diccionario se pedia 1007 // a la vez que checkuser, asi que ya estaba cargado --con el idioma de la 1008 // cookie-- cuando llegaba el del usuario. Se pierde ese paralelismo a 1009 // proposito: cargar dos diccionarios deja etiquetas del idioma anterior. 1010 var check; 1011 try { 1012 check = await op("checkuser", {}); 1013 } catch (error) { 1014 // fetch no llega al servidor: sin conexión. Con copia, el último informe; sin ella, el aviso. 1015 var copia = informeGuardado(); 1016 if (copia) return arrancaSinConexion(copia, active, lang); 1017 pintaSinConexion((COPY[lang] || COPY.es)[esApp() ? "offlineNone" : "offline"], lang); 1018 throw error; 1019 } 1020 if (failed(check) || !check.data || Number(check.data.logged) !== 1) { 1021 location.replace(route("/acceso.html", lang)); 1022 throw new Error("Permission denied"); 1023 } 1024 // Entra otra persona en este navegador: fuera la copia del informe de la anterior. 1025 var previa = informeGuardado(); 1026 if (previa && String(previa.usuario.id_usuario) !== String(check.data.id_usuario)) borraInforme(); 1027 // ALTA MINIMA (Pedro, 19/09/2026): quien dio solo el correo y ya lo verifico pasa por la 1028 // ventana de registro completa antes de seguir en el portal. 1029 if (Number(check.data.registro_pendiente) === 1 && location.pathname.indexOf("/registro-felicidad.html") < 0) { 1030 location.replace("/registro-felicidad.html?completar=1"); 1031 throw new Error("registro pendiente"); 1032 } 1033 var suyo = String(check.data.codigo_idioma || "").toLowerCase().split("-")[0]; 1034 if (LANGS.indexOf(suyo) >= 0) idiomaDeLaPersona = suyo; 1035 lang = requestedLanguage(); 1036 document.documentElement.lang = lang; 1037 if (document.body) document.body.dataset.hpLang = lang; 1038 // A SU DOMINIO. El idioma de la pantalla lo dice el host, pero el de la 1039 // persona no se pierde: si no coinciden, se la lleva al suyo. Asi la URL y 1040 // lo que se lee no pueden discrepar, y nadie se queda leyendo un idioma que 1041 // no eligio. Solo dentro de la misma familia de dominios, y solo si esa 1042 // familia ya tiene los tres en pie. 1043 var fam = familiaDelHost(); 1044 if (fam && fam.redirige && LANGS.indexOf(suyo) >= 0 && suyo !== lang) { 1045 var destino = fam.hosts[suyo]; 1046 if (destino && destino !== String(location.hostname).toLowerCase()) { 1047 location.replace("https://" + destino + location.pathname + location.search); 1048 throw new Error("cambio de dominio por idioma"); 1049 } 1050 } 1051 // La cookie se sigue escribiendo para el cartel de invitacion, que es 1052 // publico y no tiene sesion. Ya no decide nada dentro del portal. 1053 document.cookie = "hp_idioma=" + lang + ";path=/;max-age=31536000;samesite=lax"; 1054 await loadDictionary(lang); 1055 // Antes que la página pida sus datos: asà ya ve la rueda subida (y una primera rueda abre los pasos). 1056 var subida = await subeRuedaPendiente(check.data); 1057 if (subida && subida.guardada) { 1058 var otra = await op("checkuser", {}).catch(function () { return null; }); 1059 if (!failed(otra) && otra.data && Number(otra.data.logged) === 1) check.data = otra.data; 1060 } 1061 window.HP_CSRF = check.data.csrf || ""; 1062 window.HPPortal.lang = lang; window.HPPortal.langID = IDS[lang]; window.HPPortal.user = check.data; 1063 if (window.HPAnalytics) HPAnalytics.ready(check.data, lang); 1064 // Una preferencia antigua no convierte a una persona con recorrido terminado 1065 // en un alta nueva. La reapertura voluntaria vive en esta pestaña y cuenta. 1066 var recorridoTerminado = Number(check.data.num_ruedas) > 1 || 1067 (Number(check.data.num_ruedas) > 0 && check.data.paso_guiado != null && 1068 Number(check.data.paso_guiado) === 0); 1069 if (recorridoTerminado && !manualGuide(check.data) && 1070 ["unseen", "guided_active"].indexOf(check.data.onboarding_estado) >= 0) { 1071 check.data.onboarding_estado = "unguided"; 1072 check.data.onboarding_guiado = 0; 1073 } 1074 if (check.data.onboarding_estado === "unseen") { 1075 var activated = await setOnboardingPreference("guided_active"); 1076 // El alta nueva ya nace guided_active. Este respaldo absorbe usuarios 1077 // creados por una candidata anterior sin interponer otra pantalla. 1078 if (!failed(activated)) { 1079 check.data.onboarding_estado = "guided_active"; 1080 check.data.onboarding_guiado = 1; 1081 } 1082 } 1083 renderShell(check.data, active, lang); 1084 if (subida && window.HP_ruedaPendiente) window.HP_ruedaPendiente.avisa(subida, lang); 1085 return check.data; 1086 } 1087 1088 function manualGuide(user, enabled) { 1089 if (!user.id_cliente || !user.id_usuario) return false;
1090 var key = "hp-guide-manual:" + user.id_cliente + ":" + user.id_usuario; 1091 try { 1092 if (enabled === true) sessionStorage.setItem(key, "1"); 1093 if (enabled === false) sessionStorage.removeItem(key); 1094 return sessionStorage.getItem(key) === "1"; 1095 } catch (_) { return false; } 1096 } 1097 1098 window.HPPortal = { 1099 lang: "es", langID: 1, user: {}, start: start, op: op, post: post, failed: failed, 1100 t: dictionary, route: route, escapeHTML: escapeHTML, confirm: confirmDialog, tour: inlineGuide, 1101 setOnboarding: setOnboardingPreference, activateTutorial: activateTutorial, refrescaPasos: refrescaPasos, 1102 language: requestedLanguage, copyText: copyText, shareText: shareText, 1103 // La app sin conexión: `offline` es la copia con la que se arrancó, o null con conexión. 1104 app: esApp(), offline: null, guardaInforme: guardaInforme, informeGuardado: informeGuardado, 1105 borraInforme: borraInforme 1106 }; 1107 if (document.readyState === "complete") registraServiceWorker(); 1108 else window.addEventListener("load", registraServiceWorker); 1109}());
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.