Files
feadulta/wordpress/wp-content/mu-plugins/fea-beta-feedback.php
T
rafa c5704d7b4a fix(feedback-beta): no mostrar éxito si el POST del formulario falla
El handler de envío llamaba a fetch() sin await y sin comprobar
response.ok, con .catch() vacío: el UI mostraba "Gracias" aunque la
petición fallara, así que el feedback público podía no persistir sin
que nadie se enterase. Ahora se encadena la promesa, se exige
response.ok + {ok:true} antes de mostrar éxito y, si falla, se
mantiene la tarjeta abierta con mensaje de error (5 idiomas) y el
botón reactivado para reintentar.

Validado en local con Playwright: caso OK crea exactamente 1 CPT
fea_feedback; caso de fallo de red no crea ninguno y no muestra
éxito.

Refs #192
2026-08-04 09:42:30 -04:00

316 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Plugin Name: Fe Adulta — Feedback Beta
* Description: Barra sutil de aviso "Beta" en todo el sitio + mini formulario (👍/👎 +
* comentario opcional) que se abre a demanda, para que el público ayude a
* encontrar errores. Guarda cada voto como "Beta Feedback" (CPT propio),
* legible en wp-admin en una sola lista. No usa el sistema de comentarios.
* Version: 1.1
*
* Ver issue rafa/feadulta#78.
*/
if (!defined('ABSPATH')) exit;
const FEA_FB_CPT = 'fea_feedback';
const FEA_FB_RATE_MAX = 12; // máximo de envíos por IP por hora
const FEA_FB_COMMENT_MAX = 2000;
/* ── 1) CPT donde se guardan los votos (solo backend) ─────────────────────── */
add_action('init', function () {
register_post_type(FEA_FB_CPT, [
'labels' => [
'name' => 'Beta Feedback',
'singular_name' => 'Feedback',
'menu_name' => 'Beta Feedback',
],
'public' => false,
'show_ui' => true,
'show_in_menu' => true,
'menu_icon' => 'dashicons-feedback',
'menu_position' => 26,
'capability_type' => 'post',
'capabilities' => ['create_posts' => 'do_not_allow'], // solo se crean por API
'map_meta_cap' => true,
'supports' => ['title', 'editor'],
'exclude_from_search' => true,
]);
});
/* ── 2a) Endpoint de consulta de idioma Polylang (para publicabot / integración externa) ── */
add_action('rest_api_init', function () {
register_rest_route('fea/v1', '/lang/(?P<id>\d+)', [
'methods' => 'GET',
'permission_callback' => '__return_true',
'callback' => function (WP_REST_Request $req) {
$id = (int) $req->get_param('id');
$post = get_post($id);
if (!$post || $post->post_type !== 'post') {
return new WP_REST_Response(['error' => 'post not found'], 404);
}
$langs = wp_get_object_terms($id, 'language', ['fields' => 'slugs']);
$lang = (!is_wp_error($langs) && !empty($langs)) ? $langs[0] : null;
return new WP_REST_Response(['id' => $id, 'lang' => $lang], 200);
},
]);
});
/* ── 2) Endpoint REST para recibir el voto ────────────────────────────────── */
add_action('rest_api_init', function () {
register_rest_route('fea/v1', '/feedback', [
'methods' => 'POST',
'permission_callback' => '__return_true', // público (Beta); protegido con honeypot + rate-limit
'callback' => 'fea_feedback_submit',
]);
});
function fea_feedback_submit(WP_REST_Request $req) {
// Honeypot: si el campo oculto viene relleno, es un bot.
if (trim((string) $req->get_param('website')) !== '') {
return new WP_REST_Response(['ok' => true], 200); // fingir éxito
}
$vote = $req->get_param('vote') === 'up' ? 'up' : ($req->get_param('vote') === 'down' ? 'down' : '');
if ($vote === '') {
return new WP_REST_Response(['ok' => false, 'error' => 'voto inválido'], 400);
}
// Rate-limit por IP.
$ip = fea_feedback_client_ip();
$key = 'fea_fb_rl_' . md5($ip);
$n = (int) get_transient($key);
if ($n >= FEA_FB_RATE_MAX) {
return new WP_REST_Response(['ok' => false, 'error' => 'demasiados envíos'], 429);
}
set_transient($key, $n + 1, HOUR_IN_SECONDS);
$comment = trim((string) $req->get_param('comment'));
$comment = mb_substr(wp_strip_all_tags($comment), 0, FEA_FB_COMMENT_MAX);
$url = esc_url_raw((string) $req->get_param('url'));
$src_id = (int) $req->get_param('post_id');
$lang = preg_replace('/[^a-z]/', '', (string) $req->get_param('lang'));
$title = (string) $req->get_param('title');
$emoji = $vote === 'up' ? '👍' : '👎';
$post_id = wp_insert_post([
'post_type' => FEA_FB_CPT,
'post_status' => 'private',
'post_title' => sprintf('%s %s', $emoji, $title ?: $url),
'post_content' => $comment,
], true);
if (is_wp_error($post_id)) {
return new WP_REST_Response(['ok' => false, 'error' => 'no se pudo guardar'], 500);
}
update_post_meta($post_id, '_fea_fb_vote', $vote);
update_post_meta($post_id, '_fea_fb_url', $url);
update_post_meta($post_id, '_fea_fb_source_id', $src_id);
update_post_meta($post_id, '_fea_fb_lang', $lang);
update_post_meta($post_id, '_fea_fb_ua', mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255));
update_post_meta($post_id, '_fea_fb_ip', md5($ip)); // hash, no IP en claro
return new WP_REST_Response(['ok' => true], 200);
}
function fea_feedback_client_ip(): string {
foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'] as $k) {
if (!empty($_SERVER[$k])) return trim(explode(',', $_SERVER[$k])[0]);
}
return '0.0.0.0';
}
/* ── 3) Columnas en el listado del wp-admin ───────────────────────────────── */
add_filter('manage_' . FEA_FB_CPT . '_posts_columns', function ($cols) {
return [
'cb' => $cols['cb'] ?? '',
'fb_vote' => 'Voto',
'fb_url' => 'Página',
'fb_lang' => 'Idioma',
'fb_comment'=> 'Comentario',
'date' => 'Fecha',
];
});
add_action('manage_' . FEA_FB_CPT . '_posts_custom_column', function ($col, $post_id) {
if ($col === 'fb_vote') {
echo get_post_meta($post_id, '_fea_fb_vote', true) === 'up' ? '👍' : '👎';
} elseif ($col === 'fb_url') {
$u = get_post_meta($post_id, '_fea_fb_url', true);
if ($u) echo '<a href="' . esc_url($u) . '" target="_blank" rel="noopener">' . esc_html(wp_parse_url($u, PHP_URL_PATH) ?: $u) . '</a>';
} elseif ($col === 'fb_lang') {
echo esc_html(strtoupper(get_post_meta($post_id, '_fea_fb_lang', true) ?: '—'));
} elseif ($col === 'fb_comment') {
echo esc_html(wp_trim_words(get_post_field('post_content', $post_id), 24));
}
}, 10, 2);
/* ── 4) Barra Beta sutil (persistente) + tarjeta de feedback (a demanda) ──── */
/** Etiquetas del widget Beta por idioma (Polylang). */
function fea_beta_labels(): array {
$all = [
'es' => ['region'=>'Aviso Beta','intro'=>'Estamos en','help'=>'¿Nos ayudas a mejorar FeAdulta?',
'opinion'=>'Dar mi opinión','collab'=>'Colaborar','dismiss'=>'Cerrar aviso','fbregion'=>'Feedback de la página',
'close'=>'Cerrar','q'=>'¿Se ve bien esta página?','up'=>'Sí, se ve bien','down'=>'No, hay algo mal',
'ph'=>'¿Algo falla o se ve mal? Cuéntanoslo (opcional)','send'=>'Enviar','thanks'=>'¡Gracias por ayudar! 🙏',
'error'=>'No se pudo enviar. Comprueba tu conexión e inténtalo de nuevo.'],
'en' => ['region'=>'Beta notice','intro'=>'We are in','help'=>'Will you help us improve FeAdulta?',
'opinion'=>'Give feedback','collab'=>'Collaborate','dismiss'=>'Close notice','fbregion'=>'Page feedback',
'close'=>'Close','q'=>'Does this page look right?','up'=>'Yes, looks good','down'=>'No, something is wrong',
'ph'=>'Something broken or off? Tell us (optional)','send'=>'Send','thanks'=>'Thanks for helping! 🙏',
'error'=>'Could not send. Check your connection and try again.'],
'fr' => ['region'=>'Avis Bêta','intro'=>'Nous sommes en','help'=>'Voulez-vous nous aider à améliorer FeAdulta ?',
'opinion'=>'Donner mon avis','collab'=>'Collaborer','dismiss'=>'Fermer lavis','fbregion'=>'Retour sur la page',
'close'=>'Fermer','q'=>'Cette page saffiche-t-elle bien ?','up'=>'Oui, cest bien','down'=>'Non, il y a un problème',
'ph'=>'Un souci ou un affichage incorrect ? Dites-le-nous (facultatif)','send'=>'Envoyer','thanks'=>'Merci de votre aide ! 🙏',
'error'=>'Échec de lenvoi. Vérifiez votre connexion et réessayez.'],
'it' => ['region'=>'Avviso Beta','intro'=>'Siamo in','help'=>'Ci aiuti a migliorare FeAdulta?',
'opinion'=>'Dai la tua opinione','collab'=>'Collabora','dismiss'=>'Chiudi avviso','fbregion'=>'Feedback della pagina',
'close'=>'Chiudi','q'=>'Questa pagina si vede bene?','up'=>'Sì, si vede bene','down'=>'No, c’è qualcosa che non va',
'ph'=>'Qualcosa non va o si vede male? Faccelo sapere (facoltativo)','send'=>'Invia','thanks'=>'Grazie per laiuto! 🙏',
'error'=>'Invio non riuscito. Controlla la connessione e riprova.'],
'pt' => ['region'=>'Aviso Beta','intro'=>'Estamos em','help'=>'Ajuda-nos a melhorar a FeAdulta?',
'opinion'=>'Dar a minha opinião','collab'=>'Colaborar','dismiss'=>'Fechar aviso','fbregion'=>'Feedback da página',
'close'=>'Fechar','q'=>'Esta página vê-se bem?','up'=>'Sim, vê-se bem','down'=>'Não, há algo errado',
'ph'=>'Algo falha ou vê-se mal? Conta-nos (opcional)','send'=>'Enviar','thanks'=>'Obrigado por ajudar! 🙏',
'error'=>'Não foi possível enviar. Verifica a ligação e tenta novamente.'],
];
$lang = function_exists('pll_current_language') ? (string) pll_current_language() : 'es';
return $all[$lang] ?? $all['es'];
}
add_action('wp_footer', function () {
if (is_admin()) return;
$rest = esc_url_raw(rest_url('fea/v1/feedback'));
$t = fea_beta_labels();
?>
<style>
/* Barra sutil de aviso Beta, abajo, full-width */
#fea-beta-bar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 99997;
background: #faf6f2; border-top: 1px solid #e6ddd5; color: #4a3b34;
font-family: inherit; font-size: .86rem; line-height: 1.3;
padding: 8px 44px 8px 16px; text-align: center; }
#fea-beta-bar strong { color: #8b1a2e; }
#fea-beta-bar .fea-beta-open { margin-left: 10px; cursor: pointer; border: 1px solid #8b1a2e;
background: #8b1a2e; color: #fff; border-radius: 6px; padding: 4px 12px; font-size: .82rem; font-weight: 600; }
#fea-beta-bar .fea-beta-open:hover { background: #761526; }
#fea-beta-bar .fea-beta-collab { margin-left: 8px; cursor: pointer; display: inline-block;
border: 1px solid #1b7a34; background: #1b7a34; color: #fff; border-radius: 6px;
padding: 4px 12px; font-size: .82rem; font-weight: 600; text-decoration: none; }
#fea-beta-bar .fea-beta-collab:hover { background: #15642a; }
#fea-beta-bar .fea-beta-dismiss { position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
border: 0; background: none; font-size: 1.15rem; cursor: pointer; color: #8a7a72; padding: 2px 6px; line-height: 1; }
#fea-beta-bar.hidden { display: none; }
/* Tarjeta de feedback: oculta hasta que el usuario la abre desde la barra */
#fea-fb { position: fixed; right: 16px; bottom: 56px; z-index: 99998; font-family: inherit; max-width: 300px; }
#fea-fb[hidden] { display: none; }
#fea-fb .fea-fb-card { background:#fff; border:1px solid #e2e2e2; border-radius:12px;
box-shadow:0 8px 28px rgba(0,0,0,.16); padding:12px 14px; font-size:.9rem; color:#222; position:relative; }
#fea-fb .fea-fb-q { margin:0 0 8px; line-height:1.3; padding-right:16px; }
#fea-fb .fea-fb-btns { display:flex; gap:8px; }
#fea-fb button.fea-fb-vote { cursor:pointer; border:1px solid #ccc; background:#fafafa; border-radius:8px;
padding:6px 12px; font-size:1.05rem; line-height:1; }
#fea-fb button.fea-fb-vote:hover { background:#f0f0f0; }
#fea-fb button.fea-fb-vote.sel { border-color:#8b1a2e; background:#f7e9ec; }
#fea-fb textarea { width:100%; margin:9px 0 8px; border:1px solid #ccc; border-radius:8px; padding:7px;
font:inherit; font-size:.85rem; resize:vertical; min-height:58px; box-sizing:border-box; }
#fea-fb .fea-fb-send { background:#8b1a2e; color:#fff; border:1px solid #8b1a2e; border-radius:8px;
padding:6px 12px; font-size:.85rem; width:100%; cursor:pointer; }
#fea-fb .fea-fb-send:disabled { opacity:.6; cursor:default; }
#fea-fb .fea-fb-error { color:#8b1a2e; font-size:.82rem; margin:0 0 8px; }
#fea-fb .fea-fb-hp { position:absolute; left:-9999px; }
#fea-fb .fea-fb-close { position:absolute; top:4px; right:8px; border:0; background:none; font-size:1rem; cursor:pointer; padding:2px 4px; line-height:1; }
@media (max-width:600px){ #fea-fb{ right:10px; left:10px; max-width:none; } #fea-beta-bar{ font-size:.8rem; } }
</style>
<div id="fea-beta-bar" class="hidden" role="region" aria-label="<?php echo esc_attr($t['region']); ?>">
🌱 <?php echo esc_html($t['intro']); ?> <strong>Beta</strong>. <?php echo esc_html($t['help']); ?>
<button type="button" class="fea-beta-open"><?php echo esc_html($t['opinion']); ?></button>
<a class="fea-beta-collab" href="https://edicionesfeadulta.com/colabora/" target="_blank" rel="noopener"><?php echo esc_html($t['collab']); ?></a>
<button type="button" class="fea-beta-dismiss" aria-label="<?php echo esc_attr($t['dismiss']); ?>">×</button>
</div>
<div id="fea-fb" hidden role="complementary" aria-label="<?php echo esc_attr($t['fbregion']); ?>">
<div class="fea-fb-card">
<button type="button" class="fea-fb-close" aria-label="<?php echo esc_attr($t['close']); ?>">×</button>
<p class="fea-fb-q"><?php echo esc_html($t['q']); ?></p>
<div class="fea-fb-btns">
<button type="button" class="fea-fb-vote" data-vote="up" aria-label="<?php echo esc_attr($t['up']); ?>">👍</button>
<button type="button" class="fea-fb-vote" data-vote="down" aria-label="<?php echo esc_attr($t['down']); ?>">👎</button>
</div>
<div class="fea-fb-more" hidden>
<input type="text" class="fea-fb-hp" tabindex="-1" autocomplete="off" aria-hidden="true" placeholder="No rellenar">
<textarea placeholder="<?php echo esc_attr($t['ph']); ?>"></textarea>
<p class="fea-fb-error" hidden role="alert"><?php echo esc_html($t['error']); ?></p>
<button type="button" class="fea-fb-send"><?php echo esc_html($t['send']); ?></button>
</div>
<div class="fea-fb-thanks" hidden><?php echo esc_html($t['thanks']); ?></div>
</div>
</div>
<script>
(function(){
var bar = document.getElementById('fea-beta-bar');
var box = document.getElementById('fea-fb');
if(!bar || !box) return;
var REST = <?php echo json_encode($rest); ?>;
var pid = <?php echo (int) (is_singular() ? get_queried_object_id() : 0); ?>;
var lang = <?php echo json_encode(function_exists('pll_current_language') ? (string) pll_current_language() : ''); ?>;
var chosen = null;
var moreEl = box.querySelector('.fea-fb-more');
var votes = box.querySelectorAll('.fea-fb-vote');
var thanks = box.querySelector('.fea-fb-thanks');
var errorEl = box.querySelector('.fea-fb-error');
var sendBtn = box.querySelector('.fea-fb-send');
// Mostrar la barra salvo que el usuario la haya descartado antes.
try { if (!localStorage.getItem('fea_beta_bar_off')) bar.classList.remove('hidden'); }
catch(e){ bar.classList.remove('hidden'); }
function openCard(){ box.hidden = false; }
function closeCard(){ box.hidden = true; }
bar.querySelector('.fea-beta-open').addEventListener('click', openCard);
bar.querySelector('.fea-beta-dismiss').addEventListener('click', function(){
bar.classList.add('hidden');
try { localStorage.setItem('fea_beta_bar_off','1'); } catch(e){}
});
box.querySelector('.fea-fb-close').addEventListener('click', closeCard);
votes.forEach(function(b){ b.addEventListener('click', function(){
chosen = b.getAttribute('data-vote');
votes.forEach(function(x){ x.classList.toggle('sel', x===b); });
moreEl.hidden = false;
});});
sendBtn.addEventListener('click', function(){
if(!chosen) return;
var hp = box.querySelector('.fea-fb-hp').value;
var comment = box.querySelector('textarea').value;
sendBtn.disabled = true;
errorEl.hidden = true;
fetch(REST, { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ vote:chosen, comment:comment, url:location.href, post_id:pid,
lang:lang, title:document.title, website:hp }) })
.then(function(res){
if(!res.ok) throw new Error('http_' + res.status);
return res.json();
})
.then(function(data){
if(!data || data.ok !== true) throw new Error('bad_response');
box.querySelector('.fea-fb-btns').hidden = true;
box.querySelector('.fea-fb-q').hidden = true;
moreEl.hidden = true; thanks.hidden = false;
setTimeout(closeCard, 2200);
})
.catch(function(err){
if (window.console && console.warn) console.warn('fea-fb submit failed:', err && err.message);
errorEl.hidden = false;
sendBtn.disabled = false;
});
});
})();
</script>
<?php
}, 40);