Commit Graph
311 Commits
Author SHA1 Message Date
webmaster 68a388cd78 feedback: no question is required — an empty or partial form submits
Remove the required flag from all 13 questions, drop the submit-blocking
validation (no error is ever announced), and send only the questions that
were answered. The error nodes and their CSS stay in place but nothing sets
them. Empty submit now fires 1 request and shows the receipt (was 0).
2026-11-04 10:20:22 +00:00
webmaster 61fd457af0 freelancer-confirmed: the page a freelancer lands on after confirming the email 2026-11-04 10:08:15 +00:00
webmaster bde9dbc48d feat(blog): publish "Our freelancer directory opens on 1 December. Help us get the signup right"
Adds the soft-launch news post (pipeline record id=128, category News) as
blog/posts/freelancer-directory-soft-launch.html. Body pasted verbatim from
the inbox source block (sha256 14f61402…, byte-identical in the page).

- chrome copied from blog/posts/odoo-20-is-live-on-our-servers-here-is-what-changed.html
  (the newest post); inline <style> trimmed of the rules this page does not use
  (ul/ol, li, figure, figure img, figcaption) — the body carries no list or figure
- card/OG image wired additively: img.post-image (alt = the page h1), og:image,
  twitter:card, and the new blog/index.json "image" key
- BlogPosting JSON-LD with url == canonical
- blog/index.json: one entry, first, key order id,date,area,headline,teaser,link,image
  (text splice, not json.dumps)
- sitemap.xml: one <url>, first in the blog group, lastmod 2026-11-04
- llms.txt: one line at the top of the blog list
- work/verify-html.py FILES extended with the new page

