feat(feedback): seat-claim + feedback page in our own design

Replaces the n8n-hosted form. Four states — seats available (count read
from the wire, never hardcoded), seat claimed (deadline as a clock time,
one hour attributed to the workflow), cohort full (consented feedback
only), and count unavailable (today's real state: the route 500s).

One constant each for the GET status route and the POST submit route;
both wire shapes stated in the page. A count that did not come off the
wire is never printed; an entry without consent is never rendered and
"no name" shows the words without the name.

Feedback form recreated from the n8n questions with the copy corrected
(finalize, de-conflated recommend options, textarea for the offer
question, consistent question marks) plus the claim, name/email or an
anonymous tick, and the publish-consent field that gates the list.

noindex, out of sitemap.xml and llms.txt, no inbound links. The Upwork
name and email never reach the Plausible payload.
This commit is contained in:
webmaster committed 2026-11-03 19:43:58 +00:00
1 parent 8319a74f99
commit 5d2dc11fbc
1 file changed
+751
+751
View File
@@ -0,0 +1,751 @@
<!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="Claim a seat in the ODOO4projects freelancer signup and tell us how it went. The link goes to a named group of freelancers, not to search." />
<!-- 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="Claim a seat in the ODOO4projects freelancer signup and tell us how it went." />
<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" />
<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 1.5rem; }
.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); }
.seat {
font-family: Marcellus, Georgia, serif;
font-size: clamp(1.6rem, 5vw, 2.4rem);
letter-spacing: 0.02em;
margin: 0 0 0.35rem;
}
.seat strong { color: var(--brass-deep); font-weight: 400; }
/* ---- 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); }
/* ---- Claimed ---- */
.deadline {
font-family: Marcellus, Georgia, serif;
font-size: 1.35rem;
margin: 0 0 0.3rem;
}
.deadline strong { color: var(--brass-deep); font-weight: 400; }
.steps { margin: 0.4rem 0 0; padding-left: 1.2rem; font-size: 0.92rem; }
.steps li { margin: 0 0 0.3rem; }
/* ---- Feedback list (cohort full) ---- */
.fb { border-top: 1px solid var(--line); padding: 1rem 0 0; margin: 1rem 0 0; }
.fb:first-of-type { border-top: 0; padding-top: 0.2rem; margin-top: 0.4rem; }
.fb p { margin: 0 0 0.5rem; }
.fb cite { font-style: normal; font-size: 0.85rem; color: var(--brass-deep); }
.who {
font-size: 0.8rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--brass-deep);
margin: 0 0 1rem;
}
</style>
<!-- Every wire name and shape lives here, in ONE place. Two constants, one per verb:
GET reads the free-seat count and the collected feedback; POST claims a seat, and posts
the feedback form. SHAPES (this is the contract to match in the n8n workflow):
GET statusUrl (no query string)
-> 200 { "seats_left": 7, "seats_total": 10,
"feedback": [ { "text": "...", "name": "Jane", "publish": "yes" } ] }
POST submitUrl { "form": "claim", "upwork": "<their Upwork name>" }
-> 200 { "seats_left": 6, "seats_total": 10, "held_until": "2026-11-03T18:40:00Z" }
POST submitUrl { "form": "feedback", "upwork": "<their Upwork name>", "name": "...",
"email": "...", "anonymous": false, "finalized": "yes", ... ,
"publish": "yes" | "name" | "no" }
-> any 2xx = receipt shown; a non-2xx is reported as a failure, never as success.
An unexpected shape is named, never guessed: a count that did not come off the wire is
not printed, and an entry whose "publish" is missing or unknown is not rendered. -->
<script>
window.O4F = {
// The one route that answers the count + feedback (read-only).
statusUrl: "https://n8n.odoo4projects.com/webhook/feedback",
// The one route that receives a claim and the feedback form.
submitUrl: "https://n8n.odoo4projects.com/webhook/feedback",
// GET reply keys, in the order they are read. A key not listed here is never read.
leftKeys: ["seats_left", "seatsLeft"],
totalKeys: ["seats_total", "seatsTotal"],
feedbackKeys: ["feedback", "entries"],
// One feedback entry. A missing "" means "not on the wire", never a value.
entryTextKeys: ["text", "feedback", "message"],
entryNameKeys: ["name", "Name"],
entryPublishKeys: ["publish", "consent"],
// The three publish answers, character for character as the form sends them. ONLY "yes"
// and "name" are rendered; anything else — "no", missing, unknown — is never rendered.
PUBLISH_YES: "yes",
PUBLISH_NAME: "name",
PUBLISH_NO: "no",
// The POST reply key that carries the seat deadline (ISO 8601).
heldUntilKeys: ["held_until", "heldUntil", "deadline"]
};
</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. Claim a seat here, then finish
the signup and tell us how it went — every answer helps us improve the process.
</p>
<!-- Filled by the script at the foot of the page. Empty on purpose: with JavaScript off
there is no way to read the seat count or to claim, 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 read the seat count and to claim a seat.</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");
var claimedUpwork = ""; // the seat claim: an identifier, kept in memory, never in the URL
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 panel(children) {
var box = el("div", "panel");
for (var i = 0; i < children.length; i++) {
if (children[i]) box.appendChild(children[i]);
}
return box;
}
function status(text, kind) {
var p = el("p", "status" + (kind ? " state-" + kind : ""), text);
p.setAttribute("role", "status");
p.setAttribute("aria-live", "polite");
return p;
}
// Read one value by its list of accepted keys. A key not listed is never read.
function pick(obj, keys) {
if (!obj || typeof obj !== "object") return undefined;
for (var i = 0; i < keys.length; i++) {
var v = obj[keys[i]];
if (v !== undefined && v !== null && v !== "") return v;
}
return undefined;
}
// A count must come off the wire as a whole number >= 0. Anything else is "not on the wire".
function count(obj, keys) {
var v = pick(obj, keys);
if (typeof v === "number" && isFinite(v) && v >= 0) return Math.floor(v);
if (typeof v === "string" && /^\s*\d+\s*$/.test(v)) return parseInt(v, 10);
return null;
}
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 : "";
}
function retryButton(label, fn) {
var b = el("button", "btn", label);
b.type = "button";
b.addEventListener("click", fn);
return b;
}
// ---- The four states -----------------------------------------------------------------
// 1. Seats available. The number is read from the wire; a cap is not a remaining count.
function stateAvailable(seats, total) {
var h = el("p", "seat");
h.appendChild(el("strong", "", String(seats)));
h.appendChild(document.createTextNode(
total != null ? " of " + total + " seats left" : (seats === 1 ? " seat left" : " seats left")
));
var form = el("form");
form.setAttribute("novalidate", "novalidate");
var field = el("div", "field");
var label = el("label", "", "Your Upwork name");
label.setAttribute("for", "fbUpwork");
var input = el("input");
input.type = "text";
input.id = "fbUpwork";
input.name = "upwork";
input.autocomplete = "off";
input.required = true;
var hint = el("p", "hint", "Exactly as it appears on your Upwork profile — this is how we match your seat to your signup.");
field.appendChild(label);
field.appendChild(input);
field.appendChild(hint);
var err = el("p", "err", "");
err.hidden = true;
field.appendChild(err);
form.appendChild(field);
var line = status("", "");
var btn = el("button", "btn", "Claim a seat");
btn.type = "submit";
form.appendChild(btn);
form.appendChild(line);
form.addEventListener("submit", function (ev) {
ev.preventDefault();
var name = input.value.trim();
if (!name) {
field.setAttribute("data-bad", "1");
err.textContent = "Enter your Upwork name to claim a seat.";
err.hidden = false;
input.focus();
return;
}
field.setAttribute("data-bad", "0");
err.hidden = true;
btn.disabled = true;
line.className = "status";
line.textContent = "Claiming your seat…";
// The claim. The Upwork name is an identifier: it goes to our workflow and nowhere
// else — never into the address and never into an analytics payload.
fetchJson(C.submitUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ form: "claim", upwork: name })
}).then(function (res) {
if (!res.ok) {
btn.disabled = false;
line.className = "status state-warn";
line.textContent = "We could not claim your seat — the service answered " + res.status +
(msgOf(res.body) ? " — " + msgOf(res.body) : "") + ". Try again.";
return;
}
claimedUpwork = name;
stateClaimed(count(res.body, C.leftKeys), pick(res.body, C.heldUntilKeys));
}).catch(function () {
btn.disabled = false;
line.className = "status state-warn";
line.textContent = "We could not reach the service to claim your seat. Check your connection and try again.";
});
});
show([
panel([h, el("p", "detail", "One seat per freelancer, and there is a fixed number for this cohort.")]),
panel([el("h2", "", "Claim your seat"), form])
]);
}
// 2. Seat claimed — held for one hour. The deadline is shown as a clock time when the
// workflow tells us when it started; the hour is the workflow's, not this page's.
function stateClaimed(seats, heldUntil) {
var dl = el("p", "deadline");
var t = clockTime(heldUntil);
if (t) {
dl.appendChild(document.createTextNode("Your seat is held until "));
dl.appendChild(el("strong", "", t));
dl.appendChild(document.createTextNode("."));
} else {
dl.textContent = "Your seat is held.";
}
var steps = el("ul", "steps");
steps.appendChild(el("li", "", "Create your account on the ODOO4projects freelancer portal."));
steps.appendChild(el("li", "", "Add the links to your profiles (Upwork, LinkedIn, Fiverr)."));
steps.appendChild(el("li", "", "Fill in your focus, about you and the 15-point skill profile."));
steps.appendChild(el("li", "", "Submit the application. We match your signup to the Upwork name you just gave."));
var claimed = el("div", "panel");
claimed.appendChild(el("h2", "", "Seat claimed"));
claimed.appendChild(dl);
claimed.appendChild(el("p", "detail",
"The one hour to finish your signup comes from our signup workflow, not from this page."));
if (seats != null) {
claimed.appendChild(el("p", "who", (seats === 1 ? "1 seat left" : seats + " seats left") + " after your claim"));
}
claimed.appendChild(steps);
show([claimed, feedbackPanel()]);
}
function clockTime(iso) {
if (!iso) return "";
var d = new Date(iso);
if (isNaN(d.getTime())) return "";
try {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
} catch (e) {
return "";
}
}
// 3. Cohort full — consented feedback only.
function stateFull(entries) {
var shown = entries.filter(isPublishable);
var box = panel([el("h2", "", "This cohort is full")]);
box.appendChild(el("p", "detail", "All seats for this cohort have been claimed, so it is closed. Thank you — here is what the freelancers who took part told us."));
if (shown.length) {
shown.forEach(function (e) {
var fig = el("figure", "fb");
fig.appendChild(el("p", "", String(pick(e, C.entryTextKeys) || "")));
var nm = pick(e, C.entryNameKeys);
var consent = pick(e, C.entryPublishKeys);
if (consent === C.PUBLISH_YES && nm) {
fig.appendChild(el("cite", "", "— " + String(nm)));
} else {
fig.appendChild(el("cite", "", "— published without a name"));
}
box.appendChild(fig);
});
} else {
box.appendChild(el("p", "detail", "No feedback has been published yet."));
}
show([box]);
}
// 4. Count unavailable — today's real state. No number, no claim button that cannot work.
function stateUnavailable(httpStatus, msg) {
var nodes = [
status("We cannot read the seat count right now.", "warn"),
el("p", "detail", "The seat count could not be read from our service, so we are not showing a number and we are not offering a claim that cannot work. Please try again shortly.")
];
if (httpStatus) {
nodes.push(el("p", "detail", "The service answered " + httpStatus + (msg ? " — " + msg : "") + "."));
}
nodes.push(retryButton("Try again", load));
show([panel(nodes)]);
}
function stateLoadFailed(httpStatus, msg) {
var nodes = [
status("We could not reach the service.", "warn"),
el("p", "detail", "The seat count could not be read, so no number is shown and no seat is offered right now.")
];
if (httpStatus) nodes.push(el("p", "detail", "The service answered " + httpStatus + (msg ? " — " + msg : "") + "."));
nodes.push(retryButton("Try again", load));
show([panel(nodes)]);
}
function isPublishable(entry) {
var c = pick(entry, C.entryPublishKeys);
return c === C.PUBLISH_YES || c === C.PUBLISH_NAME;
}
// ---- The feedback form ----------------------------------------------------------------
// The n8n form's questions, with the copy corrected, plus the fields the owner asked for.
var FIELDS = [
{ id: "finalized", label: "Did you finalize the signup?", type: "select", options: [["yes", "Yes"], ["no", "No"]], required: true },
{ id: "difficulty", label: "How hard was the signup?", type: "select", options: [["easy", "Easy"], ["ok", "OK"], ["hard", "Hard"]], required: true },
{ id: "signup_feedback", label: "What is your feedback on the signup?", type: "textarea", required: true },
{ 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"]], required: true },
{ id: "scale_feedback", label: "What is your feedback on the 15-point skill scale?", type: "textarea", required: true },
{ id: "almost_stopped", label: "What almost stopped you from signing up?", type: "textarea", required: true, hint: "If nothing did, say so." },
{ id: "broke_or_confused", label: "Did anything break or confuse you?", type: "textarea", required: true },
{ 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"]], required: true },
{ id: "design_feedback", label: "What can we improve about the profile design?", type: "textarea", required: true },
{ id: "will_stay", label: "Will you stay on the platform?", type: "select", options: [["yes", "Yes"], ["no", "No"]], required: true },
{ id: "improve_offer", label: "How can we improve what we offer you?", type: "textarea", required: true },
{ id: "recommend", label: "Would you recommend this platform to a freelance colleague?", type: "select", options: [["yes", "Yes"], ["maybe", "Maybe"], ["no", "Preferably not"]], required: true },
{ id: "heard_about", label: "Where did you first hear about this platform?", type: "text", required: true },
{ id: "expect_next", label: "What do you expect to happen next — and how soon?", type: "textarea", required: true },
{ id: "publish", label: "May we publish your feedback with your name?", type: "radio", options: [["yes", "Yes, with my name"], ["name", "Yes, but not with my name"], ["no", "No"]], required: true }
];
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. Nothing here is published unless you say so, and you can ask us to leave your name off."));
var form = el("form");
form.setAttribute("novalidate", "novalidate");
// Name + email, or an explicit anonymous tick.
var ident = el("fieldset");
ident.setAttribute("data-field", "identity");
ident.appendChild(el("legend", "", "About you"));
var nf = el("div", "field");
var nl = el("label", "", "Your name");
nl.setAttribute("for", "fb-name");
var ni = el("input");
ni.type = "text";
ni.id = "fb-name";
ni.name = "name";
ni.autocomplete = "name";
nf.appendChild(nl); nf.appendChild(ni);
ident.appendChild(nf);
var ef = el("div", "field");
var elab = el("label", "", "Your email");
elab.setAttribute("for", "fb-email");
var ei = el("input");
ei.type = "email";
ei.id = "fb-email";
ei.name = "email";
ei.autocomplete = "email";
ef.appendChild(elab); ef.appendChild(ei);
ident.appendChild(ef);
var anonLab = el("label", "choice");
var anon = el("input");
anon.type = "checkbox";
anon.id = "fb-anonymous";
anon.name = "anonymous";
anonLab.appendChild(anon);
anonLab.appendChild(el("span", "", "Send this anonymously — do not record my name or email."));
ident.appendChild(anonLab);
var idErr = el("p", "err", "");
idErr.hidden = true;
ident.appendChild(idErr);
form.appendChild(ident);
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);
// The anonymous tick hides the name/email and removes "with my name" as an option.
function syncAnon() {
var on = anon.checked;
nf.hidden = on;
ef.hidden = on;
var yesName = form.querySelector('input[name="publish"][value="yes"]');
if (yesName) {
yesName.disabled = on;
if (on && yesName.checked) {
var alt = form.querySelector('input[name="publish"][value="name"]');
if (alt) alt.checked = true;
}
}
}
anon.addEventListener("change", syncAnon);
syncAnon();
form.addEventListener("submit", function (ev) {
ev.preventDefault();
var bad = false;
FIELDS.forEach(function (spec) {
var holder = form.querySelector('[data-field="' + spec.id + '"]');
var err = holder.querySelector(".err");
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 (spec.required && !v) {
holder.setAttribute("data-bad", "1");
err.textContent = "Please answer this before sending.";
err.hidden = false;
bad = true;
} else {
holder.removeAttribute("data-bad");
err.hidden = true;
}
});
var anonymous = anon.checked;
if (!anonymous) {
var nm = ni.value.trim(), em = ei.value.trim();
if (!nm || !em) {
ident.setAttribute("data-bad", "1");
idErr.textContent = "Add your name and email — or tick “Send this anonymously”.";
idErr.hidden = false;
bad = true;
} else {
ident.removeAttribute("data-bad");
idErr.hidden = true;
}
} else {
ident.removeAttribute("data-bad");
idErr.hidden = true;
}
if (bad) {
line.className = "status state-warn";
line.textContent = "Some answers are missing — see the notes above.";
var firstBad = form.querySelector('[data-bad="1"] input, [data-bad="1"] textarea, [data-bad="1"] select');
if (firstBad) firstBad.focus();
return;
}
// Build the body. An anonymous sender sends no name and no email.
var body = {
form: "feedback",
upwork: claimedUpwork,
name: anonymous ? "" : ni.value.trim(),
email: anonymous ? "" : ei.value.trim(),
anonymous: anonymous
};
FIELDS.forEach(function (spec) {
if (spec.type === "radio") {
var checked = form.querySelector('input[name="' + spec.id + '"]:checked');
body[spec.id] = checked ? checked.value : "";
} else {
var ctl = form.querySelector("#fb-" + spec.id);
body[spec.id] = ctl ? ctl.value.trim() : "";
}
});
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;
}
// ---- Load -----------------------------------------------------------------------------
function load() {
show([panel([status("Reading the seat count…", "")])]);
fetchJson(C.statusUrl, { method: "GET", cache: "no-store" }).then(function (res) {
if (!res.ok) {
// A workflow that throws is our problem, and it must not look like a closed cohort.
stateUnavailable(res.status, msgOf(res.body));
return;
}
var seats = count(res.body, C.leftKeys);
var total = count(res.body, C.totalKeys);
var entries = pick(res.body, C.feedbackKeys);
if (seats === null) {
// No count came off the wire: print no number.
stateUnavailable(null, "the reply carried no seat count");
return;
}
if (seats === 0) {
stateFull(Array.isArray(entries) ? entries : []);
return;
}
stateAvailable(seats, total);
}).catch(function () {
stateLoadFailed(null, "");
});
}
load();
})();
</script>
</body>
</html>