/* The freelancer PHOTO — one endpoint, one constant, one fetch. The photo does NOT live in the profile payload any more. It used to ride there as inline base64 under the key `Image`; the webhook no longer sends that key, so the payload is small and the photo is fetched separately, per card, by record id. ONE constant holds BOTH halves of the address: the route and the query-parameter name. Changing either is a one-line edit here, and both pages pick it up. The parameter name is `id`, CONFIRMED by measurement against the live route: `?id=` answers 200 with the photo for that record, while `/4`, `?profile_id=`, `?image_id=` and a missing id all fail. THE ENDPOINT RETURNS JSON, NOT IMAGE BYTES. Measured on the live route: GET …/odoo_experts_image?id=10 → 200 application/json body: {"Image":"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ…"} So a plain `` receives JSON, cannot decode it and fires `onerror` — the monogram. That is why the card FETCHES the route, reads `Image` and puts the DATA URI on an `` only when it passes the `isImageSrc` allowlist. n8n sometimes answers an array instead of an object, so both shapes are accepted. `load(id)` resolves to a usable image source, or `null` on ANY failure — a non-2xx, an empty `{}`, a missing/blank `Image`, a value that fails `isImageSrc`, or a request that does not answer in `timeoutMs`. A `null` answer means "show the monogram": the caller never sees a broken image. The decoded URI is cached by id (and a failure is remembered too), so a re-render can never re-fetch a photo already in memory. THE TIMEOUT. A request that neither succeeds nor errors leaves the fallback unable to fire (measured live: an pointed at this route sat `complete=false · naturalWidth=0` for 20 s). Each fetch is therefore bounded by `timeoutMs` (default 8000): an `AbortController` is aborted by a `setTimeout`, the abort rejects the fetch, and the rejection lands in the same catch that returns `null` — the monogram. The timer is cleared on every completion, so a fast answer never pays for the guard. `AbortController` is universal in the browsers this site targets (it already needs `fetch`, `Promise` and `IntersectionObserver`); if it were missing there is nothing to abort with, but the abort is what turns a hang into the fallback, so it is the one capability worth naming. The route answers with `Access-Control-Allow-Origin` echoing the caller and `Cache-Control: public, max-age=3600`, so the browser may also serve a repeat from its own HTTP cache. */ window.O4PExpertImage = { endpoint: "https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_image", idParam: "id", // A photo request that hangs is a card with no photo AND no fallback: bound it. timeoutMs: 8000, /* The ONLY allowlist for an image source in this codebase — `https://` or a `data:image/` URI. Both forms are what this endpoint has served across its life. */ isImageSrc: function (v) { return typeof v === "string" && (v.indexOf("https://") === 0 || v.indexOf("data:image/") === 0); }, url: function (id) { return this.endpoint + "?" + this.idParam + "=" + encodeURIComponent(id); }, // id -> decoded image source (or "" when the record has no usable photo) cache: Object.create(null), // id -> the in-flight Promise, so two observers of one id share ONE request inflight: Object.create(null), /* Fetch the photo for `id`. Resolves to an image source, or null when there is none. Never rejects — a caller only has to check for a value. Every request is bounded by `timeoutMs`: on expiry the fetch is ABORTED (not merely abandoned) and the rejection resolves null, so a hung endpoint becomes the monogram instead of an empty box. */ load: function (id) { var self = this; var key = String(id); if (key in self.cache) return Promise.resolve(self.cache[key] || null); if (self.inflight[key]) return self.inflight[key]; var ctrl = (typeof AbortController !== "undefined") ? new AbortController() : null; var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, self.timeoutMs) : 0; var req = fetch(self.url(key), { headers: { Accept: "application/json" }, signal: ctrl ? ctrl.signal : undefined }) .then(function (res) { if (!res.ok) throw new Error("HTTP " + res.status); return res.json(); }) .then(function (body) { // n8n sometimes wraps the record in an array — accept both shapes. var d = Array.isArray(body) ? body[0] : body; var img = d && d.Image; if (!self.isImageSrc(img)) throw new Error("no image"); self.cache[key] = img; return img; }) .catch(function () { // Every failure — non-2xx, no image, a decode-hostile value, or the timeout's // abort — is the monogram. Remember it, so a shared id under a re-render does not // ask the network twice. self.cache[key] = ""; return null; }) .then(function (out) { if (timer) clearTimeout(timer); delete self.inflight[key]; return out; }); self.inflight[key] = req; return req; } };