Commit Graph
278 Commits
Author SHA1 Message Date
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
webmaster c55c707694 fix(freelancer-directory): keep the final-CTA link legible on the dark section
The new '#request' link in the closing CTA was inheriting the site-wide brass-deep
link colour on the midnight background (3.6:1). It uses brass-bright now, so the
page holds its zero-added impeccable profile (14, same as before the change).
2026-11-03 11:56:43 +00:00
webmaster 63e43ce7a1 feat(freelancer-directory): CSV wire shape for languages/specialities, wire the quote route, add invite-a-friend
- languages/specialities now POST as comma-separated STRINGS ("English,German"),
  not arrays; the build script splits them into separate filter options and tags.
- the client request form is no longer inert: it posts to odoo_experts_quote with
  an explicit freelancer pick-list (never an implicit all) and a client address.
- new invite-a-friend form posts to odoo_experts_invite_friend (sender name +
  friend email); we send the invitation, not the sender's address.
- both new forms show success only on a returned 200 (n8n answers 200 before the
  workflow runs) and promise no timing.
- build-directory.py: render the pick-list, fix the country option casing.
- pages stay drafts (noindex, not in sitemap/llms, no inbound links).
2026-11-03 11:54:38 +00:00
webmaster 832bb41206 fix(freelancer-admin): url inputs no longer block a save on legacy non-URL values
The live record carries placeholder junk in Linkedin/upwork/Image ('1',
'ieivmermi', 'gineoirn'). With type=url the whole form went invalid and the owner
could not save anything. type=text inputmode=url keeps the URL intent and the
mobile keyboard without hard-blocking on data the owner is trying to correct.
2026-11-03 11:49:05 +00:00
webmaster 64540e208f chore(freelancer-directory): drop the admin page URL from the directory's script comment 2026-11-03 11:47:43 +00:00
webmaster c74315e950 fix(freelancer-directory): apply the ICP's five ship-blockers
- hide the inert client request form; announce it as coming soon with an email
  fallback (no reachable error state, no submit that refuses a completed brief)
- soften the contact claim to 'where a freelancer published their details' and
  make a card with no contact say so instead of showing an empty space
- move the admin add/edit panel off the public page to its own draft page
  (/odoo-community-developers/admin/), unlinked, reachable only by URL
- admin edit: load-and-confirm the record before writing, and read-modify-write
  the booleans so an untouched OCA_member/is_individual keeps its current value
