2 Commits
Author SHA1 Message Date
webmaster 092f1e44a2 feat(freelancer-directory): Best Practices — 12 items in two independent columns
Adds the twelfth item ("Look at a similar job they have already done") and
splits the accordion into two independent columns of six.

- Two independent columns (.bp-list > .bp-col, each its own grid, parent
  align-items:start) so opening an item grows only its own column and cannot
  shove the other. A single grid would re-size rows and make both columns
  jump on every click.
- DOM / reading order is 1–6 (left column) then 7–12 (right column) — a
  decision, not an accident.
- Exclusivity still one-open-at-a-time via the shared <details name> plus the
  inline JS fallback; re-proved, including the strip-the-name test.
- <=720 px collapses to a single column, no horizontal overflow.
2026-11-03 15:51:18 +00:00
webmaster 17f171d3d9 feat(freelancer-answer): publish the private answer page; save via PATCH
Private utility page reached only by an emailed link (noindex, absent from
sitemap.xml/llms.txt, linked from nowhere). The freelancer reads the client's
question and sends an answer back through the odoo_experts_quote webhook.

- Save the answer with PATCH, not POST: the question record already exists
  (the client's submission created it), so the answer is a partial update of
  it. POST risks a second record.
- A method the route refuses (404/405/501) now fails honestly: "We could not
  save your answer", the answer is kept for a retry, and the receipt shows
  ONLY on a 2xx.
- Body shape (match in the n8n workflow): {"has":"<hash>","answer":"<text>"}.

Hash spelling: both ?has= and ?hash= are accepted on the way in; "has" is sent
outward. Question field name in the GET reply: unverifiable (no valid hash
exists), so the page names the fields it received instead of guessing.
2026-11-03 15:51:15 +00:00
2 changed files with 502 additions and 4 deletions

No files matched your search

+468
View File
@@ -0,0 +1,468 @@
<!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" />
<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>
+34 -4
View File
@@ -960,9 +960,11 @@
}
/* ---- Best Practices accordion (page-scoped, additive). Reuses the house FAQ card
and "+/-" marker; items are full-width, single column. Exclusivity comes from the
native <details name> attribute, with a small JS fallback in the page script. All
items are CLOSED on load — nothing carries `open`. ---- */
and "+/-" marker. TWO INDEPENDENT COLUMNS of six (see .bp-list/.bp-col): each
column is its own grid, so opening an item grows only its own column and the
other column does not move. Exclusivity comes from the native <details name>
attribute, with a small JS fallback in the page script. All items are CLOSED on
load — nothing carries `open`. ---- */
.bp-lede {
max-width: 760px;
margin: 0 0 1.5rem;
@@ -970,10 +972,26 @@
line-height: 1.7;
color: var(--ink-soft);
}
/* Parent grid: two columns pinned to the top (align-items:start), so an open item
in one column cannot push the other column down. DOM order is 1–6 then 7–12. */
.bp-list {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: start;
column-gap: 2rem;
row-gap: 0.75rem;
max-width: 1100px;
}
.bp-col {
display: grid;
gap: 0.75rem;
max-width: 900px;
align-content: start;
}
/* 375 px and everything narrow: one column, no horizontal overflow. */
@media (max-width: 720px) {
.bp-list {
grid-template-columns: 1fr;
}
}
.bp-item {
background: var(--ivory);
@@ -1217,6 +1235,7 @@
<h2 class="section-title">Best Practices</h2>
<p class="bp-lede">Hiring a freelancer goes better when a handful of things are settled before the work starts. These are not rules we impose, and they are not a verdict on anyone listed here — they are simply the points where this kind of work most often goes wrong.</p>
<div class="bp-list">
<div class="bp-col">
<details class="bp-item" name="best-practices">
<summary id="bp1"><span class="bp-q">Agree the shape of the work first — workpackage or hours</span></summary>
@@ -1260,6 +1279,9 @@
</div>
</details>
</div>
<div class="bp-col">
<details class="bp-item" name="best-practices">
<summary id="bp7"><span class="bp-q">Prefer a proven third-party module — check the OCA first</span></summary>
<div class="bp-a" role="region" aria-labelledby="bp7">
@@ -1295,6 +1317,14 @@
</div>
</details>
<details class="bp-item" name="best-practices">
<summary id="bp12"><span class="bp-q">Look at a similar job they have already done</span></summary>
<div class="bp-a" role="region" aria-labelledby="bp12">
<p>Ask for one piece of work close to yours and look at it before you commit — a module, a migration, a report. Someone who can show you something near your problem is easier to trust than someone who can only describe it. If they cannot share a client's work, ask them to talk you through a job like yours instead.</p>
</div>
</details>
</div>
</div>
</div>
</section>