Files
ODOO4projects.com/freelancer-edit/index.html
T
webmaster 7dfd5aec9b fix(freelancer-edit): save sends the hash and uses PATCH
The save was authorised by the record id alone — ids are small
sequential integers, publicly visible on every directory card, so
a PATCH carrying only an id let one visitor overwrite another's
listing. The save now:

- uses PATCH (C.saveMethod), matching n8n
- carries the hash in the body under the same "has" key the
  delete already sends, so save and delete share one credential
  path, alongside the record id
- refuses to fire at all when the page holds no hash, and says why
  in the status line

Everything else is unchanged: the 15-point gate, receipt only on
2xx, the honest failure line that keeps the edits, the save.disabled
handling and the DELETE shape.
2026-11-03 18:40:29 +00:00

728 lines
39 KiB
HTML

<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Edit your listing — ODOO4projects</title>
<meta name="description" content="Private page: a listed Odoo freelancer edits or deletes their own listing." />
<!-- Private utility page. It is reached ONLY by a link we email, never by search.
noindex+nofollow here, absent from sitemap.xml and llms.txt, and linked from nowhere
on the site. See [[draft-page-convention]] for the same five rule. -->
<meta name="robots" content="noindex, nofollow" />
<!-- While the link is open the address carries a credential. No claim-by-subdomain of the
hash travels to a third party (Google Fonts, and every other cross-origin request). -->
<meta name="referrer" content="no-referrer" />
<link rel="canonical" href="https://odoo4projects.com/freelancer-edit/" />
<!-- The ONE path the emailed link uses; mirrors /freelancer-answer/ deliberately. -->
<meta property="og:url" content="https://odoo4projects.com/freelancer-edit/" />
<meta property="og:title" content="Edit your listing — ODOO4projects" />
<meta property="og:type" content="website" />
<link rel="icon" href="/images/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap"
media="print"
onload="this.media='all'"
/>
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="stylesheet" href="/style.css" />
<style>
/* Utility page. Deliberately small: no hero, no footer, no site chrome beyond a brand
line. Reuses the site tokens (:root in style.css) and the CI palette only. */
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--cream);
color: var(--ink);
font-family: Inter, "Segoe UI", sans-serif;
line-height: 1.6;
}
.wrap { width: 86%; max-width: 1200px; margin: 0 auto; }
.topbar { background: var(--midnight); border-bottom: 1px solid var(--line-brass); padding: 0.85rem 0; }
.brand {
font-family: Marcellus, Georgia, serif;
font-size: 1.15rem;
letter-spacing: 0.04em;
color: #fff;
text-decoration: none;
}
.brand span { color: var(--brass); }
main { padding: 2.5rem 0 4rem; }
.card-wrap { max-width: 720px; margin: 0 auto; }
h1 {
font-family: Marcellus, Georgia, serif;
font-weight: 400;
font-size: clamp(1.5rem, 4vw, 2.2rem);
letter-spacing: 0.02em;
margin: 0 0 0.4rem;
}
.lede { color: var(--ink-soft); margin: 0 0 1.75rem; }
.panel {
background: var(--ivory);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-rest);
}
.state { margin: 0; font-weight: 500; }
.state-warn, .state-err { color: var(--brass-deep); }
.state-ok { color: var(--success); }
.detail { margin: 0.5rem 0 0; font-size: 0.9rem; color: var(--ink-soft); }
.who {
font-size: 0.8rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--brass-deep);
margin: 0 0 1rem;
}
/* ---- The form ---- */
.field { margin: 0 0 1.1rem; }
.field > label { display: block; font-weight: 600; font-size: 0.9rem; margin: 0 0 0.35rem; }
input[type="text"], input[type="url"], input[type="email"], textarea, select {
width: 100%;
font: inherit;
padding: 0.6rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
color: var(--ink);
}
textarea { resize: vertical; }
input:focus-visible, textarea:focus-visible, select:focus-visible, button:focus-visible {
outline: 2px solid var(--brass);
outline-offset: 1px;
}
.hint { font-size: 0.82rem; color: var(--ink-soft); margin: 0.35rem 0 0; }
.counter { font-size: 0.78rem; color: var(--ink-soft); margin: 0.3rem 0 0; }
.counter[data-over="1"] { color: var(--brass-deep); font-weight: 600; }
fieldset { border: 1px solid var(--line); border-radius: var(--radius); padding: 0.9rem 1rem 0.4rem; margin: 0 0 1.1rem; }
legend { font-weight: 600; font-size: 0.9rem; padding: 0 0.35rem; }
.row { display: flex; align-items: center; gap: 0.7rem; margin: 0 0 0.7rem; }
.row label { flex: 0 0 6.5rem; font-size: 0.85rem; }
.row input[type="range"] { flex: 1 1 auto; min-width: 0; }
.row output { flex: 0 0 1.6rem; text-align: center; font-variant-numeric: tabular-nums; font-weight: 600; }
.total { font-size: 0.85rem; color: var(--ink-soft); margin: 0 0 0.4rem; }
.total[data-bad="1"] { color: var(--brass-deep); font-weight: 600; }
.btn {
display: inline-block;
font-family: Marcellus, Georgia, serif;
font-size: 0.9rem;
letter-spacing: 0.04em;
border: 0;
border-radius: var(--radius-pill);
background: var(--brass);
color: var(--midnight);
padding: 0.7rem 1.5rem;
cursor: pointer;
}
.btn:hover { background: var(--brass-bright); }
.btn:focus-visible { outline: 2px solid var(--midnight); outline-offset: 2px; }
.btn[disabled] { opacity: 0.6; cursor: default; }
.status { margin: 0.9rem 0 0; font-weight: 500; min-height: 1.2em; }
/* ---- Delete ---- */
.danger { margin-top: 1.75rem; border-top: 1px solid var(--line); padding-top: 1.25rem; }
.danger h2 { font-family: Marcellus, Georgia, serif; font-weight: 400; font-size: 1.1rem; margin: 0 0 0.4rem; }
.danger p { font-size: 0.88rem; color: var(--ink-soft); margin: 0 0 0.8rem; }
.confirm { display: flex; align-items: flex-start; gap: 0.55rem; margin: 0 0 0.9rem; font-size: 0.88rem; }
.confirm input { margin-top: 0.25rem; }
.btn-danger { background: transparent; border: 1px solid var(--brass-deep); color: var(--brass-deep); }
.btn-danger:hover { background: var(--brass-deep); color: #fff; }
.btn-danger:focus-visible { outline: 2px solid var(--midnight); outline-offset: 2px; }
.photo-readonly { display: flex; align-items: center; gap: 0.8rem; }
.photo-readonly img { width: 56px; height: 56px; border-radius: 50%; object-fit: cover; }
.photo-readonly .mono {
width: 56px; height: 56px; border-radius: 50%;
display: grid; place-items: center;
background: var(--midnight); color: var(--brass);
font-family: Marcellus, Georgia, serif;
}
</style>
<!-- The 15-point rule, in ONE copy for both forms (this one and the application form). -->
<script src="/static/focus-rule.js"></script>
<!-- 1) Read the credential, then take it OUT of the address bar.
The hash arrives as ?has=... or ?hash=... (both spellings accepted, 'has' first — the
same list the answer page reads, so the two emailed links behave identically). The page
keeps it in memory for the GET, the save, the delete and a retry; it must never sit in
the URL, because Plausible reports location.href. Stripping the query BEFORE the tracker
is loaded is what keeps it out of analytics. All wire names and key lists live here. -->
<script>
window.O4E = {
// The node webhook: holds this person's listing (GET) and receives the save + delete.
webhook: "https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_edit",
// Query-string spellings to read, in order. Same list as the answer page: the two
// emailed links are one shape. Confirm with the owner which spelling the mails carry.
hashKeys: ["has", "hash"],
// Wire name used OUTWARD on the GET query string.
hashWireKey: "has",
// SAVE: PATCH the existing record, carrying BOTH the hash (the credential, same key
// the delete already sends) and the record id. An id alone authorises nothing: record
// ids are small sequential integers, publicly visible on every directory card, so a
// request without the hash is refused before it is sent. Match this method in n8n.
saveMethod: "PATCH",
// DELETE: unverified. A route that refuses it (404/405/501) is a clear failure and
// this page never reports success on a non-2xx. Match this method in the workflow.
deleteMethod: "DELETE",
// How the GET reply is read, in ONE place. The primary name is what the directory
// webhook already returns, so the two endpoints agree; a lower-case variant is taken
// as the identical fallback. A key NOT listed here is never read.
aliases: {
id: ["id"],
Name: ["Name", "name"],
Focus: ["Focus", "focus"],
description: ["description", "Description"],
Country: ["Country", "country"],
OCA_link: ["OCA_link", "oca_link"],
functional: ["functional"], system: ["system"], modules: ["modules"],
integration: ["integration"], ai: ["ai"],
english: ["english"], german: ["german"], spanish: ["spanish"]
},
focusAxes: ["functional", "system", "modules", "integration", "ai"],
focusAxisLabels: {
functional: "Functional", system: "System", modules: "Modules",
integration: "Integration", ai: "AI"
},
focusTotal: window.O4PFocus.TOTAL,
focusMax: 5,
focusMaxChars: 70,
descMaxChars: 230,
langs: [["english", "English"], ["german", "German"], ["spanish", "Spanish"]],
// Country VALUE FORMAT the select sends, same as the other forms: the ISO alpha-2 code.
countryValueFormat: "code"
};
(function () {
var params = new URLSearchParams(location.search);
var hash = "";
for (var i = 0; i < window.O4E.hashKeys.length; i++) {
var v = params.get(window.O4E.hashKeys[i]);
if (v) { hash = v; break; }
}
window.__EDIT_HASH = hash;
// Drop the query from the address + history. The hash lives in memory above.
if (location.search) history.replaceState(null, "", location.pathname);
})();
</script>
<!-- 2) Privacy-friendly analytics by Plausible (verbatim; the page now has no query). -->
<script async src="https://plausible.office.odoo4projects.com/js/pa-NhA7Rf-79VopXFQXhSS4e.js"></script>
<script>
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
</head>
<body>
<header class="topbar">
<div class="wrap">
<a class="brand" href="https://odoo4projects.com/">ODOO<span>4</span>projects</a>
</div>
</header>
<main>
<div class="wrap">
<div class="card-wrap">
<h1>Edit your listing</h1>
<p class="lede">
This is your own listing in the Odoo Freelancer Directory. Change what you like here
and save it — or delete the listing for good.
</p>
<!-- Filled by the script at the foot of the page. Empty on purpose: with JavaScript
off there is no way to load the listing or save a change, so an empty container +
the noscript note below is the honest picture. -->
<div id="app"></div>
<noscript>
<div class="panel">
<p class="state state-warn">This page needs JavaScript to load your listing and to save your changes.</p>
<p class="detail">
Turn JavaScript on in your browser and open the link again — or reply to the email
that brought you here.
</p>
</div>
</noscript>
</div>
</div>
</main>
<script>
var COUNTRIES = [["AD", "Andorra"], ["AE", "United Arab Emirates"], ["AF", "Afghanistan"], ["AG", "Antigua and Barbuda"], ["AI", "Anguilla"], ["AL", "Albania"], ["AM", "Armenia"], ["AO", "Angola"], ["AQ", "Antarctica"], ["AR", "Argentina"], ["AS", "American Samoa"], ["AT", "Austria"], ["AU", "Australia"], ["AW", "Aruba"], ["AX", "Aland"], ["AZ", "Azerbaijan"], ["BA", "Bosnia and Herzegovina"], ["BB", "Barbados"], ["BD", "Bangladesh"], ["BE", "Belgium"], ["BF", "Burkina Faso"], ["BG", "Bulgaria"], ["BH", "Bahrain"], ["BI", "Burundi"], ["BJ", "Benin"], ["BL", "Saint Barthelemy"], ["BM", "Bermuda"], ["BN", "Brunei"], ["BO", "Bolivia"], ["BQ", "Bonaire"], ["BR", "Brazil"], ["BS", "Bahamas"], ["BT", "Bhutan"], ["BV", "Bouvet Island"], ["BW", "Botswana"], ["BY", "Belarus"], ["BZ", "Belize"], ["CA", "Canada"], ["CC", "Cocos (Keeling) Islands"], ["CD", "Democratic Republic of the Congo"], ["CF", "Central African Republic"], ["CG", "Republic of the Congo"], ["CH", "Switzerland"], ["CI", "Ivory Coast"], ["CK", "Cook Islands"], ["CL", "Chile"], ["CM", "Cameroon"], ["CN", "China"], ["CO", "Colombia"], ["CR", "Costa Rica"], ["CU", "Cuba"], ["CV", "Cape Verde"], ["CW", "Curacao"], ["CX", "Christmas Island"], ["CY", "Cyprus"], ["CZ", "Czech Republic"], ["DE", "Germany"], ["DJ", "Djibouti"], ["DK", "Denmark"], ["DM", "Dominica"], ["DO", "Dominican Republic"], ["DZ", "Algeria"], ["EC", "Ecuador"], ["EE", "Estonia"], ["EG", "Egypt"], ["EH", "Western Sahara"], ["ER", "Eritrea"], ["ES", "Spain"], ["ET", "Ethiopia"], ["FI", "Finland"], ["FJ", "Fiji"], ["FK", "Falkland Islands"], ["FM", "Micronesia"], ["FO", "Faroe Islands"], ["FR", "France"], ["GA", "Gabon"], ["GB", "United Kingdom"], ["GD", "Grenada"], ["GE", "Georgia"], ["GF", "French Guiana"], ["GG", "Guernsey"], ["GH", "Ghana"], ["GI", "Gibraltar"], ["GL", "Greenland"], ["GM", "Gambia"], ["GN", "Guinea"], ["GP", "Guadeloupe"], ["GQ", "Equatorial Guinea"], ["GR", "Greece"], ["GS", "South Georgia and the South Sandwich Islands"], ["GT", "Guatemala"], ["GU", "Guam"], ["GW", "Guinea-Bissau"], ["GY", "Guyana"], ["HK", "Hong Kong"], ["HM", "Heard Island and McDonald Islands"], ["HN", "Honduras"], ["HR", "Croatia"], ["HT", "Haiti"], ["HU", "Hungary"], ["ID", "Indonesia"], ["IE", "Ireland"], ["IL", "Israel"], ["IM", "Isle of Man"], ["IN", "India"], ["IO", "British Indian Ocean Territory"], ["IQ", "Iraq"], ["IR", "Iran"], ["IS", "Iceland"], ["IT", "Italy"], ["JE", "Jersey"], ["JM", "Jamaica"], ["JO", "Jordan"], ["JP", "Japan"], ["KE", "Kenya"], ["KG", "Kyrgyzstan"], ["KH", "Cambodia"], ["KI", "Kiribati"], ["KM", "Comoros"], ["KN", "Saint Kitts and Nevis"], ["KP", "North Korea"], ["KR", "South Korea"], ["KW", "Kuwait"], ["KY", "Cayman Islands"], ["KZ", "Kazakhstan"], ["LA", "Laos"], ["LB", "Lebanon"], ["LC", "Saint Lucia"], ["LI", "Liechtenstein"], ["LK", "Sri Lanka"], ["LR", "Liberia"], ["LS", "Lesotho"], ["LT", "Lithuania"], ["LU", "Luxembourg"], ["LV", "Latvia"], ["LY", "Libya"], ["MA", "Morocco"], ["MC", "Monaco"], ["MD", "Moldova"], ["ME", "Montenegro"], ["MF", "Saint Martin"], ["MG", "Madagascar"], ["MH", "Marshall Islands"], ["MK", "North Macedonia"], ["ML", "Mali"], ["MM", "Myanmar (Burma)"], ["MN", "Mongolia"], ["MO", "Macao"], ["MP", "Northern Mariana Islands"], ["MQ", "Martinique"], ["MR", "Mauritania"], ["MS", "Montserrat"], ["MT", "Malta"], ["MU", "Mauritius"], ["MV", "Maldives"], ["MW", "Malawi"], ["MX", "Mexico"], ["MY", "Malaysia"], ["MZ", "Mozambique"], ["NA", "Namibia"], ["NC", "New Caledonia"], ["NE", "Niger"], ["NF", "Norfolk Island"], ["NG", "Nigeria"], ["NI", "Nicaragua"], ["NL", "Netherlands"], ["NO", "Norway"], ["NP", "Nepal"], ["NR", "Nauru"], ["NU", "Niue"], ["NZ", "New Zealand"], ["OM", "Oman"], ["PA", "Panama"], ["PE", "Peru"], ["PF", "French Polynesia"], ["PG", "Papua New Guinea"], ["PH", "Philippines"], ["PK", "Pakistan"], ["PL", "Poland"], ["PM", "Saint Pierre and Miquelon"], ["PN", "Pitcairn Islands"], ["PR", "Puerto Rico"], ["PS", "Palestine"], ["PT", "Portugal"], ["PW", "Palau"], ["PY", "Paraguay"], ["QA", "Qatar"], ["RE", "Reunion"], ["RO", "Romania"], ["RS", "Serbia"], ["RU", "Russia"], ["RW", "Rwanda"], ["SA", "Saudi Arabia"], ["SB", "Solomon Islands"], ["SC", "Seychelles"], ["SD", "Sudan"], ["SE", "Sweden"], ["SG", "Singapore"], ["SH", "Saint Helena"], ["SI", "Slovenia"], ["SJ", "Svalbard and Jan Mayen"], ["SK", "Slovakia"], ["SL", "Sierra Leone"], ["SM", "San Marino"], ["SN", "Senegal"], ["SO", "Somalia"], ["SR", "Suriname"], ["SS", "South Sudan"], ["ST", "Sao Tome and Principe"], ["SV", "El Salvador"], ["SX", "Sint Maarten"], ["SY", "Syria"], ["SZ", "Eswatini"], ["TC", "Turks and Caicos Islands"], ["TD", "Chad"], ["TF", "French Southern Territories"], ["TG", "Togo"], ["TH", "Thailand"], ["TJ", "Tajikistan"], ["TK", "Tokelau"], ["TL", "East Timor"], ["TM", "Turkmenistan"], ["TN", "Tunisia"], ["TO", "Tonga"], ["TR", "Turkey"], ["TT", "Trinidad and Tobago"], ["TV", "Tuvalu"], ["TW", "Taiwan"], ["TZ", "Tanzania"], ["UA", "Ukraine"], ["UG", "Uganda"], ["UM", "U.S. Minor Outlying Islands"], ["US", "United States"], ["UY", "Uruguay"], ["UZ", "Uzbekistan"], ["VA", "Vatican City"], ["VC", "Saint Vincent and the Grenadines"], ["VE", "Venezuela"], ["VG", "British Virgin Islands"], ["VI", "U.S. Virgin Islands"], ["VN", "Vietnam"], ["VU", "Vanuatu"], ["WF", "Wallis and Futuna"], ["WS", "Samoa"], ["YE", "Yemen"], ["YT", "Mayotte"], ["ZA", "South Africa"], ["ZM", "Zambia"], ["ZW", "Zimbabwe"]]; // [code, English name] — from work/iso3166-1.json
</script>
<script>
(function () {
var C = window.O4E;
var CG = window.O4PFocus; // the shared 15-point rule (static/focus-rule.js)
var app = document.getElementById("app");
var hash = window.__EDIT_HASH || "";
var rec = null; // this person's record, once loaded
function el(tag, cls, text) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text; // textContent only: a record is never markup
return n;
}
function show(nodes) {
while (app.firstChild) app.removeChild(app.firstChild);
var box = el("div", "panel");
for (var i = 0; i < nodes.length; i++) box.appendChild(nodes[i]);
app.appendChild(box);
}
function status(text, kind) {
var p = el("p", "state" + (kind ? " state-" + kind : ""), text);
p.setAttribute("role", "status");
p.setAttribute("aria-live", "polite");
return p;
}
function retryButton() {
var b = el("button", "btn", "Try again");
b.type = "button";
b.addEventListener("click", load);
return b;
}
function messageOf(bodyText) {
try { return String((JSON.parse(bodyText) || {}).message || ""); }
catch (e) { return ""; }
}
// ---- the states -------------------------------------------------------------------
function noCode() {
show([
status("This link is incomplete — it arrived without its code.", "warn"),
el("p", "detail", "Open it again from the email that brought you here.")
]);
}
// A MISSING WORKFLOW must not read as an expired invitation. This is our problem.
function configError(httpStatus, msg) {
show([
status("The edit service is not available right now.", "warn"),
el("p", "detail",
"This is a problem at our end, not with your link. Nothing is wrong with your " +
"listing. Please try again later, or reply to the email that brought you here."),
el("p", "detail", "The service answered " + httpStatus + (msg ? " — " + msg : "") + ".")
]);
}
// 404 / 500 "No item to return was found" — the hash is wrong or has expired.
function invalidLink(httpStatus, msg) {
show([
status("This link is not valid, or it has expired.", "warn"),
el("p", "detail", "The service answered " + httpStatus + (msg ? " — " + msg : "") + "."),
el("p", "detail", "If you think that is wrong, reply to the email that brought you here.")
]);
}
function loadFailed(httpStatus, msg) {
show([
status("We could not load your listing.", "warn"),
el("p", "detail", "The service answered " + httpStatus + (msg ? " — " + msg : "") + "."),
retryButton()
]);
}
// Honest about a shape we did not expect: name the fields, never a value.
function shapeMismatch(names) {
show([
status("We could not read your listing from the reply from the service.", "warn"),
el("p", "detail",
names.length
? "The reply carried these fields: " + names.join(", ") + "."
: "The reply carried no readable data."),
retryButton()
]);
}
// ---- reading the record -----------------------------------------------------------
function unwrap(payload) {
if (Array.isArray(payload)) return payload[0] || null;
if (payload && typeof payload === "object") {
if (Array.isArray(payload.data)) return payload.data[0] || null;
if (Array.isArray(payload.items)) return payload.items[0] || null;
if (payload.data && typeof payload.data === "object") return payload.data;
return payload;
}
return null;
}
// Read one field by its list of accepted names. NEVER a value we did not expect:
// anything not in C.aliases is unreadable.
function readField(obj, key) {
var names = C.aliases[key] || [key];
for (var i = 0; i < names.length; i++) {
var v = obj[names[i]];
if (v !== undefined && v !== null && v !== "") return v;
}
return undefined;
}
function hasAnyMappedField(obj) {
for (var key in C.aliases) {
if (key === "id") continue;
if (readField(obj, key) !== undefined) return true;
}
return false;
}
function num(value, fallback) {
var n = parseInt(value, 10);
return isNaN(n) ? fallback : n;
}
function clamp05(n) { return Math.max(0, Math.min(C.focusMax, n)); }
function initials(name) {
var parts = String(name || "").trim().split(/\s+/).filter(Boolean);
if (!parts.length) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
// ---- the form ---------------------------------------------------------------------
function textField(id, labelText, value, maxLength, hint) {
var wrap = el("div", "field");
var label = el("label", "", labelText);
label.setAttribute("for", id);
var input = el("input");
input.type = "text";
input.id = id;
input.name = id;
input.value = value == null ? "" : String(value);
if (maxLength) { input.maxLength = maxLength; input.setAttribute("aria-describedby", id + "Count"); }
wrap.appendChild(label);
wrap.appendChild(input);
if (maxLength) {
var counter = el("p", "counter", input.value.length + " / " + maxLength);
counter.id = id + "Count";
input.addEventListener("input", function () {
var n = input.value.length;
counter.textContent = n + " / " + maxLength;
counter.setAttribute("data-over", n > maxLength ? "1" : "0");
});
counter.setAttribute("data-over", input.value.length > maxLength ? "1" : "0");
wrap.appendChild(counter);
}
if (hint) wrap.appendChild(el("p", "hint", hint));
return wrap;
}
function rangeField(fieldset, name, labelText, value) {
var row = el("div", "row");
var label = el("label", "", labelText);
label.setAttribute("for", "ed-" + name);
var input = el("input");
input.type = "range";
input.id = "ed-" + name;
input.name = name;
input.min = "0";
input.max = String(C.focusMax);
input.step = "1";
input.value = String(clamp05(num(value, 0)));
input.setAttribute("aria-valuetext", input.value + " of " + C.focusMax);
var out = el("output", "", input.value);
out.setAttribute("for", "ed-" + name);
input.addEventListener("input", function () {
input.setAttribute("aria-valuetext", input.value + " of " + C.focusMax);
out.textContent = input.value;
updateTotal();
});
row.appendChild(label);
row.appendChild(input);
row.appendChild(out);
fieldset.appendChild(row);
}
function countrySelect(selected) {
var sel = el("select");
sel.id = "edCountry";
sel.name = "Country";
sel.appendChild(new Option("— choose —", ""));
COUNTRIES.forEach(function (pair) {
var o = new Option(pair[1], C.countryValueFormat === "name" ? pair[1] : pair[0]);
var want = String(selected == null ? "" : selected).trim();
if (want && (want.toUpperCase() === pair[0] || want === pair[1])) o.selected = true;
sel.appendChild(o);
});
return sel;
}
var focusTotalLine = null;
function focusSum() {
var sum = 0;
C.focusAxes.forEach(function (axis) {
var el2 = document.getElementById("ed-" + axis);
sum += el2 ? num(el2.value, 0) : 0;
});
return sum;
}
function updateTotal() {
if (!focusTotalLine) return;
var sum = focusSum();
focusTotalLine.textContent = sum + " of " + C.focusTotal + " points";
focusTotalLine.setAttribute("data-bad", sum === C.focusTotal ? "0" : "1");
}
function renderForm(record) {
rec = record;
var who = readField(record, "Name");
var nodes = [];
nodes.push(el("p", "who", "Listing: " + (who ? String(who) : "(no name)")));
var form = el("form", "edit-form");
form.setAttribute("novalidate", "novalidate"); // JS announces errors instead
form.appendChild(textField("edName", "Name", readField(record, "Name"), 0, null));
form.appendChild(textField("edFocus", "Focus", readField(record, "Focus"),
C.focusMaxChars, "One short line — the kind of work you want, at most " + C.focusMaxChars + " characters."));
var descWrap = el("div", "field");
var descLabel = el("label", "", "About you");
descLabel.setAttribute("for", "edDescription");
var ta = el("textarea");
ta.id = "edDescription";
ta.name = "edDescription";
ta.rows = 6;
ta.maxLength = C.descMaxChars;
ta.value = String(readField(record, "description") == null ? "" : readField(record, "description"));
ta.setAttribute("aria-describedby", "edDescriptionCount");
var descCount = el("p", "counter", ta.value.length + " / " + C.descMaxChars);
descCount.id = "edDescriptionCount";
ta.addEventListener("input", function () {
var n = ta.value.length;
descCount.textContent = n + " / " + C.descMaxChars;
descCount.setAttribute("data-over", n > C.descMaxChars ? "1" : "0");
});
descCount.setAttribute("data-over", ta.value.length > C.descMaxChars ? "1" : "0");
descWrap.appendChild(descLabel);
descWrap.appendChild(ta);
descWrap.appendChild(descCount);
form.appendChild(descWrap);
// The five axes. An axis the record does not carry keeps the application form's
// default of 3, so the block always totals 15 and can be saved.
var axesSet = el("fieldset", "");
axesSet.appendChild(el("legend", "", CG.LEGEND));
// The same directive the application form carries, so both forms state the rule.
axesSet.appendChild(el("p", "hint", CG.DIRECTIVE));
C.focusAxes.forEach(function (axis) {
var v = readField(record, axis);
if (v === undefined || v === null || v === "") v = 3; // → the block totals 15
rangeField(axesSet, axis, C.focusAxisLabels[axis] || axis, v);
});
form.appendChild(axesSet);
focusTotalLine = el("p", "total", "");
focusTotalLine.id = "edFocusTotal";
focusTotalLine.setAttribute("role", "status");
focusTotalLine.setAttribute("aria-live", "polite");
form.appendChild(focusTotalLine);
var langsSet = el("fieldset", "");
langsSet.appendChild(el("legend", "", "Languages (0 = do not use it)"));
C.langs.forEach(function (pair) {
rangeField(langsSet, pair[0], pair[1], readField(record, pair[0]));
});
form.appendChild(langsSet);
var countryWrap = el("div", "field");
var countryLabel = el("label", "", "Country");
countryLabel.setAttribute("for", "edCountry");
countryWrap.appendChild(countryLabel);
countryWrap.appendChild(countrySelect(readField(record, "Country")));
form.appendChild(countryWrap);
form.appendChild(textField("edOcaLink", "OCA member profile link", readField(record, "OCA_link"),
0, "Your OCA profile URL, if you have one: https://apps.odoo-community.org/community/…"));
// The image is READ-ONLY on purpose: replacing a photo is a different problem from
// editing text (an upload path, a base64 blob, a size limit). Shown, not editable.
var photoWrap = el("div", "field");
photoWrap.appendChild(el("p", "", "Photo (not editable here)"));
var photoRow = el("div", "photo-readonly");
var img = readField(record, "Image");
if (typeof img === "string" && (img.indexOf("https://") === 0 || img.indexOf("data:image/") === 0)) {
var im = el("img");
im.src = img;
im.alt = "";
photoRow.appendChild(im);
} else {
photoRow.appendChild(el("span", "mono", initials(who)));
}
photoRow.appendChild(el("p", "hint", "To change your photo, reply to the email that brought you here."));
photoWrap.appendChild(photoRow);
form.appendChild(photoWrap);
var save = el("button", "btn", "Save my changes");
save.type = "submit";
var line = el("p", "status", "");
line.id = "editStatus";
line.setAttribute("role", "status");
line.setAttribute("aria-live", "polite");
form.addEventListener("submit", function (event) {
event.preventDefault();
if (focusSum() !== C.focusTotal) {
var sum = focusSum();
line.className = "status state-err";
line.textContent = CG.message(sum); // names the direction: remove / add / place all
focusTotalLine.setAttribute("data-bad", "1");
return;
}
// A save is authorised by the HASH, not by the id. If this page holds no hash there
// is nothing to prove ownership, so the request is not fired at all — a PATCH
// carrying only an id is exactly the hole this closes. Say why, do not fail silently.
if (!hash) {
line.className = "status state-err";
line.textContent = "This link arrived without its code, so we cannot save your " +
"changes. Open it again from the email that brought you here.";
return;
}
save.disabled = true;
line.className = "status";
line.textContent = "Saving…";
// The body shape: the credential (the SAME "has" key the delete sends, one
// credential path for both) + the record's id + every field this form owns.
// Method is C.saveMethod ("PATCH").
var payload = { has: hash, id: rec && rec.id !== undefined ? rec.id : undefined };
payload.Name = document.getElementById("edName").value.trim();
payload.Focus = document.getElementById("edFocus").value.trim();
payload.description = document.getElementById("edDescription").value.trim();
payload.Country = document.getElementById("edCountry").value;
payload.OCA_link = document.getElementById("edOcaLink").value.trim();
C.focusAxes.forEach(function (axis) { payload[axis] = num(document.getElementById("ed-" + axis).value, 0); });
C.langs.forEach(function (pair) { payload[pair[0]] = num(document.getElementById("ed-" + pair[0]).value, 0); });
fetch(C.webhook, {
method: C.saveMethod,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
})
.then(function (res) {
if (!res.ok) throw new Error("HTTP " + res.status);
// A RECEIPT, not a delivery claim: n8n answers 200 before the workflow runs.
line.className = "status state-ok";
line.textContent = "Saved — we have your changes. They go live once confirmed.";
})
.catch(function () {
// Non-2xx or network: never a receipt; the data stays in the form.
line.className = "status state-err";
line.textContent = "We could not save your changes just now. Your edits are still " +
"in the form — please try again, or reply to the email that brought you here.";
})
.then(function () { save.disabled = false; });
});
form.appendChild(save);
form.appendChild(line);
nodes.push(form);
nodes.push(deleteBlock());
show(nodes);
// The total needs the fields in the document to sum them — do it after show().
updateTotal();
}
// ---- delete -----------------------------------------------------------------------
function deleteBlock() {
var box = el("div", "danger");
box.appendChild(el("h2", "", "Delete this listing"));
box.appendChild(el("p", "",
"This removes your public listing for good. It cannot be undone from our side. " +
"Tick the box and confirm — nothing is deleted until you do."));
var confirmWrap = el("label", "confirm");
var box_in = el("input");
box_in.type = "checkbox";
box_in.id = "edConfirmDelete";
confirmWrap.appendChild(box_in);
confirmWrap.appendChild(el("span", "",
"Yes, delete my listing permanently."));
box.appendChild(confirmWrap);
var del = el("button", "btn btn-danger", "Delete my listing");
del.type = "button";
del.disabled = true;
var line = el("p", "status", "");
line.id = "deleteStatus";
line.setAttribute("role", "status");
line.setAttribute("aria-live", "polite");
// The deliberate second action: the button cannot fire until the box is ticked.
box_in.addEventListener("change", function () { del.disabled = !box_in.checked; });
del.addEventListener("click", function () {
if (!box_in.checked) return;
del.disabled = true;
box_in.disabled = true;
line.className = "status";
line.textContent = "Deleting…";
// DELETE with the credential and the record id. C.deleteMethod is the one place
// the method lives; the id wire key is the record's own "id".
fetch(C.webhook, {
method: C.deleteMethod,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ has: hash, id: rec && rec.id !== undefined ? rec.id : null })
})
.then(function (res) {
if (!res.ok) throw new Error("HTTP " + res.status);
// Only a 2xx is a success. Anything else fails below.
line.className = "status state-ok";
line.textContent = "Your listing has been deleted. Thank you for having been listed.";
del.hidden = true;
confirmWrap.hidden = true;
})
.catch(function () {
box_in.disabled = false;
del.disabled = !box_in.checked;
line.className = "status state-err";
line.textContent = "We could not delete your listing just now — nothing was removed. " +
"Please try again, or reply to the email that brought you here.";
});
});
box.appendChild(del);
box.appendChild(line);
return box;
}
// ---- the GET ----------------------------------------------------------------------
function load() {
if (!hash) { noCode(); return; }
show([status("Loading your listing…")]);
fetch(C.webhook + "?" + encodeURIComponent(C.hashWireKey) + "=" + encodeURIComponent(hash), {
headers: { Accept: "application/json" }
})
.then(function (res) {
if (!res.ok) {
return res.text().then(function (body) {
var msg = messageOf(body);
// A missing workflow is a CONFIGURATION error — never "your link is invalid".
if (/is not registered/i.test(msg)) { configError(res.status, msg); return; }
if (res.status === 404 || res.status === 500) { invalidLink(res.status, msg); return; }
loadFailed(res.status, msg);
});
}
return res.json().then(function (payload) {
var obj = unwrap(payload);
if (!obj) { shapeMismatch([]); return; }
if (!hasAnyMappedField(obj)) { shapeMismatch(Object.keys(obj)); return; }
renderForm(obj);
}).catch(function () {
shapeMismatch([]);
});
})
.catch(function () {
// Network failure: the hash is still in memory, so a retry needs no new link.
show([
status("We could not load your listing just now.", "warn"),
el("p", "detail", "Your link has been kept, so you can try again."),
retryButton()
]);
});
}
load();
})();
</script>
</body>
</html>