fix(freelancer-directory): apply the ICP's five ship-blockers

- hide the inert client request form; announce it as coming soon with an email
  fallback (no reachable error state, no submit that refuses a completed brief)
- soften the contact claim to 'where a freelancer published their details' and
  make a card with no contact say so instead of showing an empty space
- move the admin add/edit panel off the public page to its own draft page
  (/odoo-community-developers/admin/), unlinked, reachable only by URL
- admin edit: load-and-confirm the record before writing, and read-modify-write
  the booleans so an untouched OCA_member/is_individual keeps its current value
- application form: add Country + Odoo versions (the filters' inputs), GitHub and
  OCA profile URLs, and an explicit email-publication consent line
- tighten 'How this works' from ~120 words to two short options + one caveat
- build-directory.py: skip records with no Name; render GitHub/OCA links and the
  no-contact line; read Country via alias
This commit is contained in:
webmaster committed 2026-11-03 11:47:05 +00:00
1 parent e8c93d89ad
commit c74315e950
2 files changed
+604 -242

No files matched your search

+548
View File
@@ -0,0 +1,548 @@
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Freelancer records — admin</title>
<!-- DRAFT / INTERNAL: not indexed, not linked, not in sitemap.xml / llms.txt.
Reachable only by this URL. All five draft conditions hold
(noindex · not in sitemap · not in llms.txt · no inbound links · no announcement
surface). Do NOT link to this page from anywhere — not from the directory, not
from the footer. Remove this robots meta only if it ever becomes public. -->
<meta name="robots" content="noindex, nofollow" />
<link rel="canonical" href="https://odoo4projects.com/odoo-community-developers/admin/" />
<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>
/* ---- Freelancer records — admin (page-scoped). Internal page, never linked. ---- */
.adm-bar {
background: var(--midnight-2);
border-bottom: 1px solid var(--line-brass);
padding: 0.85rem 0;
}
.adm-bar .container {
display: flex;
align-items: baseline;
gap: 1rem;
flex-wrap: wrap;
}
.adm-brand {
font-family: Marcellus, Georgia, serif;
font-size: 1.15rem;
letter-spacing: 0.04em;
color: #fff;
}
.adm-brand span {
color: var(--brass);
}
.adm-tag {
font-size: 0.78rem;
letter-spacing: 0.05em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.65);
}
.adm-wrap {
padding: 2.5rem 0 4rem;
}
.adm-wrap h1 {
font-family: Marcellus, Georgia, serif;
font-weight: 400;
font-size: 1.9rem;
color: var(--midnight);
margin: 0 0 0.6rem;
}
.adm-note {
max-width: 720px;
margin: 0 0 1.75rem;
color: var(--ink-soft);
line-height: 1.7;
}
.adm-note code {
font-size: 0.85em;
color: var(--midnight);
}
.form-card {
max-width: 860px;
background: var(--ivory);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-rest);
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.form-field.full,
.form-grid > .full {
grid-column: 1 / -1;
}
.form-field label {
font-size: 0.8rem;
font-weight: 600;
color: var(--midnight);
}
.form-field .hint {
font-size: 0.78rem;
color: var(--ink-soft);
}
.form-field input,
.form-field textarea,
.form-field select {
font-family: Inter, sans-serif;
font-size: 0.9rem;
padding: 0.6rem 0.7rem;
border-radius: var(--radius);
border: 1px solid var(--line);
background: #fff;
color: var(--ink);
width: 100%;
}
.form-check {
grid-column: 1 / -1;
display: flex;
align-items: flex-start;
gap: 0.6rem;
font-size: 0.85rem;
color: var(--ink-soft);
}
.form-check input {
width: auto;
margin-top: 0.2rem;
flex-shrink: 0;
}
.form-actions {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
margin-top: 0.25rem;
}
.form-status {
font-size: 0.85rem;
color: var(--ink-soft);
}
.form-status[data-state="ok"] {
color: var(--success, #3f7d4f);
}
.form-status[data-state="err"] {
color: #b3452f;
}
.adm-loaded {
grid-column: 1 / -1;
margin: 0;
font-size: 0.85rem;
line-height: 1.6;
color: var(--ink-soft);
}
.adm-loaded[data-state="ok"] {
color: var(--success, #3f7d4f);
}
.adm-loaded[data-state="err"] {
color: #b3452f;
}
.adm-sep {
grid-column: 1 / -1;
width: 100%;
border: 0;
border-top: 1px solid var(--line);
margin: 0.35rem 0;
}
/* Page-local quiet button: the shared .btn-outline is cream-on-transparent (built
for the dark sections) and would be near-invisible on this page's ivory card. */
.btn-quiet {
background: transparent;
color: var(--ink);
border-color: rgba(23, 28, 38, 0.22);
}
.btn-quiet:hover {
color: var(--brass-deep);
border-color: var(--brass-deep);
background: rgba(201, 162, 39, 0.08);
}
.btn:focus-visible {
outline: 3px solid var(--brass-deep);
outline-offset: 2px;
border-radius: var(--radius);
}
@media (max-width: 768px) {
.form-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="adm-bar">
<div class="container">
<span class="adm-brand">ODOO4<span>projects</span></span>
<span class="adm-tag">Freelancer records — internal</span>
</div>
</div>
<main class="adm-wrap">
<div class="container">
<h1>Add or correct a freelancer record</h1>
<p class="adm-note">
Internal page — not linked from the directory. Add a new record, or load an existing one by
its id, <strong>confirm it is the right person</strong>, then correct what is wrong. A save
posts to the one <code>odoo_experts</code> webhook and is applied after email confirmation.
Fields you leave alone keep the record's current value.
</p>
<form class="form-card" id="adminForm" novalidate>
<div class="form-grid">
<div class="form-field">
<label for="adId">Record id <span class="hint">(leave blank to create a new record)</span></label>
<input id="adId" name="id" type="number" min="1" />
</div>
<div class="form-field">
<label for="adLoadBtn">Confirm before you edit</label>
<button type="button" class="btn btn-quiet" id="adLoadBtn">Load this record</button>
</div>
<p class="adm-loaded" id="adLoadInfo" role="status">Enter a record id and load it — you will see who it is before anything is written.</p>
<hr class="adm-sep" />
<div class="form-field">
<label for="adName">Name</label>
<input id="adName" name="Name" type="text" />
</div>
<div class="form-field">
<label for="adEmail">Email</label>
<input id="adEmail" name="email" type="email" />
</div>
<div class="form-field">
<label for="adClients">Projects on ODOO4projects</label>
<input id="adClients" name="odoo4projects_clients" type="number" min="0" />
<span class="hint">Our own count, not the freelancer's.</span>
</div>
<div class="form-field">
<label for="adFocus">Focus</label>
<input id="adFocus" name="Focus" type="text" />
</div>
<div class="form-field full">
<label for="adDescription">Description</label>
<textarea id="adDescription" name="description" rows="4"></textarea>
</div>
<div class="form-field">
<label for="adCountry">Country</label>
<input id="adCountry" name="Country" type="text" />
</div>
<div class="form-field">
<label for="adVersions">Odoo versions</label>
<input id="adVersions" name="odoo_versions" type="text" />
<span class="hint">Separate with commas.</span>
</div>
<div class="form-field">
<label for="adLanguages">Languages</label>
<input id="adLanguages" name="languages" type="text" />
<span class="hint">Separate with commas.</span>
</div>
<div class="form-field">
<label for="adSpecialities">Specialities</label>
<input id="adSpecialities" name="specialities" type="text" />
<span class="hint">Separate with commas.</span>
</div>
<div class="form-field">
<label for="adGithub">GitHub URL</label>
<input id="adGithub" name="github" type="url" />
</div>
<div class="form-field">
<label for="adOca">OCA profile URL</label>
<input id="adOca" name="oca_profile" type="url" />
</div>
<div class="form-field">
<label for="adLinkedin">LinkedIn URL</label>
<input id="adLinkedin" name="Linkedin" type="url" />
</div>
<div class="form-field">
<label for="adUpwork">Upwork URL</label>
<input id="adUpwork" name="upwork" type="url" />
</div>
<div class="form-field">
<label for="adFiverr">Fiverr URL</label>
<input id="adFiverr" name="fiverr" type="url" />
</div>
<div class="form-field">
<label for="adImage">Image URL</label>
<input id="adImage" name="Image" type="url" />
</div>
<label class="form-check">
<input type="checkbox" name="OCA_member" value="1" />
<span>OCA member.</span>
</label>
<label class="form-check">
<input type="checkbox" name="is_individual" value="1" />
<span>Works as an individual.</span>
</label>
<div class="form-actions">
<button type="submit" class="btn btn-brass">Save this record</button>
<span class="form-status" id="adminStatus" role="status"></span>
</div>
</div>
</form>
</div>
</main>
<script>
/* ============================================================================
Freelancer records — admin page script.
ONE webhook, for everything. The freelancer platform is a SEPARATE system from
the CRM: there is no /crm, no /crm_odoo and nothing from the CRM skill here.
Write contract (same URL, id decides):
POST with an id → UPDATE that record
POST without an id → CREATE a new record
Email confirmation applies to both paths.
READ-MODIFY-WRITE (the reason this page loads the record first): a boolean the
owner did not touch must keep the record's CURRENT value. Loading the record
syncs the checkboxes to it, so an untouched box sends what the record already
holds instead of flipping it to false. An edit is refused until the record has
been loaded and confirmed — a bare numeric id can no longer edit the wrong person.
============================================================================ */
var ODOO_EXPERTS_WEBHOOK =
"https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts";
var MULTI_FIELDS = ["languages", "specialities", "odoo_versions"]; // comma-separated → array
var BOOL_FIELDS = ["OCA_member", "is_individual"];
var NUM_FIELDS = ["odoo4projects_clients"];
var TEXT_FIELDS = [
"Name", "email", "Focus", "description", "Country",
"Linkedin", "upwork", "fiverr", "github", "oca_profile", "Image"
];
var loadedRecord = null;
function fieldElements(form, name) {
return Array.prototype.slice.call(
form.querySelectorAll('[name="' + name.replace(/"/g, '\\"') + '"]')
);
}
function setStatus(el, message, state) {
if (!el) return;
el.textContent = message;
if (state) el.setAttribute("data-state", state);
else el.removeAttribute("data-state");
}
/* The webhook casing is inconsistent; read a field's value by its known names. */
function recordField(rec, name) {
if (rec[name] !== undefined) return rec[name];
if (name === "Country" && rec.country !== undefined) return rec.country;
return undefined;
}
function joinMulti(value) {
if (value == null) return "";
if (Array.isArray(value)) return value.join(", ");
return String(value);
}
/* Copy a loaded record into the form, so an untouched field carries the record's
current value through to the write. */
function populate(rec) {
var form = document.getElementById("adminForm");
TEXT_FIELDS.forEach(function (name) {
var el = form.querySelector('[name="' + name + '"]');
if (!el) return;
var v = recordField(rec, name);
el.value = v == null ? "" : String(v);
});
MULTI_FIELDS.forEach(function (name) {
var el = form.querySelector('[name="' + name + '"]');
if (el) el.value = joinMulti(recordField(rec, name));
});
var clients = form.querySelector('[name="odoo4projects_clients"]');
if (clients) {
var n = rec.odoo4projects_clients;
clients.value = n == null ? "" : String(n);
}
BOOL_FIELDS.forEach(function (name) {
var box = form.querySelector('[name="' + name + '"]');
if (!box) return;
var val = rec[name] === true;
box.checked = val;
box.dataset.loaded = val ? "true" : "false";
});
}
function loadRecord() {
var form = document.getElementById("adminForm");
var info = document.getElementById("adLoadInfo");
var idRaw = form.querySelector('[name="id"]').value.trim();
loadedRecord = null;
if (!idRaw) {
setStatus(info, "Enter a record id first — or leave it blank to create a new record.", "err");
return;
}
setStatus(info, "Loading record " + idRaw + "…", null);
fetch(ODOO_EXPERTS_WEBHOOK, { method: "GET" })
.then(function (r) {
return r.json();
})
.then(function (payload) {
var list = Array.isArray(payload) ? payload : (payload && payload.data) || [];
var rec = null;
list.forEach(function (r) {
if (String(r.id) === String(idRaw)) rec = r;
});
if (!rec) {
setStatus(info, "No record with id " + idRaw + ". Nothing was loaded.", "err");
return;
}
loadedRecord = rec;
populate(rec);
var who = rec.Name ? rec.Name : "(no name)";
setStatus(
info,
"Loaded: " + who + " — id " + rec.id + ". Check this is the right person, then edit and save. Fields you leave alone keep their current value.",
"ok"
);
})
.catch(function () {
setStatus(info, "Could not read the record list. Try again.", "err");
});
}
function buildPayload(form, withId) {
var payload = {};
var names = [];
Array.prototype.forEach.call(form.querySelectorAll("[name]"), function (el) {
if (names.indexOf(el.name) === -1) names.push(el.name);
});
names.forEach(function (name) {
var els = fieldElements(form, name);
if (!els.length) return;
if (name === "id") {
if (!withId) return;
var raw = els[0].value.trim();
if (raw) payload.id = Number(raw);
return;
}
if (MULTI_FIELDS.indexOf(name) !== -1) {
var text = els[0].value || "";
var list = text
.split(",")
.map(function (s) {
return s.trim();
})
.filter(Boolean);
if (list.length) payload[name] = list; // an empty box must not wipe the field
return;
}
if (BOOL_FIELDS.indexOf(name) !== -1) {
var box = els[0];
if (withId && box.dataset.loaded !== undefined) {
// READ-MODIFY-WRITE: an untouched box keeps the record's current value.
var original = box.dataset.loaded === "true";
var now = !!box.checked;
payload[name] = now === original ? original : now;
} else {
payload[name] = !!box.checked;
}
return;
}
if (NUM_FIELDS.indexOf(name) !== -1) {
var n = els[0].value.trim();
if (n !== "") payload[name] = Number(n);
return;
}
var value = els[0].value.trim();
if (value !== "") payload[name] = value;
});
return payload;
}
function postToWebhook(payload) {
return fetch(ODOO_EXPERTS_WEBHOOK, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
(function init() {
var form = document.getElementById("adminForm");
if (!form) return;
var info = document.getElementById("adLoadInfo");
var status = document.getElementById("adminStatus");
document.getElementById("adLoadBtn").addEventListener("click", loadRecord);
// Changing the id after a load invalidates the confirmation.
form.querySelector('[name="id"]').addEventListener("input", function () {
if (loadedRecord && String(loadedRecord.id) !== this.value.trim()) {
loadedRecord = null;
setStatus(info, "The id changed — load the record again to confirm who you are editing.", "err");
}
});
form.addEventListener("submit", function (event) {
event.preventDefault();
if (!form.reportValidity()) return;
var idRaw = form.querySelector('[name="id"]').value.trim();
var hasId = !!idRaw;
if (hasId && (!loadedRecord || String(loadedRecord.id) !== idRaw)) {
setStatus(
status,
"Load the record first (Load this record) and confirm it is the right person. Nothing was saved.",
"err"
);
return;
}
var submit = form.querySelector('button[type="submit"]');
submit.disabled = true;
setStatus(status, "Saving…", null);
postToWebhook(buildPayload(form, true))
.then(function () {
setStatus(
status,
hasId
? "Saved. Check your inbox to confirm the change before it is applied."
: "New record sent. Check your inbox to confirm it before it goes live.",
"ok"
);
})
.catch(function () {
setStatus(status, "That did not save. Please try again.", "err");
})
.then(function () {
submit.disabled = false;
});
});
})();
</script>
</body>
</html>