Public Access
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:
1 parent
f0a2c8d745
commit
1a1b62136f
2 files changed
+904
-22
No files matched your search
@@ -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">×</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();
|
||||
|
||||
Reference in new issue
Block a user