Files
ODOO4projects.com/odoo-community-developers/admin/index.html
T
webmaster 9d14652a57 hide [hidden] site-wide: one [hidden] { display:none !important } in style.css
A filtered-out directory card stayed visible. The card is targeted by an
id-based ready-state selector (`.js #directory[data-dir-state="ready"]
#dirGrid > .dev-card`, two ids), which outranked the old per-element patch
`.dev-card[hidden] { display:none }` — so `card.hidden = true` set the
attribute and every card stayed on screen while the count line correctly
read "Showing 1 of 4".

Fix: one rule, `[hidden] { display:none !important }`, early in /style.css.
A class- or id-based `display` can no longer beat the attribute, so this
cannot recur with the next class that sets `display`. The per-element
`[hidden]` patches are removed so a single mechanism governs:
 - odoo-community-developers: cards + filter row + pager + modal
   (load-bearing — each sets `display`); focus-sliders/noscript (redundant)
 - index/de/es `.hero-notice[hidden]`, odoo-black-friday `.ads-consent[hidden]`
   (redundant — those elements set no `display`)
Re-fingerprint local assets (style.css changed): /style.css?v 3332fbba93 ->
061517baef across 51 pages.

Measured in a real browser (local harness, stubbed webhook; no record created,
no probe write) — computed display + geometry, never the `hidden` property:
 - filtered to one card: the other three report display:none and height 0;
   the visible set (1) equals the count line ("Showing 1 of 4") — 1 == 1
 - HEAD (pre-fix) reproduces the defect: 3 cards hidden=true yet display:flex,
   height 599.38 each; 4 visible vs the count's 1
 - pager: display:none height 0 with one page; display:flex height 66.83 with
   12 cards (Page 1 of 2); page 2 hides page-1 cards (display:none)
 - modals closed: display:none height 0; load state: cards 0, pager/empty
   display:none, filter row visibility:hidden by design
 - 375 px: same result, no horizontal overflow; avatar ask-row top identical
   across a row (2053.97 / links 2086.91)
2026-11-03 22:34:39 +00:00

1068 lines
44 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?v=061517baef" />
<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 <span class="hint">(at most 230 characters)</span></label>
<textarea id="adDescription" name="description" rows="4" maxlength="230" aria-describedby="adDescriptionCount"></textarea>
<span class="focus-counter" id="adDescriptionCount" aria-live="polite">0 / 230</span>
</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>Skill 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;
// The description is capped at 230 characters, same as the public application form.
var DESC_MAX_CHARS = 230;
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;
}
var descInput = document.getElementById("adDescription");
var descCount = document.getElementById("adDescriptionCount");
if (descInput && descCount) {
descCount.textContent = descInput.value.length + " / " + DESC_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();
}
var descInput = document.getElementById("adDescription");
var descCount = document.getElementById("adDescriptionCount");
if (descInput && descCount) {
var refreshDescCount = function () {
var n = descInput.value.length;
descCount.textContent = n + " / " + DESC_MAX_CHARS;
descCount.setAttribute("data-over", n > DESC_MAX_CHARS ? "1" : "0");
};
descInput.addEventListener("input", refreshDescCount);
refreshDescCount();
}
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>