Files
webmaster 68a388cd78 feedback: no question is required — an empty or partial form submits
Remove the required flag from all 13 questions, drop the submit-blocking
validation (no error is ever announced), and send only the questions that
were answered. The error nodes and their CSS stay in place but nothing sets
them. Empty submit now fires 1 request and shows the receipt (was 0).
2026-11-04 10:20:22 +00:00

487 lines
22 KiB
HTML

<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Freelancer signup feedback — ODOO4projects</title>
<meta name="description" content="Tell us how the ODOO4projects freelancer signup went — every answer helps us improve the process before the directory opens." />
<!-- Private utility page. It is reached ONLY by the link we send, 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" />
<!-- Nothing in this page's address is a credential, but the page is private and the
feedback is a person's words: keep the URL out of any third party's logs. -->
<meta name="referrer" content="no-referrer" />
<link rel="canonical" href="https://odoo4projects.com/feedback/" />
<meta property="og:url" content="https://odoo4projects.com/feedback/" />
<meta property="og:title" content="Freelancer signup feedback — ODOO4projects" />
<meta property="og:description" content="Tell us how the ODOO4projects freelancer signup went — every answer helps us improve the process." />
<meta property="og:type" content="website" />
<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, same shell as /freelancer-edit/ and the top bar of
/odoo-community-developers/: brand line, one card, the site tokens (:root in style.css)
and the CI palette only. No hero, no footer, no site chrome beyond the brand line. */
* { 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: 760px; 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 1rem; }
.panel {
background: var(--ivory);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-rest);
}
.panel + .panel { margin-top: 1.25rem; }
h2 {
font-family: Marcellus, Georgia, serif;
font-weight: 400;
font-size: 1.15rem;
letter-spacing: 0.02em;
margin: 0 0 0.6rem;
}
.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); }
/* ---- Forms ---- */
.field { margin: 0 0 1.1rem; }
.field > label { display: block; font-weight: 600; font-size: 0.9rem; margin: 0 0 0.35rem; }
input[type="text"], input[type="email"], textarea, select {
width: 100%;
font: inherit;
padding: 0.6rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
color: var(--ink);
}
textarea { resize: vertical; }
input:focus-visible, textarea:focus-visible, select:focus-visible, button:focus-visible,
input[type="radio"]:focus-visible, input[type="checkbox"]:focus-visible {
outline: 2px solid var(--brass);
outline-offset: 1px;
}
.hint { font-size: 0.82rem; color: var(--ink-soft); margin: 0.35rem 0 0; }
fieldset { border: 1px solid var(--line); border-radius: var(--radius); padding: 0.9rem 1rem 0.4rem; margin: 0 0 1.1rem; }
legend { font-weight: 600; font-size: 0.9rem; padding: 0 0.35rem; }
.choice { display: flex; align-items: flex-start; gap: 0.55rem; margin: 0 0 0.5rem; font-size: 0.9rem; }
.choice input { margin-top: 0.28rem; flex: 0 0 auto; }
.field[data-bad="1"] input, .field[data-bad="1"] textarea, .field[data-bad="1"] select,
fieldset[data-bad="1"] { border-color: var(--brass-deep); }
.err { font-size: 0.82rem; color: var(--brass-deep); font-weight: 600; margin: 0.35rem 0 0; }
.btn {
display: inline-block;
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; }
.status.state-warn, .status.state-err { color: var(--brass-deep); }
.status.state-ok { color: var(--success); }
/* ---- Progress bar (two steps) ---- */
.progress { margin: 0 0 1.15rem; }
.steps-bar {
list-style: none;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem 0.6rem;
margin: 0;
padding: 0;
font-size: 0.85rem;
}
.steps-bar .step { display: flex; align-items: center; gap: 0.4rem; color: var(--ink-soft); }
.steps-bar .step + .step::before { content: "·"; margin-right: 0.6rem; color: var(--line-brass); }
.step-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.55rem;
height: 1.55rem;
border-radius: 999px;
border: 1px solid var(--line);
background: #fff;
font-size: 0.8rem;
line-height: 1;
}
/* The current step is the brass pill and the bolder label, and it is said out loud
("(current step)", .vh below) — weight and a word, so it is never colour alone. */
.steps-bar .step-current { color: var(--ink); font-weight: 600; }
.steps-bar .step-current .step-mark { background: var(--brass); border-color: var(--brass); color: var(--midnight); }
.link-signup { color: var(--brass-deep); font-weight: 600; }
/* Visually hidden, still read aloud. */
.vh {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
</style>
<!-- The one wire name and shape this page uses, in ONE place. POST only — the page reads
nothing on load. SHAPE (match this in the n8n workflow):
POST submitUrl { "form": "feedback", <one key per question, the answer> }
-> any 2xx = receipt shown; a non-2xx is reported as a failure, never as success.
The body carries the answers and nothing else: no name, no email, no identifier of any
kind. An unexpected shape is named, never guessed. -->
<script>
window.O4F = {
// The one route that receives the feedback form.
submitUrl: "https://n8n.odoo4projects.com/webhook/feedback"
};
</script>
<!-- Privacy-friendly analytics by Plausible (verbatim; the page carries 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>Freelancer signup feedback</h1>
<p class="lede">
We opened a free freelancer portal on ODOO4projects.com. Sign up there, then tell us how
it went — every answer helps us improve the process.
</p>
<!-- The portal link, visible next to the intro and not buried in step copy. It opens in
a new window and says so. It is a plain link, not the page's primary action —
the feedback form still is. -->
<p class="detail">
The directory opens on <strong>1 December</strong>. Signing up is free for freelancers
and clients, and the feedback you give here is what we fix before then.
<a
class="link-signup"
href="https://odoo4projects.com/odoo-community-developers/"
target="_blank"
rel="noopener noreferrer"
>Sign up on the freelancer portal (opens in a new window)</a>.
</p>
<!-- Filled by the script at the foot of the page. Empty on purpose: with JavaScript off
there is no form to render, so an empty container plus 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 show the feedback form.</p>
<p class="detail">
Turn JavaScript on in your browser and open this page again — or reply to the message
that brought you here.
</p>
</div>
</noscript>
</div>
</div>
</main>
<script>
(function () {
var C = window.O4F;
var app = document.getElementById("app");
// The freelancer portal — the directory page, at its TOP (its own introduction), not the
// directory's apply anchor: that anchor drops the visitor at the application form,
// mid-page, past the page's own intro. There is no separate login or account page, so this
// is the one target. The static .link-signup link in the markup above is the ONE source of
// this URL; the script-written link in step 1 reads it back, so the two can never disagree.
var PORTAL_URL = document.querySelector(".link-signup").href;
function el(tag, cls, text) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text; // textContent only: a reply is never markup
return n;
}
function show(nodes) {
while (app.firstChild) app.removeChild(app.firstChild);
for (var i = 0; i < nodes.length; i++) app.appendChild(nodes[i]);
return nodes;
}
function status(text, kind) {
var p = el("p", "status" + (kind ? " state-" + kind : ""), text);
p.setAttribute("role", "status");
p.setAttribute("aria-live", "polite");
return p;
}
function fetchJson(url, opts) {
return fetch(url, opts).then(function (r) {
return r.text().then(function (t) {
var body = null;
try { body = JSON.parse(t); } catch (e) { body = null; }
return { ok: r.ok, status: r.status, body: body };
});
});
}
function msgOf(body) {
if (!body || typeof body !== "object") return "";
var m = body.message || body.error || body.detail;
return typeof m === "string" ? m : "";
}
// A link to the freelancer portal. Opens in a new window and says so, so a screen-reader
// user is not surprised by a new tab.
function portalLink() {
var p = el("p", "detail");
var a = el("a", "link-signup");
a.href = PORTAL_URL;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.textContent = "Sign up on the freelancer portal (opens in a new window)";
p.appendChild(a);
return p;
}
// ---- Progress bar (two steps) ---------------------------------------------------------
// Step 1 is the signup on the portal, step 2 is this form. The page has no honest way to
// know the visitor has used the portal link, so step 1 stays current and nothing is
// guessed: the number, the bolder label and the spoken "(current step)" say which one is
// current, so it is never colour alone.
var STEP_LABELS = ["Sign up", "Give feedback"];
function progressBar(current) {
var nav = el("nav", "progress");
nav.setAttribute("aria-label", "Progress");
var ol = el("ol", "steps-bar");
STEP_LABELS.forEach(function (label, i) {
var n = i + 1;
var li = el("li", "step");
var mark = el("span", "step-mark");
mark.setAttribute("aria-hidden", "true");
mark.textContent = String(n);
li.appendChild(mark);
li.appendChild(el("span", "step-label", label));
if (n === current) {
li.className = "step step-current";
li.setAttribute("aria-current", "step");
li.appendChild(el("span", "vh", " (current step)"));
} else {
li.className = "step step-next";
}
ol.appendChild(li);
});
nav.appendChild(ol);
return nav;
}
// ---- Step 1 — the portal link, again, in the step's own copy -------------------------
function signupPanel() {
var box = el("div", "panel");
box.appendChild(el("h2", "", "Step 1 — sign up on the freelancer portal"));
box.appendChild(el("p", "detail",
"Sign up on the ODOO4projects freelancer portal and add your profile. The directory opens on 1 December."));
box.appendChild(portalLink());
return box;
}
// ---- The feedback form ----------------------------------------------------------------
// The n8n form's questions, with the copy corrected. The identity block (name, email,
// anonymous tick) and the publish-consent question are gone — do not bring them back.
// None of these questions is compulsory: the form submits with any subset answered, or none.
// The error nodes (.err) and their CSS are kept, but nothing sets them any more.
var FIELDS = [
{ id: "finalized", label: "Did you finalize the signup?", type: "select", options: [["yes", "Yes"], ["no", "No"]] },
{ id: "difficulty", label: "How hard was the signup?", type: "select", options: [["easy", "Easy"], ["ok", "OK"], ["hard", "Hard"]] },
{ id: "signup_feedback", label: "What is your feedback on the signup?", type: "textarea" },
{ id: "understood_scale", label: "Did you understand how we ask you to rate your skills? (the 15-point scale)", type: "select", options: [["yes", "Yes"], ["no", "No"]] },
{ id: "scale_feedback", label: "What is your feedback on the 15-point skill scale?", type: "textarea" },
{ id: "almost_stopped", label: "What almost stopped you from signing up?", type: "textarea", hint: "If nothing did, say so." },
{ id: "broke_or_confused", label: "Did anything break or confuse you?", type: "textarea" },
{ id: "design_shows_work", label: "Do you think our profile design shows what you do well?", type: "select", options: [["yes", "Yes"], ["so-so", "So-so"], ["no", "No"]] },
{ id: "design_feedback", label: "What can we improve about the profile design?", type: "textarea" },
{ id: "will_stay", label: "Will you stay on the platform?", type: "select", options: [["yes", "Yes"], ["no", "No"]] },
{ id: "improve_offer", label: "How can we improve what we offer you?", type: "textarea" },
{ id: "recommend", label: "Would you recommend this platform to a freelance colleague?", type: "select", options: [["yes", "Yes, good idea"], ["maybe", "Maybe"], ["no", "Preferably not"]] },
{ id: "expect_next", label: "What do you expect to happen next — and how soon?", type: "textarea" }
];
function fieldBlock(spec) {
var f = el("div", "field");
f.setAttribute("data-field", spec.id);
if (spec.type === "radio") {
var fs = el("fieldset");
fs.setAttribute("data-field", spec.id);
var lg = el("legend", "", spec.label);
fs.appendChild(lg);
spec.options.forEach(function (pair) {
var lab = el("label", "choice");
var r = el("input");
r.type = "radio";
r.name = spec.id;
r.value = pair[0];
r.id = spec.id + "-" + pair[0];
lab.appendChild(r);
lab.appendChild(el("span", "", pair[1]));
fs.appendChild(lab);
});
var er = el("p", "err", "");
er.hidden = true;
fs.appendChild(er);
return fs;
}
var lb = el("label", "", spec.label);
lb.setAttribute("for", "fb-" + spec.id);
var ctl;
if (spec.type === "select") {
ctl = el("select");
ctl.appendChild(new Option("— choose —", ""));
spec.options.forEach(function (pair) { ctl.appendChild(new Option(pair[1], pair[0])); });
} else if (spec.type === "textarea") {
ctl = el("textarea");
ctl.rows = 4;
} else {
ctl = el("input");
ctl.type = "text";
}
ctl.id = "fb-" + spec.id;
ctl.name = spec.id;
f.appendChild(lb);
f.appendChild(ctl);
if (spec.hint) f.appendChild(el("p", "hint", spec.hint));
var e = el("p", "err", "");
e.hidden = true;
f.appendChild(e);
return f;
}
function feedbackPanel() {
var box = el("div", "panel");
box.appendChild(el("h2", "", "How did it go?"));
box.appendChild(el("p", "detail", "Answer in your own words. Your answers come to us — nothing here is published."));
var form = el("form");
form.setAttribute("novalidate", "novalidate");
FIELDS.forEach(function (spec) { form.appendChild(fieldBlock(spec)); });
var line = status("", "");
var btn = el("button", "btn", "Send feedback");
btn.type = "submit";
form.appendChild(btn);
form.appendChild(line);
form.addEventListener("submit", function (ev) {
ev.preventDefault();
// Nothing here is compulsory, so there is nothing to validate and nothing to announce.
// The .err nodes and their CSS are kept in place in case a question is ever made
// compulsory again, but nothing here sets them: an empty form submits like any other.
// Build the body. Only the questions that were answered, and nothing else — no
// name, no email, no identifier, and no empty string for a question left blank.
var body = { form: "feedback" };
FIELDS.forEach(function (spec) {
var v = "";
if (spec.type === "radio") {
var checked = form.querySelector('input[name="' + spec.id + '"]:checked');
v = checked ? checked.value : "";
} else {
var ctl = form.querySelector("#fb-" + spec.id);
v = ctl ? ctl.value.trim() : "";
}
if (v) body[spec.id] = v;
});
btn.disabled = true;
line.className = "status";
line.textContent = "Sending…";
fetchJson(C.submitUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
}).then(function (res) {
if (!res.ok) {
btn.disabled = false;
line.className = "status state-warn";
line.textContent = "We could not send your feedback — the service answered " + res.status +
(msgOf(res.body) ? " — " + msgOf(res.body) : "") + ". Your answers are still here; try again.";
return;
}
line.className = "status state-ok";
line.textContent = "Thank you — your feedback reached us.";
btn.disabled = true;
form.querySelectorAll("input, textarea, select, button").forEach(function (n) {
if (n !== btn) n.disabled = true;
});
}).catch(function () {
btn.disabled = false;
line.className = "status state-warn";
line.textContent = "We could not reach the service. Your answers are still here; try again.";
});
});
box.appendChild(form);
return box;
}
// ---- Render ---------------------------------------------------------------------------
// One state, always: the form. Nothing is read on load, so there is no fetch here.
show([progressBar(1), signupPanel(), feedbackPanel()]);
})();
</script>
</body>
</html>