Public Access
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:
1 parent
ada9605c90
commit
ff42a242db
1 file changed
+260
-15
@@ -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();
|
||||||
|
|||||||
Reference in new issue
Block a user