PageSourceSearch

https://www.happinessplay.es/assets/portal.js?v=20260905a

js happinessplay.es collected 2026-10-10 01:06:02 UTC 64,518 bytes, 1,109 lines download raw bytes

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 { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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(&quot;/images/ico/' + escapeHTML(logo) + '&quot;)"' : "";
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">&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.