feat(freelancer-directory): CSV wire shape for languages/specialities, wire the quote route, add invite-a-friend

- languages/specialities now POST as comma-separated STRINGS ("English,German"),
  not arrays; the build script splits them into separate filter options and tags.
- the client request form is no longer inert: it posts to odoo_experts_quote with
  an explicit freelancer pick-list (never an implicit all) and a client address.
- new invite-a-friend form posts to odoo_experts_invite_friend (sender name +
  friend email); we send the invitation, not the sender's address.
- both new forms show success only on a returned 200 (n8n answers 200 before the
  workflow runs) and promise no timing.
- build-directory.py: render the pick-list, fix the country option casing.
- pages stay drafts (noindex, not in sitemap/llms, no inbound links).
This commit is contained in:
webmaster committed 2026-11-03 11:54:38 +00:00
1 parent 832bb41206
commit 63e43ce7a1
2 files changed
+261 -16

No files matched your search

+244 -15
View File
@@ -460,6 +460,59 @@
color: #b3452f;
}
/* The explicit freelancer pick-list (form 3). A real list over the directory —
never a free-text box and never an implicit "all". */
.dir-pickfield {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.85rem 0.9rem 0.6rem;
min-width: 0;
}
.section-dark .dir-pickfield {
border-color: var(--line-brass);
}
.dir-pickfield legend {
font-size: 0.8rem;
font-weight: 600;
color: var(--midnight);
padding: 0 0.35rem;
}
.section-dark .dir-pickfield legend {
color: rgba(255, 255, 255, 0.85);
}
.dir-picklist {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
}
.dir-picklist .pick {
display: flex;
align-items: baseline;
gap: 0.45rem;
font-size: 0.9rem;
color: var(--ink);
}
.section-dark .dir-picklist .pick {
color: rgba(255, 255, 255, 0.85);
}
.dir-picklist .pick input {
width: auto;
margin: 0;
align-self: center;
}
.dir-picklist .pick .hint {
font-size: 0.78rem;
color: var(--ink-soft);
}
.section-dark .dir-picklist .pick .hint {
color: rgba(255, 255, 255, 0.6);
}
.dir-picklist-empty {
margin: 0;
font-size: 0.9rem;
color: var(--ink-soft);
}
/* Final CTA */
.cmp-cta {
text-align: center;
@@ -601,7 +654,7 @@
<h2 class="section-title">How this works</h2>
<div class="oc-body">
<p><strong>Write to a freelancer directly.</strong> Each card carries what that freelancer published — their focus, their languages, the work they take on, and their contact details where they chose to publish them. We do not rate them and we do not recommend one over another.</p>
<p><strong>Or let us ask them for you.</strong> We forward your brief to the freelancers you choose, without your name attached. That service is not switched on yet — see <a href="#request">Post a request</a> below.</p>
<p><strong>Or let us ask them for you.</strong> Post your brief once and pick the freelancers you want it to reach. We forward it to them with no name attached, and we send their answers back to you — see <a href="#request">Post a request</a> below.</p>
<p>Either way the work is agreed between you and the freelancer. We publish the listings; the project and the contract are yours.</p>
</div>
</div>
@@ -650,18 +703,47 @@
</div>
</section>
<!-- Client request / anonymous get-quote — NOT BUILT. The feature is announced,
not offered: there is no form and no submit button, so a client can never
write a brief into a submit that refuses it. When the workflow exists, the
freelancer selection must be an explicit pick-list over the directory — never
an implicit "all" — and the endpoint must be the odoo_experts webhook or a new
one the user names, nothing else. -->
<!-- Client request / anonymous get-quote — WIRED to the quote route
(odoo_experts_quote). The freelancer selection is an explicit pick-list over
the directory, built at build time — NEVER a free-text box and NEVER an
implicit "all". n8n answers 200 BEFORE the workflow runs and also on a bad
payload, so this form shows the success state ONLY on a returned 200 and
promises no timing the response cannot substantiate. Rebuild the pick-list with:
python3 /root/workspace/webmaster/work/build-directory.py -->
<section class="section-light" id="request">
<div class="container">
<h2 class="section-title">Post a request — coming soon</h2>
<h2 class="section-title">Post a request</h2>
<div class="oc-body">
<p>We are building the anonymous request: you write your brief once, we forward it to the freelancers you choose with no name attached, and we send their answers back to you. It is not switched on yet. Until it is, email <a href="mailto:hello@odoo4projects.com">hello@odoo4projects.com</a> with your brief and we will pass it on by hand.</p>
<p>Pick the freelancers you want to hear from, describe the work in your own words, and leave an address for the answers. We forward your brief to them with <strong>no name attached</strong>, collect what they send back, and pass it on to you. The freelancers answer when they can — we do not promise a time.</p>
</div>
<div class="dir-forms">
<form class="form-card" id="requestForm" novalidate>
<div class="form-grid">
<fieldset class="form-field full dir-pickfield">
<legend>Freelancers to ask <span class="hint">(pick at least one — we never send to everyone)</span></legend>
<div class="dir-picklist" id="reqPicklist">
<!-- REQUEST-PICKLIST-START -->
<label class="pick"><input type="checkbox" name="freelancers" value="oliver" data-id="1" /> <span>oliver</span> <span class="hint">vieoem</span></label>
<!-- REQUEST-PICKLIST-END -->
</div>
</fieldset>
<div class="form-field full">
<label for="rqBrief">Your brief</label>
<textarea id="rqBrief" name="brief" rows="6" required placeholder="What you need built, the Odoo version you run, and anything else a freelancer would need to quote."></textarea>
</div>
<div class="form-field full">
<label for="rqEmail">Your email</label>
<input id="rqEmail" name="email" type="email" autocomplete="email" required />
<span class="hint">Only so we can send their answers back to you. The freelancers never see it.</span>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-brass">Send my request</button>
<span class="form-status" id="requestStatus" role="status"></span>
</div>
</div>
</form>
</div>
<noscript><p class="form-noscript">This form needs JavaScript. Email <a href="mailto:hello@odoo4projects.com">hello@odoo4projects.com</a> with your brief and we will pass it on by hand.</p></noscript>
</div>
</section>
@@ -760,12 +842,45 @@
</div>
</section>
<!-- Invite a friend (form 4) — freelancer-facing, placed next to the application
form: it is a freelancer-to-freelancer action, not something a client does.
WE send the invitation from our own address; the sender's address sends
nothing. POSTs to the invite route (odoo_experts_invite_friend). -->
<section class="section-light" id="invite">
<div class="container">
<h2 class="section-title">Know another Odoo freelancer?</h2>
<div class="oc-body">
<p>Send us your name and a fellow freelancer's email address. <strong>We</strong> email them one invitation to sign up — from ODOO4projects, not from your address — and we tell them you suggested it. We use their address for that invitation only.</p>
</div>
<div class="dir-forms">
<form class="form-card" id="inviteForm" novalidate>
<div class="form-grid">
<div class="form-field">
<label for="ivName">Your name</label>
<input id="ivName" name="Name" type="text" autocomplete="name" required />
</div>
<div class="form-field">
<label for="ivEmail">Your friend's email</label>
<input id="ivEmail" name="friend_email" type="email" autocomplete="email" required />
<span class="hint">We email them one invitation and say you suggested it. Nothing goes out from your address.</span>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-brass">Invite a friend</button>
<span class="form-status" id="inviteStatus" role="status"></span>
</div>
</div>
</form>
</div>
<noscript><p class="form-noscript">This form needs JavaScript. Email <a href="mailto:hello@odoo4projects.com">hello@odoo4projects.com</a> a name and an address and we will invite them by hand.</p></noscript>
</div>
</section>
<!-- Final CTA -->
<section class="section-dark cmp-cta" id="start">
<div class="container">
<h2 class="section-title">Start</h2>
<div class="cmp-lead">
<p>Browse the freelancers and write to one directly from their card — or email us your brief and we will pass it on by hand until the anonymous request service is switched on.</p>
<p>Browse the freelancers and write to one directly from their card — or <a href="#request">post your brief</a> and we will forward it to the freelancers you pick, with no name attached.</p>
</div>
<div class="cmp-cta-actions">
<a href="#directory" class="btn btn-brass btn-lg">Browse the freelancers</a>
@@ -844,8 +959,18 @@
============================================================================ */
var ODOO_EXPERTS_WEBHOOK =
"https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts";
// The anonymous get-quote flow (client → us → freelancers → back to the client).
var ODOO_EXPERTS_QUOTE_WEBHOOK =
"https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_quote";
// A freelancer invites a friend to sign up.
var ODOO_EXPERTS_INVITE_WEBHOOK =
"https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_invite_friend";
var MULTI_FIELDS = ["languages", "specialities", "odoo_versions"]; // comma-separated → array
// Wire shape, per the user: `languages` and `specialities` are COMMA-SEPARATED
// STRINGS ("English,German"), NOT arrays — so the workflow can split one field.
// `odoo_versions` is still sent as an array. The build script splits BOTH shapes.
var MULTI_FIELDS = ["odoo_versions"]; // comma-separated → array
var CSV_STRING_FIELDS = ["languages", "specialities"]; // comma-separated → STRING
var BOOL_FIELDS = ["OCA_member", "is_individual", "email_public_consent"];
var NUM_FIELDS = ["odoo4projects_clients"];
@@ -885,6 +1010,20 @@
return;
}
if (CSV_STRING_FIELDS.indexOf(name) !== -1) {
// Normalise to the wire shape: trim each entry, drop blanks, re-join.
// "English, German" and "a, b, ,c" → "English,German" and "a,b,c".
var norm = (els[0].value || "")
.split(",")
.map(function (s) {
return s.trim();
})
.filter(Boolean)
.join(",");
if (norm) payload[name] = norm; // an empty box must not wipe the field
return;
}
if (BOOL_FIELDS.indexOf(name) !== -1) {
payload[name] = !!els[0].checked;
return;
@@ -920,8 +1059,10 @@
else el.removeAttribute("data-state");
}
function postToWebhook(payload) {
return fetch(ODOO_EXPERTS_WEBHOOK, {
/* One POST helper for all three routes. The endpoint is the only thing that
differs; the shape of what each form sends is its own contract. */
function postTo(url, payload) {
return fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
@@ -987,9 +1128,10 @@
withImage
.then(function () {
return postToWebhook(payload);
return postTo(ODOO_EXPERTS_WEBHOOK, payload);
})
.then(function () {
.then(function (res) {
if (!res.ok) throw new Error("HTTP " + res.status);
setStatus(
status,
"Thank you — check your inbox to confirm your email. Your listing goes live once it is confirmed and checked.",
@@ -1010,8 +1152,95 @@
});
}
/* ---- Form 3: anonymous client request (posts to the quote route) ----
The client picks the freelancers, writes the brief and leaves an address for
the answers. n8n returns 200 BEFORE the workflow runs and also on a bad
payload, so a 200 is the only signal we have — the form cannot tell success
from failure. Hence: success only on a returned 200, and no promised timing. */
function initRequestForm() {
var form = document.getElementById("requestForm");
if (!form) return;
var status = document.getElementById("requestStatus");
form.addEventListener("submit", function (event) {
event.preventDefault();
if (!form.reportValidity()) return;
var picks = Array.prototype.slice.call(
form.querySelectorAll('input[name="freelancers"]:checked')
);
if (!picks.length) {
setStatus(status, "Pick at least one freelancer to send your request to.", "err");
return;
}
var payload = {
brief: form.querySelector('[name="brief"]').value.trim(),
email: form.querySelector('[name="email"]').value.trim(),
freelancers: picks.map(function (box) {
var id = Number(box.getAttribute("data-id"));
return { id: isNaN(id) ? null : id, name: box.value };
})
};
var submit = form.querySelector('button[type="submit"]');
submit.disabled = true;
setStatus(status, "Sending…", null);
postTo(ODOO_EXPERTS_QUOTE_WEBHOOK, payload)
.then(function (res) {
if (!res.ok) throw new Error("HTTP " + res.status);
setStatus(
status,
"Thank you — your request is on its way to the freelancers you picked, with no name attached. We will come back to you with their answers.",
"ok"
);
form.reset();
})
.catch(function () {
setStatus(
status,
"That did not send. Please try again, or email us at hello@odoo4projects.com and we will pass it on.",
"err"
);
})
.then(function () {
submit.disabled = false;
});
});
}
/* ---- Form 4: invite a friend (posts to the invite route) ----
Two fields: the sender's name and a friend's email. WE send the invitation,
from our own address — never from the sender's — and we say who suggested it. */
function initInviteForm() {
var form = document.getElementById("inviteForm");
if (!form) return;
var status = document.getElementById("inviteStatus");
form.addEventListener("submit", function (event) {
event.preventDefault();
if (!form.reportValidity()) return;
var submit = form.querySelector('button[type="submit"]');
submit.disabled = true;
setStatus(status, "Sending…", null);
postTo(ODOO_EXPERTS_INVITE_WEBHOOK, buildPayload(form, false))
.then(function (res) {
if (!res.ok) throw new Error("HTTP " + res.status);
setStatus(
status,
"Thank you — we will email them one invitation to sign up, and tell them you suggested it.",
"ok"
);
form.reset();
})
.catch(function () {
setStatus(status, "That did not send. Please try again.", "err");
})
.then(function () {
submit.disabled = false;
});
});
}
initFilters();
initApplyForm();
initRequestForm();
initInviteForm();
</script>
</body>
</html>