feat(freelancer): card cog + self-service modal, and the private freelancer-edit page

Card (odoo-community-developers/):
- "Ask question anonymously" moves into its own row ABOVE the links row, in the
  runtime renderer and in build-directory.py (the build-time template).
- A cog icon button sits next to it: a real <button> with an accessible name
  (aria-label="Edit or delete your profile"), same quiet weight as its neighbour.
- Pressing the cog opens a modal that explains the flow and asks for the email
  address, then POSTs {"email":"..."} to odoo_experts_edit (body shape in one
  constant, EDIT_EMAIL_KEY). The reply is never read: one message on any 2xx, so
  the box cannot tell a visitor whether an address is on file. A non-2xx fails
  honestly and shows no receipt. Both controls ship dark (added only while their
  route constant is set), so neither is ever a dead control.

New private page (freelancer-edit/, noindex+nofollow, unlinked, absent from
sitemap.xml and llms.txt):
- Emailed link is https://odoo4projects.com/freelancer-edit/?has=<hash> — the
  same shape as /freelancer-answer/; reads `has` first, falls back to `hash`.
- GETs the edit webhook with the hash and pre-fills the form: Name, Focus (<=70),
  description (<=230), the five axis numbers (must total 15), the three language
  numbers (0-5), Country, OCA_link. The GET reply is read through one alias map;
  a shape it does not recognise names the fields it received instead of guessing.
  404 "is not registered" is a CONFIGURATION error, never "your link is invalid";
  404/500 "No item to return was found" is an invalid/expired link; a network
  failure keeps the hash for a retry.
- Save = POST with the record id (one constant, SAVE_METHOD). Delete is a
  deliberate second action (a confirm tick gates the button) and never reports
  success on a non-2xx.
- The image is read-only here, stated in the UI: replacing a photo is a different
  problem from editing text.
- The hash never reaches analytics: the query is stripped before the Plausible
  snippet loads. referrer policy no-referrer; JS off says so plainly.
This commit is contained in:
webmaster committed 2026-11-03 17:59:41 +00:00
1 parent f0a2c8d745
commit 1a1b62136f
2 files changed
+904 -22

No files matched your search

