Public Access
- F2: 'We publish exactly what you write' -> 'We publish what you write — after we check it: your photo, your email address and for spam' (the page's own consent line + photo check + spam gate made the absolute false) - claims sweep: drop the 'no claim we cannot back' meta-claim and the 'not through an agency' absolute; state the individual rule as the declaration we ask for, and make the card's individual tag read '(as declared)' to match the OCA tag - F3: directory lede now says the individual rule is a declaration, not a fact - F4: 'How this works' 110 -> 44 words (three short lines, no relay promise) - F5: count framed as 'a number we recorded' (owner-typed, not derived); photo hint agrees with its '(optional)' label; kicker 'ODOO COMMUNITY FREELANCERS' -> 'ODOO FREELANCERS' and the meta/og drop 'Odoo Community' so an Enterprise client is not invited to self-exclude - F1: the request and invite forms SHIP DARK — their endpoint constants are empty and the forms stay hidden behind a plain 'not open yet' line (no submit, so no false confirmation); the invite copy carries the one-click opt-out line the ICP asked for before the form returns
565 lines
20 KiB
HTML
565 lines
20 KiB
HTML
<!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">A number we record ourselves — not the freelancer's, not a rating.</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="text" inputmode="url" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adOca">OCA profile URL</label>
|
|
<input id="adOca" name="oca_profile" type="text" inputmode="url" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adLinkedin">LinkedIn URL</label>
|
|
<input id="adLinkedin" name="Linkedin" type="text" inputmode="url" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adUpwork">Upwork URL</label>
|
|
<input id="adUpwork" name="upwork" type="text" inputmode="url" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adFiverr">Fiverr URL</label>
|
|
<input id="adFiverr" name="fiverr" type="text" inputmode="url" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adImage">Image URL</label>
|
|
<input id="adImage" name="Image" type="text" inputmode="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 = ["odoo_versions"]; // comma-separated → array
|
|
// `languages` and `specialities` are COMMA-SEPARATED STRINGS on the wire
|
|
// ("English,German"), NOT arrays. `odoo_versions` stays an array.
|
|
var CSV_STRING_FIELDS = ["languages", "specialities"];
|
|
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 (CSV_STRING_FIELDS.indexOf(name) !== -1) {
|
|
// Normalise to the wire shape: trim each entry, drop blanks, re-join.
|
|
var norm = (els[0].value || "")
|
|
.split(",")
|
|
.map(function (s) {
|
|
return s.trim();
|
|
})
|
|
.filter(Boolean)
|
|
.join(",");
|
|
if (norm) payload[name] = norm; // 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>
|