Files
ODOO4projects.com/odoo-community-developers/admin/index.html
T
webmaster 99245764a9 feat(freelancer-directory): 5-axis focus profile, 3 numeric languages, OCA from the link; drop odoo_versions
The user's spec (2026-11-03): "remove the ODOO versions. people are lying anyways";
"15 points to distribute between functional system modules integration ai"; "the
languages should be self assessed with 0 can not speak and 5 native. we have the
languages english german and spanish"; "lets limit the focus on the form to 70 chars";
"lets remove the member OCA question … use the OCA link to determine if someone is a
member … show the oca link on top of all links and make it apart with a color, also
dont show the link itself just a button or a chip"; "the only quality signal we can give
and verify is how many projects the freelancer brought to our platform"; "no sorting we
keep the no rank policy"; "get the sort on the page out."

REMOVED (both forms + the filter + the hints):
- `odoo_versions` — the webhook never carried it, so every value was silently dropped
  (re-verified: the payload has no `odoo_versions` field).
- `specialities` — replaced by the focus profile.
- the OCA-member question — membership is now DERIVED from `OCA_link`.

ADDED:
- Five focus sliders (functional, system, modules, integration, ai), each 0-5, from ONE
  constant (`FOCUS_AXES`), on both forms. Live running total ("12 of 15 points"); the
  submit is BLOCKED with an inline warning unless the five total exactly 15. The 15-point
  budget of a possible 25 is what stops anyone claiming maximum everywhere, and the form
  says so. No-JS: a `<noscript>` block swaps the sliders for five number inputs.
- Three language sliders (English, German, Spanish), each 0-5 (0 = cannot speak,
  5 = native), as SEPARATE numeric wire fields — no CSV, no 114-name ISO dataset, no
  free text. Preset to 0 (the user's decision, stated): an untouched slider records
  "cannot speak" rather than a blank.
- `Focus` capped at 70 characters on both forms with a live counter; the render path
  truncates a longer legacy value to 70 with a visible ellipsis.
- The card, in the user's order: photo+name · "N clients on ODOO4projects" (once, with
  weight) · Focus · the freelancer's own words · the focus-profile chart (one row per
  axis the payload carries, five squares) · the language line ("English 4 · German 5") ·
  the links, with the OCA profile chip FIRST and APART (a label, never the raw URL;
  "(as declared)") · the anonymous-contact button (JS-only — dark).
- The anonymous-contact modal: sends `{freelancers:[{id,name}], brief, email}` for the
  one card, opens only when `ODOO_EXPERTS_QUOTE_WEBHOOK` is set (still empty — ships
  dark), and says plainly that the email goes to us, never to the freelancer.
- OCA membership from `OCA_link`, matched on host + PATH PREFIX
  (`https://apps.odoo-community.org/community/`), never the bare domain.

ORDER: the client-side `Name` sort is gone from BOTH renderers — the endpoint's order is
rendered as received, so cards reshuffle between loads (the server randomises on purpose).
Nothing is ever ordered by the client count.

Also: the count is rendered ONCE (the payload has one field for it — "clients" and
"projects" are the same number here), `0` renders (a fact) while absent/null renders
nothing, and the legacy `languages`/`specialities` fields are left in the payload but no
longer read.

QC: verify-html.py 0 · check-inline-js OK · test-directory-render.py PASS · competitor QC
exit 3, 74 site-wide pre-existing, 0 in our files · impeccable detect --no-advisory
dir 14 (0 added) / admin 7 (0 added) · 375 px 0 overflow · no form submitted by hand.
2026-11-03 13:26:26 +00:00

