FAQ: solo lectura y bilingüe (ES/EN)

Quita crear/editar/eliminar (la FAQ se gestiona por el webhook). Agrega
selector ES/EN con textos de interfaz en ambos idiomas y soporte para
variantes por ítem (Question_en/Answer_es) y etiqueta de idioma.
This commit is contained in:
webmanager committed 2026-11-07 17:16:51 +00:00
1 parent 399cc1a3a5
commit 06568c36a6
1 file changed
+124 -166
+124 -166
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex" /> <meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex" />
<meta name="googlebot" content="noindex, nofollow" /> <meta name="googlebot" content="noindex, nofollow" />
<title>FAQ interna — OCA Paraguay</title> <title>FAQ — OCA Paraguay</title>
<link rel="icon" type="image/svg+xml" href="../img/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="../img/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@@ -18,54 +18,35 @@
} }
*{box-sizing:border-box} *{box-sizing:border-box}
body{margin:0;font-family:Inter,system-ui,sans-serif;background:var(--bg);color:var(--ink);line-height:1.55} body{margin:0;font-family:Inter,system-ui,sans-serif;background:var(--bg);color:var(--ink);line-height:1.55}
a{color:var(--violet)}
header{background:#fff;border-bottom:1px solid var(--line);position:sticky;top:0;z-index:20} header{background:#fff;border-bottom:1px solid var(--line);position:sticky;top:0;z-index:20}
.wrap{max-width:980px;margin:0 auto;padding:0 20px} .wrap{max-width:920px;margin:0 auto;padding:0 20px}
.head{display:flex;align-items:center;gap:14px;padding:14px 0} .head{display:flex;align-items:center;gap:14px;padding:14px 0;flex-wrap:wrap}
.head img{width:38px;height:38px;border-radius:9px} .head img{width:38px;height:38px;border-radius:9px}
.head h1{font-family:Sora,sans-serif;font-size:1.05rem;margin:0} .head h1{font-family:Sora,sans-serif;font-size:1.05rem;margin:0}
.head h1 small{display:block;font-family:Inter,sans-serif;font-weight:500;font-size:.72rem;color:var(--muted);letter-spacing:.04em;text-transform:uppercase} .head h1 small{display:block;font-family:Inter,sans-serif;font-weight:500;font-size:.72rem;color:var(--muted);letter-spacing:.04em;text-transform:uppercase}
.noidx{margin-left:auto;font-size:.68rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--py-red);border:1px solid currentColor;border-radius:999px;padding:4px 10px} .tools{margin-left:auto;display:flex;align-items:center;gap:10px}
main{padding:28px 0 60px} .noidx{font-size:.64rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--py-red);border:1px solid currentColor;border-radius:999px;padding:3px 9px;white-space:nowrap}
.bar{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:22px} .lang{display:inline-flex;background:#f2eff8;border-radius:999px;padding:3px}
.bar input[type=search]{flex:1 1 240px;padding:11px 14px;border:1px solid var(--line);border-radius:12px;font:inherit;background:#fff} .lang button{border:none;background:transparent;font:inherit;font-size:.76rem;font-weight:700;padding:5px 11px;border-radius:999px;cursor:pointer;color:var(--muted)}
.bar input:focus{outline:2px solid rgba(108,76,224,.25);border-color:var(--violet-soft)} .lang button.on{background:#fff;color:var(--violet);box-shadow:0 1px 3px rgba(0,0,0,.12)}
.btn{font:inherit;font-weight:600;border-radius:12px;border:1px solid transparent;padding:10px 16px;cursor:pointer;display:inline-flex;align-items:center;gap:8px} main{padding:26px 0 60px}
.btn-primary{background:var(--violet);color:#fff} .bar{margin-bottom:22px}
.btn-primary:hover{background:#5b3fd0} .bar input{width:100%;padding:12px 15px;border:1px solid var(--line);border-radius:12px;font:inherit;background:#fff}
.btn-ghost{background:#fff;border-color:var(--line);color:var(--ink)} .bar input:focus{outline:2px solid rgba(108,76,224,.22);border-color:var(--violet-soft)}
.btn-ghost:hover{border-color:var(--violet-soft)}
.btn-danger{background:#fff;border-color:#f0c9c4;color:var(--py-red)}
.btn-danger:hover{background:#fdf1ef}
.msg{padding:12px 16px;border-radius:12px;margin-bottom:18px;font-size:.9rem;display:none} .msg{padding:12px 16px;border-radius:12px;margin-bottom:18px;font-size:.9rem;display:none}
.msg.show{display:block} .msg.show{display:block}
.msg.err{background:#fdf1ef;color:#8f2117;border:1px solid #f0c9c4} .msg.err{background:#fdf1ef;color:#8f2117;border:1px solid #f0c9c4}
.msg.ok{background:#eefaf5;color:#0b6b52;border:1px solid #bfeadd} .area{margin:0 0 26px}
.area{margin:0 0 28px} .area h2{font-family:Sora,sans-serif;font-size:.8rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin:0 0 10px}
.area h2{font-family:Sora,sans-serif;font-size:.82rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin:0 0 10px} .card{background:#fff;border:1px solid var(--line);border-radius:14px;margin-bottom:10px;overflow:hidden;transition:border-color .18s,box-shadow .18s}
.card{background:#fff;border:1px solid var(--line);border-radius:14px;margin-bottom:10px;overflow:hidden} .card[open]{border-color:var(--violet-soft);box-shadow:0 8px 26px rgba(108,76,224,.09)}
.card summary{cursor:pointer;padding:15px 18px;font-weight:600;list-style:none;display:flex;align-items:center;gap:12px} .card summary{cursor:pointer;padding:15px 18px;font-weight:600;list-style:none;display:flex;align-items:center;gap:12px}
.card summary::-webkit-details-marker{display:none} .card summary::-webkit-details-marker{display:none}
.card summary::after{content:"+";margin-left:auto;color:var(--violet);font-weight:800;font-size:1.1rem} .card summary::after{content:"+";margin-left:auto;color:var(--violet);font-weight:800;font-size:1.15rem;line-height:1}
.card[open] summary::after{content:"–"} .card[open] summary::after{content:"–"}
.card .body{padding:0 18px 16px;color:#41394a} .card .ans{padding:0 18px 17px;margin:0;white-space:pre-line;color:#41394a;font-size:.93rem}
.card .ans{white-space:pre-line;margin:0 0 14px;font-size:.93rem} .empty{color:var(--muted);padding:16px 0}
.acts{display:flex;gap:8px;flex-wrap:wrap} footer{border-top:1px solid var(--line);background:#fff;padding:18px 0;font-size:.76rem;color:var(--muted);text-align:center}
.acts .btn{padding:7px 12px;font-size:.8rem;border-radius:9px}
.empty{color:var(--muted);padding:20px 0}
dialog{border:none;border-radius:16px;padding:0;max-width:560px;width:calc(100% - 32px);box-shadow:0 30px 80px rgba(0,0,0,.35)}
dialog::backdrop{background:rgba(35,27,43,.45)}
.dlg-head{padding:18px 22px;border-bottom:1px solid var(--line);display:flex;align-items:center}
.dlg-head h3{font-family:Sora,sans-serif;font-size:1rem;margin:0}
.dlg-head button{margin-left:auto;background:none;border:none;font-size:1.3rem;cursor:pointer;color:var(--muted)}
form{padding:20px 22px 22px}
.field{margin-bottom:14px}
.field label{display:block;font-size:.78rem;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);margin-bottom:6px}
.field input,.field textarea{width:100%;padding:11px 13px;border:1px solid var(--line);border-radius:10px;font:inherit;background:#fff}
.field textarea{min-height:130px;resize:vertical}
.field input:focus,.field textarea:focus{outline:2px solid rgba(108,76,224,.22);border-color:var(--violet-soft)}
.dlg-foot{display:flex;gap:10px;justify-content:flex-end;margin-top:6px}
footer{border-top:1px solid var(--line);background:#fff;padding:18px 0;font-size:.78rem;color:var(--muted);text-align:center}
</style> </style>
</head> </head>
<body> <body>
@@ -73,52 +54,73 @@
<header> <header>
<div class="wrap head"> <div class="wrap head">
<img src="../img/logo.svg" alt="OCA Paraguay" width="38" height="38" /> <img src="../img/logo.svg" alt="OCA Paraguay" width="38" height="38" />
<h1>Preguntas frecuentes<small>OCA Paraguay · uso interno</small></h1> <h1><span data-i18n="title">Preguntas frecuentes</span><small data-i18n="subtitle">OCA Paraguay · uso interno</small></h1>
<span class="noidx">no indexada</span> <div class="tools">
<span class="noidx" data-i18n="badge">no indexada</span>
<span class="lang" role="group" aria-label="Language">
<button type="button" data-lang="es">ES</button>
<button type="button" data-lang="en">EN</button>
</span>
</div>
</div> </div>
</header> </header>
<main class="wrap"> <main class="wrap">
<div class="msg" id="msg"></div> <div class="msg" id="msg"></div>
<div class="bar"> <div class="bar">
<input type="search" id="q" placeholder="Buscar pregunta, área o respuesta…" aria-label="Buscar" /> <input type="search" id="q" data-i18n-placeholder="search" placeholder="Buscar…" aria-label="Buscar" />
<button class="btn btn-primary" id="add">+ Nueva pregunta</button>
<button class="btn btn-ghost" id="reload" title="Recargar">↻</button>
</div> </div>
<div id="list"><p class="empty" data-i18n="loading">Cargando…</p></div>
<div id="list"><p class="empty">Cargando…</p></div>
</main> </main>
<footer>Página interna · datos vía webhook · <span id="count">—</span></footer> <footer><span data-i18n="footer">Página interna · no indexada</span> · <span id="count">—</span></footer>
<dialog id="dlg">
<div class="dlg-head"><h3 id="dlg-title">Nueva pregunta</h3><button type="button" id="close" aria-label="Cerrar">×</button></div>
<form id="form">
<input type="hidden" id="f-id" />
<div class="field"><label for="f-area">Área</label><input id="f-area" name="area" placeholder="Paraguay Tax" required /></div>
<div class="field"><label for="f-q">Pregunta</label><input id="f-q" name="question" placeholder="¿…?" required /></div>
<div class="field"><label for="f-a">Respuesta</label><textarea id="f-a" name="answer" placeholder="Respuesta…" required></textarea></div>
<div class="dlg-foot">
<button type="button" class="btn btn-ghost" id="cancel">Cancelar</button>
<button type="submit" class="btn btn-primary" id="save">Guardar</button>
</div>
</form>
</dialog>
<script> <script>
const API = 'https://n8n.odoo4projects.com/webhook/oca-py/faq'; const API = 'https://n8n.odoo4projects.com/webhook/oca-py/faq';
const el = (id) => document.getElementById(id); const el = (id) => document.getElementById(id);
let items = [];
function flash(text, kind) { const I18N = {
es: {
title: 'Preguntas frecuentes', subtitle: 'OCA Paraguay · uso interno', badge: 'no indexada',
search: 'Buscar pregunta, área o respuesta…', loading: 'Cargando…', empty: 'Sin resultados.',
footer: 'Página interna · no indexada', count: (n, total) => n + ' de ' + total + ' preguntas',
group: 'General', errRead: 'No se pudo leer el webhook',
errData: 'El webhook respondió sin datos',
hint: 'En n8n, poné el nodo Webhook en "Respond: When Last Node Finishes".',
},
en: {
title: 'Frequently asked questions', subtitle: 'OCA Paraguay · internal use', badge: 'not indexed',
search: 'Search question, area or answer…', loading: 'Loading…', empty: 'No results.',
footer: 'Internal page · not indexed', count: (n, total) => n + ' of ' + total + ' questions',
group: 'General', errRead: 'Could not read the webhook',
errData: 'The webhook returned no data',
hint: 'In n8n, set the Webhook node to "Respond: When Last Node Finishes".',
}
};
const state = {
lang: (localStorage.getItem('ocaFaqLang') || (navigator.language || 'es').slice(0, 2)) === 'en' ? 'en' : 'es',
items: [],
};
const t = (k) => I18N[state.lang][k] ?? k;
function applyLang() {
document.documentElement.lang = state.lang;
document.querySelectorAll('[data-i18n]').forEach(n => { n.textContent = t(n.dataset.i18n); });
document.querySelectorAll('[data-i18n-placeholder]').forEach(n => { n.placeholder = t(n.dataset.i18nPlaceholder); });
document.querySelectorAll('.lang button').forEach(b => b.classList.toggle('on', b.dataset.lang === state.lang));
render();
}
function flash(text) {
const m = el('msg'); const m = el('msg');
m.textContent = text; m.textContent = text;
m.className = 'msg show ' + (kind || 'ok'); m.className = 'msg show err';
clearTimeout(flash._t);
flash._t = setTimeout(() => { m.className = 'msg'; }, 6000);
} }
function hideMsg() { el('msg').className = 'msg'; }
function normalize(payload) { function normalize(payload) {
if (Array.isArray(payload)) { if (Array.isArray(payload)) {
if (payload.length && Array.isArray(payload[0].data)) return payload[0].data; if (payload.length && Array.isArray(payload[0].data)) return payload[0].data;
@@ -128,35 +130,45 @@ function normalize(payload) {
return []; return [];
} }
async function load() { // Optional per-language variants: Question_en / Answer_es / area_en …
try { function pick(it, field) {
const res = await fetch(API, { headers: { Accept: 'application/json' } }); const key = Object.keys(it).find(k => k.toLowerCase() === (field + '_' + state.lang));
if (!res.ok) throw new Error('HTTP ' + res.status); if (key && it[key]) return it[key];
const payload = await res.json(); return it[field] ?? '';
items = normalize(payload);
render();
if (!items.length && !Array.isArray(payload)) {
flash('El webhook respondió sin datos (' + JSON.stringify(payload) + '). En n8n, poné el nodo Webhook en "Respond: When Last Node Finishes" para que devuelva la lista.', 'err');
}
} catch (err) {
el('list').innerHTML = '';
flash('No se pudo leer el webhook (' + err.message + '). Si es CORS, hay que habilitar el origen https://oca-py.org en n8n.', 'err');
} }
// Optional language tag on an item (lang / language / idioma)
function itemLang(it) {
const key = Object.keys(it).find(k => ['lang', 'language', 'idioma'].includes(k.toLowerCase()));
const v = key ? String(it[key] || '').toLowerCase().slice(0, 2) : '';
return (v === 'es' || v === 'en') ? v : '';
} }
function render() { function render() {
const term = el('q').value.trim().toLowerCase();
const list = el('list'); const list = el('list');
const shown = items.filter(it => { if (!state.items.length) {
list.innerHTML = '<p class="empty">' + t('empty') + '</p>';
el('count').textContent = t('count')(0, 0);
return;
}
const term = (el('q').value || '').trim().toLowerCase();
const shown = state.items.filter(it => {
const lang = itemLang(it);
if (lang && lang !== state.lang) return false;
if (!term) return true; if (!term) return true;
return [it.area, it.Question, it.Answer].join(' ').toLowerCase().includes(term); return [pick(it, 'area'), pick(it, 'Question'), pick(it, 'Answer')]
.join(' ').toLowerCase().includes(term);
}); });
el('count').textContent = shown.length + ' de ' + items.length + ' preguntas';
el('count').textContent = t('count')(shown.length, state.items.length);
list.innerHTML = ''; list.innerHTML = '';
if (!shown.length) { list.innerHTML = '<p class="empty">Sin resultados.</p>'; return; } if (!shown.length) { list.innerHTML = '<p class="empty">' + t('empty') + '</p>'; return; }
const groups = {}; const groups = {};
shown.forEach(it => { (groups[it.area || 'General'] = groups[it.area || 'General'] || []).push(it); }); shown.forEach(it => {
const area = pick(it, 'area') || t('group');
(groups[area] = groups[area] || []).push(it);
});
Object.keys(groups).sort().forEach(area => { Object.keys(groups).sort().forEach(area => {
const sec = document.createElement('section'); const sec = document.createElement('section');
@@ -169,99 +181,45 @@ function render() {
const card = document.createElement('details'); const card = document.createElement('details');
card.className = 'card'; card.className = 'card';
const sum = document.createElement('summary'); const sum = document.createElement('summary');
sum.textContent = it.Question || '(sin pregunta)'; sum.textContent = pick(it, 'Question') || '—';
card.appendChild(sum);
const body = document.createElement('div');
body.className = 'body';
const ans = document.createElement('p'); const ans = document.createElement('p');
ans.className = 'ans'; ans.className = 'ans';
ans.textContent = it.Answer || ''; ans.textContent = pick(it, 'Answer') || '';
body.appendChild(ans); card.append(sum, ans);
const acts = document.createElement('div');
acts.className = 'acts';
const edit = document.createElement('button');
edit.className = 'btn btn-ghost';
edit.textContent = 'Editar';
edit.onclick = () => openForm(it);
const del = document.createElement('button');
del.className = 'btn btn-danger';
del.textContent = 'Eliminar';
del.onclick = () => remove(it);
acts.append(edit, del);
body.appendChild(acts);
card.appendChild(body);
sec.appendChild(card); sec.appendChild(card);
}); });
list.appendChild(sec); list.appendChild(sec);
}); });
} }
function openForm(it) { async function load() {
el('dlg-title').textContent = it ? 'Editar pregunta' : 'Nueva pregunta'; try {
el('f-id').value = it ? it.id : ''; const res = await fetch(API, { headers: { Accept: 'application/json' } });
el('f-area').value = it ? (it.area || '') : ''; if (!res.ok) throw new Error('HTTP ' + res.status);
el('f-q').value = it ? (it.Question || '') : ''; const payload = await res.json();
el('f-a').value = it ? (it.Answer || '') : ''; state.items = normalize(payload);
el('dlg').showModal(); hideMsg();
el(it ? 'f-q' : 'f-area').focus(); render();
if (!state.items.length && !Array.isArray(payload)) {
flash(t('errData') + ' (' + JSON.stringify(payload) + '). ' + t('hint'));
}
} catch (err) {
state.items = [];
render();
flash(t('errRead') + ' (' + err.message + ').');
}
} }
async function save(ev) { document.querySelectorAll('.lang button').forEach(b => {
ev.preventDefault(); b.onclick = () => {
const id = el('f-id').value; state.lang = b.dataset.lang;
const payload = { localStorage.setItem('ocaFaqLang', state.lang);
area: el('f-area').value.trim(), applyLang();
Question: el('f-q').value.trim(),
Answer: el('f-a').value.trim(),
}; };
if (id) payload.id = Number(id);
const btn = el('save');
btn.disabled = true; btn.textContent = 'Guardando…';
try {
const res = await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}); });
if (!res.ok) throw new Error('HTTP ' + res.status);
el('dlg').close();
flash(id ? 'Pregunta actualizada.' : 'Pregunta creada.', 'ok');
await load();
} catch (err) {
flash('No se pudo guardar (' + err.message + ').', 'err');
} finally {
btn.disabled = false; btn.textContent = 'Guardar';
}
}
async function remove(it) {
if (!confirm('¿Eliminar «' + (it.Question || '') + '»?')) return;
try {
let res = await fetch(API, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: it.id }),
});
if (!res.ok) {
res = await fetch(API + '?id=' + encodeURIComponent(it.id), { method: 'DELETE' });
}
if (!res.ok) throw new Error('HTTP ' + res.status);
flash('Pregunta eliminada.', 'ok');
await load();
} catch (err) {
flash('No se pudo eliminar (' + err.message + ').', 'err');
}
}
el('add').onclick = () => openForm(null);
el('reload').onclick = load;
el('close').onclick = () => el('dlg').close();
el('cancel').onclick = () => el('dlg').close();
el('form').addEventListener('submit', save);
el('q').addEventListener('input', render); el('q').addEventListener('input', render);
applyLang();
load(); load();
</script> </script>
</body> </body>