Files
ODOO4projects.com/freelancer-answer/index.html
T
webmaster 9d14652a57 hide [hidden] site-wide: one [hidden] { display:none !important } in style.css
A filtered-out directory card stayed visible. The card is targeted by an
id-based ready-state selector (`.js #directory[data-dir-state="ready"]
#dirGrid > .dev-card`, two ids), which outranked the old per-element patch
`.dev-card[hidden] { display:none }` — so `card.hidden = true` set the
attribute and every card stayed on screen while the count line correctly
read "Showing 1 of 4".

Fix: one rule, `[hidden] { display:none !important }`, early in /style.css.
A class- or id-based `display` can no longer beat the attribute, so this
cannot recur with the next class that sets `display`. The per-element
`[hidden]` patches are removed so a single mechanism governs:
 - odoo-community-developers: cards + filter row + pager + modal
   (load-bearing — each sets `display`); focus-sliders/noscript (redundant)
 - index/de/es `.hero-notice[hidden]`, odoo-black-friday `.ads-consent[hidden]`
   (redundant — those elements set no `display`)
Re-fingerprint local assets (style.css changed): /style.css?v 3332fbba93 ->
061517baef across 51 pages.

Measured in a real browser (local harness, stubbed webhook; no record created,
no probe write) — computed display + geometry, never the `hidden` property:
 - filtered to one card: the other three report display:none and height 0;
   the visible set (1) equals the count line ("Showing 1 of 4") — 1 == 1
 - HEAD (pre-fix) reproduces the defect: 3 cards hidden=true yet display:flex,
   height 599.38 each; 4 visible vs the count's 1
 - pager: display:none height 0 with one page; display:flex height 66.83 with
   12 cards (Page 1 of 2); page 2 hides page-1 cards (display:none)
 - modals closed: display:none height 0; load state: cards 0, pager/empty
   display:none, filter row visibility:hidden by design
 - 375 px: same result, no horizontal overflow; avatar ask-row top identical
   across a row (2053.97 / links 2086.91)
2026-11-03 22:34:39 +00:00

468 lines
17 KiB
HTML

