Files
ODOO4projects.com/freelancer-edit/index.html
T
webmaster 768dc1151a fix(freelancer): the 15-point rule is now one shared constant, in both forms
The edit form's legend read "Skill areas" and carried no directive, while the
application form read "Skill profile — 15 points to distribute" and said what to
do. Same rule, worded in two places — and the edit form lost the sentence.

- New static/focus-rule.js exposes window.O4PFocus: LEGEND, DIRECTIVE, TOTAL, MAX
  and message(sum). Both forms render the legend, the directive and the
  wrong-total message from it, so the two cannot drift apart again.
- Edit form: legend from the constant, the application form's directive under it,
  and the blocked-save message now names the direction.
- Application form: legend set from the constant, live warn and sum gate use the
  same message(); the all-zero gate keeps the directive.
- Message wording: over -> "remove N", under -> "add N", zero -> "place all 15
  points"; the total, aria-live, data-bad, the blocked save and the
  fieldset/legend structure are unchanged.

The rule text now appears once: "You have 17 of 15 — remove 2."
2026-11-03 18:34:43 +00:00

716 lines
38 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: POST with the record id — the admin path's convention (POST with an id =
// update an existing record). Match this method in the n8n workflow.
saveMethod: "POST",
// 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;
}
save.disabled = true;
line.className = "status";
line.textContent = "Saving…";
// The body shape: the record's id + every field this form owns. SAVE_METHOD is
// C.saveMethod ("POST", the admin path's convention).
var payload = { 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>