Files
ODOO4projects.com/freelancer-edit/index.html
T
webmaster cdf166632a feat(freelancer-edit): add the missing GitHub link field
The edit form carried LinkedIn/Upwork/Fiverr but not GitHub, though the
application form, the webhook and the card renderer already do. Add a
fourth optional link field mirroring the other three, under the live wire
key verbatim (lower-case github; never normalised), pre-filled from the
record and added to the save loop with the same rules: a key is written
only when the input has a value, an empty optional field never invents ""
on a record that never had one, and a link the owner cleared sends "".
No new URL check — the card's isLink() stays the only url predicate.
2026-11-03 20:48:31 +00:00

827 lines
45 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>
<script src="/static/photo-upload.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"],
// The four profile links. The primary name is the LIVE wire key, casing and all —
// "Linkedin", "github", "upwork", "fiverr" are deliberately mixed and must never be
// normalised (a normalised key is the silent-drop class that ate odoo_versions). A
// same-word casing variant is the identical fallback, the way Name/name already works;
// "github" is lower-case on the wire, so it is read exactly as it is, with no variant.
Linkedin: ["Linkedin", "linkedin"], github: ["github"], upwork: ["upwork", "Upwork"], fiverr: ["fiverr", "Fiverr"],
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;
}
// An optional profile link. A real `type="url"` input with the application form's own
// label and placeholder, so the two forms ask for a link the same way. No URL check
// lives here: the value is passed through untouched, and the ONLY url predicate in the
// fleet is the directory card's isLink() (a prefix allowlist) — one check, not two.
function linkField(id, name, labelText, value, placeholder) {
var wrap = el("div", "field");
var label = el("label", "", labelText);
label.setAttribute("for", id);
label.appendChild(el("span", "hint", " (optional)"));
var input = el("input");
input.type = "url";
input.id = id;
input.name = name;
input.value = value == null ? "" : String(value);
input.placeholder = placeholder;
wrap.appendChild(label);
wrap.appendChild(input);
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 four profile links — parity with the application form. Keys are the LIVE wire
// keys verbatim ("Linkedin" capital L; "github", "upwork" and "fiverr" lower) and the
// labels and placeholders are the application form's own. Each is optional and
// pre-filled from the record.
form.appendChild(linkField("edLinkedin", "Linkedin", "LinkedIn URL",
readField(record, "Linkedin"), "https://www.linkedin.com/in/…"));
form.appendChild(linkField("edGithub", "github", "GitHub URL",
readField(record, "github"), "https://github.com/…"));
form.appendChild(linkField("edUpwork", "upwork", "Upwork URL",
readField(record, "upwork"), "https://www.upwork.com/freelancers/…"));
form.appendChild(linkField("edFiverr", "fiverr", "Fiverr URL",
readField(record, "fiverr"), "https://www.fiverr.com/…"));
// The photo is editable here with the SAME rules as the application form
// (static/photo-upload.js). A picked file is validated and downscaled on the spot;
// leaving it untouched keeps the record's own Image, which rides back byte-identical
// because the save sends the whole record (see the submit handler below).
var newImage = null;
var photoWrap = el("div", "field");
var photoLabel = el("label", "", "Photo");
photoLabel.setAttribute("for", "edImage");
photoWrap.appendChild(photoLabel);
var photoRow = el("div", "photo-readonly");
var img = readField(record, "Image");
var currentImage = (typeof img === "string" && (img.indexOf("https://") === 0 || img.indexOf("data:image/") === 0)) ? img : null;
var previewImg = el("img");
previewImg.id = "edImagePreview";
previewImg.alt = "";
if (currentImage) {
previewImg.src = currentImage;
photoRow.appendChild(previewImg);
} else {
previewImg.hidden = true;
photoRow.appendChild(previewImg);
photoRow.appendChild(el("span", "mono", initials(who)));
}
photoWrap.appendChild(photoRow);
var photoInput = el("input");
photoInput.type = "file";
photoInput.id = "edImage";
photoInput.name = "Image";
photoInput.accept = "image/jpeg,image/png,image/webp";
photoWrap.appendChild(photoInput);
var photoResult = el("p", "hint", "");
photoResult.id = "edImageResult";
photoResult.setAttribute("role", "status");
photoResult.setAttribute("aria-live", "polite");
photoWrap.appendChild(photoResult);
photoWrap.appendChild(el("p", "hint",
"JPEG, PNG or WebP, up to 8 MB — we resize it to a square of at most 384 px first. " +
"Leave it to keep your current photo."));
O4PPhoto.wire(photoInput, previewImg, photoResult, function (dataUrl) {
newImage = dataUrl;
var fb = photoRow.querySelector(".mono");
if (fb) fb.hidden = !!dataUrl;
if (dataUrl) return; // wire already shows the new image
// reverted to "no new photo": show the record's own photo again
if (currentImage) { previewImg.src = currentImage; previewImg.hidden = false; }
else previewImg.hidden = true;
});
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: READ-MODIFY-WRITE. Start from the record the webhook returned and
// overwrite ONLY the fields this form owns. A field-by-field payload is what deletes a
// freelancer's own photo and links on save — a partial payload wipes what it leaves
// out, so the whole record goes back and only the edited keys are replaced. The
// credential rides first: "has" is the SAME key the delete sends (one credential path
// for both), then the record's id. Method is C.saveMethod ("PATCH").
var payload = { has: hash };
if (rec) {
Object.keys(rec).forEach(function (k) {
if (k === "has") return; // never let the record's own "has" shadow the credential
payload[k] = rec[k]; // Image, the links, createdAt/updatedAt and everything else
});
}
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); });
// The four links, read from their inputs. Only a key we actually have a value for is
// touched: an empty optional field must NOT invent an empty string on a record that
// never had one — the record's own value (null, or absent) is already in the payload
// and is left as it is. A link the record DID carry and the owner cleared is a
// deliberate clear and sends "".
[["edLinkedin", "Linkedin"], ["edGithub", "github"], ["edUpwork", "upwork"], ["edFiverr", "fiverr"]].forEach(function (pair) {
var input = document.getElementById(pair[0]);
if (!input) return;
var v = input.value.trim();
var had = rec && rec[pair[1]] !== undefined && rec[pair[1]] !== null && rec[pair[1]] !== "";
if (v) payload[pair[1]] = v;
else if (had) payload[pair[1]] = "";
});
// An untouched photo rides back exactly as loaded — it came in with the record
// above and was never touched, so it stays byte-identical. Only a NEWLY picked
// photo (already validated and downscaled) replaces it.
if (newImage !== null) payload.Image = newImage;
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>