Public Access
feat(freelancer): card cog + self-service modal, and the private freelancer-edit page
Card (odoo-community-developers/):
- "Ask question anonymously" moves into its own row ABOVE the links row, in the
runtime renderer and in build-directory.py (the build-time template).
- A cog icon button sits next to it: a real <button> with an accessible name
(aria-label="Edit or delete your profile"), same quiet weight as its neighbour.
- Pressing the cog opens a modal that explains the flow and asks for the email
address, then POSTs {"email":"..."} to odoo_experts_edit (body shape in one
constant, EDIT_EMAIL_KEY). The reply is never read: one message on any 2xx, so
the box cannot tell a visitor whether an address is on file. A non-2xx fails
honestly and shows no receipt. Both controls ship dark (added only while their
route constant is set), so neither is ever a dead control.
New private page (freelancer-edit/, noindex+nofollow, unlinked, absent from
sitemap.xml and llms.txt):
- Emailed link is https://odoo4projects.com/freelancer-edit/?has=<hash> — the
same shape as /freelancer-answer/; reads `has` first, falls back to `hash`.
- GETs the edit webhook with the hash and pre-fills the form: Name, Focus (<=70),
description (<=230), the five axis numbers (must total 15), the three language
numbers (0-5), Country, OCA_link. The GET reply is read through one alias map;
a shape it does not recognise names the fields it received instead of guessing.
404 "is not registered" is a CONFIGURATION error, never "your link is invalid";
404/500 "No item to return was found" is an invalid/expired link; a network
failure keeps the hash for a retry.
- Save = POST with the record id (one constant, SAVE_METHOD). Delete is a
deliberate second action (a confirm tick gates the button) and never reports
success on a non-2xx.
- The image is read-only here, stated in the UI: replacing a photo is a different
problem from editing text.
- The hash never reaches analytics: the query is stripped before the Plausible
snippet loads. referrer policy no-referrer; JS off says so plainly.
This commit is contained in:
1 parent
f0a2c8d745
commit
1a1b62136f
2 files changed
+897
-15
No files matched your search
@@ -0,0 +1,710 @@
|
|||||||
|
<!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>
|
||||||
|
|
||||||
|
<!-- 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: 15,
|
||||||
|
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", "Line truncated
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var C = window.O4E;
|
||||||
|
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", "", "Skill areas"));
|
||||||
|
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 = "The five skill numbers total " + sum + ". They must total exactly " +
|
||||||
|
C.focusTotal + " before this can be saved.";
|
||||||
|
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>
|
||||||
@@ -502,6 +502,40 @@
|
|||||||
outline: 3px solid var(--brass-deep);
|
outline: 3px solid var(--brass-deep);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
/* The row ABOVE the links: the "Ask question anonymously" action and, next to it,
|
||||||
|
the self-service cog (edit/delete your profile). Both are added by script only
|
||||||
|
while their route is set, so neither is ever a dead control. The cog is a small
|
||||||
|
quiet icon at the same weight as the link beside it — not a second primary button. */
|
||||||
|
.dev-ask-row {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.dev-ask-row button,
|
||||||
|
.dev-ask-row a {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--brass-deep);
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.dev-ask-row .dev-cog-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.2rem;
|
||||||
|
line-height: 0;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.dev-ask-row button:focus-visible {
|
||||||
|
outline: 3px solid var(--brass-deep);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
/* A card with no contact route of any kind says so — it never shows an empty space. */
|
/* A card with no contact route of any kind says so — it never shows an empty space. */
|
||||||
.dev-contact-none {
|
.dev-contact-none {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -1827,6 +1861,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile self-service modal — the cog button opens it. It asks for the freelancer's
|
||||||
|
email and posts { email } to the odoo_experts_edit route, which emails a private
|
||||||
|
link to edit or delete their listing. It ships DARK: the cog is added by the page
|
||||||
|
script only while ODOO_EXPERTS_EDIT_WEBHOOK is set, so a stub route can never be
|
||||||
|
submitted to. The reply is NEVER read — the page shows one message on any 2xx, so
|
||||||
|
it cannot reveal whether an address is on file. -->
|
||||||
|
<div class="contact-modal" id="profileModal" hidden role="dialog" aria-modal="true" aria-labelledby="profileModalTitle">
|
||||||
|
<div class="contact-modal-card" role="document">
|
||||||
|
<button type="button" class="contact-modal-close" id="profileModalClose" aria-label="Close">×</button>
|
||||||
|
<h3 id="profileModalTitle">Edit or delete your profile</h3>
|
||||||
|
<p class="contact-modal-lede">If you have a listing in this directory, enter the email address you applied with and we will send you a private link. That link lets you edit your details — or delete your listing.</p>
|
||||||
|
<form id="profileModalForm" novalidate>
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="profileModalEmail">Your email address</label>
|
||||||
|
<input type="email" id="profileModalEmail" name="email" autocomplete="email" required />
|
||||||
|
<span class="hint">We only use it to send the link.</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-brass">Email me the link</button>
|
||||||
|
<span class="form-status" id="profileModalStatus" role="status"></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- The apply modal — LIVE. It holds the ONE #applyForm, moved in by the page script
|
<!-- The apply modal — LIVE. It holds the ONE #applyForm, moved in by the page script
|
||||||
when it runs; it is NOT gated by any endpoint constant, so the anon-question
|
when it runs; it is NOT gated by any endpoint constant, so the anon-question
|
||||||
modal's dark state (ODOO_EXPERTS_QUOTE_WEBHOOK empty) cannot disable it. With
|
modal's dark state (ODOO_EXPERTS_QUOTE_WEBHOOK empty) cannot disable it. With
|
||||||
@@ -1924,6 +1983,12 @@
|
|||||||
// never a delivery claim — n8n answers 200 before the workflow runs, so the form
|
// never a delivery claim — n8n answers 200 before the workflow runs, so the form
|
||||||
// cannot detect failure and must not promise an outcome.
|
// cannot detect failure and must not promise an outcome.
|
||||||
var ODOO_EXPERTS_QUOTE_WEBHOOK = "https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_quote";
|
var ODOO_EXPERTS_QUOTE_WEBHOOK = "https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_quote";
|
||||||
|
// The per-card self-service cog: "edit or delete your profile", by emailed link.
|
||||||
|
// Setting this endpoint switches the cog and its modal on. The modal asks for an
|
||||||
|
// email address and posts { email } here; the workflow then emails that address a
|
||||||
|
// private link. The request body shape lives in ONE place (EDIT_EMAIL_KEY below).
|
||||||
|
var ODOO_EXPERTS_EDIT_WEBHOOK = "https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_edit";
|
||||||
|
var EDIT_EMAIL_KEY = "email"; // body sent to ODOO_EXPERTS_EDIT_WEBHOOK: { email: "..." }
|
||||||
|
|
||||||
// ---- The shared constants (the same lists live in build-directory.py) --------
|
// ---- The shared constants (the same lists live in build-directory.py) --------
|
||||||
// The five self-assessed focus axes — each a SEPARATE numeric field (0-5) on the
|
// The five self-assessed focus axes — each a SEPARATE numeric field (0-5) on the
|
||||||
@@ -2106,9 +2171,9 @@
|
|||||||
else el.removeAttribute("data-state");
|
else el.removeAttribute("data-state");
|
||||||
}
|
}
|
||||||
|
|
||||||
function postTo(url, payload) {
|
function postTo(url, payload, method) {
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
method: "POST",
|
method: method || "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
@@ -2571,13 +2636,113 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add the button to one card — ONLY while the route exists AND the card is marked
|
/* ---- The profile self-service modal (edit or delete your listing) ---------------
|
||||||
(data-relay) as a record with an address we can relay to. Nothing is added
|
The cog opens it. It asks for the freelancer's email and POSTs { email } to the
|
||||||
otherwise, so the button is never dead and never promises a delivery. */
|
edit route, which emails a private link. Ships DARK: the cog is added only while
|
||||||
|
ODOO_EXPERTS_EDIT_WEBHOOK is set. THE REPLY IS NEVER READ — on ANY 2xx the page
|
||||||
|
shows ONE constant message, so this box cannot be used to ask "does this address
|
||||||
|
have a profile with you?" The answer is identical whether or not the address is
|
||||||
|
on file (no branch on the address, no branch on the response body). */
|
||||||
|
var profileLastFocus = null;
|
||||||
|
|
||||||
|
function profileEls() {
|
||||||
|
return {
|
||||||
|
modal: document.getElementById("profileModal"),
|
||||||
|
form: document.getElementById("profileModalForm"),
|
||||||
|
email: document.getElementById("profileModalEmail"),
|
||||||
|
status: document.getElementById("profileModalStatus"),
|
||||||
|
close: document.getElementById("profileModalClose")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function openProfileModal(trigger) {
|
||||||
|
var m = profileEls();
|
||||||
|
if (!m.modal || !m.form) return;
|
||||||
|
profileLastFocus = trigger || document.activeElement;
|
||||||
|
setStatus(m.status, "", null);
|
||||||
|
m.modal.hidden = false;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
if (m.email) m.email.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeProfileModal() {
|
||||||
|
var m = profileEls();
|
||||||
|
if (!m.modal || m.modal.hidden) return;
|
||||||
|
m.modal.hidden = true;
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
if (profileLastFocus && profileLastFocus.focus) profileLastFocus.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initProfileModal() {
|
||||||
|
if (!ODOO_EXPERTS_EDIT_WEBHOOK) return; // dark
|
||||||
|
var m = profileEls();
|
||||||
|
if (!m.modal || !m.form) return;
|
||||||
|
if (m.close) m.close.addEventListener("click", closeProfileModal);
|
||||||
|
m.modal.addEventListener("click", function (e) {
|
||||||
|
if (e.target === m.modal) closeProfileModal();
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (m.modal.hidden) return;
|
||||||
|
if (e.key === "Escape") { closeProfileModal(); return; }
|
||||||
|
if (e.key !== "Tab") return;
|
||||||
|
var focusables = m.modal.querySelectorAll(
|
||||||
|
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||||
|
);
|
||||||
|
if (!focusables.length) return;
|
||||||
|
var f = focusables[0], last = focusables[focusables.length - 1];
|
||||||
|
if (e.shiftKey && document.activeElement === f) { e.preventDefault(); last.focus(); }
|
||||||
|
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); f.focus(); }
|
||||||
|
});
|
||||||
|
m.form.addEventListener("submit", function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!m.form.reportValidity()) return;
|
||||||
|
// The body shape, in ONE constant (EDIT_EMAIL_KEY): { email: "<address>" }.
|
||||||
|
var body = {};
|
||||||
|
body[EDIT_EMAIL_KEY] = m.email ? m.email.value.trim() : "";
|
||||||
|
var submit = m.form.querySelector('button[type="submit"]');
|
||||||
|
if (submit) submit.disabled = true;
|
||||||
|
setStatus(m.status, "Sending…", null);
|
||||||
|
postTo(ODOO_EXPERTS_EDIT_WEBHOOK, body)
|
||||||
|
.then(function (res) {
|
||||||
|
// A refusal (route missing, method refused) fails honestly and shows no
|
||||||
|
// receipt. Only a 2xx earns the receipt below.
|
||||||
|
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||||
|
// ONE message on ANY 2xx — read from no part of the reply, so it cannot
|
||||||
|
// reveal whether the address is on file.
|
||||||
|
setStatus(m.status, "If that address is on file, we will email a link to it.", "ok");
|
||||||
|
m.form.reset();
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
setStatus(m.status,
|
||||||
|
"We could not send the email just now. Please try again, or write to support@odoo4projects.com.",
|
||||||
|
"err");
|
||||||
|
})
|
||||||
|
.then(function () { if (submit) submit.disabled = false; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The cog glyph (Feather "settings", MIT) — a small quiet icon, same weight as the
|
||||||
|
link beside it. Inline so the card carries no extra request. */
|
||||||
|
var COG_SVG =
|
||||||
|
'<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false"' +
|
||||||
|
' fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"' +
|
||||||
|
' stroke-linejoin="round"><circle cx="12" cy="12" r="3"/>' +
|
||||||
|
'<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>';
|
||||||
|
|
||||||
|
/* Add the self-service controls to one card, in ONE row ABOVE the links so the
|
||||||
|
contact action comes first:
|
||||||
|
· "Ask question anonymously" — only while its route is set AND the card is
|
||||||
|
marked (data-relay) as a record with an address we can relay to;
|
||||||
|
· the cog (edit or delete your profile) — only while its route is set.
|
||||||
|
Nothing is added otherwise, so neither control is ever dead. The cog's
|
||||||
|
accessible name comes from its aria-label. */
|
||||||
function enhanceAsk(card) {
|
function enhanceAsk(card) {
|
||||||
if (!ODOO_EXPERTS_QUOTE_WEBHOOK) return;
|
var canQuote = !!ODOO_EXPERTS_QUOTE_WEBHOOK && card.getAttribute("data-relay") === "1";
|
||||||
if (card.getAttribute("data-relay") !== "1") return;
|
var canEdit = !!ODOO_EXPERTS_EDIT_WEBHOOK;
|
||||||
if (card.querySelector(".dev-ask-btn")) return;
|
if (!canQuote && !canEdit) return;
|
||||||
|
if (card.querySelector(".dev-ask-row")) return;
|
||||||
|
var row = liveEl("p", "dev-ask-row");
|
||||||
|
if (canQuote) {
|
||||||
var btn = document.createElement("button");
|
var btn = document.createElement("button");
|
||||||
btn.type = "button";
|
btn.type = "button";
|
||||||
btn.className = "dev-ask-btn";
|
btn.className = "dev-ask-btn";
|
||||||
@@ -2585,15 +2750,21 @@
|
|||||||
btn.addEventListener("click", function () {
|
btn.addEventListener("click", function () {
|
||||||
openAskModal(card.getAttribute("data-id"), card.getAttribute("data-name") || "");
|
openAskModal(card.getAttribute("data-id"), card.getAttribute("data-name") || "");
|
||||||
});
|
});
|
||||||
var row = card.querySelector(".dev-links");
|
|
||||||
if (row) {
|
|
||||||
row.appendChild(document.createTextNode(" \u00b7 "));
|
|
||||||
row.appendChild(btn);
|
row.appendChild(btn);
|
||||||
} else {
|
|
||||||
var p = liveEl("p", "dev-links");
|
|
||||||
p.appendChild(btn);
|
|
||||||
card.appendChild(p);
|
|
||||||
}
|
}
|
||||||
|
if (canEdit) {
|
||||||
|
var cog = document.createElement("button");
|
||||||
|
cog.type = "button";
|
||||||
|
cog.className = "dev-cog-btn";
|
||||||
|
cog.setAttribute("aria-label", "Edit or delete your profile");
|
||||||
|
cog.innerHTML = COG_SVG; // a static constant, never data from a record
|
||||||
|
cog.addEventListener("click", function () { openProfileModal(cog); });
|
||||||
|
row.appendChild(cog);
|
||||||
|
}
|
||||||
|
// Above the links row, or the "no way to reach" note, else at the end of the card.
|
||||||
|
var anchor = card.querySelector(".dev-links") || card.querySelector(".dev-contact-none");
|
||||||
|
if (anchor) card.insertBefore(row, anchor);
|
||||||
|
else card.appendChild(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
function initLiveList() {
|
function initLiveList() {
|
||||||
@@ -2882,6 +3053,7 @@
|
|||||||
initFocusCounter(document.getElementById("applyForm"), "apFocus", "apFocusCount");
|
initFocusCounter(document.getElementById("applyForm"), "apFocus", "apFocusCount");
|
||||||
initFocusCounter(document.getElementById("applyForm"), "apDescription", "apDescriptionCount", DESC_MAX_CHARS);
|
initFocusCounter(document.getElementById("applyForm"), "apDescription", "apDescriptionCount", DESC_MAX_CHARS);
|
||||||
initAskModal();
|
initAskModal();
|
||||||
|
initProfileModal();
|
||||||
Array.prototype.forEach.call(document.querySelectorAll(".dev-card"), enhanceAsk);
|
Array.prototype.forEach.call(document.querySelectorAll(".dev-card"), enhanceAsk);
|
||||||
initPager();
|
initPager();
|
||||||
initFilters();
|
initFilters();
|
||||||
|
|||||||
Reference in new issue
Block a user