1049 lines
43 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%;
}
/* The standardised language multi-select: a closed, scrollable checklist. The
level <select> appears beside a language only once it is checked — pure CSS. */
.lang-field {
min-width: 0;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.85rem 0.9rem 0.6rem;
}
.lang-field legend {
font-size: 0.8rem;
font-weight: 600;
color: var(--midnight);
padding: 0 0.35rem;
}
.lang-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 220px;
overflow: auto;
padding-right: 0.35rem;
}
.lang-pick {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.88rem;
color: var(--ink);
}
.lang-pick input {
width: auto;
margin: 0;
flex-shrink: 0;
}
.lang-pick .lang-level {
display: none;
width: auto;
max-width: 9rem;
padding: 0.25rem 0.4rem;
font-size: 0.8rem;
}
.lang-pick input:checked ~ .lang-level {
display: inline-block;
}
.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);
}
/* ---- Focus + language sliders (2026-11-03n) ---- */
.focus-field {
min-width: 0;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.85rem 0.9rem 0.7rem;
}
.focus-field legend {
font-size: 0.8rem;
font-weight: 600;
color: var(--midnight);
padding: 0 0.35rem;
}
.focus-axis {
display: grid;
grid-template-columns: 6.5rem 1fr 2.2rem;
align-items: center;
gap: 0.6rem;
margin: 0.3rem 0;
}
.focus-axis label {
font-size: 0.85rem;
font-weight: 500;
color: var(--ink);
}
.focus-axis input[type="range"] {
width: 100%;
accent-color: var(--brass-deep);
}
.focus-axis output {
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
text-align: right;
color: var(--ink);
}
.focus-total {
margin: 0.6rem 0 0;
font-size: 0.85rem;
font-weight: 600;
color: var(--midnight);
}
.focus-scale {
margin: 0.45rem 0 0;
font-size: 0.76rem;
line-height: 1.55;
color: var(--ink-soft);
}
.focus-warn {
margin: 0.55rem 0 0;
font-size: 0.85rem;
color: #b3452f;
min-height: 1.1em;
}
.focus-counter {
font-size: 0.78rem;
color: var(--ink-soft);
}
.focus-counter[data-over="1"] {
color: #b3452f;
}
@media (max-width: 768px) {
.form-grid {
grid-template-columns: 1fr;
}
.focus-axis {
grid-template-columns: 5.2rem 1fr 1.7rem;
gap: 0.4rem;
}
}
</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 <span class="hint">(one line, at most 70 characters)</span></label>
<input id="adFocus" name="Focus" type="text" maxlength="70" aria-describedby="adFocusCount" />
<span class="focus-counter" id="adFocusCount" aria-live="polite">0 / 70</span>
</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>
<select id="adCountry" name="Country" required>
<!-- COUNTRY-OPTIONS-START -->
<option value="">— choose —</option>
<option value="AD">Andorra</option>
<option value="AE">United Arab Emirates</option>
<option value="AF">Afghanistan</option>
<option value="AG">Antigua and Barbuda</option>
<option value="AI">Anguilla</option>
<option value="AL">Albania</option>
<option value="AM">Armenia</option>
<option value="AO">Angola</option>
<option value="AQ">Antarctica</option>
<option value="AR">Argentina</option>
<option value="AS">American Samoa</option>
<option value="AT">Austria</option>
<option value="AU">Australia</option>
<option value="AW">Aruba</option>
<option value="AX">Aland</option>
<option value="AZ">Azerbaijan</option>
<option value="BA">Bosnia and Herzegovina</option>
<option value="BB">Barbados</option>
<option value="BD">Bangladesh</option>
<option value="BE">Belgium</option>
<option value="BF">Burkina Faso</option>
<option value="BG">Bulgaria</option>
<option value="BH">Bahrain</option>
<option value="BI">Burundi</option>
<option value="BJ">Benin</option>
<option value="BL">Saint Barthelemy</option>
<option value="BM">Bermuda</option>
<option value="BN">Brunei</option>
<option value="BO">Bolivia</option>
<option value="BQ">Bonaire</option>
<option value="BR">Brazil</option>
<option value="BS">Bahamas</option>
<option value="BT">Bhutan</option>
<option value="BV">Bouvet Island</option>
<option value="BW">Botswana</option>
<option value="BY">Belarus</option>
<option value="BZ">Belize</option>
<option value="CA">Canada</option>
<option value="CC">Cocos (Keeling) Islands</option>
<option value="CD">Democratic Republic of the Congo</option>
<option value="CF">Central African Republic</option>
<option value="CG">Republic of the Congo</option>
<option value="CH">Switzerland</option>
<option value="CI">Ivory Coast</option>
<option value="CK">Cook Islands</option>
<option value="CL">Chile</option>
<option value="CM">Cameroon</option>
<option value="CN">China</option>
<option value="CO">Colombia</option>
<option value="CR">Costa Rica</option>
<option value="CU">Cuba</option>
<option value="CV">Cape Verde</option>
<option value="CW">Curacao</option>
<option value="CX">Christmas Island</option>
<option value="CY">Cyprus</option>
<option value="CZ">Czech Republic</option>
<option value="DE">Germany</option>
<option value="DJ">Djibouti</option>
<option value="DK">Denmark</option>
<option value="DM">Dominica</option>
<option value="DO">Dominican Republic</option>
<option value="DZ">Algeria</option>
<option value="EC">Ecuador</option>
<option value="EE">Estonia</option>
<option value="EG">Egypt</option>
<option value="EH">Western Sahara</option>
<option value="ER">Eritrea</option>
<option value="ES">Spain</option>
<option value="ET">Ethiopia</option>
<option value="FI">Finland</option>
<option value="FJ">Fiji</option>
<option value="FK">Falkland Islands</option>
<option value="FM">Micronesia</option>
<option value="FO">Faroe Islands</option>
<option value="FR">France</option>
<option value="GA">Gabon</option>
<option value="GB">United Kingdom</option>
<option value="GD">Grenada</option>
<option value="GE">Georgia</option>
<option value="GF">French Guiana</option>
<option value="GG">Guernsey</option>
<option value="GH">Ghana</option>
<option value="GI">Gibraltar</option>
<option value="GL">Greenland</option>
<option value="GM">Gambia</option>
<option value="GN">Guinea</option>
<option value="GP">Guadeloupe</option>
<option value="GQ">Equatorial Guinea</option>
<option value="GR">Greece</option>
<option value="GS">South Georgia and the South Sandwich Islands</option>
<option value="GT">Guatemala</option>
<option value="GU">Guam</option>
<option value="GW">Guinea-Bissau</option>
<option value="GY">Guyana</option>
<option value="HK">Hong Kong</option>
<option value="HM">Heard Island and McDonald Islands</option>
<option value="HN">Honduras</option>
<option value="HR">Croatia</option>
<option value="HT">Haiti</option>
<option value="HU">Hungary</option>
<option value="ID">Indonesia</option>
<option value="IE">Ireland</option>
<option value="IL">Israel</option>
<option value="IM">Isle of Man</option>
<option value="IN">India</option>
<option value="IO">British Indian Ocean Territory</option>
<option value="IQ">Iraq</option>
<option value="IR">Iran</option>
<option value="IS">Iceland</option>
<option value="IT">Italy</option>
<option value="JE">Jersey</option>
<option value="JM">Jamaica</option>
<option value="JO">Jordan</option>
<option value="JP">Japan</option>
<option value="KE">Kenya</option>
<option value="KG">Kyrgyzstan</option>
<option value="KH">Cambodia</option>
<option value="KI">Kiribati</option>
<option value="KM">Comoros</option>
<option value="KN">Saint Kitts and Nevis</option>
<option value="KP">North Korea</option>
<option value="KR">South Korea</option>
<option value="KW">Kuwait</option>
<option value="KY">Cayman Islands</option>
<option value="KZ">Kazakhstan</option>
<option value="LA">Laos</option>
<option value="LB">Lebanon</option>
<option value="LC">Saint Lucia</option>
<option value="LI">Liechtenstein</option>
<option value="LK">Sri Lanka</option>
<option value="LR">Liberia</option>
<option value="LS">Lesotho</option>
<option value="LT">Lithuania</option>
<option value="LU">Luxembourg</option>
<option value="LV">Latvia</option>
<option value="LY">Libya</option>
<option value="MA">Morocco</option>
<option value="MC">Monaco</option>
<option value="MD">Moldova</option>
<option value="ME">Montenegro</option>
<option value="MF">Saint Martin</option>
<option value="MG">Madagascar</option>
<option value="MH">Marshall Islands</option>
<option value="MK">North Macedonia</option>
<option value="ML">Mali</option>
<option value="MM">Myanmar (Burma)</option>
<option value="MN">Mongolia</option>
<option value="MO">Macao</option>
<option value="MP">Northern Mariana Islands</option>
<option value="MQ">Martinique</option>
<option value="MR">Mauritania</option>
<option value="MS">Montserrat</option>
<option value="MT">Malta</option>
<option value="MU">Mauritius</option>
<option value="MV">Maldives</option>
<option value="MW">Malawi</option>
<option value="MX">Mexico</option>
<option value="MY">Malaysia</option>
<option value="MZ">Mozambique</option>
<option value="NA">Namibia</option>
<option value="NC">New Caledonia</option>
<option value="NE">Niger</option>
<option value="NF">Norfolk Island</option>
<option value="NG">Nigeria</option>
<option value="NI">Nicaragua</option>
<option value="NL">Netherlands</option>
<option value="NO">Norway</option>
<option value="NP">Nepal</option>
<option value="NR">Nauru</option>
<option value="NU">Niue</option>
<option value="NZ">New Zealand</option>
<option value="OM">Oman</option>
<option value="PA">Panama</option>
<option value="PE">Peru</option>
<option value="PF">French Polynesia</option>
<option value="PG">Papua New Guinea</option>
<option value="PH">Philippines</option>
<option value="PK">Pakistan</option>
<option value="PL">Poland</option>
<option value="PM">Saint Pierre and Miquelon</option>
<option value="PN">Pitcairn Islands</option>
<option value="PR">Puerto Rico</option>
<option value="PS">Palestine</option>
<option value="PT">Portugal</option>
<option value="PW">Palau</option>
<option value="PY">Paraguay</option>
<option value="QA">Qatar</option>
<option value="RE">Reunion</option>
<option value="RO">Romania</option>
<option value="RS">Serbia</option>
<option value="RU">Russia</option>
<option value="RW">Rwanda</option>
<option value="SA">Saudi Arabia</option>
<option value="SB">Solomon Islands</option>
<option value="SC">Seychelles</option>
<option value="SD">Sudan</option>
<option value="SE">Sweden</option>
<option value="SG">Singapore</option>
<option value="SH">Saint Helena</option>
<option value="SI">Slovenia</option>
<option value="SJ">Svalbard and Jan Mayen</option>
<option value="SK">Slovakia</option>
<option value="SL">Sierra Leone</option>
<option value="SM">San Marino</option>
<option value="SN">Senegal</option>
<option value="SO">Somalia</option>
<option value="SR">Suriname</option>
<option value="SS">South Sudan</option>
<option value="ST">Sao Tome and Principe</option>
<option value="SV">El Salvador</option>
<option value="SX">Sint Maarten</option>
<option value="SY">Syria</option>
<option value="SZ">Eswatini</option>
<option value="TC">Turks and Caicos Islands</option>
<option value="TD">Chad</option>
<option value="TF">French Southern Territories</option>
<option value="TG">Togo</option>
<option value="TH">Thailand</option>
<option value="TJ">Tajikistan</option>
<option value="TK">Tokelau</option>
<option value="TL">East Timor</option>
<option value="TM">Turkmenistan</option>
<option value="TN">Tunisia</option>
<option value="TO">Tonga</option>
<option value="TR">Turkey</option>
<option value="TT">Trinidad and Tobago</option>
<option value="TV">Tuvalu</option>
<option value="TW">Taiwan</option>
<option value="TZ">Tanzania</option>
<option value="UA">Ukraine</option>
<option value="UG">Uganda</option>
<option value="UM">U.S. Minor Outlying Islands</option>
<option value="US">United States</option>
<option value="UY">Uruguay</option>
<option value="UZ">Uzbekistan</option>
<option value="VA">Vatican City</option>
<option value="VC">Saint Vincent and the Grenadines</option>
<option value="VE">Venezuela</option>
<option value="VG">British Virgin Islands</option>
<option value="VI">U.S. Virgin Islands</option>
<option value="VN">Vietnam</option>
<option value="VU">Vanuatu</option>
<option value="WF">Wallis and Futuna</option>
<option value="WS">Samoa</option>
<option value="YE">Yemen</option>
<option value="YT">Mayotte</option>
<option value="ZA">South Africa</option>
<option value="ZM">Zambia</option>
<option value="ZW">Zimbabwe</option>
<!-- COUNTRY-OPTIONS-END -->
</select>
<span class="hint">Stored as the ISO code (e.g. DE).</span>
</div>
<fieldset class="form-field full focus-field">
<legend>Focus profile — five numbers, 0 to 5, totalling 15</legend>
<div class="focus-axis">
<label for="ad-axis-functional">Functional</label>
<input type="range" id="ad-axis-functional" name="functional" min="0" max="5" step="1" value="0" />
<output for="ad-axis-functional">0</output>
</div>
<div class="focus-axis">
<label for="ad-axis-system">System</label>
<input type="range" id="ad-axis-system" name="system" min="0" max="5" step="1" value="0" />
<output for="ad-axis-system">0</output>
</div>
<div class="focus-axis">
<label for="ad-axis-modules">Modules</label>
<input type="range" id="ad-axis-modules" name="modules" min="0" max="5" step="1" value="0" />
<output for="ad-axis-modules">0</output>
</div>
<div class="focus-axis">
<label for="ad-axis-integration">Integration</label>
<input type="range" id="ad-axis-integration" name="integration" min="0" max="5" step="1" value="0" />
<output for="ad-axis-integration">0</output>
</div>
<div class="focus-axis">
<label for="ad-axis-ai">AI</label>
<input type="range" id="ad-axis-ai" name="ai" min="0" max="5" step="1" value="0" />
<output for="ad-axis-ai">0</output>
</div>
<p class="focus-total" id="adFocusTotal" aria-live="polite">0 of 15 points</p>
<p class="focus-warn" id="adFocusWarn" role="alert"></p>
<p class="focus-scale">How much of the freelancer's work each area is — 0 to 5, totalling 15. Leave all five at 0 to leave the profile untouched (a save then keeps the record's current numbers); any other value must total exactly 15.</p>
</fieldset>
<fieldset class="form-field full lang-field">
<legend>Languages — 0 to 5, same scale as the public form</legend>
<div class="focus-axis">
<label for="ad-lang-english">English</label>
<input type="range" id="ad-lang-english" name="english" min="0" max="5" step="1" value="0" />
<output for="ad-lang-english">0</output>
</div>
<div class="focus-axis">
<label for="ad-lang-german">German</label>
<input type="range" id="ad-lang-german" name="german" min="0" max="5" step="1" value="0" />
<output for="ad-lang-german">0</output>
</div>
<div class="focus-axis">
<label for="ad-lang-spanish">Spanish</label>
<input type="range" id="ad-lang-spanish" name="spanish" min="0" max="5" step="1" value="0" />
<output for="ad-lang-spanish">0</output>
</div>
<p class="focus-scale">0 means “cannot speak it”; 5 means “native”. The applicant's own assessment.</p>
</fieldset>
<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_link" type="text" inputmode="url" />
<span class="hint">The OCA member profile link. A chip leads to it from the card when the host is apps.odoo-community.org and the path is /community/…</span>
</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" />
<span class="hint">A link (https://…). A photo shows on the card only when this is a link — an inline/base64 image is not displayed.</span>
</div>
<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 value the
owner did not touch must keep the record's CURRENT value. Loading the record
syncs every control to it, so an untouched control sends what the record already
holds. An edit is refused until the record has been loaded and confirmed.
The focus block: left all-zero it is simply NOT sent (an unrelated correction
must stay savable); any other value must total exactly 15.
============================================================================ */
var ODOO_EXPERTS_WEBHOOK =
"https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts";
var FOCUS_AXES = ["functional", "system", "modules", "integration", "ai"];
var FOCUS_TOTAL = 15;
var FOCUS_MAX = 5;
var FOCUS_MAX_CHARS = 70;
var LANG_FIELDS = [["english", "English"], ["german", "German"], ["spanish", "Spanish"]];
// Country VALUE FORMAT — what the Country select sends to the webhook.
// "code" → the ISO 3166-1 alpha-2 code, e.g. "DE" (default)
// "name" → the full English name, e.g. "Germany"
var COUNTRY_VALUE_FORMAT = "code";
var BOOL_FIELDS = ["is_individual"];
var NUM_FIELDS = ["odoo4projects_clients"].concat(FOCUS_AXES).concat(
LANG_FIELDS.map(function (p) { return p[0]; })
);
var TEXT_FIELDS = [
"Name", "email", "Focus", "description", "Country",
"Linkedin", "upwork", "fiverr", "github", "OCA_link", "Image"
];
var loadedRecord = null;
function fieldElements(form, name) {
return Array.prototype.slice.call(
form.querySelectorAll('[name="' + name.replace(/"/g, '\\"') + '"]')
);
}
function fold(text) {
return String(text == null ? "" : text)
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
}
function intOr(value, fallback) {
var n = parseInt(value, 10);
return isNaN(n) ? fallback : n;
}
function clamp05(n) { return Math.max(0, Math.min(FOCUS_MAX, n)); }
function countryValue(form) {
var sel = form.querySelector('select[name="Country"]');
if (!sel || !sel.value) return "";
if (COUNTRY_VALUE_FORMAT === "name") {
var opt = sel.options[sel.selectedIndex];
return opt ? opt.textContent.trim() : "";
}
return sel.value.trim();
}
function setStatus(el, message, state) {
if (!el) return;
el.textContent = message;
if (state) el.setAttribute("data-state", state);
else el.removeAttribute("data-state");
}
function recordField(rec, name) {
if (rec[name] !== undefined) return rec[name];
if (name === "Country" && rec.country !== undefined) return rec.country;
return undefined;
}
function focusSum(form) {
return FOCUS_AXES.reduce(function (s, axis) {
var el = form.querySelector('[name="' + axis + '"]');
return s + (el ? intOr(el.value, 0) : 0);
}, 0);
}
function focusError(form, required) {
var sum = focusSum(form);
if (sum === 0) {
return required ? "Distribute all 15 points across the five areas." : "";
}
if (sum !== FOCUS_TOTAL) {
return "The five focus numbers total " + sum + ". They must total exactly " +
FOCUS_TOTAL + " (or all be 0 to leave the profile untouched).";
}
return "";
}
function refreshFocus(form) {
var sum = 0;
FOCUS_AXES.forEach(function (axis) {
var el = form.querySelector('[name="' + axis + '"]');
if (!el) return;
var n = clamp05(intOr(el.value, 0));
el.value = String(n);
sum += n;
var out = el.parentNode ? el.parentNode.querySelector("output") : null;
if (out) out.textContent = String(n);
el.setAttribute("aria-valuetext", n + " of " + FOCUS_MAX);
});
var langs = form.querySelectorAll(".lang-field input[type=range]");
Array.prototype.forEach.call(langs, function (el) {
var n = clamp05(intOr(el.value, 0));
el.value = String(n);
var out = el.parentNode ? el.parentNode.querySelector("output") : null;
if (out) out.textContent = String(n);
el.setAttribute("aria-valuetext", n + " of " + FOCUS_MAX);
});
var total = document.getElementById("adFocusTotal");
if (total) total.textContent = sum + " of " + FOCUS_TOTAL + " points";
var warn = document.getElementById("adFocusWarn");
if (warn) {
warn.textContent = (sum === 0 || sum === FOCUS_TOTAL)
? ""
: "You have " + sum + " of 15. The five must total exactly 15, or all be 0 to leave the profile untouched.";
}
}
/* 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);
});
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";
});
// The five focus axes and the three languages — plain numbers, read off the
// record. A field the record does not carry stays 0 (and the block is left
// unsent unless it totals 15).
FOCUS_AXES.forEach(function (axis) {
var el = form.querySelector('[name="' + axis + '"]');
if (el) el.value = String(clamp05(intOr(rec[axis], 0)));
});
LANG_FIELDS.forEach(function (p) {
var el = form.querySelector('[name="' + p[0] + '"]');
if (el) el.value = String(clamp05(intOr(rec[p[0]], 0)));
});
refreshFocus(form);
var focusInput = document.getElementById("adFocus");
var focusCount = document.getElementById("adFocusCount");
if (focusInput && focusCount) {
focusCount.textContent = focusInput.value.length + " / " + FOCUS_MAX_CHARS;
}
}
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 (name === "Country") {
var countryVal = countryValue(form);
if (countryVal !== "") payload[name] = countryVal;
return;
}
if (BOOL_FIELDS.indexOf(name) !== -1) {
var box = els[0];
if (withId && box.dataset.loaded !== undefined) {
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) {
// A focus block that is not a valid 15 is left unsent, so an update cannot
// wipe it (the gate already refused a half-set block).
if (FOCUS_AXES.indexOf(name) !== -1 && focusSum(form) !== FOCUS_TOTAL) return;
var nv = els[0].value.trim();
if (nv !== "") payload[name] = Number(nv);
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);
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");
}
});
Array.prototype.forEach.call(form.querySelectorAll(".focus-field input, .lang-field input"),
function (el) { el.addEventListener("input", function () { refreshFocus(form); }); });
var focusInput = document.getElementById("adFocus");
var focusCount = document.getElementById("adFocusCount");
if (focusInput && focusCount) {
var refreshCount = function () {
var n = focusInput.value.length;
focusCount.textContent = n + " / " + FOCUS_MAX_CHARS;
focusCount.setAttribute("data-over", n > FOCUS_MAX_CHARS ? "1" : "0");
};
focusInput.addEventListener("input", refreshCount);
refreshCount();
}
refreshFocus(form);
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 focusMsg = focusError(form, false);
if (focusMsg) {
setStatus(status, focusMsg, "err");
var warn = document.getElementById("adFocusWarn");
if (warn) warn.textContent = focusMsg;
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>