<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Answer a question — ODOO4projects</title>
<meta name="description" content="Private page: an Odoo freelancer answers a client's question." />
<!-- Private utility page. It is reached ONLY by a link we email, never by search.
noindex+nofollow here, absent from sitemap.xml and llms.txt, and linked from nowhere
on the site. See [[draft-page-convention]] for the same five rule. -->
<meta name="robots" content="noindex, nofollow" />
<!-- While the link is open the address carries a credential. No claim-by-subdomain of the
hash travels to a third party (Google Fonts, and every other cross-origin request). -->
<meta name="referrer" content="no-referrer" />
<link rel="canonical" href="https://odoo4projects.com/freelancer-answer/" />
<link rel="icon" href="/images/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap"
media="print"
onload="this.media='all'"
/>
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="stylesheet" href="/style.css?v=061517baef" />
<style>
/* Utility page. Deliberately small: no hero, no footer, no site chrome beyond a
brand line. Reuses the site tokens (:root in style.css) and the CI palette only. */
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--cream);
color: var(--ink);
font-family: Inter, "Segoe UI", sans-serif;
line-height: 1.6;
}
.wrap {
width: 86%;
max-width: 1200px;
margin: 0 auto;
}
.topbar {
background: var(--midnight);
border-bottom: 1px solid var(--line-brass);
padding: 0.85rem 0;
}
.brand {
font-family: Marcellus, Georgia, serif;
font-size: 1.15rem;
letter-spacing: 0.04em;
color: #fff;
text-decoration: none;
}
.brand span {
color: var(--brass);
}
main {
padding: 2.5rem 0 4rem;
}
.card-wrap {
max-width: 720px;
margin: 0 auto;
}
h1 {
font-family: Marcellus, Georgia, serif;
font-weight: 400;
font-size: clamp(1.5rem, 4vw, 2.2rem);
letter-spacing: 0.02em;
margin: 0 0 0.4rem;
}
.lede {
color: var(--ink-soft);
margin: 0 0 1.75rem;
}
.panel {
background: var(--ivory);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-rest);
}
.state {
margin: 0;
font-weight: 500;
}
.state-warn,
.state-err {
color: var(--brass-deep);
}
.state-ok {
color: var(--success);
}
.detail {
margin: 0.5rem 0 0;
font-size: 0.9rem;
color: var(--ink-soft);
}
.qcard {
background: var(--midnight);
color: var(--cream);
border-radius: var(--radius);
padding: 1.1rem 1.25rem;
margin: 0 0 1.5rem;
}
.qcard-label {
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--brass);
margin: 0 0 0.4rem;
}
.qcard-text {
margin: 0;
font-size: 1.05rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.field label {
display: block;
font-weight: 600;
font-size: 0.9rem;
margin: 0 0 0.4rem;
}
textarea {
width: 100%;
font: inherit;
padding: 0.7rem 0.8rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
color: var(--ink);
resize: vertical;
}
textarea:focus-visible {
outline: 2px solid var(--brass);
outline-offset: 1px;
}
.hint {
font-size: 0.82rem;
color: var(--ink-soft);
margin: 0.4rem 0 0;
}
.btn {
display: inline-block;
margin-top: 1rem;
font-family: Marcellus, Georgia, serif;
font-size: 0.9rem;
letter-spacing: 0.04em;
border: 0;
border-radius: var(--radius-pill);
background: var(--brass);
color: var(--midnight);
padding: 0.7rem 1.5rem;
cursor: pointer;
}
.btn:hover {
background: var(--brass-bright);
}
.btn:focus-visible {
outline: 2px solid var(--midnight);
outline-offset: 2px;
}
.btn[disabled] {
opacity: 0.6;
cursor: default;
}
.status {
margin: 0.9rem 0 0;
font-weight: 500;
min-height: 1.2em;
}
</style>
<!-- 1) Read the credential, then take it OUT of the address bar.
The hash arrives as ?has=... or ?hash=... (both spellings accepted, 'has' first).
The page keeps it in memory for the GET, the PATCH and a retry; it must never sit in
the URL, because Plausible reports location.href and a shared/forwarded link would
leak it. Stripping the query BEFORE the tracker is loaded is what keeps it out of
analytics. All wire names and key lists live here, in one place. -->
<script>
window.O4P = {
// The node webhook: holds the question (GET) and receives the answer (PATCH).
webhook: "https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_quote",
// Query-string spellings to read, in order. The brief wrote "has" twice, so "has"
// wins; "hash" is accepted as the identical fallback. Confirm with the owner which
// spelling the emailed links will carry.
hashKeys: ["has", "hash"],
// Wire name used OUTWARD, both directions: GET query param and PATCH body key.
hashWireKey: "has",
// PATCH body key carrying the freelancer's answer.
answerWireKey: "answer",
// The ONLY response keys this page may render. Anything not listed is never read,
// so a client's email/name/phone sitting in the same object cannot reach the page.
questionKeys: ["question", "brief", "message", "text"]
};
(function () {
var params = new URLSearchParams(location.search);
var hash = "";
for (var i = 0; i < window.O4P.hashKeys.length; i++) {
var v = params.get(window.O4P.hashKeys[i]);
if (v) { hash = v; break; }
}
window.__ANSWER_HASH = hash;
// Drop the query string from the address + history. No reload loses anything:
// the hash now lives in memory above.
if (location.search) history.replaceState(null, "", location.pathname);
})();
</script>
<!-- 2) Privacy-friendly analytics by Plausible (verbatim; the page now has no query). -->
<script async src="https://plausible.office.odoo4projects.com/js/pa-NhA7Rf-79VopXFQXhSS4e.js"></script>
<script>
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
</head>
<body>
<header class="topbar">
<div class="wrap">
<a class="brand" href="https://odoo4projects.com/">ODOO<span>4</span>projects</a>
</div>
</header>
<main>
<div class="wrap">
<div class="card-wrap">
<h1>Answer a question</h1>
<p class="lede">
A client asked you a question through ODOO4projects. Write your answer below and it
goes back to us — we pass it on to them.
</p>
<!-- Filled by the script at the foot of the page. Empty on purpose: with
JavaScript off there is no way to read the question or send an answer, so an
empty container + the noscript note below is the honest picture. -->
<div id="app"></div>
<noscript>
<div class="panel">
<p class="state state-warn">This page needs JavaScript to load the question and to send your answer.</p>
<p class="detail">
Turn JavaScript on in your browser and open the link again — or reply to the email
that brought you here.
</p>
</div>
</noscript>
</div>
</div>
</main>
<script>
(function () {
var C = window.O4P;
var app = document.getElementById("app");
var hash = window.__ANSWER_HASH || "";
function el(tag, cls, text) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text; // textContent only: a question is never markup
return n;
}
function show(nodes) {
while (app.firstChild) app.removeChild(app.firstChild);
var box = el("div", "panel");
for (var i = 0; i < nodes.length; i++) box.appendChild(nodes[i]);
app.appendChild(box);
}
function status(text, kind) {
var p = el("p", "state" + (kind ? " state-" + kind : ""), text);
p.setAttribute("role", "status");
p.setAttribute("aria-live", "polite");
return p;
}
function retryButton() {
var b = el("button", "btn", "Try again");
b.type = "button";
b.addEventListener("click", load);
return b;
}
// The GET reply may be a bare object, an array, or wrapped in .data. Unwrap the
// envelope only — the field names inside are decided by questionKeys.
function unwrap(payload) {
if (Array.isArray(payload)) return payload[0] || null;
if (payload && typeof payload === "object") {
if (Array.isArray(payload.data)) return payload.data[0] || null;
if (Array.isArray(payload.items)) return payload.items[0] || null;
return payload;
}
return null;
}
function pickQuestion(obj) {
if (!obj) return "";
for (var i = 0; i < C.questionKeys.length; i++) {
var v = obj[C.questionKeys[i]];
if (typeof v === "string" && v.trim()) return v.trim();
}
return "";
}
function load() {
if (!hash) {
show([
status("This link is incomplete — it arrived without its code.", "warn"),
el("p", "detail", "Open it again from the email that brought you here.")
]);
return;
}
show([status("Loading your question…")]);
fetch(C.webhook + "?" + encodeURIComponent(C.hashWireKey) + "=" + encodeURIComponent(hash), {
headers: { Accept: "application/json" }
})
.then(function (res) {
if (!res.ok) {
// Live behaviour for an unknown hash: 500 {"code":0,"message":"No item to return was found"}.
return res.text().then(function (body) {
var msg = "";
try { msg = (JSON.parse(body) || {}).message || ""; } catch (e) { /* not json */ }
show([
status("This link is not valid, or it has expired.", "warn"),
el("p", "detail", "The service answered " + res.status + (msg ? " — " + msg : "") + "."),
el("p", "detail", "If you think that is wrong, reply to the email that brought you here.")
]);
});
}
return res.json().then(renderQuestion).catch(function () {
show([
status("We could not read the reply from the service.", "warn"),
el("p", "detail", "The reply was not in a form we understand. Please try again."),
retryButton()
]);
});
})
.catch(function () {
// Network failure: the hash is still in memory, so a retry needs no new link.
show([
status("We could not load your question just now.", "warn"),
el("p", "detail", "Your link has been kept, so you can try again."),
retryButton()
]);
});
}
function renderQuestion(payload) {
var obj = unwrap(payload);
var question = pickQuestion(obj);
if (!question) {
// Honest about a shape we did not expect: name the fields, never a value. A
// field name cannot identify the client; a value might.
var names = obj && typeof obj === "object" ? Object.keys(obj) : [];
show([
status("We could not read the question from the reply from the service.", "warn"),
el("p", "detail",
names.length
? "The reply carried these fields: " + names.join(", ") + "."
: "The reply carried no readable data.")
]);
return;
}
var card = el("div", "qcard");
card.appendChild(el("p", "qcard-label", "The question"));
card.appendChild(el("blockquote", "qcard-text", question));
var form = el("form", "answer-form");
form.setAttribute("novalidate", "novalidate"); // JS does the check, announced below
var field = el("div", "field");
var label = el("label", "", "Your answer");
label.setAttribute("for", "answerText");
var ta = el("textarea");
ta.id = "answerText";
ta.name = C.answerWireKey;
ta.rows = 8;
ta.required = true;
ta.setAttribute("aria-describedby", "answerHint");
var hint = el("p", "hint", "Your answer goes back to ODOO4projects, who pass it on. Write it as you would to the client.");
hint.id = "answerHint";
field.appendChild(label);
field.appendChild(ta);
field.appendChild(hint);
var btn = el("button", "btn", "Send answer");
btn.type = "submit";
var line = el("p", "status");
line.id = "answerStatus";
line.setAttribute("role", "status");
line.setAttribute("aria-live", "polite");
function setLine(text, kind) {
line.className = "status" + (kind ? " state-" + kind : "");
line.textContent = text;
}
form.addEventListener("submit", function (event) {
event.preventDefault();
var answer = ta.value.trim();
if (!answer) {
ta.setAttribute("aria-invalid", "true");
setLine("Please write your answer first.", "warn");
ta.focus();
return;
}
ta.removeAttribute("aria-invalid");
// The body shape, in one constant (C.hashWireKey + C.answerWireKey):
// {"has":"<hash>","answer":"<text>"}
// PATCH, not POST: the question record already exists (the client's submission
// created it), so this answer is a PARTIAL UPDATE of it — POST risks creating a
// second record. Match this method and these two keys in the n8n workflow.
var body = {};
body[C.hashWireKey] = hash;
body[C.answerWireKey] = answer;
btn.disabled = true;
setLine("Sending…", null);
fetch(C.webhook, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
})
.then(function (res) {
if (res.ok) {
// A RECEIPT, not a delivery claim: n8n answers 200 before the workflow
// runs, so this page cannot know the answer reached anyone. No time is
// promised. The receipt shows ONLY on a 2xx.
setLine("Thank you — we have your answer.", "ok");
form.reset();
return;
}
// Non-2xx: never show the receipt. A method the route refuses (404/405/501)
// is a clear failure, not a transient one — say so plainly and KEEP the
// answer in the box so it can be retried.
var refused = res.status === 404 || res.status === 405 || res.status === 501;
setLine(
refused
? "We could not save your answer. Please reply to the email that brought you here."
: "That did not send. Please try again.",
"err"
);
})
.catch(function () {
// Network failure: the hash stays in memory, the answer stays in the box.
setLine("That did not send. Please try again.", "err");
})
.then(function () { btn.disabled = false; });
});
form.appendChild(field);
form.appendChild(btn);
form.appendChild(line);
show([card, form]);
}
load();
})();
</script>
</body>
</html>