- application form: add Country + Odoo versions (the filters' inputs), GitHub and
  OCA profile URLs, and an explicit email-publication consent line
- tighten 'How this works' from ~120 words to two short options + one caveat
- build-directory.py: skip records with no Name; render GitHub/OCA links and the
  no-contact line; read Country via alias
2026-11-03 11:47:05 +00:00
webmaster e8c93d89ad feat(freelancer-directory): add /odoo-community-developers/ as a draft
Odoo freelancer directory for our clients, built as a draft (noindex, out of
sitemap.xml and llms.txt, no inbound links, no announcement surface, no tracking).

- The list is rendered at BUILD TIME from the single freelancer webhook
  (GET/POST .../odoo_experts) so it works with JavaScript disabled; the page
  script only filters that HTML.
- Filters (country, odoo_versions, languages, specialities) are printed only when
  the live data actually supports them, so a filter that returns nothing never
  ships. No record has them yet, so none render today.
- Three forms: freelancer application (POST, no id -> create), admin add/edit
  (POST with id -> update), and a client request form whose endpoint is a single
  inert constant until the workflow exists.
- No reviews, no ratings, no claims beyond what a freelancer wrote and the count
  from our own records. No competitor named.
2026-11-03 11:37:17 +00:00
webmaster 82b74c359a refactor(blog): rebuild Odoo 20 Is Live post on the newest post chrome
Same URL, slug, date and body (body bytes unchanged, sha256 4fea9c0c).
Chrome copied from blog/posts/how-to-install-odoo-modules-using-git.html:
main.blog-post / span.badge / div.post-body / a.back-link, Marcellus headings,
cream palette. Replaces the older .container/.post-content chrome.
2026-11-03 10:16:35 +00:00
webmaster 5d65fa7027 feat(blog): publish Odoo 20 Is Live on Our Servers — Here Is What Changed
New post blog/posts/odoo-20-is-live-on-our-servers-here-is-what-changed.html
(chrome copied from blog/posts/track-the-oca-modules-you-actually-use.html,
body verbatim from the inbox source), plus the blog/index.json entry (first),
one sitemap.xml <url> and one llms.txt blog line.
2026-11-03 10:11:11 +00:00
webmaster c6509e9920 restore(odooityourself): re-add the affiliate page at the user's request
Restore /odooityourself.html from ada11e4^ — the exact content that was
live when commit ada11e4 deleted it on 2026-11-01. That revision already
includes the three later edits that were in the live page:
  4da2f1c  remove the hero CTA
  3e913d6  remove the trial MCP endpoint line
  0cd0fa1  Plausible snippet on every page

Byte-identical to ada11e4^:odooityourself.html
(sha256 20561121ba14ff18e482eb2e40b49085cf9a5586885483bda427d647ed19bc4d,
 27475 bytes / 704 lines).

Deliberately stays OUT of search: it is an affiliate's page (Andrew), not a
search target (decision 2026-10-30). No sitemap.xml and no llms.txt entry
added — both still contain 0 references. No inbound links added.

The two SVGs it uses (images/andrew/ai-agent-on-server.svg and
ai-agent-on-server-mobile.svg) were kept at deletion and are still present.
No other change: no copy edits, no redesign, no tracking, no consent gate.
verify-html.py PROBLEMS: 0; competitor QC exit 0.
2026-11-03 08:27:33 +00:00
webmaster 0e832d8fd1 perf(site-wide): render-blocking /style.css on 17 pages; <main> landmark; BF footer form action
Same defect the bootcamp carried (2aa9b19), fixed site-wide in one mechanical pass.

P0 (17 pages): /style.css was deferred via media="print" onload=... -> unstyled
first paint -> a BODY layout shift. Now a normal render-blocking
<link rel="stylesheet">; the now-redundant no-JS <noscript> for style.css is
dropped. Web fonts + Font Awesome keep their legitimate deferral.
Pages: index.html, odoo-black-friday/, odoo-hosting-offers/, de/index.html,
es/index.html, odoo-hosting-2027.html (+de/+es), odoo-community-upgrade.html
(+de/+es), odoo-staging-environment/, odoo-community-ai/,
odoo-n8n-masterclass/, australia.html, workshops/, what-can-i-use-odoo-for/.

P1 (1 page): /odoo-black-friday/ footer form action mailto: -> CRM webhook
(Lighthouse is-on-https). Changed only because the page already loads a handler
for #footerContactForm (static/black-friday.js). The other 13 mailto-form pages
load no handler and are left alone on purpose (no drive-by handler copy-in).
Added the no-JS <noscript> fallback so a JS-off visitor keeps a path.

P2 (35 pages): add a single <main> landmark wrapping the primary content; top
bar and footer stay outside. Additive only, no redesign.

Guards held: bootcamp EN/DE/ES and every JS/static file untouched; Buy forms
product_id 4/17 intact; one [data-bc-seats]; ads-consent gate intact.
verify-html.py PROBLEMS 0; competitor QC 74 pre-existing, 0 added.
2026-11-02 18:18:46 +00:00
webmaster 2aa9b19c3c perf(bootcamp): fix CLS/LCP + a11y on EN/DE/ES; add the missing footer-form handler
PageSpeed pass on /odoo-erp-automation-bootcamp/ (EN + DE + ES):

- CLS: /style.css was deferred via media="print" onload=... → unstyled first paint
  shifted BODY 0.31 (live CLS 0.326). Now a normal <link rel="stylesheet">; the two
  hero webp files are preloaded (fetchpriority=high) by viewport.
- a11y: footer <h4> → <h3> (heading-order), a lighter brass + underline for links
  inside on-dark notes (color-contrast, link-in-text-block), .fcopy 3.2:1 → 5.17:1.
- best-practices: the footer contact forms and the waiting-list form now post to the
  CRM webhook instead of action="mailto:", which Lighthouse flags as an insecure
  request (is-on-https 0 → 1). No Google Ads tag on these pages (no consent gate).
- regression fix: these pages do NOT load /script.js, so after the action change the
  footer form had no JS handler and a submit navigated the visitor to the webhook's raw
  JSON reply. static/bootcamp-waitlist.js now carries a footer-form handler mirroring
  /script.js one-for-one (same webhook, payload { email, requirements, area:'message' }
  in UTM.inject, success/failure behaviour); its two strings are read from the form's
  own data-ok / data-err attributes so each language keeps its own text. ENDPOINT
  hoisted to module scope so both handlers share it.
- no-JS path: a <noscript> footer line with support@ODOO4projects.com as a plain
  mailto link. Verified it does not re-trigger is-on-https (unlike a mailto FORM action).

Guards held: Buy forms still post product_id 17; exactly one [data-bc-seats];
waitlist still posts area:'message'; plausible snippet present; competitor QC exit 0.
2026-11-02 16:07:20 +00:00
webmaster bc942cdb7c feat(bootcamp): move the live seat-count line immediately below the hero
The [data-bc-seats] line lived in the Price-and-booking section; the user wants it
just under the hero. Moved (not copied) on EN/DE/ES — one element per page, the same
/static/bootcamp-availability.js, every string unchanged ("x of 6 seats available" /
"x von 6 Plätzen frei" / "x de 6 plazas disponibles").

Also adds a page-scoped :disabled style for the Buy buttons so a sold-out cohort's
disabled buttons actually read as disabled (rule: zero is honest — the buy must not
look open). Buy forms untouched: still post product_id 17.
2026-11-02 15:32:43 +00:00
webmaster 8f72652ada feat(bootcamp): waiting-list form for another timezone or language
Three bootcamp pages (EN/DE/ES) get a small waiting-list block below the Buy
button, in the Price and booking section next to the live seat count.

- two required fields: email + comment; framed as "another timezone or
  language?" and worded around the January cohort's own schedule
- posts to the same n8n CRM webhook as the site's other forms, with the
  footer-message payload shape copied exactly: area:'message',
  requirements carries the comment, no name field, wrapped in UTM.inject()
- success hides the form and shows a confirmation; a failure restores the
  button and shows a retryable error (never stuck on "sending")
- Plausible event 'Bootcamp Waitlist' on success only (cookieless); no
  Google Ads tag and no conversion, as on the rest of these pages
- new page-local /static/bootcamp-waitlist.js carries the verbatim UTM
  helpers from script.js (this page does not load script.js)
- buying is untouched: no markup change to the three product-17 forms

Verified: exact posted body per language captured on the wire, success and
error states in a real browser, 375 px no overflow, verify-html 0, JS syntax
0, competitor QC 0 new, impeccable detect byte-identical (zero new findings).
2026-11-02 15:26:31 +00:00
webmaster 812571b177 fix(bootcamp): give the availability fetch a 6s bound (cold-start headroom)
Measured live from the browser: the webhook answers in ~2.2-2.6s warm but a
cold first connection can exceed the old 3s abort, so the count would fall
back to the static text on a fresh visit. 6s is bounded and nothing blocks
the buy either way.
2026-11-02 15:19:54 +00:00
webmaster f2e375a8f1 feat(bootcamp): live remaining-seat count from availability webhook
Show the honest remaining seats for product 17 (Bootcamp Q1-2027) on all
three bootcamp pages, in the page's own language, from the availability
webhook. Progressive enhancement (static/bootcamp-availability.js): empty
and hidden by default, filled on a successful fetch; on qty 0 the cohort is
reported full, the next cohort is pointed at, and the Buy buttons are
disabled; on any timeout/error/unexpected payload the page is left exactly
as-is and the Buy buttons stay enabled. The count is never written into the
HTML as a fixed string.
2026-11-02 15:17:36 +00:00
webmaster d8a6126b9d fix(bootcamp): Buy forms add the Q1 2027 cohort (product 12 -> 17)
All nine Buy forms on the bootcamp pages (EN/DE/ES, three each) posted
product_id 12, which the shop resolves to 'Bootcamp - Q1-2026-eng'. The
advertised cohort is Q1 2027 (starts Thu 21 Jan 2027), so a customer
buying the 2027 bootcamp received the 2026 cohort. Both are 595.00 and
neither cart was empty, so it went unnoticed; the form's info field reads
'Q1 2027, English' while the product was 2026.

product_id 12 -> 17 in every bootcamp Buy form, all three languages.
The info field is kept (it renders as the cart line description via
sale.order.line.name, so it is read downstream); it is truthful.
static/buy.js doc comment updated to match. Nothing else changed:
plausible-event-name=Buy class, target=_blank form post, prices and
layout untouched.
2026-11-02 15:03:16 +00:00
oliver bcd0ef890a feat(blog): publish From a Spreadsheet to a Running ERP in Ten Thursday Sessions
New post blog/posts/from-a-spreadsheet-to-a-running-erp.html (area news, 2026-11-02),
body pasted verbatim from the inbox source (sha256 59568fc8…). Chrome copied from the
newest post (oca-paraguay-internationalization-hackathon.html), inline style trimmed to
the rules the page uses. Card image screencast.png wired in three additive places
(post-image after the meta line, og:image, blog/index.json image key) plus BlogPosting
JSON-LD. blog/index.json 17 -> 18, sitemap.xml 42 -> 43 <loc>, llms.txt 17 -> 18 lines,
all text splices (16 insertions, 0 deletions).
2026-11-02 14:39:29 +00:00
webmaster 2359e87a35 feat(bf): put the HOST27 deal on the homepage; deal window now starts 2 November
The coupon applies today (signed-out test: post_basket -> /coupon/HOST27 ->
cart 295.00 from a 395.00 list), so the offer starts today, not on 16 November.

- index.html: one seasonal banner above the hero, a single <a> to
  /odoo-black-friday/. Homepage components and CI only (midnight/brass/cream,
  Marcellus/Inter). No script, no tracking tag, no event: the homepage has no
  consent gate, so nothing measurable may be added there. Hero, server
  selector, section order and JSON-LD untouched. Dated for removal after
  30 November 2026.
- odoo-black-friday/: every start claim 16 -> 2 (meta, og:image:alt, JSON-LD
  WebPage description + two FAQ answers, hero-sub, two body FAQ answers).
  End date, countdown instant, forms, consent gate and the three conversions
  unchanged.
- odoo-hosting-offers/: same start claim on the second ad lander, so the AG1
  visitor is not told the deal begins in two weeks.

Every price claim keeps the "first year" qualifier.
2026-11-02 10:27:02 +00:00
webmaster 9e38e05aad feat(bf): Server Location selector beside the deal Buy, carried into both buy forms
Move the homepage's Server Location lockup (label + select) out of section#pricing
and into the HOST27 deal card's CTA row, next to the Buy button. There is still
exactly ONE element with id pricingServerLocation on the page, so buyNow() — which
resolves the selector by id — and every pricing button keep working untouched.
The pricing block keeps only the Odoo Version selector and reads the same global
location selection.

- markup: new .bf-cta-row in the deal form (selector left, brass Buy right);
  location lockup removed from .pricing-server-row
- CSS (page-scoped): .bf-cta-row / .bf-cta-location, and the light-on-ivory twin
  of .pricing-server-select (style.css carries neither; the homepage styles it
  inline for its dark section). Wraps at 375px with no horizontal overflow; the
  Buy stays the one dominant action.
- static/black-friday.js: on submit of the native post_basket forms, write the
  chosen city + version into the basket line's `info`, exactly as buyNow() does.
  Additive only — with JavaScript off the POST is unchanged (no info) and the
  selector is empty because its options are built here (pre-existing behaviour).

Untouched: the hidden redirect=/coupon/HOST27, the single plausible-event-name=Buy
mark, the price, the countdown, #trialForm, the booking link and the consent gate.

Verified: verify-html.py PROBLEMS 0; competitor QC exit 0 on the page (74
pre-existing site-wide, 0 added); node --check OK; impeccable detect 35 findings,
identical set to HEAD (0 introduced); live browser: fastest server auto-selected,
wire body basket=[{"product_id":4,"qty":1,"info":"boston|20.0"}] and it tracks the
selectors on repeat submits; 375px no overflow.
2026-11-02 09:42:01 +00:00
webmaster 0f8ae45e59 fix(bf): align the offer row — trial/booking column flush with the server grid, deal centred
The offer row was a centred pair (620 + 300), so the trial/booking column ended at
1104 px while the server grid's last cell (Sydney) ended at 1156 px — a 52 px gap
against the only edge on the page it should match.

The row is now two columns: the aside takes a fixed 300 px and sits on the
container's right content edge; the left column takes the rest, so the deal box is
centred in the space left of it rather than parked in its own 620 px lane. The row's
column gap is folded into that centring, and the deal is capped at the column width
minus one gap so the two boxes can never touch just above the 900 px stack.

CSS only: no markup, no ids, no forms, no JS. Buy form, hidden redirect, countdown,
Buy mark, #trialCard/#trialForm, the booking <a> and the consent gate are untouched.
Measured 1280 px: aside right 1156.4 == Sydney right 1156.4; deal gaps 63.9 / 64.0.
2026-11-02 09:35:13 +00:00
webmaster 95d5d455b9 feat(bf): trial widget + booking link beside the HOST27 deal
On /odoo-black-friday/ the offer row is now two columns at >=901px: the deal (left,
unchanged and still the single dominant primary) with the free trial and a booking
link beside it (right, deliberately smaller and quieter).

- Trial: the existing #trialCard/#trialForm moves verbatim from the features grid into
  the row's aside. Same ids, same static/black-friday.js handler, same consent gate and
  label (AW-18065522009/AtAPCIjjvrAdENn6p6ZD) — no second trial mechanism.
- Booking: a plain <a target=_blank rel=noopener> to Oliver's verified Google
  appointment page (no iframe). Its click fires AW-18065522009/P0j5CPiCv7AdENn6p6ZD
  through the same gate via window.o4pAds.bookConversion; the click is never
  preventDefault-ed, so a blocked tag can never stop the link opening.
- ads-consent.js gains the booking label (from the page, not hard-coded) and the
  bookConversion hook; the buy path is untouched.

Deal buy stays the dominant action; ONE plausible Buy mark; one price; countdown pair
unchanged.
2026-11-02 09:28:09 +00:00
webmaster 7497fbf588 feat(bf): fire the trial conversion behind the same consent gate
Adds the second Google Ads label to /odoo-black-friday/: the free-trial
form (#trialForm) now fires AW-18065522009/AtAPCIjjvrAdENn6p6ZD (action
7886320008 "Registrierung") when it is submitted.

Reuses the existing gate in static/ads-consent.js rather than a second
mechanism: the label is read from the page (data-ads-trial-send-to on
#adsConsent), so the script stays generic. The page hands the gate its
real submit function via window.o4pAds.trialConversion; the gate fires
the conversion and runs the submit from the event callback, with the same
700 ms hard timeout that runs it anyway. Nothing fires and no cookie is
set before consent (identical default-deny gate).

The buy conversion is untouched (comments only).
2026-11-02 09:23:14 +00:00
webmaster 6d486ad954 refactor(bf): hold the Google Ads id/label in the page, keep ads-consent.js generic
Tag id + conversion label now live as data-ads-id / data-ads-send-to on the banner
element, so the served HTML carries the tag config and the script stays reusable
(missing config -> inert). Re-verified: no cookie/request before consent, cookies +
tag after Accept, conversion + form submit on click, 700 ms fallback submit with the
tag blocked.
2026-11-02 09:13:16 +00:00
webmaster ae97f4f5eb feat(bf): gate the Google Ads tag behind a consent banner; fire the buy-click conversion proxy
/odoo-black-friday/ only, page-side. No Odoo change, no ad-account change.

- static/ads-consent.js (new): default-DENY consent; nothing loads and no request
  fires until Accept. On Decline (or switching to Decline later) the _gcl_au/_gcl_aw
  cookies are cleared. A footer "Cookie settings" link reopens the banner.
- conversion: on submit of either /shop/post_basket buy form (offer block + On the
  Rise card), gtag('event','conversion',{send_to: AW-18065522009/vnDUCPDxt7AdENn6p6ZD})
  then form.submit() from event_callback, with a 700 ms hard-timeout fallback so a
  blocked tag can never swallow the buy. No consent / no tag -> native POST untouched.
- privacy.html: new "Advertising cookies" disclosure (what, why, how to decline).
- Plausible Buy marks and the checkout-side Purchase event untouched.
2026-11-02 09:12:08 +00:00
webmaster f119f041f0 i18n(de,es): qualify the hero-sub upgrade line on odoo-hosting-2027.html
The DE/ES hero-sub read '... Zertifikat und Upgrades.' / '... el certificado y
las actualizaciones.' — the last unqualified upgrade instances, mirroring the EN
hero fix (8fc6478). Both pages already qualify the distinction in the body and
the comparison table; the hero now matches, naming the in-version scope with the
pages' own phrase (DE 'die Updates innerhalb Ihrer Odoo-Version' / ES 'las
actualizaciones dentro de su versión de Odoo').

In-version updates/upgrades stay included; a major-version migration (19 -> 20)
remains a separate quote. Copy-only: tag/attr/JSON-LD structure proven identical.
2026-11-02 08:47:19 +00:00
webmaster 8fc647826f fix(copy): qualify the hero upgrade line on odoo-hosting-2027.html
The hero-sub read 'the certificate and the upgrades' unqualified while the
same page qualifies the distinction twice (body + comparison table). Replaced
with the page's own qualified phrase, 'the updates within your Odoo version'
(no new vocabulary, text-only). In-version updates/upgrades stay included; a
major-version migration (19 -> 20) remains a separate quote.

Copy-only: 2 lines, tag/attr/JSON-LD structure proven identical.
2026-11-02 08:41:55 +00:00
translator b71d6bddb9 i18n(de,es): mirror the qualified upgrade wording on odoo-hosting-2027.html
Qualify the version-upgrade claim in the three spots the EN page (46b943b) already
qualifies, in the DE and ES twins at exactly the same three places:
  - JSON-LD Product.description
  - "Why we exist" body paragraph
  - comparison-table row (Version upgrades / Versions-Upgrades / Actualizaciones de versión)

In-version / server-side updates and upgrades are included; a major-version
migration (Odoo 19 -> 20) is a separate service and is quoted separately
(user rule 2026-11-02, wiki concepts/odo4-commercial-promises). Copy-only: no
price, plan, structure, tag, attribute, class or link changed.
2026-11-02 08:38:25 +00:00
webmaster ec6e496900 fix(bf): page-scoped CRO fixes on /odoo-black-friday/ (deal price, one deal buy, live countdown)
Fix 1: the On the Rise pricing card quoted $395 while the deal block said $295 first
year — a price contradiction within one scroll. On this page only the card now reads
$295.00 / 'for your first year, then $395 a year'. Other pages keep $395.
JSON-LD Offer price was already 295.00 and still matches the visible price.

Fix 2: the three list-price Buy Now buttons (data-id 6/4/5) competed with the deal.
Side Hustle and Powerhouse buy buttons removed on this page. The On the Rise card's
buy is now the deal purchase — the same one-click checkout as the offer block
(product 4, /coupon/HOST27?r=/shop/checkout), native POST so it works with JS off.
Tracking: the offer-block form is the single element carrying the Plausible Buy class;
the Bootcamp CTA and the two list-price buttons no longer carry it.

Fix 10: a time-remaining element near the offer and near the buy, computed client-side
from one documented constant (2026-11-30T23:59:59-05:00, US/Eastern). The static
sentence 'Ends Monday 30 November 2026.' still reads with JS off; past the instant the
text switches to the past tense. No visibility animation.
2026-11-02 08:36:55 +00:00
webmaster 46b943bffe fix(copy): qualify version upgrades as in-version only; major migration is a separate quote
The four 'version upgrades are run for you' lines (black-friday, hosting-offers,
hosting-2027 body + table row, llms.txt) and their JSON-LD descriptions now say
updates and in-version upgrades are included, and name a major-version migration
as a separate, quoted service. terms.html: migration = separate service, quoted
separately; release list no longer reads stale (names Odoo 20 as current).
No offer, price, page structure or ad change.
2026-11-02 08:31:51 +00:00
webmaster ef0ea506c2 fix(bf): keep og:image:alt accurate to the hosting-only OG card
The archived two-offer alt named a Bootcamp line the current card does not carry
(re-rendered hosting-only in e6f0b6a). The meta + og:description do carry the Bootcamp
again (the page shows it); the image alt stays true to the image.
2026-11-02 08:26:44 +00:00
webmaster 0a493d4e10 feat(bf): lead /odoo-black-friday/ with the homepage hero, move the Bootcamp box to the foot
- Hero is the homepage's (same markup/assets): slide-in city image + the server
  location selector below it that slides a chosen city in; headline adapted to the offer.
- Order now: hero > location selector > deal (HOST27, $295 first year / $395 after,
  one-click product-4 buy with redirect=/coupon/HOST27?r=/shop/checkout) > trust bar >
  Black Friday FAQ > features + trial card > FAQ > pricing (buy section) > Bootcamp > footer.
- Bootcamp box restored from webmaster/out/2026-11-02-bootcamp-box-removed-archive.md:
  heading, USD 595.00, mechanism line, deadline, CTA, its meta/og contribution, its JSON-LD
  Product entry and the FAQ answer are all back and consistent.
- Excluded (no blog, no OCA modules, no Project Plan); no dangling refs.
- New /static/black-friday.js = the homepage behaviours this page needs (root-relative
  image paths), with the blog/OCA/project-plan blocks removed.
- Canonical URL unchanged; competitor QC 0 on the page.
2026-11-02 08:25:20 +00:00
webmaster e6f0b6a395 feat(bf): clarify the one-year offer and drop the Bootcamp box on /odoo-black-friday/
Two user-requested changes on the Black Friday landing page.

1. The one-year framing is now unmissable where the price is stated:
   "$295 for your first year — then $395 a year after that." (was: "$395.00 a year —
   $295.00 for your first year"). The hero, meta description, og:description and the
   JSON-LD WebPage description all carry the same first-year-then-$395 wording. The
   offer itself is unchanged ($295 first year, $395 after, code HOST27, 16-30 Nov 2026).

2. The Bootcamp box is removed; the page is now a hosting-only offer surface. The
   meta/og, the JSON-LD Product entry and WebPage.about Thing were rewritten, the FAQ
   answer no longer claims the Bootcamp is part of the offer (HTML + JSON-LD, byte-
   identical), and the offer grid re-flows to one centred card (no empty cell).
   The removed box is archived, with line numbers, at
   webmaster/out/2026-11-02-bootcamp-box-removed-archive.md.
   The OG card was re-rendered hosting-only (sub line "your first year"); og:image:alt
   updated to match.

Verified: verify-html.py exit 0; competitor QC on the page exit 0; impeccable detector
0 new findings (23 -> 21); JSON-LD valid, 1 Product, FAQ 6/6 byte-identical.
2026-11-02 08:17:12 +00:00
webmaster 283983012f feat(bf): auto-apply HOST27 on the On the Rise CTA (one-click discounted checkout)
The Hosting form on /odoo-black-friday/ posts product 4 ("basket") + clear=1
to the shop's custom /shop/post_basket route. Adding a third hidden field,
redirect=/coupon/HOST27?r=/shop/checkout, chains that route's redirect into
the core website_sale_loyalty GET /coupon/<code> handler, so a single click
lands the visitor on /shop/address with the -$100 first-year discount already
itemised (Total $295) - no typing, works with JavaScript off, and it survives
the cross-host POST because the target is baked into the form.

Mechanism designed, probed and proven signed-out end to end by odoo-dev
(wiki/concepts/odoo-shop-promo-autoapply.md). No module, no price change.

Rollback: delete the one <input type="hidden" name="redirect"> line.
2026-11-01 23:24:48 +00:00