FAQ interna (no indexada) con CRUD sobre el webhook de n8n
Nueva página /faq/ que lee las preguntas del webhook, agrupa por área, permite buscar y crear/editar/eliminar. Incluye meta robots noindex y robots.txt con Disallow: /faq.
This commit is contained in:
1 parent
588dfd6548
commit
ed645942a4
2 files changed
+270
No files matched your search
+264
@@ -0,0 +1,264 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex" />
|
||||||
|
<meta name="googlebot" content="noindex, nofollow" />
|
||||||
|
<title>FAQ interna — OCA Paraguay</title>
|
||||||
|
<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.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Sora:wght@600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--plum:#714B67; --plum-dark:#4b2f43; --violet:#6C4CE0; --violet-soft:#8f7bf0;
|
||||||
|
--mint:#16C79A; --mint-dark:#0e8f6e; --py-red:#D52B1E; --py-blue:#0038A8;
|
||||||
|
--ink:#231b2b; --muted:#6b6473; --line:#e7e3ec; --bg:#faf9fc;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
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}
|
||||||
|
.wrap{max-width:980px;margin:0 auto;padding:0 20px}
|
||||||
|
.head{display:flex;align-items:center;gap:14px;padding:14px 0}
|
||||||
|
.head img{width:38px;height:38px;border-radius:9px}
|
||||||
|
.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}
|
||||||
|
.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}
|
||||||
|
main{padding:28px 0 60px}
|
||||||
|
.bar{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:22px}
|
||||||
|
.bar input[type=search]{flex:1 1 240px;padding:11px 14px;border:1px solid var(--line);border-radius:12px;font:inherit;background:#fff}
|
||||||
|
.bar input:focus{outline:2px solid rgba(108,76,224,.25);border-color:var(--violet-soft)}
|
||||||
|
.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}
|
||||||
|
.btn-primary{background:var(--violet);color:#fff}
|
||||||
|
.btn-primary:hover{background:#5b3fd0}
|
||||||
|
.btn-ghost{background:#fff;border-color:var(--line);color:var(--ink)}
|
||||||
|
.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.show{display:block}
|
||||||
|
.msg.err{background:#fdf1ef;color:#8f2117;border:1px solid #f0c9c4}
|
||||||
|
.msg.ok{background:#eefaf5;color:#0b6b52;border:1px solid #bfeadd}
|
||||||
|
.area{margin:0 0 28px}
|
||||||
|
.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}
|
||||||
|
.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::after{content:"+";margin-left:auto;color:var(--violet);font-weight:800;font-size:1.1rem}
|
||||||
|
.card[open] summary::after{content:"–"}
|
||||||
|
.card .body{padding:0 18px 16px;color:#41394a}
|
||||||
|
.card .ans{white-space:pre-line;margin:0 0 14px;font-size:.93rem}
|
||||||
|
.acts{display:flex;gap:8px;flex-wrap:wrap}
|
||||||
|
.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>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<div class="wrap head">
|
||||||
|
<img src="../img/logo.svg" alt="OCA Paraguay" width="38" height="38" />
|
||||||
|
<h1>Preguntas frecuentes<small>OCA Paraguay · uso interno</small></h1>
|
||||||
|
<span class="noidx">no indexada</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="wrap">
|
||||||
|
<div class="msg" id="msg"></div>
|
||||||
|
|
||||||
|
<div class="bar">
|
||||||
|
<input type="search" id="q" placeholder="Buscar pregunta, área o respuesta…" 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 id="list"><p class="empty">Cargando…</p></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>Página interna · datos vía webhook · <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>
|
||||||
|
const API = 'https://n8n.odoo4projects.com/webhook/oca-py/faq';
|
||||||
|
const el = (id) => document.getElementById(id);
|
||||||
|
let items = [];
|
||||||
|
|
||||||
|
function flash(text, kind) {
|
||||||
|
const m = el('msg');
|
||||||
|
m.textContent = text;
|
||||||
|
m.className = 'msg show ' + (kind || 'ok');
|
||||||
|
clearTimeout(flash._t);
|
||||||
|
flash._t = setTimeout(() => { m.className = 'msg'; }, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(payload) {
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
if (payload.length && Array.isArray(payload[0].data)) return payload[0].data;
|
||||||
|
if (payload.length && payload[0].id !== undefined) return payload;
|
||||||
|
}
|
||||||
|
if (payload && Array.isArray(payload.data)) return payload.data;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(API, { headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
|
items = normalize(await res.json());
|
||||||
|
render();
|
||||||
|
} 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const term = el('q').value.trim().toLowerCase();
|
||||||
|
const list = el('list');
|
||||||
|
const shown = items.filter(it => {
|
||||||
|
if (!term) return true;
|
||||||
|
return [it.area, it.Question, it.Answer].join(' ').toLowerCase().includes(term);
|
||||||
|
});
|
||||||
|
el('count').textContent = shown.length + ' de ' + items.length + ' preguntas';
|
||||||
|
list.innerHTML = '';
|
||||||
|
if (!shown.length) { list.innerHTML = '<p class="empty">Sin resultados.</p>'; return; }
|
||||||
|
|
||||||
|
const groups = {};
|
||||||
|
shown.forEach(it => { (groups[it.area || 'General'] = groups[it.area || 'General'] || []).push(it); });
|
||||||
|
|
||||||
|
Object.keys(groups).sort().forEach(area => {
|
||||||
|
const sec = document.createElement('section');
|
||||||
|
sec.className = 'area';
|
||||||
|
const h = document.createElement('h2');
|
||||||
|
h.textContent = area;
|
||||||
|
sec.appendChild(h);
|
||||||
|
|
||||||
|
groups[area].forEach(it => {
|
||||||
|
const card = document.createElement('details');
|
||||||
|
card.className = 'card';
|
||||||
|
const sum = document.createElement('summary');
|
||||||
|
sum.textContent = it.Question || '(sin pregunta)';
|
||||||
|
card.appendChild(sum);
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'body';
|
||||||
|
const ans = document.createElement('p');
|
||||||
|
ans.className = 'ans';
|
||||||
|
ans.textContent = it.Answer || '';
|
||||||
|
body.appendChild(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);
|
||||||
|
});
|
||||||
|
list.appendChild(sec);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openForm(it) {
|
||||||
|
el('dlg-title').textContent = it ? 'Editar pregunta' : 'Nueva pregunta';
|
||||||
|
el('f-id').value = it ? it.id : '';
|
||||||
|
el('f-area').value = it ? (it.area || '') : '';
|
||||||
|
el('f-q').value = it ? (it.Question || '') : '';
|
||||||
|
el('f-a').value = it ? (it.Answer || '') : '';
|
||||||
|
el('dlg').showModal();
|
||||||
|
el(it ? 'f-q' : 'f-area').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
const id = el('f-id').value;
|
||||||
|
const payload = {
|
||||||
|
area: el('f-area').value.trim(),
|
||||||
|
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);
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
Disallow: /faq
|
||||||
|
Disallow: /faq/
|
||||||
|
|
||||||
|
# The FAQ page is internal (noindex). Not a spider trap.
|
||||||
Reference in new issue
Block a user