Widget de Feedback
Integra un portal de feedback completo — ideas, roadmap y anuncios — directamente en tu producto con una sola etiqueta script.
Descripción general
El widget de Priority Hunter se carga de forma asíncrona dentro de un <iframe> aislado. No bloquea el renderizado de tu página, no tiene impacto en el tamaño de tu bundle y funciona con cualquier framework — React, Vue, Angular, Svelte o HTML puro.
Una vez integrado, los usuarios finales pueden enviar ideas, votar las existentes, explorar el roadmap público y leer los últimos anuncios sin salir de tu app. Tú controlas qué pestañas son visibles y cómo luce el widget desde Studio › Editor de Widget.
Inicio rápido
Pega el snippet dentro del <head> de cada página donde quieras que aparezca el widget. Sustituye YOUR_WIDGET_ID por el ID de Studio › Widget › Cómo conectar.
<!-- Priority Hunter Widget -->
<script>
(function(w,d){
w.PH=w.PH||function(){(w.PH.q=w.PH.q||[]).push(Array.prototype.slice.call(arguments));};
function load(){var s=d.createElement('script');s.async=true;s.src='https://widget.priorityhunter.com/embed.js';d.head.appendChild(s);}
d.readyState==='loading'?d.addEventListener('DOMContentLoaded',load):load();
})(window,document);
PH('init', { widgetId: 'YOUR_WIDGET_ID' });
</script>
<!-- End Priority Hunter Widget -->El loader pesa solo ~300 bytes. El archivo embed.js completo (~6 KB) se descarga de forma asíncrona después de que la página se ha procesado, por lo que no bloquea el renderizado.
Si el usuario ya está autenticado cuando carga la página, puedes combinar init e identify en el mismo bloque:
PH('init', { widgetId: 'YOUR_WIDGET_ID' });
PH('identify', {
id: 'u_123',
email: 'jane@acme.com', // optional — omit to avoid storing PII
name: 'Jane Smith', // optional
});Opciones de init
El segundo argumento de PH('init', ...) acepta las siguientes opciones:
| Opción | Tipo | Requerido | Descripción |
|---|---|---|---|
widgetId | string | Sí | El ID del widget de tu panel en Studio. |
ssoToken | string | No | Un JWT firmado en el servidor para identificar al usuario sin llamar a identify() por separado. Ver sección SSO. |
SSO (autenticación segura)
Para producción, genera un JWT de corta duración en tu servidor y pásalo mediante ssoToken. Esto evita exponer tu secreto SSO en el navegador.
1 — Genera el token en el servidor
// Node.js — generate the token server-side, never in the browser
import jwt from 'jsonwebtoken';
const ssoToken = jwt.sign(
{
id: user.id,
email: user.email, // optional — omit to avoid storing PII
name: user.name, // optional — shown on ideas and comments
},
process.env.PH_SSO_SECRET,
{ expiresIn: '1h' }
);2 — Pasa el token en el init
Inyecta el token en la página (por ejemplo, como variable de plantilla o mediante una llamada a la API) y pásalo a PH('init'):
// Pass the server-generated token at init time
PH('init', {
widgetId: 'YOUR_WIDGET_ID',
ssoToken: '<token-from-your-server>',
});Nunca incluyas tu secreto SSO en JavaScript del lado cliente. El token debe generarse en tu backend y enviarse al navegador en el momento de renderizado (o mediante un endpoint de corta duración).
Identificar
Llamar a PH('identify', user) asocia todas las interacciones del widget (votos, ideas, comentarios) con un usuario concreto. Es el método recomendado cuando no usas tokens SSO.
// Call after your user session is available
PH('identify', {
id: currentUser.id, // your internal user ID (required)
email: currentUser.email, // optional — omit to avoid storing PII
name: currentUser.name, // optional — shown on ideas and comments
});El objeto de usuario acepta los siguientes campos:
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
id | string | Sí | Tu ID de usuario interno. Se usa para deduplicar usuarios entre sesiones. |
email | string | No | Dirección de email del usuario. Omítela en configuraciones orientadas a la privacidad — el widget funciona sin ella. |
name | string | No | Nombre visible en ideas y votos. |
Modo privacidad: puedes pasar solo id — no se almacena ningún email. Los autores aparecerán como Anónimo en tu panel; tú mantienes el mapeo usuario→ID en tu lado. Útil cuando tu política de retención de datos prohíbe enviar PII a herramientas de terceros.
Callback opcional
Pasa una función como tercer argumento — se llamará cuando el widget haya cargado y la identidad se haya aplicado.
PH('identify', {
id: currentUser.id,
email: currentUser.email, // optional
name: currentUser.name, // optional
}, function() {
console.log('User identified — widget is ready');
});Desidentificar
Llama a PH('unidentify') cuando el usuario cierre sesión. El widget volverá al modo anónimo.
// Call when your user logs out
PH('unidentify');API JavaScript — Comandos
Todos los comandos se invocan mediante la función global PH(command, payload?, callback?). Los comandos pueden encolarse incluso antes de que embed.js se haya cargado.
| Comando | Payload | Descripción |
|---|---|---|
init | { widgetId, ssoToken? } | Inicializa el widget. Debe ser la primera llamada. |
identify | { id, email?, name? } | Asocia la sesión actual del navegador con un usuario. |
unidentify | — | Limpia la identidad del usuario. Llamar al cerrar sesión. |
open | { tab? } | Abre el widget. Opcionalmente pasa tab: 'ideas' | 'roadmap' | 'announcements'. |
close | — | Cierra el widget. |
toggle | — | Abre si está cerrado, cierra si está abierto. |
viewSection | 'ideas' | 'roadmap' | 'announcements' | Abre el widget y navega a la sección indicada. |
on | (event, fn) | Suscribirse a un evento del widget. |
off | (event, fn) | Desuscribirse de un evento del widget. |
destroy | — | Elimina el iframe del widget del DOM y borra window.PH. |
Abrir, cerrar y alternar
PH('open'); // open on the default tab
PH('open', { tab: 'roadmap' }); // open directly on Roadmap
PH('close'); // close the widget
PH('toggle'); // toggle open / closedNavegar a una sección
// Navigate to a specific section
PH('viewSection', 'ideas');
PH('viewSection', 'roadmap');
PH('viewSection', 'announcements');Destruir
Usa destroy en aplicaciones de una sola página cuando quieras eliminar el widget al navegar fuera de una sección concreta de tu producto.
// Remove the widget from the page entirely
PH('destroy');Eventos
Suscríbete a eventos del ciclo de vida del widget con PH('on', event, handler). Cancela la suscripción con PH('off', event, handler).
| Evento | Payload | Descripción |
|---|---|---|
ready | — | Se dispara una vez tras la inicialización del iframe del widget. El primer evento badgeCount se emite poco después, mientras el widget aún está cerrado. |
open | — | Se dispara cuando el panel del widget se hace visible. |
close | — | Se dispara cuando el panel del widget se oculta. |
badgeCount | { count: number } | Se dispara cuando cambia el contador de no leídos. count es la suma de: anuncios no vistos (más recientes que la última visita del usuario a la pestaña de Anuncios) + ideas enviadas por el usuario que han recibido nuevos comentarios o un cambio de estado desde la última vez que se vieron. |
// Subscribe
PH('on', 'open', function() { console.log('opened'); });
PH('on', 'close', function() { console.log('closed'); });
PH('on', 'ready', function() { console.log('widget ready'); });
PH('on', 'badgeCount', function(data) {
document.getElementById('my-badge').textContent = data.count;
});
// Unsubscribe
function onOpen() { /* ... */ }
PH('on', 'open', onOpen);
PH('off', 'open', onOpen);PH.ready
PH.ready es una Promise<void> que se resuelve en cuanto el iframe del widget señala que se ha inicializado. Es seguro usarla para comandos diferidos que requieren que el widget esté completamente cargado antes de ejecutarse.
// PH.ready is a Promise that resolves once the widget has initialised
PH.ready.then(function() {
console.log('widget is ready');
PH('open');
});
// Or with async/await inside an async function
await PH.ready;
PH('viewSection', 'announcements');Los comandos habituales como open e identify se encolan automáticamente si se llaman antes de que el widget se cargue, por lo que generalmente no necesitas PH.ready. Es más útil cuando necesitas garantizar que el widget es interactivo antes de actuar.
Guía para frameworks — React
El patrón recomendado es un hook personalizado que inicializa el widget una vez al montar y vuelve a identificar al usuario cada vez que cambia la sesión.
// hooks/usePriorityHunter.ts
import { useEffect } from 'react';
declare global {
interface Window {
PH?: (cmd: string, payload?: unknown, cb?: () => void) => void;
PH_q?: unknown[][];
}
}
export function usePriorityHunter(widgetId: string, user?: {
id: string; email?: string; name?: string;
}) {
useEffect(() => {
if (typeof window === 'undefined') return;
if (document.getElementById('ph-embed-script')) return;
// Stub queue
if (!window.PH) {
window.PH = function(...args: unknown[]) {
((window.PH as any).q = (window.PH as any).q ?? []).push(args);
};
}
window.PH('init', { widgetId });
if (user) window.PH('identify', user);
const script = document.createElement('script');
script.id = 'ph-embed-script';
script.src = 'https://widget.priorityhunter.com/embed.js';
script.async = true;
document.head.appendChild(script);
return () => {
window.PH?.('destroy');
};
}, []);
// Re-identify when user changes
useEffect(() => {
if (user) window.PH?.('identify', user);
else window.PH?.('unidentify');
}, [user?.id]);
}// app/layout.tsx (or any root component)
import { usePriorityHunter } from '@/hooks/usePriorityHunter';
export default function RootLayout() {
const { user } = useCurrentUser(); // your auth hook
usePriorityHunter('YOUR_WIDGET_ID', user
? { id: user.id, email: user.email, name: user.name }
: undefined
);
return <>{children}</>;
}Devolver PH('destroy') en la limpieza del efecto garantiza que el widget se elimine si el componente se desmonta — útil para apps que renderizan condicionalmente un layout con el widget.
Guía para frameworks — Next.js (App Router)
Como window solo está disponible en el cliente, el cargador del widget debe ser un Client Component. Crea un componente ligero y añádelo a tu layout raíz:
// components/WidgetLoader.tsx
'use client';
import { useEffect } from 'react';
import { useSession } from 'next-auth/react';
export function WidgetLoader() {
const { data: session } = useSession();
useEffect(() => {
if (window.self !== window.top) return; // skip inside iframes
if (document.getElementById('ph-embed-script')) return;
if (!(window as any).PH) {
(window as any).PH = function(...args: unknown[]) {
((window as any).PH.q = (window as any).PH.q ?? []).push(args);
};
}
(window as any).PH('init', { widgetId: process.env.NEXT_PUBLIC_PH_WIDGET_ID });
const script = document.createElement('script');
script.id = 'ph-embed-script';
script.src = '/embed.js'; // if self-hosting, or the CDN URL
script.async = true;
document.head.appendChild(script);
}, []);
useEffect(() => {
const user = session?.user;
if (user?.email) {
(window as any).PH?.('identify', {
id: user.id ?? user.email,
email: user.email,
name: user.name ?? undefined,
});
} else {
(window as any).PH?.('unidentify');
}
}, [session]);
return null;
}// app/layout.tsx
import { WidgetLoader } from '@/components/WidgetLoader';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<WidgetLoader />
</body>
</html>
);
}Si alojas el widget tú mismo (es decir, Priority Hunter es tu propio despliegue), establece src como /embed.js para servirlo desde el mismo origen y evitar problemas de CORS o CSP.
Guía para frameworks — Vue 3
// composables/usePriorityHunter.ts
import { onMounted, onUnmounted, watch, type Ref } from 'vue';
export function usePriorityHunter(
widgetId: string,
user: Ref<{ id: string; email?: string; name?: string } | null>
) {
onMounted(() => {
if (document.getElementById('ph-embed-script')) return;
const w = window as any;
if (!w.PH) {
w.PH = function(...args: unknown[]) {
(w.PH.q = w.PH.q ?? []).push(args);
};
}
w.PH('init', { widgetId });
const script = document.createElement('script');
script.id = 'ph-embed-script';
script.src = 'https://widget.priorityhunter.com/embed.js';
script.async = true;
document.head.appendChild(script);
});
watch(user, (u) => {
const w = window as any;
if (u) w.PH?.('identify', { id: u.id, email: u.email, name: u.name }); // email optional
else w.PH?.('unidentify');
});
onUnmounted(() => {
(window as any).PH?.('destroy');
});
}Avanzado — Badge personalizado
Si quieres ocultar el lanzador predeterminado del widget y usar tu propio botón (por ejemplo, un ítem "Feedback" en tu navegación), puedes desactivar el lanzador integrado desde el Editor de Widget y usar el evento badgeCount para mostrar un badge de notificación en tu propio elemento.
El contador refleja dos fuentes:
| Fuente | Se cuenta cuando | Se limpia cuando |
|---|---|---|
| Anuncios | Un anuncio publicado tiene fecha posterior a la última visita del usuario a la pestaña de Anuncios. | El usuario abre la pestaña de Anuncios. |
| Mis ideas | Una idea enviada por el usuario ha recibido un nuevo comentario o un cambio de estado desde que el usuario abrió por última vez la vista de detalle de esa idea. | El usuario abre la vista de detalle de la idea. |
El estado de no leídos se almacena en localStorage bajo las claves ph-widget-{widgetId}-last-visit y ph-widget-{widgetId}-my-ideas. Persiste entre cargas de página pero está limitado al navegador — un usuario en otro dispositivo verá de nuevo el contador completo.
<!-- Your own trigger button -->
<button id="feedback-btn" style="position:relative">
Feedback
<span id="ph-badge" style="display:none; position:absolute; top:-4px; right:-4px;
width:16px; height:16px; border-radius:50%; background:#ef4444;
color:#fff; font-size:10px; line-height:16px; text-align:center;"></span>
</button>
<script>
PH('on', 'badgeCount', function(data) {
var badge = document.getElementById('ph-badge');
if (data.count > 0) {
badge.textContent = data.count;
badge.style.display = 'block';
} else {
badge.style.display = 'none';
}
});
document.getElementById('feedback-btn').addEventListener('click', function() {
PH('toggle');
});
</script>El evento badgeCount se dispara automáticamente al cargar el widget — mientras el panel aún está cerrado — por lo que tu badge se rellena antes de que el usuario haya interactuado con el widget. Se vuelve a disparar cada vez que cambia el contador: el usuario abre la pestaña de Anuncios o ve una idea con actividad pendiente.