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.
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.
- 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).
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.
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.
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.
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.
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.
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.
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).
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.
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.
- 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.
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.
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.
- 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.
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.
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".
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.
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.
- 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
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).
- 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).
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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).
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.
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.
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.
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.
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).
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.
/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.
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.
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.
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.
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.
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.
- 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.
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.
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.
The code was behind a reveal (click 'Open the Black Friday price'). Both offer
cards are now always open: the price and HOST27 are readable without any
interaction. The scroll animation (opacity/transform only, base state fully
opaque, behind @supports) is presentation, never a gate.
The hosting CTA is now a native form that posts the On the Rise product straight
to the shop basket, so one click from the page lands in checkout with the plan in
the cart — replacing a three-navigation detour (offers hub -> homepage trial ->
pricing).
Bootcamp box content is untouched; only its reveal gate was removed.
The two Black Friday landing pages carried 0 Buy marks, so the page we are
about to advertise reported nothing. Buy is a CLICK PROXY by contract (it marks
a click on the offer CTA, not a completed purchase); the real purchase is fired
on the checkout host (20.odoo4projects.com) by odoo-dev. Both CTAs on
/odoo-black-friday/ and the BF-block link on /odoo-hosting-offers/ now carry
plausible-event-name=Buy, each labelled in-page as the proxy it is.
No Google Ads tag added: see handoff 2026-11-01-webmaster-to-olchi-ads-tag-and-buy-proxy.md
(privacy.html discloses no advertising cookies and the site has no consent gate).
The ODOO ERP Automation Bootcamp price is now USD 595.00 (shop product
already set). Every printed EN bootcamp price now reads 595.00 so the page
no longer loses the sale at the cart (page said 465.00, cart charged 595.00).
EN surfaces changed (price strings only — term, dates, six-seat cap and the
one-year hosted Odoo Community wording untouched):
- odoo-erp-automation-bootcamp/index.html: 3 buy buttons, JSON-LD Offer
price, .bc-price sentence
- odoo-black-friday/index.html: Bootcamp box JSON-LD Offer price, bf-teaser,
bf-price
- workshops/index.html: both meta descriptions, JSON-LD Offer price,
.bc-card-price, comparison-table cell
- what-can-i-use-odoo-for/index.html: the line naming the bootcamp
- llms.txt: workshops line
verify-html.py 0 problems, node --check script.js clean; competitor checker
unchanged (74 pre-existing Odoo Online hits, 0 added by this diff).
All three bootcamp pages (EN/DE/ES), every buy form: the basket line now
carries info="Q1 2027, English" alongside product_id 12 and qty 1, the
same field shape australia.html uses for location. Value verbatim on all
three pages (the programme is delivered in English in every language).
clear=1 / action / method / target / button labels unchanged.
Verified end to end against the live shop: POST /shop/post_basket returns
303 -> /shop/checkout and /shop/cart shows product 12 at qty 1 with the
info text rendered on the order line.
Deleted odooityourself.html ('delete the site we did for odooityourself').
The URL was already out of sitemap.xml and llms.txt, so no dead URL
stays advertised. The file is removed from the working tree; git history
retains it in full (recoverable from any earlier commit). The two SVGs
it used (images/andrew/ai-agent-on-server.svg and
ai-agent-on-server-mobile.svg) are kept — candidate art for
/odoo-community-ai/ — not part of this removal.
The code may not be named where a visitor or crawler can read it before
opening a reveal panel. Three off-panel leaks removed:
- /odoo-black-friday/ FAQ (visible answer + its FAQPage JSON-LD twin,
byte-identical): 'the code stops working' -> 'the offer stops working'.
- /odoo-hosting-offers/ og:image:alt: the trailing ', and the code
HOST27' dropped; the offer and the 16-30 November window stay.
- /odoo-hosting-offers/ date-scoped Black Friday block: the line no
longer names the code; it states the offer and links to
/odoo-black-friday/ (the only page with a reveal panel), marked with a
dated comment for removal after 30 November 2026.
HOST27 now survives only inside the .bf-panel on /odoo-black-friday/;
/odoo-hosting-offers/ carries zero.
New page "Run Odoo Community with your own AI agent", built from the copywriter's
build-ready copy (copywriter/out/2026-11-01-odoo-community-ai-copy.md) on the newest
page pattern (what-can-i-use-odoo-for/ , odoo-staging-environment/): page-scoped top
bar + language switcher, hero, alternating bands, shared footer, page-scoped CSS only.
Draft contract (all five): <meta name="robots" content="noindex, nofollow">; not added
to sitemap.xml; not added to llms.txt; no inbound link from any page (footer untouched);
self-canonical kept, no og:image. No file outside odoo-community-ai/ is touched.
Copy carried verbatim: title (53), meta (148), one H1, the seven H2s, the FAQ pairs
byte-identical between the visible FAQ and the FAQPage JSON-LD, the two MCP settings
shown apart (a table), the "for your developer" <details> after the offer, and the
fence sentence "does not move data". The copy's DRAFT(R1) marker and its [OPEN-SLOT:…]
marks ship as HTML comments, never as visible text.
The copy's 'Into this page' table, applied as single additive sentences:
- support.html: the 'error 500 after a git check in' FAQ answer links
'test it safely first'.
- blog/posts/speed-run-backup.html: end of the post links
'what a staging environment is, and why you probably do not need one'.
- odoo-hosting-2027.html: one contextual note by the backup/module rows
links 'do you need a staging environment?'.
- index.html: one FAQ card 'Do you need an Odoo staging environment?'.
No footer change. No new impeccable finding category.
New standalone page answering 'odoo staging environment': what staging is,
how odoo.sh's staging branches work (sourced to odoo.sh's own docs and
pricing page), why most small businesses do not need one, and the
back-up-install-test route we offer instead.
- Built on the newest page pattern (top bar, band rhythm, hero, FAQ,
language switcher, footer), page-scoped CSS only; no new framework.
- ICP review R1-R7 applied: backup plan-gate and slot limit stated,
the 'ask us' claim resolved against the Side Hustle limit, the restore
stated as a rollback of live work, the restore routed through support,
the meta de-claims 'one-click ... safely', 'module' defined at first
use, and a scope/price boundary added to the escalation.
- Title 51 chars, meta 148; one H1; six FAQ pairs byte-identical to the
FAQPage JSON-LD; @graph WebPage+Article+FAQPage+BreadcrumbList parses.
- CTA -> support.html#contact (existing route). og:image reuses the
established hero share image.
- sitemap.xml + one llms.txt line; blog/index.json untouched.
Remove the odooityourself.html <url> block from sitemap.xml (42 -> 41 <loc>)
and its line from llms.txt. The page stays published and crawlable: no
noindex, no canonical change, no internal links. It is a deliberate orphan
for a single affiliate, not a search target.
The Bootcamp note comment and its inline [OPEN-SLOT: …] marker named the code
HOST27 in the served HTML, so view-source leaked it before anyone opened the
reveal. Both mark the same still-open decision (BOOTCAMP-CONFIRM / bootcamp BF
price line), so the markers are kept and rewritten without the code string.
The reveal content that names the code on open is untouched.
- delete the whole 'What the code does' <section> (heading, both .bf-lead paragraphs, markup)
- remove HOST27 from meta/og/twitter descriptions, og:image:alt and the WebPage JSON-LD description
- rewrite the two FAQ strings (question + 'On the Rise cost' answer) and their JSON-LD twins so no code is named
- drop the now-dead .bf-lead CSS block and its dangling comment
- reveal mechanic untouched: the code stays inside each native <details> .bf-panel
/odoo-black-friday/ gains og:image + og:image:width/height/alt + twitter:image
pointing at the 1200x630 OG card; /odoo-hosting-offers/ reuses the same card.
Both pages declared no og:image before.
Same base filenames (bf2026-hosting-og, bf2026-hosting-linkedin,
bf2026-bootcamp-lastcall-linkedin), so the pipeline records' image URLs stay
valid. PNGs byte-identical to thumbnail-designer's rendered files:
og 161923B, linkedin 99673B, bootcamp 169572B. WebP siblings regenerated.
The page carried only the Hosting offer; it now carries both Black Friday
offers. Bootcamp box first, then Hosting, in the same <section class="bf-box">,
each in its own .bf-box-inner card, each with the same native
<details class="bf-reveal"> reveal (closed teaser, panel in the DOM in both
states, keyboard-operable, no display:none, JS-off safe, aria-expanded synced
for both). Copy is the copywriter's v3 verbatim: USD 465.00, ten live Thursday
sessions 18:00-19:00 Central Time from Thursday 21 January 2027, the Black
Friday second hosted year, six seats, deadline 30 November 2026. No discounted
Bootcamp figure is printed (HOST27 applicability unconfirmed): the value slot
stays a build placeholder in the one greppable bf-mechanism line. Hero value
statement, meta/og description, WebPage description, FAQ (6 pairs) and the
@graph (second Product+Offer, 465.00 USD) follow the same copy file.
Page A /odoo-black-friday/ owns "odoo black friday": hero, one On the Rise
box with a CSS-only reveal (native <details>) of the HOST27 offer, "what the
code does", FAQ. Page B /odoo-hosting-offers/ is the evergreen hub owning
"odoo hosting offers/deals/discount", with a date-scoped Black Friday block
(BF-BLOCK-START/END) linking to Page A. Both carry the copy's title/meta/H1,
@graph JSON-LD (Organization, WebPage, BreadcrumbList, Product+Offer, FAQPage).
sitemap.xml and llms.txt updated. Copy source: copywriter 2026-10-30.
Mirror the EN cap from 2940d33 in native register, cap only — the Black
Friday call-out (window, second hosted year, BF2026) is not added; it was
dropped from EN in fd3f1dc and stays out of every language.
- de|es/odoo-erp-automation-bootcamp/index.html: Class-size fact row
(Begrenzt auf 6 Teilnehmer / Limitado a 6 participantes), the .bc-price
cap line, and JSON-LD CourseInstance maximumAttendeeCapacity: 6.
- No workshops DE/ES copies exist, so no card / comparison row applies.
- why-odoo4projects.html left untracked.
The bootcamp has no Black Friday offer. Remove the .bc-cards call-out block
(16-30 Nov 2026 dates, second hosted year, BF2026 code, six-seats line) and
the OPEN-SLOT(olchi) landing-page comment. No BF string existed in llms.txt
or workshops/index.html. The 6-seat capacity stays: Class size fact row, the
bc-price cap line, CourseInstance maximumAttendeeCapacity: 6, the workshops
card cap + comparison row, and the llms.txt six-participants sentence.
- bootcamp page: Class size fact row (Limited to 6 participants), a cap line by
the price, JSON-LD CourseInstance maximumAttendeeCapacity: 6, and a compact
Black Friday call-out (16-30 Nov 2026, second hosted year + $100 off BF2026,
six seats) with the landing-page slot left open for olchi
- workshops page: the cap on the bootcamp card and a Class size comparison row
- llms.txt: durable 'limited to six participants per cohort' on the bootcamp line
(evergreen - no dated Black Friday offer in llms.txt)
Mirror the EN term change (01c5dd3) into the published German and Spanish
bootcamp pages: hero kicker, hero first-session stat, the four schedule
facts, the DST note and both JSON-LD dates. Text/date splice only — the
verifier reports the tag sequence, attribute list, <style>/<script> bytes
and JSON-LD shape identical before and after, english_leftovers: none.
Splice the bootcamp term on the EN page to the Q1 2027 January term:
hero kicker, hero stat, schedule <dd>s (Term/First/Last/Dates), DST note
(8 March -> 14 March), and JSON-LD CourseInstance startDate/endDate
(2026-01-22/2026-03-26 -> 2027-01-21/2027-03-25). llms.txt: 'Q1 2026,
starting 8 January 2026' -> 'Q1 2027, starting 21 January 2027' (retires
the 8-Jan vs 22-Jan conflict). sitemap.xml: bump lastmod on the three
bootcamp <loc> entries to 2026-10-30. No structural/CSS change.
The blog was an island: zero post→page links to hosting/upgrade/support/courses,
and only one page→post link in the whole site. Posts ranked for nothing while the
old blog host ranked for our long-tail queries.
Posts → pages (26 new in-body contextual links across 17 posts):
each post now carries one or two links to the page that answers its reader's next
question — odoo-hosting-2027, odoo-community-upgrade, support, the workshops hub,
the n8n masterclass, and the plan pricing on index.html#pricing.
Pages → posts (7 pages, one contextual link each):
index.html (OCA section), odoo-hosting-2027, odoo-community-upgrade, support,
workshops/, odoo-n8n-masterclass/, odoo-erp-automation-bootcamp/ now link into the
post that fits their own subject. The clearest miss is fixed — the
community-vs-enterprise-cost post and the odoo-community-upgrade page now link
to each other.
Anchors wrap existing sentences where the text already said the thing; four short
linking sentences were added (index OCA section, support first-steps FAQ, masterclass
lessons, bootcamp included section). No new components, no related-posts widget,
in-body anchors only. English pages only; DE/ES untouched (translator follow-up).
New post blog/posts/oca-paraguay-internationalization-hackathon.html (News,
2026-10-29), body verbatim from the supplied source (sha256 cca84c45...),
chrome copied from how-to-install-odoo-modules-using-git.html with the inline
style trimmed to the figureless variant. Adds the newest-first entry to
blog/index.json (16 -> 17), one 0.6 <url> to sitemap.xml (39 -> 40) and the
top line of the llms.txt blog list (16 -> 17).
Plausible's click handler walks up from the clicked element and bails out the
moment it meets a <form>, so a plausible-event-name=Buy class on a
<button type="submit"> inside a plain form never fires Buy — the tracker sends
its automatic "Form: Submission" event instead and the goal records nothing.
Measured live on /odoo-erp-automation-bootcamp/ before this change:
{"n":"Form: Submission"}.
- odoo-erp-automation-bootcamp/index.html (3 CTAs): class moved from the
<button type="submit"> to the enclosing <form>.
- australia.html (2 CTAs): same move.
- de|es/odoo-erp-automation-bootcamp/index.html (3 forms each): the buy forms
carried no Buy marking at all, so the DE and ES bootcamp sales were invisible
to the goal. Class added to the <form>.
- odoo/upsell.html: the three .plan-btn buttons drive the same JS buy path and
were unmarked. A class on a plain <button> (not a form submit) does fire, so
it was added there and browser-verified. The disabled Trial button is left
unmarked.
Class attribute only: every button label, every form action and every basket
payload is byte-identical. No CSS or JS rule references the class.
Verified in a real browser (fetch shimmed, tracker's localhost/automation gates
bypassed): EN bootcamp 3/3, australia 2/2, DE 3/3, ES 3/3, upsell 1/1 clicks
each send exactly one {"n":"Buy"} to /api/event and never "Form: Submission".
Browser measurement on the live page: with plausible-event-name=Buy only on the
<button type="submit">, clicking it sends Plausible's automatic "Form: Submission"
event and no "Buy". With the class on the enclosing <form> the click sends exactly
one {"n":"Buy"}. The button keeps its class as specified; the form now carries it as
well, so the Buy goal fires once per click.
Same behaviour measured on the live bootcamp page (its three CTAs also emit only
"Form: Submission") - left untouched, reported as a site-wide follow-up.
Two directory URLs Google still has indexed were 404ing after the old site was
torn down. Both are rebuilt as trailing-slash directories (same model as
/odoo-erp-automation-bootcamp/, so the non-slash form 301s to the slash form).
odoo-n8n-masterclass/index.html
- copy from copywriter, structure and page-scoped CSS reused from the bootcamp page
- next session: four Thursdays of February 2027, 17:00 Central Time (hero eyebrow,
price and booking, JSON-LD startDate 2027-02-04)
- booking: native POST form to 20.odoo4projects.com/shop/post_basket, basket
product_id 44, zero JavaScript, three CTAs
- n8n environment corrected: we do not hand out n8n workspaces; the participant
uses n8n's own free plan. Body, hero fact, FAQ and JSON-LD say so plainly.
- JSON-LD Course + FAQPage; Plausible snippet; FAQ and Buy markings
workshops/index.html
- hub page, two cards (masterclass USD 120.00 / bootcamp USD 465.00), pull quote,
routing section, FAQ, comparison table
- the bootcamp card carries no term or date; the "ERP Automation Quick Wins" free
offer is gone entirely (no card, no 1-hour format, no /free-webinar link)
- JSON-LD ItemList; Plausible snippet; FAQ markings
SEO: canonical to the slash URL, OG tags, the bootcamp's font/style.css loading
pattern, sitemap.xml (+2 urls, lastmod 2026-10-28, bootcamp lastmod bumped for the
two links added there), llms.txt (+2 lines). Cross-linked both ways and both to the
bootcamp; the bootcamp page gains the inbound links.
English only. No DE/ES twins yet - translator follow-up.
Checks: verify-html.py 0 problems, check-links.py clean, check-links-2026-10-28.py
56 PASS / 0 FAIL, verify-plausible-events 71 PASS / 0 FAIL, JSON-LD parses.
The snippet was on 10 of 40 pages, so the Trial / FAQ / Buy / Custom Quote /
search posts / Project Plan goals were inert everywhere else. It now sits
verbatim, in the same <head> position as index.html, on all 39 visitor-facing
pages. email/trial-confirmation.html is deliberately left out (email template).
FAQ: the five <details class="bc-faq"> <summary> elements on the bootcamp page
and its DE/ES twins now carry plausible-event-name=FAQ, and the 15
.faq-question buttons on de/es/support.html get the marking they were believed
to already have. No goal renamed: one FAQ goal across all languages.
No markup, copy or behaviour change, and no third-party request beyond the
snippet itself. verify-html.py PROBLEMS 0; check-links.py OK; event checker
59 PASS / 0 FAIL; impeccable detector +0/-0 findings on the changed files.
New post at blog/posts/how-to-install-odoo-modules-using-git.html
(2026-10-28, area "deep dive"), body pasted verbatim from the boss's
supplied block (7 h2, two figures in place). Chrome copied from the
newest post (odoo-community-is-going-online-on-odoo4projects.html:
main.blog-post / span.badge / div.post-meta / img.post-image /
div.post-body / a.back-link), style block trimmed to the rules this
page uses plus the ol/figure/figcaption rules its body needs.
Additive wiring: og:image + twitter:card, post-image after post-meta
(alt = h1), BlogPosting JSON-LD with dateModified.
blog/index.json gets the entry first (newest-first, key order
id,date,area,headline,teaser,link,image) by text splice - 15 -> 16
posts; sitemap.xml one new 0.6 blog url - 35 -> 36 <loc>; llms.txt one
new line at the top of the post list.
The visible FAQ answer used arrows while the structured-data copy spelled the
chain out with 'to', so acceptedAnswer.text was not verbatim-present on the
page. FAQ parity check now reports 8/8 on EN/DE/ES.
build-i18n.py's rebase_paths() only re-pointed a root-absolute page link when it
contained ".html", so extensionless directory links stayed on the EN route even
when the language had its own copy (`/odoo-erp-automation-bootcamp/` on all four
DE and ES pages, and `/` on the DE/ES bootcamp pages). Same guard as the .html
branch: localise only when <lang>/<path>/index.html exists.
Re-applied the rebasing to the 8 already-built DE/ES pages (work/i18n/
relocalise-links.py) - a full re-transform is not safe for them because the
switcher-slot/hero rework means repo pages are not raw transform output. 18 refs
in 8 files. The DE/ES homepages were the stale case: their body links were built
before support.html/odoo-hosting-2027.html existed in that language and were
never revisited.
check-links.py now flags an EN-route page link whose language has its own copy:
0 such links on all 10 DE/ES pages after the rebuild. test-build.py 107 passed;
verify-html.py 0 problems.
Copy-only mirrors of odoo-community-upgrade.html built by translator against the
post-R1-R5 English (EN af27098a, commit 2fe0c31), rendered through work/i18n/build-i18n.py:
<html lang>, canonical, 4-way hreflang cluster (en-US/de/es/x-default), og:url,
/static/lang.js and 15 rebased refs. No EN page change.
- sitemap.xml: 33 -> 35 <loc>, both new pages at priority 0.9
- llms.txt ## Languages: DE + ES lists now name /<lang>/odoo-community-upgrade.html
- checks: check-links.py 0 broken, verify-html.py 0 problems (tag balance, JSON-LD,
sitemap XML), JSON-LD FAQPage answers verbatim in the visible FAQ 8/8 per page
Retrospective ICP 1 review (kanban t_03b62410) found the EN page made a
non-technical owner-operator hesitate: the offer sat behind four blocks of
shell commands, the hero ended on "a developer", one hero line carried an
untraced claim, there was no route to a number, the FAQ printed the
OpenUpgrade data-loss warning against our own fresh-database service, and
the "restaurant meal" simile was unitless.
R1 move "The ODOO4projects way" above the process and the commands; swap the
two band classes so the page still alternates; keep the offer's code cards
legible on the dark band; reword the §5 intro and §6 opening sentence.
R2 hero subhead reworded, dropping the untraced "costs you your history".
R3 "See the plans" route added inside §6, with the honest "not priced on that
page" clause. No price, no plan hero, no Offer node.
R4 FAQ "What happens to my history…" answer rewritten (JSON-LD acceptedAnswer
kept verbatim-equal to the visible text).
R5 "restaurant meal" clause deleted.
Band-aware text colours added for the elements the class swap re-parented
(process list on light, technical section and its notes on dark, the §6 link).
No other content changed: H1, CTAs, code blocks (all 4 byte-identical),
Sweat Equity, the 8 FAQs and the JSON-LD shape are untouched.
Four follow-up defects found beyond the 2026-10-27 SEO audit (no copy changes):
1. legal.html canonical was https://ODOO4projects.com/legal — uppercase host AND a
path that 404s. Now https://odoo4projects.com/legal.html. The file's og:url keeps
its path /legal but its host is lowercased (path fix awaiting a decision).
2. The no-"4" domain typo in reader-visible <pre><code> body text of
blog/posts/{guide-migrate-enterprise,speed-run-deploy,my-first-post}.html:
odooprojects.com -> odoo4projects.com. Domain string only, no other wording.
3. Lowercase host in every machine-read value: the robots.txt Sitemap line, the
homepage + de/ + es/ Organization JSON-LD url / contactPoint.url / offers[].url,
legal.html og:url, and the four absolute self-URL hrefs in blog posts,
odoo/support.html and email/trial-confirmation.html. Display text untouched
(support@ODOO4projects.com stays as written).
4. One BlogPosting per blog post (15), every value taken from the page itself:
headline = its <h1>, datePublished = its visible post-meta date, url = its own
canonical, author/publisher = the Organization the site already publishes,
image = its og:image where one exists (omitted on the 10 posts that have none).
Empire ">950.00", FAQPage on support.html, the blog-chrome change and the bootcamp
date conflict are untouched — decisions pending with the user.
New flat landing page for Australian buyers, built from the approved Phase 2
copy with the five buyer-lens changes (R1-R5) applied. English only, no hreflang.
- australia.html: hero, five sections, four-plan pricing table (On the Rise
heroed), 7-question FAQ, final CTA band; house CI (midnight/brass/cream,
Marcellus + Inter), page-scoped CSS only.
- Buy CTAs are native POST forms to the live checkout with Australia (Sydney)
pre-selected (basket product_id 4, verified against the homepage card).
- images/city-sydney.webp: city-sydney.jpg resized 6720x2240 -> 1920x640,
9.8 MB -> 131 KB.
- index.html: the Sydney server card now links to the new page.
- sitemap.xml + llms.txt: page registered.
Evidence: verify-html.py PROBLEMS 0, verify-australia-page.py PROBLEMS 0,
JSON-LD parses, impeccable detect run (findings recorded).
The post carried the planned/September copy ("scheduled to go online on the
06.10.2026"). Body replaced verbatim with the supplied live copy (1379 bytes,
2 h2, 221 words), post-meta -> October 27, 2026 · News, meta/og description
refreshed, title/canonical/chrome/og:image unchanged.
- blog/index.json: the existing entry moved to first, date 2026-10-27,
teaser = first 120 chars of the new first paragraph (text splice).
- sitemap.xml: one new <url> as the first 0.6 blog entry (24 -> 25 <loc>).
- work/verify-html.py: page added to FILES (in webmaster/work, not in repo).
The user reversed the earlier 'publish it' decision: the trial endpoint
paragraph (and its now-dead .ai-endpoint CSS rules) come off the AI
section. The 30-day trial line in 'Some other information' stays.
- HTML: <p class="ai-endpoint"> removed (was the AI section's last para)
- CSS: .ai-endpoint, .ai-endpoint a, .ai-endpoint a:focus-visible deleted
(the focus-visible selector dropped from its group; group otherwise intact)
- grep ai-endpoint 0, mcp/trial 0, competitor grep 0
- verify-html.py PROBLEMS: 0; impeccable detect 10 (no new finding)
The user asked to drop the 'Start a free trial' button from the hero.
The hero now ends with the supporting paragraph; the now-dead
.andrew-actions rule (its only consumer) goes with it. The 30-day trial
line in 'Some other information' is untouched.
Diff: 8 deletions, one file. verify-html.py PROBLEMS: 0; competitor
grep 0; impeccable detect 11 -> 10 (the removed finding was the CTA
button's low-contrast), no new findings.
git mv for-andrew/index.html odooityourself.html (history kept) and drop the
now-empty for-andrew/ directory, so the page serves at the flat root URL
https://odoo4projects.com/odooityourself.html with no directory redirect.
Update every reference to the old path: canonical, og:url and the WebPage
JSON-LD url in the page; the sitemap.xml <loc> (replaced, lastmod 2026-10-25,
still 24 entries); and the llms.txt key-page line. Copy, CSS, assets and all
other pages are untouched.
/for-andrew/ was never announced or indexed, so it is deliberately left as a
404 — no redirect added.
Six-section briefing page (hero vision, 2x3 feature grid, AI/MCP section
with art-director's CI SVG + mobile variant, management console with the
real screenshot, pricing with Empire quoted and no number, some other
information with the demo checklist). Zero competitor mentions.
Reuses the existing bootcamp hero assets; adds /images/andrew/ SVGs,
sitemap + llms.txt entries.
The translated pages now carry the same four footer items as the three English pages:
ODOO Bootcamp -> /odoo-erp-automation-bootcamp/, ODOO Hosting in 2027 (DE) / ODOO Hosting en
2027 (ES), Support/Soporte, Rechtliches/Aviso legal. Deleted: Startseite/Inicio,
Datenschutz/Privacidad, AGB/Terminos, Kontakt/Contactenos.
Rebuilt with work/i18n/build-i18n.py from the translator's six files (work/i18n/I18N-BUILD-PLAN.md
section 5), then: place-hero-slot.py for de|es/index.html, place-slot.py for the two 2027 pages,
insert-hero-i18n.py for the two bootcamp pages (the hero CSS is language-neutral chrome held
outside the translated source). Nothing else changed: git diff 31c02b1 over these six files is
the footer list and nothing else - bootcamp hero, three buy forms (product_id 12), 465.00,
selector and hreflang clusters untouched.
Checked: verify-html.py PROBLEMS: 0; node --check static/lang.js OK; verify-footer-useful-links-de-es.py
PROBLEMS: 0; test-build.py 88/88; test-lang.js 31/31; check-links.py BROKEN/MISPLACED: 0;
impeccable detector identical finding set on all six pages (0 changed lines).
The list becomes the same four items in all three English pages:
ODOO Bootcamp -> /odoo-erp-automation-bootcamp/ (new, first),
ODOO Hosting in 2027 -> the existing comparison page (renamed from
"Odoo hosting 2027", added to the homepage which lacked it),
Support, Legal. Home / Privacy / Terms / Contact us removed.
Content-only edit inside the existing <ul>; each page keeps its own
href style. No CSS, no markup pattern change, no other footer column
touched. de/ and es/ untouched (translator owns those in parallel).
Checks: verify-html.py PROBLEMS 0; work/verify-footer-useful-links.py
PROBLEMS 0; impeccable detect finding set identical to HEAD (87
anti-patterns / 100 advisories, all pre-existing); browser check at
1280px and 375px on all three pages: 4 items, no overlap, no
horizontal overflow.
The uniform .72 -> .55 scrim composited the whole hero band to luma ~37 and the
right zone to ~35, against a flat midnight of 25.4: a dark photo made darker.
The scrim is now two zones on the same photo:
.78 0%-88% (copy zone: kicker, H1, value line, the four facts, the CTA)
ramp 88%-94%
.25 94%-100% (the lit skyline, no copy over it)
.78 is the smallest flat alpha that holds every text block over its floor
(body 4.5:1, large 3.0:1) at 1920/1440/1024/390 in EN, DE and ES - measured on
the rendered page, not modelled. 88% is where the ramp has to start: the longest
translated fact line (DE, 1024px) reaches 89.3% of the hero.
Measured before -> after (full-range mean luma of the rendered band):
whole band 37.3 / 36.8 / 37.6 / 37.3 -> 33.8 / 33.6 / 34.1 / 34.0
right 88-100% 34.8 / 35.9 / 35.5 / 38.4 -> 38.7 / 40.2 / 39.4 / 43.4
plateau 94-100% -> 41.4 / 44.2 / 42.7 / 46.9
(= 1.63x-1.91x the flat-midnight 25.4, was 1.37x-1.51x)
Worst text block: 4.87:1 (was 3.13:1). No copy, buy form, JSON-LD, selector,
hreflang or price touched: de/ and es/ differ ONLY by this inserted hero block.
The user asked for a photo in the header of /odoo-erp-automation-bootcamp/.
Adds our own London night aerial (art-director's chosen cut, measured) as the
page-scoped .bc-hero background, under a midnight gradient scrim
(rgba(13,27,46,.72) -> .55, inside CI §5's band) and over the --midnight base.
- desktop: bootcamp-hero-desktop-1920.webp, cover, background-position 50% 25%
- <=640px: bootcamp-hero-mobile-900.webp, background-position 50% 50%
- the global .hero::after 50% wash is switched off on this page only, so the
scrim above is the one that was measured
No copy, buy form, JSON-LD, language selector or hreflang change. One CSS block,
two images, nothing else. verify-html.py PROBLEMS: 0; impeccable detect finding
set identical to HEAD; check-links 0. Worst p05 over rendered type 5.64:1.