directory: never show the stale build-time cards before the webhook answers

With JS the build-time card painted first and was then replaced by the webhook's
list — a visitor could see a person who is no longer listed.

- `js` class set on <html> by an inline script as the first thing in <head>, so the
  static cards are hidden from the first frame only when JS will replace them.
- JS on: a text loading line (role=status, aria-live=polite) stands in their place;
  on success the static cards are removed from the DOM and the live list rendered; on
  failure an honest error with a Try again button replaces them — never the stale cards.
- Count line + filter row held out (invisible, out of the tab order) until the data is
  there; pager absent while loading.
- Space reserved (dir-grid min-height, dir-count min-height, filter row kept in flow) so
  the list does not jump when the cards arrive — measured 0px movement loading -> ready.
- JS off: unchanged — the static cards show.
This commit is contained in:
webmaster committed 2026-11-03 21:11:36 +00:00
1 parent cdf166632a
commit 1f8179d562
1 file changed
+154 -3
+154 -3
View File
@@ -1,6 +1,11 @@
<!doctype html> <!doctype html>
<html lang="en-US"> <html lang="en-US">
<head> <head>
<!-- Set `js` on <html> as the first thing in <head>, so the build-time directory cards
can be hidden from the very first frame when (and only when) JavaScript will replace
them with the webhook's list. Tiny and synchronous on purpose: it must land before
any stylesheet is applied, or the stale card paints for a moment. -->
<script>document.documentElement.className += " js";</script>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hire an Odoo Developer for Your Project</title> <title>Hire an Odoo Developer for Your Project</title>
@@ -300,6 +305,9 @@
font-size: 0.82rem; font-size: 0.82rem;
color: rgba(255, 255, 255, 0.6); color: rgba(255, 255, 255, 0.6);
margin: 0 0 1.25rem; margin: 0 0 1.25rem;
/* Hold one line of height even while empty (the JS-on loading state keeps this line
in the layout but hidden), so its text arriving does not move the list. */
min-height: 1.6em;
} }
.dir-empty { .dir-empty {
text-align: center; text-align: center;
@@ -389,6 +397,63 @@
gap: 1.25rem; gap: 1.25rem;
max-width: 1000px; max-width: 1000px;
margin: 0 auto; margin: 0 auto;
/* Reserve the space a row of cards occupies, so the page does not jump when the
webhook list replaces the loading line. Matched to the tallest single-row card
measured (~701 px at 1280, ~728 px at 375). */
min-height: 730px;
}
/* ---- With JavaScript: the build-time cards are hidden until the webhook answers ----
`js` is set on <html> in <head> before the first paint, so the stale card never
shows for a moment; a no-JS visitor never gets the class and sees every static card
exactly as before. The build-time cards are NEVER a fallback: on success the list is
re-rendered from the webhook (and the static cards removed from the DOM), and on a
failed fetch the honest error below stands in their place. */
.js #dirGrid > .dev-card {
display: none;
}
.js #directory[data-dir-state="ready"] #dirGrid > .dev-card {
display: flex;
}
/* The count line and the filter row say something ABOUT the data — so with JavaScript
they stay hidden until the list has arrived (a count of zero during load is a lie,
and a filter over an empty list is a bug). `visibility: hidden` rather than
`display: none`: the space is held, so the row appearing does not shove the list
down. It is also taken out of the tab order and the accessibility tree, so it cannot
be operated even though it is present. */
.js #directory:not([data-dir-state="ready"]) #dirFilters,
.js #directory:not([data-dir-state="ready"]) #dirCount {
visibility: hidden;
}
/* The pager and the empty state sit below the list and appear only in specific
conditions, so they are simply removed while loading. */
.js #directory:not([data-dir-state="ready"]) #dirPager,
.js #directory:not([data-dir-state="ready"]) #dirEmpty {
display: none;
}
/* The loading line and the failure state: full-width items in the list's own grid,
both plain text and both announced (status / alert). The failure is a real message
with a real retry button — never the stale card, never colour alone. */
.dir-loading,
.dir-fail {
display: none;
grid-column: 1 / -1;
text-align: center;
margin: 0;
}
.js #directory:not([data-dir-state="ready"]):not([data-dir-state="error"]) .dir-loading {
display: block;
color: rgba(255, 255, 255, 0.72);
}
.js #directory[data-dir-state="error"] .dir-fail {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.9rem;
color: rgba(255, 255, 255, 0.78);
}
.dir-fail p {
margin: 0;
max-width: 560px;
} }
.dev-card { .dev-card {
background: var(--ivory); background: var(--ivory);
@@ -1274,6 +1339,15 @@
</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">
<!-- Two states that exist only while JavaScript is running: the loading line shown
until the webhook answers, and the honest failure (with a retry) that replaces
the cards if it does not. A no-JS visitor sees neither — only the build-time
cards below. -->
<p class="dir-loading" id="dirLoading" role="status" aria-live="polite">Loading freelancers…</p>
<div class="dir-fail" id="dirError" role="alert">
<p id="dirErrorText"></p>
<button type="button" class="btn btn-outline" id="dirRetry">Try again</button>
</div>
<!-- DIRECTORY-LIST-START --> <!-- DIRECTORY-LIST-START -->
<article class="dev-card" data-search="oliver - the oc guy 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 agent automation, manufacturing, e-commerce english german spanish" data-id="2" data-name="Oliver - The OC Guy" data-country="py" data-languages="english|german|spanish" data-oca="1" data-relay="1"> <article class="dev-card" data-search="oliver - the oc guy 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 agent automation, manufacturing, e-commerce english german spanish" data-id="2" data-name="Oliver - The OC Guy" data-country="py" data-languages="english|german|spanish" data-oca="1" data-relay="1">
<header class="dev-head"> <header class="dev-head">
@@ -2748,9 +2822,50 @@
else card.appendChild(row); else card.appendChild(row);
} }
/* The list, from the webhook. With JavaScript the build-time cards are hidden until
this answers (see the `.js` rules in the page style): on success the static cards are
REMOVED from the DOM and the live list is rendered; on failure they are removed too
and an honest error with a retry stands in their place. The static cards are never a
fallback — a stale listing is the very thing this replaces. */
function setDirState(state) {
var section = document.getElementById("directory");
if (section) section.setAttribute("data-dir-state", state);
}
// Take the build-time cards out of the DOM entirely — hidden is not enough, they must
// not be reachable or readable as the list.
function dropStaticCards(grid) {
Array.prototype.forEach.call(grid.querySelectorAll(".dev-card"), function (card) {
if (card.parentNode) card.parentNode.removeChild(card);
});
}
// The failure state: no cards (never the stale ones), the message written into the
// role="alert" region so it is announced, the retry button left to re-run the fetch.
// `dirReady` stays false, so the count line and the filters stay away.
function failDir(message) {
var grid = document.getElementById("dirGrid");
dirReady = false;
if (grid) dropStaticCards(grid);
var text = document.getElementById("dirErrorText");
if (text) text.textContent = message;
setDirState("error");
}
function initLiveList() { function initLiveList() {
var grid = document.getElementById("dirGrid"); var grid = document.getElementById("dirGrid");
if (!grid || !window.fetch || !ODOO_EXPERTS_WEBHOOK) return; if (!grid) return;
if (!window.fetch || !ODOO_EXPERTS_WEBHOOK) {
// No way to load the list at all: say so plainly rather than leave the loading line
// standing. The static cards stay hidden either way — they are never the answer.
failDir("We could not load the freelancers just now. Please try again in a moment.");
return;
}
// Every attempt starts from the loading state: static cards out, loading line in,
// count / filters / pager away. This is the first load and every retry alike.
dropStaticCards(grid);
setDirState("loading");
var errorText = document.getElementById("dirErrorText");
if (errorText) errorText.textContent = "";
fetch(ODOO_EXPERTS_WEBHOOK, { headers: { Accept: "application/json" } }) fetch(ODOO_EXPERTS_WEBHOOK, { headers: { Accept: "application/json" } })
.then(function (res) { .then(function (res) {
if (!res.ok) throw new Error("HTTP " + res.status); if (!res.ok) throw new Error("HTTP " + res.status);
@@ -2760,7 +2875,12 @@
var all = Array.isArray(payload) ? payload : (payload && payload.data) || []; var all = Array.isArray(payload) ? payload : (payload && payload.data) || [];
// A record with no Name is not a freelancer (build-directory.py skips it too). // 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(); }); 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 if (!records.length) {
// The endpoint answered but lists nobody: not a load failure, but not the stale
// card either. Say so, with the same way to retry.
failDir("No freelancers are listed right now.");
return;
}
// NO sort: the endpoint's order is rendered exactly as received. // NO sort: the endpoint's order is rendered exactly as received.
var active = liveFilters(records); var active = liveFilters(records);
while (grid.firstChild) grid.removeChild(grid.firstChild); while (grid.firstChild) grid.removeChild(grid.firstChild);
@@ -2768,9 +2888,20 @@
// The button is added after the cards exist — only while the route is set. // The button is added after the cards exist — only while the route is set.
Array.prototype.forEach.call(grid.querySelectorAll(".dev-card"), enhanceAsk); Array.prototype.forEach.call(grid.querySelectorAll(".dev-card"), enhanceAsk);
dirPage = 1; dirPage = 1;
dirReady = true;
setDirState("ready");
applyFilters(); applyFilters();
}) })
.catch(function () { /* the build-time list already on the page stands */ }); .catch(function () {
failDir("We could not load the freelancers just now. Please try again in a moment.");
});
}
// Retry — wired once; the button lives in the failure region, which the loading and
// success states remove from the DOM.
function initDirRetry() {
var retry = document.getElementById("dirRetry");
if (retry) retry.addEventListener("click", function () { initLiveList(); });
} }
/* ---- Directory filtering (an enhancement over the build-time HTML) ---- */ /* ---- Directory filtering (an enhancement over the build-time HTML) ---- */
@@ -2803,6 +2934,10 @@
the facets follow. With a short list it stays hidden, so no pager appears. */ the facets follow. With a short list it stays hidden, so no pager appears. */
var PER_PAGE = 9; var PER_PAGE = 9;
var dirPage = 1; var dirPage = 1;
// False until the webhook's list is on screen. The count line, the filters, the pager
// and the empty state all read this: nothing about the list is shown while the data is
// not there yet (a zero count and an operable empty filter are both lies during load).
var dirReady = false;
function renderPager(total) { function renderPager(total) {
var pager = document.getElementById("dirPager"); var pager = document.getElementById("dirPager");
@@ -2867,6 +3002,21 @@
var empty = document.getElementById("dirEmpty"); var empty = document.getElementById("dirEmpty");
if (!bar || !grid) return; if (!bar || !grid) return;
// No list on screen yet (the webhook has not answered): there is nothing to count,
// filter or page, so keep the count line, the filter row, the empty state and the
// pager out of the way. The CSS already hides them; this keeps the logic honest too.
if (!dirReady) {
// Hold the count line and the filter row in the layout (the CSS keeps them
// invisible), so their arrival does not jump the list; the pager and the empty
// state stay fully out.
bar.hidden = false;
if (count) count.hidden = false;
if (empty) empty.hidden = true;
var pager0 = document.getElementById("dirPager");
if (pager0) pager0.hidden = true;
return;
}
var search = bar.querySelector("[data-search-box]"); var search = bar.querySelector("[data-search-box]");
var query = search ? fold(search.value.trim()) : ""; var query = search ? fold(search.value.trim()) : "";
var selects = Array.prototype.slice.call(bar.querySelectorAll("select")); var selects = Array.prototype.slice.call(bar.querySelectorAll("select"));
@@ -3055,6 +3205,7 @@
Array.prototype.forEach.call(document.querySelectorAll(".dev-card"), enhanceAsk); Array.prototype.forEach.call(document.querySelectorAll(".dev-card"), enhanceAsk);
initPager(); initPager();
initFilters(); initFilters();
initDirRetry();
initLiveList(); initLiveList();
initApplyForm(); initApplyForm();
initApplyModal(); initApplyModal();