// Resenas.jsx — Reseñas y sugerencias de los usuarios.
//
// Tres piezas:
// · ResenaModal → el formulario (pop-up)
// · ResenaNudge → el aviso pequeño y discreto dentro de cada función
// · ResenasSection → la lista pública, con promedio y 3 órdenes de lectura
//
// SEGURIDAD Y PRIVACIDAD
// La moderación de verdad ocurre en el SERVIDOR (sql/resenas.sql): un filtro
// en JavaScript se saltaría abriendo las herramientas del navegador. Aquí solo
// se avisa al usuario cuanto antes, por cortesía.
// La lista pública llega por la función resenas_publicas(), que jamás devuelve
// user_id ni correos.
const { useState: useReS, useEffect: useReE, useCallback: useReC } = React;
const RESENA_VISTA_KEY = 'cyna_resena_popup_visto';
const RESENA_NUDGE_KEY = 'cyna_resena_nudge_cerrado';
function sbClient() {
return (window.cynaAuth && window.cynaAuth.client) || null;
}
// ─── Estrellas ──────────────────────────────────────────────
function Estrellas({ valor, onChange, size = 20, readOnly }) {
const [hover, setHover] = useReS(0);
const activo = hover || valor;
return (
{[1, 2, 3, 4, 5].map(n => (
))}
);
}
// ─── Formulario (pop-up) ────────────────────────────────────
function ResenaModal({ onClose }) {
const user = window.__cynaUser || null;
const [estrellas, setEstrellas] = useReS(0);
const [texto, setTexto] = useReS('');
const [sugerencia, setSugerencia] = useReS('');
const [estado, setEstado] = useReS('idle'); // idle | enviando | ok | retenida | error
const [error, setError] = useReS('');
const [yaTenia, setYaTenia] = useReS(false);
// Si ya dejó una reseña, la traemos para que la edite en vez de duplicarla.
useReE(() => {
const sb = sbClient();
if (!sb || !user) return;
let cancel = false;
sb.rpc('mi_resena').then(({ data }) => {
if (cancel || !data || !data.length) return;
const r = data[0];
setEstrellas(r.estrellas); setTexto(r.texto || '');
setSugerencia(r.sugerencia || ''); setYaTenia(true);
}, () => {});
return () => { cancel = true; };
}, []);
const enviar = async () => {
setError('');
const sec = window.cynaSec;
const sb = sbClient();
if (!user) { setError('Necesitas iniciar sesión para dejar tu reseña.'); return; }
if (!sb) { setError('El servicio no está disponible ahora mismo.'); return; }
if (!estrellas) { setError('Elige cuántas estrellas le das.'); return; }
const limpio = sec ? sec.sanitizeText(texto, 600) : texto.trim();
const sug = sec ? sec.sanitizeText(sugerencia, 400) : sugerencia.trim();
if (limpio.length < 5) { setError('Cuéntanos un poco más (al menos 5 caracteres).'); return; }
// Límite en el navegador: cortesía para no golpear el servidor. El límite
// real lo impone la base de datos.
if (sec) {
const rl = sec.rateLimit('resena', { minGap: 60, maxPerHour: 5 });
if (!rl.ok) { setError('Espera un momento antes de volver a enviar.'); return; }
}
setEstado('enviando');
const nombre = (user.user_metadata && (user.user_metadata.full_name || user.user_metadata.nombre))
|| (user.email ? user.email.split('@')[0] : 'Usuario');
const { error: err } = await sb.from('cyna_resenas').upsert({
user_id: user.id,
nombre: String(nombre).slice(0, 60),
estrellas,
texto: limpio,
sugerencia: sug || null,
}, { onConflict: 'user_id' });
if (err) {
setEstado('error');
// El error completo va a la consola: sin esto, un fallo de permisos y uno
// de red se ven igual en pantalla y no hay forma de diagnosticarlos.
console.error('[Cynamonurs] Error al guardar la reseña:', err);
const msg = err.message || '';
if (/Espera un momento/i.test(msg)) {
setError('Espera un minuto antes de volver a editar tu reseña.');
} else if (err.code === '42501' || /row-level security|permission denied/i.test(msg)) {
setError('No pudimos guardar tu reseña en este momento. Inténtalo de nuevo en un rato.');
} else if (/check constraint|violates check/i.test(msg)) {
setError('Revisa el texto: debe tener entre 5 y 600 caracteres.');
} else {
setError('No se pudo guardar tu reseña. Inténtalo de nuevo más tarde.');
}
return;
}
// Preguntamos cómo quedó: el servidor pudo retenerla por el filtro.
const { data } = await sb.rpc('mi_resena');
const retenida = data && data[0] && data[0].estado === 'retenida';
setEstado(retenida ? 'retenida' : 'ok');
if (window.cynaTrack) window.cynaTrack('resena_enviada', { estrellas, retenida: !!retenida });
try { localStorage.setItem(RESENA_VISTA_KEY, '1'); } catch (e) {}
window.dispatchEvent(new CustomEvent('cyna-resenas'));
};
const cerrar = () => {
try { localStorage.setItem(RESENA_VISTA_KEY, '1'); } catch (e) {}
onClose();
};
return (
e.stopPropagation()}>
{yaTenia ? 'Edita tu reseña' : 'Cuéntanos qué te pareció'}
{!user ? (
Para dejar una reseña necesitas iniciar sesión. Así evitamos reseñas falsas y spam.
) : estado === 'ok' || estado === 'retenida' ? (
{estado === 'ok' ? (
¡Gracias! Tu reseña ya está publicada.
) : (
Recibimos tu reseña. La revisaremos antes de publicarla —
nuestro filtro detectó algo que conviene mirar con calma. Si todo está bien,
aparecerá en breve.
)}
) : (
<>
{error &&
{error}
}
Tu reseña se publica con tu nombre. No mostramos tu correo ni datos de pacientes.
>
)}
{user && estado !== 'ok' && estado !== 'retenida' && (
Todavía no hay reseñas. Puedes ser la primera persona en dejar una.
)}
{total > 0 && (
{ORDENES.map(o => (
))}
)}
{cargando ? (
Cargando reseñas…
) : rows.length === 0 ? (
total > 0 ?
No hay reseñas que mostrar en este orden.
: null
) : (
{rows.map(r => (
{fecha(r.created_at)}
{r.texto}
{r.sugerencia && (
Sugerencia: {r.sugerencia}
)}
— {r.nombre}
))}
)}
);
}
// ─── Destacadas para la portada (solo las mejores) ──────────
// Muestra únicamente reseñas de 4 y 5 estrellas. Es la práctica habitual en una
// portada, y no oculta nada: la lista COMPLETA —incluidas las peores— sigue
// accesible en Contacto, con su filtro «Peor calificadas».
// Si no hay ninguna buena todavía, no se dibuja nada (mejor vacío que forzado).
const MIN_ESTRELLAS_PORTADA = 4;
const MAX_DESTACADAS = 3;
function ResenasDestacadas() {
const [rows, setRows] = useReS([]);
const [resumen, setResumen] = useReS(null);
const cargar = useReC(async () => {
const sb = sbClient();
if (!sb) return;
const [lista, res] = await Promise.all([
sb.rpc('resenas_publicas', { p_orden: 'mejores' }),
sb.rpc('resenas_resumen'),
]);
const buenas = ((lista && lista.data) || []).filter(r => r.estrellas >= MIN_ESTRELLAS_PORTADA);
setRows(buenas.slice(0, MAX_DESTACADAS));
setResumen(res && res.data && res.data[0] ? res.data[0] : null);
}, []);
useReE(() => { cargar(); }, [cargar]);
useReE(() => {
window.addEventListener('cyna-resenas', cargar);
return () => window.removeEventListener('cyna-resenas', cargar);
}, [cargar]);
if (!rows.length) return null;
const total = resumen ? Number(resumen.total || 0) : 0;
const prom = resumen && resumen.promedio != null ? Number(resumen.promedio) : null;
return (
Quienes ya la usan
Lo que dicen las enfermeras.
{total > 0 && prom != null && (
{prom}· {total} reseña{total === 1 ? '' : 's'}
)}
{rows.map(r => (
{r.texto}
— {r.nombre}
))}
);
}
// ─── Anfitrión del pop-up: se monta una vez en App ──────────
function ResenaHost() {
const [abierto, setAbierto] = useReS(false);
useReE(() => {
window.__cynaOpenResena = () => setAbierto(true);
return () => { delete window.__cynaOpenResena; };
}, []);
if (!abierto) return null;
return setAbierto(false)}/>;
}
// Abre el pop-up UNA SOLA VEZ (tras descargar un PDF, p. ej.).
// Si ya reseñó o ya lo cerró antes, no vuelve a molestar.
function pedirResenaUnaVez() {
try {
if (localStorage.getItem(RESENA_VISTA_KEY) === '1') return;
if (!window.__cynaUser) return; // sin sesión no podría enviarla
} catch (e) { return; }
setTimeout(() => { if (window.__cynaOpenResena) window.__cynaOpenResena(); }, 1200);
}
Object.assign(window, {
ResenaModal, ResenaNudge, ResenasSection, ResenasDestacadas, ResenaHost, pedirResenaUnaVez,
});