Blog stays English-only. No older post touched.
2026-11-04 09:43:51 +00:00
webmaster 1fd092a9f0 One word for one action: the application form submit button is 'Sign up'
The last differing phrasing for the signup action: the apply form's submit
button read 'Apply to be listed'. Label only - type, classes, submit handling
and data-* attributes unchanged; no aria-label so the accessible name agrees.
Its modal already opens from a 'Sign up' trigger under a '... Sign up' title.
2026-11-03 23:53:34 +00:00
webmaster 8d1b953c61 One word for one action: Get listed -> Sign up on the directory, and the feedback step line drops account wording
Directory: all 8 instances of "Get listed" become "Sign up" - the three
data-apply-open triggers keep href="#apply" and their modal behaviour (label
text only), the two section headings and the modal title keep their sentence
grammar ("Are you an Odoo freelancer? Sign up"), and the three code comments
follow. Feedback: the step instruction line no longer says "Create your
... account" - it reads "Sign up on the ODOO4projects freelancer portal";
the factual second sentence and the "add your profile" instruction are kept.
2026-11-03 23:48:13 +00:00
webmaster 497002a112 directory: SOFTLAUNCH label beside the 'Freelancers available' heading
Adds a hollow brass chip next to the directory heading, sibling of the <h2>
(so the heading's name stays 'Freelancers available'). Uses the OCA Member
Profile badge's chip language (pill radius, 600 weight, 0.72rem) but not its
solid brass fill - related, clearly distinct. Real text, not aria-hidden.

Contrast measured on the section's real surface: brass #c9a227 on midnight
#0d1b2e = 7.15:1 (small text needs 4.5:1). Wraps as a unit below the heading
at 375 px, 0 overflow. No date, count or scarcity word added.
2026-11-03 23:43:21 +00:00
webmaster b28c002d7d feedback page: portal link points at the top of the directory page, not its #apply anchor
Both references (the static .link-signup href and the PORTAL_URL constant) now
target https://odoo4projects.com/odoo-community-developers/ with no #apply, so a
visitor lands at the page's own introduction instead of dropping mid-page at the
application form. The constant is no longer a second literal: it reads the static
href back from the markup, so the two cannot disagree — the markup href is the one
source. The directory page's own same-page #apply triggers are untouched.
2026-11-03 23:40:30 +00:00
webmaster 59ffde396f feedback page: strip the seat claim, keep it a plain feedback form with a visible portal link
The owner asked for a plain feedback form: no Upwork name, no seat claim, and the
freelancer-portal link visible on the page. The seat-claim layer is removed whole.

- remove the GET /webhook/feedback seat-count read, its parsing and the four states
  it drove (available / claimed / full / count-unavailable) — one state now, the form
- remove the claim POST, the 1-hour hold and its clock-time copy, the leftKeys
  contract, the count-retry button and the cohort-full branch
- no capacity wording: seat/spot/claim/hold/cohort/full are gone, and "unlimited"
  is not written either — the page says nothing about capacity
- the form posts the answers only: no upwork, no email, no identifier
- progress bar is two steps (1 Sign up - 2 Give feedback); step 1 stays current
  because the page cannot honestly detect that the portal link was used
- the freelancer-portal link is visible next to the intro and again in step 1's copy,
  new window with rel=noopener noreferrer and it says so in its accessible name

Nothing on load is fetched: 0 requests to the webhook until a real submit.
2026-11-03 23:24:58 +00:00
webmaster 29ff175e89 directory card photo: fetch the JSON envelope, decode the data URI, and bound every request
The endpoint answers 200 application/json with {"Image":"data:image/jpeg;base64,…"} —
a JSON envelope, NOT image bytes. An <img src="…?id=N"> pointed at it never settles:
measured on the live page, complete=false · naturalWidth=0 for 20 s, so the fallback
never fired and the card showed an empty slot. The card now FETCHES the route, reads
`Image` and puts the data URI on an <img> only when it passes the isImageSrc allowlist.

- static/expert-image.js: `load(id)` replaces `url(id, rec)`. It fetches the route,
  reads `Image`, accepts the array shape n8n sometimes returns, and resolves to a usable
  image source or `null` on ANY failure. EVERY request is bounded by `timeoutMs` (8000):
  an AbortController is aborted by a setTimeout, the abort rejects the fetch and lands in
  the same catch that returns null — so a hang becomes the monogram, not an empty box.
  The timer is cleared on every completion. The decoded URI is cached by id (failures
  too), so a re-render or a shared id never re-fetches. Every non-success path — timeout,
  abort, non-2xx, {}, a blank/missing Image, a value that fails isImageSrc — ends in the
  monogram: never an empty box, never a broken-image icon, never alt text in the layout.
- Directory card (odoo-community-developers/index.html): the monogram reserves the 72x72
  box and stands until a real photo has DECODED; the photo is requested lazily, only when
  a card nears the viewport (IntersectionObserver, rootMargin 200px), so a 14-card page
  does not fire 1.4 MB on load. The monogram and the photo are the same 72x72 box, so the
  swap moves nothing (measured: head box 274.81x72 and ask-row tops byte-identical across
  a loaded photo and a monogram).
- /freelancer-edit/: the photo preview makes the same fetch, so the form no longer points
  an <img> at the JSON route either.

Proved in a real browser against the repo served with the two webhook URLs stubbed:
jpeg data URI -> photo (src prefix data:image/jpeg;base64,/, naturalWidth 800); {} / 404 /
500 / a bad value -> monogram, 0 broken-image icons, 0 cards with neither; a stub that
never answers -> monogram after 8000 ms with the request ABORTED (client abort event at
8000.x ms, all 6 sockets disconnected server-side). No record created, no probe write.
2026-11-03 23:19:06 +00:00
webmaster 2b0e89191a directory card photos: load from the image endpoint, not inline base64
The webhook no longer sends `Image`, so the card photo comes from its own
endpoint, addressed by the record id — one <img> per card, one request.

- static/expert-image.js: the route AND its query-parameter name in ONE
  constant (id, confirmed by measurement; unverified in the brief), plus an
  optional version token used only when the payload carries one.
- Directory card: <img src="…odoo_experts_image?id=<id>">, width/height
  reserve the 72x72 box, decoding=async, eager for the first row and lazy
  below it; onerror swaps in the SAME monogram a card with no photo shows,
  so a failing image looks exactly like today.
- The static build-time card loses its 185 KB inline base64 photo and shows
  a monogram: it is the no-JavaScript fallback, where there is no onerror to
  fall back with. Page 341,345 -> 158,428 bytes.
- /freelancer-edit/: the photo block is a look, not a field (no file input),
  and the whole-record save sends NO `Image` key at all.
2026-11-03 22:59:53 +00:00
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
webmaster 634f4fcdf1 directory card: distribute the content so the ask row lines up across every card
The card reads as three blocks — the head/clients/focus block at the top,
the description centred in the free space, and the charts + ask row + links
block sitting on the card's floor. Two CSS lines and two renderer lines do it:

  .dev-bio   margin-block: auto  — the auto margins absorb the card's free
             space equally above and below the description, so it centres
             between the two blocks and everything after it is pushed down
             onto the floor. No wrapper element, no markup change, and the
             description is never clipped to fit.

  .dev-links min-height: 36px   — the row's line is reserved at the height
             its 36 px hit areas already give it.

  renderer   the .dev-links row and the .dev-bio container are ALWAYS in the
             DOM, even with nothing in them. An absent container is what lets
             the ask row drop a line (0 links) or float up off the floor (no
             description — optional on the application form). Reserved, not
             filled: no empty anchor, no empty icon, no placeholder text, and
             a record's own words are rendered as given.
2026-11-03 21:34:24 +00:00
webmaster bd0881f882 cache-bust local assets; narrow the language facet to scores >= 3
Part 1 — asset versioning: every local <script src> and <link rel=stylesheet> on the
tracked pages now carries ?v=<sha256-of-the-file> (10 hex), computed at BUILD time from
the file's bytes by work/build-asset-versions.py — never hand-typed, and idempotent
(re-running with no file change rewrites nothing). This is what makes an edited
focus-rule.js / photo-upload.js a new URL instead of a stale cache hit.

Part 2 — language facet: a language now counts for the Language filter only when its
0-5 self-assessment is >= LANG_FACET_MIN (3 = 'works in it'). The constant sits beside
the 15-point rule in the page script and its mirror in build-directory.py. Nothing
stored changes; the card's languages chart still prints every language above 0, the
search box is unchanged, and the facet stays an exact membership test against the
card's value. A '1' language no longer offers the person under that language.
2026-11-03 21:26:58 +00:00
webmaster c83186f809 directory: hide the clients line at 0, and profile links are logo only
Clients line: a count of 0 (or an absent count) now renders NO line at all —
never "0 clients", never a dash. The >=1 rules (the plural, the plain number)
are unchanged. Same rule in the runtime renderer and in build-directory.py.

Links row: LinkedIn/GitHub/Upwork/Fiverr now render as LOGO ONLY. The four marks
are one uniform monochrome set (Simple Icons v13, 24x24, single-path), inlined so
the row makes no request and each mark inherits currentColor. Self-hosted copies
in static/icons/. Each link keeps its accessible name (aria-label + title, e.g.
"GitHub profile"); the inline SVG is aria-hidden + focusable=false; the hit area
is 36x36 around a 24px mark, with a visible focus ring.

A null/blank link value still renders no anchor (no empty icon).
2026-11-03 21:19:00 +00:00
webmaster 1f8179d562 directory: never show the stale build-time cards before the webhook answers
With JS the build-time card painted first and was then replaced by the webhook's
list — a visitor could see a person who is no longer listed.

- `js` class set on <html> by an inline script as the first thing in <head>, so the
  static cards are hidden from the first frame only when JS will replace them.
- JS on: a text loading line (role=status, aria-live=polite) stands in their place;
  on success the static cards are removed from the DOM and the live list rendered; on
  failure an honest error with a Try again button replaces them — never the stale cards.
- Count line + filter row held out (invisible, out of the tab order) until the data is
  there; pager absent while loading.
- Space reserved (dir-grid min-height, dir-count min-height, filter row kept in flow) so
  the list does not jump when the cards arrive — measured 0px movement loading -> ready.
- JS off: unchanged — the static cards show.
2026-11-03 21:11:36 +00:00
webmaster cdf166632a feat(freelancer-edit): add the missing GitHub link field
The edit form carried LinkedIn/Upwork/Fiverr but not GitHub, though the
application form, the webhook and the card renderer already do. Add a
fourth optional link field mirroring the other three, under the live wire
key verbatim (lower-case github; never normalised), pre-filled from the
record and added to the save loop with the same rules: a key is written
only when the input has a value, an empty optional field never invents ""
on a record that never had one, and a link the owner cleared sends "".
No new URL check — the card's isLink() stays the only url predicate.
2026-11-03 20:48:31 +00:00
webmaster 1b0198913c fix(directory): rebuild the static cards from the live webhook — drop the stale "oliver" junk card
Re-ran the canonical builder (work/build-directory.py) against the live
odoo_experts webhook, which now returns exactly one record (id=2,
"Oliver - The OC Guy"; records 1 and 3-7 are deleted).

The JS-off build-time list shipped two hardcoded cards: the junk record
id=1 ("oliver", focus "vieoem") and the real id=2. The junk card was
compiled markup, not data, so no DB delete could remove it. The rebuild
ships exactly the one card the record set supports today.

Same rebuild also brings the card in line with the current renderer: the
card carries the JS-off-safe "Ask question anonymously" mailto contact row
(the record has email oliver@odoo4projects.com), and no self-declared link
row (the record's Linkedin field is now null).

Runtime fetch, pager, filters, Best Practices, OCA badge and copy untouched.
2026-11-03 20:24:45 +00:00
webmaster a6ada39784 feedback: remove identity block, heard_about and the publish consent question
- Delete the 'About you' fieldset (name, email, anonymous tick) and its
  validation: the claimed Upwork name now identifies the sender, and the
  feedback payload carries it only when a seat was claimed this visit.
- Remove the heard_about question.
- Remove the publish consent question. That question WAS the consent, so
  no feedback may be published: the cohort-full state now renders the
  closed-cohort line only, and the feedback-rendering branch (entry keys,
  publish constants, isPublishable, the .fb styles) is gone rather than
  left dead. The GET's 'feedback' array is no longer consumed.
- recommend: first label 'Yes' -> 'Yes, good idea' (value yes unchanged).
2026-11-03 20:09:07 +00:00
webmaster bb963850b1 feat(feedback): three-step progress bar + "Sign up here" link
A real <ol> whose current step carries aria-current="step" and whose
done step is marked by a tick (never colour alone), following the page
state — step 1 when seats are available, step 1 done / step 2 current
when a seat is claimed, and no bar at all in the cohort-full and
count-unavailable states. The bar states no count and no deadline:
both come from the wire.

The claimed state gains the signup link: "Sign up here",
target="_blank" rel="noopener noreferrer", pointing at the directory
page's own #apply form — the one place the application lives, no
invented URL. Secondary to any button; the accessible text says it
opens a new window.

Untouched: the four states, the consent gate, the claim body, the
leftKeys contract, the leak rules, the 15 questions, the corrected copy.
2026-11-03 20:04:20 +00:00
webmaster 28d95b4dcd fix(feedback): read the live route's Free/free seat-count keys
GET /webhook/feedback answers {"free":"","Free":9} (200), but the page
only read seats_left/seatsLeft, so the live page rendered state 4 ("We
cannot read the seat count right now."). Add the wire's own names after the
documented ones: leftKeys: [seats_left, seatsLeft, Free, free]. The existing
guard already skips "free": "" and requires a whole number >= 0, so 9 is
used and an empty string alone prints no number. Document the expected GET
reply (free/Free + the optional feedback[] shape and its publish gate) in the
constants comment. No total is returned, so "9 seats left" stays. Four
states, consent gate, claim body, leak rules and copy untouched.
2026-11-03 19:51:50 +00:00
webmaster 5d2dc11fbc feat(feedback): seat-claim + feedback page in our own design
Replaces the n8n-hosted form. Four states — seats available (count read
from the wire, never hardcoded), seat claimed (deadline as a clock time,
one hour attributed to the workflow), cohort full (consented feedback
only), and count unavailable (today's real state: the route 500s).

One constant each for the GET status route and the POST submit route;
both wire shapes stated in the page. A count that did not come off the
wire is never printed; an entry without consent is never rendered and
"no name" shows the words without the name.

Feedback form recreated from the n8n questions with the copy corrected
(finalize, de-conflated recommend options, textarea for the offer
question, consistent question marks) plus the claim, name/email or an
anonymous tick, and the publish-consent field that gates the list.

noindex, out of sitemap.xml and llms.txt, no inbound links. The Upwork
name and email never reach the Plausible payload.
2026-11-03 19:43:58 +00:00
webmaster 8319a74f99 feat(freelancer): validate and downscale photo uploads on both forms
Both the application form (/odoo-community-developers/) and the self-service
edit form (/freelancer-edit/) now use one shared module, static/photo-upload.js
(window.O4PPhoto), so the limits live in one place:

- validate() decides on the File object BEFORE any read: type allowlist
  (image/jpeg, image/png, image/webp) and an 8 MiB cap. A refused file is read
  zero bytes and is never submitted.
- downscale() reads, centre-crops square, caps the longest side at 384 px,
  encodes JPEG at 0.82 and steps the quality down until the result is <= 80 KiB.
  Re-encoding also strips EXIF (a phone photo's GPS does not reach the page) and
  refuses a spoofed type that fails to decode.
- The edit form's photo field becomes a real labelled input that keeps the
  current photo when untouched; the whole-record save carries it back
  byte-identical.

Existing stored photos are untouched, so the directory page is not lighter until
profiles re-upload. accept= is a hint, not validation.
2026-11-03 19:07:07 +00:00
webmaster 5cf58ffa8a feat(freelancer-edit): add LinkedIn/Upwork/Fiverr fields; save the whole record
Two defects, one file. (1) The edit form was missing the three profile links the
application form collects. Added as optional type=url fields pre-filled from the
record, with the application form's own labels and placeholders, under the LIVE
wire keys verbatim: "Linkedin" (capital L), "upwork", "fiverr" — casing never
normalised. (2) The save built its payload field-by-field, so Image and the three
links were absent from the body and a freelancer saving an edit wiped their own
photo and links. The payload now starts from the loaded record (read-modify-write)
and overwrites only the fields this form owns; Image rides through untouched. An
empty optional link on a record that never had one is left as the record's own
value — no invented empty strings. 'has' stays first, method stays PATCH.

No second URL check: the only url predicate is the directory card's isLink().
2026-11-03 18:51:51 +00:00
webmaster 7dfd5aec9b fix(freelancer-edit): save sends the hash and uses PATCH
The save was authorised by the record id alone — ids are small
sequential integers, publicly visible on every directory card, so
a PATCH carrying only an id let one visitor overwrite another's
listing. The save now:

- uses PATCH (C.saveMethod), matching n8n
- carries the hash in the body under the same "has" key the
  delete already sends, so save and delete share one credential
  path, alongside the record id
- refuses to fire at all when the page holds no hash, and says why
  in the status line

Everything else is unchanged: the 15-point gate, receipt only on
2xx, the honest failure line that keeps the edits, the save.disabled
handling and the DELETE shape.
2026-11-03 18:40:29 +00:00
webmaster 768dc1151a fix(freelancer): the 15-point rule is now one shared constant, in both forms
The edit form's legend read "Skill areas" and carried no directive, while the
application form read "Skill profile — 15 points to distribute" and said what to
do. Same rule, worded in two places — and the edit form lost the sentence.

- New static/focus-rule.js exposes window.O4PFocus: LEGEND, DIRECTIVE, TOTAL, MAX
  and message(sum). Both forms render the legend, the directive and the
  wrong-total message from it, so the two cannot drift apart again.
- Edit form: legend from the constant, the application form's directive under it,
  and the blocked-save message now names the direction.
- Application form: legend set from the constant, live warn and sum gate use the
  same message(); the all-zero gate keeps the directive.
- Message wording: over -> "remove N", under -> "add N", zero -> "place all 15
  points"; the total, aria-live, data-bad, the blocked save and the
  fieldset/legend structure are unchanged.

The rule text now appears once: "You have 17 of 15 — remove 2."
2026-11-03 18:34:43 +00:00
webmaster 1a1b62136f feat(freelancer): card cog + self-service modal, and the private freelancer-edit page
Card (odoo-community-developers/):
- "Ask question anonymously" moves into its own row ABOVE the links row, in the
  runtime renderer and in build-directory.py (the build-time template).
- A cog icon button sits next to it: a real <button> with an accessible name
  (aria-label="Edit or delete your profile"), same quiet weight as its neighbour.
- Pressing the cog opens a modal that explains the flow and asks for the email
  address, then POSTs {"email":"..."} to odoo_experts_edit (body shape in one
  constant, EDIT_EMAIL_KEY). The reply is never read: one message on any 2xx, so
  the box cannot tell a visitor whether an address is on file. A non-2xx fails
  honestly and shows no receipt. Both controls ship dark (added only while their
  route constant is set), so neither is ever a dead control.

New private page (freelancer-edit/, noindex+nofollow, unlinked, absent from
sitemap.xml and llms.txt):
- Emailed link is https://odoo4projects.com/freelancer-edit/?has=<hash> — the
  same shape as /freelancer-answer/; reads `has` first, falls back to `hash`.
- GETs the edit webhook with the hash and pre-fills the form: Name, Focus (<=70),
  description (<=230), the five axis numbers (must total 15), the three language
  numbers (0-5), Country, OCA_link. The GET reply is read through one alias map;
  a shape it does not recognise names the fields it received instead of guessing.
  404 "is not registered" is a CONFIGURATION error, never "your link is invalid";
  404/500 "No item to return was found" is an invalid/expired link; a network
  failure keeps the hash for a retry.
- Save = POST with the record id (one constant, SAVE_METHOD). Delete is a
  deliberate second action (a confirm tick gates the button) and never reports
  success on a non-2xx.
- The image is read-only here, stated in the UI: replacing a photo is a different
  problem from editing text.
- The hash never reaches analytics: the query is stripped before the Plausible
  snippet loads. referrer policy no-referrer; JS off says so plainly.
2026-11-03 17:59:41 +00:00
webmaster f0a2c8d745 feat(freelancer-directory): nine cards per page with a pager
Cap the directory at 9 cards per page (3 x 3 at desktop) and add pagination.

- The grid rule is untouched (auto-fill minmax(300px,1fr), max-width 1000px):
  measured 3 columns at 1280, 1 at 375. The cap does not change column behaviour.
- A <nav id="dirPager"> ships `hidden` and is un-hidden by script ONLY when the
  filtered list needs more than one page — so with 7 records no pager appears, the
  same rule the facets follow.
- Previous/Next plus one button per page; the current page carries aria-current,
  a filled pill and the live "Page N of M" line — never colour alone. Focus is kept
  on the pager across a rebuild rather than dropped to the document.
- A filter or search resets to page 1; a page left out of range falls back to 1
  instead of rendering an empty page.
- Pagination applies to the filtered set, not the raw list.
- No new URLs: the pager holds no href and touches no history, so there is nothing
  to deep-link into a broken state.
- With JavaScript off the pager never appears and every build-time card shows; the
  #directory anchor is unaffected.
2026-11-03 17:13:47 +00:00
webmaster 53e9ea999d feat(footer): add Odoo Freelancer Directory to Useful Links; drop the directory page's bottom Start CTA
- 13 tracked pages + the directory page's footer carry a new <li>
  <a href="/odoo-community-developers/">Odoo Freelancer Directory</a>,
  placed after "ODOO Hosting in 2027" (our service links together, before Support/Legal).
- The directory page itself is excluded (a page does not link to itself in its own footer).
- Removed the final Start / "Browse the freelancers" CTA section from the directory page;
  the hero copy keeps its one "Browse the freelancers" link. .cmp-cta CSS untouched (11 pages).
2026-11-03 17:05:18 +00:00
webmaster 092f1e44a2 feat(freelancer-directory): Best Practices — 12 items in two independent columns
Adds the twelfth item ("Look at a similar job they have already done") and
splits the accordion into two independent columns of six.

- Two independent columns (.bp-list > .bp-col, each its own grid, parent
  align-items:start) so opening an item grows only its own column and cannot
  shove the other. A single grid would re-size rows and make both columns
  jump on every click.
- DOM / reading order is 1–6 (left column) then 7–12 (right column) — a
  decision, not an accident.
- Exclusivity still one-open-at-a-time via the shared <details name> plus the
  inline JS fallback; re-proved, including the strip-the-name test.
- <=720 px collapses to a single column, no horizontal overflow.
2026-11-03 15:51:18 +00:00
webmaster 17f171d3d9 feat(freelancer-answer): publish the private answer page; save via PATCH
Private utility page reached only by an emailed link (noindex, absent from
sitemap.xml/llms.txt, linked from nowhere). The freelancer reads the client's
question and sends an answer back through the odoo_experts_quote webhook.

- Save the answer with PATCH, not POST: the question record already exists
  (the client's submission created it), so the answer is a partial update of
  it. POST risks a second record.
- A method the route refuses (404/405/501) now fails honestly: "We could not
  save your answer", the answer is kept for a retry, and the receipt shows
  ONLY on a 2xx.
- Body shape (match in the n8n workflow): {"has":"<hash>","answer":"<text>"}.

Hash spelling: both ?has= and ?hash= are accepted on the way in; "has" is sent
outward. Question field name in the GET reply: unverifiable (no valid hash
exists), so the page names the fields it received instead of guessing.
2026-11-03 15:51:15 +00:00
webmaster 5535f10e7d feat(freelancer-directory): anonymous-contact modal live, description cap, copy removals, Get-listed modals, Best Practices accordion
Publishes the accumulated freelancer-directory work (six held passes) plus the
contact-modal success copy the user supplied today.

1. Anonymous-question button + modal — LIVE. ODOO_EXPERTS_QUOTE_WEBHOOK is now
   set, so the per-card "Ask question anonymously" button and its modal render.
   The button is added by script only on cards marked data-relay (a record we can
   relay to); the modal posts {freelancers:[{id,name}], brief, email} to the
   odoo_experts_quote route. With the constant empty the surface stays dark and
   no dead button is rendered.
2. Description cap 230 — the "About you" textarea is capped at 230 characters
   with a live counter (DESC_MAX_CHARS); the same cap truncates a longer legacy
   value on the render path. The matching change to the owner's edit form lives
   in odoo-community-developers/admin/index.html (included here).
3. Removal of "Ask us to find one" — the dead call to action is gone.
4. Copy removals — the invitation swap, four lede sentences, the whole invite
   section, and the Start line are removed.
5. Two "Get listed" buttons + the application form as a modal — the ONE
   #applyForm stays inline in the served HTML (works with JS off) and is moved
   into #applyModal once when the script runs; the data-apply-open anchors then
   open the dialog instead of jumping, and are working links with JS off.
6. Best Practices accordion — 11 <details name="best-practices">, all CLOSED on
   load; at most one open via the shared name attribute plus a JS fallback.

Contact-modal success copy (user, 2026-11-03): the receipt clause is kept and the
user's two promises are used in their words —

  "Thank you — we have your question. We will contact the freelancer, and as soon
   as we have their answer we will come back to you."

The sending and error states are unchanged. NOTE: both promises (the relay and
the reply) depend on the n8n workflow that does not exist yet — the endpoint is a
stub. The owner took that decision knowingly; the copy ships as asked.

QC: verify-html.py 0 · check-inline-js OK · competitor QC exit 3, 74 site-wide
pre-existing, 0 in our files · impeccable detect --no-advisory dir 14 (0 new).
No form submitted.
2026-11-03 15:19:15 +00:00
webmaster a0ef97b48d Merge fix/remove-clear-filter-button: clear filter button removed, empty state carries the way back 2026-11-03 14:37:08 +00:00
webmaster f3e2812694 fix(freelancer-directory): remove the Clear filter button; give the way back inside the empty state
The Clear button, its .dir-clear-wrap container, the .dir-clear CSS and the JS
that showed, hid and wired it are gone from the filter row. The search box keeps
its native type=search clear. The empty state no longer names a missing control:
it now reads "Nothing matches. Widen your search, or set the filters back to All."
— with that phrase an inline text action (#dirEmptyReset) that does exactly what
Clear did, so the one-click way back survives where a visitor needs it.
2026-11-03 14:37:05 +00:00
webmaster 80bf4bc019 fix(freelancer-directory): the clients filter is a yes/all facet, the dropdowns are 30% narrower, the badge is opaque and inset
The "Clients on ODOO4projects" filter was a numeric minimum (data-match="min",
"4+ clients"). Both live records carry the same count, so no threshold could
ever narrow it — it was dead on live data. Replaced with one two-option control,
All "" / Yes "yes", and the test is now "has a count above zero". The threshold
machinery (data-match="min" and its numeric branch) is dropped from both
renderers and from build-directory.py.

A facet is still printed only when the data supports it: the yes/all control
renders only when a record with a count and a record without one (0 or absent)
both exist — otherwise "Yes" and "All" return the same list. With today's two
live records (counts 2 and 4) it therefore does not print; it appears by itself
the moment a zero lands.

The four dropdown boxes are 30% narrower: .dir-filter:has(> select) min-width
180px -> 126px, so only the <select> facets change; the search box (240px) and
the Clear wrapper are untouched.

The OCA Member Profile badge no longer hangs past the card's right border and is
no longer translucent: right -0.55rem -> 0.5rem (an 8px gap to the border, the
top border still straddled), background rgba(201,162,39,0.2) -> var(--brass)
(solid, the palette token the tint was built from), hover var(--brass-bright).
Label contrast on the solid fill 7.15:1 idle / 9.09:1 hover.

Rebuilt the card HTML from the live webhook (build-directory.py) so the built
list and the runtime renderer agree. No form submitted, nothing else touched.
2026-11-03 14:12:01 +00:00
webmaster a8c5c1837d fix(freelancer-directory): the OCA Member Profile badge straddles the card's top-right corner
The badge sat inside the frame (top/right: 0.85rem). It now straddles the
border — top: -0.7rem, right: -0.55rem — so the card's top and right border
lines run through it, like a tag on the corner.

The :has() header reserve (padding-top: 3.15rem) is no longer needed and is
removed: the badge now intrudes only ~12px into the card (was ~36px), so the
name clears it at the card's normal 20px padding — proven with a long-name
variant. At 375px the page keeps zero horizontal overflow (scrollWidth ==
clientWidth); the badge's 8px right protrusion has 21px of slack. Geometry is
identical in both renderers (built HTML and the JS list): badge offset
dt=-10px, dr=+8px. DOM order, the OCA predicate, the label and the link
attributes are unchanged.
2026-11-03 14:04:29 +00:00
webmaster 04199bd606 fix(freelancer-directory): delete the dark anonymous-contact modal; retire hello@ -> support@ site-wide
Two user decisions (2026-11-03). The page is live.

1. The anonymous-contact modal is removed in full, not just its gate: its markup and
   comment, the .contact-modal and .dev-anon-btn CSS, the JS (modalEls, openContactModal,
   closeContactModal, initContactModal, enhanceContact), the two init-time calls and the
   per-card render call. ODOO_EXPERTS_QUOTE_WEBHOOK is deleted — the only readers were the
   two gates (initContactModal, enhanceContact) and the postTo inside the modal's own
   submit handler; nothing else read it. The modal's lede ("We pass your request on ...")
   goes with it, so no relay copy survives. Kept on purpose: the invite relay form and
   ODOO_EXPERTS_INVITE_WEBHOOK, the OCA filter + badge, ODOO_EXPERTS_WEBHOOK, and the
   card's direct "Email this freelancer" chip.

2. hello@odoo4projects.com is retired site-wide: 24 occurrences (mailto + visible text)
   become support@odoo4projects.com, and the 12 support@ODOO4projects.com case variants
   are normalised to lower case. oliver@odoo4projects.com is a freelancer's own published
   contact address on their card and is left untouched.

Tests run: verify-html.py PROBLEMS 0; check-inline-js.py OK; competitor QC exit 3 with 74
pre-existing hits and 0 added; impeccable detect 1 -> 1 (identical, 0 new).
2026-11-03 13:52:44 +00:00
webmaster 570085cb04 fix(freelancer-directory): the OCA Member Profile badge moves onto the card frame, top right
The badge was an inline-block inside .dev-head, below the name. It is now
absolutely positioned in the card's top-right corner, inset 0.85rem from both
edges (inside the 1rem radius, nothing clipped), with .dev-card as its
positioning context.

The badge keeps its DOM home with the name (h3 then a), so a screen reader
still reads the freelancer's name first; only the paint position moved. No
markup change in either renderer — the build-time HTML and the runtime
liveCard() path both already emit the badge inside .dev-id, so the CSS change
covers them identically (verified: byte-identical geometry at 375 and 1280).

No overlap at any width: .dev-card:has(.dev-oca-badge) reserves the badge's
band by padding the header down, so the name keeps the card's full width and
wraps freely instead of running under the badge. A horizontal reserve was
measured and rejected — at 375 px it left the name column 42 px. Longest live
name (Oliver - The OC Guy) and a deliberately long name both clear by 0 px of
overlap; .dev-name gains overflow-wrap: anywhere so a name with no space in it
cannot push the card open.

Untouched: the OCA_link predicate, the data-oca attribute and the OCA filter
(1 of 2 when set to OCA member), the label, href, target and rel.
2026-11-03 13:49:50 +00:00
webmaster d440d3fa15 Merge fix/focus-sliders-default-3: apply-form focus sliders default to 3 2026-11-03 13:41:43 +00:00
webmaster 2677881a88 fix(freelancer-directory): apply-form focus sliders default to 3 (15 of 15 on load)
The application form's five focus sliders now open at 3 each — exactly the 15-point
budget — so the form starts from a legal profile and the applicant adjusts, rather
than building up from zero. The scale copy now states the rule the default teaches:
raising one axis forces lowering another. The noscript fallback number inputs
default to 3 as well.

Gate unchanged: submit is still blocked unless the five sum to exactly 15; the
default satisfies it on load, it does not skip it.

The admin form is deliberately NOT changed: it keeps loading the record's real
values (populate()), and 0 for a brand-new record — an operator editing only a
description must not silently write a 3/3/3/3/3 profile. Its "leave all five at 0
to leave the profile untouched" escape is intact.
2026-11-03 13:41:42 +00:00
webmaster 68dd48e506 fix(freelancer-directory): card + forms — drop the count hedge, the 15-point sentence and the axis digits; kill every ", as declared"; rename Focus→Skill profile; languages on the shared chart; OCA becomes an "OCA Member Profile" badge; OCA filter re-added off OCA_link
- card count reads "4 clients on ODOO4projects" (no "— a number we recorded"); the
  lede says "our own count" for the same reason. Admin form hint left alone (it is
  an instruction to the operator, not a claim to the visitor).
- removed "15 points over five areas — nobody can be maximum in everything." from
  the card (build + runtime); the forms keep their own explanation.
- removed the visible axis digit (dev-axis-val) from both renderers; the five
  squares stay and each row carries its value as role="img" + aria-label="N of 5".
- removed every ", as declared" (10 on the served page) and rewrote the trust block
  so it no longer promises a label that no longer exists.
- "Focus profile" -> "Skill profile" on the card (build + runtime) and in both form
  legends. The separate `Focus` payload field is untouched.
- languages render through the same dev-axes chart as the skill profile (one
  component, two labels; no digit either).
- OCA becomes a clickable "OCA Member Profile" badge in the card header, once; the
  links row no longer carries it. Same predicate drives the re-added OCA filter
  (OCA_link under https://apps.odoo-community.org/community/), which is a facet in
  the existing filter row, renders only when the data supports it, and filters
  rather than ranks.
2026-11-03 13:38:12 +00:00
webmaster 5aa4782de7 fix(freelancer-directory): remove the Post-a-request section and every relay promise; state the service is free both sides; count = clients, one label; render every confirmed record
- Delete the whole "Post a request" section (heading, "Not open yet" note, pick-list/
  brief/email form) and the JS+Css that existed only for it: initRequestForm(),
  wireRelay("requestFormWrap"…), initRequestForm(), .dir-pickfield/.dir-picklist/.pick.
  build-directory.py: drop PICK_START/PICK_END, render_picklist() and its call.
- Rewrite every relay promise to direct contact (trust block, listing note, final CTA);
  keep the "we are not a party to the contract" point.
- Trust block: state no fee to the freelancer and no fee to the client.
- Count means client instances: single label "clients on ODOO4projects" (no projects).
- No confirmation gating: comment now states the backend only exposes confirmed profiles.
- Left with a comment, not deleted: ODOO_EXPERTS_QUOTE_WEBHOOK (it gates the dark
  anonymous-contact modal, which was out of scope). The modal stays dark (0 buttons).
- Kept: id=1 junk record; the inlined base64 photo (flagged, not changed).
2026-11-03 13:33:53 +00:00
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
webmaster 5ce14ea666 fix(freelancer-directory): render an uploaded photo (data:image/), not only a linked URL
Both card renderers emitted an <img> only for a URL, so a record whose Image is an
inline data:image/... URI (what the upload step stores today) fell through to the
monogram. The avatar check now allowlists exactly two schemes — https:// and
data:image/ — in build-directory.py and in the page script; anything else (empty,
junk like "gineoirn", another scheme) still falls back to the monogram, and the
value never reaches src unchecked. The lede copy is corrected to match: a photo
shows where the freelancer gave a link OR uploaded an image.
2026-11-03 13:04:06 +00:00
webmaster e067fec168 feat(freelancer-directory): full-text search, OCA/projects/language filters, standardised languages
- Search box: substring, case- and accent-insensitive, over Name, description,
  Focus, specialities and language names; combines with every filter (AND).
- Filters: OCA membership (any / member / not a member), Language (canonical
  names only), plus Country/Speciality; a project MINIMUM ('N+ projects'),
  rendered only when a threshold can narrow the list.
- Languages standardised: a closed ISO 639-1 checklist on both forms, with the
  proficiency as a SEPARATE axis (language_levels, 'Spanish:beginner'). A stored
  'Spanish - beginners' now maps to the language 'Spanish' and never becomes a
  filter option.
- Fix: .dev-card/.dir-filters set display, which beat the UA [hidden] rule, so
  filtered-out cards and the JS-off filter bar stayed visible.
- Deterministic order by Name (the endpoint's order alternates between reads).
- Card states plainly that a photo shows only when it is a link (the stored
  value is base64 and is not rendered); admin Image field says the same.
- specialities stays free text.
2026-11-03 12:57:51 +00:00
webmaster ff42a242db fix(freelancer-directory): render the list from the webhook at runtime
A record saved after the last build never reached the page: the list was
build-time HTML only, so the new freelancer (id=2) stayed invisible until
someone re-ran the build. That is the cause, not the symptom.

- The page now GETs the odoo_experts webhook on load and re-renders the cards
  and the filter bar from what it returns.
- The build-time HTML stays in the page and is the fallback: it is replaced
  only when the fetch succeeds, so JavaScript-off and webhook-down still show
  the last built list. An empty response never overwrites a populated list.
- The renderer mirrors build-directory.py field for field (casing aliases
  included); every freelancer-written value goes in as textContent.
- initFilters() is split so one delegated listener re-reads the selects on
  every change: the same code filters the built list and the rendered one.
- Confirmation is NOT gated: the endpoint carries no status/email_verified
  marker, so nothing was guessed. Recorded in the page comment as a data gap.
- rebuild: build-directory.py, so the served HTML starts from the real set.
2026-11-03 12:45:26 +00:00
webmaster ada9605c90 feat(freelancer-directory): Country is an ISO 3166-1 dropdown on both forms
Country was free text, so one country could be entered as Germany / DE /
Deutschland / germany and split the directory filter four ways. Both the
application and the admin form now submit from a native <select> of the
249 ISO 3166-1 alpha-2 codes: the option shows the English name, the value
is the code. No free-text fallback and no "other" escape hatch. Required,
with an empty "— choose —" first option. The directory filter shows the
English name for the stored code, so one country is one option.

Non-trivial, non-UI: the submitted value format lives in one constant per
page, COUNTRY_VALUE_FORMAT ("code" | "name") — switching to full country
names is a one-line change if the n8n workflow expects names, not codes.
The webhook field name stays exactly "Country".
2026-11-03 12:41:02 +00:00
webmaster babc4642ce feat(freelancer-directory): add the trust block (what we do / don't)
The keyword work found no trust keyword with demand and none we could answer
without a review/vetting claim, so the trust content ships as a block on the
directory page itself. It folds the old "How this works" section and states
our limits plainly: you deal with the freelancer, we rank nobody, we verify
nothing (declarations tagged '(as declared)'), invitations carry a one-click
opt-out. The contract point links to the note under the listings instead of
copying it. No trust adjective is a claim; no review/rating/star.
2026-11-03 12:32:25 +00:00
oliver d69a3da26e feat(freelancer-directory): go live — indexable, sitemap + llms, og:image, Plausible, homepage inbound link
- /odoo-community-developers/ no longer noindex,nofollow; title/description/H1 target 'hire odoo developer'
- added og:image + og:image:alt (the page's own hero), twitter summary_large_image
- Plausible snippet copied byte-for-byte as the last thing in <head>
- sitemap.xml + llms.txt entries
- one inbound link: a homepage FAQ card (client-facing, the established FAQ->page pattern)
- relay forms untouched: ODOO_EXPERTS_QUOTE_WEBHOOK / ODOO_EXPERTS_INVITE_WEBHOOK stay empty
- admin page (/admin/) stays noindex and unlinked
2026-11-03 12:26:23 +00:00
webmaster 1c3a2a4b0e fix(freelancer-directory): success copy is a receipt, not a delivery claim
The two relay success strings still described a delivery the stub endpoints
cannot perform ("on its way", "we will email them one invitation"). They were
unreachable only while ODOO_EXPERTS_QUOTE_WEBHOOK / ODOO_EXPERTS_INVITE_WEBHOOK
are empty — setting either re-armed the F1 false confirmation.

- request form success -> "we have your request … come back to you by email"
- invite form success -> "we have recorded your invitation … one-click opt-out"
- comment at each constant: setting it switches the form on; the success copy
  must already be a receipt.

Constants stay empty; both forms stay dark.
2026-11-03 12:11:03 +00:00
webmaster 4d5d59f3bd fix(freelancer-directory): apply the ICP re-review — claims sweep, F1 gate
- 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
2026-11-03 12:04:04 +00:00