fix(freelancer-directory): render the list from the webhook at runtime

A record saved after the last build never reached the page: the list was
build-time HTML only, so the new freelancer (id=2) stayed invisible until
someone re-ran the build. That is the cause, not the symptom.

- The page now GETs the odoo_experts webhook on load and re-renders the cards
  and the filter bar from what it returns.
- The build-time HTML stays in the page and is the fallback: it is replaced
  only when the fetch succeeds, so JavaScript-off and webhook-down still show
  the last built list. An empty response never overwrites a populated list.
- The renderer mirrors build-directory.py field for field (casing aliases
  included); every freelancer-written value goes in as textContent.
- initFilters() is split so one delegated listener re-reads the selects on
  every change: the same code filters the built list and the rendered one.
- Confirmation is NOT gated: the endpoint carries no status/email_verified
  marker, so nothing was guessed. Recorded in the page comment as a data gap.
- rebuild: build-directory.py, so the served HTML starts from the real set.
This commit is contained in:
webmaster committed 2026-11-03 12:45:26 +00:00
1 parent ada9605c90
commit ff42a242db
1 file changed
+260 -15
+260 -15
View File
@@ -700,13 +700,15 @@
python3 /root/workspace/webmaster/work/build-directory.py --> python3 /root/workspace/webmaster/work/build-directory.py -->
<div class="dir-filters" id="dirFilters" hidden> <div class="dir-filters" id="dirFilters" hidden>
<!-- FILTERS-START --> <!-- FILTERS-START -->
<div class="dir-filter"><label for="f-country">Country</label><select id="f-country" data-field="country"><option value="">All</option><option value="py">Paraguay</option></select></div>
<div class="dir-filter"><label for="f-languages">Language</label><select id="f-languages" data-field="languages"><option value="">All</option><option value="english">English</option><option value="german">German</option><option value="spanish - beginners">Spanish - beginners</option></select></div>
<div class="dir-filter"><label for="f-specialities">Speciality</label><select id="f-specialities" data-field="specialities"><option value="">All</option><option value="functional consulting">Functional Consulting</option></select></div>
<!-- FILTERS-END --> <!-- FILTERS-END -->
</div> </div>
<p class="dir-count" id="dirCount" hidden></p> <p class="dir-count" id="dirCount" hidden></p>
<div class="dir-grid" id="dirGrid"> <div class="dir-grid" id="dirGrid">
<!-- DIRECTORY-LIST-START --> <!-- DIRECTORY-LIST-START -->
<article class="dev-card" data-name="oliver"> <article class="dev-card" data-name="oliver" data-country="" data-languages="" data-specialities="">
<header class="dev-head"> <header class="dev-head">
<span class="dev-avatar" aria-hidden="true">OL</span> <span class="dev-avatar" aria-hidden="true">OL</span>
<div class="dev-id"> <div class="dev-id">
@@ -719,6 +721,24 @@
</ul> </ul>
<dl class="dev-facts"><div><dt>Projects on ODOO4projects</dt><dd>4 projects — a number we recorded</dd></div></dl> <dl class="dev-facts"><div><dt>Projects on ODOO4projects</dt><dd>4 projects — a number we recorded</dd></div></dl>
<p class="dev-contact-none">No contact details published for this freelancer.</p> <p class="dev-contact-none">No contact details published for this freelancer.</p>
</article>
<article class="dev-card" data-name="oliver - the oc guy" data-country="py" data-languages="german|english|spanish - beginners" data-specialities="functional consulting">
<header class="dev-head">
<span class="dev-avatar" aria-hidden="true">OG</span>
<div class="dev-id">
<h3 class="dev-name">Oliver - The OC Guy</h3>
<p class="dev-focus">Agent Automation, Manufacturing, E-commerce</p>
</div></header>
<p class="dev-bio">I love to see how far I can push the ODOO Community version for my clients. Besides the module implementation i am quite happy with adding to odoo using Hermes Agent, N8N and Progressive Web Apps</p>
<ul class="dev-tags">
<li class="dev-tag">Works as an individual (as declared)</li>
<li class="dev-tag">German</li>
<li class="dev-tag">English</li>
<li class="dev-tag">Spanish - beginners</li>
<li class="dev-tag">Functional Consulting</li>
</ul>
<dl class="dev-facts"><div><dt>Projects on ODOO4projects</dt><dd>4 projects — a number we recorded</dd></div></dl>
<p class="dev-links"><a href="mailto:oliver@odoo4projects.com">Email this freelancer</a></p>
</article> </article>
<!-- DIRECTORY-LIST-END --> <!-- DIRECTORY-LIST-END -->
</div> </div>
@@ -751,6 +771,7 @@
<div class="dir-picklist" id="reqPicklist"> <div class="dir-picklist" id="reqPicklist">
<!-- REQUEST-PICKLIST-START --> <!-- 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> <label class="pick"><input type="checkbox" name="freelancers" value="oliver" data-id="1" /> <span>oliver</span> <span class="hint">vieoem</span></label>
<label class="pick"><input type="checkbox" name="freelancers" value="Oliver - The OC Guy" data-id="2" /> <span>Oliver - The OC Guy</span> <span class="hint">Agent Automation, Manufacturing, E-commerce</span></label>
<!-- REQUEST-PICKLIST-END --> <!-- REQUEST-PICKLIST-END -->
</div> </div>
</fieldset> </fieldset>
@@ -1396,8 +1417,240 @@
if (note) note.hidden = true; if (note) note.hidden = true;
} }
/* ---- The live list: render the directory from the webhook at page load ----
build-directory.py writes the cards into the page at BUILD time, which is why a
record added afterwards never reached the page. So the build-time cards stay in
the HTML exactly as they are and act as the FALLBACK: this fetch replaces them
only when it succeeds. A visitor with JavaScript off, or arriving while the
webhook is down, still sees the last built list — an empty directory is worse
than a stale one, so an empty or failed response never overwrites a populated one.
NOT BUILT — the confirmation gate. The user's flow creates a record only after
the email is confirmed, so an unconfirmed record must not show. This endpoint
exposes NO way to tell the two apart: there is no `status`, no `email_verified`,
nothing but the record's own fields. So nothing is guessed here — every record
the endpoint returns with a `Name` is rendered, exactly as build-directory.py
does it. The workflow has to expose a marker before this can gate on one. */
var LIVE_FILTERS = [
["Country", "Country", "country"],
["odoo_versions", "Odoo version", "odoo-versions"],
["languages", "Language", "languages"],
["specialities", "Speciality", "specialities"]
];
// The webhook's casing has drifted once already. Accept the known aliases too —
// the same list as FIELD_ALIASES in build-directory.py, so both renderers agree.
var LIVE_ALIASES = { Country: ["country"], odoo_versions: ["Odoo_versions"] };
function liveValues(rec, field) {
var names = [field].concat(LIVE_ALIASES[field] || []);
var raw = null;
for (var i = 0; i < names.length; i++) {
var v = rec[names[i]];
if (v !== null && v !== undefined && v !== "") { raw = v; break; }
}
if (raw === null) return [];
var list = Array.isArray(raw) ? raw : String(raw).split(",");
return list.map(function (s) { return String(s).trim(); }).filter(Boolean);
}
function isLink(v) {
return v.indexOf("http://") === 0 || v.indexOf("https://") === 0 || v.indexOf("/") === 0;
}
function liveEl(tag, cls, text) {
var node = document.createElement(tag);
if (cls) node.className = cls;
if (text !== undefined) node.textContent = text;
return node;
}
/* One card, field for field the same as render_card() in build-directory.py.
Everything a freelancer wrote goes in as textContent, never as HTML. */
function liveCard(rec, active) {
var name = String(rec.Name || "").trim();
var card = liveEl("article", "dev-card");
card.setAttribute("data-name", name.toLowerCase());
active.forEach(function (pair) {
card.setAttribute("data-" + pair[2], liveValues(rec, pair[0]).map(function (v) {
return v.toLowerCase();
}).join("|"));
});
var photo = String(rec.Image || "").trim();
var avatar;
if (isLink(photo)) {
avatar = liveEl("img", "dev-photo"); // a real URL only — never a base64 blob
avatar.src = photo;
avatar.alt = "";
avatar.width = 72;
avatar.height = 72;
avatar.loading = "lazy";
avatar.decoding = "async";
} else {
var parts = name.split(/\s+/).filter(Boolean);
var mark = parts.length > 1
? parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
: (parts[0] || "?").slice(0, 2);
avatar = liveEl("span", "dev-avatar", mark.toUpperCase());
avatar.setAttribute("aria-hidden", "true");
}
var head = liveEl("header", "dev-head");
var who = liveEl("div", "dev-id");
who.appendChild(liveEl("h3", "dev-name", name));
var focus = String(rec.Focus || "").trim();
if (focus) who.appendChild(liveEl("p", "dev-focus", focus));
head.appendChild(avatar);
head.appendChild(who);
card.appendChild(head);
var bio = String(rec.description || "").trim();
if (bio) card.appendChild(liveEl("p", "dev-bio", bio));
// Tags: only what the freelancer themselves declared.
var tags = [];
if (rec.OCA_member === true) tags.push("OCA member (as declared)");
if (rec.is_individual === true) tags.push("Works as an individual (as declared)");
liveValues(rec, "languages").forEach(function (v) { tags.push(v); });
liveValues(rec, "specialities").forEach(function (v) { tags.push(v); });
if (tags.length) {
var ul = liveEl("ul", "dev-tags");
tags.forEach(function (t) { ul.appendChild(liveEl("li", "dev-tag", t)); });
card.appendChild(ul);
}
// The one number we publish about a person, and only one we recorded ourselves.
var clients = parseInt(rec.odoo4projects_clients, 10);
if (!isNaN(clients)) {
var dl = liveEl("dl", "dev-facts");
var cell = document.createElement("div");
cell.appendChild(liveEl("dt", null, "Projects on ODOO4projects"));
cell.appendChild(liveEl("dd", null, clients + (clients === 1 ? " project" : " projects") +
" — a number we recorded"));
dl.appendChild(cell);
card.appendChild(dl);
}
var email = String(rec.email || "").trim();
var hasEmail = email.indexOf("@") !== -1;
var links = [];
[["Linkedin", "LinkedIn"], ["github", "GitHub"], ["oca_profile", "OCA profile"],
["upwork", "Upwork"], ["fiverr", "Fiverr"]].forEach(function (pair) {
var val = String(rec[pair[0]] || "").trim();
if (isLink(val)) {
var a = liveEl("a", null, pair[1]);
a.href = val;
a.target = "_blank";
a.rel = "noopener nofollow";
links.push(a);
}
});
if (hasEmail) {
var mail = liveEl("a", null, "Email this freelancer");
mail.href = "mailto:" + email;
links.push(mail);
}
if (links.length) {
var row = liveEl("p", "dev-links");
links.forEach(function (a, i) {
if (i) row.appendChild(document.createTextNode(" · "));
row.appendChild(a);
});
card.appendChild(row);
}
if (!hasEmail) {
card.appendChild(liveEl("p", "dev-contact-none",
"No contact details published for this freelancer."));
}
return card;
}
/* ISO code → English name, read off the application form's own Country <select>:
the 249 options are already on the page, so no second copy is shipped. */
function countryNames() {
var map = {};
var sel = document.getElementById("apCountry");
if (sel) {
Array.prototype.forEach.call(sel.options, function (o) {
if (o.value) map[o.value.toUpperCase()] = o.textContent.trim();
});
}
return map;
}
/* The filter bar, rebuilt from the records just fetched. Same rule as the build:
a filter appears only when at least one record carries a value for it. */
function liveFilters(records) {
var bar = document.getElementById("dirFilters");
var countries = countryNames();
while (bar.firstChild) bar.removeChild(bar.firstChild);
var active = [];
LIVE_FILTERS.forEach(function (pair) {
var seen = [];
records.forEach(function (rec) {
liveValues(rec, pair[0]).forEach(function (v) {
var dup = seen.some(function (s) { return s.toLowerCase() === v.toLowerCase(); });
if (!dup) seen.push(v);
});
});
if (!seen.length) return; // no data → no filter
active.push(pair);
var wrap = liveEl("div", "dir-filter");
var id = "f-" + pair[2];
var label = liveEl("label", null, pair[1]);
label.setAttribute("for", id);
var sel = document.createElement("select");
sel.id = id;
sel.setAttribute("data-field", pair[2]);
sel.appendChild(new Option("All", ""));
seen.sort(function (a, b) {
return a.toLowerCase() < b.toLowerCase() ? -1 : 1;
}).forEach(function (v) {
sel.appendChild(new Option(
pair[0] === "Country" ? (countries[v.trim().toUpperCase()] || v) : v,
v.toLowerCase()));
});
wrap.appendChild(label);
wrap.appendChild(sel);
bar.appendChild(wrap);
});
return active;
}
function initLiveList() {
var grid = document.getElementById("dirGrid");
if (!grid || !window.fetch || !ODOO_EXPERTS_WEBHOOK) return;
fetch(ODOO_EXPERTS_WEBHOOK, { headers: { Accept: "application/json" } })
.then(function (res) {
if (!res.ok) throw new Error("HTTP " + res.status);
return res.json();
})
.then(function (payload) {
var all = Array.isArray(payload) ? payload : (payload && payload.data) || [];
// A record with no Name is not a freelancer (build-directory.py skips it too).
var records = all.filter(function (r) { return r && String(r.Name || "").trim(); });
if (!records.length) return; // never trade a stale list for an empty one
var active = liveFilters(records);
while (grid.firstChild) grid.removeChild(grid.firstChild);
records.forEach(function (rec) { grid.appendChild(liveCard(rec, active)); });
applyFilters();
})
.catch(function () { /* the build-time list already on the page stands */ });
}
/* ---- Directory filtering (an enhancement over the build-time HTML) ---- */ /* ---- Directory filtering (an enhancement over the build-time HTML) ---- */
function initFilters() { function initFilters() {
var bar = document.getElementById("dirFilters");
if (!bar) return;
// One listener for the life of the page. It re-reads the selects on every change,
// so the same code filters the build-time list and a runtime-rendered one.
bar.addEventListener("change", function (event) {
if (event.target && event.target.tagName === "SELECT") applyFilters();
});
applyFilters();
}
function applyFilters() {
var bar = document.getElementById("dirFilters"); var bar = document.getElementById("dirFilters");
var grid = document.getElementById("dirGrid"); var grid = document.getElementById("dirGrid");
var count = document.getElementById("dirCount"); var count = document.getElementById("dirCount");
@@ -1405,13 +1658,10 @@
if (!bar || !grid) return; if (!bar || !grid) return;
var selects = Array.prototype.slice.call(bar.querySelectorAll("select")); var selects = Array.prototype.slice.call(bar.querySelectorAll("select"));
if (!selects.length) return; // no data supports any filter → nothing to show bar.hidden = !selects.length; // no data supports any filter → no filter bar
if (count) count.hidden = !selects.length;
bar.hidden = false;
if (count) count.hidden = false;
var cards = Array.prototype.slice.call(grid.querySelectorAll(".dev-card")); var cards = Array.prototype.slice.call(grid.querySelectorAll(".dev-card"));
function apply() {
var shown = 0; var shown = 0;
cards.forEach(function (card) { cards.forEach(function (card) {
var ok = selects.every(function (sel) { var ok = selects.every(function (sel) {
@@ -1423,14 +1673,8 @@
card.hidden = !ok; card.hidden = !ok;
if (ok) shown++; if (ok) shown++;
}); });
if (count) count.textContent = "Showing " + shown + " of " + cards.length; if (count && selects.length) count.textContent = "Showing " + shown + " of " + cards.length;
if (empty) empty.hidden = shown !== 0; if (empty) empty.hidden = selects.length ? shown !== 0 : true;
}
selects.forEach(function (sel) {
sel.addEventListener("change", apply);
});
apply();
} }
/* ---- Form 1: freelancer application (create — no id) ---- */ /* ---- Form 1: freelancer application (create — no id) ---- */
@@ -1567,6 +1811,7 @@
wireRelay("requestFormWrap", "requestNotLive", ODOO_EXPERTS_QUOTE_WEBHOOK); wireRelay("requestFormWrap", "requestNotLive", ODOO_EXPERTS_QUOTE_WEBHOOK);
wireRelay("inviteFormWrap", "inviteNotLive", ODOO_EXPERTS_INVITE_WEBHOOK); wireRelay("inviteFormWrap", "inviteNotLive", ODOO_EXPERTS_INVITE_WEBHOOK);
initFilters(); initFilters();
initLiveList();
initApplyForm(); initApplyForm();
initRequestForm(); initRequestForm();
initInviteForm(); initInviteForm();