Public Access
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
092f1e44a2 | ||
|
|
17f171d3d9 |
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user