Public Access
GET /webhook/feedback answers {"free":"","Free":9} (200), but the page
only read seats_left/seatsLeft, so the live page rendered state 4 ("We
cannot read the seat count right now."). Add the wire's own names after the
documented ones: leftKeys: [seats_left, seatsLeft, Free, free]. The existing
guard already skips "free": "" and requires a whole number >= 0, so 9 is
used and an empty string alone prints no number. Document the expected GET
reply (free/Free + the optional feedback[] shape and its publish gate) in the
constants comment. No total is returned, so "9 seats left" stays. Four
states, consent gate, claim body, leak rules and copy untouched.
763 lines
34 KiB
HTML
763 lines
34 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="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 } <- the intended shape
|
|
or { "free": 9 } <- what the live route answers today
|
|
or { "free": 0,
|
|
"feedback": [ { "text": "...", "name": "Jane", "publish": "yes" } ] }
|
|
A count arrives under the documented names first, then the live route's
|
|
"Free"/"free"; a count must be a whole number >= 0 or it is not on the wire
|
|
(so "free": "" is skipped and the next key is read). "feedback" (aka "entries")
|
|
carries the cohort's words: each entry is published only when its "publish" is
|
|
"yes" (words with the name) or "name" (words without it) — "no", missing or
|
|
unknown is never rendered. With no seats and no "feedback", the cohort-full
|
|
state states plainly that nothing is published yet (never a blank list).
|
|
|
|
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.
|
|
// "Free"/"free" are the live route's names today; they come last so the documented
|
|
// shape still wins if it ever arrives. "free": "" is skipped by pick() below.
|
|
leftKeys: ["seats_left", "seatsLeft", "Free", "free"],
|
|
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>
|