+194 -22
View File
@@ -502,6 +502,40 @@
outline: 3px solid var(--brass-deep);
outline-offset: 2px;
}
/* The row ABOVE the links: the "Ask question anonymously" action and, next to it,
the self-service cog (edit/delete your profile). Both are added by script only
while their route is set, so neither is ever a dead control. The cog is a small
quiet icon at the same weight as the link beside it — not a second primary button. */
.dev-ask-row {
margin: 0;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 0.55rem;
flex-wrap: wrap;
}
.dev-ask-row button,
.dev-ask-row a {
font: inherit;
padding: 0;
border: 0;
background: none;
color: var(--brass-deep);
text-decoration: underline;
cursor: pointer;
}
.dev-ask-row .dev-cog-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.2rem;
line-height: 0;
text-decoration: none;
}
.dev-ask-row button:focus-visible {
outline: 3px solid var(--brass-deep);
outline-offset: 2px;
}
/* A card with no contact route of any kind says so — it never shows an empty space. */
.dev-contact-none {
margin: 0;
@@ -1827,6 +1861,31 @@
</div>
</div>
<!-- Profile self-service modal — the cog button opens it. It asks for the freelancer's
email and posts { email } to the odoo_experts_edit route, which emails a private
link to edit or delete their listing. It ships DARK: the cog is added by the page
script only while ODOO_EXPERTS_EDIT_WEBHOOK is set, so a stub route can never be
submitted to. The reply is NEVER read — the page shows one message on any 2xx, so
it cannot reveal whether an address is on file. -->
<div class="contact-modal" id="profileModal" hidden role="dialog" aria-modal="true" aria-labelledby="profileModalTitle">
<div class="contact-modal-card" role="document">
<button type="button" class="contact-modal-close" id="profileModalClose" aria-label="Close">&times;</button>
<h3 id="profileModalTitle">Edit or delete your profile</h3>
<p class="contact-modal-lede">If you have a listing in this directory, enter the email address you applied with and we will send you a private link. That link lets you edit your details — or delete your listing.</p>
<form id="profileModalForm" novalidate>
<div class="form-field">
<label for="profileModalEmail">Your email address</label>
<input type="email" id="profileModalEmail" name="email" autocomplete="email" required />
<span class="hint">We only use it to send the link.</span>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-brass">Email me the link</button>
<span class="form-status" id="profileModalStatus" role="status"></span>
</div>
</form>
</div>
</div>
<!-- The apply modal — LIVE. It holds the ONE #applyForm, moved in by the page script
when it runs; it is NOT gated by any endpoint constant, so the anon-question
modal's dark state (ODOO_EXPERTS_QUOTE_WEBHOOK empty) cannot disable it. With
@@ -1924,6 +1983,12 @@
// never a delivery claim — n8n answers 200 before the workflow runs, so the form
// cannot detect failure and must not promise an outcome.
var ODOO_EXPERTS_QUOTE_WEBHOOK = "https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_quote";
// The per-card self-service cog: "edit or delete your profile", by emailed link.
// Setting this endpoint switches the cog and its modal on. The modal asks for an
// email address and posts { email } here; the workflow then emails that address a
// private link. The request body shape lives in ONE place (EDIT_EMAIL_KEY below).
var ODOO_EXPERTS_EDIT_WEBHOOK = "https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_edit";
var EDIT_EMAIL_KEY = "email"; // body sent to ODOO_EXPERTS_EDIT_WEBHOOK: { email: "..." }
// ---- The shared constants (the same lists live in build-directory.py) --------
// The five self-assessed focus axes — each a SEPARATE numeric field (0-5) on the
@@ -2106,9 +2171,9 @@
else el.removeAttribute("data-state");
}
function postTo(url, payload) {
function postTo(url, payload, method) {
return fetch(url, {
method: "POST",
method: method || "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
@@ -2571,29 +2636,135 @@
});
}
/* Add the button to one card — ONLY while the route exists AND the card is marked
(data-relay) as a record with an address we can relay to. Nothing is added
otherwise, so the button is never dead and never promises a delivery. */
function enhanceAsk(card) {
if (!ODOO_EXPERTS_QUOTE_WEBHOOK) return;
if (card.getAttribute("data-relay") !== "1") return;
if (card.querySelector(".dev-ask-btn")) return;
var btn = document.createElement("button");
btn.type = "button";
btn.className = "dev-ask-btn";
btn.textContent = "Ask question anonymously";
btn.addEventListener("click", function () {
openAskModal(card.getAttribute("data-id"), card.getAttribute("data-name") || "");
/* ---- The profile self-service modal (edit or delete your listing) ---------------
The cog opens it. It asks for the freelancer's email and POSTs { email } to the
edit route, which emails a private link. Ships DARK: the cog is added only while
ODOO_EXPERTS_EDIT_WEBHOOK is set. THE REPLY IS NEVER READ — on ANY 2xx the page
shows ONE constant message, so this box cannot be used to ask "does this address
have a profile with you?" The answer is identical whether or not the address is
on file (no branch on the address, no branch on the response body). */
var profileLastFocus = null;
function profileEls() {
return {
modal: document.getElementById("profileModal"),
form: document.getElementById("profileModalForm"),
email: document.getElementById("profileModalEmail"),
status: document.getElementById("profileModalStatus"),
close: document.getElementById("profileModalClose")
};
}
function openProfileModal(trigger) {
var m = profileEls();
if (!m.modal || !m.form) return;
profileLastFocus = trigger || document.activeElement;
setStatus(m.status, "", null);
m.modal.hidden = false;
document.body.style.overflow = "hidden";
if (m.email) m.email.focus();
}
function closeProfileModal() {
var m = profileEls();
if (!m.modal || m.modal.hidden) return;
m.modal.hidden = true;
document.body.style.overflow = "";
if (profileLastFocus && profileLastFocus.focus) profileLastFocus.focus();
}
function initProfileModal() {
if (!ODOO_EXPERTS_EDIT_WEBHOOK) return; // dark
var m = profileEls();
if (!m.modal || !m.form) return;
if (m.close) m.close.addEventListener("click", closeProfileModal);
m.modal.addEventListener("click", function (e) {
if (e.target === m.modal) closeProfileModal();
});
var row = card.querySelector(".dev-links");
if (row) {
row.appendChild(document.createTextNode(" \u00b7 "));
document.addEventListener("keydown", function (e) {
if (m.modal.hidden) return;
if (e.key === "Escape") { closeProfileModal(); return; }
if (e.key !== "Tab") return;
var focusables = m.modal.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
if (!focusables.length) return;
var f = focusables[0], last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === f) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); f.focus(); }
});
m.form.addEventListener("submit", function (event) {
event.preventDefault();
if (!m.form.reportValidity()) return;
// The body shape, in ONE constant (EDIT_EMAIL_KEY): { email: "<address>" }.
var body = {};
body[EDIT_EMAIL_KEY] = m.email ? m.email.value.trim() : "";
var submit = m.form.querySelector('button[type="submit"]');
if (submit) submit.disabled = true;
setStatus(m.status, "Sending…", null);
postTo(ODOO_EXPERTS_EDIT_WEBHOOK, body)
.then(function (res) {
// A refusal (route missing, method refused) fails honestly and shows no
// receipt. Only a 2xx earns the receipt below.
if (!res.ok) throw new Error("HTTP " + res.status);
// ONE message on ANY 2xx — read from no part of the reply, so it cannot
// reveal whether the address is on file.
setStatus(m.status, "If that address is on file, we will email a link to it.", "ok");
m.form.reset();
})
.catch(function () {
setStatus(m.status,
"We could not send the email just now. Please try again, or write to support@odoo4projects.com.",
"err");
})
.then(function () { if (submit) submit.disabled = false; });
});
}
/* The cog glyph (Feather "settings", MIT) — a small quiet icon, same weight as the
link beside it. Inline so the card carries no extra request. */
var COG_SVG =
'<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false"' +
' fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"' +
' stroke-linejoin="round"><circle cx="12" cy="12" r="3"/>' +
'<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>';
/* Add the self-service controls to one card, in ONE row ABOVE the links so the
contact action comes first:
· "Ask question anonymously" — only while its route is set AND the card is
marked (data-relay) as a record with an address we can relay to;
· the cog (edit or delete your profile) — only while its route is set.
Nothing is added otherwise, so neither control is ever dead. The cog's
accessible name comes from its aria-label. */
function enhanceAsk(card) {
var canQuote = !!ODOO_EXPERTS_QUOTE_WEBHOOK && card.getAttribute("data-relay") === "1";
var canEdit = !!ODOO_EXPERTS_EDIT_WEBHOOK;
if (!canQuote && !canEdit) return;
if (card.querySelector(".dev-ask-row")) return;
var row = liveEl("p", "dev-ask-row");
if (canQuote) {
var btn = document.createElement("button");
btn.type = "button";
btn.className = "dev-ask-btn";
btn.textContent = "Ask question anonymously";
btn.addEventListener("click", function () {
openAskModal(card.getAttribute("data-id"), card.getAttribute("data-name") || "");
});
row.appendChild(btn);
} else {
var p = liveEl("p", "dev-links");
p.appendChild(btn);
card.appendChild(p);
}
if (canEdit) {
var cog = document.createElement("button");
cog.type = "button";
cog.className = "dev-cog-btn";
cog.setAttribute("aria-label", "Edit or delete your profile");
cog.innerHTML = COG_SVG; // a static constant, never data from a record
cog.addEventListener("click", function () { openProfileModal(cog); });
row.appendChild(cog);
}
// Above the links row, or the "no way to reach" note, else at the end of the card.
var anchor = card.querySelector(".dev-links") || card.querySelector(".dev-contact-none");
if (anchor) card.insertBefore(row, anchor);
else card.appendChild(row);
}
function initLiveList() {
@@ -2882,6 +3053,7 @@
initFocusCounter(document.getElementById("applyForm"), "apFocus", "apFocusCount");
initFocusCounter(document.getElementById("applyForm"), "apDescription", "apDescriptionCount", DESC_MAX_CHARS);
initAskModal();
initProfileModal();
Array.prototype.forEach.call(document.querySelectorAll(".dev-card"), enhanceAsk);
initPager();
initFilters();