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

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

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

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

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

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

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

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

- static/expert-image.js: the route AND its query-parameter name in ONE
  constant (id, confirmed by measurement; unverified in the brief), plus an
  optional version token used only when the payload carries one.
- Directory card: <img src="…odoo_experts_image?id=<id>">, width/height
  reserve the 72x72 box, decoding=async, eager for the first row and lazy
  below it; onerror swaps in the SAME monogram a card with no photo shows,
  so a failing image looks exactly like today.
- The static build-time card loses its 185 KB inline base64 photo and shows
  a monogram: it is the no-JavaScript fallback, where there is no onerror to
  fall back with. Page 341,345 -> 158,428 bytes.
- /freelancer-edit/: the photo block is a look, not a field (no file input),
  and the whole-record save sends NO `Image` key at all.
2026-11-03 22:59:53 +00:00
webmaster 9d14652a57 hide [hidden] site-wide: one [hidden] { display:none !important } in style.css
A filtered-out directory card stayed visible. The card is targeted by an
id-based ready-state selector (`.js #directory[data-dir-state="ready"]
#dirGrid > .dev-card`, two ids), which outranked the old per-element patch
`.dev-card[hidden] { display:none }` — so `card.hidden = true` set the
attribute and every card stayed on screen while the count line correctly
read "Showing 1 of 4".

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

Measured in a real browser (local harness, stubbed webhook; no record created,
no probe write) — computed display + geometry, never the `hidden` property:
 - filtered to one card: the other three report display:none and height 0;
   the visible set (1) equals the count line ("Showing 1 of 4") — 1 == 1
 - HEAD (pre-fix) reproduces the defect: 3 cards hidden=true yet display:flex,
   height 599.38 each; 4 visible vs the count's 1
 - pager: display:none height 0 with one page; display:flex height 66.83 with
   12 cards (Page 1 of 2); page 2 hides page-1 cards (display:none)
 - modals closed: display:none height 0; load state: cards 0, pager/empty
   display:none, filter row visibility:hidden by design
 - 375 px: same result, no horizontal overflow; avatar ask-row top identical
   across a row (2053.97 / links 2086.91)
2026-11-03 22:34:39 +00:00
webmaster 634f4fcdf1 directory card: distribute the content so the ask row lines up across every card
The card reads as three blocks — the head/clients/focus block at the top,
the description centred in the free space, and the charts + ask row + links
block sitting on the card's floor. Two CSS lines and two renderer lines do it:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The admin form is deliberately NOT changed: it keeps loading the record's real
values (populate()), and 0 for a brand-new record — an operator editing only a
description must not silently write a 3/3/3/3/3 profile. Its "leave all five at 0
to leave the profile untouched" escape is intact.
2026-11-03 13:41:42 +00:00
webmaster 68dd48e506 fix(freelancer-directory): card + forms — drop the count hedge, the 15-point sentence and the axis digits; kill every ", as declared"; rename Focus→Skill profile; languages on the shared chart; OCA becomes an "OCA Member Profile" badge; OCA filter re-added off OCA_link
- card count reads "4 clients on ODOO4projects" (no "— a number we recorded"); the
  lede says "our own count" for the same reason. Admin form hint left alone (it is
  an instruction to the operator, not a claim to the visitor).
- removed "15 points over five areas — nobody can be maximum in everything." from
  the card (build + runtime); the forms keep their own explanation.
- removed the visible axis digit (dev-axis-val) from both renderers; the five
  squares stay and each row carries its value as role="img" + aria-label="N of 5".
- removed every ", as declared" (10 on the served page) and rewrote the trust block
  so it no longer promises a label that no longer exists.
- "Focus profile" -> "Skill profile" on the card (build + runtime) and in both form
  legends. The separate `Focus` payload field is untouched.
- languages render through the same dev-axes chart as the skill profile (one
  component, two labels; no digit either).
- OCA becomes a clickable "OCA Member Profile" badge in the card header, once; the
  links row no longer carries it. Same predicate drives the re-added OCA filter
  (OCA_link under https://apps.odoo-community.org/community/), which is a facet in
  the existing filter row, renders only when the data supports it, and filters
  rather than ranks.
2026-11-03 13:38:12 +00:00
webmaster 5aa4782de7 fix(freelancer-directory): remove the Post-a-request section and every relay promise; state the service is free both sides; count = clients, one label; render every confirmed record
- Delete the whole "Post a request" section (heading, "Not open yet" note, pick-list/
  brief/email form) and the JS+Css that existed only for it: initRequestForm(),
  wireRelay("requestFormWrap"…), initRequestForm(), .dir-pickfield/.dir-picklist/.pick.
  build-directory.py: drop PICK_START/PICK_END, render_picklist() and its call.
- Rewrite every relay promise to direct contact (trust block, listing note, final CTA);
  keep the "we are not a party to the contract" point.
- Trust block: state no fee to the freelancer and no fee to the client.
- Count means client instances: single label "clients on ODOO4projects" (no projects).
- No confirmation gating: comment now states the backend only exposes confirmed profiles.
- Left with a comment, not deleted: ODOO_EXPERTS_QUOTE_WEBHOOK (it gates the dark
  anonymous-contact modal, which was out of scope). The modal stays dark (0 buttons).
- Kept: id=1 junk record; the inlined base64 photo (flagged, not changed).
2026-11-03 13:33:53 +00:00
webmaster 99245764a9 feat(freelancer-directory): 5-axis focus profile, 3 numeric languages, OCA from the link; drop odoo_versions
The user's spec (2026-11-03): "remove the ODOO versions. people are lying anyways";
"15 points to distribute between functional system modules integration ai"; "the
languages should be self assessed with 0 can not speak and 5 native. we have the
languages english german and spanish"; "lets limit the focus on the form to 70 chars";
"lets remove the member OCA question … use the OCA link to determine if someone is a
member … show the oca link on top of all links and make it apart with a color, also
dont show the link itself just a button or a chip"; "the only quality signal we can give
and verify is how many projects the freelancer brought to our platform"; "no sorting we
keep the no rank policy"; "get the sort on the page out."

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

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

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

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

QC: verify-html.py 0 · check-inline-js OK · test-directory-render.py PASS · competitor QC
exit 3, 74 site-wide pre-existing, 0 in our files · impeccable detect --no-advisory
dir 14 (0 added) / admin 7 (0 added) · 375 px 0 overflow · no form submitted by hand.
2026-11-03 13:26:26 +00:00
webmaster 5ce14ea666 fix(freelancer-directory): render an uploaded photo (data:image/), not only a linked URL
Both card renderers emitted an <img> only for a URL, so a record whose Image is an
inline data:image/... URI (what the upload step stores today) fell through to the
monogram. The avatar check now allowlists exactly two schemes — https:// and
data:image/ — in build-directory.py and in the page script; anything else (empty,
junk like "gineoirn", another scheme) still falls back to the monogram, and the
value never reaches src unchecked. The lede copy is corrected to match: a photo
shows where the freelancer gave a link OR uploaded an image.
2026-11-03 13:04:06 +00:00
webmaster e067fec168 feat(freelancer-directory): full-text search, OCA/projects/language filters, standardised languages
- Search box: substring, case- and accent-insensitive, over Name, description,
  Focus, specialities and language names; combines with every filter (AND).
- Filters: OCA membership (any / member / not a member), Language (canonical
  names only), plus Country/Speciality; a project MINIMUM ('N+ projects'),
  rendered only when a threshold can narrow the list.
- Languages standardised: a closed ISO 639-1 checklist on both forms, with the
  proficiency as a SEPARATE axis (language_levels, 'Spanish:beginner'). A stored
  'Spanish - beginners' now maps to the language 'Spanish' and never becomes a
  filter option.
- Fix: .dev-card/.dir-filters set display, which beat the UA [hidden] rule, so
  filtered-out cards and the JS-off filter bar stayed visible.
- Deterministic order by Name (the endpoint's order alternates between reads).
- Card states plainly that a photo shows only when it is a link (the stored
  value is base64 and is not rendered); admin Image field says the same.
- specialities stays free text.
2026-11-03 12:57:51 +00:00
webmaster ff42a242db fix(freelancer-directory): render the list from the webhook at runtime
A record saved after the last build never reached the page: the list was
build-time HTML only, so the new freelancer (id=2) stayed invisible until
someone re-ran the build. That is the cause, not the symptom.

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

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

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

Constants stay empty; both forms stay dark.
2026-11-03 12:11:03 +00:00
webmaster 4d5d59f3bd fix(freelancer-directory): apply the ICP re-review — claims sweep, F1 gate
- F2: 'We publish exactly what you write' -> 'We publish what you write —
  after we check it: your photo, your email address and for spam' (the page's
  own consent line + photo check + spam gate made the absolute false)
- claims sweep: drop the 'no claim we cannot back' meta-claim and the
  'not through an agency' absolute; state the individual rule as the
  declaration we ask for, and make the card's individual tag read
  '(as declared)' to match the OCA tag
- F3: directory lede now says the individual rule is a declaration, not a fact
- F4: 'How this works' 110 -> 44 words (three short lines, no relay promise)
- F5: count framed as 'a number we recorded' (owner-typed, not derived);
  photo hint agrees with its '(optional)' label; kicker 'ODOO COMMUNITY
  FREELANCERS' -> 'ODOO FREELANCERS' and the meta/og drop 'Odoo Community'
  so an Enterprise client is not invited to self-exclude
- F1: the request and invite forms SHIP DARK — their endpoint constants are
  empty and the forms stay hidden behind a plain 'not open yet' line
  (no submit, so no false confirmation); the invite copy carries the
  one-click opt-out line the ICP asked for before the form returns
2026-11-03 12:04:04 +00:00
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
webmaster 3e2a7a71a6 feat(bf): show the code on arrival and buy in one click on /odoo-black-friday/
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.
2026-11-01 23:10:54 +00:00
webmaster 4801e09466 tracking(bf): mark the offer CTAs on the two Black Friday pages as Buy
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).
2026-11-01 18:45:42 +00:00
translator 02b7b4f18c policy(2026-11-01): mirror the three commercial promises into DE/ES
- de|es/index.html: order widget #orderPlanTax '+ MwSt.'/'+ impuestos'
  -> 'inkl. MwSt.'/'impuestos incluidos' (static span, no JS writes it)
- de|es/support.html: add the AI-agent urgent + 24h email support sentence
- de|es/odoo-erp-automation-bootcamp/index.html: add the seat terms
  (transferable any time, cancellable up to two weeks before 21 Jan 2027)
- terms.html has no DE/ES page (legal pages held back) -> nothing to mirror
2026-11-01 16:40:39 +00:00
webmaster 3d8fe194be policy(2026-11-01): publish the three new commercial promises
- terms.html: cancel any time + refund unused time (pro rata); delete the
  no-refund sentence and the 'no refund promise beyond' clause
- terms.html: prices are tax included (was 'plus tax where it applies')
- index.html: order widget #orderPlanTax text '+ tax' -> 'tax included'
  (id/class kept; static text, no JS reads or writes it)
- support.html: add the AI-agent-urgent + 24h email support promise,
  bot still scoped to quick fixes
- odoo-erp-automation-bootcamp/index.html: seat transferable any time,
  cancellable up to two weeks before the 21 Jan 2027 start
2026-11-01 16:33:27 +00:00
translator db8b2b1c63 price(bootcamp): DE/ES pages print USD 595.00 to match EN
Mirror of 9715a3b into the two translated bootcamp pages. Each page's three
surfaces now read 595.00: the three buy buttons, the JSON-LD Offer `price`
field, and the `.bc-price` sentence. Matches the shop product, so the DE/ES
page can no longer lose the sale at the cart.

Price-only: the term (Q1 2027), the dates, the six-seat cap and the "one year
of hosted Odoo Community Edition" wording are untouched. Currency stays USD,
numeral format unchanged per page.

Verified (see translator/work/verify-price-change.py):
- HEAD vs worktree per page identical on tag sequence, attribute list,
  <style> bytes, non-JSON-LD <script> bytes and JSON-LD shape -> text-only.
- 595.00 x5, 465.00 x0, no stray `465` token on either page.
- the three buy forms label-normalised byte-identical to EN.
No DE/ES workshops, black-friday or what-can-i-use-odoo-for translations
exist; llms.txt already carries 595.00.
2026-11-01 14:58:40 +00:00
webmaster 9715a3b01f price(bootcamp): EN pages print USD 595.00 to match the shop
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).
2026-11-01 14:55:26 +00:00
webmaster b42f754525 Bootcamp: hand cohort term + language to the basket (info field)
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.
2026-11-01 14:47:30 +00:00
webmaster ada11e42cf chore(odooityourself): remove the page at the user's request
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.
2026-11-01 14:24:41 +00:00
webmaster f457858a0f fix(bf): strip the last off-panel mentions of the discount code
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.
2026-11-01 14:24:39 +00:00
webmaster 937e79b59d feat(odoo-community-ai): publish /odoo-community-ai/ as a draft
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.
2026-11-01 13:17:27 +00:00
webmaster 137bd1a2ae feat(staging): inbound links so /odoo-staging-environment/ is not an orphan
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.
2026-11-01 09:32:52 +00:00
webmaster 3dad3c61db feat(staging): publish /odoo-staging-environment/ guide page
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.
2026-11-01 09:31:31 +00:00
webmaster a7a428c66e Stop submitting /odooityourself.html to search
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.
2026-10-30 14:31:58 +00:00
webmaster 5c3b66c90b fix(bf): keep the code out of the served source — strip HOST27 from the two build comments
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.
2026-10-30 14:29:34 +00:00
webmaster 91e4162230 fix(bf): keep HOST27 behind the reveal — drop 'What the code does' section and every off-panel mention
- 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
2026-10-30 14:27:37 +00:00
webmaster 6c488a443f feat(seo): set the Black Friday share image on the two BF pages
/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.
2026-10-30 13:33:24 +00:00
webmaster 740e5650a9 chore(images): replace Black Friday 2026 social stills with the cream/midnight rebuild
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.
2026-10-30 13:33:23 +00:00
webmaster 185e7ab419 feat(black-friday): add the Bootcamp box beside the Hosting box
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.
2026-10-30 13:28:24 +00:00
webmaster 2a62ac871a fix(bf-pages): restore ODOO Bootcamp footer link; add HOST27 to the BF block
Both pages shipped (355c5d0) with a three-item footer; the site footer is a
four-item commitment. The /odoo-erp-automation-bootcamp/ entry is restored on
both pages, matching index.html / odoo-hosting-2027.html.

/odoo-hosting-offers/: the code HOST27 is added inside the date-scoped Black
Friday block only (BF-BLOCK-START..END), so it disappears with the block after
30 Nov. Title, meta description and H1 are untouched — the intent split holds.

verify-html.py: PROBLEMS 0. node --check script.js: OK.
2026-10-30 13:22:48 +00:00
webmaster 14029c1074 chore(images): host Black Friday 2026 social stills (PNG + WebP)
Three stills from the thumbnail-designer render pipeline, copied byte-identical
(md5 verified against the handoff) into images/black-friday/ so the two BF
pipeline records can reference stable public URLs. WebP siblings added to match
how the site serves images. Not referenced from any page yet — hosting only.

  bf2026-hosting-og.png                 1200x630
  bf2026-hosting-linkedin.png           1200x627
  bf2026-bootcamp-lastcall-linkedin.png 1200x627
2026-10-30 13:22:46 +00:00
78 changed files with 11675 additions and 422 deletions

No files matched your search

+6 -4
View File
@@ -28,8 +28,7 @@
onload="this.media='all'" onload="this.media='all'"
/> />
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript> <noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="stylesheet" href="style.css" media="print" onload="this.media='all'" /> <link rel="stylesheet" href="style.css?v=061517baef" />
<noscript><link rel="stylesheet" href="style.css" /></noscript>
<style> <style>
/* ---- Australia landing page (page-scoped; additive) ---- */ /* ---- Australia landing page (page-scoped; additive) ---- */
.cmp-sr-only { .cmp-sr-only {
@@ -392,7 +391,7 @@
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}}; window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init() plausible.init()
</script> </script>
<script defer src="/static/lang.js"></script> <script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head> </head>
<body> <body>
<!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. --> <!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. -->
@@ -408,6 +407,7 @@
</div> </div>
<!-- Hero --> <!-- Hero -->
<main>
<section class="hero cmp-hero"> <section class="hero cmp-hero">
<div class="hero-bg"> <div class="hero-bg">
<img <img
@@ -733,6 +733,7 @@
<!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page <!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page
carries no external icon dependency) --> carries no external icon dependency) -->
</main>
<footer class="footer"> <footer class="footer">
<div class="container"> <div class="container">
<div class="fgrid"> <div class="fgrid">
@@ -741,6 +742,7 @@
<ul> <ul>
<li><a href="/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li> <li><a href="/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li>
<li><a href="odoo-hosting-2027.html">ODOO Hosting in 2027</a></li> <li><a href="odoo-hosting-2027.html">ODOO Hosting in 2027</a></li>
<li><a href="/odoo-community-developers/">Odoo Freelancer Directory</a></li>
<li><a href="support.html">Support</a></li> <li><a href="support.html">Support</a></li>
<li><a href="legal.html">Legal</a></li> <li><a href="legal.html">Legal</a></li>
</ul> </ul>
@@ -764,7 +766,7 @@
<a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a> <a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a>
<a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a> <a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a>
</div> </div>
<form class="footer-contact-form" id="footerContactForm" action="mailto:hello@odoo4projects.com" method="post" enctype="text/plain"> <form class="footer-contact-form" id="footerContactForm" action="mailto:support@odoo4projects.com" method="post" enctype="text/plain">
<label class="cmp-sr-only" for="footerContactEmail">Your email address</label> <label class="cmp-sr-only" for="footerContactEmail">Your email address</label>
<input type="email" id="footerContactEmail" name="email" placeholder="Your email address" required /> <input type="email" id="footerContactEmail" name="email" placeholder="Your email address" required />
<div class="footer-textarea-wrap"> <div class="footer-textarea-wrap">
+36
View File
@@ -1,5 +1,41 @@
{ {
"posts": [ "posts": [
{
"id": "your-agent-can-run-your-odoo-hosting",
"date": "2026-11-05",
"area": "news",
"headline": "Your Agent Can Run Your Odoo Hosting: the MCP Server Is Live",
"teaser": "This is the piece of the Odoo 20 update we are asked about most: an MCP server that lets your agent work on your Odoo ho",
"link": "blog/posts/your-agent-can-run-your-odoo-hosting.html",
"image": "https://image.office.odoo4projects.com/img/20_4server.png"
},
{
"id": "freelancer-directory-soft-launch",
"date": "2026-11-04",
"area": "news",
"headline": "Our freelancer directory opens on 1 December. Help us get the signup right",
"teaser": "We need your help! We are opening our Odoo Community Freelancer directory on 1 December. Before real clients start brows",
"link": "blog/posts/freelancer-directory-soft-launch.html",
"image": "https://image.office.odoo4projects.com/img/freelance_platform.png"
},
{
"id": "odoo-20-is-live-on-our-servers-here-is-what-changed",
"date": "2026-11-03",
"area": "news",
"headline": "Odoo 20 Is Live on Our Servers — Here Is What Changed",
"teaser": "Two weeks. That is how long it took us to bring up Odoo 20 on our servers, with every new feature that comes with it. It",
"link": "blog/posts/odoo-20-is-live-on-our-servers-here-is-what-changed.html",
"image": "https://image.office.odoo4projects.com/img/ODOO_20_chatter.png"
},
{
"id": "from-a-spreadsheet-to-a-running-erp",
"date": "2026-11-02",
"area": "news",
"headline": "From a Spreadsheet to a Running ERP in Ten Thursday Sessions",
"teaser": "Most small businesses do not run on an ERP. They run on a spreadsheet, a messaging group, and one person's memory. It wo",
"link": "blog/posts/from-a-spreadsheet-to-a-running-erp.html",
"image": "https://image.office.odoo4projects.com/img/screencast.png"
},
{ {
"id": "oca-paraguay-internationalization-hackathon", "id": "oca-paraguay-internationalization-hackathon",
"date": "2026-10-29", "date": "2026-10-29",
@@ -16,7 +16,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700;800&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
<link rel="stylesheet" href="../../style.css" /> <link rel="stylesheet" href="../../style.css?v=061517baef" />
<style> <style>
body { background: #0d1b2e; } body { background: #0d1b2e; }
.blog-post { max-width: 720px; margin: 0 auto; padding: 3rem 1.5rem 6rem; color: #171c26; background: #faf7f1; min-height: 100vh; } .blog-post { max-width: 720px; margin: 0 auto; padding: 3rem 1.5rem 6rem; color: #171c26; background: #faf7f1; min-height: 100vh; }
+4 -2
View File
@@ -33,7 +33,7 @@
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/> />
<link rel="stylesheet" href="../../style.css" /> <link rel="stylesheet" href="../../style.css?v=061517baef" />
<style> <style>
:root { :root {
--primary: #714b67; --primary: #714b67;
@@ -134,6 +134,7 @@
</script> </script>
</head> </head>
<body> <body>
<main>
<div class="container"> <div class="container">
<div class="blog-header"> <div class="blog-header">
<a href="../../index.html#blog">&larr; Back to Blog</a> <a href="../../index.html#blog">&larr; Back to Blog</a>
@@ -323,6 +324,7 @@
<a href="../../index.html#blog">&larr; Back to all posts</a> <a href="../../index.html#blog">&larr; Back to all posts</a>
</div> </div>
</div> </div>
<script defer src="/static/agent-odoo4projects/agent.js"></script> </main>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body> </body>
</html> </html>
@@ -0,0 +1,83 @@
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Our freelancer directory opens on 1 December. Help us get the signup right | ODOO4projects</title>
<meta name="description" content="We need your help! We are opening our Odoo Community Freelancer directory on 1 December. Before real clients start brows" />
<link rel="canonical" href="https://odoo4projects.com/blog/posts/freelancer-directory-soft-launch.html" />
<meta property="og:title" content="Our freelancer directory opens on 1 December. Help us get the signup right" />
<meta property="og:description" content="We need your help! We are opening our Odoo Community Freelancer directory on 1 December. Before real clients start brows" />
<meta property="og:url" content="https://odoo4projects.com/blog/posts/freelancer-directory-soft-launch.html" />
<meta property="og:type" content="article" />
<meta property="og:image" content="https://image.office.odoo4projects.com/img/freelance_platform.png" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="icon" href="../../images/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
<link rel="stylesheet" href="../../style.css?v=061517baef" />
<style>
body { background: #0d1b2e; }
.blog-post { max-width: 720px; margin: 0 auto; padding: 3rem 1.5rem 6rem; color: #171c26; background: #faf7f1; min-height: 100vh; }
.blog-post h1 { font-family: 'Marcellus', Georgia, serif; font-size: 2.4rem; font-weight: 400; margin: 0 0 0.5rem; color: #0d1b2e; }
.blog-post .post-meta { font-family: Inter, sans-serif; font-size: 0.82rem; color: rgba(23,28,38,0.6); margin-bottom: 2rem; }
.blog-post .post-meta .badge { display: inline-block; font-size: 0.68rem; font-weight: 600; letter-spacing: 0.15em; text-transform: uppercase; color: #c9a227; margin-right: 0.75rem; }
.blog-post .post-image { width: 100%; border-radius: 0.75rem; margin-bottom: 2rem; box-shadow: 0 2px 8px rgba(13,27,46,0.08); }
.blog-post p { font-family: Inter, sans-serif; font-size: 1rem; line-height: 1.7; margin-bottom: 1.2rem; }
.blog-post h2 { font-family: 'Marcellus', Georgia, serif; font-size: 1.6rem; font-weight: 400; margin: 2rem 0 0.75rem; color: #0d1b2e; }
.blog-post a { color: #8a6f1f; text-decoration: underline; }
.blog-post strong { font-weight: 600; }
.back-link { display: inline-block; margin-top: 2rem; font-family: 'Marcellus', Georgia, serif; font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.04em; color: #c9a227; text-decoration: none; }
.back-link:hover { text-decoration: underline; }
@media (max-width: 600px) { .blog-post h1 { font-size: 1.7rem; } .blog-post { padding: 2rem 1rem 4rem; } }
</style>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "Our freelancer directory opens on 1 December. Help us get the signup right",
"datePublished": "2026-11-04",
"url": "https://odoo4projects.com/blog/posts/freelancer-directory-soft-launch.html",
"image": "https://image.office.odoo4projects.com/img/freelance_platform.png",
"author": {
"@type": "Organization",
"name": "ODOO4projects",
"url": "https://odoo4projects.com/"
},
"publisher": {
"@type": "Organization",
"name": "ODOO4projects",
"url": "https://odoo4projects.com/",
"logo": {
"@type": "ImageObject",
"url": "https://odoo4projects.com/images/logo.png"
}
}
}
</script>
<!-- Privacy-friendly analytics by Plausible -->
<script async src="https://plausible.office.odoo4projects.com/js/pa-NhA7Rf-79VopXFQXhSS4e.js"></script>
<script>
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
</head>
<body>
<main class="blog-post">
<span class="badge">news</span>
<h1>Our freelancer directory opens on 1 December. Help us get the signup right</h1>
<div class="post-meta">November 4, 2026 &middot; News</div>
<img src="https://image.office.odoo4projects.com/img/freelance_platform.png" alt="Our freelancer directory opens on 1 December. Help us get the signup right" class="post-image" loading="lazy" />
<div class="post-body">
<p>We need your help!<br><br>We are opening our <b>Odoo Community Freelancer directory</b> on 1 December. Before real clients start browsing it, we want the signup to work, and we would like your help to get it right.</p>
<p>So this is a soft launch. Sign up for free and tell us how the signup went: where you got stuck, what you expected to find, what you would change. That feedback is what we fix before opening day.</p>
<p>Joining is free, for freelancers and clients both. The directory is where a freelancer describes their work, in their own words.</p>
<p>If you are a member of the Odoo Community Association, this is a chance to be seen alongside your community. Your profile card carries an OCA Member Profile badge, and a visitor can filter the directory by OCA membership. We mark it, and the choice of who to contact stays with the visitor.</p>
<p>Start here: <a href="https://odoo4projects.com/feedback/">odoo4projects.com/feedback</a></p>
</div>
<a href="../../index.html#blog" class="back-link">&larr; Back to the Blog</a>
</main>
</body>
</html>
@@ -0,0 +1,97 @@
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>From a Spreadsheet to a Running ERP in Ten Thursday Sessions | ODOO4projects</title>
<meta name="description" content="Most small businesses do not run on an ERP. They run on a spreadsheet, a messaging group, and one person's memory. It works — until that person is on holiday." />
<link rel="canonical" href="https://odoo4projects.com/blog/posts/from-a-spreadsheet-to-a-running-erp.html" />
<meta property="og:title" content="From a Spreadsheet to a Running ERP in Ten Thursday Sessions" />
<meta property="og:description" content="Most small businesses do not run on an ERP. They run on a spreadsheet, a messaging group, and one person's memory. It works — until that person is on holiday." />
<meta property="og:type" content="article" />
<meta property="og:image" content="https://image.office.odoo4projects.com/img/screencast.png" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="icon" href="../../images/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
<link rel="stylesheet" href="../../style.css?v=061517baef" />
<style>
body { background: #0d1b2e; }
.blog-post { max-width: 720px; margin: 0 auto; padding: 3rem 1.5rem 6rem; color: #171c26; background: #faf7f1; min-height: 100vh; }
.blog-post h1 { font-family: 'Marcellus', Georgia, serif; font-size: 2.4rem; font-weight: 400; margin: 0 0 0.5rem; color: #0d1b2e; }
.blog-post .post-meta { font-family: Inter, sans-serif; font-size: 0.82rem; color: rgba(23,28,38,0.6); margin-bottom: 2rem; }
.blog-post .post-meta .badge { display: inline-block; font-size: 0.68rem; font-weight: 600; letter-spacing: 0.15em; text-transform: uppercase; color: #c9a227; margin-right: 0.75rem; }
.blog-post .post-image { width: 100%; border-radius: 0.75rem; margin-bottom: 2rem; box-shadow: 0 2px 8px rgba(13,27,46,0.08); }
.blog-post p { font-family: Inter, sans-serif; font-size: 1rem; line-height: 1.7; margin-bottom: 1.2rem; }
.blog-post ul { font-family: Inter, sans-serif; font-size: 1rem; line-height: 1.7; margin-bottom: 1.2rem; padding-left: 1.5rem; }
.blog-post li { margin-bottom: 0.3rem; }
.blog-post a { color: #8a6f1f; text-decoration: underline; }
.blog-post strong { font-weight: 600; }
.back-link { display: inline-block; margin-top: 2rem; font-family: 'Marcellus', Georgia, serif; font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.04em; color: #c9a227; text-decoration: none; }
.back-link:hover { text-decoration: underline; }
@media (max-width: 600px) { .blog-post h1 { font-size: 1.7rem; } .blog-post { padding: 2rem 1rem 4rem; } }
</style>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "From a Spreadsheet to a Running ERP in Ten Thursday Sessions",
"datePublished": "2026-11-02",
"dateModified": "2026-11-02",
"url": "https://odoo4projects.com/blog/posts/from-a-spreadsheet-to-a-running-erp.html",
"image": "https://image.office.odoo4projects.com/img/screencast.png",
"author": {
"@type": "Organization",
"name": "ODOO4projects",
"url": "https://odoo4projects.com/"
},
"publisher": {
"@type": "Organization",
"name": "ODOO4projects",
"url": "https://odoo4projects.com/",
"logo": {
"@type": "ImageObject",
"url": "https://odoo4projects.com/images/logo.png"
}
}
}
</script>
<!-- Privacy-friendly analytics by Plausible -->
<script async src="https://plausible.office.odoo4projects.com/js/pa-NhA7Rf-79VopXFQXhSS4e.js"></script>
<script>
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
</head>
<body>
<main class="blog-post">
<span class="badge">news</span>
<h1>From a Spreadsheet to a Running ERP in Ten Thursday Sessions</h1>
<div class="post-meta">November 2, 2026 &middot; News</div>
<img src="https://image.office.odoo4projects.com/img/screencast.png" alt="From a Spreadsheet to a Running ERP in Ten Thursday Sessions" class="post-image" loading="lazy" />
<div class="post-body">
<p>Most small businesses do not run on an ERP. They run on a spreadsheet, a messaging group, and one person's memory. It works — until that person is on holiday.</p>
<p>The <strong>Odoo ERP Automation Bootcamp</strong> is ten live Thursday sessions where you build a working system for your own company: your products, your customers, your quotes, your invoices, your stock.</p>
<p>No programming. No server to install. If you can run your business on a spreadsheet, you can do this.</p>
<p><strong>What you get</strong></p>
<ul>
<li>Ten live sessions, one hour each — Thursdays, 18:00–19:00 Central Time</li>
<li>Starts Thursday 21 January 2027</li>
<li>You build on a live system from the first session, not on slides</li>
<li><strong>One year of hosted Odoo Community Edition included</strong> — you automate on a real instance, not a sandbox you set up once and forget</li>
<li>A group capped at six, so everyone gets seen</li>
</ul>
<p>You finish with an ERP you actually use, and the judgement to keep it running yourself.</p>
<p>USD 595.00. Details and the full ten lessons: <a href="https://odoo4projects.com/odoo-erp-automation-bootcamp/?utm_source=linkedin&utm_medium=social&utm_campaign=bootcamp-jan2027">odoo4projects.com/odoo-erp-automation-bootcamp</a></p>
</div>
<a href="../../index.html#blog" class="back-link">&larr; Back to the Blog</a>
</main>
</body>
</html>
+4 -2
View File
@@ -36,7 +36,7 @@
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/> />
<link rel="stylesheet" href="../../style.css" /> <link rel="stylesheet" href="../../style.css?v=061517baef" />
<style> <style>
.blog-header { .blog-header {
padding: 2rem 0; padding: 2rem 0;
@@ -203,6 +203,7 @@
</script> </script>
</head> </head>
<body> <body>
<main>
<div class="container"> <div class="container">
<div class="blog-header"> <div class="blog-header">
<a href="../../index.html#blog">&larr; Back to Blog</a> <a href="../../index.html#blog">&larr; Back to Blog</a>
@@ -273,6 +274,7 @@
<a href="../../index.html#blog">&larr; Back to all posts</a> <a href="../../index.html#blog">&larr; Back to all posts</a>
</div> </div>
</div> </div>
<script defer src="/static/agent-odoo4projects/agent.js"></script> </main>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body> </body>
</html> </html>
@@ -16,7 +16,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700;800&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
<link rel="stylesheet" href="../../style.css" /> <link rel="stylesheet" href="../../style.css?v=061517baef" />
<style> <style>
body { background: #0d1b2e; } body { background: #0d1b2e; }
.blog-post { max-width: 720px; margin: 0 auto; padding: 3rem 1.5rem 6rem; color: #171c26; background: #faf7f1; min-height: 100vh; } .blog-post { max-width: 720px; margin: 0 auto; padding: 3rem 1.5rem 6rem; color: #171c26; background: #faf7f1; min-height: 100vh; }
+3 -1
View File
@@ -31,7 +31,7 @@
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700;800&display=swap" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700;800&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<link rel="stylesheet" href="../../style.css" /> <link rel="stylesheet" href="../../style.css?v=061517baef" />
<style> <style>
/* Blog post specific overrides — matching homepage light theme */ /* Blog post specific overrides — matching homepage light theme */
body { body {
@@ -190,6 +190,7 @@
</script> </script>
</head> </head>
<body> <body>
<main>
<div class="blog-post-container"> <div class="blog-post-container">
<div class="blog-header"> <div class="blog-header">
<a href="../../index.html#blog">&larr; Back to Blog</a> <a href="../../index.html#blog">&larr; Back to Blog</a>
@@ -253,5 +254,6 @@
<a href="../../index.html#blog">&larr; Back to all posts</a> <a href="../../index.html#blog">&larr; Back to all posts</a>
</div> </div>
</div> </div>
</main>
</body> </body>
</html> </html>
+4 -2
View File
@@ -36,7 +36,7 @@
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/> />
<link rel="stylesheet" href="../../style.css" /> <link rel="stylesheet" href="../../style.css?v=061517baef" />
<style> <style>
:root { :root {
--primary: #714b67; --primary: #714b67;
@@ -214,6 +214,7 @@
</script> </script>
</head> </head>
<body> <body>
<main>
<div class="container"> <div class="container">
<div class="blog-header"> <div class="blog-header">
<a href="../../index.html#blog">&larr; Back to Blog</a> <a href="../../index.html#blog">&larr; Back to Blog</a>
@@ -275,6 +276,7 @@
<a href="../../index.html#blog">&larr; Back to all posts</a> <a href="../../index.html#blog">&larr; Back to all posts</a>
</div> </div>
</div> </div>
<script defer src="/static/agent-odoo4projects/agent.js"></script> </main>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body> </body>
</html> </html>
+4 -2
View File
@@ -36,7 +36,7 @@
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/> />
<link rel="stylesheet" href="../../style.css" /> <link rel="stylesheet" href="../../style.css?v=061517baef" />
<style> <style>
:root { :root {
--primary: #714b67; --primary: #714b67;
@@ -214,6 +214,7 @@
</script> </script>
</head> </head>
<body> <body>
<main>
<div class="container"> <div class="container">
<div class="blog-header"> <div class="blog-header">
<a href="../../index.html#blog">&larr; Back to Blog</a> <a href="../../index.html#blog">&larr; Back to Blog</a>
@@ -294,6 +295,7 @@ Admin credentials: sent via email</code></pre>
<a href="../../index.html#blog">&larr; Back to all posts</a> <a href="../../index.html#blog">&larr; Back to all posts</a>
</div> </div>
</div> </div>
<script defer src="/static/agent-odoo4projects/agent.js"></script> </main>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body> </body>
</html> </html>
+4 -2
View File
@@ -35,7 +35,7 @@
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/> />
<link rel="stylesheet" href="../../style.css" /> <link rel="stylesheet" href="../../style.css?v=061517baef" />
<style> <style>
:root { :root {
--primary: #714b67; --primary: #714b67;
@@ -196,6 +196,7 @@
</script> </script>
</head> </head>
<body> <body>
<main>
<div class="container"> <div class="container">
<div class="blog-header"> <div class="blog-header">
<a href="../../index.html#blog">&larr; Back to Blog</a> <a href="../../index.html#blog">&larr; Back to Blog</a>
@@ -259,9 +260,10 @@
<a href="../../index.html#blog">&larr; Back to all posts</a> <a href="../../index.html#blog">&larr; Back to all posts</a>
</div> </div>
</div> </div>
</main>
<script <script
defer defer
src="/static/agent-odoo4projects/agent.js" src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"
></script> ></script>
</body> </body>
</html> </html>
+4 -2
View File
@@ -36,7 +36,7 @@
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/> />
<link rel="stylesheet" href="../../style.css" /> <link rel="stylesheet" href="../../style.css?v=061517baef" />
<style> <style>
* { * {
box-sizing: border-box; box-sizing: border-box;
@@ -197,6 +197,7 @@
</script> </script>
</head> </head>
<body> <body>
<main>
<div class="container"> <div class="container">
<div class="blog-header"> <div class="blog-header">
<a href="../../index.html#blog">&larr; Back to Blog</a> <a href="../../index.html#blog">&larr; Back to Blog</a>
@@ -259,9 +260,10 @@
<a href="../../index.html#blog">&larr; Back to all posts</a> <a href="../../index.html#blog">&larr; Back to all posts</a>
</div> </div>
</div> </div>
</main>
<script <script
defer defer
src="/static/agent-odoo4projects/agent.js" src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"
></script> ></script>
</body> </body>
</html> </html>
@@ -16,7 +16,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700;800&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
<link rel="stylesheet" href="../../style.css" /> <link rel="stylesheet" href="../../style.css?v=061517baef" />
<style> <style>
body { background: #0d1b2e; } body { background: #0d1b2e; }
.blog-post { max-width: 720px; margin: 0 auto; padding: 3rem 1.5rem 6rem; color: #171c26; background: #faf7f1; min-height: 100vh; } .blog-post { max-width: 720px; margin: 0 auto; padding: 3rem 1.5rem 6rem; color: #171c26; background: #faf7f1; min-height: 100vh; }
@@ -34,7 +34,7 @@
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/> />
<link rel="stylesheet" href="../../style.css" /> <link rel="stylesheet" href="../../style.css?v=061517baef" />
<style> <style>
:root { :root {
--primary: #714b67; --primary: #714b67;
@@ -107,6 +107,7 @@
</script> </script>
</head> </head>
<body> <body>
<main>
<div class="container"> <div class="container">
<div class="blog-header"> <div class="blog-header">
<a href="../../index.html#blog">&larr; Back to Blog</a> <a href="../../index.html#blog">&larr; Back to Blog</a>
@@ -173,6 +174,7 @@
<a href="../../index.html#blog">&larr; Back to all posts</a> <a href="../../index.html#blog">&larr; Back to all posts</a>
</div> </div>
</div> </div>
<script defer src="/static/agent-odoo4projects/agent.js"></script> </main>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body> </body>
</html> </html>
@@ -0,0 +1,101 @@
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Odoo 20 Is Live on Our Servers — Here Is What Changed | ODOO4projects</title>
<meta name="description" content="Two weeks. That is how long it took us to bring up Odoo 20 on our servers, with every new feature that comes with it. It" />
<link rel="canonical" href="https://odoo4projects.com/blog/posts/odoo-20-is-live-on-our-servers-here-is-what-changed.html" />
<meta property="og:title" content="Odoo 20 Is Live on Our Servers — Here Is What Changed" />
<meta property="og:description" content="Two weeks. That is how long it took us to bring up Odoo 20 on our servers, with every new feature that comes with it. It" />
<meta property="og:url" content="https://odoo4projects.com/blog/posts/odoo-20-is-live-on-our-servers-here-is-what-changed.html" />
<meta property="og:type" content="article" />
<meta property="og:image" content="https://image.office.odoo4projects.com/img/ODOO_20_chatter.png" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="icon" href="../../images/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
<link rel="stylesheet" href="../../style.css?v=061517baef" />
<style>
body { background: #0d1b2e; }
.blog-post { max-width: 720px; margin: 0 auto; padding: 3rem 1.5rem 6rem; color: #171c26; background: #faf7f1; min-height: 100vh; }
.blog-post h1 { font-family: 'Marcellus', Georgia, serif; font-size: 2.4rem; font-weight: 400; margin: 0 0 0.5rem; color: #0d1b2e; }
.blog-post .post-meta { font-family: Inter, sans-serif; font-size: 0.82rem; color: rgba(23,28,38,0.6); margin-bottom: 2rem; }
.blog-post .post-meta .badge { display: inline-block; font-size: 0.68rem; font-weight: 600; letter-spacing: 0.15em; text-transform: uppercase; color: #c9a227; margin-right: 0.75rem; }
.blog-post .post-image { width: 100%; border-radius: 0.75rem; margin-bottom: 2rem; box-shadow: 0 2px 8px rgba(13,27,46,0.08); }
.blog-post p { font-family: Inter, sans-serif; font-size: 1rem; line-height: 1.7; margin-bottom: 1.2rem; }
.blog-post h2 { font-family: 'Marcellus', Georgia, serif; font-size: 1.6rem; font-weight: 400; margin: 2rem 0 0.75rem; color: #0d1b2e; }
.blog-post ul, .blog-post ol { font-family: Inter, sans-serif; font-size: 1rem; line-height: 1.7; margin-bottom: 1.2rem; padding-left: 1.5rem; }
.blog-post li { margin-bottom: 0.3rem; }
.blog-post a { color: #8a6f1f; text-decoration: underline; }
.blog-post strong { font-weight: 600; }
.blog-post figure { margin: 2rem 0; }
.blog-post figure img { width: 100%; border-radius: 0.75rem; box-shadow: 0 2px 8px rgba(13,27,46,0.08); }
.blog-post figcaption { font-family: Inter, sans-serif; font-size: 0.85rem; line-height: 1.6; color: rgba(23,28,38,0.6); margin-top: 0.6rem; }
.back-link { display: inline-block; margin-top: 2rem; font-family: 'Marcellus', Georgia, serif; font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.04em; color: #c9a227; text-decoration: none; }
.back-link:hover { text-decoration: underline; }
@media (max-width: 600px) { .blog-post h1 { font-size: 1.7rem; } .blog-post { padding: 2rem 1rem 4rem; } }
</style>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "Odoo 20 Is Live on Our Servers — Here Is What Changed",
"datePublished": "2026-11-03",
"url": "https://odoo4projects.com/blog/posts/odoo-20-is-live-on-our-servers-here-is-what-changed.html",
"image": "https://image.office.odoo4projects.com/img/ODOO_20_chatter.png",
"author": {
"@type": "Organization",
"name": "ODOO4projects",
"url": "https://odoo4projects.com/"
},
"publisher": {
"@type": "Organization",
"name": "ODOO4projects",
"url": "https://odoo4projects.com/",
"logo": {
"@type": "ImageObject",
"url": "https://odoo4projects.com/images/logo.png"
}
}
}
</script>
<!-- Privacy-friendly analytics by Plausible -->
<script async src="https://plausible.office.odoo4projects.com/js/pa-NhA7Rf-79VopXFQXhSS4e.js"></script>
<script>
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
</head>
<body>
<main class="blog-post">
<span class="badge">news</span>
<h1>Odoo 20 Is Live on Our Servers — Here Is What Changed</h1>
<div class="post-meta">November 3, 2026 &middot; News</div>
<img src="https://image.office.odoo4projects.com/img/ODOO_20_chatter.png" alt="Odoo 20 Is Live on Our Servers — Here Is What Changed" class="post-image" loading="lazy" />
<div class="post-body">
<p>Two weeks. That is how long it took us to bring up <strong>Odoo 20</strong> on our servers, with every new feature that comes with it. It is live now — if your instance runs with us, the engine underneath it just moved forward.</p>
<p>The version upgrade is only part of the story. Alongside it we shipped a set of changes to the management console, the place where you run your Odoo hosting from inside Odoo itself.</p>
<h2>What landed</h2>
<ul>
<li><strong>An MCP server</strong> so your agent can work on your hosting directly — restart the server, upload modules, add and check domains, manage backups, read the logfiles.</li>
<li><strong>Logfiles you can keep</strong> — download and archive them instead of watching a live view and hoping the right lines are still there.</li>
<li><strong>A free backup scheme for everyone</strong> — the least glamorous part of hosting, and the one you miss most when it is gone.</li>
<li><strong>A better AI server-management backend</strong> — the layer that connects the agent-facing tools to the hosting underneath.</li>
</ul>
<h2>Why it matters</h2>
<p>Nothing here asks you to learn a new product. The console stays where it was; the upgrade makes it do more of the routine work for you, and the parts you would rather not think about — backups, logs, restarts — now have a baseline everyone gets.</p>
<p>Over the next two weeks we take one piece at a time: the MCP server first, then the quieter half — backups and logfiles. Follow along.</p>
</div>
<a href="../../index.html#blog" class="back-link">&larr; Back to the Blog</a>
</main>
</body>
</html>
@@ -16,7 +16,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700;800&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
<link rel="stylesheet" href="../../style.css" /> <link rel="stylesheet" href="../../style.css?v=061517baef" />
<style> <style>
body { background: #0d1b2e; } body { background: #0d1b2e; }
.blog-post { max-width: 720px; margin: 0 auto; padding: 3rem 1.5rem 6rem; color: #171c26; background: #faf7f1; min-height: 100vh; } .blog-post { max-width: 720px; margin: 0 auto; padding: 3rem 1.5rem 6rem; color: #171c26; background: #faf7f1; min-height: 100vh; }
@@ -16,7 +16,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700;800&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
<link rel="stylesheet" href="../../style.css" /> <link rel="stylesheet" href="../../style.css?v=061517baef" />
<style> <style>
body { background: #0d1b2e; } body { background: #0d1b2e; }
.blog-post { max-width: 720px; margin: 0 auto; padding: 3rem 1.5rem 6rem; color: #171c26; background: #faf7f1; min-height: 100vh; } .blog-post { max-width: 720px; margin: 0 auto; padding: 3rem 1.5rem 6rem; color: #171c26; background: #faf7f1; min-height: 100vh; }
+4 -2
View File
@@ -35,7 +35,7 @@
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/> />
<link rel="stylesheet" href="../../style.css" /> <link rel="stylesheet" href="../../style.css?v=061517baef" />
<style> <style>
/* Blog post specific styles — leverages CSS vars from style.css */ /* Blog post specific styles — leverages CSS vars from style.css */
:root { :root {
@@ -173,6 +173,7 @@
</script> </script>
</head> </head>
<body> <body>
<main>
<div class="container" style="width:80%; max-width:800px; margin:0 auto; padding:0 20px;"> <div class="container" style="width:80%; max-width:800px; margin:0 auto; padding:0 20px;">
<div class="blog-header"> <div class="blog-header">
<a href="../../index.html#blog">&larr; Back to Blog</a> <a href="../../index.html#blog">&larr; Back to Blog</a>
@@ -309,6 +310,7 @@
<a href="../../index.html#blog">&larr; Back to all posts</a> <a href="../../index.html#blog">&larr; Back to all posts</a>
</div> </div>
</div> </div>
<script defer src="/static/agent-odoo4projects/agent.js"></script> </main>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body> </body>
</html> </html>
+9 -2
View File
@@ -35,7 +35,7 @@
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/> />
<link rel="stylesheet" href="../../style.css" /> <link rel="stylesheet" href="../../style.css?v=061517baef" />
<style> <style>
.container { .container {
width: 80%; width: 80%;
@@ -181,6 +181,7 @@
</script> </script>
</head> </head>
<body> <body>
<main>
<div class="container"> <div class="container">
<div class="blog-header"> <div class="blog-header">
<a href="../../index.html#blog">&larr; Back to Blog</a> <a href="../../index.html#blog">&larr; Back to Blog</a>
@@ -237,12 +238,18 @@ Odoo &gt; Settings &gt; Technical &gt; Scheduled Actions &gt; backup job</code><
>Choose a Plan</a >Choose a Plan</a
> >
</div> </div>
<p>
A backup is the safety net for a single change. When the change is bigger than one
module, a version upgrade or a migration, a staging environment is worth it. Read
<a href="../../odoo-staging-environment/">what a staging environment is, and why you probably do not need one</a>.
</p>
</div> </div>
</article> </article>
<div class="post-footer"> <div class="post-footer">
<a href="../../index.html#blog">&larr; Back to all posts</a> <a href="../../index.html#blog">&larr; Back to all posts</a>
</div> </div>
</div> </div>
<script defer src="/static/agent-odoo4projects/agent.js"></script> </main>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body> </body>
</html> </html>
+4 -2
View File
@@ -35,7 +35,7 @@
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/> />
<link rel="stylesheet" href="../../style.css" /> <link rel="stylesheet" href="../../style.css?v=061517baef" />
<style> <style>
:root { :root {
--primary: #714b67; --primary: #714b67;
@@ -202,6 +202,7 @@
</script> </script>
</head> </head>
<body> <body>
<main>
<div class="container"> <div class="container">
<div class="blog-header"> <div class="blog-header">
<a href="../../index.html#blog">&larr; Back to Blog</a> <a href="../../index.html#blog">&larr; Back to Blog</a>
@@ -265,6 +266,7 @@ Password: (check your email)</code></pre>
<a href="../../index.html#blog">&larr; Back to all posts</a> <a href="../../index.html#blog">&larr; Back to all posts</a>
</div> </div>
</div> </div>
<script defer src="/static/agent-odoo4projects/agent.js"></script> </main>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body> </body>
</html> </html>
@@ -34,7 +34,7 @@
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/> />
<link rel="stylesheet" href="../../style.css" /> <link rel="stylesheet" href="../../style.css?v=061517baef" />
<style> <style>
:root { :root {
--primary: #714b67; --primary: #714b67;
@@ -107,6 +107,7 @@
</script> </script>
</head> </head>
<body> <body>
<main>
<div class="container"> <div class="container">
<div class="blog-header"> <div class="blog-header">
<a href="../../index.html#blog">&larr; Back to Blog</a> <a href="../../index.html#blog">&larr; Back to Blog</a>
@@ -163,6 +164,7 @@
<a href="../../index.html#blog">&larr; Back to all posts</a> <a href="../../index.html#blog">&larr; Back to all posts</a>
</div> </div>
</div> </div>
<script defer src="/static/agent-odoo4projects/agent.js"></script> </main>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body> </body>
</html> </html>
@@ -0,0 +1,97 @@
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Your Agent Can Run Your Odoo Hosting: the MCP Server Is Live | ODOO4projects</title>
<meta name="description" content="This is the piece of the Odoo 20 update we are asked about most: an MCP server that lets your agent work on your Odoo ho" />
<link rel="canonical" href="https://odoo4projects.com/blog/posts/your-agent-can-run-your-odoo-hosting.html" />
<meta property="og:title" content="Your Agent Can Run Your Odoo Hosting: the MCP Server Is Live" />
<meta property="og:description" content="This is the piece of the Odoo 20 update we are asked about most: an MCP server that lets your agent work on your Odoo ho" />
<meta property="og:url" content="https://odoo4projects.com/blog/posts/your-agent-can-run-your-odoo-hosting.html" />
<meta property="og:type" content="article" />
<meta property="og:image" content="https://image.office.odoo4projects.com/img/20_4server.png" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="icon" href="../../images/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
<link rel="stylesheet" href="../../style.css?v=061517baef" />
<style>
body { background: #0d1b2e; }
.blog-post { max-width: 720px; margin: 0 auto; padding: 3rem 1.5rem 6rem; color: #171c26; background: #faf7f1; min-height: 100vh; }
.blog-post h1 { font-family: 'Marcellus', Georgia, serif; font-size: 2.4rem; font-weight: 400; margin: 0 0 0.5rem; color: #0d1b2e; }
.blog-post .post-meta { font-family: Inter, sans-serif; font-size: 0.82rem; color: rgba(23,28,38,0.6); margin-bottom: 2rem; }
.blog-post .post-meta .badge { display: inline-block; font-size: 0.68rem; font-weight: 600; letter-spacing: 0.15em; text-transform: uppercase; color: #c9a227; margin-right: 0.75rem; }
.blog-post .post-image { width: 100%; border-radius: 0.75rem; margin-bottom: 2rem; box-shadow: 0 2px 8px rgba(13,27,46,0.08); }
.blog-post p { font-family: Inter, sans-serif; font-size: 1rem; line-height: 1.7; margin-bottom: 1.2rem; }
.blog-post h2 { font-family: 'Marcellus', Georgia, serif; font-size: 1.6rem; font-weight: 400; margin: 2rem 0 0.75rem; color: #0d1b2e; }
.blog-post ul, .blog-post ol { font-family: Inter, sans-serif; font-size: 1rem; line-height: 1.7; margin-bottom: 1.2rem; padding-left: 1.5rem; }
.blog-post li { margin-bottom: 0.3rem; }
.blog-post a { color: #8a6f1f; text-decoration: underline; }
.blog-post strong { font-weight: 600; }
.back-link { display: inline-block; margin-top: 2rem; font-family: 'Marcellus', Georgia, serif; font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.04em; color: #c9a227; text-decoration: none; }
.back-link:hover { text-decoration: underline; }
@media (max-width: 600px) { .blog-post h1 { font-size: 1.7rem; } .blog-post { padding: 2rem 1rem 4rem; } }
</style>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "Your Agent Can Run Your Odoo Hosting: the MCP Server Is Live",
"datePublished": "2026-11-05",
"url": "https://odoo4projects.com/blog/posts/your-agent-can-run-your-odoo-hosting.html",
"image": "https://image.office.odoo4projects.com/img/20_4server.png",
"author": {
"@type": "Organization",
"name": "ODOO4projects",
"url": "https://odoo4projects.com/"
},
"publisher": {
"@type": "Organization",
"name": "ODOO4projects",
"url": "https://odoo4projects.com/",
"logo": {
"@type": "ImageObject",
"url": "https://odoo4projects.com/images/logo.png"
}
}
}
</script>
<!-- Privacy-friendly analytics by Plausible -->
<script async src="https://plausible.office.odoo4projects.com/js/pa-NhA7Rf-79VopXFQXhSS4e.js"></script>
<script>
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
</head>
<body>
<main class="blog-post">
<span class="badge">news</span>
<h1>Your Agent Can Run Your Odoo Hosting: the MCP Server Is Live</h1>
<div class="post-meta">November 5, 2026 &middot; News</div>
<img src="https://image.office.odoo4projects.com/img/20_4server.png" alt="Your Agent Can Run Your Odoo Hosting: the MCP Server Is Live" class="post-image" loading="lazy" />
<div class="post-body">
<p>This is the piece of the Odoo 20 update we are asked about most: an <strong>MCP server</strong> that lets your agent work on your Odoo hosting directly, instead of you clicking through menus to do routine things.</p>
<h2>What your agent can do</h2>
<ul>
<li><strong>Restart the server</strong> — the task nobody wants to be paged for.</li>
<li><strong>Upload modules</strong> — deploy new functionality as part of the same workflow.</li>
<li><strong>Add and check domains</strong> — routing handled where the work happens.</li>
<li><strong>Manage your backups</strong> — create them, and reinstall one when you need to.</li>
<li><strong>Read the logfiles</strong> — investigate a problem without digging through them by hand.</li>
</ul>
<h2>Why we built it this way</h2>
<p>Hosting chores are short, frequent and interrupt you at the worst moment. Handing them to an agent changes the shape of the day: you stay on the work that moves your project, and the operational tasks happen in the background — under the same account, on the same console you already use.</p>
<p>The console itself is unchanged. What changed is that the routine half of running an instance no longer needs you in front of it.</p>
</div>
<a href="../../index.html#blog" class="back-link">&larr; Back to the Blog</a>
</main>
</body>
</html>
+10 -7
View File
@@ -128,7 +128,9 @@
.hero-stats{display:flex;gap:1.5rem;margin-bottom:2rem;flex-wrap:wrap}.hero-stat{display:flex;align-items:center;gap:.4rem;color:rgba(255,255,255,.65);font-size:.85rem}.hero-stat i{color:var(--brass);font-size:.75rem} .hero-stats{display:flex;gap:1.5rem;margin-bottom:2rem;flex-wrap:wrap}.hero-stat{display:flex;align-items:center;gap:.4rem;color:rgba(255,255,255,.65);font-size:.85rem}.hero-stat i{color:var(--brass);font-size:.75rem}
.hero-actions{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:2rem} .hero-actions{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:2rem}
.hero-notice{position:absolute;top:1.25rem;right:1.25rem;z-index:3;display:flex;align-items:center;gap:.55rem;max-width:min(360px,calc(100% - 2rem));padding:.6rem .85rem .6rem 1rem;background:#f5c542;color:var(--midnight);border:1px solid rgba(13,27,46,.14);border-radius:var(--radius);box-shadow:0 8px 24px rgba(13,27,46,.3);font-family:Inter,sans-serif;font-size:.82rem;line-height:1.4;animation:heroNoticeIn .4s cubic-bezier(.16,1,.3,1)} .hero-notice{position:absolute;top:1.25rem;right:1.25rem;z-index:3;display:flex;align-items:center;gap:.55rem;max-width:min(360px,calc(100% - 2rem));padding:.6rem .85rem .6rem 1rem;background:#f5c542;color:var(--midnight);border:1px solid rgba(13,27,46,.14);border-radius:var(--radius);box-shadow:0 8px 24px rgba(13,27,46,.3);font-family:Inter,sans-serif;font-size:.82rem;line-height:1.4;animation:heroNoticeIn .4s cubic-bezier(.16,1,.3,1)}
.hero-notice[hidden]{display:none} /* `.hero-notice[hidden]{display:none}` used to live here; the site-wide `[hidden]`
rule in /style.css hides it (`.hero-notice` sets `display:flex`, which outranked
the UA rule). */
.hero-notice i{color:var(--midnight);font-size:.9rem;flex-shrink:0} .hero-notice i{color:var(--midnight);font-size:.9rem;flex-shrink:0}
.hero-notice-text{flex:1;min-width:0;overflow-wrap:anywhere} .hero-notice-text{flex:1;min-width:0;overflow-wrap:anywhere}
.hero-notice-close{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:1.35rem;height:1.35rem;padding:0;border:0;border-radius:50%;background:rgba(13,27,46,.12);color:var(--midnight);font-size:1rem;line-height:1;cursor:pointer;transition:background .2s} .hero-notice-close{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:1.35rem;height:1.35rem;padding:0;border:0;border-radius:50%;background:rgba(13,27,46,.12);color:var(--midnight);font-size:1rem;line-height:1;cursor:pointer;transition:background .2s}
@@ -220,15 +222,14 @@
#customQuoteModal .biz-success p{color:var(--midnight)} #customQuoteModal .biz-success p{color:var(--midnight)}
@media(max-width:600px){.oca-table td,.oca-table th{padding:.5rem .65rem;font-size:.8rem}.ow-panel{left:5%;right:5%}.ow-close{top:.5rem;right:.5rem;font-size:1.6rem}} @media(max-width:600px){.oca-table td,.oca-table th{padding:.5rem .65rem;font-size:.8rem}.ow-panel{left:5%;right:5%}.ow-close{top:.5rem;right:.5rem;font-size:1.6rem}}
</style> </style>
<link rel="stylesheet" href="/style.css" media="print" onload="this.media='all'" /> <link rel="stylesheet" href="/style.css?v=061517baef" />
<noscript><link rel="stylesheet" href="/style.css" /></noscript>
<!-- Privacy-friendly analytics by Plausible --> <!-- Privacy-friendly analytics by Plausible -->
<script async src="https://plausible.office.odoo4projects.com/js/pa-NhA7Rf-79VopXFQXhSS4e.js"></script> <script async src="https://plausible.office.odoo4projects.com/js/pa-NhA7Rf-79VopXFQXhSS4e.js"></script>
<script> <script>
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}}; window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init() plausible.init()
</script> </script>
<script defer src="/static/lang.js"></script> <script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head> </head>
<body> <body>
<!-- <!--
@@ -240,6 +241,7 @@
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md. FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md.
--> -->
<!-- Hero --> <!-- Hero -->
<main>
<section class="hero"> <section class="hero">
<div class="hero-notice" id="heroConfirmNotice" role="status" aria-live="polite" hidden> <div class="hero-notice" id="heroConfirmNotice" role="status" aria-live="polite" hidden>
<i class="fas fa-check-circle" aria-hidden="true"></i> <i class="fas fa-check-circle" aria-hidden="true"></i>
@@ -496,7 +498,7 @@
<h2 class="ow-title" id="orderPlanTitle">On the Rise</h2> <h2 class="ow-title" id="orderPlanTitle">On the Rise</h2>
<p class="ow-price"> <p class="ow-price">
<span class="ow-price-amount" id="orderPlanPrice">$ 395.00</span> <span id="orderPlanPeriod">/ Jahr</span> <span class="ow-price-amount" id="orderPlanPrice">$ 395.00</span> <span id="orderPlanPeriod">/ Jahr</span>
<span class="ow-price-tax" id="orderPlanTax">+ MwSt.</span> <span class="ow-price-tax" id="orderPlanTax">inkl. MwSt.</span>
</p> </p>
<p class="ow-coupon-msg" id="orderCouponMsg">Fragen vor dem Kauf? Fragen Sie den Assistenten auf dieser Seite.</p> <p class="ow-coupon-msg" id="orderCouponMsg">Fragen vor dem Kauf? Fragen Sie den Assistenten auf dieser Seite.</p>
<p class="ow-yearly-save" id="orderYearlySave">Jährliche Abrechnung. Keine Einrichtungsgebühr.</p> <p class="ow-yearly-save" id="orderYearlySave">Jährliche Abrechnung. Keine Einrichtungsgebühr.</p>
@@ -625,6 +627,7 @@
</section> </section>
<!-- Footer --> <!-- Footer -->
</main>
<footer class="footer"> <footer class="footer">
<div class="container"> <div class="container">
<div class="fgrid"> <div class="fgrid">
@@ -671,7 +674,7 @@
</footer> </footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script src="/script.js"></script> <script src="/script.js?v=d2046e3469"></script>
<script defer src="/static/agent-odoo4projects/agent.js"></script> <script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body> </body>
</html> </html>
+5 -4
View File
@@ -38,8 +38,7 @@
onload="this.media='all'" onload="this.media='all'"
/> />
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript> <noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="stylesheet" href="/style.css" media="print" onload="this.media='all'" /> <link rel="stylesheet" href="/style.css?v=061517baef" />
<noscript><link rel="stylesheet" href="/style.css" /></noscript>
<style> <style>
/* ---- Odoo Community Upgrade page (page-scoped; additive) ---- */ /* ---- Odoo Community Upgrade page (page-scoped; additive) ---- */
.oc-sr-only { .oc-sr-only {
@@ -480,7 +479,7 @@
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}}; window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init() plausible.init()
</script> </script>
<script defer src="/static/lang.js"></script> <script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head> </head>
<body> <body>
<!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. --> <!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. -->
@@ -496,6 +495,7 @@
</div> </div>
<!-- Hero --> <!-- Hero -->
<main>
<section class="hero oc-hero"> <section class="hero oc-hero">
<div class="hero-bg"> <div class="hero-bg">
<img <img
@@ -837,6 +837,7 @@ sudo -u postgres pg_restore -d &lt;DB_NEW&gt; /path/backup/&lt;DB&gt;.dump
<!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page <!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page
carries no external icon dependency) --> carries no external icon dependency) -->
</main>
<footer class="footer"> <footer class="footer">
<div class="container"> <div class="container">
<div class="fgrid"> <div class="fgrid">
@@ -868,7 +869,7 @@ sudo -u postgres pg_restore -d &lt;DB_NEW&gt; /path/backup/&lt;DB&gt;.dump
<a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a> <a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a>
<a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a> <a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a>
</div> </div>
<form class="footer-contact-form" id="footerContactForm" action="mailto:hello@odoo4projects.com" method="post" enctype="text/plain"> <form class="footer-contact-form" id="footerContactForm" action="mailto:support@odoo4projects.com" method="post" enctype="text/plain">
<label class="oc-sr-only" for="footerContactEmail">Ihre E-Mail-Adresse</label> <label class="oc-sr-only" for="footerContactEmail">Ihre E-Mail-Adresse</label>
<input type="email" id="footerContactEmail" name="email" placeholder="Ihre E-Mail-Adresse" required /> <input type="email" id="footerContactEmail" name="email" placeholder="Ihre E-Mail-Adresse" required />
<div class="footer-textarea-wrap"> <div class="footer-textarea-wrap">
+162 -19
View File
@@ -31,8 +31,9 @@
onload="this.media='all'" onload="this.media='all'"
/> />
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript> <noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="stylesheet" href="/style.css" media="print" onload="this.media='all'" /> <link rel="preload" as="image" href="/images/hero/bootcamp-hero-mobile-900.webp" media="(max-width: 640px)" fetchpriority="high" />
<noscript><link rel="stylesheet" href="/style.css" /></noscript> <link rel="preload" as="image" href="/images/hero/bootcamp-hero-desktop-1920.webp" media="(min-width: 641px)" fetchpriority="high" />
<link rel="stylesheet" href="/style.css?v=061517baef" />
<style> <style>
/* ---- ODOO ERP Automation Bootcamp landing page (page-scoped; additive) ---- */ /* ---- ODOO ERP Automation Bootcamp landing page (page-scoped; additive) ---- */
.bc-sr-only { .bc-sr-only {
@@ -192,6 +193,14 @@
form[action$="/shop/post_basket"] .btn { form[action$="/shop/post_basket"] .btn {
line-height: inherit; line-height: inherit;
} }
/* At 0 seats /static/bootcamp-availability.js disables these Buy buttons; make the
disabled state read as disabled — quiet dimming in the page's own CI, never an
alert banner. */
form[action$="/shop/post_basket"] .btn:disabled,
form[action$="/shop/post_basket"] .btn[aria-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
}
/* Grids — auto-fit so no breakpoint is needed */ /* Grids — auto-fit so no breakpoint is needed */
.bc-list { .bc-list {
@@ -223,7 +232,7 @@
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
box-shadow: var(--shadow-rest); box-shadow: var(--shadow-rest);
padding: 1.4rem 1.4rem 1.4rem 3.4rem; padding: 1.4rem 1.4rem 1.4rem 3.4rem;
transition: box-shadow 0.25s, transform 0.25s; transition: transform 0.25s; /* box-shadow kept, not transitioned (non-composited) */
} }
.bc-lessons li:hover { .bc-lessons li:hover {
box-shadow: var(--shadow-hover); box-shadow: var(--shadow-hover);
@@ -313,7 +322,7 @@
.bc-schedule dt { .bc-schedule dt {
font-family: Marcellus, Georgia, serif; font-family: Marcellus, Georgia, serif;
font-size: 0.92rem; font-size: 0.92rem;
color: var(--brass-deep); color: #876c1e; /* 4.68:1 on --ivory (was --brass-deep 4.49:1) */
} }
.bc-schedule dd { .bc-schedule dd {
margin: 0; margin: 0;
@@ -452,6 +461,109 @@
.btn-brass:focus-visible { .btn-brass:focus-visible {
outline-color: #fff; outline-color: #fff;
} }
/* Waiting list — for someone who cannot attend the January cohort.
Deliberately quieter than the Buy button: outline button, smaller type, no brass fill. */
.bc-waitlist {
/* Component-scoped error colour: the site has no shared token for it. */
--wl-error: #b02a37;
max-width: 720px;
margin: 2.5rem auto 0;
padding: 1.5rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--ivory);
text-align: left;
}
.bc-wl-title {
margin: 0 0 0.5rem;
font-size: 1.1rem;
color: var(--ink);
}
.bc-wl-lead {
margin: 0 0 1.1rem;
font-size: 0.88rem;
line-height: 1.6;
color: rgba(23, 28, 38, 0.78); /* the page's own .bc-note value, on the same cream */
}
.bc-wl-form input,
.bc-wl-form textarea {
display: block;
width: 100%;
box-sizing: border-box;
margin-bottom: 0.6rem;
padding: 0.6rem 0.75rem;
font-family: Inter, sans-serif;
font-size: 0.9rem;
color: var(--ink);
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
}
.bc-wl-form textarea {
min-height: 3.4rem;
resize: vertical;
}
.bc-wl-btn {
padding: 0.6rem 1.5rem;
font-size: 0.8rem;
background: transparent;
color: var(--ink);
border-color: rgba(23, 28, 38, 0.35);
}
.bc-wl-btn:hover {
border-color: var(--brass);
background: rgba(201, 162, 39, 0.12);
transform: none;
box-shadow: none;
}
.bc-wl-btn:disabled {
opacity: 0.6;
cursor: default;
}
.bc-wl-success {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--success);
}
.bc-wl-error {
margin: 0;
font-size: 0.9rem;
color: var(--wl-error);
}
/* --- 2026-11-02 PageSpeed pass (page-scoped, additive) ---------------------
1. The footer <h4>s became <h3>s so the heading order descends after the last
section h2 (a11y "heading-order"); these rules keep their look identical to
the shared .footer h4 in /style.css.
2. A link inside an on-dark note is the lighter brass (7.15:1 on midnight,
A11y "color-contrast") and underlined, so it is distinguishable without
colour (A11y "link-in-text-block"). */
.footer h3 {
font-family: Marcellus, Georgia, serif;
color: #fff;
margin-bottom: 0.8rem;
font-size: 1rem;
letter-spacing: 0.03em;
}
.bc-note.on-dark a {
color: var(--brass);
text-decoration: underline;
}
/* the footer copyright line at .35 alpha was 3.2:1 on midnight */
.footer .fcopy {
color: rgba(255, 255, 255, 0.5); /* 5.17:1 */
}
/* no-JS footer fallback (rendered only when JavaScript is off) */
.footer .bc-noscript {
margin-top: 0.6rem;
font-size: 0.85rem;
}
.footer .bc-noscript a {
color: var(--brass);
text-decoration: underline;
}
</style> </style>
<script type="application/ld+json"> <script type="application/ld+json">
{ {
@@ -501,7 +613,7 @@
}, },
"offers": { "offers": {
"@type": "Offer", "@type": "Offer",
"price": "465.00", "price": "595.00",
"priceCurrency": "USD", "priceCurrency": "USD",
"category": "Paid", "category": "Paid",
"url": "https://odoo4projects.com/odoo-erp-automation-bootcamp/" "url": "https://odoo4projects.com/odoo-erp-automation-bootcamp/"
@@ -576,6 +688,7 @@
</div> </div>
<!-- Hero --> <!-- Hero -->
<main>
<section class="hero bc-hero"> <section class="hero bc-hero">
<div class="container"> <div class="container">
<p class="hero-kicker">Q1 2027 &middot; 10 LIVE-SESSIONS &middot; OLIVER ARNOLD</p> <p class="hero-kicker">Q1 2027 &middot; 10 LIVE-SESSIONS &middot; OLIVER ARNOLD</p>
@@ -591,14 +704,21 @@
</div> </div>
<div class="hero-actions"> <div class="hero-actions">
<form method="post" action="https://20.odoo4projects.com/shop/post_basket" target="_blank" class="plausible-event-name=Buy"> <form method="post" action="https://20.odoo4projects.com/shop/post_basket" target="_blank" class="plausible-event-name=Buy">
<input type="hidden" name="basket" value='[{"product_id":12,"qty":1}]' /> <input type="hidden" name="basket" value='[{"product_id":17,"qty":1,"info":"Q1 2027, English"}]' />
<input type="hidden" name="clear" value="1" /> <input type="hidden" name="clear" value="1" />
<button type="submit" class="btn btn-brass btn-lg">Für 465.00 USD kaufen</button> <button type="submit" class="btn btn-brass btn-lg">Für 595.00 USD kaufen</button>
</form> </form>
</div> </div>
</div> </div>
</section> </section>
<!-- Live remaining-seat count, immediately below the hero. One seat element only on
the page; /static/bootcamp-availability.js fills it from the availability webhook
and leaves it empty+hidden if that fetch fails. -->
<div class="container">
<p class="bc-note bc-seats" data-bc-seats hidden></p>
</div>
<!-- From zero to a running ERP --> <!-- From zero to a running ERP -->
<section class="section-light"> <section class="section-light">
<div class="container"> <div class="container">
@@ -612,9 +732,9 @@
<p class="bc-subline">Drei Monate. Zehn Stunden Unterricht. Ein System, das Ihr Unternehmen steuert.</p> <p class="bc-subline">Drei Monate. Zehn Stunden Unterricht. Ein System, das Ihr Unternehmen steuert.</p>
<div class="bc-action"> <div class="bc-action">
<form method="post" action="https://20.odoo4projects.com/shop/post_basket" target="_blank" class="plausible-event-name=Buy"> <form method="post" action="https://20.odoo4projects.com/shop/post_basket" target="_blank" class="plausible-event-name=Buy">
<input type="hidden" name="basket" value='[{"product_id":12,"qty":1}]' /> <input type="hidden" name="basket" value='[{"product_id":17,"qty":1,"info":"Q1 2027, English"}]' />
<input type="hidden" name="clear" value="1" /> <input type="hidden" name="clear" value="1" />
<button type="submit" class="btn btn-brass btn-lg">Für 465.00 USD kaufen</button> <button type="submit" class="btn btn-brass btn-lg">Für 595.00 USD kaufen</button>
</form> </form>
</div> </div>
</div> </div>
@@ -769,18 +889,37 @@
<section class="section-light"> <section class="section-light">
<div class="container"> <div class="container">
<h2 class="section-title">Preis und Buchung</h2> <h2 class="section-title">Preis und Buchung</h2>
<p class="bc-price">Das Programm kostet USD 465.00 für alle zehn Sitzungen.</p> <p class="bc-price">Das Programm kostet USD 595.00 für alle zehn Sitzungen.</p>
<p class="bc-note"><strong>Begrenzt auf 6 Teilnehmer</strong> — die Gruppe ist klein, damit der Trainer sich Ihre eigene Konfiguration ansehen kann.</p> <p class="bc-note"><strong>Begrenzt auf 6 Teilnehmer</strong> — die Gruppe ist klein, damit der Trainer sich Ihre eigene Konfiguration ansehen kann.</p>
<p class="bc-note">Ein Platz ist <strong>jederzeit übertragbar</strong> und kann <strong>bis zwei Wochen vor</strong> der ersten Sitzung am 21. Januar 2027 storniert werden.</p>
<p class="bc-lead"> <p class="bc-lead">
Umfasst das komplette Programm: zehn Live-Sessions, ein Jahr gehostetes Odoo Community Edition, die Alumni-Community und die Expertenprüfung. Umfasst das komplette Programm: zehn Live-Sessions, ein Jahr gehostetes Odoo Community Edition, die Alumni-Community und die Expertenprüfung.
</p> </p>
<div class="bc-action"> <div class="bc-action">
<form method="post" action="https://20.odoo4projects.com/shop/post_basket" target="_blank" class="plausible-event-name=Buy"> <form method="post" action="https://20.odoo4projects.com/shop/post_basket" target="_blank" class="plausible-event-name=Buy">
<input type="hidden" name="basket" value='[{"product_id":12,"qty":1}]' /> <input type="hidden" name="basket" value='[{"product_id":17,"qty":1,"info":"Q1 2027, English"}]' />
<input type="hidden" name="clear" value="1" /> <input type="hidden" name="clear" value="1" />
<button type="submit" class="btn btn-brass btn-lg">Für 465.00 USD kaufen</button> <button type="submit" class="btn btn-brass btn-lg">Für 595.00 USD kaufen</button>
</form> </form>
</div> </div>
<!-- Waiting list: for a visitor who cannot attend the January cohort.
Secondary to the Buy button above. Same CRM webhook and same payload shape as the
site's footer contact form (area:'message'); the copy lives here, in the page's
language, and /static/bootcamp-waitlist.js carries no strings. -->
<div class="bc-waitlist">
<h3 class="bc-wl-title">Andere Zeitzone oder andere Sprache?</h3>
<p class="bc-wl-lead">Der Januar-Kurs läuft donnerstags 18:00&ndash;19:00 US-Zentralzeit, auf Englisch. Wenn Sie eine andere Uhrzeit oder eine andere Sprache brauchen, tragen Sie sich in die Warteliste ein. Wir schreiben Ihnen, sobald ein passender Kurs startet.</p>
<form id="bcWaitlistForm" class="bc-wl-form" action="https://n8n.odoo4projects.com/webhook/ODOO4projects/crm" method="post" enctype="text/plain">
<label class="bc-sr-only" for="bcWaitlistEmail">Ihre E-Mail-Adresse</label>
<input type="email" id="bcWaitlistEmail" name="email" placeholder="Ihre E-Mail-Adresse" required />
<label class="bc-sr-only" for="bcWaitlistComment">Welche Zeitzone oder Sprache Sie brauchen</label>
<textarea id="bcWaitlistComment" name="comment" rows="2" placeholder="Welche Zeitzone oder Sprache brauchen Sie?" required></textarea>
<button type="submit" class="btn bc-wl-btn" data-sending="Wird eingetragen&hellip;">In die Warteliste eintragen</button>
</form>
<p class="bc-wl-success" id="bcWaitlistSuccess" role="status" style="display:none">Sie stehen auf der Warteliste. Wir schreiben Ihnen, sobald ein Kurs in Ihrer Zeitzone oder Sprache angekündigt wird.</p>
<p class="bc-wl-error" id="bcWaitlistError" role="alert" style="display:none">Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.</p>
</div>
<ul class="bc-related"> <ul class="bc-related">
<li><a href="/de/support.html#contact">Fragen vor der Buchung? Sprechen Sie zuerst mit uns</a></li> <li><a href="/de/support.html#contact">Fragen vor der Buchung? Sprechen Sie zuerst mit uns</a></li>
<li><a href="/de/odoo-hosting-2027.html">Wie das Hosting nach Ihrem Jahr bei uns aussieht</a></li> <li><a href="/de/odoo-hosting-2027.html">Wie das Hosting nach Ihrem Jahr bei uns aussieht</a></li>
@@ -828,6 +967,7 @@
</div> </div>
</div> </div>
</section> </section>
</main>
<!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page <!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page
carries no external icon dependency) --> carries no external icon dependency) -->
@@ -835,7 +975,7 @@
<div class="container"> <div class="container">
<div class="fgrid"> <div class="fgrid">
<div> <div>
<h4>Nützliche Links</h4> <h3>Nützliche Links</h3>
<ul> <ul>
<li><a href="/de/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li> <li><a href="/de/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li>
<li><a href="/de/odoo-hosting-2027.html">ODOO Hosting in 2027</a></li> <li><a href="/de/odoo-hosting-2027.html">ODOO Hosting in 2027</a></li>
@@ -844,7 +984,7 @@
</ul> </ul>
</div> </div>
<div> <div>
<h4>Partner</h4> <h3>Partner</h3>
<div class="partner-list"> <div class="partner-list">
<a href="https://oc-guy.com" target="_blank" rel="noopener" class="partner-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9a227" stroke-width="2" aria-hidden="true"><path d="M20 7h-9M14 17H5M20 7l-4-4M20 7l-4 4M4 17l4 4M4 17l4-4" /></svg> OC Guy</a> <a href="https://oc-guy.com" target="_blank" rel="noopener" class="partner-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9a227" stroke-width="2" aria-hidden="true"><path d="M20 7h-9M14 17H5M20 7l-4-4M20 7l-4 4M4 17l4 4M4 17l4-4" /></svg> OC Guy</a>
<a href="https://oca-py.org" target="_blank" rel="noopener" class="partner-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9a227" stroke-width="2" aria-hidden="true"><path d="M20 7h-9M14 17H5M20 7l-4-4M20 7l-4 4M4 17l4 4M4 17l4-4" /></svg> OCA Paraguay</a> <a href="https://oca-py.org" target="_blank" rel="noopener" class="partner-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9a227" stroke-width="2" aria-hidden="true"><path d="M20 7h-9M14 17H5M20 7l-4-4M20 7l-4 4M4 17l4 4M4 17l4-4" /></svg> OCA Paraguay</a>
@@ -852,17 +992,17 @@
</div> </div>
</div> </div>
<div class="fcol-desc"> <div class="fcol-desc">
<h4>Über uns</h4> <h3>Über uns</h3>
<p>Wir bauen clevere, skalierbare Geschäftslösungen: die <strong>Stabilität</strong> von Odoo ERP trifft auf die <strong>Kraft</strong> von KI-Agenten und N8N-Automatisierungen.</p> <p>Wir bauen clevere, skalierbare Geschäftslösungen: die <strong>Stabilität</strong> von Odoo ERP trifft auf die <strong>Kraft</strong> von KI-Agenten und N8N-Automatisierungen.</p>
<p>Von schlanken Vertriebsprozessen bis zum globalen Rollout: Unser <strong>schlanker, umweltfreundlicher</strong> Ansatz senkt Kosten, steigert die Effizienz und lässt Unternehmen ohne Grenzen wachsen.</p> <p>Von schlanken Vertriebsprozessen bis zum globalen Rollout: Unser <strong>schlanker, umweltfreundlicher</strong> Ansatz senkt Kosten, steigert die Effizienz und lässt Unternehmen ohne Grenzen wachsen.</p>
</div> </div>
<div> <div>
<h4>Folgen Sie uns</h4> <h3>Folgen Sie uns</h3>
<div class="social"> <div class="social">
<a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a> <a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a>
<a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a> <a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a>
</div> </div>
<form class="footer-contact-form" id="footerContactForm" action="mailto:hello@odoo4projects.com" method="post" enctype="text/plain"> <form class="footer-contact-form" id="footerContactForm" action="https://n8n.odoo4projects.com/webhook/ODOO4projects/crm" method="post" enctype="text/plain" data-ok="Danke! Wir melden uns in Kürze bei Ihnen." data-err="Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.">
<label class="bc-sr-only" for="footerContactEmail">Ihre E-Mail-Adresse</label> <label class="bc-sr-only" for="footerContactEmail">Ihre E-Mail-Adresse</label>
<input type="email" id="footerContactEmail" name="email" placeholder="Ihre E-Mail-Adresse" required /> <input type="email" id="footerContactEmail" name="email" placeholder="Ihre E-Mail-Adresse" required />
<div class="footer-textarea-wrap"> <div class="footer-textarea-wrap">
@@ -872,12 +1012,15 @@
</div> </div>
<div class="footer-form-status" id="footerFormStatus" role="status"></div> <div class="footer-form-status" id="footerFormStatus" role="status"></div>
</form> </form>
<noscript><p class="bc-noscript">JavaScript ist deaktiviert. Schreiben Sie uns direkt an <a href="mailto:support@odoo4projects.com">support@odoo4projects.com</a>.</p></noscript>
</div> </div>
</div> </div>
<div class="fcopy">Copyright &copy; ODOO4projects LLC</div> <div class="fcopy">Copyright &copy; ODOO4projects LLC</div>
</div> </div>
</footer> </footer>
<script defer src="/static/buy.js"></script> <script defer src="/static/buy.js?v=31314de600"></script>
<script defer src="/static/lang.js"></script> <script defer src="/static/bootcamp-availability.js?v=94dfd8cd7c"></script>
<script defer src="/static/bootcamp-waitlist.js?v=6c2e2fbd33"></script>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</body> </body>
</html> </html>
+12 -9
View File
@@ -31,8 +31,7 @@
onload="this.media='all'" onload="this.media='all'"
/> />
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript> <noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="stylesheet" href="/style.css" media="print" onload="this.media='all'" /> <link rel="stylesheet" href="/style.css?v=061517baef" />
<noscript><link rel="stylesheet" href="/style.css" /></noscript>
<style> <style>
/* ---- Odoo Hosting 2027 landing page (page-scoped; additive) ---- */ /* ---- Odoo Hosting 2027 landing page (page-scoped; additive) ---- */
.cmp-sr-only { .cmp-sr-only {
@@ -479,7 +478,7 @@
{ {
"@type": "Product", "@type": "Product",
"name": "Verwaltetes Odoo Community Hosting", "name": "Verwaltetes Odoo Community Hosting",
"description": "Verwaltetes Odoo Community Hosting: Server, Backups, SSL-Zertifikat und Versions-Upgrades laufen für Sie zu einem Jahrespreis, ohne Lizenz pro Benutzer bei Odoo Community.", "description": "Verwaltetes Odoo Community Hosting: Server, Backups und SSL-Zertifikat laufen für Sie, und Ihre Odoo-Installation bleibt innerhalb ihrer Version aktuell, zu einem Jahrespreis, ohne Lizenz pro Benutzer bei Odoo Community. Ein Wechsel auf eine neue Hauptversion ist eine Migration und wird separat angeboten.",
"brand": { "@id": "https://odoo4projects.com/#organization" }, "brand": { "@id": "https://odoo4projects.com/#organization" },
"offers": [ "offers": [
{ {
@@ -560,7 +559,7 @@
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}}; window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init() plausible.init()
</script> </script>
<script defer src="/static/lang.js"></script> <script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head> </head>
<body> <body>
<!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. --> <!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. -->
@@ -576,14 +575,15 @@
</div> </div>
<!-- Hero --> <!-- Hero -->
<main>
<section class="hero cmp-hero"> <section class="hero cmp-hero">
<div class="container"> <div class="container">
<p class="hero-kicker">Vollständig verwaltetes Odoo Community Hosting</p> <p class="hero-kicker">Vollständig verwaltetes Odoo Community Hosting</p>
<h1>Odoo Hosting 2027: verwaltetes Odoo Community, <span>in etwa fünf Minuten einsatzbereit</span></h1> <h1>Odoo Hosting 2027: verwaltetes Odoo Community, <span>in etwa fünf Minuten einsatzbereit</span></h1>
<p class="hero-sub"> <p class="hero-sub">
Ein verwaltetes Odoo Community System: Ihr Lager, Ihre Aufträge, Ihre Rechnungen und Ihre Kunden in Ein verwaltetes Odoo Community System: Ihr Lager, Ihre Aufträge, Ihre Rechnungen und Ihre Kunden in
einem System. Wir betreiben Server, Backups, Zertifikat und Upgrades. Sie pflegen Ihre Daten ein und einem System. Wir betreiben Server, Backups, Zertifikat und die Updates innerhalb Ihrer Odoo-Version.
kümmern sich ums Geschäft. Sie pflegen Ihre Daten ein und kümmern sich ums Geschäft.
</p> </p>
<div class="hero-stats"> <div class="hero-stats">
<span class="hero-stat"><i aria-hidden="true">&#10003;</i> Etwa 5 Minuten bis zum laufenden System</span> <span class="hero-stat"><i aria-hidden="true">&#10003;</i> Etwa 5 Minuten bis zum laufenden System</span>
@@ -606,7 +606,9 @@
<p class="cmp-lead"> <p class="cmp-lead">
Sie sollten nicht die IT-Abteilung werden müssen, um zu wissen, was in Ihrem Lager liegt. Ein Sie sollten nicht die IT-Abteilung werden müssen, um zu wissen, was in Ihrem Lager liegt. Ein
Geschäftssystem abonniert man, man baut es nicht. Server, Patches, Backups, Zertifikat, Geschäftssystem abonniert man, man baut es nicht. Server, Patches, Backups, Zertifikat,
Versions-Upgrade: das ist dauerhaft unsere Aufgabe, und kein Abend, den Sie Ihrer Firma zurückgeben. die Updates innerhalb Ihrer Odoo-Version: das ist dauerhaft unsere Aufgabe, und kein Abend,
den Sie Ihrer Firma zurückgeben. Die Datenbank auf eine neue Hauptversion zu bringen, ist eine
Migration, die wir separat anbieten.
</p> </p>
<p class="cmp-lead"> <p class="cmp-lead">
Deshalb bringen wir Ihr Lager, Ihre Aufträge, Ihre Rechnungen und die offenen Forderungen an einen Deshalb bringen wir Ihr Lager, Ihre Aufträge, Ihre Rechnungen und die offenen Forderungen an einen
@@ -703,7 +705,7 @@
</tr> </tr>
<tr> <tr>
<th scope="row">Versions-Upgrades</th> <th scope="row">Versions-Upgrades</th>
<td class="is-us">Ein Support-Ticket. Wir migrieren die Versionen 17, 18 und 19.</td> <td class="is-us">Updates und Upgrades innerhalb Ihrer Version sind inklusive. Ein Wechsel auf eine neue Hauptversion ist eine Migration und wird separat angeboten.</td>
<td>Sie machen es selbst, nach einem vollständigen Backup.</td> <td>Sie machen es selbst, nach einem vollständigen Backup.</td>
<td>Push in den Branch; scheitert ein Modul-Update, setzt Odoo den Server zurück und rollt die Datenbank zurück.</td> <td>Push in den Branch; scheitert ein Modul-Update, setzt Odoo den Server zurück und rollt die Datenbank zurück.</td>
<td>Automatisch und verpflichtend nach Odoos Zeitplan.</td> <td>Automatisch und verpflichtend nach Odoos Zeitplan.</td>
@@ -979,6 +981,7 @@
<!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page <!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page
carries no external icon dependency) --> carries no external icon dependency) -->
</main>
<footer class="footer"> <footer class="footer">
<div class="container"> <div class="container">
<div class="fgrid"> <div class="fgrid">
@@ -1010,7 +1013,7 @@
<a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a> <a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a>
<a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a> <a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a>
</div> </div>
<form class="footer-contact-form" id="footerContactForm" action="mailto:hello@odoo4projects.com" method="post" enctype="text/plain"> <form class="footer-contact-form" id="footerContactForm" action="mailto:support@odoo4projects.com" method="post" enctype="text/plain">
<label class="cmp-sr-only" for="footerContactEmail">Ihre E-Mail-Adresse</label> <label class="cmp-sr-only" for="footerContactEmail">Ihre E-Mail-Adresse</label>
<input type="email" id="footerContactEmail" name="email" placeholder="Ihre E-Mail-Adresse" required /> <input type="email" id="footerContactEmail" name="email" placeholder="Ihre E-Mail-Adresse" required />
<div class="footer-textarea-wrap"> <div class="footer-textarea-wrap">
+8 -4
View File
@@ -29,7 +29,7 @@
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/> />
<link rel="stylesheet" href="/style.css" /> <link rel="stylesheet" href="/style.css?v=061517baef" />
<style> <style>
/* Language selector — the page's top row: the back-link stays left, the selector travels right. /* Language selector — the page's top row: the back-link stays left, the selector travels right.
Cream canvas, so lang.js's own dark-brass active colour is the correct one (no override). */ Cream canvas, so lang.js's own dark-brass active colour is the correct one (no override). */
@@ -274,9 +274,10 @@
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}}; window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init() plausible.init()
</script> </script>
<script defer src="/static/lang.js"></script> <script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head> </head>
<body> <body>
<main>
<div class="container"> <div class="container">
<div class="blog-header"> <div class="blog-header">
<a href="/de/">&larr; Zurück zur Startseite</a> <a href="/de/">&larr; Zurück zur Startseite</a>
@@ -291,7 +292,9 @@
<p> <p>
Für die schnellste Hilfe lesen Sie bitte zuerst unsere <strong>FAQ</strong> — unser Support-Bot Für die schnellste Hilfe lesen Sie bitte zuerst unsere <strong>FAQ</strong> — unser Support-Bot
hilft bei schnellen Lösungen wie <strong>git pull</strong> oder Neustarts — oder senden Sie hilft bei schnellen Lösungen wie <strong>git pull</strong> oder Neustarts — oder senden Sie
einfach ein <strong>Ticket</strong> an unser Support-Team. einfach ein <strong>Ticket</strong> an unser Support-Team. Dringende Themen kann der
<strong>KI-Agent</strong> übernehmen, den wir anbieten, und E-Mail-Support antwortet
innerhalb von <strong>24 Stunden</strong>.
</p> </p>
</div> </div>
</section> </section>
@@ -582,6 +585,7 @@
}); });
</script> </script>
<script defer src="/static/agent-odoo4projects/agent.js"></script> </main>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body> </body>
</html> </html>
+10 -7
View File
@@ -128,7 +128,9 @@
.hero-stats{display:flex;gap:1.5rem;margin-bottom:2rem;flex-wrap:wrap}.hero-stat{display:flex;align-items:center;gap:.4rem;color:rgba(255,255,255,.65);font-size:.85rem}.hero-stat i{color:var(--brass);font-size:.75rem} .hero-stats{display:flex;gap:1.5rem;margin-bottom:2rem;flex-wrap:wrap}.hero-stat{display:flex;align-items:center;gap:.4rem;color:rgba(255,255,255,.65);font-size:.85rem}.hero-stat i{color:var(--brass);font-size:.75rem}
.hero-actions{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:2rem} .hero-actions{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:2rem}
.hero-notice{position:absolute;top:1.25rem;right:1.25rem;z-index:3;display:flex;align-items:center;gap:.55rem;max-width:min(360px,calc(100% - 2rem));padding:.6rem .85rem .6rem 1rem;background:#f5c542;color:var(--midnight);border:1px solid rgba(13,27,46,.14);border-radius:var(--radius);box-shadow:0 8px 24px rgba(13,27,46,.3);font-family:Inter,sans-serif;font-size:.82rem;line-height:1.4;animation:heroNoticeIn .4s cubic-bezier(.16,1,.3,1)} .hero-notice{position:absolute;top:1.25rem;right:1.25rem;z-index:3;display:flex;align-items:center;gap:.55rem;max-width:min(360px,calc(100% - 2rem));padding:.6rem .85rem .6rem 1rem;background:#f5c542;color:var(--midnight);border:1px solid rgba(13,27,46,.14);border-radius:var(--radius);box-shadow:0 8px 24px rgba(13,27,46,.3);font-family:Inter,sans-serif;font-size:.82rem;line-height:1.4;animation:heroNoticeIn .4s cubic-bezier(.16,1,.3,1)}
.hero-notice[hidden]{display:none} /* `.hero-notice[hidden]{display:none}` used to live here; the site-wide `[hidden]`
rule in /style.css hides it (`.hero-notice` sets `display:flex`, which outranked
the UA rule). */
.hero-notice i{color:var(--midnight);font-size:.9rem;flex-shrink:0} .hero-notice i{color:var(--midnight);font-size:.9rem;flex-shrink:0}
.hero-notice-text{flex:1;min-width:0;overflow-wrap:anywhere} .hero-notice-text{flex:1;min-width:0;overflow-wrap:anywhere}
.hero-notice-close{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:1.35rem;height:1.35rem;padding:0;border:0;border-radius:50%;background:rgba(13,27,46,.12);color:var(--midnight);font-size:1rem;line-height:1;cursor:pointer;transition:background .2s} .hero-notice-close{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:1.35rem;height:1.35rem;padding:0;border:0;border-radius:50%;background:rgba(13,27,46,.12);color:var(--midnight);font-size:1rem;line-height:1;cursor:pointer;transition:background .2s}
@@ -220,15 +222,14 @@
#customQuoteModal .biz-success p{color:var(--midnight)} #customQuoteModal .biz-success p{color:var(--midnight)}
@media(max-width:600px){.oca-table td,.oca-table th{padding:.5rem .65rem;font-size:.8rem}.ow-panel{left:5%;right:5%}.ow-close{top:.5rem;right:.5rem;font-size:1.6rem}} @media(max-width:600px){.oca-table td,.oca-table th{padding:.5rem .65rem;font-size:.8rem}.ow-panel{left:5%;right:5%}.ow-close{top:.5rem;right:.5rem;font-size:1.6rem}}
</style> </style>
<link rel="stylesheet" href="/style.css" media="print" onload="this.media='all'" /> <link rel="stylesheet" href="/style.css?v=061517baef" />
<noscript><link rel="stylesheet" href="/style.css" /></noscript>
<!-- Privacy-friendly analytics by Plausible --> <!-- Privacy-friendly analytics by Plausible -->
<script async src="https://plausible.office.odoo4projects.com/js/pa-NhA7Rf-79VopXFQXhSS4e.js"></script> <script async src="https://plausible.office.odoo4projects.com/js/pa-NhA7Rf-79VopXFQXhSS4e.js"></script>
<script> <script>
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}}; window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init() plausible.init()
</script> </script>
<script defer src="/static/lang.js"></script> <script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head> </head>
<body> <body>
<!-- <!--
@@ -240,6 +241,7 @@
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md. FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md.
--> -->
<!-- Hero --> <!-- Hero -->
<main>
<section class="hero"> <section class="hero">
<div class="hero-notice" id="heroConfirmNotice" role="status" aria-live="polite" hidden> <div class="hero-notice" id="heroConfirmNotice" role="status" aria-live="polite" hidden>
<i class="fas fa-check-circle" aria-hidden="true"></i> <i class="fas fa-check-circle" aria-hidden="true"></i>
@@ -496,7 +498,7 @@
<h2 class="ow-title" id="orderPlanTitle">On the Rise</h2> <h2 class="ow-title" id="orderPlanTitle">On the Rise</h2>
<p class="ow-price"> <p class="ow-price">
<span class="ow-price-amount" id="orderPlanPrice">$ 395.00</span> <span id="orderPlanPeriod">/ año</span> <span class="ow-price-amount" id="orderPlanPrice">$ 395.00</span> <span id="orderPlanPeriod">/ año</span>
<span class="ow-price-tax" id="orderPlanTax">+ impuestos</span> <span class="ow-price-tax" id="orderPlanTax">impuestos incluidos</span>
</p> </p>
<p class="ow-coupon-msg" id="orderCouponMsg">¿Dudas antes de comprar? Pregunte al asistente en esta página.</p> <p class="ow-coupon-msg" id="orderCouponMsg">¿Dudas antes de comprar? Pregunte al asistente en esta página.</p>
<p class="ow-yearly-save" id="orderYearlySave">Facturación anual. Sin cuota de alta.</p> <p class="ow-yearly-save" id="orderYearlySave">Facturación anual. Sin cuota de alta.</p>
@@ -625,6 +627,7 @@
</section> </section>
<!-- Footer --> <!-- Footer -->
</main>
<footer class="footer"> <footer class="footer">
<div class="container"> <div class="container">
<div class="fgrid"> <div class="fgrid">
@@ -671,7 +674,7 @@
</footer> </footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script src="/script.js"></script> <script src="/script.js?v=d2046e3469"></script>
<script defer src="/static/agent-odoo4projects/agent.js"></script> <script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body> </body>
</html> </html>
+5 -4
View File
@@ -38,8 +38,7 @@
onload="this.media='all'" onload="this.media='all'"
/> />
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript> <noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="stylesheet" href="/style.css" media="print" onload="this.media='all'" /> <link rel="stylesheet" href="/style.css?v=061517baef" />
<noscript><link rel="stylesheet" href="/style.css" /></noscript>
<style> <style>
/* ---- Odoo Community Upgrade page (page-scoped; additive) ---- */ /* ---- Odoo Community Upgrade page (page-scoped; additive) ---- */
.oc-sr-only { .oc-sr-only {
@@ -480,7 +479,7 @@
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}}; window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init() plausible.init()
</script> </script>
<script defer src="/static/lang.js"></script> <script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head> </head>
<body> <body>
<!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. --> <!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. -->
@@ -496,6 +495,7 @@
</div> </div>
<!-- Hero --> <!-- Hero -->
<main>
<section class="hero oc-hero"> <section class="hero oc-hero">
<div class="hero-bg"> <div class="hero-bg">
<img <img
@@ -837,6 +837,7 @@ sudo -u postgres pg_restore -d &lt;DB_NEW&gt; /path/backup/&lt;DB&gt;.dump
<!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page <!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page
carries no external icon dependency) --> carries no external icon dependency) -->
</main>
<footer class="footer"> <footer class="footer">
<div class="container"> <div class="container">
<div class="fgrid"> <div class="fgrid">
@@ -868,7 +869,7 @@ sudo -u postgres pg_restore -d &lt;DB_NEW&gt; /path/backup/&lt;DB&gt;.dump
<a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a> <a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a>
<a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a> <a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a>
</div> </div>
<form class="footer-contact-form" id="footerContactForm" action="mailto:hello@odoo4projects.com" method="post" enctype="text/plain"> <form class="footer-contact-form" id="footerContactForm" action="mailto:support@odoo4projects.com" method="post" enctype="text/plain">
<label class="oc-sr-only" for="footerContactEmail">Su dirección de correo</label> <label class="oc-sr-only" for="footerContactEmail">Su dirección de correo</label>
<input type="email" id="footerContactEmail" name="email" placeholder="Su dirección de correo" required /> <input type="email" id="footerContactEmail" name="email" placeholder="Su dirección de correo" required />
<div class="footer-textarea-wrap"> <div class="footer-textarea-wrap">
+162 -19
View File
@@ -31,8 +31,9 @@
onload="this.media='all'" onload="this.media='all'"
/> />
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript> <noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="stylesheet" href="/style.css" media="print" onload="this.media='all'" /> <link rel="preload" as="image" href="/images/hero/bootcamp-hero-mobile-900.webp" media="(max-width: 640px)" fetchpriority="high" />
<noscript><link rel="stylesheet" href="/style.css" /></noscript> <link rel="preload" as="image" href="/images/hero/bootcamp-hero-desktop-1920.webp" media="(min-width: 641px)" fetchpriority="high" />
<link rel="stylesheet" href="/style.css?v=061517baef" />
<style> <style>
/* ---- ODOO ERP Automation Bootcamp landing page (page-scoped; additive) ---- */ /* ---- ODOO ERP Automation Bootcamp landing page (page-scoped; additive) ---- */
.bc-sr-only { .bc-sr-only {
@@ -192,6 +193,14 @@
form[action$="/shop/post_basket"] .btn { form[action$="/shop/post_basket"] .btn {
line-height: inherit; line-height: inherit;
} }
/* At 0 seats /static/bootcamp-availability.js disables these Buy buttons; make the
disabled state read as disabled — quiet dimming in the page's own CI, never an
alert banner. */
form[action$="/shop/post_basket"] .btn:disabled,
form[action$="/shop/post_basket"] .btn[aria-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
}
/* Grids — auto-fit so no breakpoint is needed */ /* Grids — auto-fit so no breakpoint is needed */
.bc-list { .bc-list {
@@ -223,7 +232,7 @@
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
box-shadow: var(--shadow-rest); box-shadow: var(--shadow-rest);
padding: 1.4rem 1.4rem 1.4rem 3.4rem; padding: 1.4rem 1.4rem 1.4rem 3.4rem;
transition: box-shadow 0.25s, transform 0.25s; transition: transform 0.25s; /* box-shadow kept, not transitioned (non-composited) */
} }
.bc-lessons li:hover { .bc-lessons li:hover {
box-shadow: var(--shadow-hover); box-shadow: var(--shadow-hover);
@@ -313,7 +322,7 @@
.bc-schedule dt { .bc-schedule dt {
font-family: Marcellus, Georgia, serif; font-family: Marcellus, Georgia, serif;
font-size: 0.92rem; font-size: 0.92rem;
color: var(--brass-deep); color: #876c1e; /* 4.68:1 on --ivory (was --brass-deep 4.49:1) */
} }
.bc-schedule dd { .bc-schedule dd {
margin: 0; margin: 0;
@@ -452,6 +461,109 @@
.btn-brass:focus-visible { .btn-brass:focus-visible {
outline-color: #fff; outline-color: #fff;
} }
/* Waiting list — for someone who cannot attend the January cohort.
Deliberately quieter than the Buy button: outline button, smaller type, no brass fill. */
.bc-waitlist {
/* Component-scoped error colour: the site has no shared token for it. */
--wl-error: #b02a37;
max-width: 720px;
margin: 2.5rem auto 0;
padding: 1.5rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--ivory);
text-align: left;
}
.bc-wl-title {
margin: 0 0 0.5rem;
font-size: 1.1rem;
color: var(--ink);
}
.bc-wl-lead {
margin: 0 0 1.1rem;
font-size: 0.88rem;
line-height: 1.6;
color: rgba(23, 28, 38, 0.78); /* the page's own .bc-note value, on the same cream */
}
.bc-wl-form input,
.bc-wl-form textarea {
display: block;
width: 100%;
box-sizing: border-box;
margin-bottom: 0.6rem;
padding: 0.6rem 0.75rem;
font-family: Inter, sans-serif;
font-size: 0.9rem;
color: var(--ink);
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
}
.bc-wl-form textarea {
min-height: 3.4rem;
resize: vertical;
}
.bc-wl-btn {
padding: 0.6rem 1.5rem;
font-size: 0.8rem;
background: transparent;
color: var(--ink);
border-color: rgba(23, 28, 38, 0.35);
}
.bc-wl-btn:hover {
border-color: var(--brass);
background: rgba(201, 162, 39, 0.12);
transform: none;
box-shadow: none;
}
.bc-wl-btn:disabled {
opacity: 0.6;
cursor: default;
}
.bc-wl-success {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--success);
}
.bc-wl-error {
margin: 0;
font-size: 0.9rem;
color: var(--wl-error);
}
/* --- 2026-11-02 PageSpeed pass (page-scoped, additive) ---------------------
1. The footer <h4>s became <h3>s so the heading order descends after the last
section h2 (a11y "heading-order"); these rules keep their look identical to
the shared .footer h4 in /style.css.
2. A link inside an on-dark note is the lighter brass (7.15:1 on midnight,
A11y "color-contrast") and underlined, so it is distinguishable without
colour (A11y "link-in-text-block"). */
.footer h3 {
font-family: Marcellus, Georgia, serif;
color: #fff;
margin-bottom: 0.8rem;
font-size: 1rem;
letter-spacing: 0.03em;
}
.bc-note.on-dark a {
color: var(--brass);
text-decoration: underline;
}
/* the footer copyright line at .35 alpha was 3.2:1 on midnight */
.footer .fcopy {
color: rgba(255, 255, 255, 0.5); /* 5.17:1 */
}
/* no-JS footer fallback (rendered only when JavaScript is off) */
.footer .bc-noscript {
margin-top: 0.6rem;
font-size: 0.85rem;
}
.footer .bc-noscript a {
color: var(--brass);
text-decoration: underline;
}
</style> </style>
<script type="application/ld+json"> <script type="application/ld+json">
{ {
@@ -501,7 +613,7 @@
}, },
"offers": { "offers": {
"@type": "Offer", "@type": "Offer",
"price": "465.00", "price": "595.00",
"priceCurrency": "USD", "priceCurrency": "USD",
"category": "Paid", "category": "Paid",
"url": "https://odoo4projects.com/odoo-erp-automation-bootcamp/" "url": "https://odoo4projects.com/odoo-erp-automation-bootcamp/"
@@ -576,6 +688,7 @@
</div> </div>
<!-- Hero --> <!-- Hero -->
<main>
<section class="hero bc-hero"> <section class="hero bc-hero">
<div class="container"> <div class="container">
<p class="hero-kicker">Q1 2027 &middot; 10 SESIONES EN DIRECTO &middot; OLIVER ARNOLD</p> <p class="hero-kicker">Q1 2027 &middot; 10 SESIONES EN DIRECTO &middot; OLIVER ARNOLD</p>
@@ -591,14 +704,21 @@
</div> </div>
<div class="hero-actions"> <div class="hero-actions">
<form method="post" action="https://20.odoo4projects.com/shop/post_basket" target="_blank" class="plausible-event-name=Buy"> <form method="post" action="https://20.odoo4projects.com/shop/post_basket" target="_blank" class="plausible-event-name=Buy">
<input type="hidden" name="basket" value='[{"product_id":12,"qty":1}]' /> <input type="hidden" name="basket" value='[{"product_id":17,"qty":1,"info":"Q1 2027, English"}]' />
<input type="hidden" name="clear" value="1" /> <input type="hidden" name="clear" value="1" />
<button type="submit" class="btn btn-brass btn-lg">Comprar por 465.00 USD</button> <button type="submit" class="btn btn-brass btn-lg">Comprar por 595.00 USD</button>
</form> </form>
</div> </div>
</div> </div>
</section> </section>
<!-- Live remaining-seat count, immediately below the hero. One seat element only on
the page; /static/bootcamp-availability.js fills it from the availability webhook
and leaves it empty+hidden if that fetch fails. -->
<div class="container">
<p class="bc-note bc-seats" data-bc-seats hidden></p>
</div>
<!-- From zero to a running ERP --> <!-- From zero to a running ERP -->
<section class="section-light"> <section class="section-light">
<div class="container"> <div class="container">
@@ -612,9 +732,9 @@
<p class="bc-subline">Tres meses. Diez horas de clases. Un sistema que dirige su empresa.</p> <p class="bc-subline">Tres meses. Diez horas de clases. Un sistema que dirige su empresa.</p>
<div class="bc-action"> <div class="bc-action">
<form method="post" action="https://20.odoo4projects.com/shop/post_basket" target="_blank" class="plausible-event-name=Buy"> <form method="post" action="https://20.odoo4projects.com/shop/post_basket" target="_blank" class="plausible-event-name=Buy">
<input type="hidden" name="basket" value='[{"product_id":12,"qty":1}]' /> <input type="hidden" name="basket" value='[{"product_id":17,"qty":1,"info":"Q1 2027, English"}]' />
<input type="hidden" name="clear" value="1" /> <input type="hidden" name="clear" value="1" />
<button type="submit" class="btn btn-brass btn-lg">Comprar por 465.00 USD</button> <button type="submit" class="btn btn-brass btn-lg">Comprar por 595.00 USD</button>
</form> </form>
</div> </div>
</div> </div>
@@ -769,18 +889,37 @@
<section class="section-light"> <section class="section-light">
<div class="container"> <div class="container">
<h2 class="section-title">Precio y reserva</h2> <h2 class="section-title">Precio y reserva</h2>
<p class="bc-price">El programa cuesta USD 465.00 por las diez sesiones.</p> <p class="bc-price">El programa cuesta USD 595.00 por las diez sesiones.</p>
<p class="bc-note"><strong>Limitado a 6 participantes</strong> — el grupo es reducido, para que el instructor pueda trabajar sobre su propia configuración.</p> <p class="bc-note"><strong>Limitado a 6 participantes</strong> — el grupo es reducido, para que el instructor pueda trabajar sobre su propia configuración.</p>
<p class="bc-note">Una plaza se puede <strong>transferir en cualquier momento</strong> y <strong>cancelar hasta dos semanas antes</strong> de la primera sesión, el 21 de enero de 2027.</p>
<p class="bc-lead"> <p class="bc-lead">
Cubre el programa completo: diez sesiones en directo, un año de Odoo Community Edition alojado, la comunidad de antiguos alumnos y la revisión experta. Cubre el programa completo: diez sesiones en directo, un año de Odoo Community Edition alojado, la comunidad de antiguos alumnos y la revisión experta.
</p> </p>
<div class="bc-action"> <div class="bc-action">
<form method="post" action="https://20.odoo4projects.com/shop/post_basket" target="_blank" class="plausible-event-name=Buy"> <form method="post" action="https://20.odoo4projects.com/shop/post_basket" target="_blank" class="plausible-event-name=Buy">
<input type="hidden" name="basket" value='[{"product_id":12,"qty":1}]' /> <input type="hidden" name="basket" value='[{"product_id":17,"qty":1,"info":"Q1 2027, English"}]' />
<input type="hidden" name="clear" value="1" /> <input type="hidden" name="clear" value="1" />
<button type="submit" class="btn btn-brass btn-lg">Comprar por 465.00 USD</button> <button type="submit" class="btn btn-brass btn-lg">Comprar por 595.00 USD</button>
</form> </form>
</div> </div>
<!-- Waiting list: for a visitor who cannot attend the January cohort.
Secondary to the Buy button above. Same CRM webhook and same payload shape as the
site's footer contact form (area:'message'); the copy lives here, in the page's
language, and /static/bootcamp-waitlist.js carries no strings. -->
<div class="bc-waitlist">
<h3 class="bc-wl-title">&iquest;Otra zona horaria u otro idioma?</h3>
<p class="bc-wl-lead">El grupo de enero se reúne los jueves, 18:00&ndash;19:00, hora del centro de EE. UU., en inglés. Si necesita otro horario u otro idioma, apúntese a la lista de espera y le escribiremos cuando se abra un grupo que le encaje.</p>
<form id="bcWaitlistForm" class="bc-wl-form" action="https://n8n.odoo4projects.com/webhook/ODOO4projects/crm" method="post" enctype="text/plain">
<label class="bc-sr-only" for="bcWaitlistEmail">Su dirección de correo electrónico</label>
<input type="email" id="bcWaitlistEmail" name="email" placeholder="Su dirección de correo electrónico" required />
<label class="bc-sr-only" for="bcWaitlistComment">Qu&eacute; zona horaria o idioma necesita</label>
<textarea id="bcWaitlistComment" name="comment" rows="2" placeholder="¿Qué zona horaria o idioma necesita?" required></textarea>
<button type="submit" class="btn bc-wl-btn" data-sending="Apuntando&hellip;">Apuntarme a la lista de espera</button>
</form>
<p class="bc-wl-success" id="bcWaitlistSuccess" role="status" style="display:none">Está en la lista de espera. Le escribiremos en cuanto se anuncie un grupo en su zona horaria o idioma.</p>
<p class="bc-wl-error" id="bcWaitlistError" role="alert" style="display:none">Algo ha fallado. Inténtelo de nuevo.</p>
</div>
<ul class="bc-related"> <ul class="bc-related">
<li><a href="/es/support.html#contact">¿Preguntas antes de reservar? Hable antes con nosotros</a></li> <li><a href="/es/support.html#contact">¿Preguntas antes de reservar? Hable antes con nosotros</a></li>
<li><a href="/es/odoo-hosting-2027.html">Cómo es el hosting después de su año con nosotros</a></li> <li><a href="/es/odoo-hosting-2027.html">Cómo es el hosting después de su año con nosotros</a></li>
@@ -828,6 +967,7 @@
</div> </div>
</div> </div>
</section> </section>
</main>
<!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page <!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page
carries no external icon dependency) --> carries no external icon dependency) -->
@@ -835,7 +975,7 @@
<div class="container"> <div class="container">
<div class="fgrid"> <div class="fgrid">
<div> <div>
<h4>Enlaces útiles</h4> <h3>Enlaces útiles</h3>
<ul> <ul>
<li><a href="/es/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li> <li><a href="/es/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li>
<li><a href="/es/odoo-hosting-2027.html">ODOO Hosting en 2027</a></li> <li><a href="/es/odoo-hosting-2027.html">ODOO Hosting en 2027</a></li>
@@ -844,7 +984,7 @@
</ul> </ul>
</div> </div>
<div> <div>
<h4>Socios</h4> <h3>Socios</h3>
<div class="partner-list"> <div class="partner-list">
<a href="https://oc-guy.com" target="_blank" rel="noopener" class="partner-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9a227" stroke-width="2" aria-hidden="true"><path d="M20 7h-9M14 17H5M20 7l-4-4M20 7l-4 4M4 17l4 4M4 17l4-4" /></svg> OC Guy</a> <a href="https://oc-guy.com" target="_blank" rel="noopener" class="partner-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9a227" stroke-width="2" aria-hidden="true"><path d="M20 7h-9M14 17H5M20 7l-4-4M20 7l-4 4M4 17l4 4M4 17l4-4" /></svg> OC Guy</a>
<a href="https://oca-py.org" target="_blank" rel="noopener" class="partner-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9a227" stroke-width="2" aria-hidden="true"><path d="M20 7h-9M14 17H5M20 7l-4-4M20 7l-4 4M4 17l4 4M4 17l4-4" /></svg> OCA Paraguay</a> <a href="https://oca-py.org" target="_blank" rel="noopener" class="partner-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9a227" stroke-width="2" aria-hidden="true"><path d="M20 7h-9M14 17H5M20 7l-4-4M20 7l-4 4M4 17l4 4M4 17l4-4" /></svg> OCA Paraguay</a>
@@ -852,17 +992,17 @@
</div> </div>
</div> </div>
<div class="fcol-desc"> <div class="fcol-desc">
<h4>Sobre nosotros</h4> <h3>Sobre nosotros</h3>
<p>Creamos soluciones de negocio inteligentes y escalables combinando la <strong>estabilidad</strong> de Odoo ERP con la <strong>potencia</strong> de los agentes de IA y las automatizaciones N8N.</p> <p>Creamos soluciones de negocio inteligentes y escalables combinando la <strong>estabilidad</strong> de Odoo ERP con la <strong>potencia</strong> de los agentes de IA y las automatizaciones N8N.</p>
<p>Desde unas ventas ágiles hasta despliegues globales, nuestro <strong>lean y ecológico</strong> enfoque ayuda a las empresas a reducir costes, ganar eficiencia y crecer sin límites.</p> <p>Desde unas ventas ágiles hasta despliegues globales, nuestro <strong>lean y ecológico</strong> enfoque ayuda a las empresas a reducir costes, ganar eficiencia y crecer sin límites.</p>
</div> </div>
<div> <div>
<h4>Síganos</h4> <h3>Síganos</h3>
<div class="social"> <div class="social">
<a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a> <a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a>
<a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a> <a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a>
</div> </div>
<form class="footer-contact-form" id="footerContactForm" action="mailto:hello@odoo4projects.com" method="post" enctype="text/plain"> <form class="footer-contact-form" id="footerContactForm" action="https://n8n.odoo4projects.com/webhook/ODOO4projects/crm" method="post" enctype="text/plain" data-ok="¡Gracias! Le responderemos en breve." data-err="Algo ha fallado. Inténtelo de nuevo.">
<label class="bc-sr-only" for="footerContactEmail">Su dirección de correo</label> <label class="bc-sr-only" for="footerContactEmail">Su dirección de correo</label>
<input type="email" id="footerContactEmail" name="email" placeholder="Su dirección de correo" required /> <input type="email" id="footerContactEmail" name="email" placeholder="Su dirección de correo" required />
<div class="footer-textarea-wrap"> <div class="footer-textarea-wrap">
@@ -872,12 +1012,15 @@
</div> </div>
<div class="footer-form-status" id="footerFormStatus" role="status"></div> <div class="footer-form-status" id="footerFormStatus" role="status"></div>
</form> </form>
<noscript><p class="bc-noscript">JavaScript está desactivado. Escríbanos directamente a <a href="mailto:support@odoo4projects.com">support@odoo4projects.com</a>.</p></noscript>
</div> </div>
</div> </div>
<div class="fcopy">Copyright &copy; ODOO4projects LLC</div> <div class="fcopy">Copyright &copy; ODOO4projects LLC</div>
</div> </div>
</footer> </footer>
<script defer src="/static/buy.js"></script> <script defer src="/static/buy.js?v=31314de600"></script>
<script defer src="/static/lang.js"></script> <script defer src="/static/bootcamp-availability.js?v=94dfd8cd7c"></script>
<script defer src="/static/bootcamp-waitlist.js?v=6c2e2fbd33"></script>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</body> </body>
</html> </html>
+11 -8
View File
@@ -31,8 +31,7 @@
onload="this.media='all'" onload="this.media='all'"
/> />
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript> <noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="stylesheet" href="/style.css" media="print" onload="this.media='all'" /> <link rel="stylesheet" href="/style.css?v=061517baef" />
<noscript><link rel="stylesheet" href="/style.css" /></noscript>
<style> <style>
/* ---- Odoo Hosting 2027 landing page (page-scoped; additive) ---- */ /* ---- Odoo Hosting 2027 landing page (page-scoped; additive) ---- */
.cmp-sr-only { .cmp-sr-only {
@@ -479,7 +478,7 @@
{ {
"@type": "Product", "@type": "Product",
"name": "Hosting gestionado de Odoo Community", "name": "Hosting gestionado de Odoo Community",
"description": "Hosting gestionado de Odoo Community: el servidor, las copias de seguridad, el certificado SSL y las actualizaciones de versión corren por nuestra cuenta a un precio anual, sin licencia por usuario en Odoo Community.", "description": "Hosting gestionado de Odoo Community: el servidor, las copias de seguridad y el certificado SSL corren por nuestra cuenta, y mantenemos su Odoo al día dentro de su versión, a un precio anual, sin licencia por usuario en Odoo Community. Cambiar a una nueva versión mayor es una migración y se presupuesta por separado.",
"brand": { "@id": "https://odoo4projects.com/#organization" }, "brand": { "@id": "https://odoo4projects.com/#organization" },
"offers": [ "offers": [
{ {
@@ -560,7 +559,7 @@
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}}; window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init() plausible.init()
</script> </script>
<script defer src="/static/lang.js"></script> <script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head> </head>
<body> <body>
<!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. --> <!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. -->
@@ -576,6 +575,7 @@
</div> </div>
<!-- Hero --> <!-- Hero -->
<main>
<section class="hero cmp-hero"> <section class="hero cmp-hero">
<div class="container"> <div class="container">
<p class="hero-kicker">Hosting de Odoo Community totalmente gestionado</p> <p class="hero-kicker">Hosting de Odoo Community totalmente gestionado</p>
@@ -583,7 +583,7 @@
<p class="hero-sub"> <p class="hero-sub">
Un sistema Odoo Community gestionado: su inventario, sus pedidos, sus facturas y sus clientes en un Un sistema Odoo Community gestionado: su inventario, sus pedidos, sus facturas y sus clientes en un
solo sistema. Nosotros nos ocupamos del servidor, las copias de seguridad, el certificado y las solo sistema. Nosotros nos ocupamos del servidor, las copias de seguridad, el certificado y las
actualizaciones. Usted introduce sus datos y se dedica a su negocio. actualizaciones dentro de su versión de Odoo. Usted introduce sus datos y se dedica a su negocio.
</p> </p>
<div class="hero-stats"> <div class="hero-stats">
<span class="hero-stat"><i aria-hidden="true">&#10003;</i> Unos 5 minutos hasta tener el sistema funcionando</span> <span class="hero-stat"><i aria-hidden="true">&#10003;</i> Unos 5 minutos hasta tener el sistema funcionando</span>
@@ -606,8 +606,10 @@
<p class="cmp-lead"> <p class="cmp-lead">
No debería tener que convertirse en el departamento de informática para saber qué hay en su No debería tener que convertirse en el departamento de informática para saber qué hay en su
inventario. Un sistema de gestión se contrata como un servicio, no se construye como un proyecto. El inventario. Un sistema de gestión se contrata como un servicio, no se construye como un proyecto. El
servidor, los parches, las copias de seguridad, el certificado, la actualización de versión: eso es servidor, los parches, las copias de seguridad, el certificado, las actualizaciones dentro de su versión de Odoo: eso es
nuestro trabajo, de forma permanente, y no una tarde que usted le devuelve a su empresa. nuestro trabajo, de forma permanente, y no una tarde que usted le devuelve a su empresa.
Llevar la base de datos a una nueva versión mayor es una migración que
presupuestamos por separado.
</p> </p>
<p class="cmp-lead"> <p class="cmp-lead">
Por eso reunimos su inventario, sus pedidos, sus facturas y quién le debe dinero en un solo lugar, y Por eso reunimos su inventario, sus pedidos, sus facturas y quién le debe dinero en un solo lugar, y
@@ -704,7 +706,7 @@
</tr> </tr>
<tr> <tr>
<th scope="row">Actualizaciones de versión</th> <th scope="row">Actualizaciones de versión</th>
<td class="is-us">Un ticket de soporte. Migramos las versiones 17, 18 y 19.</td> <td class="is-us">Las actualizaciones y las mejoras dentro de su versión están incluidas. Cambiar a una nueva versión mayor es una migración y se presupuesta por separado.</td>
<td>Lo hace usted, después de una copia de seguridad completa.</td> <td>Lo hace usted, después de una copia de seguridad completa.</td>
<td>Suba la rama; si falla la actualización de un módulo, Odoo revierte el servidor y restaura la base de datos.</td> <td>Suba la rama; si falla la actualización de un módulo, Odoo revierte el servidor y restaura la base de datos.</td>
<td>Automáticas y obligatorias según el calendario de Odoo.</td> <td>Automáticas y obligatorias según el calendario de Odoo.</td>
@@ -978,6 +980,7 @@
<!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page <!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page
carries no external icon dependency) --> carries no external icon dependency) -->
</main>
<footer class="footer"> <footer class="footer">
<div class="container"> <div class="container">
<div class="fgrid"> <div class="fgrid">
@@ -1009,7 +1012,7 @@
<a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a> <a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a>
<a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a> <a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a>
</div> </div>
<form class="footer-contact-form" id="footerContactForm" action="mailto:hello@odoo4projects.com" method="post" enctype="text/plain"> <form class="footer-contact-form" id="footerContactForm" action="mailto:support@odoo4projects.com" method="post" enctype="text/plain">
<label class="cmp-sr-only" for="footerContactEmail">Su dirección de correo</label> <label class="cmp-sr-only" for="footerContactEmail">Su dirección de correo</label>
<input type="email" id="footerContactEmail" name="email" placeholder="Su dirección de correo" required /> <input type="email" id="footerContactEmail" name="email" placeholder="Su dirección de correo" required />
<div class="footer-textarea-wrap"> <div class="footer-textarea-wrap">
+8 -4
View File
@@ -29,7 +29,7 @@
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/> />
<link rel="stylesheet" href="/style.css" /> <link rel="stylesheet" href="/style.css?v=061517baef" />
<style> <style>
/* Language selector — the page's top row: the back-link stays left, the selector travels right. /* Language selector — the page's top row: the back-link stays left, the selector travels right.
Cream canvas, so lang.js's own dark-brass active colour is the correct one (no override). */ Cream canvas, so lang.js's own dark-brass active colour is the correct one (no override). */
@@ -274,9 +274,10 @@
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}}; window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init() plausible.init()
</script> </script>
<script defer src="/static/lang.js"></script> <script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head> </head>
<body> <body>
<main>
<div class="container"> <div class="container">
<div class="blog-header"> <div class="blog-header">
<a href="/es/">&larr; Volver al inicio</a> <a href="/es/">&larr; Volver al inicio</a>
@@ -291,7 +292,9 @@
<p> <p>
Para una ayuda más rápida, lea primero nuestras <strong>preguntas frecuentes</strong>: nuestro Para una ayuda más rápida, lea primero nuestras <strong>preguntas frecuentes</strong>: nuestro
bot de soporte le ayuda con soluciones rápidas como <strong>git pull</strong> o reinicios — o bot de soporte le ayuda con soluciones rápidas como <strong>git pull</strong> o reinicios — o
envíe simplemente un <strong>ticket</strong> a nuestro equipo de soporte. envíe simplemente un <strong>ticket</strong> a nuestro equipo de soporte. Los temas urgentes los puede
atender el <strong>agente de IA</strong> que ofrecemos, y el soporte por correo responde
en un plazo de <strong>24 horas</strong>.
</p> </p>
</div> </div>
</section> </section>
@@ -582,6 +585,7 @@
}); });
</script> </script>
<script defer src="/static/agent-odoo4projects/agent.js"></script> </main>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body> </body>
</html> </html>
+486
View File
@@ -0,0 +1,486 @@
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Freelancer signup feedback — ODOO4projects</title>
<meta name="description" content="Tell us how the ODOO4projects freelancer signup went — every answer helps us improve the process before the directory opens." />
<!-- Private utility page. It is reached ONLY by the link we send, never by search.
noindex+nofollow here, absent from sitemap.xml and llms.txt, and linked from nowhere
on the site. See [[draft-page-convention]] for the same five rule. -->
<meta name="robots" content="noindex, nofollow" />
<!-- Nothing in this page's address is a credential, but the page is private and the
feedback is a person's words: keep the URL out of any third party's logs. -->
<meta name="referrer" content="no-referrer" />
<link rel="canonical" href="https://odoo4projects.com/feedback/" />
<meta property="og:url" content="https://odoo4projects.com/feedback/" />
<meta property="og:title" content="Freelancer signup feedback — ODOO4projects" />
<meta property="og:description" content="Tell us how the ODOO4projects freelancer signup went — every answer helps us improve the process." />
<meta property="og:type" content="website" />
<link rel="icon" href="/images/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap"
media="print"
onload="this.media='all'"
/>
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="stylesheet" href="/style.css?v=061517baef" />
<style>
/* Utility page, same shell as /freelancer-edit/ and the top bar of
/odoo-community-developers/: brand line, one card, the site tokens (:root in style.css)
and the CI palette only. No hero, no footer, no site chrome beyond the brand line. */
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--cream);
color: var(--ink);
font-family: Inter, "Segoe UI", sans-serif;
line-height: 1.6;
}
.wrap { width: 86%; max-width: 1200px; margin: 0 auto; }
.topbar { background: var(--midnight); border-bottom: 1px solid var(--line-brass); padding: 0.85rem 0; }
.brand {
font-family: Marcellus, Georgia, serif;
font-size: 1.15rem;
letter-spacing: 0.04em;
color: #fff;
text-decoration: none;
}
.brand span { color: var(--brass); }
main { padding: 2.5rem 0 4rem; }
.card-wrap { max-width: 760px; margin: 0 auto; }
h1 {
font-family: Marcellus, Georgia, serif;
font-weight: 400;
font-size: clamp(1.5rem, 4vw, 2.2rem);
letter-spacing: 0.02em;
margin: 0 0 0.4rem;
}
.lede { color: var(--ink-soft); margin: 0 0 1rem; }
.panel {
background: var(--ivory);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-rest);
}
.panel + .panel { margin-top: 1.25rem; }
h2 {
font-family: Marcellus, Georgia, serif;
font-weight: 400;
font-size: 1.15rem;
letter-spacing: 0.02em;
margin: 0 0 0.6rem;
}
.state { margin: 0; font-weight: 500; }
.state-warn, .state-err { color: var(--brass-deep); }
.state-ok { color: var(--success); }
.detail { margin: 0.5rem 0 0; font-size: 0.9rem; color: var(--ink-soft); }
/* ---- Forms ---- */
.field { margin: 0 0 1.1rem; }
.field > label { display: block; font-weight: 600; font-size: 0.9rem; margin: 0 0 0.35rem; }
input[type="text"], input[type="email"], textarea, select {
width: 100%;
font: inherit;
padding: 0.6rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
color: var(--ink);
}
textarea { resize: vertical; }
input:focus-visible, textarea:focus-visible, select:focus-visible, button:focus-visible,
input[type="radio"]:focus-visible, input[type="checkbox"]:focus-visible {
outline: 2px solid var(--brass);
outline-offset: 1px;
}
.hint { font-size: 0.82rem; color: var(--ink-soft); margin: 0.35rem 0 0; }
fieldset { border: 1px solid var(--line); border-radius: var(--radius); padding: 0.9rem 1rem 0.4rem; margin: 0 0 1.1rem; }
legend { font-weight: 600; font-size: 0.9rem; padding: 0 0.35rem; }
.choice { display: flex; align-items: flex-start; gap: 0.55rem; margin: 0 0 0.5rem; font-size: 0.9rem; }
.choice input { margin-top: 0.28rem; flex: 0 0 auto; }
.field[data-bad="1"] input, .field[data-bad="1"] textarea, .field[data-bad="1"] select,
fieldset[data-bad="1"] { border-color: var(--brass-deep); }
.err { font-size: 0.82rem; color: var(--brass-deep); font-weight: 600; margin: 0.35rem 0 0; }
.btn {
display: inline-block;
font-family: Marcellus, Georgia, serif;
font-size: 0.9rem;
letter-spacing: 0.04em;
border: 0;
border-radius: var(--radius-pill);
background: var(--brass);
color: var(--midnight);
padding: 0.7rem 1.5rem;
cursor: pointer;
}
.btn:hover { background: var(--brass-bright); }
.btn:focus-visible { outline: 2px solid var(--midnight); outline-offset: 2px; }
.btn[disabled] { opacity: 0.6; cursor: default; }
.status { margin: 0.9rem 0 0; font-weight: 500; min-height: 1.2em; }
.status.state-warn, .status.state-err { color: var(--brass-deep); }
.status.state-ok { color: var(--success); }
/* ---- Progress bar (two steps) ---- */
.progress { margin: 0 0 1.15rem; }
.steps-bar {
list-style: none;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem 0.6rem;
margin: 0;
padding: 0;
font-size: 0.85rem;
}
.steps-bar .step { display: flex; align-items: center; gap: 0.4rem; color: var(--ink-soft); }
.steps-bar .step + .step::before { content: "·"; margin-right: 0.6rem; color: var(--line-brass); }
.step-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.55rem;
height: 1.55rem;
border-radius: 999px;
border: 1px solid var(--line);
background: #fff;
font-size: 0.8rem;
line-height: 1;
}
/* The current step is the brass pill and the bolder label, and it is said out loud
("(current step)", .vh below) — weight and a word, so it is never colour alone. */
.steps-bar .step-current { color: var(--ink); font-weight: 600; }
.steps-bar .step-current .step-mark { background: var(--brass); border-color: var(--brass); color: var(--midnight); }
.link-signup { color: var(--brass-deep); font-weight: 600; }
/* Visually hidden, still read aloud. */
.vh {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
</style>
<!-- The one wire name and shape this page uses, in ONE place. POST only — the page reads
nothing on load. SHAPE (match this in the n8n workflow):
POST submitUrl { "form": "feedback", <one key per question, the answer> }
-> any 2xx = receipt shown; a non-2xx is reported as a failure, never as success.
The body carries the answers and nothing else: no name, no email, no identifier of any
kind. An unexpected shape is named, never guessed. -->
<script>
window.O4F = {
// The one route that receives the feedback form.
submitUrl: "https://n8n.odoo4projects.com/webhook/feedback"
};
</script>
<!-- Privacy-friendly analytics by Plausible (verbatim; the page carries no query). -->
<script async src="https://plausible.office.odoo4projects.com/js/pa-NhA7Rf-79VopXFQXhSS4e.js"></script>
<script>
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
</head>
<body>
<header class="topbar">
<div class="wrap">
<a class="brand" href="https://odoo4projects.com/">ODOO<span>4</span>projects</a>
</div>
</header>
<main>
<div class="wrap">
<div class="card-wrap">
<h1>Freelancer signup feedback</h1>
<p class="lede">
We opened a free freelancer portal on ODOO4projects.com. Sign up there, then tell us how
it went — every answer helps us improve the process.
</p>
<!-- The portal link, visible next to the intro and not buried in step copy. It opens in
a new window and says so. It is a plain link, not the page's primary action —
the feedback form still is. -->
<p class="detail">
The directory opens on <strong>1 December</strong>. Signing up is free for freelancers
and clients, and the feedback you give here is what we fix before then.
<a
class="link-signup"
href="https://odoo4projects.com/odoo-community-developers/"
target="_blank"
rel="noopener noreferrer"
>Sign up on the freelancer portal (opens in a new window)</a>.
</p>
<!-- Filled by the script at the foot of the page. Empty on purpose: with JavaScript off
there is no form to render, so an empty container plus the noscript note below is
the honest picture. -->
<div id="app"></div>
<noscript>
<div class="panel">
<p class="state state-warn">This page needs JavaScript to show the feedback form.</p>
<p class="detail">
Turn JavaScript on in your browser and open this page again — or reply to the message
that brought you here.
</p>
</div>
</noscript>
</div>
</div>
</main>
<script>
(function () {
var C = window.O4F;
var app = document.getElementById("app");
// The freelancer portal — the directory page, at its TOP (its own introduction), not the
// directory's apply anchor: that anchor drops the visitor at the application form,
// mid-page, past the page's own intro. There is no separate login or account page, so this
// is the one target. The static .link-signup link in the markup above is the ONE source of
// this URL; the script-written link in step 1 reads it back, so the two can never disagree.
var PORTAL_URL = document.querySelector(".link-signup").href;
function el(tag, cls, text) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text; // textContent only: a reply is never markup
return n;
}
function show(nodes) {
while (app.firstChild) app.removeChild(app.firstChild);
for (var i = 0; i < nodes.length; i++) app.appendChild(nodes[i]);
return nodes;
}
function status(text, kind) {
var p = el("p", "status" + (kind ? " state-" + kind : ""), text);
p.setAttribute("role", "status");
p.setAttribute("aria-live", "polite");
return p;
}
function fetchJson(url, opts) {
return fetch(url, opts).then(function (r) {
return r.text().then(function (t) {
var body = null;
try { body = JSON.parse(t); } catch (e) { body = null; }
return { ok: r.ok, status: r.status, body: body };
});
});
}
function msgOf(body) {
if (!body || typeof body !== "object") return "";
var m = body.message || body.error || body.detail;
return typeof m === "string" ? m : "";
}
// A link to the freelancer portal. Opens in a new window and says so, so a screen-reader
// user is not surprised by a new tab.
function portalLink() {
var p = el("p", "detail");
var a = el("a", "link-signup");
a.href = PORTAL_URL;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.textContent = "Sign up on the freelancer portal (opens in a new window)";
p.appendChild(a);
return p;
}
// ---- Progress bar (two steps) ---------------------------------------------------------
// Step 1 is the signup on the portal, step 2 is this form. The page has no honest way to
// know the visitor has used the portal link, so step 1 stays current and nothing is
// guessed: the number, the bolder label and the spoken "(current step)" say which one is
// current, so it is never colour alone.
var STEP_LABELS = ["Sign up", "Give feedback"];
function progressBar(current) {
var nav = el("nav", "progress");
nav.setAttribute("aria-label", "Progress");
var ol = el("ol", "steps-bar");
STEP_LABELS.forEach(function (label, i) {
var n = i + 1;
var li = el("li", "step");
var mark = el("span", "step-mark");
mark.setAttribute("aria-hidden", "true");
mark.textContent = String(n);
li.appendChild(mark);
li.appendChild(el("span", "step-label", label));
if (n === current) {
li.className = "step step-current";
li.setAttribute("aria-current", "step");
li.appendChild(el("span", "vh", " (current step)"));
} else {
li.className = "step step-next";
}
ol.appendChild(li);
});
nav.appendChild(ol);
return nav;
}
// ---- Step 1 — the portal link, again, in the step's own copy -------------------------
function signupPanel() {
var box = el("div", "panel");
box.appendChild(el("h2", "", "Step 1 — sign up on the freelancer portal"));
box.appendChild(el("p", "detail",
"Sign up on the ODOO4projects freelancer portal and add your profile. The directory opens on 1 December."));
box.appendChild(portalLink());
return box;
}
// ---- The feedback form ----------------------------------------------------------------
// The n8n form's questions, with the copy corrected. The identity block (name, email,
// anonymous tick) and the publish-consent question are gone — do not bring them back.
// None of these questions is compulsory: the form submits with any subset answered, or none.
// The error nodes (.err) and their CSS are kept, but nothing sets them any more.
var FIELDS = [
{ id: "finalized", label: "Did you finalize the signup?", type: "select", options: [["yes", "Yes"], ["no", "No"]] },
{ id: "difficulty", label: "How hard was the signup?", type: "select", options: [["easy", "Easy"], ["ok", "OK"], ["hard", "Hard"]] },
{ id: "signup_feedback", label: "What is your feedback on the signup?", type: "textarea" },
{ id: "understood_scale", label: "Did you understand how we ask you to rate your skills? (the 15-point scale)", type: "select", options: [["yes", "Yes"], ["no", "No"]] },
{ id: "scale_feedback", label: "What is your feedback on the 15-point skill scale?", type: "textarea" },
{ id: "almost_stopped", label: "What almost stopped you from signing up?", type: "textarea", hint: "If nothing did, say so." },
{ id: "broke_or_confused", label: "Did anything break or confuse you?", type: "textarea" },
{ id: "design_shows_work", label: "Do you think our profile design shows what you do well?", type: "select", options: [["yes", "Yes"], ["so-so", "So-so"], ["no", "No"]] },
{ id: "design_feedback", label: "What can we improve about the profile design?", type: "textarea" },
{ id: "will_stay", label: "Will you stay on the platform?", type: "select", options: [["yes", "Yes"], ["no", "No"]] },
{ id: "improve_offer", label: "How can we improve what we offer you?", type: "textarea" },
{ id: "recommend", label: "Would you recommend this platform to a freelance colleague?", type: "select", options: [["yes", "Yes, good idea"], ["maybe", "Maybe"], ["no", "Preferably not"]] },
{ id: "expect_next", label: "What do you expect to happen next — and how soon?", type: "textarea" }
];
function fieldBlock(spec) {
var f = el("div", "field");
f.setAttribute("data-field", spec.id);
if (spec.type === "radio") {
var fs = el("fieldset");
fs.setAttribute("data-field", spec.id);
var lg = el("legend", "", spec.label);
fs.appendChild(lg);
spec.options.forEach(function (pair) {
var lab = el("label", "choice");
var r = el("input");
r.type = "radio";
r.name = spec.id;
r.value = pair[0];
r.id = spec.id + "-" + pair[0];
lab.appendChild(r);
lab.appendChild(el("span", "", pair[1]));
fs.appendChild(lab);
});
var er = el("p", "err", "");
er.hidden = true;
fs.appendChild(er);
return fs;
}
var lb = el("label", "", spec.label);
lb.setAttribute("for", "fb-" + spec.id);
var ctl;
if (spec.type === "select") {
ctl = el("select");
ctl.appendChild(new Option("— choose —", ""));
spec.options.forEach(function (pair) { ctl.appendChild(new Option(pair[1], pair[0])); });
} else if (spec.type === "textarea") {
ctl = el("textarea");
ctl.rows = 4;
} else {
ctl = el("input");
ctl.type = "text";
}
ctl.id = "fb-" + spec.id;
ctl.name = spec.id;
f.appendChild(lb);
f.appendChild(ctl);
if (spec.hint) f.appendChild(el("p", "hint", spec.hint));
var e = el("p", "err", "");
e.hidden = true;
f.appendChild(e);
return f;
}
function feedbackPanel() {
var box = el("div", "panel");
box.appendChild(el("h2", "", "How did it go?"));
box.appendChild(el("p", "detail", "Answer in your own words. Your answers come to us — nothing here is published."));
var form = el("form");
form.setAttribute("novalidate", "novalidate");
FIELDS.forEach(function (spec) { form.appendChild(fieldBlock(spec)); });
var line = status("", "");
var btn = el("button", "btn", "Send feedback");
btn.type = "submit";
form.appendChild(btn);
form.appendChild(line);
form.addEventListener("submit", function (ev) {
ev.preventDefault();
// Nothing here is compulsory, so there is nothing to validate and nothing to announce.
// The .err nodes and their CSS are kept in place in case a question is ever made
// compulsory again, but nothing here sets them: an empty form submits like any other.
// Build the body. Only the questions that were answered, and nothing else — no
// name, no email, no identifier, and no empty string for a question left blank.
var body = { form: "feedback" };
FIELDS.forEach(function (spec) {
var v = "";
if (spec.type === "radio") {
var checked = form.querySelector('input[name="' + spec.id + '"]:checked');
v = checked ? checked.value : "";
} else {
var ctl = form.querySelector("#fb-" + spec.id);
v = ctl ? ctl.value.trim() : "";
}
if (v) body[spec.id] = v;
});
btn.disabled = true;
line.className = "status";
line.textContent = "Sending…";
fetchJson(C.submitUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
}).then(function (res) {
if (!res.ok) {
btn.disabled = false;
line.className = "status state-warn";
line.textContent = "We could not send your feedback — the service answered " + res.status +
(msgOf(res.body) ? " — " + msgOf(res.body) : "") + ". Your answers are still here; try again.";
return;
}
line.className = "status state-ok";
line.textContent = "Thank you — your feedback reached us.";
btn.disabled = true;
form.querySelectorAll("input, textarea, select, button").forEach(function (n) {
if (n !== btn) n.disabled = true;
});
}).catch(function () {
btn.disabled = false;
line.className = "status state-warn";
line.textContent = "We could not reach the service. Your answers are still here; try again.";
});
});
box.appendChild(form);
return box;
}
// ---- Render ---------------------------------------------------------------------------
// One state, always: the form. Nothing is read on load, so there is no fetch here.
show([progressBar(1), signupPanel(), feedbackPanel()]);
})();
</script>
</body>
</html>
+468
View File
@@ -0,0 +1,468 @@
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Answer a question — ODOO4projects</title>
<meta name="description" content="Private page: an Odoo freelancer answers a client's question." />
<!-- Private utility page. It is reached ONLY by a link we email, never by search.
noindex+nofollow here, absent from sitemap.xml and llms.txt, and linked from nowhere
on the site. See [[draft-page-convention]] for the same five rule. -->
<meta name="robots" content="noindex, nofollow" />
<!-- While the link is open the address carries a credential. No claim-by-subdomain of the
hash travels to a third party (Google Fonts, and every other cross-origin request). -->
<meta name="referrer" content="no-referrer" />
<link rel="canonical" href="https://odoo4projects.com/freelancer-answer/" />
<link rel="icon" href="/images/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap"
media="print"
onload="this.media='all'"
/>
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="stylesheet" href="/style.css?v=061517baef" />
<style>
/* Utility page. Deliberately small: no hero, no footer, no site chrome beyond a
brand line. Reuses the site tokens (:root in style.css) and the CI palette only. */
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--cream);
color: var(--ink);
font-family: Inter, "Segoe UI", sans-serif;
line-height: 1.6;
}
.wrap {
width: 86%;
max-width: 1200px;
margin: 0 auto;
}
.topbar {
background: var(--midnight);
border-bottom: 1px solid var(--line-brass);
padding: 0.85rem 0;
}
.brand {
font-family: Marcellus, Georgia, serif;
font-size: 1.15rem;
letter-spacing: 0.04em;
color: #fff;
text-decoration: none;
}
.brand span {
color: var(--brass);
}
main {
padding: 2.5rem 0 4rem;
}
.card-wrap {
max-width: 720px;
margin: 0 auto;
}
h1 {
font-family: Marcellus, Georgia, serif;
font-weight: 400;
font-size: clamp(1.5rem, 4vw, 2.2rem);
letter-spacing: 0.02em;
margin: 0 0 0.4rem;
}
.lede {
color: var(--ink-soft);
margin: 0 0 1.75rem;
}
.panel {
background: var(--ivory);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-rest);
}
.state {
margin: 0;
font-weight: 500;
}
.state-warn,
.state-err {
color: var(--brass-deep);
}
.state-ok {
color: var(--success);
}
.detail {
margin: 0.5rem 0 0;
font-size: 0.9rem;
color: var(--ink-soft);
}
.qcard {
background: var(--midnight);
color: var(--cream);
border-radius: var(--radius);
padding: 1.1rem 1.25rem;
margin: 0 0 1.5rem;
}
.qcard-label {
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--brass);
margin: 0 0 0.4rem;
}
.qcard-text {
margin: 0;
font-size: 1.05rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.field label {
display: block;
font-weight: 600;
font-size: 0.9rem;
margin: 0 0 0.4rem;
}
textarea {
width: 100%;
font: inherit;
padding: 0.7rem 0.8rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
color: var(--ink);
resize: vertical;
}
textarea:focus-visible {
outline: 2px solid var(--brass);
outline-offset: 1px;
}
.hint {
font-size: 0.82rem;
color: var(--ink-soft);
margin: 0.4rem 0 0;
}
.btn {
display: inline-block;
margin-top: 1rem;
font-family: Marcellus, Georgia, serif;
font-size: 0.9rem;
letter-spacing: 0.04em;
border: 0;
border-radius: var(--radius-pill);
background: var(--brass);
color: var(--midnight);
padding: 0.7rem 1.5rem;
cursor: pointer;
}
.btn:hover {
background: var(--brass-bright);
}
.btn:focus-visible {
outline: 2px solid var(--midnight);
outline-offset: 2px;
}
.btn[disabled] {
opacity: 0.6;
cursor: default;
}
.status {
margin: 0.9rem 0 0;
font-weight: 500;
min-height: 1.2em;
}
</style>
<!-- 1) Read the credential, then take it OUT of the address bar.
The hash arrives as ?has=... or ?hash=... (both spellings accepted, 'has' first).
The page keeps it in memory for the GET, the PATCH and a retry; it must never sit in
the URL, because Plausible reports location.href and a shared/forwarded link would
leak it. Stripping the query BEFORE the tracker is loaded is what keeps it out of
analytics. All wire names and key lists live here, in one place. -->
<script>
window.O4P = {
// The node webhook: holds the question (GET) and receives the answer (PATCH).
webhook: "https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_quote",
// Query-string spellings to read, in order. The brief wrote "has" twice, so "has"
// wins; "hash" is accepted as the identical fallback. Confirm with the owner which
// spelling the emailed links will carry.
hashKeys: ["has", "hash"],
// Wire name used OUTWARD, both directions: GET query param and PATCH body key.
hashWireKey: "has",
// PATCH body key carrying the freelancer's answer.
answerWireKey: "answer",
// The ONLY response keys this page may render. Anything not listed is never read,
// so a client's email/name/phone sitting in the same object cannot reach the page.
questionKeys: ["question", "brief", "message", "text"]
};
(function () {
var params = new URLSearchParams(location.search);
var hash = "";
for (var i = 0; i < window.O4P.hashKeys.length; i++) {
var v = params.get(window.O4P.hashKeys[i]);
if (v) { hash = v; break; }
}
window.__ANSWER_HASH = hash;
// Drop the query string from the address + history. No reload loses anything:
// the hash now lives in memory above.
if (location.search) history.replaceState(null, "", location.pathname);
})();
</script>
<!-- 2) Privacy-friendly analytics by Plausible (verbatim; the page now has no query). -->
<script async src="https://plausible.office.odoo4projects.com/js/pa-NhA7Rf-79VopXFQXhSS4e.js"></script>
<script>
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
</head>
<body>
<header class="topbar">
<div class="wrap">
<a class="brand" href="https://odoo4projects.com/">ODOO<span>4</span>projects</a>
</div>
</header>
<main>
<div class="wrap">
<div class="card-wrap">
<h1>Answer a question</h1>
<p class="lede">
A client asked you a question through ODOO4projects. Write your answer below and it
goes back to us — we pass it on to them.
</p>
<!-- Filled by the script at the foot of the page. Empty on purpose: with
JavaScript off there is no way to read the question or send an answer, so an
empty container + the noscript note below is the honest picture. -->
<div id="app"></div>
<noscript>
<div class="panel">
<p class="state state-warn">This page needs JavaScript to load the question and to send your answer.</p>
<p class="detail">
Turn JavaScript on in your browser and open the link again — or reply to the email
that brought you here.
</p>
</div>
</noscript>
</div>
</div>
</main>
<script>
(function () {
var C = window.O4P;
var app = document.getElementById("app");
var hash = window.__ANSWER_HASH || "";
function el(tag, cls, text) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text; // textContent only: a question is never markup
return n;
}
function show(nodes) {
while (app.firstChild) app.removeChild(app.firstChild);
var box = el("div", "panel");
for (var i = 0; i < nodes.length; i++) box.appendChild(nodes[i]);
app.appendChild(box);
}
function status(text, kind) {
var p = el("p", "state" + (kind ? " state-" + kind : ""), text);
p.setAttribute("role", "status");
p.setAttribute("aria-live", "polite");
return p;
}
function retryButton() {
var b = el("button", "btn", "Try again");
b.type = "button";
b.addEventListener("click", load);
return b;
}
// The GET reply may be a bare object, an array, or wrapped in .data. Unwrap the
// envelope only — the field names inside are decided by questionKeys.
function unwrap(payload) {
if (Array.isArray(payload)) return payload[0] || null;
if (payload && typeof payload === "object") {
if (Array.isArray(payload.data)) return payload.data[0] || null;
if (Array.isArray(payload.items)) return payload.items[0] || null;
return payload;
}
return null;
}
function pickQuestion(obj) {
if (!obj) return "";
for (var i = 0; i < C.questionKeys.length; i++) {
var v = obj[C.questionKeys[i]];
if (typeof v === "string" && v.trim()) return v.trim();
}
return "";
}
function load() {
if (!hash) {
show([
status("This link is incomplete — it arrived without its code.", "warn"),
el("p", "detail", "Open it again from the email that brought you here.")
]);
return;
}
show([status("Loading your question…")]);
fetch(C.webhook + "?" + encodeURIComponent(C.hashWireKey) + "=" + encodeURIComponent(hash), {
headers: { Accept: "application/json" }
})
.then(function (res) {
if (!res.ok) {
// Live behaviour for an unknown hash: 500 {"code":0,"message":"No item to return was found"}.
return res.text().then(function (body) {
var msg = "";
try { msg = (JSON.parse(body) || {}).message || ""; } catch (e) { /* not json */ }
show([
status("This link is not valid, or it has expired.", "warn"),
el("p", "detail", "The service answered " + res.status + (msg ? " — " + msg : "") + "."),
el("p", "detail", "If you think that is wrong, reply to the email that brought you here.")
]);
});
}
return res.json().then(renderQuestion).catch(function () {
show([
status("We could not read the reply from the service.", "warn"),
el("p", "detail", "The reply was not in a form we understand. Please try again."),
retryButton()
]);
});
})
.catch(function () {
// Network failure: the hash is still in memory, so a retry needs no new link.
show([
status("We could not load your question just now.", "warn"),
el("p", "detail", "Your link has been kept, so you can try again."),
retryButton()
]);
});
}
function renderQuestion(payload) {
var obj = unwrap(payload);
var question = pickQuestion(obj);
if (!question) {
// Honest about a shape we did not expect: name the fields, never a value. A
// field name cannot identify the client; a value might.
var names = obj && typeof obj === "object" ? Object.keys(obj) : [];
show([
status("We could not read the question from the reply from the service.", "warn"),
el("p", "detail",
names.length
? "The reply carried these fields: " + names.join(", ") + "."
: "The reply carried no readable data.")
]);
return;
}
var card = el("div", "qcard");
card.appendChild(el("p", "qcard-label", "The question"));
card.appendChild(el("blockquote", "qcard-text", question));
var form = el("form", "answer-form");
form.setAttribute("novalidate", "novalidate"); // JS does the check, announced below
var field = el("div", "field");
var label = el("label", "", "Your answer");
label.setAttribute("for", "answerText");
var ta = el("textarea");
ta.id = "answerText";
ta.name = C.answerWireKey;
ta.rows = 8;
ta.required = true;
ta.setAttribute("aria-describedby", "answerHint");
var hint = el("p", "hint", "Your answer goes back to ODOO4projects, who pass it on. Write it as you would to the client.");
hint.id = "answerHint";
field.appendChild(label);
field.appendChild(ta);
field.appendChild(hint);
var btn = el("button", "btn", "Send answer");
btn.type = "submit";
var line = el("p", "status");
line.id = "answerStatus";
line.setAttribute("role", "status");
line.setAttribute("aria-live", "polite");
function setLine(text, kind) {
line.className = "status" + (kind ? " state-" + kind : "");
line.textContent = text;
}
form.addEventListener("submit", function (event) {
event.preventDefault();
var answer = ta.value.trim();
if (!answer) {
ta.setAttribute("aria-invalid", "true");
setLine("Please write your answer first.", "warn");
ta.focus();
return;
}
ta.removeAttribute("aria-invalid");
// The body shape, in one constant (C.hashWireKey + C.answerWireKey):
// {"has":"<hash>","answer":"<text>"}
// PATCH, not POST: the question record already exists (the client's submission
// created it), so this answer is a PARTIAL UPDATE of it — POST risks creating a
// second record. Match this method and these two keys in the n8n workflow.
var body = {};
body[C.hashWireKey] = hash;
body[C.answerWireKey] = answer;
btn.disabled = true;
setLine("Sending…", null);
fetch(C.webhook, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
})
.then(function (res) {
if (res.ok) {
// A RECEIPT, not a delivery claim: n8n answers 200 before the workflow
// runs, so this page cannot know the answer reached anyone. No time is
// promised. The receipt shows ONLY on a 2xx.
setLine("Thank you — we have your answer.", "ok");
form.reset();
return;
}
// Non-2xx: never show the receipt. A method the route refuses (404/405/501)
// is a clear failure, not a transient one — say so plainly and KEEP the
// answer in the box so it can be retried.
var refused = res.status === 404 || res.status === 405 || res.status === 501;
setLine(
refused
? "We could not save your answer. Please reply to the email that brought you here."
: "That did not send. Please try again.",
"err"
);
})
.catch(function () {
// Network failure: the hash stays in memory, the answer stays in the box.
setLine("That did not send. Please try again.", "err");
})
.then(function () { btn.disabled = false; });
});
form.appendChild(field);
form.appendChild(btn);
form.appendChild(line);
show([card, form]);
}
load();
})();
</script>
</body>
</html>
+179
View File
@@ -0,0 +1,179 @@
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Your email is confirmed — ODOO4projects</title>
<meta name="description" content="Private page: your email is confirmed and your card is in the Odoo Freelancer Directory." />
<!-- Private utility page. It is reached ONLY by the confirmation link we email, never by
search. noindex+nofollow here, absent from sitemap.xml and llms.txt, and linked from
nowhere on the site. See [[draft-page-convention]] for the same five rule. -->
<meta name="robots" content="noindex, nofollow" />
<!-- The confirmation is completed by the workflow, so this address may carry anything the
redirect appends. Nothing from the query string is ever rendered or read: the page is
identical with a bare URL and with something appended, and the query is dropped from the
address (below) before the tracker loads. -->
<meta name="referrer" content="no-referrer" />
<link rel="canonical" href="https://odoo4projects.com/freelancer-confirmed/" />
<meta property="og:url" content="https://odoo4projects.com/freelancer-confirmed/" />
<meta property="og:title" content="Your email is confirmed — ODOO4projects" />
<meta property="og:description" content="Your card is in the Odoo Freelancer Directory." />
<meta property="og:type" content="website" />
<link rel="icon" href="/images/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap"
media="print"
onload="this.media='all'"
/>
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="stylesheet" href="/style.css?v=061517baef" />
<style>
/* Utility page, same shell as /freelancer-edit/ and /feedback/: brand line, one card, the
site tokens (:root in style.css) and the CI palette only. No hero, no footer, no site
chrome beyond the brand line. */
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--cream);
color: var(--ink);
font-family: Inter, "Segoe UI", sans-serif;
line-height: 1.6;
}
.wrap { width: 86%; max-width: 1200px; margin: 0 auto; }
.topbar { background: var(--midnight); border-bottom: 1px solid var(--line-brass); padding: 0.85rem 0; }
.brand {
font-family: Marcellus, Georgia, serif;
font-size: 1.15rem;
letter-spacing: 0.04em;
color: #fff;
text-decoration: none;
}
.brand span { color: var(--brass); }
main { padding: 2.5rem 0 4rem; }
.card-wrap { max-width: 760px; margin: 0 auto; }
h1 {
font-family: Marcellus, Georgia, serif;
font-weight: 400;
font-size: clamp(1.5rem, 4vw, 2.2rem);
letter-spacing: 0.02em;
margin: 0 0 0.4rem;
}
.lede { color: var(--ink-soft); margin: 0 0 1rem; }
.panel {
background: var(--ivory);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-rest);
}
.panel + .panel { margin-top: 1.25rem; }
h2 {
font-family: Marcellus, Georgia, serif;
font-weight: 400;
font-size: 1.15rem;
letter-spacing: 0.02em;
margin: 0 0 0.6rem;
}
.detail { margin: 0.5rem 0 0; font-size: 0.9rem; color: var(--ink-soft); }
.detail:first-of-type { margin-top: 0; }
/* The page's ONE primary action is the feedback link; the directory link is a plain link. */
.btn {
display: inline-block;
font-family: Marcellus, Georgia, serif;
font-size: 1rem;
letter-spacing: 0.04em;
border: 0;
border-radius: var(--radius-pill);
background: var(--brass);
color: var(--midnight);
padding: 0.85rem 2rem;
margin-top: 1rem;
text-align: center;
text-decoration: none;
}
.btn:hover { background: var(--brass-bright); }
/* The anchor needs the text colour kept on hover: the site's global a:hover ({color:
var(--brass)}) outranks a lone .btn, and brass on brass-bright is unreadable. */
a.btn, a.btn:hover { color: var(--midnight); }
.btn:focus-visible { outline: 2px solid var(--midnight); outline-offset: 2px; }
.link-plain { color: var(--brass-deep); font-weight: 600; }
</style>
<!-- The confirmation redirect may append anything (a name, an id, a tracking token). NOTHING
from the query string is read or rendered: this page has one state, whatever it is handed.
The query is dropped from the address + the history entry BEFORE the tracker is loaded —
Plausible reports location.href, so stripping it first is what keeps anything appended out
of the analytics payload (the same care as /freelancer-edit/). With JavaScript off the
query stays in the address bar and still nothing renders it. -->
<script>
(function () {
if (location.search) history.replaceState(null, "", location.pathname);
})();
</script>
<!-- Privacy-friendly analytics by Plausible (verbatim; the page now carries no query). -->
<script async src="https://plausible.office.odoo4projects.com/js/pa-NhA7Rf-79VopXFQXhSS4e.js"></script>
<script>
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
</head>
<body>
<header class="topbar">
<div class="wrap">
<a class="brand" href="https://odoo4projects.com/">ODOO<span>4</span>projects</a>
</div>
</header>
<main>
<div class="wrap">
<div class="card-wrap">
<h1>Your email is confirmed</h1>
<p class="lede">
Thank you — your email address is confirmed, and your card is now in the Odoo Freelancer
Directory.
</p>
<!-- 1. The page's primary action: the feedback. On its own panel, first, brass and larger. -->
<div class="panel">
<h2>How did the signup go?</h2>
<p class="detail">
The signup is the part we most want to get right, and you have just been through it.
Tell us how it went — every answer helps us improve it.
</p>
<a class="btn" href="https://odoo4projects.com/feedback/">Give feedback on the signup</a>
</div>
<!-- 2. See yourself in the directory. -->
<div class="panel">
<h2>See your card in the directory</h2>
<p class="detail">
Everything on a card is the freelancer's own words.
</p>
<p class="detail">
<a
class="link-plain"
href="https://odoo4projects.com/odoo-community-developers/"
target="_blank"
rel="noopener noreferrer"
>Open the Odoo Freelancer Directory (opens in a new window)</a>
</p>
</div>
<!-- 3. Nobody is stuck with what they typed. One line, and the credential stays where it
belongs: the edit link is the one we email, so it is not linked from here. -->
<p class="detail">
You can change or remove your profile at any time — the link for that is in the email we
send you.
</p>
</div>
</div>
</main>
</body>
</html>
+813
View File
@@ -0,0 +1,813 @@
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Edit your listing — ODOO4projects</title>
<meta name="description" content="Private page: a listed Odoo freelancer edits or deletes their own listing." />
<!-- Private utility page. It is reached ONLY by a link we email, never by search.
noindex+nofollow here, absent from sitemap.xml and llms.txt, and linked from nowhere
on the site. See [[draft-page-convention]] for the same five rule. -->
<meta name="robots" content="noindex, nofollow" />
<!-- While the link is open the address carries a credential. No claim-by-subdomain of the
hash travels to a third party (Google Fonts, and every other cross-origin request). -->
<meta name="referrer" content="no-referrer" />
<link rel="canonical" href="https://odoo4projects.com/freelancer-edit/" />
<!-- The ONE path the emailed link uses; mirrors /freelancer-answer/ deliberately. -->
<meta property="og:url" content="https://odoo4projects.com/freelancer-edit/" />
<meta property="og:title" content="Edit your listing — ODOO4projects" />
<meta property="og:type" content="website" />
<link rel="icon" href="/images/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap"
media="print"
onload="this.media='all'"
/>
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="stylesheet" href="/style.css?v=061517baef" />
<style>
/* Utility page. Deliberately small: no hero, no footer, no site chrome beyond a brand
line. Reuses the site tokens (:root in style.css) and the CI palette only. */
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--cream);
color: var(--ink);
font-family: Inter, "Segoe UI", sans-serif;
line-height: 1.6;
}
.wrap { width: 86%; max-width: 1200px; margin: 0 auto; }
.topbar { background: var(--midnight); border-bottom: 1px solid var(--line-brass); padding: 0.85rem 0; }
.brand {
font-family: Marcellus, Georgia, serif;
font-size: 1.15rem;
letter-spacing: 0.04em;
color: #fff;
text-decoration: none;
}
.brand span { color: var(--brass); }
main { padding: 2.5rem 0 4rem; }
.card-wrap { max-width: 720px; margin: 0 auto; }
h1 {
font-family: Marcellus, Georgia, serif;
font-weight: 400;
font-size: clamp(1.5rem, 4vw, 2.2rem);
letter-spacing: 0.02em;
margin: 0 0 0.4rem;
}
.lede { color: var(--ink-soft); margin: 0 0 1.75rem; }
.panel {
background: var(--ivory);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-rest);
}
.state { margin: 0; font-weight: 500; }
.state-warn, .state-err { color: var(--brass-deep); }
.state-ok { color: var(--success); }
.detail { margin: 0.5rem 0 0; font-size: 0.9rem; color: var(--ink-soft); }
.who {
font-size: 0.8rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--brass-deep);
margin: 0 0 1rem;
}
/* ---- The form ---- */
.field { margin: 0 0 1.1rem; }
.field > label { display: block; font-weight: 600; font-size: 0.9rem; margin: 0 0 0.35rem; }
input[type="text"], input[type="url"], input[type="email"], textarea, select {
width: 100%;
font: inherit;
padding: 0.6rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
color: var(--ink);
}
textarea { resize: vertical; }
input:focus-visible, textarea:focus-visible, select:focus-visible, button:focus-visible {
outline: 2px solid var(--brass);
outline-offset: 1px;
}
.hint { font-size: 0.82rem; color: var(--ink-soft); margin: 0.35rem 0 0; }
.counter { font-size: 0.78rem; color: var(--ink-soft); margin: 0.3rem 0 0; }
.counter[data-over="1"] { color: var(--brass-deep); font-weight: 600; }
fieldset { border: 1px solid var(--line); border-radius: var(--radius); padding: 0.9rem 1rem 0.4rem; margin: 0 0 1.1rem; }
legend { font-weight: 600; font-size: 0.9rem; padding: 0 0.35rem; }
.row { display: flex; align-items: center; gap: 0.7rem; margin: 0 0 0.7rem; }
.row label { flex: 0 0 6.5rem; font-size: 0.85rem; }
.row input[type="range"] { flex: 1 1 auto; min-width: 0; }
.row output { flex: 0 0 1.6rem; text-align: center; font-variant-numeric: tabular-nums; font-weight: 600; }
.total { font-size: 0.85rem; color: var(--ink-soft); margin: 0 0 0.4rem; }
.total[data-bad="1"] { color: var(--brass-deep); font-weight: 600; }
.btn {
display: inline-block;
font-family: Marcellus, Georgia, serif;
font-size: 0.9rem;
letter-spacing: 0.04em;
border: 0;
border-radius: var(--radius-pill);
background: var(--brass);
color: var(--midnight);
padding: 0.7rem 1.5rem;
cursor: pointer;
}
.btn:hover { background: var(--brass-bright); }
.btn:focus-visible { outline: 2px solid var(--midnight); outline-offset: 2px; }
.btn[disabled] { opacity: 0.6; cursor: default; }
.status { margin: 0.9rem 0 0; font-weight: 500; min-height: 1.2em; }
/* ---- Delete ---- */
.danger { margin-top: 1.75rem; border-top: 1px solid var(--line); padding-top: 1.25rem; }
.danger h2 { font-family: Marcellus, Georgia, serif; font-weight: 400; font-size: 1.1rem; margin: 0 0 0.4rem; }
.danger p { font-size: 0.88rem; color: var(--ink-soft); margin: 0 0 0.8rem; }
.confirm { display: flex; align-items: flex-start; gap: 0.55rem; margin: 0 0 0.9rem; font-size: 0.88rem; }
.confirm input { margin-top: 0.25rem; }
.btn-danger { background: transparent; border: 1px solid var(--brass-deep); color: var(--brass-deep); }
.btn-danger:hover { background: var(--brass-deep); color: #fff; }
.btn-danger:focus-visible { outline: 2px solid var(--midnight); outline-offset: 2px; }
.photo-readonly { display: flex; align-items: center; gap: 0.8rem; }
.photo-readonly img { width: 56px; height: 56px; border-radius: 50%; object-fit: cover; }
.photo-readonly .mono {
width: 56px; height: 56px; border-radius: 50%;
display: grid; place-items: center;
background: var(--midnight); color: var(--brass);
font-family: Marcellus, Georgia, serif;
}
</style>
<!-- The 15-point rule, in ONE copy for both forms (this one and the application form). -->
<script src="/static/focus-rule.js?v=22ac72a8f0"></script>
<script src="/static/photo-upload.js?v=c17638457b"></script>
<script src="/static/expert-image.js?v=bad5d8c733"></script>
<!-- 1) Read the credential, then take it OUT of the address bar.
The hash arrives as ?has=... or ?hash=... (both spellings accepted, 'has' first — the
same list the answer page reads, so the two emailed links behave identically). The page
keeps it in memory for the GET, the save, the delete and a retry; it must never sit in
the URL, because Plausible reports location.href. Stripping the query BEFORE the tracker
is loaded is what keeps it out of analytics. All wire names and key lists live here. -->
<script>
window.O4E = {
// The node webhook: holds this person's listing (GET) and receives the save + delete.
webhook: "https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_edit",
// Query-string spellings to read, in order. Same list as the answer page: the two
// emailed links are one shape. Confirm with the owner which spelling the mails carry.
hashKeys: ["has", "hash"],
// Wire name used OUTWARD on the GET query string.
hashWireKey: "has",
// SAVE: PATCH the existing record, carrying BOTH the hash (the credential, same key
// the delete already sends) and the record id. An id alone authorises nothing: record
// ids are small sequential integers, publicly visible on every directory card, so a
// request without the hash is refused before it is sent. Match this method in n8n.
saveMethod: "PATCH",
// DELETE: unverified. A route that refuses it (404/405/501) is a clear failure and
// this page never reports success on a non-2xx. Match this method in the workflow.
deleteMethod: "DELETE",
// How the GET reply is read, in ONE place. The primary name is what the directory
// webhook already returns, so the two endpoints agree; a lower-case variant is taken
// as the identical fallback. A key NOT listed here is never read.
aliases: {
id: ["id"],
Name: ["Name", "name"],
Focus: ["Focus", "focus"],
description: ["description", "Description"],
Country: ["Country", "country"],
OCA_link: ["OCA_link", "oca_link"],
// The four profile links. The primary name is the LIVE wire key, casing and all —
// "Linkedin", "github", "upwork", "fiverr" are deliberately mixed and must never be
// normalised (a normalised key is the silent-drop class that ate odoo_versions). A
// same-word casing variant is the identical fallback, the way Name/name already works;
// "github" is lower-case on the wire, so it is read exactly as it is, with no variant.
Linkedin: ["Linkedin", "linkedin"], github: ["github"], upwork: ["upwork", "Upwork"], fiverr: ["fiverr", "Fiverr"],
functional: ["functional"], system: ["system"], modules: ["modules"],
integration: ["integration"], ai: ["ai"],
english: ["english"], german: ["german"], spanish: ["spanish"]
},
focusAxes: ["functional", "system", "modules", "integration", "ai"],
focusAxisLabels: {
functional: "Functional", system: "System", modules: "Modules",
integration: "Integration", ai: "AI"
},
focusTotal: window.O4PFocus.TOTAL,
focusMax: 5,
focusMaxChars: 70,
descMaxChars: 230,
langs: [["english", "English"], ["german", "German"], ["spanish", "Spanish"]],
// Country VALUE FORMAT the select sends, same as the other forms: the ISO alpha-2 code.
countryValueFormat: "code"
};
(function () {
var params = new URLSearchParams(location.search);
var hash = "";
for (var i = 0; i < window.O4E.hashKeys.length; i++) {
var v = params.get(window.O4E.hashKeys[i]);
if (v) { hash = v; break; }
}
window.__EDIT_HASH = hash;
// Drop the query from the address + history. The hash lives in memory above.
if (location.search) history.replaceState(null, "", location.pathname);
})();
</script>
<!-- 2) Privacy-friendly analytics by Plausible (verbatim; the page now has no query). -->
<script async src="https://plausible.office.odoo4projects.com/js/pa-NhA7Rf-79VopXFQXhSS4e.js"></script>
<script>
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
</head>
<body>
<header class="topbar">
<div class="wrap">
<a class="brand" href="https://odoo4projects.com/">ODOO<span>4</span>projects</a>
</div>
</header>
<main>
<div class="wrap">
<div class="card-wrap">
<h1>Edit your listing</h1>
<p class="lede">
This is your own listing in the Odoo Freelancer Directory. Change what you like here
and save it — or delete the listing for good.
</p>
<!-- Filled by the script at the foot of the page. Empty on purpose: with JavaScript
off there is no way to load the listing or save a change, so an empty container +
the noscript note below is the honest picture. -->
<div id="app"></div>
<noscript>
<div class="panel">
<p class="state state-warn">This page needs JavaScript to load your listing and to save your changes.</p>
<p class="detail">
Turn JavaScript on in your browser and open the link again — or reply to the email
that brought you here.
</p>
</div>
</noscript>
</div>
</div>
</main>
<script>
var COUNTRIES = [["AD", "Andorra"], ["AE", "United Arab Emirates"], ["AF", "Afghanistan"], ["AG", "Antigua and Barbuda"], ["AI", "Anguilla"], ["AL", "Albania"], ["AM", "Armenia"], ["AO", "Angola"], ["AQ", "Antarctica"], ["AR", "Argentina"], ["AS", "American Samoa"], ["AT", "Austria"], ["AU", "Australia"], ["AW", "Aruba"], ["AX", "Aland"], ["AZ", "Azerbaijan"], ["BA", "Bosnia and Herzegovina"], ["BB", "Barbados"], ["BD", "Bangladesh"], ["BE", "Belgium"], ["BF", "Burkina Faso"], ["BG", "Bulgaria"], ["BH", "Bahrain"], ["BI", "Burundi"], ["BJ", "Benin"], ["BL", "Saint Barthelemy"], ["BM", "Bermuda"], ["BN", "Brunei"], ["BO", "Bolivia"], ["BQ", "Bonaire"], ["BR", "Brazil"], ["BS", "Bahamas"], ["BT", "Bhutan"], ["BV", "Bouvet Island"], ["BW", "Botswana"], ["BY", "Belarus"], ["BZ", "Belize"], ["CA", "Canada"], ["CC", "Cocos (Keeling) Islands"], ["CD", "Democratic Republic of the Congo"], ["CF", "Central African Republic"], ["CG", "Republic of the Congo"], ["CH", "Switzerland"], ["CI", "Ivory Coast"], ["CK", "Cook Islands"], ["CL", "Chile"], ["CM", "Cameroon"], ["CN", "China"], ["CO", "Colombia"], ["CR", "Costa Rica"], ["CU", "Cuba"], ["CV", "Cape Verde"], ["CW", "Curacao"], ["CX", "Christmas Island"], ["CY", "Cyprus"], ["CZ", "Czech Republic"], ["DE", "Germany"], ["DJ", "Djibouti"], ["DK", "Denmark"], ["DM", "Dominica"], ["DO", "Dominican Republic"], ["DZ", "Algeria"], ["EC", "Ecuador"], ["EE", "Estonia"], ["EG", "Egypt"], ["EH", "Western Sahara"], ["ER", "Eritrea"], ["ES", "Spain"], ["ET", "Ethiopia"], ["FI", "Finland"], ["FJ", "Fiji"], ["FK", "Falkland Islands"], ["FM", "Micronesia"], ["FO", "Faroe Islands"], ["FR", "France"], ["GA", "Gabon"], ["GB", "United Kingdom"], ["GD", "Grenada"], ["GE", "Georgia"], ["GF", "French Guiana"], ["GG", "Guernsey"], ["GH", "Ghana"], ["GI", "Gibraltar"], ["GL", "Greenland"], ["GM", "Gambia"], ["GN", "Guinea"], ["GP", "Guadeloupe"], ["GQ", "Equatorial Guinea"], ["GR", "Greece"], ["GS", "South Georgia and the South Sandwich Islands"], ["GT", "Guatemala"], ["GU", "Guam"], ["GW", "Guinea-Bissau"], ["GY", "Guyana"], ["HK", "Hong Kong"], ["HM", "Heard Island and McDonald Islands"], ["HN", "Honduras"], ["HR", "Croatia"], ["HT", "Haiti"], ["HU", "Hungary"], ["ID", "Indonesia"], ["IE", "Ireland"], ["IL", "Israel"], ["IM", "Isle of Man"], ["IN", "India"], ["IO", "British Indian Ocean Territory"], ["IQ", "Iraq"], ["IR", "Iran"], ["IS", "Iceland"], ["IT", "Italy"], ["JE", "Jersey"], ["JM", "Jamaica"], ["JO", "Jordan"], ["JP", "Japan"], ["KE", "Kenya"], ["KG", "Kyrgyzstan"], ["KH", "Cambodia"], ["KI", "Kiribati"], ["KM", "Comoros"], ["KN", "Saint Kitts and Nevis"], ["KP", "North Korea"], ["KR", "South Korea"], ["KW", "Kuwait"], ["KY", "Cayman Islands"], ["KZ", "Kazakhstan"], ["LA", "Laos"], ["LB", "Lebanon"], ["LC", "Saint Lucia"], ["LI", "Liechtenstein"], ["LK", "Sri Lanka"], ["LR", "Liberia"], ["LS", "Lesotho"], ["LT", "Lithuania"], ["LU", "Luxembourg"], ["LV", "Latvia"], ["LY", "Libya"], ["MA", "Morocco"], ["MC", "Monaco"], ["MD", "Moldova"], ["ME", "Montenegro"], ["MF", "Saint Martin"], ["MG", "Madagascar"], ["MH", "Marshall Islands"], ["MK", "North Macedonia"], ["ML", "Mali"], ["MM", "Myanmar (Burma)"], ["MN", "Mongolia"], ["MO", "Macao"], ["MP", "Northern Mariana Islands"], ["MQ", "Martinique"], ["MR", "Mauritania"], ["MS", "Montserrat"], ["MT", "Malta"], ["MU", "Mauritius"], ["MV", "Maldives"], ["MW", "Malawi"], ["MX", "Mexico"], ["MY", "Malaysia"], ["MZ", "Mozambique"], ["NA", "Namibia"], ["NC", "New Caledonia"], ["NE", "Niger"], ["NF", "Norfolk Island"], ["NG", "Nigeria"], ["NI", "Nicaragua"], ["NL", "Netherlands"], ["NO", "Norway"], ["NP", "Nepal"], ["NR", "Nauru"], ["NU", "Niue"], ["NZ", "New Zealand"], ["OM", "Oman"], ["PA", "Panama"], ["PE", "Peru"], ["PF", "French Polynesia"], ["PG", "Papua New Guinea"], ["PH", "Philippines"], ["PK", "Pakistan"], ["PL", "Poland"], ["PM", "Saint Pierre and Miquelon"], ["PN", "Pitcairn Islands"], ["PR", "Puerto Rico"], ["PS", "Palestine"], ["PT", "Portugal"], ["PW", "Palau"], ["PY", "Paraguay"], ["QA", "Qatar"], ["RE", "Reunion"], ["RO", "Romania"], ["RS", "Serbia"], ["RU", "Russia"], ["RW", "Rwanda"], ["SA", "Saudi Arabia"], ["SB", "Solomon Islands"], ["SC", "Seychelles"], ["SD", "Sudan"], ["SE", "Sweden"], ["SG", "Singapore"], ["SH", "Saint Helena"], ["SI", "Slovenia"], ["SJ", "Svalbard and Jan Mayen"], ["SK", "Slovakia"], ["SL", "Sierra Leone"], ["SM", "San Marino"], ["SN", "Senegal"], ["SO", "Somalia"], ["SR", "Suriname"], ["SS", "South Sudan"], ["ST", "Sao Tome and Principe"], ["SV", "El Salvador"], ["SX", "Sint Maarten"], ["SY", "Syria"], ["SZ", "Eswatini"], ["TC", "Turks and Caicos Islands"], ["TD", "Chad"], ["TF", "French Southern Territories"], ["TG", "Togo"], ["TH", "Thailand"], ["TJ", "Tajikistan"], ["TK", "Tokelau"], ["TL", "East Timor"], ["TM", "Turkmenistan"], ["TN", "Tunisia"], ["TO", "Tonga"], ["TR", "Turkey"], ["TT", "Trinidad and Tobago"], ["TV", "Tuvalu"], ["TW", "Taiwan"], ["TZ", "Tanzania"], ["UA", "Ukraine"], ["UG", "Line truncated
</script>
<script>
(function () {
var C = window.O4E;
var CG = window.O4PFocus; // the shared 15-point rule (static/focus-rule.js)
var app = document.getElementById("app");
var hash = window.__EDIT_HASH || "";
var rec = null; // this person's record, once loaded
function el(tag, cls, text) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text; // textContent only: a record is never markup
return n;
}
function show(nodes) {
while (app.firstChild) app.removeChild(app.firstChild);
var box = el("div", "panel");
for (var i = 0; i < nodes.length; i++) box.appendChild(nodes[i]);
app.appendChild(box);
}
function status(text, kind) {
var p = el("p", "state" + (kind ? " state-" + kind : ""), text);
p.setAttribute("role", "status");
p.setAttribute("aria-live", "polite");
return p;
}
function retryButton() {
var b = el("button", "btn", "Try again");
b.type = "button";
b.addEventListener("click", load);
return b;
}
function messageOf(bodyText) {
try { return String((JSON.parse(bodyText) || {}).message || ""); }
catch (e) { return ""; }
}
// ---- the states -------------------------------------------------------------------
function noCode() {
show([
status("This link is incomplete — it arrived without its code.", "warn"),
el("p", "detail", "Open it again from the email that brought you here.")
]);
}
// A MISSING WORKFLOW must not read as an expired invitation. This is our problem.
function configError(httpStatus, msg) {
show([
status("The edit service is not available right now.", "warn"),
el("p", "detail",
"This is a problem at our end, not with your link. Nothing is wrong with your " +
"listing. Please try again later, or reply to the email that brought you here."),
el("p", "detail", "The service answered " + httpStatus + (msg ? " — " + msg : "") + ".")
]);
}
// 404 / 500 "No item to return was found" — the hash is wrong or has expired.
function invalidLink(httpStatus, msg) {
show([
status("This link is not valid, or it has expired.", "warn"),
el("p", "detail", "The service answered " + httpStatus + (msg ? " — " + msg : "") + "."),
el("p", "detail", "If you think that is wrong, reply to the email that brought you here.")
]);
}
function loadFailed(httpStatus, msg) {
show([
status("We could not load your listing.", "warn"),
el("p", "detail", "The service answered " + httpStatus + (msg ? " — " + msg : "") + "."),
retryButton()
]);
}
// Honest about a shape we did not expect: name the fields, never a value.
function shapeMismatch(names) {
show([
status("We could not read your listing from the reply from the service.", "warn"),
el("p", "detail",
names.length
? "The reply carried these fields: " + names.join(", ") + "."
: "The reply carried no readable data."),
retryButton()
]);
}
// ---- reading the record -----------------------------------------------------------
function unwrap(payload) {
if (Array.isArray(payload)) return payload[0] || null;
if (payload && typeof payload === "object") {
if (Array.isArray(payload.data)) return payload.data[0] || null;
if (Array.isArray(payload.items)) return payload.items[0] || null;
if (payload.data && typeof payload.data === "object") return payload.data;
return payload;
}
return null;
}
// Read one field by its list of accepted names. NEVER a value we did not expect:
// anything not in C.aliases is unreadable.
function readField(obj, key) {
var names = C.aliases[key] || [key];
for (var i = 0; i < names.length; i++) {
var v = obj[names[i]];
if (v !== undefined && v !== null && v !== "") return v;
}
return undefined;
}
function hasAnyMappedField(obj) {
for (var key in C.aliases) {
if (key === "id") continue;
if (readField(obj, key) !== undefined) return true;
}
return false;
}
function num(value, fallback) {
var n = parseInt(value, 10);
return isNaN(n) ? fallback : n;
}
function clamp05(n) { return Math.max(0, Math.min(C.focusMax, n)); }
function initials(name) {
var parts = String(name || "").trim().split(/\s+/).filter(Boolean);
if (!parts.length) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
// ---- the form ---------------------------------------------------------------------
function textField(id, labelText, value, maxLength, hint) {
var wrap = el("div", "field");
var label = el("label", "", labelText);
label.setAttribute("for", id);
var input = el("input");
input.type = "text";
input.id = id;
input.name = id;
input.value = value == null ? "" : String(value);
if (maxLength) { input.maxLength = maxLength; input.setAttribute("aria-describedby", id + "Count"); }
wrap.appendChild(label);
wrap.appendChild(input);
if (maxLength) {
var counter = el("p", "counter", input.value.length + " / " + maxLength);
counter.id = id + "Count";
input.addEventListener("input", function () {
var n = input.value.length;
counter.textContent = n + " / " + maxLength;
counter.setAttribute("data-over", n > maxLength ? "1" : "0");
});
counter.setAttribute("data-over", input.value.length > maxLength ? "1" : "0");
wrap.appendChild(counter);
}
if (hint) wrap.appendChild(el("p", "hint", hint));
return wrap;
}
// An optional profile link. A real `type="url"` input with the application form's own
// label and placeholder, so the two forms ask for a link the same way. No URL check
// lives here: the value is passed through untouched, and the ONLY url predicate in the
// fleet is the directory card's isLink() (a prefix allowlist) — one check, not two.
function linkField(id, name, labelText, value, placeholder) {
var wrap = el("div", "field");
var label = el("label", "", labelText);
label.setAttribute("for", id);
label.appendChild(el("span", "hint", " (optional)"));
var input = el("input");
input.type = "url";
input.id = id;
input.name = name;
input.value = value == null ? "" : String(value);
input.placeholder = placeholder;
wrap.appendChild(label);
wrap.appendChild(input);
return wrap;
}
function rangeField(fieldset, name, labelText, value) {
var row = el("div", "row");
var label = el("label", "", labelText);
label.setAttribute("for", "ed-" + name);
var input = el("input");
input.type = "range";
input.id = "ed-" + name;
input.name = name;
input.min = "0";
input.max = String(C.focusMax);
input.step = "1";
input.value = String(clamp05(num(value, 0)));
input.setAttribute("aria-valuetext", input.value + " of " + C.focusMax);
var out = el("output", "", input.value);
out.setAttribute("for", "ed-" + name);
input.addEventListener("input", function () {
input.setAttribute("aria-valuetext", input.value + " of " + C.focusMax);
out.textContent = input.value;
updateTotal();
});
row.appendChild(label);
row.appendChild(input);
row.appendChild(out);
fieldset.appendChild(row);
}
function countrySelect(selected) {
var sel = el("select");
sel.id = "edCountry";
sel.name = "Country";
sel.appendChild(new Option("— choose —", ""));
COUNTRIES.forEach(function (pair) {
var o = new Option(pair[1], C.countryValueFormat === "name" ? pair[1] : pair[0]);
var want = String(selected == null ? "" : selected).trim();
if (want && (want.toUpperCase() === pair[0] || want === pair[1])) o.selected = true;
sel.appendChild(o);
});
return sel;
}
var focusTotalLine = null;
function focusSum() {
var sum = 0;
C.focusAxes.forEach(function (axis) {
var el2 = document.getElementById("ed-" + axis);
sum += el2 ? num(el2.value, 0) : 0;
});
return sum;
}
function updateTotal() {
if (!focusTotalLine) return;
var sum = focusSum();
focusTotalLine.textContent = sum + " of " + C.focusTotal + " points";
focusTotalLine.setAttribute("data-bad", sum === C.focusTotal ? "0" : "1");
}
function renderForm(record) {
rec = record;
var who = readField(record, "Name");
var nodes = [];
nodes.push(el("p", "who", "Listing: " + (who ? String(who) : "(no name)")));
var form = el("form", "edit-form");
form.setAttribute("novalidate", "novalidate"); // JS announces errors instead
form.appendChild(textField("edName", "Name", readField(record, "Name"), 0, null));
form.appendChild(textField("edFocus", "Focus", readField(record, "Focus"),
C.focusMaxChars, "One short line — the kind of work you want, at most " + C.focusMaxChars + " characters."));
var descWrap = el("div", "field");
var descLabel = el("label", "", "About you");
descLabel.setAttribute("for", "edDescription");
var ta = el("textarea");
ta.id = "edDescription";
ta.name = "edDescription";
ta.rows = 6;
ta.maxLength = C.descMaxChars;
ta.value = String(readField(record, "description") == null ? "" : readField(record, "description"));
ta.setAttribute("aria-describedby", "edDescriptionCount");
var descCount = el("p", "counter", ta.value.length + " / " + C.descMaxChars);
descCount.id = "edDescriptionCount";
ta.addEventListener("input", function () {
var n = ta.value.length;
descCount.textContent = n + " / " + C.descMaxChars;
descCount.setAttribute("data-over", n > C.descMaxChars ? "1" : "0");
});
descCount.setAttribute("data-over", ta.value.length > C.descMaxChars ? "1" : "0");
descWrap.appendChild(descLabel);
descWrap.appendChild(ta);
descWrap.appendChild(descCount);
form.appendChild(descWrap);
// The five axes. An axis the record does not carry keeps the application form's
// default of 3, so the block always totals 15 and can be saved.
var axesSet = el("fieldset", "");
axesSet.appendChild(el("legend", "", CG.LEGEND));
// The same directive the application form carries, so both forms state the rule.
axesSet.appendChild(el("p", "hint", CG.DIRECTIVE));
C.focusAxes.forEach(function (axis) {
var v = readField(record, axis);
if (v === undefined || v === null || v === "") v = 3; // → the block totals 15
rangeField(axesSet, axis, C.focusAxisLabels[axis] || axis, v);
});
form.appendChild(axesSet);
focusTotalLine = el("p", "total", "");
focusTotalLine.id = "edFocusTotal";
focusTotalLine.setAttribute("role", "status");
focusTotalLine.setAttribute("aria-live", "polite");
form.appendChild(focusTotalLine);
var langsSet = el("fieldset", "");
langsSet.appendChild(el("legend", "", "Languages (0 = do not use it)"));
C.langs.forEach(function (pair) {
rangeField(langsSet, pair[0], pair[1], readField(record, pair[0]));
});
form.appendChild(langsSet);
var countryWrap = el("div", "field");
var countryLabel = el("label", "", "Country");
countryLabel.setAttribute("for", "edCountry");
countryWrap.appendChild(countryLabel);
countryWrap.appendChild(countrySelect(readField(record, "Country")));
form.appendChild(countryWrap);
form.appendChild(textField("edOcaLink", "OCA member profile link", readField(record, "OCA_link"),
0, "Your OCA profile URL, if you have one: https://apps.odoo-community.org/community/…"));
// The four profile links — parity with the application form. Keys are the LIVE wire
// keys verbatim ("Linkedin" capital L; "github", "upwork" and "fiverr" lower) and the
// labels and placeholders are the application form's own. Each is optional and
// pre-filled from the record.
form.appendChild(linkField("edLinkedin", "Linkedin", "LinkedIn URL",
readField(record, "Linkedin"), "https://www.linkedin.com/in/…"));
form.appendChild(linkField("edGithub", "github", "GitHub URL",
readField(record, "github"), "https://github.com/…"));
form.appendChild(linkField("edUpwork", "upwork", "Upwork URL",
readField(record, "upwork"), "https://www.upwork.com/freelancers/…"));
form.appendChild(linkField("edFiverr", "fiverr", "Fiverr URL",
readField(record, "fiverr"), "https://www.fiverr.com/…"));
// The photo block is a LOOK, not a field. The photo does not live in this record
// any more — the payload carries no `Image` — it comes from its own endpoint,
// addressed by the record id (window.O4PExpertImage). So nothing here edits it,
// and the save below sends no `Image` key at all: not the value, not null, not "".
var photoWrap = el("div", "field");
photoWrap.appendChild(el("label", "", "Photo"));
var photoRow = el("div", "photo-readonly");
var photoMono = el("span", "mono", initials(who));
photoRow.appendChild(photoMono);
if (rec && rec.id !== undefined && rec.id !== null && rec.id !== "") {
// Same fetch the directory card makes: the route answers JSON, not image bytes,
// so the value is read from `Image` and only then put on an <img>. Any failure
// (or a value that fails isImageSrc) leaves the monogram standing.
O4PExpertImage.load(String(rec.id)).then(function (src) {
if (!src) return;
var photoImg = el("img");
photoImg.id = "edImagePreview";
photoImg.alt = "";
photoImg.decoding = "async";
photoImg.addEventListener("load", function () {
if (photoMono.parentNode) photoMono.parentNode.replaceChild(photoImg, photoMono);
}, { once: true });
photoImg.addEventListener("error", function () {
// A failing photo is the monogram: no broken-image icon, no empty box.
if (photoImg.parentNode) photoImg.parentNode.removeChild(photoImg);
}, { once: true });
photoImg.src = src;
});
}
photoWrap.appendChild(photoRow);
photoWrap.appendChild(el("p", "hint",
"Your photo comes from your listing and is not editable here — reply to the email " +
"that brought you here if it needs to change."));
form.appendChild(photoWrap);
var save = el("button", "btn", "Save my changes");
save.type = "submit";
var line = el("p", "status", "");
line.id = "editStatus";
line.setAttribute("role", "status");
line.setAttribute("aria-live", "polite");
form.addEventListener("submit", function (event) {
event.preventDefault();
if (focusSum() !== C.focusTotal) {
var sum = focusSum();
line.className = "status state-err";
line.textContent = CG.message(sum); // names the direction: remove / add / place all
focusTotalLine.setAttribute("data-bad", "1");
return;
}
// A save is authorised by the HASH, not by the id. If this page holds no hash there
// is nothing to prove ownership, so the request is not fired at all — a PATCH
// carrying only an id is exactly the hole this closes. Say why, do not fail silently.
if (!hash) {
line.className = "status state-err";
line.textContent = "This link arrived without its code, so we cannot save your " +
"changes. Open it again from the email that brought you here.";
return;
}
save.disabled = true;
line.className = "status";
line.textContent = "Saving…";
// The body shape: READ-MODIFY-WRITE. Start from the record the webhook returned and
// overwrite ONLY the fields this form owns. A field-by-field payload is what deletes a
// freelancer's own photo and links on save — a partial payload wipes what it leaves
// out, so the whole record goes back and only the edited keys are replaced. The
// credential rides first: "has" is the SAME key the delete sends (one credential path
// for both), then the record's id. Method is C.saveMethod ("PATCH").
var payload = { has: hash };
if (rec) {
Object.keys(rec).forEach(function (k) {
if (k === "has") return; // never let the record's own "has" shadow the credential
payload[k] = rec[k]; // Image, the links, createdAt/updatedAt and everything else
});
}
payload.id = rec && rec.id !== undefined ? rec.id : undefined;
payload.Name = document.getElementById("edName").value.trim();
payload.Focus = document.getElementById("edFocus").value.trim();
payload.description = document.getElementById("edDescription").value.trim();
payload.Country = document.getElementById("edCountry").value;
payload.OCA_link = document.getElementById("edOcaLink").value.trim();
C.focusAxes.forEach(function (axis) { payload[axis] = num(document.getElementById("ed-" + axis).value, 0); });
C.langs.forEach(function (pair) { payload[pair[0]] = num(document.getElementById("ed-" + pair[0]).value, 0); });
// The four links, read from their inputs. Only a key we actually have a value for is
// touched: an empty optional field must NOT invent an empty string on a record that
// never had one — the record's own value (null, or absent) is already in the payload
// and is left as it is. A link the record DID carry and the owner cleared is a
// deliberate clear and sends "".
[["edLinkedin", "Linkedin"], ["edGithub", "github"], ["edUpwork", "upwork"], ["edFiverr", "fiverr"]].forEach(function (pair) {
var input = document.getElementById(pair[0]);
if (!input) return;
var v = input.value.trim();
var had = rec && rec[pair[1]] !== undefined && rec[pair[1]] !== null && rec[pair[1]] !== "";
if (v) payload[pair[1]] = v;
else if (had) payload[pair[1]] = "";
});
// The photo does NOT ride in this record any more: the payload carries no
// `Image` key and this form sends none — not the value, not null, not "". The
// delete is the guarantee, whatever a reply may one day carry.
delete payload.Image;
fetch(C.webhook, {
method: C.saveMethod,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
})
.then(function (res) {
if (!res.ok) throw new Error("HTTP " + res.status);
// A RECEIPT, not a delivery claim: n8n answers 200 before the workflow runs.
line.className = "status state-ok";
line.textContent = "Saved — we have your changes. They go live once confirmed.";
})
.catch(function () {
// Non-2xx or network: never a receipt; the data stays in the form.
line.className = "status state-err";
line.textContent = "We could not save your changes just now. Your edits are still " +
"in the form — please try again, or reply to the email that brought you here.";
})
.then(function () { save.disabled = false; });
});
form.appendChild(save);
form.appendChild(line);
nodes.push(form);
nodes.push(deleteBlock());
show(nodes);
// The total needs the fields in the document to sum them — do it after show().
updateTotal();
}
// ---- delete -----------------------------------------------------------------------
function deleteBlock() {
var box = el("div", "danger");
box.appendChild(el("h2", "", "Delete this listing"));
box.appendChild(el("p", "",
"This removes your public listing for good. It cannot be undone from our side. " +
"Tick the box and confirm — nothing is deleted until you do."));
var confirmWrap = el("label", "confirm");
var box_in = el("input");
box_in.type = "checkbox";
box_in.id = "edConfirmDelete";
confirmWrap.appendChild(box_in);
confirmWrap.appendChild(el("span", "",
"Yes, delete my listing permanently."));
box.appendChild(confirmWrap);
var del = el("button", "btn btn-danger", "Delete my listing");
del.type = "button";
del.disabled = true;
var line = el("p", "status", "");
line.id = "deleteStatus";
line.setAttribute("role", "status");
line.setAttribute("aria-live", "polite");
// The deliberate second action: the button cannot fire until the box is ticked.
box_in.addEventListener("change", function () { del.disabled = !box_in.checked; });
del.addEventListener("click", function () {
if (!box_in.checked) return;
del.disabled = true;
box_in.disabled = true;
line.className = "status";
line.textContent = "Deleting…";
// DELETE with the credential and the record id. C.deleteMethod is the one place
// the method lives; the id wire key is the record's own "id".
fetch(C.webhook, {
method: C.deleteMethod,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ has: hash, id: rec && rec.id !== undefined ? rec.id : null })
})
.then(function (res) {
if (!res.ok) throw new Error("HTTP " + res.status);
// Only a 2xx is a success. Anything else fails below.
line.className = "status state-ok";
line.textContent = "Your listing has been deleted. Thank you for having been listed.";
del.hidden = true;
confirmWrap.hidden = true;
})
.catch(function () {
box_in.disabled = false;
del.disabled = !box_in.checked;
line.className = "status state-err";
line.textContent = "We could not delete your listing just now — nothing was removed. " +
"Please try again, or reply to the email that brought you here.";
});
});
box.appendChild(del);
box.appendChild(line);
return box;
}
// ---- the GET ----------------------------------------------------------------------
function load() {
if (!hash) { noCode(); return; }
show([status("Loading your listing…")]);
fetch(C.webhook + "?" + encodeURIComponent(C.hashWireKey) + "=" + encodeURIComponent(hash), {
headers: { Accept: "application/json" }
})
.then(function (res) {
if (!res.ok) {
return res.text().then(function (body) {
var msg = messageOf(body);
// A missing workflow is a CONFIGURATION error — never "your link is invalid".
if (/is not registered/i.test(msg)) { configError(res.status, msg); return; }
if (res.status === 404 || res.status === 500) { invalidLink(res.status, msg); return; }
loadFailed(res.status, msg);
});
}
return res.json().then(function (payload) {
var obj = unwrap(payload);
if (!obj) { shapeMismatch([]); return; }
if (!hasAnyMappedField(obj)) { shapeMismatch(Object.keys(obj)); return; }
renderForm(obj);
}).catch(function () {
shapeMismatch([]);
});
})
.catch(function () {
// Network failure: the hash is still in memory, so a retry needs no new link.
show([
status("We could not load your listing just now.", "warn"),
el("p", "detail", "Your link has been kept, so you can try again."),
retryButton()
]);
});
}
load();
})();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+39 -7
View File
@@ -128,7 +128,9 @@
.hero-stats{display:flex;gap:1.5rem;margin-bottom:2rem;flex-wrap:wrap}.hero-stat{display:flex;align-items:center;gap:.4rem;color:rgba(255,255,255,.65);font-size:.85rem}.hero-stat i{color:var(--brass);font-size:.75rem} .hero-stats{display:flex;gap:1.5rem;margin-bottom:2rem;flex-wrap:wrap}.hero-stat{display:flex;align-items:center;gap:.4rem;color:rgba(255,255,255,.65);font-size:.85rem}.hero-stat i{color:var(--brass);font-size:.75rem}
.hero-actions{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:2rem} .hero-actions{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:2rem}
.hero-notice{position:absolute;top:1.25rem;right:1.25rem;z-index:3;display:flex;align-items:center;gap:.55rem;max-width:min(360px,calc(100% - 2rem));padding:.6rem .85rem .6rem 1rem;background:#f5c542;color:var(--midnight);border:1px solid rgba(13,27,46,.14);border-radius:var(--radius);box-shadow:0 8px 24px rgba(13,27,46,.3);font-family:Inter,sans-serif;font-size:.82rem;line-height:1.4;animation:heroNoticeIn .4s cubic-bezier(.16,1,.3,1)} .hero-notice{position:absolute;top:1.25rem;right:1.25rem;z-index:3;display:flex;align-items:center;gap:.55rem;max-width:min(360px,calc(100% - 2rem));padding:.6rem .85rem .6rem 1rem;background:#f5c542;color:var(--midnight);border:1px solid rgba(13,27,46,.14);border-radius:var(--radius);box-shadow:0 8px 24px rgba(13,27,46,.3);font-family:Inter,sans-serif;font-size:.82rem;line-height:1.4;animation:heroNoticeIn .4s cubic-bezier(.16,1,.3,1)}
.hero-notice[hidden]{display:none} /* `.hero-notice[hidden]{display:none}` used to live here; the site-wide `[hidden]`
rule in /style.css hides it (`.hero-notice` sets `display:flex`, which outranked
the UA rule). */
.hero-notice i{color:var(--midnight);font-size:.9rem;flex-shrink:0} .hero-notice i{color:var(--midnight);font-size:.9rem;flex-shrink:0}
.hero-notice-text{flex:1;min-width:0;overflow-wrap:anywhere} .hero-notice-text{flex:1;min-width:0;overflow-wrap:anywhere}
.hero-notice-close{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:1.35rem;height:1.35rem;padding:0;border:0;border-radius:50%;background:rgba(13,27,46,.12);color:var(--midnight);font-size:1rem;line-height:1;cursor:pointer;transition:background .2s} .hero-notice-close{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:1.35rem;height:1.35rem;padding:0;border:0;border-radius:50%;background:rgba(13,27,46,.12);color:var(--midnight);font-size:1rem;line-height:1;cursor:pointer;transition:background .2s}
@@ -219,16 +221,25 @@
#customQuoteModal .biz-success h3{color:var(--midnight)} #customQuoteModal .biz-success h3{color:var(--midnight)}
#customQuoteModal .biz-success p{color:var(--midnight)} #customQuoteModal .biz-success p{color:var(--midnight)}
@media(max-width:600px){.oca-table td,.oca-table th{padding:.5rem .65rem;font-size:.8rem}.ow-panel{left:5%;right:5%}.ow-close{top:.5rem;right:.5rem;font-size:1.6rem}} @media(max-width:600px){.oca-table td,.oca-table th{padding:.5rem .65rem;font-size:.8rem}.ow-panel{left:5%;right:5%}.ow-close{top:.5rem;right:.5rem;font-size:1.6rem}}
/* Black Friday 2026 — seasonal top banner. Live 2-30 November 2026 only: remove this rule
together with the .bf-topbar markup block at the top of <body> after 30 November 2026.
CI: midnight canvas, brass label/code/CTA only, cream-white body text. No script, no tag. */
.bf-topbar{display:flex;align-items:center;justify-content:center;gap:.3rem 1rem;flex-wrap:wrap;background:var(--midnight);border-bottom:1px solid var(--line-brass);padding:.65rem 1.25rem;font-family:Inter,sans-serif;font-size:.82rem;line-height:1.5;color:rgba(255,255,255,.82);text-align:center;text-decoration:none}
.bf-topbar:hover,.bf-topbar:focus-visible{background:var(--midnight-2);color:#fff}
.bf-topbar-badge{font-family:Marcellus,Georgia,serif;font-size:.78rem;letter-spacing:.08em;text-transform:uppercase;color:var(--brass);white-space:nowrap}
.bf-topbar strong{color:#fff;font-weight:600}
.bf-topbar-code{font-family:Marcellus,Georgia,serif;letter-spacing:.06em;color:var(--brass-bright)}
.bf-topbar-cta{color:var(--brass);white-space:nowrap}
.bf-topbar:hover .bf-topbar-cta,.bf-topbar:focus-visible .bf-topbar-cta{color:var(--brass-bright)}
</style> </style>
<link rel="stylesheet" href="style.css" media="print" onload="this.media='all'" /> <link rel="stylesheet" href="style.css?v=061517baef" />
<noscript><link rel="stylesheet" href="style.css" /></noscript>
<!-- Privacy-friendly analytics by Plausible --> <!-- Privacy-friendly analytics by Plausible -->
<script async src="https://plausible.office.odoo4projects.com/js/pa-NhA7Rf-79VopXFQXhSS4e.js"></script> <script async src="https://plausible.office.odoo4projects.com/js/pa-NhA7Rf-79VopXFQXhSS4e.js"></script>
<script> <script>
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}}; window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init() plausible.init()
</script> </script>
<script defer src="/static/lang.js"></script> <script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head> </head>
<body> <body>
<!-- <!--
@@ -239,7 +250,18 @@
FORM: The Grand Hotel Business Lounge (model-pick, user-chosen). Seed key: 7db4dced. FORM: The Grand Hotel Business Lounge (model-pick, user-chosen). Seed key: 7db4dced.
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md. FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md.
--> -->
<!-- Black Friday 2026 — seasonal banner. Live 2-30 November 2026 only: remove this whole block
(and the .bf-topbar rules in <style>) after 30 November 2026. It is one link to the deal page:
no script, no tracking tag, no event. The offer itself lives on /odoo-black-friday/ only. -->
<a class="bf-topbar" href="/odoo-black-friday/">
<span class="bf-topbar-badge">Black Friday</span>
<span><strong>$100 off your first year</strong> &middot; On the Rise: $395 a year list &rarr; $295 for your first year</span>
<span>Code <span class="bf-topbar-code">HOST27</span> &middot; Ends 30 November 2026</span>
<span class="bf-topbar-cta">See the offer &rarr;</span>
</a>
<!-- Hero --> <!-- Hero -->
<main>
<section class="hero"> <section class="hero">
<div class="hero-notice" id="heroConfirmNotice" role="status" aria-live="polite" hidden> <div class="hero-notice" id="heroConfirmNotice" role="status" aria-live="polite" hidden>
<i class="fas fa-check-circle" aria-hidden="true"></i> <i class="fas fa-check-circle" aria-hidden="true"></i>
@@ -382,10 +404,18 @@
<div class="faq-q"><span>ODOO Community? What can it do?</span> <i class="fas fa-chevron-down"></i></div> <div class="faq-q"><span>ODOO Community? What can it do?</span> <i class="fas fa-chevron-down"></i></div>
<div class="faq-a"><p>Inventory, accounting, CRM, sales, manufacturing, HR — all in one system. Free and fully open-source. <a href="what-can-i-use-odoo-for/">What you can use Odoo for, in plain English</a>.</p></div> <div class="faq-a"><p>Inventory, accounting, CRM, sales, manufacturing, HR — all in one system. Free and fully open-source. <a href="what-can-i-use-odoo-for/">What you can use Odoo for, in plain English</a>.</p></div>
</div> </div>
<div class="faq-card plausible-event-name=FAQ" onclick="this.classList.toggle('open')">
<div class="faq-q"><span>Do you need an Odoo staging environment?</span> <i class="fas fa-chevron-down"></i></div>
<div class="faq-a"><p>Most small businesses do not. If you change Odoo's code you do; if you install a finished module, a backup and a careful test is enough. <a href="odoo-staging-environment/">Do you need an Odoo staging environment?</a></p></div>
</div>
<div class="faq-card plausible-event-name=FAQ" onclick="this.classList.toggle('open')"> <div class="faq-card plausible-event-name=FAQ" onclick="this.classList.toggle('open')">
<div class="faq-q"><span>Do you have an affiliate program?</span> <i class="fas fa-chevron-down"></i></div> <div class="faq-q"><span>Do you have an affiliate program?</span> <i class="fas fa-chevron-down"></i></div>
<div class="faq-a"><p>No. We believe it is in our best interest to have competitive prices for our shared clients.</p></div> <div class="faq-a"><p>No. We believe it is in our best interest to have competitive prices for our shared clients.</p></div>
</div> </div>
<div class="faq-card plausible-event-name=FAQ" onclick="this.classList.toggle('open')">
<div class="faq-q"><span>I need a developer for a custom module — where do I find one?</span> <i class="fas fa-chevron-down"></i></div>
<div class="faq-a"><p>We keep a directory of independent Odoo freelancers. Each card carries what the freelancer published and their contact details where they published them. <a href="odoo-community-developers/">Browse the Odoo freelancers</a>.</p></div>
</div>
</div> </div>
<a href="support.html" class="faq-link">Full FAQ &rarr;</a> <a href="support.html" class="faq-link">Full FAQ &rarr;</a>
</div> </div>
@@ -495,7 +525,7 @@
<h2 class="ow-title" id="orderPlanTitle">On the Rise</h2> <h2 class="ow-title" id="orderPlanTitle">On the Rise</h2>
<p class="ow-price"> <p class="ow-price">
<span class="ow-price-amount" id="orderPlanPrice">$ 395.00</span> <span id="orderPlanPeriod">/ year</span> <span class="ow-price-amount" id="orderPlanPrice">$ 395.00</span> <span id="orderPlanPeriod">/ year</span>
<span class="ow-price-tax" id="orderPlanTax">+ tax</span> <span class="ow-price-tax" id="orderPlanTax">tax included</span>
</p> </p>
<p class="ow-coupon-msg" id="orderCouponMsg">Questions before you buy? Ask the assistant on this page.</p> <p class="ow-coupon-msg" id="orderCouponMsg">Questions before you buy? Ask the assistant on this page.</p>
<p class="ow-yearly-save" id="orderYearlySave">Billed yearly. No setup fee.</p> <p class="ow-yearly-save" id="orderYearlySave">Billed yearly. No setup fee.</p>
@@ -625,6 +655,7 @@
</section> </section>
<!-- Footer --> <!-- Footer -->
</main>
<footer class="footer"> <footer class="footer">
<div class="container"> <div class="container">
<div class="fgrid"> <div class="fgrid">
@@ -633,6 +664,7 @@
<ul> <ul>
<li><a href="/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li> <li><a href="/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li>
<li><a href="odoo-hosting-2027.html">ODOO Hosting in 2027</a></li> <li><a href="odoo-hosting-2027.html">ODOO Hosting in 2027</a></li>
<li><a href="/odoo-community-developers/">Odoo Freelancer Directory</a></li>
<li><a href="support.html">Support</a></li> <li><a href="support.html">Support</a></li>
<li><a href="legal.html">Legal</a></li> <li><a href="legal.html">Legal</a></li>
</ul> </ul>
@@ -671,7 +703,7 @@
</footer> </footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script src="script.js"></script> <script src="script.js?v=d2046e3469"></script>
<script defer src="/static/agent-odoo4projects/agent.js"></script> <script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body> </body>
</html> </html>
+5 -3
View File
@@ -25,7 +25,7 @@
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/> />
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css?v=061517baef" />
<style> <style>
.legal-hero { .legal-hero {
padding: 4rem 0 2.5rem; padding: 4rem 0 2.5rem;
@@ -123,6 +123,7 @@
</script> </script>
</head> </head>
<body> <body>
<main>
<div class="container"> <div class="container">
<div class="blog-header"> <div class="blog-header">
<a href="index.html">&larr; Back to Home</a> <a href="index.html">&larr; Back to Home</a>
@@ -145,7 +146,7 @@
WY 82801, USA WY 82801, USA
</p> </p>
<p style="margin-top: 0.75rem;"> <p style="margin-top: 0.75rem;">
Contact: <span class="contact-email">support@ODOO4projects.com</span> Contact: <span class="contact-email">support@odoo4projects.com</span>
</p> </p>
</div> </div>
@@ -202,6 +203,7 @@
</div> </div>
</section> </section>
<script defer src="/static/agent-odoo4projects/agent.js"></script> </main>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body> </body>
</html> </html>
+11 -5
View File
@@ -1,9 +1,10 @@
# ODOO4projects # ODOO4projects
> ODOO4projects is a managed Odoo Community hosting service. We run the server, the backups, the > ODOO4projects is a managed Odoo Community hosting service. We run the server, the backups, the
> SSL certificate and the version upgrades for a monthly-or-yearly price, so the customer does not > SSL certificate and the updates that keep your Odoo current within its version, for a monthly-or-
> have to administer a server. One login: everything happens inside the customer's own Odoo > yearly price, so the customer does not have to administer a server. Moving to a new major version
> instance. We do not sell Odoo itself; we sell a business system that runs. > is a migration and is quoted separately. One login: everything happens inside the customer's own
> Odoo instance. We do not sell Odoo itself; we sell a business system that runs.
## Products and pricing (verified 2026-10-21, list prices, USD, VAT excluded) ## Products and pricing (verified 2026-10-21, list prices, USD, VAT excluded)
@@ -37,9 +38,10 @@
- [Odoo hosting 2027 — comparison landing page](https://odoo4projects.com/odoo-hosting-2027.html): every route to Odoo (managed, VPS, odoo.sh, Odoo Online, own hardware) side by side, with annual and five-year cost comparisons. - [Odoo hosting 2027 — comparison landing page](https://odoo4projects.com/odoo-hosting-2027.html): every route to Odoo (managed, VPS, odoo.sh, Odoo Online, own hardware) side by side, with annual and five-year cost comparisons.
- [Odoo ERP Automation Bootcamp](https://odoo4projects.com/odoo-erp-automation-bootcamp/): a three-month, ten-session live bootcamp with Oliver Arnold, Technical Lead, that takes a small business from zero to a running ERP. Thursdays, one hour, Q1 2027, starting 21 January 2027; one year of hosted Odoo Community Edition included. The bootcamp is limited to six participants per cohort. The current price is confirmed on registration. - [Odoo ERP Automation Bootcamp](https://odoo4projects.com/odoo-erp-automation-bootcamp/): a three-month, ten-session live bootcamp with Oliver Arnold, Technical Lead, that takes a small business from zero to a running ERP. Thursdays, one hour, Q1 2027, starting 21 January 2027; one year of hosted Odoo Community Edition included. The bootcamp is limited to six participants per cohort. The current price is confirmed on registration.
- [ODOO x N8N Masterclass](https://odoo4projects.com/odoo-n8n-masterclass/): a two-hour live masterclass with Oliver Arnold on connecting Odoo to n8n — calling n8n webhooks from Odoo automated actions, how the n8n integration for Odoo works, and website workarounds that extend your automation. Hands-on in a free Odoo trial system; n8n itself runs on n8n's own free plan, which is not included. USD 120.00, next session the four Thursdays of February 2027 at 17:00 Central Time. - [ODOO x N8N Masterclass](https://odoo4projects.com/odoo-n8n-masterclass/): a two-hour live masterclass with Oliver Arnold on connecting Odoo to n8n — calling n8n webhooks from Odoo automated actions, how the n8n integration for Odoo works, and website workarounds that extend your automation. Hands-on in a free Odoo trial system; n8n itself runs on n8n's own free plan, which is not included. USD 120.00, next session the four Thursdays of February 2027 at 17:00 Central Time.
- [ODOO Workshops and Classes](https://odoo4projects.com/workshops/): the two live courses side by side — the two-hour ODOO x N8N Masterclass (USD 120.00) and the ten-session ODOO ERP Automation Bootcamp (USD 465.00), both taught by Oliver Arnold — which course fits which business, and a full comparison table. - [ODOO Workshops and Classes](https://odoo4projects.com/workshops/): the two live courses side by side — the two-hour ODOO x N8N Masterclass (USD 120.00) and the ten-session ODOO ERP Automation Bootcamp (USD 595.00), both taught by Oliver Arnold — which course fits which business, and a full comparison table.
- [What can I use Odoo for?](https://odoo4projects.com/what-can-i-use-odoo-for/): plain-English guide to what Odoo is used for — the ten apps and what each is for, what Odoo is used for by business type (manufacturing, retail and trade, services, construction, nonprofits, accounting practices, small business starting out), where it stops (Odoo Online will not take custom code, it will not do your trade's specialist work), what it costs, and how to start. Includes the nine-question FAQ. - [What can I use Odoo for?](https://odoo4projects.com/what-can-i-use-odoo-for/): plain-English guide to what Odoo is used for — the ten apps and what each is for, what Odoo is used for by business type (manufacturing, retail and trade, services, construction, nonprofits, accounting practices, small business starting out), where it stops (Odoo Online will not take custom code, it will not do your trade's specialist work), what it costs, and how to start. Includes the nine-question FAQ.
- [A briefing for Andrew](https://odoo4projects.com/odooityourself.html): Odoo Community as a first-class citizen — the vision behind our managed Community hosting, the six regions, the management console, the MCP agent that can touch the server, and the plan prices (Empire quoted per setup). - [Odoo staging environment](https://odoo4projects.com/odoo-staging-environment/): what an Odoo staging environment is, how odoo.sh's staging branches work, why most small businesses do not need one, and the simpler back-up-install-test route we offer instead.
- [Odoo freelancers](https://odoo4projects.com/odoo-community-developers/): independent Odoo freelancers for our clients looking for one. Each card carries what the freelancer published and their contact details where they published them — no rating and no ranking, and the client writes to the freelancer directly.
- [Home](https://odoo4projects.com/): plans and pricing, FAQ, server locations. - [Home](https://odoo4projects.com/): plans and pricing, FAQ, server locations.
- [Support](https://odoo4projects.com/support.html): support and frequently asked questions. - [Support](https://odoo4projects.com/support.html): support and frequently asked questions.
- [Legal](https://odoo4projects.com/legal.html): company details. - [Legal](https://odoo4projects.com/legal.html): company details.
@@ -48,6 +50,10 @@
## Blog posts ## Blog posts
- [Your Agent Can Run Your Odoo Hosting: the MCP Server Is Live](https://odoo4projects.com/blog/posts/your-agent-can-run-your-odoo-hosting.html) — 2026-11-05 — This is the piece of the Odoo 20 update we are asked about most: an MCP server that lets your agent work on your Odoo hosting directly, instead of you clicking through menus to do routine things.
- [Our freelancer directory opens on 1 December. Help us get the signup right](https://odoo4projects.com/blog/posts/freelancer-directory-soft-launch.html) — 2026-11-04 — We need your help! We are opening our Odoo Community Freelancer directory on 1 December. Before real clients start browsing it, we want the signup to work, and we would like your help to get it right.
- [Odoo 20 Is Live on Our Servers — Here Is What Changed](https://odoo4projects.com/blog/posts/odoo-20-is-live-on-our-servers-here-is-what-changed.html) — 2026-11-03 — Two weeks. That is how long it took us to bring up Odoo 20 on our servers, with every new feature that comes with it. It is live now — if your instance runs with us, the engine underneath it just moved forward.
- [From a Spreadsheet to a Running ERP in Ten Thursday Sessions](https://odoo4projects.com/blog/posts/from-a-spreadsheet-to-a-running-erp.html) — 2026-11-02 — Most small businesses do not run on an ERP. They run on a spreadsheet, a messaging group, and one person's memory. It works — until that person is on holiday.
- [ODOO4projects Supports the OCA Paraguay Internationalization Hackathon](https://odoo4projects.com/blog/posts/oca-paraguay-internationalization-hackathon.html) — 2026-10-29 — Two online days to push Odoo's Paraguayan localisation forward — and we are supporting them. - [ODOO4projects Supports the OCA Paraguay Internationalization Hackathon](https://odoo4projects.com/blog/posts/oca-paraguay-internationalization-hackathon.html) — 2026-10-29 — Two online days to push Odoo's Paraguayan localisation forward — and we are supporting them.
- [How to Install Odoo Modules Using Git — and the Quick Git Route That Skips the Console](https://odoo4projects.com/blog/posts/how-to-install-odoo-modules-using-git.html) — 2026-10-28 — Every business is slightly different, which is why a system that only does the standard thing is only ever half a system. - [How to Install Odoo Modules Using Git — and the Quick Git Route That Skips the Console](https://odoo4projects.com/blog/posts/how-to-install-odoo-modules-using-git.html) — 2026-10-28 — Every business is slightly different, which is why a system that only does the standard thing is only ever half a system.
- [ODOO Community Is Going Online on ODOO4projects](https://odoo4projects.com/blog/posts/odoo-community-is-going-online-on-odoo4projects.html) — 2026-10-27 — ODOO Community is online. - [ODOO Community Is Going Online on ODOO4projects](https://odoo4projects.com/blog/posts/odoo-community-is-going-online-on-odoo4projects.html) — 2026-10-27 — ODOO Community is online.
File diff suppressed because it is too large. Load diff
+942
View File
@@ -0,0 +1,942 @@
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Odoo Community + AI Agent: Run It With Your Own Agent</title>
<meta name="description" content="Odoo Community + your own AI agent, on our managed hosting. Bring Hermes or Claude Code, keep your keys and your data, no lock-in. Much MCP is free." />
<!-- DRAFT: not indexed, not linked, not in sitemap.xml / llms.txt. Remove this robots meta on go-live. -->
<meta name="robots" content="noindex, nofollow" />
<link rel="canonical" href="https://odoo4projects.com/odoo-community-ai/" />
<meta property="og:title" content="Run Odoo Community with your own AI agent" />
<meta property="og:description" content="Bring the AI agent you already use to your Odoo Community system. It works inside Odoo, we run the server, and you keep your keys." />
<meta property="og:type" content="article" />
<meta property="og:url" content="https://odoo4projects.com/odoo-community-ai/" />
<meta name="twitter:card" content="summary" />
<link rel="icon" href="/images/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap"
media="print"
onload="this.media='all'"
/>
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="stylesheet" href="/style.css?v=061517baef" />
<style>
/* ---- "Odoo Community + AI agent" page (page-scoped; additive).
Reuses the top bar, band rhythm, hero, FAQ and final-CTA treatment already
shipped on odoo-staging-environment/ and what-can-i-use-odoo-for/.
Additions this page needs and no other: .oca-table (the two MCP servers shown
apart), .oca-fence (the "does not move data" sentence, the page's pivot),
.oca-proof (the §3 proof sentence), .oca-dev (the collapsed developer
<details>, after the offer) and the light-band CTA re-pointing. ---- */
.oca-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Top bar (page-scoped: the site has no global header) */
.cmp-topbar {
background: var(--midnight-2);
border-bottom: 1px solid var(--line-brass);
padding: 0.85rem 0;
}
.cmp-topbar .container {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.cmp-brand {
font-family: Marcellus, Georgia, serif;
font-size: 1.15rem;
letter-spacing: 0.04em;
color: #fff;
}
.cmp-brand span {
color: var(--brass);
}
.cmp-topbar-links {
display: flex;
gap: 1.25rem;
font-size: 0.82rem;
margin-left: auto;
}
.cmp-topbar-links a {
color: rgba(255, 255, 255, 0.7);
}
.cmp-topbar-links a:hover {
color: var(--brass);
}
.cmp-topbar .lang-switch-slot {
display: flex;
align-items: center;
padding-left: 1rem;
border-left: 1px solid rgba(201, 162, 39, 0.45);
}
.cmp-topbar .lang-switch {
color: rgba(255, 255, 255, 0.7);
}
.cmp-topbar .lang-switch a:hover,
.cmp-topbar .lang-switch a:focus-visible {
color: var(--brass-bright);
}
.cmp-topbar .lang-switch a[aria-current="true"] {
color: var(--brass);
text-decoration: underline;
text-underline-offset: 3px;
}
/* Hero */
.oc-hero {
min-height: 0;
padding: 3.5rem 0 3rem;
}
.oc-hero .container {
position: relative;
z-index: 1;
}
.oc-hero .hero-sub {
max-width: 760px;
}
.oc-hero-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
opacity: 0.25;
filter: brightness(1.15) saturate(1.1);
}
/* Prose column */
.oc-body {
max-width: 760px;
margin: 0 auto;
color: var(--ink-soft);
line-height: 1.75;
}
.section-dark .oc-body {
color: rgba(255, 255, 255, 0.78);
}
.oc-body p:last-child {
margin-bottom: 0;
}
.oc-body strong {
color: var(--ink);
}
.section-dark .oc-body strong {
color: #fff;
}
.section-dark .oc-body a {
color: var(--brass-bright);
}
/* Sub-headings inside the prose: inherit the site's h3 size (clamp(1.1rem, 2vw, 1.4rem)) */
.oc-body h3 {
font-family: Marcellus, Georgia, serif;
font-weight: 400;
letter-spacing: 0.01em;
color: var(--midnight);
margin: 1.7rem 0 0.4rem;
}
.section-dark .oc-body h3 {
color: #fff;
}
/* The §3 proof sentence, read first: brass rules top and bottom, ink text */
.oca-proof {
color: var(--ink);
border-top: 1px solid var(--line-brass);
border-bottom: 1px solid var(--line-brass);
padding: 0.9rem 0;
margin-bottom: 1.2rem;
}
/* The two MCP servers, shown apart (§5) */
.oca-table {
width: 100%;
max-width: 900px;
margin: 1.5rem auto;
border-collapse: collapse;
font-size: 0.88rem;
line-height: 1.6;
}
.oca-table th,
.oca-table td {
text-align: left;
vertical-align: top;
padding: 0.7rem 0.9rem;
border-bottom: 1px solid var(--line);
color: var(--ink-soft);
}
.oca-table thead th {
font-family: Marcellus, Georgia, serif;
font-weight: 400;
color: var(--midnight);
border-bottom: 1px solid rgba(201, 162, 39, 0.5);
}
.oca-table tbody th {
color: var(--ink);
font-weight: 600;
white-space: nowrap;
}
.oca-table td strong {
color: var(--ink);
}
/* The fence sentence, the page's pivot: a midnight plaque with a brass hairline */
.oca-fence {
max-width: 860px;
margin: 1.75rem auto 0;
padding: 1.35rem 1.5rem;
background: var(--midnight);
border: 1px solid var(--line-brass);
border-radius: var(--radius);
color: rgba(255, 255, 255, 0.82);
line-height: 1.75;
}
.oca-fence p {
margin: 0;
}
.oca-fence a {
color: var(--brass-bright);
}
/* Numbered run-through */
.oc-steps {
max-width: 820px;
margin: 1.5rem auto 0;
padding: 0;
list-style: none;
counter-reset: oc-step;
}
.oc-steps li {
position: relative;
counter-increment: oc-step;
padding: 0 0 1.4rem 3.2rem;
color: rgba(255, 255, 255, 0.78);
line-height: 1.7;
border-left: 1px solid rgba(201, 162, 39, 0.25);
margin-left: 1rem;
}
.oc-steps li::before {
content: counter(oc-step);
position: absolute;
left: -1.05rem;
top: -0.15rem;
width: 2.1rem;
height: 2.1rem;
display: flex;
align-items: center;
justify-content: center;
font-family: Marcellus, Georgia, serif;
font-size: 1rem;
color: var(--midnight);
background: var(--brass);
border-radius: 50%;
}
.oc-steps li strong {
color: #fff;
font-weight: 600;
}
.oc-steps li:last-child {
padding-bottom: 0;
}
.section-light .oc-steps li {
color: var(--ink-soft);
border-left-color: rgba(138, 111, 31, 0.3);
}
.section-light .oc-steps li strong {
color: var(--ink);
}
/* FAQ */
.cmp-faq-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
max-width: 1000px;
margin: 0 auto;
}
.cmp-faq {
background: var(--ivory);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 0.4rem 1.25rem;
box-shadow: var(--shadow-rest);
transition: box-shadow 0.25s;
}
.cmp-faq[open],
.cmp-faq:hover {
box-shadow: var(--shadow-hover);
}
.cmp-faq summary {
cursor: pointer;
list-style: none;
padding: 0.85rem 0;
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
font-family: Marcellus, Georgia, serif;
font-size: 0.98rem;
color: var(--midnight);
}
.cmp-faq summary::-webkit-details-marker {
display: none;
}
.cmp-faq summary::after {
content: "+";
font-family: Inter, sans-serif;
color: var(--brass);
font-size: 1.15rem;
line-height: 1;
flex-shrink: 0;
transition: transform 0.25s;
}
.cmp-faq[open] summary::after {
content: "\2212";
}
.cmp-faq summary:focus-visible {
outline: 2px solid var(--brass);
outline-offset: 3px;
border-radius: var(--radius);
}
.cmp-faq .cmp-a {
font-size: 0.87rem;
color: var(--ink-soft);
line-height: 1.7;
padding-bottom: 1rem;
}
.cmp-faq .cmp-a p:last-child {
margin-bottom: 0;
}
/* Final CTA (the block odoo-hosting-2027.html uses), run on the light band here */
.cmp-cta {
text-align: center;
}
.cmp-cta .cmp-lead {
font-size: 1.1rem;
color: rgba(255, 255, 255, 0.72);
max-width: 720px;
line-height: 1.75;
margin: 0 auto 1.5rem;
}
/* .cmp-cta and .section-light sit on the SAME element, so the override must be a
compound selector, not a descendant one. */
.cmp-cta.section-light .cmp-lead {
color: var(--ink-soft);
}
.cmp-cta.section-light .cmp-lead p:last-child {
margin-bottom: 0;
}
.cmp-cta-actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.section-light .cmp-cta-actions .btn-outline {
color: var(--ink);
border-color: rgba(23, 28, 38, 0.22);
}
.section-light .cmp-cta-actions .btn-outline:hover {
color: var(--brass-deep);
border-color: var(--brass-deep);
background: rgba(201, 162, 39, 0.08);
}
.oca-recap {
max-width: 820px;
margin: 1.75rem auto 0;
font-size: 0.87rem;
color: var(--ink-soft);
line-height: 1.7;
}
/* For your developer: the collapsed technical block, after the offer */
.oca-dev {
max-width: 860px;
margin: 0 auto;
background: var(--midnight-2);
border: 1px solid var(--line-brass);
border-radius: var(--radius-lg);
padding: 0.5rem 1.5rem;
}
.oca-dev summary {
cursor: pointer;
list-style: none;
padding: 0.95rem 0;
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
font-family: Marcellus, Georgia, serif;
font-size: 1.05rem;
color: #fff;
}
.oca-dev summary::-webkit-details-marker {
display: none;
}
.oca-dev summary::after {
content: "+";
font-family: Inter, sans-serif;
color: var(--brass);
font-size: 1.15rem;
line-height: 1;
flex-shrink: 0;
}
.oca-dev[open] summary::after {
content: "\2212";
}
.oca-dev summary:focus-visible {
outline: 2px solid var(--brass);
outline-offset: 3px;
border-radius: var(--radius);
}
.oca-dev .oca-dev-body {
color: rgba(255, 255, 255, 0.78);
font-size: 0.87rem;
line-height: 1.75;
padding-bottom: 1.25rem;
}
.oca-dev .oca-dev-body p:last-child {
margin-bottom: 0;
}
.oca-dev .oca-dev-body ol {
padding-left: 1.2rem;
margin: 0 0 1rem;
}
.oca-dev .oca-dev-body li {
margin-bottom: 0.6rem;
}
.oca-dev .oca-dev-body li:last-child {
margin-bottom: 1rem;
}
.oca-dev .oca-dev-body li::marker {
color: var(--brass);
}
/* Focus states on every CTA */
.btn:focus-visible,
.cmp-topbar-links a:focus-visible {
outline: 3px solid var(--brass-bright);
outline-offset: 2px;
border-radius: var(--radius);
}
.btn-brass:focus-visible {
outline-color: #fff;
}
.section-light .btn-brass:focus-visible {
outline-color: var(--midnight);
}
@media (max-width: 768px) {
.cmp-faq-grid {
grid-template-columns: 1fr;
}
.oc-hero {
padding: 3rem 0 2.5rem;
}
.oc-steps li {
padding-left: 2.6rem;
}
}
/* Below 640px the two MCP columns cannot hold text side by side, so each row
becomes a card: the row label is the title, each value carries its own column
label (data-label). The two servers stay visibly apart. */
@media (max-width: 640px) {
.oca-table {
border-collapse: separate;
font-size: 1rem;
}
.oca-table thead {
display: none;
}
.oca-table tr {
display: block;
background: var(--ivory);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 0.35rem 1rem 0.85rem;
margin-bottom: 0.9rem;
box-shadow: var(--shadow-rest);
}
.oca-table tbody th {
display: block;
padding: 0.6rem 0 0.5rem;
border-bottom: 1px solid var(--line-brass);
white-space: normal;
}
.oca-table td {
display: block;
padding: 0.6rem 0 0;
border-bottom: 0;
color: var(--ink);
}
.oca-table td::before {
content: attr(data-label);
display: block;
font-weight: 600;
font-size: 0.82rem;
color: var(--midnight);
margin-bottom: 0.15rem;
}
}
</style>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebPage",
"@id": "https://odoo4projects.com/odoo-community-ai/#webpage",
"url": "https://odoo4projects.com/odoo-community-ai/",
"name": "Odoo Community + AI Agent: Run It With Your Own Agent",
"description": "Odoo Community + your own AI agent, on our managed hosting. Bring Hermes or Claude Code, keep your keys and your data, no lock-in. Much MCP is free.",
"inLanguage": "en-US",
"isPartOf": {
"@id": "https://odoo4projects.com/#organization"
},
"breadcrumb": {
"@id": "https://odoo4projects.com/odoo-community-ai/#breadcrumb"
},
"mainEntity": {
"@id": "https://odoo4projects.com/odoo-community-ai/#article"
}
},
{
"@type": "Article",
"@id": "https://odoo4projects.com/odoo-community-ai/#article",
"headline": "Odoo Community + AI Agent: Run It With Your Own Agent",
"description": "Odoo Community + your own AI agent, on our managed hosting. Bring Hermes or Claude Code, keep your keys and your data, no lock-in. Much MCP is free.",
"articleSection": [
"What you get",
"Why Odoo Community is ready for this now",
"What you need",
"A run-through: connect your agent, then install a module",
"Why your own agent beats a provider's closed AI",
"Questions owners ask",
"Start"
],
"inLanguage": "en-US",
"datePublished": "2026-11-01",
"dateModified": "2026-11-01",
"author": {
"@id": "https://odoo4projects.com/#organization"
},
"publisher": {
"@id": "https://odoo4projects.com/#organization"
},
"mainEntityOfPage": {
"@id": "https://odoo4projects.com/odoo-community-ai/#webpage"
}
},
{
"@type": "FAQPage",
"@id": "https://odoo4projects.com/odoo-community-ai/#faq",
"isPartOf": {
"@id": "https://odoo4projects.com/odoo-community-ai/#webpage"
},
"mainEntity": [
{
"@type": "Question",
"name": "Do I need to be technical?",
"acceptedAnswer": {
"@type": "Answer",
"text": "No. You need to already use an AI agent, and that is the only precondition. Two of our own clients run theirs with no IT background at all. If you can ask for something in plain words, you can use this."
}
},
{
"@type": "Question",
"name": "What do I need to already have?",
"acceptedAnswer": {
"@type": "Answer",
"text": "An AI agent you already run (Hermes or Claude Code), and an Odoo Community system with us — or the plans to start one. There is nothing to install by hand: we set up the two MCP connections for the agent, and the free Much MCP gives it the Odoo skills."
}
},
{
"@type": "Question",
"name": "How much of my time does this take?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Once the two connections are set up, the next thing you do is ask the agent for something. You point your agent at the two MCP servers once, or send us a short message and we set them up for you. From then on the agent works inside your Odoo and you read and approve the result. There is nothing to build or train on your side."
}
},
{
"@type": "Question",
"name": "Is my data locked in?",
"acceptedAnswer": {
"@type": "Answer",
"text": "No. Your keys stay yours, your agent stays yours, and your Odoo database stays yours. We run the hosting; we do not hold your agent or the work you have built with it."
}
},
{
"@type": "Question",
"name": "Which agent does this work with?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Hermes or Claude Code, today. Those are the two we support now."
}
},
{
"@type": "Question",
"name": "What can the agent actually do, and what can’t it?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Inside Odoo, the agent can read and write your records through the Odoo MCP (an invoice, a customer, a product line), because the free Much MCP gives it those Odoo skills. Around Odoo, it can run the hosting through our MCP: start, stop and restart services, install a module, add or check a domain, manage backups, and read the logfiles. What our MCP does not do is move your data. It does not migrate or import records; it runs the server. Moving data is a different job with its own route."
}
},
{
"@type": "Question",
"name": "Do I need Enterprise to get AI in Odoo?",
"acceptedAnswer": {
"@type": "Answer",
"text": "No. Odoo’s own built-in AI is an Enterprise feature. On Community you bring your own agent instead, which is the point of this page, and the free community modules and MCP servers cover much of the ground Enterprise used to hold back."
}
},
{
"@type": "Question",
"name": "What does it cost?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Much MCP is free and comes bundled with our service, so it is not an extra you buy. You pay for the hosting, on the same four plans as always, and Odoo Community carries no per-user licence, so the agent’s own account costs nothing extra."
}
}
]
},
{
"@type": "BreadcrumbList",
"@id": "https://odoo4projects.com/odoo-community-ai/#breadcrumb",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://odoo4projects.com/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Odoo Community + AI",
"item": "https://odoo4projects.com/odoo-community-ai/"
}
]
}
]
}
</script>
<!-- Privacy-friendly analytics by Plausible -->
<script async src="https://plausible.office.odoo4projects.com/js/pa-NhA7Rf-79VopXFQXhSS4e.js"></script>
<script>
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
</head>
<body>
<!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. -->
<div class="cmp-topbar">
<div class="container">
<a class="cmp-brand" href="/index.html">ODOO4<span>projects</span></a>
<nav class="cmp-topbar-links" aria-label="Page links">
<a href="/index.html#pricing">Plans &amp; pricing</a>
<a href="/support.html">Support</a>
</nav>
<div data-lang-switcher class="lang-switch-slot"></div>
</div>
</div>
<!-- Hero -->
<main>
<section class="hero oc-hero">
<div class="hero-bg">
<img
class="oc-hero-img"
src="/images/hero/amsterdam.webp"
alt="Amsterdam at dusk, one of the six regions our servers run in"
width="1920"
height="768"
fetchpriority="high"
decoding="async"
/>
</div>
<div class="container">
<p class="hero-kicker">ODOO COMMUNITY + AI</p>
<h1>Run your Odoo Community business with your own AI agent</h1>
<p class="hero-sub">You already use an AI agent — Hermes or Claude Code. Connect it to your Odoo and it can do the work you keep putting off: write the invoice, update the customer, add the stock item, install the module. You answer the question once, and the agent does the typing. Your keys stay yours, and your data stays yours.</p>
<div class="hero-actions">
<a href="/support.html#contact" class="btn btn-brass btn-lg">Ask us a question</a>
<a href="/index.html#pricing" class="btn btn-outline btn-lg">See the plans</a>
</div>
</div>
</section>
<!-- What you get -->
<section class="section-light">
<div class="container">
<h2 class="section-title">What you get</h2>
<div class="oc-body">
<p class="oca-proof">Two of our clients know nothing about IT, and they use Claude Code to implement modules in their Odoo. Not developers, not IT staff. Business owners with an agent, the same as you.</p>
<!-- OPEN-SLOT (O1): the two non-technical Claude Code clients stay unnamed until naming permission is confirmed. -->
<p>You did not start a business to type the same invoice forty times, or to hunt for a record you know is in there somewhere. An agent removes that kind of work, because the work is repetitive and the rules are simple, which is exactly what an agent is good at and a person is bad at.</p>
<p>Ask for something in plain words. The invoice gets written, the customer record gets updated, the missing stock item gets added, the module you wanted gets installed. You read the result and approve it, instead of building it by hand.</p>
<p>Here is how that lands during a week. The supplier invoices that used to pile up until the weekend are entered on the day they arrive, because you hand them to the agent and check the first one. The product you forgot to add is added when you notice it, not when someone finally complains. The new menu you asked your developer about last quarter is installed this afternoon.</p>
<p>None of this is a promise about your numbers. It is the plain shape of it: the repetitive part moves off your desk, and you keep the judgement.</p>
</div>
</div>
</section>
<!-- Why Odoo Community is ready for this now -->
<section class="section-dark">
<div class="container">
<h2 class="section-title">Why Odoo Community is ready for this now</h2>
<div class="oc-body">
<p>Odoo's best AI features used to be Enterprise-only. On Community you get there a different way — with your own agent — and that route works today, for three reasons. Odoo's own built-in AI still lives in the Enterprise edition, and Community does not ship it; that used to be a real gap.</p>
<p>First, the community modules. The Odoo Community Association, the group that writes and maintains free Odoo modules, now builds modules that cover a lot of what Enterprise used to hold back. Enterprise's newest features are increasingly matched by free community modules. It is not a mirror image, and we are not going to claim it is, but the distance is much shorter than it was.</p>
<p>Second, the agent side. MCP is the plug that lets an AI agent use an outside tool. Free MCP servers now exist that give an AI agent the skills to work inside Odoo. That is the piece that lets your agent, which you already have, do real work in the system rather than just talk about it.</p>
<p>Third, the hosting side. We run your Odoo for you on our own servers, and we ship our own MCP server so your agent can run the hosting tasks as well. You do not need a server of your own to make any of this work.</p>
<p>One more difference worth knowing, because it decides whether this is affordable at all. Odoo Community has no per-user licence. So giving your agent its own user account, with its own limits on what it may touch, costs you nothing extra.</p>
</div>
</div>
</section>
<!-- What you need -->
<section class="section-light">
<div class="container">
<h2 class="section-title">What you need</h2>
<div class="oc-body">
<p>There are four things. You bring one, and we bring the other three.</p>
<h3>(1) An agent you already run.</h3>
<p>Hermes or Claude Code, today. That is the only technical precondition on this whole page. The rest we do for you. If you are already using one of those, you are ready.</p>
<h3>(2) Two MCP connections — and it matters that there are two.</h3>
<p>You point your agent at an MCP server, and the agent can use whatever that server offers. You will set up two of them, and they do two different jobs. Keep them apart in your head, because blurring them is the mistake.</p>
</div>
<table class="oca-table">
<caption class="oca-sr-only">The two MCP servers, side by side</caption>
<thead>
<tr><th scope="col"></th><th scope="col">The Odoo MCP</th><th scope="col">The hosting MCP</th></tr>
</thead>
<tbody>
<tr><th scope="row">What it is</th><td data-label="The Odoo MCP">A free MCP server that gives your agent Odoo skills (we use Much MCP)</td><td data-label="The hosting MCP">Our own MCP server, shipped inside our service</td></tr>
<tr><th scope="row">What it does</th><td data-label="The Odoo MCP">The agent works <strong>inside Odoo</strong>: reads and writes records, such as an invoice, a customer or a product line</td><td data-label="The hosting MCP">The agent runs <strong>the server around Odoo</strong>: start, stop and restart services, install a module, add or check a domain, manage backups, read the logfiles</td></tr>
<tr><th scope="row">What it touches</th><td data-label="The Odoo MCP">Your business records</td><td data-label="The hosting MCP">The hosting, not your records</td></tr>
</tbody>
</table>
<div class="oc-body">
<h3>(3) Much MCP is free, and it comes with our service.</h3>
<p>Much MCP is the free MCP server that gives your agent its Odoo skills. It comes bundled with our service. You do not buy it separately, and it is not a third-party cost on top of your plan.</p>
<h3>(4) Our hosting.</h3>
<p>Your Odoo runs on our servers, managed by us: the server, the backups, the certificate and the updates are our job, not yours. Our MCP server is the piece your agent uses to run the hosting tasks, which is why there is no server of your own to look after.</p>
</div>
<div class="oca-fence">
<p>Our MCP does hosting operations only. It does not move your data, and it does not perform migrations. Moving records between two systems is a different job with a different route; this page is about running your business with an agent, not about upgrading a version. (Upgrading an Odoo version is a separate job. That is on <a href="/odoo-community-upgrade.html">our upgrade page</a>.)</p>
</div>
</div>
</section>
<!-- A real run-through -->
<section class="section-dark">
<div class="container">
<h2 class="section-title">A run-through: connect your agent, then install a module</h2>
<!-- DRAFT(R1): steps reconstructed from the published capability list; awaiting the user's real steps. -->
<div class="oc-body">
<p>Here is one task, start to finish: you connect the agent you already run to your Odoo, then ask it to install a module. It is two connections, then one request. Nothing here needs a server of your own, and nothing here is a migration. No records move.</p>
<p>Your agent reaches Odoo through MCP, the plug that lets an agent use an outside tool, and this task uses two of them: one for your Odoo system, one for the hosting. They are the two §5 describes, set up in the order below.</p>
</div>
<ol class="oc-steps">
<li>
<strong>Check what you need, three things.</strong> Hermes or Claude Code already installed and answering you. An Odoo Community system with us on a plan from On the Rise upward (module installation is not part of Side Hustle, so on that plan this task stops here). And the module you want, or just a plain description of what you want it to do. You do not need its technical name; the agent can find the module from what you describe.
</li>
<li>
<strong>Open the management console and find the two MCP settings.</strong> The console is the app we preinstall on your service, and it is where our side of the setup lives. It shows two MCP settings. One points at your Odoo system (that is Much MCP, the free one we bundle). The other points at operations (that is our own MCP). Keep them apart: the first works inside your Odoo, the second runs the server around it.
</li>
<li>
<strong>Connect the Odoo setting to your agent.</strong> Take the Odoo MCP connection details from the console and add them where your agent keeps its MCP servers. This is the connection that gives your agent its Odoo skills, which is what lets it read and write records. You add a server to your agent once, and both Hermes and Claude Code have a place to list one.
<!-- OPEN-SLOT: the exact Odoo MCP connection details from the console, the server address and the key. -->
</li>
<li>
<strong>Connect the operations setting, the same way.</strong> Add the operations MCP from the console beside the first. This is the connection that lets the agent run the hosting, and installing a module is one of those operations.
<!-- OPEN-SLOT: the exact operations MCP connection details from the console, the server address and the key. -->
</li>
<li>
<strong>Confirm both connections landed.</strong> Ask the agent, in plain words, what it can do in Odoo. It should answer with two kinds of work: inside the system (records, invoices, customers, products) and on the server (restart a service, install a module, domains, backups, logfiles). If one of the two is missing, that setting did not take, and you add it again from step 3 or step 4.
</li>
<li>
<strong>Ask for the install in plain words.</strong> Now say something like: install the module that adds what you did not have. Installing a module is a hosting operation, so it runs through the operations MCP, not the Odoo one. If you want a way back before any change, ask the agent to take a backup first, because managing backups is one of the operations.
</li>
<li>
<strong>Read what the agent reports, then check it in Odoo.</strong> The agent tells you the module is installed, or tells you plainly what stopped it. Then open your Odoo, log in, and open the app menu. The module is listed as installed and its new menu or feature is there. That is the whole task: you asked, the agent did the steps, and you checked the result.
</li>
</ol>
</div>
</section>
<!-- Why your own agent beats a provider's closed AI -->
<section class="section-light">
<div class="container">
<h2 class="section-title">Why your own agent beats a provider's closed AI</h2>
<div class="oc-body">
<p>Some hosts give you their own AI, and it only works inside their product. The tasks you teach it, and the way you set it up, stay with them — not with you. Leave, and the AI stays behind.</p>
<p>We do the opposite. We bring the hosting, the Odoo system and the MCP servers that connect them, and you bring your own agent.</p>
<p>That means your keys are yours. Your agent is yours. The rules you teach it, the tasks you build, the way it works with your business: none of that is locked inside our product. If you leave us tomorrow, you take your agent and everything you built with it. And because it can also run the hosting tasks, there is very little you cannot do yourself. That is the whole point.</p>
<p>We would rather sell you the thing that runs, and let the agent be yours.</p>
</div>
</div>
</section>
<!-- FAQ -->
<section class="section-dark" id="faq">
<div class="container">
<h2 class="section-title">Questions owners ask</h2>
<div class="cmp-faq-grid">
<details class="cmp-faq">
<summary class="plausible-event-name=FAQ">Do I need to be technical?</summary>
<div class="cmp-a">
<p>No. You need to already use an AI agent, and that is the only precondition. Two of our own clients run theirs with no IT background at all. If you can ask for something in plain words, you can use this.</p>
</div>
</details>
<details class="cmp-faq">
<summary class="plausible-event-name=FAQ">What do I need to already have?</summary>
<div class="cmp-a">
<p>An AI agent you already run (Hermes or Claude Code), and an Odoo Community system with us — or the plans to start one. There is nothing to install by hand: we set up the two MCP connections for the agent, and the free Much MCP gives it the Odoo skills.</p>
</div>
</details>
<details class="cmp-faq">
<summary class="plausible-event-name=FAQ">How much of my time does this take?</summary>
<div class="cmp-a">
<p>Once the two connections are set up, the next thing you do is ask the agent for something. You point your agent at the two MCP servers once, or send us a short message and we set them up for you. From then on the agent works inside your Odoo and you read and approve the result. There is nothing to build or train on your side.</p>
</div>
</details>
<details class="cmp-faq">
<summary class="plausible-event-name=FAQ">Is my data locked in?</summary>
<div class="cmp-a">
<p>No. Your keys stay yours, your agent stays yours, and your Odoo database stays yours. We run the hosting; we do not hold your agent or the work you have built with it.</p>
</div>
</details>
<details class="cmp-faq">
<summary class="plausible-event-name=FAQ">Which agent does this work with?</summary>
<div class="cmp-a">
<p>Hermes or Claude Code, today. Those are the two we support now.</p>
</div>
</details>
<details class="cmp-faq">
<summary class="plausible-event-name=FAQ">What can the agent actually do, and what can’t it?</summary>
<div class="cmp-a">
<p>Inside Odoo, the agent can read and write your records through the Odoo MCP (an invoice, a customer, a product line), because the free Much MCP gives it those Odoo skills. Around Odoo, it can run the hosting through our MCP: start, stop and restart services, install a module, add or check a domain, manage backups, and read the logfiles. What our MCP does not do is move your data. It does not migrate or import records; it runs the server. Moving data is a different job with its own route.</p>
</div>
</details>
<details class="cmp-faq">
<summary class="plausible-event-name=FAQ">Do I need Enterprise to get AI in Odoo?</summary>
<div class="cmp-a">
<p>No. Odoo’s own built-in AI is an Enterprise feature. On Community you bring your own agent instead, which is the point of this page, and the free community modules and MCP servers cover much of the ground Enterprise used to hold back.</p>
</div>
</details>
<details class="cmp-faq">
<summary class="plausible-event-name=FAQ">What does it cost?</summary>
<div class="cmp-a">
<p>Much MCP is free and comes bundled with our service, so it is not an extra you buy. You pay for the hosting, on the same four plans as always, and Odoo Community carries no per-user licence, so the agent’s own account costs nothing extra.</p>
</div>
</details>
</div>
</div>
</section>
<!-- Start -->
<section class="section-light cmp-cta" id="start">
<div class="container">
<h2 class="section-title">Start</h2>
<div class="cmp-lead">
<p>If you already run an agent and your business runs on Odoo Community with us, you are one connection away from handing it work. Send us a message and we will point you at the two MCP connections, or tell you plainly what fits your situation.</p>
<p>If you are not hosted with us yet, the plans are here, and you can bring your own agent from the first day.</p>
</div>
<div class="cmp-cta-actions">
<a href="/support.html#contact" class="btn btn-brass btn-lg">Ask us a question</a>
<a href="/index.html#pricing" class="btn btn-outline btn-lg">See the plans</a>
</div>
<p class="oca-recap">On Odoo Community you can run your business with your own AI agent. The agent works inside Odoo through an MCP server, and your host runs the server. ODOO4projects bundles the free Much MCP, which gives the agent Odoo skills, and ships its own hosting MCP for operations: starting and stopping services, uploading modules, domains, backups and logfiles. Your keys and your data stay yours.</p>
</div>
</section>
<!-- For your developer (collapsed, after the offer) -->
<section class="section-dark">
<div class="container">
<details class="oca-dev">
<summary>For your developer: which MCP does what</summary>
<div class="oca-dev-body">
<p>Two MCP servers connect to the agent.</p>
<ol>
<li>The Odoo MCP, "Much MCP" (free; bundled with the hosted service). Points the agent at Odoo and gives it the Odoo skills to read and write records inside the system. This is the MCP that touches business data.</li>
<li>The hosting MCP, ours, shipped inside the ODOO4projects management console (the app preinstalled on the service). Its published operations: restart the server, upload/install modules, add or check domains, manage backups, read and download logfiles. It does running the server and nothing else. It does not move, import or migrate data.</li>
</ol>
<p>Both connect the same way: you add each MCP server to your agent (Hermes or Claude Code) and the agent can then use the tools that server exposes.</p>
</div>
</details>
</div>
</section>
<!-- Footer (copied from odoo-staging-environment/; no link to this page is added anywhere) -->
</main>
<footer class="footer">
<div class="container">
<div class="fgrid">
<div>
<h4>Useful Links</h4>
<ul>
<li><a href="/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li>
<li><a href="/odoo-hosting-2027.html">ODOO Hosting in 2027</a></li>
<li><a href="/odoo-community-developers/">Odoo Freelancer Directory</a></li>
<li><a href="/support.html">Support</a></li>
<li><a href="/legal.html">Legal</a></li>
</ul>
</div>
<div>
<h4>Partners</h4>
<div class="partner-list">
<a href="https://oc-guy.com" target="_blank" rel="noopener" class="partner-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9a227" stroke-width="2" aria-hidden="true"><path d="M20 7h-9M14 17H5M20 7l-4-4M20 7l-4 4M4 17l4 4M4 17l4-4" /></svg> OC Guy</a>
<a href="https://oca-py.org" target="_blank" rel="noopener" class="partner-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9a227" stroke-width="2" aria-hidden="true"><path d="M20 7h-9M14 17H5M20 7l-4-4M20 7l-4 4M4 17l4 4M4 17l4-4" /></svg> OCA Paraguay</a>
<a href="https://OD8N.com" target="_blank" rel="noopener" class="partner-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9a227" stroke-width="2" aria-hidden="true"><path d="M20 7h-9M14 17H5M20 7l-4-4M20 7l-4 4M4 17l4 4M4 17l4-4" /></svg> OD8N</a>
</div>
</div>
<div class="fcol-desc">
<h4>About us</h4>
<p>We build smart, scalable business solutions by combining the <strong>stability</strong> of Odoo ERP with the <strong>power</strong> of AI Agents and N8N automations.</p>
<p>From streamlined sales to global rollouts, our <strong>lean, eco-friendly</strong> approach helps businesses cut costs, boost efficiency, and grow without limits.</p>
</div>
<div>
<h4>Follow us on</h4>
<div class="social">
<a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a>
<a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a>
</div>
<form class="footer-contact-form" id="footerContactForm" action="mailto:support@odoo4projects.com" method="post" enctype="text/plain">
<label class="oca-sr-only" for="footerContactEmail">Your email address</label>
<input type="email" id="footerContactEmail" name="email" placeholder="Your email address" required />
<div class="footer-textarea-wrap">
<label class="oca-sr-only" for="footerContactText">Your message</label>
<textarea id="footerContactText" name="message" placeholder="Your message..." required></textarea>
<button type="submit" class="footer-send-btn" aria-label="Send message"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M2 21l21-9L2 3v7l15 2-15 2z" /></svg></button>
</div>
<div class="footer-form-status" id="footerFormStatus" role="status"></div>
</form>
</div>
</div>
<div class="fcopy">Copyright &copy; ODOO4projects LLC</div>
</div>
</footer>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+6 -4
View File
@@ -38,8 +38,7 @@
onload="this.media='all'" onload="this.media='all'"
/> />
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript> <noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="stylesheet" href="style.css" media="print" onload="this.media='all'" /> <link rel="stylesheet" href="style.css?v=061517baef" />
<noscript><link rel="stylesheet" href="style.css" /></noscript>
<style> <style>
/* ---- Odoo Community Upgrade page (page-scoped; additive) ---- */ /* ---- Odoo Community Upgrade page (page-scoped; additive) ---- */
.oc-sr-only { .oc-sr-only {
@@ -480,7 +479,7 @@
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}}; window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init() plausible.init()
</script> </script>
<script defer src="/static/lang.js"></script> <script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head> </head>
<body> <body>
<!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. --> <!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. -->
@@ -496,6 +495,7 @@
</div> </div>
<!-- Hero --> <!-- Hero -->
<main>
<section class="hero oc-hero"> <section class="hero oc-hero">
<div class="hero-bg"> <div class="hero-bg">
<img <img
@@ -987,6 +987,7 @@ sudo -u postgres pg_restore -d &lt;DB_NEW&gt; /path/backup/&lt;DB&gt;.dump
<!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page <!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page
carries no external icon dependency) --> carries no external icon dependency) -->
</main>
<footer class="footer"> <footer class="footer">
<div class="container"> <div class="container">
<div class="fgrid"> <div class="fgrid">
@@ -995,6 +996,7 @@ sudo -u postgres pg_restore -d &lt;DB_NEW&gt; /path/backup/&lt;DB&gt;.dump
<ul> <ul>
<li><a href="/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li> <li><a href="/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li>
<li><a href="odoo-hosting-2027.html">ODOO Hosting in 2027</a></li> <li><a href="odoo-hosting-2027.html">ODOO Hosting in 2027</a></li>
<li><a href="/odoo-community-developers/">Odoo Freelancer Directory</a></li>
<li><a href="support.html">Support</a></li> <li><a href="support.html">Support</a></li>
<li><a href="legal.html">Legal</a></li> <li><a href="legal.html">Legal</a></li>
</ul> </ul>
@@ -1018,7 +1020,7 @@ sudo -u postgres pg_restore -d &lt;DB_NEW&gt; /path/backup/&lt;DB&gt;.dump
<a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a> <a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a>
<a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a> <a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a>
</div> </div>
<form class="footer-contact-form" id="footerContactForm" action="mailto:hello@odoo4projects.com" method="post" enctype="text/plain"> <form class="footer-contact-form" id="footerContactForm" action="mailto:support@odoo4projects.com" method="post" enctype="text/plain">
<label class="oc-sr-only" for="footerContactEmail">Your email address</label> <label class="oc-sr-only" for="footerContactEmail">Your email address</label>
<input type="email" id="footerContactEmail" name="email" placeholder="Your email address" required /> <input type="email" id="footerContactEmail" name="email" placeholder="Your email address" required />
<div class="footer-textarea-wrap"> <div class="footer-textarea-wrap">
+163 -19
View File
@@ -31,8 +31,9 @@
onload="this.media='all'" onload="this.media='all'"
/> />
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript> <noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="stylesheet" href="/style.css" media="print" onload="this.media='all'" /> <link rel="preload" as="image" href="/images/hero/bootcamp-hero-mobile-900.webp" media="(max-width: 640px)" fetchpriority="high" />
<noscript><link rel="stylesheet" href="/style.css" /></noscript> <link rel="preload" as="image" href="/images/hero/bootcamp-hero-desktop-1920.webp" media="(min-width: 641px)" fetchpriority="high" />
<link rel="stylesheet" href="/style.css?v=061517baef" />
<style> <style>
/* ---- ODOO ERP Automation Bootcamp landing page (page-scoped; additive) ---- */ /* ---- ODOO ERP Automation Bootcamp landing page (page-scoped; additive) ---- */
.bc-sr-only { .bc-sr-only {
@@ -192,6 +193,14 @@
form[action$="/shop/post_basket"] .btn { form[action$="/shop/post_basket"] .btn {
line-height: inherit; line-height: inherit;
} }
/* At 0 seats /static/bootcamp-availability.js disables these Buy buttons; make the
disabled state read as disabled — quiet dimming in the page's own CI, never an
alert banner. */
form[action$="/shop/post_basket"] .btn:disabled,
form[action$="/shop/post_basket"] .btn[aria-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
}
/* Grids — auto-fit so no breakpoint is needed */ /* Grids — auto-fit so no breakpoint is needed */
.bc-list { .bc-list {
@@ -223,7 +232,7 @@
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
box-shadow: var(--shadow-rest); box-shadow: var(--shadow-rest);
padding: 1.4rem 1.4rem 1.4rem 3.4rem; padding: 1.4rem 1.4rem 1.4rem 3.4rem;
transition: box-shadow 0.25s, transform 0.25s; transition: transform 0.25s; /* box-shadow kept, not transitioned (non-composited) */
} }
.bc-lessons li:hover { .bc-lessons li:hover {
box-shadow: var(--shadow-hover); box-shadow: var(--shadow-hover);
@@ -313,7 +322,7 @@
.bc-schedule dt { .bc-schedule dt {
font-family: Marcellus, Georgia, serif; font-family: Marcellus, Georgia, serif;
font-size: 0.92rem; font-size: 0.92rem;
color: var(--brass-deep); color: #876c1e; /* 4.68:1 on --ivory (was --brass-deep 4.49:1) */
} }
.bc-schedule dd { .bc-schedule dd {
margin: 0; margin: 0;
@@ -452,6 +461,109 @@
.btn-brass:focus-visible { .btn-brass:focus-visible {
outline-color: #fff; outline-color: #fff;
} }
/* Waiting list — for someone who cannot attend the January cohort.
Deliberately quieter than the Buy button: outline button, smaller type, no brass fill. */
.bc-waitlist {
/* Component-scoped error colour: the site has no shared token for it. */
--wl-error: #b02a37;
max-width: 720px;
margin: 2.5rem auto 0;
padding: 1.5rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--ivory);
text-align: left;
}
.bc-wl-title {
margin: 0 0 0.5rem;
font-size: 1.1rem;
color: var(--ink);
}
.bc-wl-lead {
margin: 0 0 1.1rem;
font-size: 0.88rem;
line-height: 1.6;
color: rgba(23, 28, 38, 0.78); /* the page's own .bc-note value, on the same cream */
}
.bc-wl-form input,
.bc-wl-form textarea {
display: block;
width: 100%;
box-sizing: border-box;
margin-bottom: 0.6rem;
padding: 0.6rem 0.75rem;
font-family: Inter, sans-serif;
font-size: 0.9rem;
color: var(--ink);
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
}
.bc-wl-form textarea {
min-height: 3.4rem;
resize: vertical;
}
.bc-wl-btn {
padding: 0.6rem 1.5rem;
font-size: 0.8rem;
background: transparent;
color: var(--ink);
border-color: rgba(23, 28, 38, 0.35);
}
.bc-wl-btn:hover {
border-color: var(--brass);
background: rgba(201, 162, 39, 0.12);
transform: none;
box-shadow: none;
}
.bc-wl-btn:disabled {
opacity: 0.6;
cursor: default;
}
.bc-wl-success {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--success);
}
.bc-wl-error {
margin: 0;
font-size: 0.9rem;
color: var(--wl-error);
}
/* --- 2026-11-02 PageSpeed pass (page-scoped, additive) ---------------------
1. The footer <h4>s became <h3>s so the heading order descends after the last
section h2 (a11y "heading-order"); these rules keep their look identical to
the shared .footer h4 in /style.css.
2. A link inside an on-dark note is the lighter brass (7.15:1 on midnight,
A11y "color-contrast") and underlined, so it is distinguishable without
colour (A11y "link-in-text-block"). */
.footer h3 {
font-family: Marcellus, Georgia, serif;
color: #fff;
margin-bottom: 0.8rem;
font-size: 1rem;
letter-spacing: 0.03em;
}
.bc-note.on-dark a {
color: var(--brass);
text-decoration: underline;
}
/* the footer copyright line at .35 alpha was 3.2:1 on midnight */
.footer .fcopy {
color: rgba(255, 255, 255, 0.5); /* 5.17:1 */
}
/* no-JS footer fallback (rendered only when JavaScript is off) */
.footer .bc-noscript {
margin-top: 0.6rem;
font-size: 0.85rem;
}
.footer .bc-noscript a {
color: var(--brass);
text-decoration: underline;
}
</style> </style>
<script type="application/ld+json"> <script type="application/ld+json">
{ {
@@ -501,7 +613,7 @@
}, },
"offers": { "offers": {
"@type": "Offer", "@type": "Offer",
"price": "465.00", "price": "595.00",
"priceCurrency": "USD", "priceCurrency": "USD",
"category": "Paid", "category": "Paid",
"url": "https://odoo4projects.com/odoo-erp-automation-bootcamp/" "url": "https://odoo4projects.com/odoo-erp-automation-bootcamp/"
@@ -576,6 +688,7 @@
</div> </div>
<!-- Hero --> <!-- Hero -->
<main>
<section class="hero bc-hero"> <section class="hero bc-hero">
<div class="container"> <div class="container">
<p class="hero-kicker">Q1 2027 &middot; 10 LIVE SESSIONS &middot; OLIVER ARNOLD</p> <p class="hero-kicker">Q1 2027 &middot; 10 LIVE SESSIONS &middot; OLIVER ARNOLD</p>
@@ -592,14 +705,21 @@
</div> </div>
<div class="hero-actions"> <div class="hero-actions">
<form method="post" action="https://20.odoo4projects.com/shop/post_basket" target="_blank" class="plausible-event-name=Buy"> <form method="post" action="https://20.odoo4projects.com/shop/post_basket" target="_blank" class="plausible-event-name=Buy">
<input type="hidden" name="basket" value='[{"product_id":12,"qty":1}]' /> <input type="hidden" name="basket" value='[{"product_id":17,"qty":1,"info":"Q1 2027, English"}]' />
<input type="hidden" name="clear" value="1" /> <input type="hidden" name="clear" value="1" />
<button type="submit" class="btn btn-brass btn-lg">Buy for 465.00 USD</button> <button type="submit" class="btn btn-brass btn-lg">Buy for 595.00 USD</button>
</form> </form>
</div> </div>
</div> </div>
</section> </section>
<!-- Live remaining-seat count, immediately below the hero. One seat element only on
the page; /static/bootcamp-availability.js fills it from the availability webhook
and leaves it empty+hidden if that fetch fails. -->
<div class="container">
<p class="bc-note bc-seats" data-bc-seats hidden></p>
</div>
<!-- From zero to a running ERP --> <!-- From zero to a running ERP -->
<section class="section-light"> <section class="section-light">
<div class="container"> <div class="container">
@@ -615,9 +735,9 @@
<p class="bc-subline">Three months. Ten hours of teaching. One system that runs your company.</p> <p class="bc-subline">Three months. Ten hours of teaching. One system that runs your company.</p>
<div class="bc-action"> <div class="bc-action">
<form method="post" action="https://20.odoo4projects.com/shop/post_basket" target="_blank" class="plausible-event-name=Buy"> <form method="post" action="https://20.odoo4projects.com/shop/post_basket" target="_blank" class="plausible-event-name=Buy">
<input type="hidden" name="basket" value='[{"product_id":12,"qty":1}]' /> <input type="hidden" name="basket" value='[{"product_id":17,"qty":1,"info":"Q1 2027, English"}]' />
<input type="hidden" name="clear" value="1" /> <input type="hidden" name="clear" value="1" />
<button type="submit" class="btn btn-brass btn-lg">Buy for 465.00 USD</button> <button type="submit" class="btn btn-brass btn-lg">Buy for 595.00 USD</button>
</form> </form>
</div> </div>
</div> </div>
@@ -786,19 +906,38 @@
<section class="section-light"> <section class="section-light">
<div class="container"> <div class="container">
<h2 class="section-title">Price and booking</h2> <h2 class="section-title">Price and booking</h2>
<p class="bc-price">The programme costs USD 465.00 for all ten sessions.</p> <p class="bc-price">The programme costs USD 595.00 for all ten sessions.</p>
<p class="bc-note"><strong>Limited to 6 participants</strong> &mdash; the cohort is capped, so the instructor can work through your own setup.</p> <p class="bc-note"><strong>Limited to 6 participants</strong> &mdash; the cohort is capped, so the instructor can work through your own setup.</p>
<p class="bc-note">A seat can be <strong>transferred at any time</strong>, and <strong>cancelled up to two weeks before</strong> the first session on 21 January 2027.</p>
<p class="bc-lead"> <p class="bc-lead">
Covers the full programme: ten live sessions, one year of hosted Odoo Community Edition, the Covers the full programme: ten live sessions, one year of hosted Odoo Community Edition, the
alumni community and the expert review. alumni community and the expert review.
</p> </p>
<div class="bc-action"> <div class="bc-action">
<form method="post" action="https://20.odoo4projects.com/shop/post_basket" target="_blank" class="plausible-event-name=Buy"> <form method="post" action="https://20.odoo4projects.com/shop/post_basket" target="_blank" class="plausible-event-name=Buy">
<input type="hidden" name="basket" value='[{"product_id":12,"qty":1}]' /> <input type="hidden" name="basket" value='[{"product_id":17,"qty":1,"info":"Q1 2027, English"}]' />
<input type="hidden" name="clear" value="1" /> <input type="hidden" name="clear" value="1" />
<button type="submit" class="btn btn-brass btn-lg">Buy for 465.00 USD</button> <button type="submit" class="btn btn-brass btn-lg">Buy for 595.00 USD</button>
</form> </form>
</div> </div>
<!-- Waiting list: for a visitor who cannot attend the January cohort.
Secondary to the Buy button above. Same CRM webhook and same payload shape as the
site's footer contact form (area:'message'); the copy lives here, in the page's
language, and /static/bootcamp-waitlist.js carries no strings. -->
<div class="bc-waitlist">
<h3 class="bc-wl-title">Another timezone or language?</h3>
<p class="bc-wl-lead">The January cohort runs Thursdays 18:00&ndash;19:00 Central Time, in English. If you need a different time or another language, join the waiting list and we will write to you when a cohort that fits opens.</p>
<form id="bcWaitlistForm" class="bc-wl-form" action="https://n8n.odoo4projects.com/webhook/ODOO4projects/crm" method="post" enctype="text/plain">
<label class="bc-sr-only" for="bcWaitlistEmail">Your email address</label>
<input type="email" id="bcWaitlistEmail" name="email" placeholder="Your email address" required />
<label class="bc-sr-only" for="bcWaitlistComment">Which timezone or language you need</label>
<textarea id="bcWaitlistComment" name="comment" rows="2" placeholder="Which timezone or language do you need?" required></textarea>
<button type="submit" class="btn bc-wl-btn" data-sending="Adding you&hellip;">Join the waiting list</button>
</form>
<p class="bc-wl-success" id="bcWaitlistSuccess" role="status" style="display:none">You are on the waiting list. We will write to you as soon as a cohort in your timezone or language is announced.</p>
<p class="bc-wl-error" id="bcWaitlistError" role="alert" style="display:none">Something went wrong. Please try again.</p>
</div>
<ul class="bc-related"> <ul class="bc-related">
<li><a href="/support.html#contact">Questions before you book? Talk to us first</a></li> <li><a href="/support.html#contact">Questions before you book? Talk to us first</a></li>
<li><a href="/workshops/">All our live courses, side by side</a></li> <li><a href="/workshops/">All our live courses, side by side</a></li>
@@ -848,6 +987,7 @@
</div> </div>
</div> </div>
</section> </section>
</main>
<!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page <!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page
carries no external icon dependency) --> carries no external icon dependency) -->
@@ -855,16 +995,17 @@
<div class="container"> <div class="container">
<div class="fgrid"> <div class="fgrid">
<div> <div>
<h4>Useful Links</h4> <h3>Useful Links</h3>
<ul> <ul>
<li><a href="/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li> <li><a href="/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li>
<li><a href="/odoo-hosting-2027.html">ODOO Hosting in 2027</a></li> <li><a href="/odoo-hosting-2027.html">ODOO Hosting in 2027</a></li>
<li><a href="/odoo-community-developers/">Odoo Freelancer Directory</a></li>
<li><a href="/support.html">Support</a></li> <li><a href="/support.html">Support</a></li>
<li><a href="/legal.html">Legal</a></li> <li><a href="/legal.html">Legal</a></li>
</ul> </ul>
</div> </div>
<div> <div>
<h4>Partners</h4> <h3>Partners</h3>
<div class="partner-list"> <div class="partner-list">
<a href="https://oc-guy.com" target="_blank" rel="noopener" class="partner-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9a227" stroke-width="2" aria-hidden="true"><path d="M20 7h-9M14 17H5M20 7l-4-4M20 7l-4 4M4 17l4 4M4 17l4-4" /></svg> OC Guy</a> <a href="https://oc-guy.com" target="_blank" rel="noopener" class="partner-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9a227" stroke-width="2" aria-hidden="true"><path d="M20 7h-9M14 17H5M20 7l-4-4M20 7l-4 4M4 17l4 4M4 17l4-4" /></svg> OC Guy</a>
<a href="https://oca-py.org" target="_blank" rel="noopener" class="partner-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9a227" stroke-width="2" aria-hidden="true"><path d="M20 7h-9M14 17H5M20 7l-4-4M20 7l-4 4M4 17l4 4M4 17l4-4" /></svg> OCA Paraguay</a> <a href="https://oca-py.org" target="_blank" rel="noopener" class="partner-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9a227" stroke-width="2" aria-hidden="true"><path d="M20 7h-9M14 17H5M20 7l-4-4M20 7l-4 4M4 17l4 4M4 17l4-4" /></svg> OCA Paraguay</a>
@@ -872,17 +1013,17 @@
</div> </div>
</div> </div>
<div class="fcol-desc"> <div class="fcol-desc">
<h4>About us</h4> <h3>About us</h3>
<p>We build smart, scalable business solutions by combining the <strong>stability</strong> of Odoo ERP with the <strong>power</strong> of AI Agents and N8N automations.</p> <p>We build smart, scalable business solutions by combining the <strong>stability</strong> of Odoo ERP with the <strong>power</strong> of AI Agents and N8N automations.</p>
<p>From streamlined sales to global rollouts, our <strong>lean, eco-friendly</strong> approach helps businesses cut costs, boost efficiency, and grow without limits.</p> <p>From streamlined sales to global rollouts, our <strong>lean, eco-friendly</strong> approach helps businesses cut costs, boost efficiency, and grow without limits.</p>
</div> </div>
<div> <div>
<h4>Follow us on</h4> <h3>Follow us on</h3>
<div class="social"> <div class="social">
<a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a> <a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a>
<a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a> <a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a>
</div> </div>
<form class="footer-contact-form" id="footerContactForm" action="mailto:hello@odoo4projects.com" method="post" enctype="text/plain"> <form class="footer-contact-form" id="footerContactForm" action="https://n8n.odoo4projects.com/webhook/ODOO4projects/crm" method="post" enctype="text/plain" data-ok="Thanks! We'll get back to you shortly." data-err="Something went wrong. Please try again.">
<label class="bc-sr-only" for="footerContactEmail">Your email address</label> <label class="bc-sr-only" for="footerContactEmail">Your email address</label>
<input type="email" id="footerContactEmail" name="email" placeholder="Your email address" required /> <input type="email" id="footerContactEmail" name="email" placeholder="Your email address" required />
<div class="footer-textarea-wrap"> <div class="footer-textarea-wrap">
@@ -892,12 +1033,15 @@
</div> </div>
<div class="footer-form-status" id="footerFormStatus" role="status"></div> <div class="footer-form-status" id="footerFormStatus" role="status"></div>
</form> </form>
<noscript><p class="bc-noscript">JavaScript is off. Email us directly at <a href="mailto:support@odoo4projects.com">support@odoo4projects.com</a>.</p></noscript>
</div> </div>
</div> </div>
<div class="fcopy">Copyright &copy; ODOO4projects LLC</div> <div class="fcopy">Copyright &copy; ODOO4projects LLC</div>
</div> </div>
</footer> </footer>
<script defer src="/static/buy.js"></script> <script defer src="/static/buy.js?v=31314de600"></script>
<script defer src="/static/lang.js"></script> <script defer src="/static/bootcamp-availability.js?v=94dfd8cd7c"></script>
<script defer src="/static/bootcamp-waitlist.js?v=6c2e2fbd33"></script>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</body> </body>
</html> </html>
+22 -10
View File
@@ -31,8 +31,7 @@
onload="this.media='all'" onload="this.media='all'"
/> />
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript> <noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="stylesheet" href="style.css" media="print" onload="this.media='all'" /> <link rel="stylesheet" href="style.css?v=061517baef" />
<noscript><link rel="stylesheet" href="style.css" /></noscript>
<style> <style>
/* ---- Odoo Hosting 2027 landing page (page-scoped; additive) ---- */ /* ---- Odoo Hosting 2027 landing page (page-scoped; additive) ---- */
.cmp-sr-only { .cmp-sr-only {
@@ -479,7 +478,7 @@
{ {
"@type": "Product", "@type": "Product",
"name": "Managed Odoo Community hosting", "name": "Managed Odoo Community hosting",
"description": "Managed Odoo Community hosting: the server, backups, SSL certificate and version upgrades are run for you on one yearly price, with no per-user licence on Odoo Community.", "description": "Managed Odoo Community hosting: the server, backups and SSL certificate are run for you and your Odoo is kept current within its version, on one yearly price with no per-user licence on Odoo Community. Moving to a new major version is a migration and is quoted separately.",
"brand": { "@id": "https://odoo4projects.com/#organization" }, "brand": { "@id": "https://odoo4projects.com/#organization" },
"offers": [ "offers": [
{ {
@@ -560,7 +559,7 @@
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}}; window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init() plausible.init()
</script> </script>
<script defer src="/static/lang.js"></script> <script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head> </head>
<body> <body>
<!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. --> <!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. -->
@@ -576,14 +575,15 @@
</div> </div>
<!-- Hero --> <!-- Hero -->
<main>
<section class="hero cmp-hero"> <section class="hero cmp-hero">
<div class="container"> <div class="container">
<p class="hero-kicker">Managed Odoo Community Hosting</p> <p class="hero-kicker">Managed Odoo Community Hosting</p>
<h1>Odoo Hosting 2027: Managed Odoo Community, <span>Live in About Five Minutes</span></h1> <h1>Odoo Hosting 2027: Managed Odoo Community, <span>Live in About Five Minutes</span></h1>
<p class="hero-sub"> <p class="hero-sub">
A managed Odoo Community instance: your stock, orders, invoices and customers in one system. We A managed Odoo Community instance: your stock, orders, invoices and customers in one system. We
run the server, the backups, the certificate and the upgrades. You put your own data in and get on run the server, the backups, the certificate and the updates within your Odoo version. You put
with the business. your own data in and get on with the business.
</p> </p>
<div class="hero-stats"> <div class="hero-stats">
<span class="hero-stat"><i aria-hidden="true">&#10003;</i> About 5 minutes to a live instance</span> <span class="hero-stat"><i aria-hidden="true">&#10003;</i> About 5 minutes to a live instance</span>
@@ -606,8 +606,9 @@
<p class="cmp-lead"> <p class="cmp-lead">
You should not have to become the IT department to find out what is in your stock. A business You should not have to become the IT department to find out what is in your stock. A business
system is something you subscribe to, not a project you build. The server, the patches, the system is something you subscribe to, not a project you build. The server, the patches, the
backups, the certificate, the version upgrade: those are our job, permanently, and not an evening backups, the certificate, the updates within your Odoo version: those are our job, permanently,
you hand back to the company. and not an evening you hand back to the company. Moving the database to a new major version is a
migration we quote for separately.
</p> </p>
<p class="cmp-lead"> <p class="cmp-lead">
So we put your stock, your orders, your invoices and who owes you money in one place, and we keep So we put your stock, your orders, your invoices and who owes you money in one place, and we keep
@@ -704,7 +705,7 @@
</tr> </tr>
<tr> <tr>
<th scope="row">Version upgrades</th> <th scope="row">Version upgrades</th>
<td class="is-us">A support ticket. We migrate versions 17, 18 and 19.</td> <td class="is-us">Updates and in-version upgrades are included. A move to a new major version is a migration, quoted separately.</td>
<td>You do it, after taking a full backup.</td> <td>You do it, after taking a full backup.</td>
<td>Push to the branch; if a module update fails, Odoo reverts the server and rolls the database back.</td> <td>Push to the branch; if a module update fails, Odoo reverts the server and rolls the database back.</td>
<td>Automatic and mandatory on Odoo's schedule.</td> <td>Automatic and mandatory on Odoo's schedule.</td>
@@ -754,6 +755,15 @@
panel. It is optional. The business works exactly the same without it. panel. It is optional. The business works exactly the same without it.
</p> </p>
</div> </div>
<div class="cmp-note">
<h3>Do you need staging?</h3>
<p>
A staging environment is a second copy of your system you are allowed to break. It is the right
tool when you change Odoo's code, and overkill when you install a finished module on a backup.
We wrote out <a href="/odoo-staging-environment/">do you need a staging environment?</a>, and the
simpler route we recommend first.
</p>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -976,6 +986,7 @@
<!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page <!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page
carries no external icon dependency) --> carries no external icon dependency) -->
</main>
<footer class="footer"> <footer class="footer">
<div class="container"> <div class="container">
<div class="fgrid"> <div class="fgrid">
@@ -984,6 +995,7 @@
<ul> <ul>
<li><a href="/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li> <li><a href="/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li>
<li><a href="odoo-hosting-2027.html">ODOO Hosting in 2027</a></li> <li><a href="odoo-hosting-2027.html">ODOO Hosting in 2027</a></li>
<li><a href="/odoo-community-developers/">Odoo Freelancer Directory</a></li>
<li><a href="support.html">Support</a></li> <li><a href="support.html">Support</a></li>
<li><a href="legal.html">Legal</a></li> <li><a href="legal.html">Legal</a></li>
</ul> </ul>
@@ -1007,7 +1019,7 @@
<a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a> <a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a>
<a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a> <a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a>
</div> </div>
<form class="footer-contact-form" id="footerContactForm" action="mailto:hello@odoo4projects.com" method="post" enctype="text/plain"> <form class="footer-contact-form" id="footerContactForm" action="mailto:support@odoo4projects.com" method="post" enctype="text/plain">
<label class="cmp-sr-only" for="footerContactEmail">Your email address</label> <label class="cmp-sr-only" for="footerContactEmail">Your email address</label>
<input type="email" id="footerContactEmail" name="email" placeholder="Your email address" required /> <input type="email" id="footerContactEmail" name="email" placeholder="Your email address" required />
<div class="footer-textarea-wrap"> <div class="footer-textarea-wrap">
+23 -9
View File
@@ -16,7 +16,15 @@
/> />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:url" content="https://odoo4projects.com/odoo-hosting-offers/" /> <meta property="og:url" content="https://odoo4projects.com/odoo-hosting-offers/" />
<meta property="og:image" content="https://odoo4projects.com/images/black-friday/bf2026-hosting-og.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta
property="og:image:alt"
content="Black Friday 2026 poster: 'Black Friday — 2–30 November' and 'Save $100 on Odoo hosting', with a secret deal on the Bootcamp."
/>
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://odoo4projects.com/images/black-friday/bf2026-hosting-og.png" />
<link rel="icon" href="/images/favicon.ico" /> <link rel="icon" href="/images/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@@ -27,8 +35,7 @@
onload="this.media='all'" onload="this.media='all'"
/> />
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript> <noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="stylesheet" href="/style.css" media="print" onload="this.media='all'" /> <link rel="stylesheet" href="/style.css?v=061517baef" />
<noscript><link rel="stylesheet" href="/style.css" /></noscript>
<style> <style>
/* ---- Odoo Hosting Offers (evergreen hub) page (page-scoped; additive) ---- */ /* ---- Odoo Hosting Offers (evergreen hub) page (page-scoped; additive) ---- */
.of-sr-only { .of-sr-only {
@@ -376,7 +383,7 @@
{ {
"@type": "Product", "@type": "Product",
"name": "On the Rise — managed Odoo Community hosting", "name": "On the Rise — managed Odoo Community hosting",
"description": "Managed Odoo Community hosting on the On the Rise plan: the server, backups, SSL certificate and version upgrades are run for you on one yearly price, with no per-user licence and no setup fee.", "description": "Managed Odoo Community hosting on the On the Rise plan: the server, backups and SSL certificate are run for you and your Odoo is kept current within its version, on one yearly price with no per-user licence and no setup fee. Moving to a new major version is a migration and is quoted separately.",
"brand": { "@id": "https://odoo4projects.com/#organization" }, "brand": { "@id": "https://odoo4projects.com/#organization" },
"offers": [ "offers": [
{ {
@@ -442,7 +449,7 @@
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}}; window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init() plausible.init()
</script> </script>
<script defer src="/static/lang.js"></script> <script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head> </head>
<body> <body>
<!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. --> <!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. -->
@@ -458,6 +465,7 @@
</div> </div>
<!-- Hero --> <!-- Hero -->
<main>
<section class="hero of-hero"> <section class="hero of-hero">
<div class="container"> <div class="container">
<h1>Odoo hosting offers, and what a year costs</h1> <h1>Odoo hosting offers, and what a year costs</h1>
@@ -471,11 +479,14 @@
<!-- Date-scoped Black Friday block --> <!-- Date-scoped Black Friday block -->
<section class="section-light"> <section class="section-light">
<div class="container"> <div class="container">
<!-- BF-BLOCK-START: live 16–30 November 2026 only. Remove this block after 30 Nov 2026. --> <!-- BF-BLOCK-START: live 2–30 November 2026 only. Remove this block after 30 Nov 2026. -->
<div class="bf-hub"> <div class="bf-hub">
<h2>Black Friday and Cyber Monday 2026</h2> <h2>Black Friday and Cyber Monday 2026</h2>
<p class="bf-hub-line">This November we take $100 off your first year of hosting. The window is 16–30 November 2026, and it ends on Cyber Monday.</p> <p class="bf-hub-line">This November we take $100 off your first year of hosting. The window is 2–30 November 2026, and it ends on Cyber Monday.</p>
<p class="bf-hub-link"><a href="/odoo-black-friday/">See the Black Friday hosting offer</a></p> <!-- 2026-11-01, dated for removal: the offer and the code are revealed on /odoo-black-friday/ only. Remove this link with the BF block after 30 November 2026. -->
<!-- Buy = CLICK PROXY (offer-CTA click), NOT a sale. The real purchase event is fired on the
checkout host (20.odoo4projects.com) by odoo-dev. See wiki/concepts/conversion-tracking-contract. -->
<p class="bf-hub-link"><a class="plausible-event-name=Buy" href="/odoo-black-friday/">See the Black Friday offer</a></p>
</div> </div>
<!-- BF-BLOCK-END --> <!-- BF-BLOCK-END -->
</div> </div>
@@ -485,7 +496,7 @@
<section class="section-dark"> <section class="section-dark">
<div class="container"> <div class="container">
<h2 class="section-title">What a hosting offer from us actually is</h2> <h2 class="section-title">What a hosting offer from us actually is</h2>
<p class="of-lead">A hosting offer from us is one flat price for a year. That price runs your whole business system, and we run everything under it: the server, the backups, your certificate and the version upgrades.</p> <p class="of-lead">A hosting offer from us is one flat price for a year. That price runs your whole business system, and we run everything under it: the server, the backups, your certificate, and the updates that keep your Odoo current within its version. Moving to a new major version is a migration, and we quote that separately.</p>
<p class="of-lead">What is not on the bill: a charge for each person who logs in, and a fee to set you up. Odoo Community has no per-user licence, so there is nothing per seat to pay, and there is no setup fee.</p> <p class="of-lead">What is not on the bill: a charge for each person who logs in, and a fee to set you up. Odoo Community has no per-user licence, so there is nothing per seat to pay, and there is no setup fee.</p>
<p class="of-lead">Start with a 30-day free trial and no card. If it fits, the year starts when you pay, not when you sign up.</p> <p class="of-lead">Start with a 30-day free trial and no card. If it fits, the year starts when you pay, not when you sign up.</p>
</div> </div>
@@ -557,13 +568,16 @@
<!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page <!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page
carries no external icon dependency) --> carries no external icon dependency) -->
</main>
<footer class="footer"> <footer class="footer">
<div class="container"> <div class="container">
<div class="fgrid"> <div class="fgrid">
<div> <div>
<h4>Useful Links</h4> <h4>Useful Links</h4>
<ul> <ul>
<li><a href="/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li>
<li><a href="/odoo-hosting-2027.html">ODOO Hosting in 2027</a></li> <li><a href="/odoo-hosting-2027.html">ODOO Hosting in 2027</a></li>
<li><a href="/odoo-community-developers/">Odoo Freelancer Directory</a></li>
<li><a href="/support.html">Support</a></li> <li><a href="/support.html">Support</a></li>
<li><a href="/legal.html">Legal</a></li> <li><a href="/legal.html">Legal</a></li>
</ul> </ul>
@@ -587,7 +601,7 @@
<a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a> <a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a>
<a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a> <a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a>
</div> </div>
<form class="footer-contact-form" id="footerContactForm" action="mailto:hello@odoo4projects.com" method="post" enctype="text/plain"> <form class="footer-contact-form" id="footerContactForm" action="mailto:support@odoo4projects.com" method="post" enctype="text/plain">
<label class="of-sr-only" for="footerContactEmail">Your email address</label> <label class="of-sr-only" for="footerContactEmail">Your email address</label>
<input type="email" id="footerContactEmail" name="email" placeholder="Your email address" required /> <input type="email" id="footerContactEmail" name="email" placeholder="Your email address" required />
<div class="footer-textarea-wrap"> <div class="footer-textarea-wrap">
+7 -5
View File
@@ -27,8 +27,7 @@
onload="this.media='all'" onload="this.media='all'"
/> />
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript> <noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="stylesheet" href="/style.css" media="print" onload="this.media='all'" /> <link rel="stylesheet" href="/style.css?v=061517baef" />
<noscript><link rel="stylesheet" href="/style.css" /></noscript>
<style> <style>
/* ---- ODOO x N8N Masterclass landing page (page-scoped; additive). /* ---- ODOO x N8N Masterclass landing page (page-scoped; additive).
The block below is the bootcamp page's page-scoped CSS, reused so the two The block below is the bootcamp page's page-scoped CSS, reused so the two
@@ -558,6 +557,7 @@
</div> </div>
<!-- Hero --> <!-- Hero -->
<main>
<section class="hero bc-hero"> <section class="hero bc-hero">
<div class="container"> <div class="container">
<p class="hero-kicker">2 HOURS &middot; LIVE &middot; OLIVER ARNOLD &middot; ENGLISH</p> <p class="hero-kicker">2 HOURS &middot; LIVE &middot; OLIVER ARNOLD &middot; ENGLISH</p>
@@ -783,6 +783,7 @@
<!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page <!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page
carries no external icon dependency) --> carries no external icon dependency) -->
</main>
<footer class="footer"> <footer class="footer">
<div class="container"> <div class="container">
<div class="fgrid"> <div class="fgrid">
@@ -791,6 +792,7 @@
<ul> <ul>
<li><a href="/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li> <li><a href="/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li>
<li><a href="/odoo-hosting-2027.html">ODOO Hosting in 2027</a></li> <li><a href="/odoo-hosting-2027.html">ODOO Hosting in 2027</a></li>
<li><a href="/odoo-community-developers/">Odoo Freelancer Directory</a></li>
<li><a href="/support.html">Support</a></li> <li><a href="/support.html">Support</a></li>
<li><a href="/legal.html">Legal</a></li> <li><a href="/legal.html">Legal</a></li>
</ul> </ul>
@@ -814,7 +816,7 @@
<a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a> <a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a>
<a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a> <a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a>
</div> </div>
<form class="footer-contact-form" id="footerContactForm" action="mailto:hello@odoo4projects.com" method="post" enctype="text/plain"> <form class="footer-contact-form" id="footerContactForm" action="mailto:support@odoo4projects.com" method="post" enctype="text/plain">
<label class="bc-sr-only" for="footerContactEmail">Your email address</label> <label class="bc-sr-only" for="footerContactEmail">Your email address</label>
<input type="email" id="footerContactEmail" name="email" placeholder="Your email address" required /> <input type="email" id="footerContactEmail" name="email" placeholder="Your email address" required />
<div class="footer-textarea-wrap"> <div class="footer-textarea-wrap">
@@ -829,7 +831,7 @@
<div class="fcopy">Copyright &copy; ODOO4projects LLC</div> <div class="fcopy">Copyright &copy; ODOO4projects LLC</div>
</div> </div>
</footer> </footer>
<script defer src="/static/buy.js"></script> <script defer src="/static/buy.js?v=31314de600"></script>
<script defer src="/static/lang.js"></script> <script defer src="/static/lang.js?v=3ec49a7e62"></script>
</body> </body>
</html> </html>
+827
View File
@@ -0,0 +1,827 @@
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Odoo Staging Environment: Do You Actually Need One?</title>
<meta
name="description"
content="Most small businesses don't need an Odoo staging environment. How staging works, what it costs, and the back-up-install-test route we offer instead."
/>
<link rel="canonical" href="https://odoo4projects.com/odoo-staging-environment/" />
<meta property="og:title" content="Odoo Staging Environment: Do You Actually Need One?" />
<meta
property="og:description"
content="What an Odoo staging environment is, how odoo.sh's staging works, and the simpler route most small businesses should use instead."
/>
<meta property="og:type" content="article" />
<meta property="og:url" content="https://odoo4projects.com/odoo-staging-environment/" />
<meta property="og:image" content="https://odoo4projects.com/images/hero/amsterdam.webp" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://odoo4projects.com/images/hero/amsterdam.webp" />
<link rel="icon" href="/images/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap"
media="print"
onload="this.media='all'"
/>
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="stylesheet" href="/style.css?v=061517baef" />
<style>
/* ---- "Odoo staging environment" guide page (page-scoped; additive).
Reuses the top bar, band rhythm, hero and FAQ treatment already shipped on
what-can-i-use-odoo-for/, plus the final-CTA block from odoo-hosting-2027.html.
Three additions this page needs and no other: .oc-lede (the opening prose in
the hero), the light-band re-pointing of the shared .oc-steps colours, and the
in-prose bullet list inside a dark band. ---- */
.oc-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Top bar (page-scoped: the site has no global header) */
.cmp-topbar {
background: var(--midnight-2);
border-bottom: 1px solid var(--line-brass);
padding: 0.85rem 0;
}
.cmp-topbar .container {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.cmp-brand {
font-family: Marcellus, Georgia, serif;
font-size: 1.15rem;
letter-spacing: 0.04em;
color: #fff;
}
.cmp-brand span {
color: var(--brass);
}
.cmp-topbar-links {
display: flex;
gap: 1.25rem;
font-size: 0.82rem;
margin-left: auto;
}
.cmp-topbar-links a {
color: rgba(255, 255, 255, 0.7);
}
.cmp-topbar-links a:hover {
color: var(--brass);
}
.cmp-topbar .lang-switch-slot {
display: flex;
align-items: center;
padding-left: 1rem;
border-left: 1px solid rgba(201, 162, 39, 0.45);
}
.cmp-topbar .lang-switch {
color: rgba(255, 255, 255, 0.7);
}
.cmp-topbar .lang-switch a:hover,
.cmp-topbar .lang-switch a:focus-visible {
color: var(--brass-bright);
}
.cmp-topbar .lang-switch a[aria-current="true"] {
color: var(--brass);
text-decoration: underline;
text-underline-offset: 3px;
}
/* Hero */
.oc-hero {
min-height: 0;
padding: 3.5rem 0 3rem;
}
.oc-hero .container {
position: relative;
z-index: 1;
}
.oc-hero .hero-sub {
max-width: 760px;
}
.oc-hero-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
opacity: 0.25;
filter: brightness(1.15) saturate(1.1);
}
/* The opening prose in the hero: the reader's question is answered before the first H2. */
.oc-lede {
max-width: 780px;
color: rgba(255, 255, 255, 0.72);
line-height: 1.7;
font-size: 1rem;
}
.oc-lede p {
margin-bottom: 0.9rem;
}
.oc-lede p:last-child {
margin-bottom: 0;
}
.oc-lede a {
color: var(--brass-bright);
}
/* Prose column */
.oc-body {
max-width: 760px;
margin: 0 auto;
color: var(--ink-soft);
line-height: 1.75;
}
.section-dark .oc-body {
color: rgba(255, 255, 255, 0.78);
}
.oc-body p:last-child {
margin-bottom: 0;
}
.oc-body strong {
color: var(--ink);
}
.section-dark .oc-body strong {
color: #fff;
}
.section-dark .oc-body a {
color: var(--brass-bright);
}
/* In-prose bullet list (the odoo.sh facts): the shared list has no styling of its own. */
.oc-body ul {
padding-left: 1.2rem;
margin: 0 0 1.2rem;
}
.oc-body li {
margin-bottom: 0.55rem;
}
.oc-body li:last-child {
margin-bottom: 0;
}
.section-dark .oc-body li::marker {
color: var(--brass);
}
/* Numbered path (back up, install, test) */
.oc-steps {
max-width: 820px;
margin: 0 auto;
padding: 0;
list-style: none;
counter-reset: oc-step;
}
.oc-steps li {
position: relative;
counter-increment: oc-step;
padding: 0 0 1.4rem 3.2rem;
color: rgba(255, 255, 255, 0.78);
line-height: 1.7;
border-left: 1px solid rgba(201, 162, 39, 0.25);
margin-left: 1rem;
}
.oc-steps li::before {
content: counter(oc-step);
position: absolute;
left: -1.05rem;
top: -0.15rem;
width: 2.1rem;
height: 2.1rem;
display: flex;
align-items: center;
justify-content: center;
font-family: Marcellus, Georgia, serif;
font-size: 1rem;
color: var(--midnight);
background: var(--brass);
border-radius: 50%;
}
.oc-steps li strong {
color: #fff;
font-weight: 600;
}
.oc-steps li em {
color: rgba(255, 255, 255, 0.9);
}
.oc-steps li:last-child {
padding-bottom: 0;
}
/* The shared .oc-steps colours are tuned for a dark band; this page runs the path
on the light band, so the text re-points to ink (the brass counter still reads). */
.section-light .oc-steps li {
color: var(--ink-soft);
border-left-color: rgba(138, 111, 31, 0.3);
}
.section-light .oc-steps li strong {
color: var(--ink);
}
.section-light .oc-steps li em {
color: var(--ink);
}
.section-light .oc-steps li a {
color: var(--brass-deep);
}
/* FAQ */
.cmp-faq-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
max-width: 1000px;
margin: 0 auto;
}
.cmp-faq {
background: var(--ivory);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 0.4rem 1.25rem;
box-shadow: var(--shadow-rest);
transition: box-shadow 0.25s;
}
.cmp-faq[open],
.cmp-faq:hover {
box-shadow: var(--shadow-hover);
}
.cmp-faq summary {
cursor: pointer;
list-style: none;
padding: 0.85rem 0;
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
font-family: Marcellus, Georgia, serif;
font-size: 0.98rem;
color: var(--midnight);
}
.cmp-faq summary::-webkit-details-marker {
display: none;
}
.cmp-faq summary::after {
content: "+";
font-family: Inter, sans-serif;
color: var(--brass);
font-size: 1.15rem;
line-height: 1;
flex-shrink: 0;
transition: transform 0.25s;
}
.cmp-faq[open] summary::after {
content: "\2212";
}
.cmp-faq summary:focus-visible {
outline: 2px solid var(--brass);
outline-offset: 3px;
border-radius: var(--radius);
}
.cmp-faq .cmp-a {
font-size: 0.87rem;
color: var(--ink-soft);
line-height: 1.7;
padding-bottom: 1rem;
}
.cmp-faq .cmp-a p:last-child {
margin-bottom: 0;
}
/* Final CTA (the same block odoo-hosting-2027.html uses) */
.cmp-cta {
text-align: center;
}
.cmp-cta .cmp-lead {
font-size: 1.1rem;
color: rgba(255, 255, 255, 0.72);
max-width: 720px;
line-height: 1.75;
margin: 0 auto 1.5rem;
}
.cmp-cta-actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-top: 0.5rem;
}
/* Focus states on every CTA */
.btn:focus-visible,
.cmp-topbar-links a:focus-visible {
outline: 3px solid var(--brass-bright);
outline-offset: 2px;
border-radius: var(--radius);
}
.btn-brass:focus-visible {
outline-color: #fff;
}
@media (max-width: 768px) {
.cmp-faq-grid {
grid-template-columns: 1fr;
}
.oc-hero {
padding: 3rem 0 2.5rem;
}
.oc-steps li {
padding-left: 2.6rem;
}
}
</style>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebPage",
"@id": "https://odoo4projects.com/odoo-staging-environment/#webpage",
"url": "https://odoo4projects.com/odoo-staging-environment/",
"name": "Odoo Staging Environment: Do You Actually Need One?",
"description": "Most small businesses don't need an Odoo staging environment. How staging works, what it costs, and the back-up-install-test route we offer instead.",
"inLanguage": "en-US",
"isPartOf": { "@id": "https://odoo4projects.com/#organization" },
"breadcrumb": { "@id": "https://odoo4projects.com/odoo-staging-environment/#breadcrumb" },
"mainEntity": { "@id": "https://odoo4projects.com/odoo-staging-environment/#article" }
},
{
"@type": "Article",
"@id": "https://odoo4projects.com/odoo-staging-environment/#article",
"headline": "Odoo Staging Environment: Do You Actually Need One?",
"description": "Most small businesses don't need an Odoo staging environment. How staging works, what it costs, and the back-up-install-test route we offer instead.",
"articleSection": [
"What is an Odoo staging environment?",
"How odoo.sh works with staging",
"Who staging is really for",
"Do you need one? For most small businesses, no.",
"The simpler way: back up, install, test",
"When it does get complicated, we set up staging for you",
"Frequently asked questions"
],
"inLanguage": "en-US",
"datePublished": "2026-11-01",
"dateModified": "2026-11-01",
"author": { "@id": "https://odoo4projects.com/#organization" },
"publisher": { "@id": "https://odoo4projects.com/#organization" },
"mainEntityOfPage": { "@id": "https://odoo4projects.com/odoo-staging-environment/#webpage" }
},
{
"@type": "FAQPage",
"@id": "https://odoo4projects.com/odoo-staging-environment/#faq",
"isPartOf": { "@id": "https://odoo4projects.com/odoo-staging-environment/#webpage" },
"mainEntity": [
{
"@type": "Question",
"name": "Do I need a staging environment for my Odoo?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Most small businesses do not. You need one if you change Odoo's code, writing or editing modules or migrating a whole system, and you want to test the change on real data before it goes live. If you add finished modules to a running business system, a backup and a careful test does the job that staging would."
}
},
{
"@type": "Question",
"name": "What is an Odoo staging environment?",
"acceptedAnswer": {
"@type": "Answer",
"text": "A copy of your live Odoo, with the same apps and a recent copy of your data, running separately so a mistake in it cannot reach the system your business uses. You try a change in the copy first and move it to the live system only once it works."
}
},
{
"@type": "Question",
"name": "How much does an Odoo.sh staging environment cost?",
"acceptedAnswer": {
"@type": "Answer",
"text": "odoo.sh's own pricing page lists base hosting from $57.80 a month and Staging environment as a metered line at $14.40 per environment per month, and states the hosting price does not include the Enterprise licence. odoo.sh is reached through Odoo's Custom plan."
}
},
{
"@type": "Question",
"name": "Can I test a new module without a staging environment?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes. Take a backup first, install the module into your live Odoo, and try it on your real data. Modules install from the On the Rise plan upwards, in one click. If it misbehaves, the backup you took is what puts the system back."
}
},
{
"@type": "Question",
"name": "What happens if a new module breaks my Odoo?",
"acceptedAnswer": {
"@type": "Answer",
"text": "If you took a backup first, tell us through support and we restore it, and your system goes back to the moment of your backup. That is why the backup comes first: it is the safety net that makes a staging environment unnecessary for a single change."
}
},
{
"@type": "Question",
"name": "Can you set up a staging environment for me?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes. When a change is bigger than one module, a version upgrade, several modules at once, a migration, tell us and we set up the staging environment for you. You do not need a hosting account elsewhere or to learn how staging branches work."
}
}
]
},
{
"@type": "BreadcrumbList",
"@id": "https://odoo4projects.com/odoo-staging-environment/#breadcrumb",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://odoo4projects.com/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Odoo Staging Environment",
"item": "https://odoo4projects.com/odoo-staging-environment/"
}
]
}
]
}
</script>
<!-- Privacy-friendly analytics by Plausible -->
<script async src="https://plausible.office.odoo4projects.com/js/pa-NhA7Rf-79VopXFQXhSS4e.js"></script>
<script>
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
</head>
<body>
<!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. -->
<div class="cmp-topbar">
<div class="container">
<a class="cmp-brand" href="/index.html">ODOO4<span>projects</span></a>
<nav class="cmp-topbar-links" aria-label="Page links">
<a href="/index.html#pricing">Plans &amp; pricing</a>
<a href="/support.html">Support</a>
</nav>
<div data-lang-switcher class="lang-switch-slot"></div>
</div>
</div>
<!-- Hero: the reader's question, answered before the first H2 -->
<main>
<section class="hero oc-hero">
<div class="hero-bg">
<img
class="oc-hero-img"
src="/images/hero/amsterdam.webp"
alt="Amsterdam at night — one of the six regions our servers run in"
width="1920"
height="768"
fetchpriority="high"
decoding="async"
/>
</div>
<div class="container">
<p class="hero-kicker">STAGING, IN PLAIN WORDS</p>
<h1>Do You Need an Odoo Staging Environment?</h1>
<div class="oc-lede">
<p>
An Odoo staging environment is a copy of your live Odoo that you are allowed to break. It carries
the same apps, the same settings and a copy of your real data, and it runs on its own, so nothing
you do inside it reaches the system your invoices, orders and stock depend on. You install a module
— an add-on, a feature you switch on — into the copy, put it through its paces, and if it misbehaves
you delete the copy and the business never noticed.
</p>
<p>
That is a developer's tool. Staging exists for the person who changes Odoo's code and wants to see
the change work on real data before it goes near the live system. If you write or edit Odoo modules
for a living, it is the right instrument, and you already know what to do with it.
</p>
<p>
Most people who search for a staging environment are not that person. They run a shop, a workshop or
an office, someone told them they need staging before they touch their Odoo, and the honest answer
is that they usually do not. The rest of this page covers how staging works, who it is really for,
and what to do instead.
</p>
</div>
</div>
</section>
<!-- What it is -->
<section class="section-light">
<div class="container">
<p class="section-kicker">The plain definition</p>
<h2 class="section-title">What is an Odoo staging environment?</h2>
<div class="oc-body">
<p>
Staging is a second version of your Odoo that stands beside the one your business actually uses. The
version customers and staff touch is called production. Staging is a copy of it, with the same
applications and configuration and a recent copy of your data, running separately so that a mistake
there cannot reach production.
</p>
<p>
You use it to try something before committing to it. A new module, a changed setting, a small piece
of code: you put it into the copy first, see how it behaves with your real products and your real
customers, and only then move it across. If it turns out badly, production never saw it.
</p>
<p>Staging is somewhere to be wrong without paying for it.</p>
</div>
</div>
</section>
<!-- odoo.sh -->
<section class="section-dark">
<div class="container">
<p class="section-kicker">Another vendor's product, stated fairly</p>
<h2 class="section-title">How odoo.sh works with staging</h2>
<div class="oc-body">
<p>
odoo.sh is Odoo's own hosting platform, and it has staging built in. A project on odoo.sh is
organised into three stages, named exactly Production, Staging and Development, and you move a
branch between them. A staging branch is a neutralized duplicate of the production database: it
holds your real data, and neutralization switches off the parts that would reach the outside world.
Scheduled actions and outgoing email are stopped (mail is caught rather than sent), and payment
providers, shipping connectors and Odoo's own paid services are put into test mode. That way you can
test on production's data without emailing a customer or charging a real card.
</p>
<p>A few facts worth knowing before you plan around it:</p>
<ul>
<li>
Staging databases are not backed up automatically. You either take a manual backup or restore a
production backup into the staging branch.
</li>
<li>A staging database is deleted automatically after one month. To keep using the branch, you rebuild it.</li>
<li>
When you merge staging back into production, only the source code moves across. Database changes
you made while testing are not carried over, so a setting you need permanently has to be committed
as code or copied by hand.
</li>
<li>On a trial project, production and every staging branch drop back to the Development stage after 30 days.</li>
</ul>
<p>
The price, from odoo.sh's own pricing page: base hosting starts at $57.80 a month, and Staging
environment is a metered line at $14.40 per environment per month. Dedicated hosting is $480.00 a
month, workers are $57.60 each per month, and storage is $0.20 per GB per month. That page states
the hosting price does not include the Enterprise licence. You reach odoo.sh through Odoo's Custom
plan, US$49.00 per user per month billed yearly, or US$61.00 monthly.
</p>
<p>That is an accurate picture of a good product. For a team that ships Odoo code, it is a sensible way to work.</p>
</div>
</div>
</section>
<!-- Who it is for -->
<section class="section-light">
<div class="container">
<p class="section-kicker">Who the tool is built for</p>
<h2 class="section-title">Who staging is really for</h2>
<div class="oc-body">
<p>
Staging is for people who change Odoo's code. A developer adding a module, an agency doing a
migration, a technical person on staff who has to know a change works before the warehouse opens on
Monday. Their job creates the risk staging removes: they are editing the machinery, and a mistake in
the machinery is expensive and stays invisible until it is live.
</p>
<p>If you have that person, or you are that person, staging is a normal and sensible expense.</p>
<p>
For everyone else, the useful question is whether they need staging at all. For most small
businesses the answer is no, and the reason is worth spelling out.
</p>
</div>
</div>
</section>
<!-- Do you need one -->
<section class="section-dark">
<div class="container">
<p class="section-kicker">The honest answer</p>
<h2 class="section-title">Do you need one? For most small businesses, no.</h2>
<div class="oc-body">
<p>Two things are true of a typical small business, and both point the same way.</p>
<p>
The first is how you get a new capability. You do not write a module; you buy one that is already
finished and proven. A stock feature, a report, a payment option: it was built and tested by someone
else and is already in use at thousands of businesses. You install it and you use it. There is no
code of yours to test, because there is no code of yours.
</p>
<p>
The second is who tests it. If you have anybody technical at all, the usual arrangement is that your
developer runs the module on their own machine before it comes near your live system, and hands it
over when it already works. That local test does the job staging does, for the change you are
actually making.
</p>
<p>
So the risk staging removes, breaking live data with an untested code change, barely exists in your
week. You are not running a code pipeline. You are adding a capability to a business system, and the
honest tool for that is smaller than staging: a backup, an install, and a way back if it goes wrong.
</p>
</div>
</div>
</section>
<!-- The simpler way -->
<section class="section-light">
<div class="container">
<p class="section-kicker">The path we recommend first</p>
<h2 class="section-title">The simpler way: back up, install, test</h2>
<div class="oc-body">
<p>There is a lighter version of the same safety net, and it is the one we recommend first.</p>
<p>
Everything a staging environment protects against comes down to one fear: that a change breaks your
live system and your data goes with it. You can settle that fear directly, on the system you already
have, without a second environment to pay for and keep running.
</p>
</div>
<ol class="oc-steps">
<li>
<strong>Take a backup.</strong> Backups run on your instance already. Before you install anything,
<a href="/blog/posts/speed-run-backup.html">take one by hand</a>, so you hold a known-good copy from
the moment before the change. Backups run on paid plans: four rotating slots on On the Rise and
Powerhouse, one on Side Hustle. The 30-day trial has none. Slots work first-in, first-out, so a new
backup overwrites the oldest: a backup is your latest safety copy, not a permanent one. If you are
on Side Hustle, check the backup exists before you install, and finish your test before the next
scheduled backup replaces it.
</li>
<li>
<strong>Install the module.</strong> Modules
<a href="/blog/posts/how-to-install-odoo-modules-using-git.html">install from the On the Rise plan upwards</a>,
from inside <a href="/odoo-hosting-2027.html">your own Odoo</a>, in one click. On Side Hustle, module
installation is not included. Contact support and we will take it from there. You install straight
into your live system, which is the point: you want to see it work with your real data.
</li>
<li>
<strong>Test it, for real.</strong> Open the module, click through what it does, and run it against
your actual products, customers and orders. Ten minutes of real use tells you more than any test
environment.
</li>
<li>
<strong>If it does not work, put the backup back.</strong> If the module misbehaves, tell us through
<a href="/support.html">support</a> and we restore the backup you took; the request is handled by
support like any other. Restoring puts the system back to the state it was in when you took the
backup. Anything entered since then (orders, invoices, payments) goes back with it, so test at a
quiet moment and keep the gap short. On a one-slot plan the backup is only as fresh as the next
scheduled run, so finish the test before that replaces it.
</li>
</ol>
<div class="oc-body" style="margin-top: 2.25rem">
<p>
The whole sequence is a backup, a click, a look, and, only if you need it, a restore. For a single
module that is less work than setting up and maintaining a staging environment, and it happens on
the system your staff already use.
</p>
</div>
</div>
</section>
<!-- The escalation -->
<section class="section-dark">
<div class="container">
<p class="section-kicker">When the change is bigger</p>
<h2 class="section-title">When it does get complicated, we set up staging for you</h2>
<div class="oc-body">
<p>Sometimes the change is bigger than a single module.</p>
<p>
A <a href="/odoo-community-upgrade.html">version upgrade</a> that moves your whole system to a new
Odoo. Several modules arriving at once. A migration where the data itself is being reshaped. A change
nobody in the business wants to be the one who risks on the live system. These are the moments where
a plain backup and restore is not enough, because you need to see a whole rebuilt system working
beside the real one before you commit.
</p>
<p>
That is a staging environment, and it is exactly the job it was built for. If you reach that moment,
tell us. We set up the staging environment for you: the copy, the test data, the place to try the
change. You do not need to learn how the branches work, or hold a hosting account somewhere else to
get one.
</p>
<p>
We scope it with you and price it before we start, and we only suggest it when the change is
genuinely bigger than a module, not for one app you could install on a backup.
</p>
<p>
That is the honest split. You install finished modules on a backup; we build you a staging
environment the day a small business genuinely needs one.
</p>
</div>
</div>
</section>
<!-- FAQ -->
<section class="section-light" id="faq">
<div class="container">
<p class="section-kicker">Straight answers</p>
<h2 class="section-title">Frequently asked questions</h2>
<div class="cmp-faq-grid">
<details class="cmp-faq">
<summary class="plausible-event-name=FAQ">Do I need a staging environment for my Odoo?</summary>
<div class="cmp-a">
<p>Most small businesses do not. You need one if you change Odoo's code, writing or editing modules or migrating a whole system, and you want to test the change on real data before it goes live. If you add finished modules to a running business system, a backup and a careful test does the job that staging would.</p>
</div>
</details>
<details class="cmp-faq">
<summary class="plausible-event-name=FAQ">What is an Odoo staging environment?</summary>
<div class="cmp-a">
<p>A copy of your live Odoo, with the same apps and a recent copy of your data, running separately so a mistake in it cannot reach the system your business uses. You try a change in the copy first and move it to the live system only once it works.</p>
</div>
</details>
<details class="cmp-faq">
<summary class="plausible-event-name=FAQ">How much does an Odoo.sh staging environment cost?</summary>
<div class="cmp-a">
<p>odoo.sh's own pricing page lists base hosting from $57.80 a month and Staging environment as a metered line at $14.40 per environment per month, and states the hosting price does not include the Enterprise licence. odoo.sh is reached through Odoo's Custom plan.</p>
</div>
</details>
<details class="cmp-faq">
<summary class="plausible-event-name=FAQ">Can I test a new module without a staging environment?</summary>
<div class="cmp-a">
<p>Yes. Take a backup first, install the module into your live Odoo, and try it on your real data. Modules install from the On the Rise plan upwards, in one click. If it misbehaves, the backup you took is what puts the system back.</p>
</div>
</details>
<details class="cmp-faq">
<summary class="plausible-event-name=FAQ">What happens if a new module breaks my Odoo?</summary>
<div class="cmp-a">
<p>If you took a backup first, tell us through support and we restore it, and your system goes back to the moment of your backup. That is why the backup comes first: it is the safety net that makes a staging environment unnecessary for a single change.</p>
</div>
</details>
<details class="cmp-faq">
<summary class="plausible-event-name=FAQ">Can you set up a staging environment for me?</summary>
<div class="cmp-a">
<p>Yes. When a change is bigger than one module, a version upgrade, several modules at once, a migration, tell us and we set up the staging environment for you. You do not need a hosting account elsewhere or to learn how staging branches work.</p>
</div>
</details>
</div>
</div>
</section>
<!-- Final CTA -->
<section class="section-dark cmp-cta" id="talk">
<div class="container">
<p class="section-kicker">One change, handled properly</p>
<h2 class="section-title">Talk to us about your change.</h2>
<p class="cmp-lead">
Installing a module: take a backup, click install, and ask us if anything looks wrong. A bigger
change: we set up a staging environment for you.
</p>
<div class="cmp-cta-actions">
<a href="/support.html#contact" class="btn btn-brass btn-lg">Contact support</a>
</div>
</div>
</section>
<!-- Footer (copied from what-can-i-use-odoo-for/; icon-font glyphs replaced with inline SVG so the page
carries no external icon dependency) -->
</main>
<footer class="footer">
<div class="container">
<div class="fgrid">
<div>
<h4>Useful Links</h4>
<ul>
<li><a href="/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li>
<li><a href="/odoo-hosting-2027.html">ODOO Hosting in 2027</a></li>
<li><a href="/odoo-community-developers/">Odoo Freelancer Directory</a></li>
<li><a href="/support.html">Support</a></li>
<li><a href="/legal.html">Legal</a></li>
</ul>
</div>
<div>
<h4>Partners</h4>
<div class="partner-list">
<a href="https://oc-guy.com" target="_blank" rel="noopener" class="partner-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9a227" stroke-width="2" aria-hidden="true"><path d="M20 7h-9M14 17H5M20 7l-4-4M20 7l-4 4M4 17l4 4M4 17l4-4" /></svg> OC Guy</a>
<a href="https://oca-py.org" target="_blank" rel="noopener" class="partner-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9a227" stroke-width="2" aria-hidden="true"><path d="M20 7h-9M14 17H5M20 7l-4-4M20 7l-4 4M4 17l4 4M4 17l4-4" /></svg> OCA Paraguay</a>
<a href="https://OD8N.com" target="_blank" rel="noopener" class="partner-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9a227" stroke-width="2" aria-hidden="true"><path d="M20 7h-9M14 17H5M20 7l-4-4M20 7l-4 4M4 17l4 4M4 17l4-4" /></svg> OD8N</a>
</div>
</div>
<div class="fcol-desc">
<h4>About us</h4>
<p>We build smart, scalable business solutions by combining the <strong>stability</strong> of Odoo ERP with the <strong>power</strong> of AI Agents and N8N automations.</p>
<p>From streamlined sales to global rollouts, our <strong>lean, eco-friendly</strong> approach helps businesses cut costs, boost efficiency, and grow without limits.</p>
</div>
<div>
<h4>Follow us on</h4>
<div class="social">
<a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a>
<a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a>
</div>
<form class="footer-contact-form" id="footerContactForm" action="mailto:support@odoo4projects.com" method="post" enctype="text/plain">
<label class="oc-sr-only" for="footerContactEmail">Your email address</label>
<input type="email" id="footerContactEmail" name="email" placeholder="Your email address" required />
<div class="footer-textarea-wrap">
<label class="oc-sr-only" for="footerContactText">Your message</label>
<textarea id="footerContactText" name="message" placeholder="Your message..." required></textarea>
<button type="submit" class="footer-send-btn" aria-label="Send message"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M2 21l21-9L2 3v7l15 2-15 2z" /></svg></button>
</div>
<div class="footer-form-status" id="footerFormStatus" role="status"></div>
</form>
</div>
</div>
<div class="fcopy">Copyright &copy; ODOO4projects LLC</div>
</div>
</footer>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</body>
</html>
+2
View File
@@ -210,6 +210,7 @@
</script> </script>
</head> </head>
<body> <body>
<main>
<div class="page-subtitle"> <div class="page-subtitle">
You <b>immediately</b> unlock all new features and higher limits.<br /> You <b>immediately</b> unlock all new features and higher limits.<br />
Any extra time is <b>added</b> to your existing subscription. Any extra time is <b>added</b> to your existing subscription.
@@ -492,5 +493,6 @@
loadContract(); loadContract();
</script> </script>
</main>
</body> </body>
</html> </html>
+5 -4
View File
@@ -27,8 +27,8 @@
onload="this.media='all'" onload="this.media='all'"
/> />
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript> <noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="stylesheet" href="/style.css" media="print" onload="this.media='all'" /> <link rel="stylesheet" href="/style.css?v=061517baef" media="print" onload="this.media='all'" />
<noscript><link rel="stylesheet" href="/style.css" /></noscript> <noscript><link rel="stylesheet" href="/style.css?v=061517baef" /></noscript>
<style> <style>
/* ---- Partner briefing for Andrew (page-scoped; additive) ---- */ /* ---- Partner briefing for Andrew (page-scoped; additive) ---- */
.andrew-sr-only { .andrew-sr-only {
@@ -661,6 +661,7 @@
<ul> <ul>
<li><a href="/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li> <li><a href="/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li>
<li><a href="/odoo-hosting-2027.html">ODOO Hosting in 2027</a></li> <li><a href="/odoo-hosting-2027.html">ODOO Hosting in 2027</a></li>
<li><a href="/odoo-community-developers/">Odoo Freelancer Directory</a></li>
<li><a href="/support.html">Support</a></li> <li><a href="/support.html">Support</a></li>
<li><a href="/legal.html">Legal</a></li> <li><a href="/legal.html">Legal</a></li>
</ul> </ul>
@@ -684,7 +685,7 @@
<a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a> <a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a>
<a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a> <a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a>
</div> </div>
<form class="footer-contact-form" id="footerContactForm" action="mailto:hello@odoo4projects.com" method="post" enctype="text/plain"> <form class="footer-contact-form" id="footerContactForm" action="mailto:support@odoo4projects.com" method="post" enctype="text/plain">
<label class="bc-sr-only" for="footerContactEmail">Your email address</label> <label class="bc-sr-only" for="footerContactEmail">Your email address</label>
<input type="email" id="footerContactEmail" name="email" placeholder="Your email address" required /> <input type="email" id="footerContactEmail" name="email" placeholder="Your email address" required />
<div class="footer-textarea-wrap"> <div class="footer-textarea-wrap">
@@ -699,6 +700,6 @@
<div class="fcopy">Copyright &copy; ODOO4projects LLC</div> <div class="fcopy">Copyright &copy; ODOO4projects LLC</div>
</div> </div>
</footer> </footer>
<script defer src="/static/lang.js"></script> <script defer src="/static/lang.js?v=3ec49a7e62"></script>
</body> </body>
</html> </html>
+15 -5
View File
@@ -25,7 +25,7 @@
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/> />
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css?v=061517baef" />
<style> <style>
.legal-hero { .legal-hero {
padding: 4rem 0 2.5rem; padding: 4rem 0 2.5rem;
@@ -111,6 +111,7 @@
</script> </script>
</head> </head>
<body> <body>
<main>
<div class="container"> <div class="container">
<div class="blog-header"> <div class="blog-header">
<a href="index.html">&larr; Back to Home</a> <a href="index.html">&larr; Back to Home</a>
@@ -121,7 +122,7 @@
<div class="container"> <div class="container">
<h1>Privacy Policy</h1> <h1>Privacy Policy</h1>
<p style="margin-top: 0.5rem; color: rgba(33, 37, 41, 0.6); font-size: 0.85rem;"> <p style="margin-top: 0.5rem; color: rgba(33, 37, 41, 0.6); font-size: 0.85rem;">
Last updated 2026-10-21 Last updated 2026-11-02
</p> </p>
</div> </div>
</section> </section>
@@ -135,7 +136,7 @@
30 N Gould St, STE R, Sheridan<br /> 30 N Gould St, STE R, Sheridan<br />
WY 82801, USA WY 82801, USA
</p> </p>
<p>Contact: <span class="contact-email">support@ODOO4projects.com</span></p> <p>Contact: <span class="contact-email">support@odoo4projects.com</span></p>
</div> </div>
<div class="legal-card"> <div class="legal-card">
@@ -173,6 +174,14 @@
<p>Our pages load Stripe (payments), Google Fonts, Font Awesome, GSAP, and our own agent widget from our server. Like any website, these requests can see the IP address your browser connects from.</p> <p>Our pages load Stripe (payments), Google Fonts, Font Awesome, GSAP, and our own agent widget from our server. Like any website, these requests can see the IP address your browser connects from.</p>
</div> </div>
<div class="legal-card">
<h2>Advertising cookies</h2>
<p>On our Black Friday page we would like to measure our advertising with Google Ads. This is optional, and it is off until you turn it on.</p>
<p>If you accept, the Google Ads tag loads and sets advertising cookies (named <strong>_gcl_au</strong> and <strong>_gcl_aw</strong>). They let Google tell us which ad or search led to a visit, so we spend our budget on the ads that work. They do not change what you pay, and we do not sell your data.</p>
<p>If you decline, the tag does not load and those cookies are not set. The page works the same either way. You can change your choice at any time with the &ldquo;Cookie settings&rdquo; link in the footer of the Black Friday page, or by clearing cookies in your browser.</p>
<p>Our privacy-friendly analytics are separate: they set no cookies and collect no personal data.</p>
</div>
<div class="legal-card"> <div class="legal-card">
<h2>Email</h2> <h2>Email</h2>
<p>We email you about your account, your invoices and your service. Marketing email carries an unsubscribe link, and unsubscribing does not stop service email about an active account.</p> <p>We email you about your account, your invoices and your service. Marketing email carries an unsubscribe link, and unsubscribing does not stop service email about an active account.</p>
@@ -185,7 +194,7 @@
<div class="legal-card"> <div class="legal-card">
<h2>Your requests</h2> <h2>Your requests</h2>
<p>Ask us for a copy of your data, a correction, or deletion at <span class="contact-email">support@ODOO4projects.com</span>. If your account is active, deletion ends the service. We keep invoice records for as long as accounting rules require them.</p> <p>Ask us for a copy of your data, a correction, or deletion at <span class="contact-email">support@odoo4projects.com</span>. If your account is active, deletion ends the service. We keep invoice records for as long as accounting rules require them.</p>
</div> </div>
<div class="legal-card"> <div class="legal-card">
@@ -195,6 +204,7 @@
</div> </div>
</section> </section>
<script defer src="/static/agent-odoo4projects/agent.js"></script> </main>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body> </body>
</html> </html>
+33 -3
View File
@@ -61,10 +61,16 @@
<priority>0.6</priority> <priority>0.6</priority>
</url> </url>
<url> <url>
<loc>https://odoo4projects.com/odooityourself.html</loc> <loc>https://odoo4projects.com/odoo-staging-environment/</loc>
<lastmod>2026-10-26</lastmod> <lastmod>2026-11-01</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.9</priority> <priority>0.6</priority>
</url>
<url>
<loc>https://odoo4projects.com/odoo-community-developers/</loc>
<lastmod>2026-11-03</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url> </url>
<url> <url>
<loc>https://odoo4projects.com/support.html</loc> <loc>https://odoo4projects.com/support.html</loc>
@@ -78,6 +84,30 @@
<changefreq>yearly</changefreq> <changefreq>yearly</changefreq>
<priority>0.4</priority> <priority>0.4</priority>
</url> </url>
<url>
<loc>https://odoo4projects.com/blog/posts/your-agent-can-run-your-odoo-hosting.html</loc>
<lastmod>2026-11-05</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://odoo4projects.com/blog/posts/freelancer-directory-soft-launch.html</loc>
<lastmod>2026-11-04</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://odoo4projects.com/blog/posts/odoo-20-is-live-on-our-servers-here-is-what-changed.html</loc>
<lastmod>2026-11-03</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://odoo4projects.com/blog/posts/from-a-spreadsheet-to-a-running-erp.html</loc>
<lastmod>2026-11-02</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url> <url>
<loc>https://odoo4projects.com/blog/posts/oca-paraguay-internationalization-hackathon.html</loc> <loc>https://odoo4projects.com/blog/posts/oca-paraguay-internationalization-hackathon.html</loc>
<lastmod>2026-10-29</lastmod> <lastmod>2026-10-29</lastmod>
+234
View File
@@ -0,0 +1,234 @@
/* ads-consent.js — Google Ads tag, gated behind a consent banner. Page-side only.
*
* Page: /odoo-black-friday/. Companion to /static/black-friday.js (which is untouched).
* The tag ID and the conversion label are read from the banner element's data-* attributes
* (#adsConsent[data-ads-id][data-ads-send-to]), so this file stays generic and reusable and
* the page holds the one copy of the config. Without those attributes the script stays inert.
*
* WHY A GATE: the Google Ads tag is NOT cookieless — it sets the advertising cookies
* _gcl_au / _gcl_aw. So nothing loads and no request fires until the visitor accepts.
* The default is DENY. Declining genuinely prevents the tag from loading.
*
* WHAT THE CONVERSIONS ARE: both are CLICK PROXIES, not sales.
* - a buy form on this page (POST /shop/post_basket) -> data-ads-send-to
* - the free-trial form (#trialForm, the n8n webhook) -> data-ads-trial-send-to
* The real purchase is measured on the checkout host (20.odoo4projects.com) by odoo-dev.
* See wiki/concepts/conversion-tracking-contract. Never label either a sale.
*
* SAFETY: a conversion tag must never swallow an action. On submit we fire the event and run the
* action from the event callback, with a hard timeout that runs it anyway. For a buy form the
* action is form.submit(); for the trial form the page hands us its send function via
* window.o4pAds.trialConversion. With JavaScript off the native POST forms submit untouched
* (no banner, no conversion, no error) and the trial form does its native thing.
*/
(function () {
"use strict";
var STORE_KEY = "o4p_ads_consent"; // "granted" | "denied"
var SUBMIT_FALLBACK_MS = 700; // hard cap: submit even if the tag never answers
var cfg = null; // { id, sendTo, trialSendTo } — from the banner, never hard-coded
/* ---- the visitor's stored choice ---- */
function readChoice() {
try {
return window.localStorage.getItem(STORE_KEY);
} catch (e) {
return null;
}
}
function saveChoice(value) {
try {
window.localStorage.setItem(STORE_KEY, value);
} catch (e) {
/* private mode / storage disabled: treat as no choice, banner stays up */
}
}
function isGranted() {
return readChoice() === "granted";
}
/* ---- cookie removal (only used to revoke a prior grant) ---- */
function clearCookie(name) {
var host = window.location.hostname;
var bare = host.replace(/^www\./, "");
var paths = ["/", "/odoo-black-friday/"];
var domains = ["", "; domain=" + host, "; domain=." + bare];
for (var i = 0; i < paths.length; i++) {
for (var j = 0; j < domains.length; j++) {
document.cookie =
name + "=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=" + paths[i] + domains[j];
}
}
}
function clearAdsCookies() {
clearCookie("_gcl_au");
clearCookie("_gcl_aw");
}
/* ---- the tag: loaded ONLY on consent ---- */
function loadGtag() {
if (!cfg || window.__o4pAdsLoaded) return;
window.__o4pAdsLoaded = true;
window.dataLayer = window.dataLayer || [];
window.gtag =
window.gtag ||
function () {
window.dataLayer.push(arguments);
};
window.gtag("js", new Date());
window.gtag("config", cfg.id);
var s = document.createElement("script");
s.async = true;
s.src = "https://www.googletagmanager.com/gtag/js?id=" + cfg.id;
document.head.appendChild(s);
}
/* ---- fire one conversion, then run the action from the callback or the hard timeout ---- */
function fireConversion(sendTo, action) {
var done = false;
function run() {
if (done) return;
done = true;
action(); // the action always runs: at most SUBMIT_FALLBACK_MS after the event
}
try {
window.gtag("event", "conversion", {
send_to: sendTo,
event_callback: run,
});
} catch (err) {
run();
}
window.setTimeout(run, SUBMIT_FALLBACK_MS);
}
/* ---- the conversion proxy, fired on a buy-form submit ---- */
/* NOTE: the buy path below is deliberately left byte-identical to what shipped and was
* verified on 2026-11-02 — its label, its one mark and its 700 ms fallback are unchanged. */
function onSubmit(e) {
var form = e.target;
if (!form || form.tagName !== "FORM") return;
var action = form.getAttribute("action") || "";
if (action.indexOf("/shop/post_basket") === -1) return;
// Not configured, no consent, or no tag: do nothing, let the native POST go through.
if (!cfg || !isGranted() || typeof window.gtag !== "function") return;
e.preventDefault();
var submitted = false;
function submitNow() {
if (submitted) return;
submitted = true;
form.submit(); // bypasses the submit event: no loop, no re-fire
}
try {
window.gtag("event", "conversion", {
send_to: cfg.sendTo,
event_callback: submitNow,
});
} catch (err) {
submitNow();
}
// Hard fallback: a blocked / slow / failed tag must never stop the purchase.
window.setTimeout(submitNow, SUBMIT_FALLBACK_MS);
}
/* ---- the banner ---- */
function bannerEl() {
return document.getElementById("adsConsent");
}
function showBanner() {
var el = bannerEl();
if (el) el.hidden = false;
}
function hideBanner() {
var el = bannerEl();
if (el) el.hidden = true;
}
function accept() {
saveChoice("granted");
loadGtag();
hideBanner();
}
function decline() {
saveChoice("denied");
clearAdsCookies();
hideBanner();
}
function init() {
document.addEventListener("submit", onSubmit, false);
var banner = bannerEl();
if (!banner) return;
// Config lives in the page. Missing config -> stay inert (no tag, no conversion).
var id = banner.getAttribute("data-ads-id");
var sendTo = banner.getAttribute("data-ads-send-to");
if (!id || !sendTo) return;
cfg = {
id: id,
sendTo: sendTo,
trialSendTo: banner.getAttribute("data-ads-trial-send-to"),
bookSendTo: banner.getAttribute("data-ads-book-send-to"),
};
var choice = readChoice();
if (choice === "granted") {
loadGtag(); // returning visitor who already accepted: load straight away
} else if (choice !== "denied") {
showBanner(); // first visit (or no stored choice): default deny, ask
}
var acceptBtn = document.getElementById("adsConsentAccept");
if (acceptBtn) acceptBtn.addEventListener("click", accept);
var declineBtn = document.getElementById("adsConsentDecline");
if (declineBtn) declineBtn.addEventListener("click", decline);
// A real way to change the choice later.
var opener = document.getElementById("adsConsentOpen");
if (opener) {
opener.addEventListener("click", function (e) {
e.preventDefault();
showBanner();
});
}
}
/* ---- the trial conversion: the page hands us its real submit function ---- *
* Called by /static/black-friday.js when #trialForm is submitted. With consent we fire the Ads
* conversion and run the page's submit from the callback; a blocked/slow tag is covered by the
* hard timeout, so the trial always completes. Without consent (or without the tag) we run the
* submit immediately and fire nothing — the trial is never gated on the tag.
*/
window.o4pAds = window.o4pAds || {};
window.o4pAds.trialConversion = function (action) {
if (typeof action !== "function") return;
if (!cfg || !cfg.trialSendTo || !isGranted() || typeof window.gtag !== "function") {
action();
return;
}
fireConversion(cfg.trialSendTo, action);
};
/* ---- the booking conversion: fired on a click of the booking link ---- *
* The booking happens on Google's domain, off-site, so a click is all we can observe. The
* caller does NOT preventDefault: the link opens in a new tab on its own, so even a blocked,
* slow or absent tag can never stop the booking page from opening — this only ADDS the event.
* Without consent (or the label) it fires nothing and simply runs the (optional) action.
*/
window.o4pAds.bookConversion = function (action) {
if (typeof action !== "function") action = function () {};
if (!cfg || !cfg.bookSendTo || !isGranted() || typeof window.gtag !== "function") {
action();
return;
}
fireConversion(cfg.bookSendTo, action);
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
+521
View File
@@ -0,0 +1,521 @@
/* Page-local script for /odoo-black-friday/.
The homepage behaviours this landing page needs, copied from the site's /script.js:
the hero slide-in image driven by the server-location selector, the Buy Now buttons,
the free-trial card, the Custom Quote modal and the footer contact form.
The blog, OCA-modules and project-plan blocks of /script.js are removed here because this
page does not carry those sections (and those blocks throw on a page without their markup).
Image paths are root-relative: this page lives in a subdirectory (/odoo-black-friday/).
ES5, no build step, no new dependency beyond the gsap the homepage already loads. */
(function () {
"use strict";
/* ---- UTM tracking helpers (verbatim from /script.js) ---- */
(function () {
function getCookie(name) {
var match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
return match ? decodeURIComponent(match[2]) : null;
}
function setCookie(name, value, days) {
var expires = '';
if (days) {
var date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
expires = '; expires=' + date.toUTCString();
}
document.cookie = name + '=' + encodeURIComponent(value) + expires + '; path=/; SameSite=Lax';
}
function parseUTM() {
var urlParams = new URLSearchParams(window.location.search);
var utm = {};
var keys = ['source', 'medium', 'campaign'];
for (var i = 0; i < keys.length; i++) {
var val = urlParams.get('utm_' + keys[i]);
if (val) {
utm[keys[i]] = val;
setCookie('utm_' + keys[i], val, 90);
}
}
return utm;
}
function getStoredUTM() {
var utm = {};
var keys = ['source', 'medium', 'campaign'];
for (var i = 0; i < keys.length; i++) {
var val = getCookie('utm_' + keys[i]);
if (val) utm[keys[i]] = val;
}
return utm;
}
function injectUTM(data) {
var stored = getStoredUTM();
if (Object.keys(stored).length > 0) {
data.utm_source = stored.source || null;
data.utm_medium = stored.medium || null;
data.utm_campaign = stored.campaign || null;
}
return data;
}
function appendToForm(form) {
var stored = getStoredUTM();
var keys = ['source', 'medium', 'campaign'];
for (var i = 0; i < keys.length; i++) {
if (stored[keys[i]]) {
var input = document.createElement('input');
input.type = 'hidden';
input.name = 'utm_' + keys[i];
input.value = stored[keys[i]];
form.appendChild(input);
}
}
}
function toQuery() {
var stored = getStoredUTM();
var parts = [];
var keys = ['source', 'medium', 'campaign'];
for (var i = 0; i < keys.length; i++) {
if (stored[keys[i]]) {
parts.push('utm_' + keys[i] + '=' + encodeURIComponent(stored[keys[i]]));
}
}
return parts.join('&');
}
window.UTM = { parse: parseUTM, getStored: getStoredUTM, inject: injectUTM, appendToForm: appendToForm, toQuery: toQuery };
window.UTM.parse();
})();
/* ---- Hero slide-in image + server-location selector (homepage behaviour) ---- */
(function () {
var SERVERS = [
{ host: 'api.meppel.odoo4projects.com', city: 'Amsterdam' },
{ host: 'api.boston.odoo4projects.com', city: 'Boston' },
{ host: 'api.manchester.odoo4projects.com', city: 'Manchester' },
{ host: 'api.mumbai.odoo4projects.com', city: 'Mumbai' },
{ host: 'api.saopaulo.odoo4projects.com', city: 'S\u00e3o Paulo' },
{ host: 'api.sydney.odoo4projects.com', city: 'Sydney' }
];
/* Root-relative on purpose: this page is served from /odoo-black-friday/. */
var heroImages = {
'api.meppel.odoo4projects.com': '/images/hero/amsterdam.webp',
'api.boston.odoo4projects.com': '/images/hero/boston.webp',
'api.manchester.odoo4projects.com': '/images/hero/london.webp',
'api.mumbai.odoo4projects.com': '/images/hero/mumbai.webp',
'api.saopaulo.odoo4projects.com': '/images/hero/brazil.webp',
'api.sydney.odoo4projects.com': '/images/hero/sydney.webp'
};
function pingServer(host) {
return new Promise(function (resolve) {
var start = performance.now();
var img = new Image();
var timedOut = false;
var timer = setTimeout(function () {
timedOut = true; img.src = '';
resolve({ host: host, time: -1 });
}, 2000);
img.onload = function () {
var elapsed = performance.now() - start;
if (!timedOut) { clearTimeout(timer); resolve({ host: host, time: elapsed }); }
};
img.onerror = function () {
var elapsed = performance.now() - start;
if (!timedOut) { clearTimeout(timer); resolve({ host: host, time: elapsed }); }
};
img.src = 'https://' + host + '/favicon.ico?_=' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
});
}
function animateHero(city, image, showLabel) {
var hero = document.querySelector('.hero');
if (!hero || !window.gsap) return;
var bg = hero.querySelector('.hero-bg');
if (!bg) {
bg = document.createElement('div');
bg.className = 'hero-bg';
hero.insertBefore(bg, hero.firstChild);
}
var hasExisting = bg.children.length > 0;
var newEl = document.createElement('div');
newEl.className = 'hero-bg-slide';
newEl.style.backgroundImage = "url('" + image + "')";
bg.appendChild(newEl);
var tl = gsap.timeline();
gsap.set(newEl, { x: '100%' });
var label = null;
if (showLabel !== false && city) {
var oldLabel = hero.querySelector('.hero-city-label');
if (oldLabel) oldLabel.remove();
label = document.createElement('div');
label.className = 'hero-city-label';
label.innerHTML = 'Our <strong>' + city + '</strong> server fits your location';
hero.appendChild(label);
gsap.set(label, { x: '150%' });
}
if (hasExisting) {
var oldEl = bg.children[0];
gsap.set(oldEl, { x: '0%' });
tl.to(oldEl, { x: '100%', duration: 3, ease: 'power2.out' }, 0)
.to(newEl, { x: '0%', duration: 3, ease: 'power2.out' }, 0);
if (label) {
tl.to(label, { x: '0%', duration: 2.5, ease: 'power2.out' }, 0.3);
}
tl.call(function () { oldEl.remove(); });
} else {
tl.to(newEl, { x: '0%', duration: 3, ease: 'power2.out' }, 0);
if (label) {
tl.to(label, { x: '0%', duration: 2.5, ease: 'power2.out' }, 0.3);
}
}
}
/* Location selector: click a server card to slide its city into the hero. */
var serverCards = document.querySelectorAll('.server-card');
serverCards.forEach(function (card, idx) {
card.addEventListener('click', function () {
var s = SERVERS[idx];
if (!s || !heroImages[s.host]) return;
serverCards.forEach(function (c) { c.classList.remove('server-best'); });
card.classList.add('server-best');
animateHero(null, heroImages[s.host], false);
});
});
/* Populate the trial + pricing location selects. */
var trialLoc = document.getElementById('trialLocation');
var pricingLoc = document.getElementById('pricingServerLocation');
[trialLoc, pricingLoc].forEach(function (el) {
if (el) {
SERVERS.forEach(function (s) {
var opt = document.createElement('option');
var cityVal = s.host.replace('api.', '').replace('.odoo4projects.com', '');
opt.value = cityVal;
opt.textContent = s.city;
el.appendChild(opt);
});
}
});
/* Ping all servers, highlight the fastest, slide its image in. */
var results = [];
var pending = SERVERS.length;
SERVERS.forEach(function (s) {
pingServer(s.host).then(function (r) {
results.push(r);
pending--;
if (pending !== 0) return;
var valid = results.filter(function (x) { return x.time >= 0; });
if (valid.length === 0) return;
valid.sort(function (a, b) { return a.time - b.time; });
var best = valid[0];
var bestServer = SERVERS.filter(function (x) { return x.host === best.host; })[0];
if (bestServer) {
[trialLoc, pricingLoc].forEach(function (el) {
if (el) {
var cityVal = bestServer.host.replace('api.', '').replace('.odoo4projects.com', '');
for (var i = 0; i < el.options.length; i++) {
if (el.options[i].value === cityVal) { el.selectedIndex = i; break; }
}
}
});
}
if (bestServer && heroImages[best.host]) {
var cards = document.querySelectorAll('.server-card');
for (var i = 0; i < SERVERS.length && i < cards.length; i++) {
if (SERVERS[i].host === best.host) { cards[i].classList.add('server-best'); break; }
}
var img = new Image();
img.onload = function () { animateHero(bestServer.city, heroImages[best.host]); };
img.src = heroImages[best.host];
}
});
});
/* Free-trial card. */
var trialForm = document.getElementById('trialForm');
if (trialForm) {
trialForm.addEventListener('submit', function (e) {
e.preventDefault();
var email = document.getElementById('trialEmail').value.trim();
var location = trialLoc ? trialLoc.value : '';
if (!email) return;
var btn = trialForm.querySelector('.btn-brass');
function sendTrial() {
btn.textContent = 'Sending...';
btn.disabled = true;
fetch('https://n8n.odoo4projects.com/webhook/ODOO4projects/trial', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(UTM.inject({ email: email, location: location }))
}).then(function () {
if (window.plausible) window.plausible('Trial');
trialForm.style.display = 'none';
document.getElementById('trialSuccess').classList.add('visible');
}).catch(function () {
btn.textContent = 'Send';
btn.disabled = false;
var errorMsg = document.createElement('div');
errorMsg.className = 'trial-error visible';
errorMsg.textContent = 'Something went wrong. Please try again.';
trialForm.appendChild(errorMsg);
setTimeout(function () { errorMsg.classList.remove('visible'); }, 3000);
});
}
/* When the consent gate is present it fires the Google Ads trial conversion and runs
sendTrial from the tag's callback, with a hard timeout that runs it anyway — so a
blocked or slow tag can never swallow the trial. Without the gate, send directly. */
if (window.o4pAds && typeof window.o4pAds.trialConversion === 'function') {
window.o4pAds.trialConversion(sendTrial);
} else {
sendTrial();
}
});
}
})();
/* ---- Buy Now — POST the basket straight to the shop, open checkout in a new tab ---- */
window.buyNow = function (productId) {
var locSelect = document.getElementById('pricingServerLocation');
var location = locSelect ? locSelect.value : '';
var verSelect = document.getElementById('pricingOdooVersion');
var version = verSelect ? verSelect.value : '';
var form = document.createElement('form');
form.method = 'POST';
form.action = 'https://20.odoo4projects.com/shop/post_basket';
form.target = '_blank';
form.style.display = 'none';
var basket = document.createElement('input');
basket.type = 'hidden';
basket.name = 'basket';
var item = { product_id: productId, qty: 1 };
var info = location ? location.replace('.odoo4projects.com', '') : '';
if (version) { info += (info ? '|' : '') + version; }
if (info) { item.info = info; }
basket.value = JSON.stringify([item]);
form.appendChild(basket);
var clear = document.createElement('input');
clear.type = 'hidden';
clear.name = 'clear';
clear.value = '1';
form.appendChild(clear);
UTM.appendToForm(form);
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
};
/* ---- Carry the chosen Server Location + Odoo version into the native buy forms ----
* The Black Friday buy buttons are plain form POSTs to /shop/post_basket, so they work with
* JavaScript off. buyNow() builds `info` for the JS buttons; this does exactly the same for
* the native forms, on submit, from the one global selector pair (#pricingServerLocation +
* #pricingOdooVersion). Additive only: with JavaScript off the basket posts exactly as before
* (no info) and the selector is empty anyway because its options are built here. The POST is
* untouched — only the form's own hidden `basket` input is rewritten. */
(function () {
var forms = document.querySelectorAll('form[action*="/shop/post_basket"]');
forms.forEach(function (form) {
form.addEventListener('submit', function () {
var basket = form.querySelector('input[name="basket"]');
if (!basket) return;
var locSelect = document.getElementById('pricingServerLocation');
var verSelect = document.getElementById('pricingOdooVersion');
var location = locSelect ? locSelect.value : '';
var version = verSelect ? verSelect.value : '';
var info = location ? location.replace('.odoo4projects.com', '') : '';
if (version) { info += (info ? '|' : '') + version; }
if (!info) return;
try {
var items = JSON.parse(basket.value);
if (!Array.isArray(items)) return;
items.forEach(function (it) { if (it) { it.info = info; } });
basket.value = JSON.stringify(items);
} catch (e) { /* leave the basket untouched rather than break the purchase */ }
});
});
})();
/* ---- Buy buttons + Custom Quote modal ---- */
(function () {
document.querySelectorAll('.btn-buy').forEach(function (btn) {
btn.addEventListener('click', function () {
var id = this.getAttribute('data-id');
if (id) buyNow(parseInt(id, 10));
});
});
function openCustomQuoteModal() {
document.getElementById('customQuotePanel').style.display = 'block';
document.getElementById('customQuoteModal').style.display = 'flex';
}
function closeCustomQuoteModal() {
document.getElementById('customQuoteModal').style.display = 'none';
document.getElementById('customQuoteForm').reset();
document.getElementById('customQuoteSuccess').style.display = 'none';
}
window.openCustomQuoteModal = openCustomQuoteModal;
window.closeCustomQuoteModal = closeCustomQuoteModal;
document.querySelectorAll('.btn-empire').forEach(function (btn) {
btn.addEventListener('click', function () { openCustomQuoteModal(); });
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
var modal = document.getElementById('customQuoteModal');
if (modal && modal.style.display === 'flex') { closeCustomQuoteModal(); }
}
});
var customQuoteForm = document.getElementById('customQuoteForm');
if (customQuoteForm) {
customQuoteForm.addEventListener('submit', function (e) {
e.preventDefault();
var name = document.getElementById('cqName').value.trim();
var email = document.getElementById('cqEmail').value.trim();
var requirements = document.getElementById('cqRequirements').value.trim();
if (!name || !email || !requirements) return;
var submitBtn = customQuoteForm.querySelector('.btn-lg');
submitBtn.textContent = 'Sending...';
submitBtn.disabled = true;
fetch('https://n8n.odoo4projects.com/webhook/ODOO4projects/crm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(UTM.inject({ name: name, email: email, requirements: requirements, area: 'quote' }))
}).then(function (r) {
if (!r.ok) throw new Error('bad status ' + r.status);
if (window.plausible) window.plausible('Custom Quote');
customQuoteForm.style.display = 'none';
document.getElementById('customQuoteSuccess').style.display = 'block';
submitBtn.textContent = 'Submit Request';
submitBtn.disabled = false;
}).catch(function () {
submitBtn.textContent = 'Submit Request';
submitBtn.disabled = false;
var errorMsg = document.createElement('div');
errorMsg.style.marginTop = '1rem';
errorMsg.style.color = '#dc3545';
errorMsg.textContent = 'Something went wrong. Please try again.';
customQuoteForm.appendChild(errorMsg);
setTimeout(function () { if (errorMsg.parentNode) errorMsg.parentNode.removeChild(errorMsg); }, 3000);
});
});
}
})();
/* ---- Booking link — fire the Google Ads booking conversion on the click, through the same
consent gate as the buy and trial conversions. The link is a plain <a target="_blank">, so
the click is never preventDefault-ed: the Google appointment page opens regardless, and a
blocked, slow or absent tag merely skips the event. Fires nothing without consent. ---- */
(function () {
var link = document.getElementById('bookingCTA');
if (!link) return;
link.addEventListener('click', function () {
if (window.o4pAds && typeof window.o4pAds.bookConversion === 'function') {
window.o4pAds.bookConversion();
}
});
})();
/* ---- Footer contact form ---- */
(function () {
var form = document.getElementById('footerContactForm');
if (!form) return;
var emailInput = document.getElementById('footerContactEmail');
var textInput = document.getElementById('footerContactText');
var submitBtn = form.querySelector('.footer-send-btn');
var status = document.getElementById('footerFormStatus');
function showStatus(message, isError) {
status.textContent = message;
status.classList.toggle('error', !!isError);
status.classList.add('visible');
}
form.addEventListener('submit', function (e) {
e.preventDefault();
var email = emailInput.value.trim();
var text = textInput.value.trim();
if (!email || !text) return;
submitBtn.disabled = true;
status.classList.remove('visible');
fetch('https://n8n.odoo4projects.com/webhook/ODOO4projects/crm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(UTM.inject({ email: email, requirements: text, area: 'message' }))
}).then(function (r) {
if (!r.ok) throw new Error('bad status ' + r.status);
form.reset();
submitBtn.disabled = false;
showStatus("Thanks! We'll get back to you shortly.", false);
}).catch(function () {
submitBtn.disabled = false;
showStatus('Something went wrong. Please try again.', true);
});
});
})();
/* ---- Scroll-triggered reveal (same grids as the homepage, minus blog / project-plan) ---- */
(function () {
if (!window.gsap || !('IntersectionObserver' in window)) return;
var animated = {};
var observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
var grid = entry.target;
if (!grid || !grid.children || grid.children.length === 0) return;
var key = grid.className || 'anon';
if (animated[key]) return;
animated[key] = true;
gsap.fromTo(grid.children, { y: 30, opacity: 0 }, {
y: 0, opacity: 1, duration: 0.7, ease: 'power2.out', stagger: 0.08, overwrite: 'auto'
});
});
}, { threshold: 0.1 });
var grids = document.querySelectorAll('.features-grid, .faq-grid, .pricing-grid');
grids.forEach(function (g) {
if (g.children.length > 0) { gsap.set(g.children, { y: 30, opacity: 0 }); }
observer.observe(g);
});
})();
/* ---- Black Friday time remaining (Fix 10) ----
ONE documented constant: the instant the offer ends — 30 November 2026, end of day, in the
account's US/Eastern context (EST, UTC-5, after the 1 Nov DST change). The day count is
computed client-side from this instant on every page load; it is NEVER hard-coded, so it
stays correct as the date changes.
Fail-safe: the HTML carries the static sentence "Ends Monday 30 November 2026." with an empty
span beside it, so with JavaScript off the deadline still reads correctly on its own. Once the
end instant has passed the text switches to the past tense — it never keeps claiming time
remains. Nothing here animates visibility. */
(function () {
var BF_END = new Date('2026-11-30T23:59:59-05:00');
var DAY = 86400000;
var nodes = document.querySelectorAll('[data-bf-days]');
if (!nodes.length) return;
/* Whole calendar days in US/Eastern, so "days left" is a date difference and not a 24-hour
window (the offer ends at the end of a day, not 28 x 24h from now). */
function easternDayNumber(d) {
var parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit'
}).format(d).split('-');
return Date.UTC(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10)) / DAY;
}
var daysLeft = easternDayNumber(BF_END) - easternDayNumber(new Date());
var text;
if (daysLeft > 1) {
text = daysLeft + ' days left';
} else if (daysLeft === 1) {
text = '1 day left';
} else if (daysLeft === 0) {
text = 'last day today';
} else {
text = 'this offer has ended';
}
nodes.forEach(function (n) { n.textContent = text; });
})();
})();
+137
View File
@@ -0,0 +1,137 @@
/* bootcamp-availability.js — live remaining-seat count for the bootcamp cohort.
*
* Source of truth: the availability webhook (product 17 = Bootcamp Q1-2027).
* Progressive enhancement, same spirit as buy.js — it only ever ADDS, and it can
* never block the buy:
*
* fetch OK, seats > 0 -> fill the [data-bc-seats] line with "<n> of 6 seats available"
* in the page's own language; Buy stays enabled.
* fetch OK, seats == 0 -> say the cohort is full, point at the next one, and disable
* the Buy buttons (we must not sell a full cohort).
* anything else -> DO NOTHING. Timeout, network error, CORS, non-200 or an
* unexpected payload leave the page exactly as it is: the static
* cap text shows and the Buy buttons stay enabled. A
* conversion-critical element must not fail closed.
*
* The count is live and authoritative, so it is never written into the HTML as a fixed
* string — the [data-bc-seats] element ships empty and hidden.
*/
(function () {
'use strict';
var ENDPOINT = 'https://n8n.odoo4projects.com/webhook/ODOO4projects/available';
var PRODUCT_ID = 17;
var CAP = 6; /* the page's own cohort cap ("Limited to 6 participants") */
var TIMEOUT_MS = 6000;
/* The page's own words and language. "seat" = seat / Platz / plaza, as used elsewhere
on the same page ("A seat can be transferred" / "Ein Platz ist übertragbar" /
"Una plaza se puede transferir"). */
var TEXT = {
en: {
available: function (n) { return n + ' of ' + CAP + ' seats available'; },
full: 'This cohort is full.',
next: 'Talk to us about the next cohort',
href: '/support.html#contact'
},
de: {
available: function (n) { return n + ' von ' + CAP + ' Plätzen frei'; },
full: 'Dieser Kurs ist ausgebucht.',
next: 'Sprechen Sie mit uns über den nächsten Kurs',
href: '/de/support.html#contact'
},
es: {
available: function (n) { return n + ' de ' + CAP + ' plazas disponibles'; },
full: 'Este grupo está completo.',
next: 'Hable con nosotros sobre el próximo grupo',
href: '/es/support.html#contact'
}
};
function lang() {
var l = (document.documentElement.getAttribute('lang') || 'en').toLowerCase();
if (l.indexOf('de') === 0) return 'de';
if (l.indexOf('es') === 0) return 'es';
return 'en';
}
function renderAvailable(el, n, t) {
el.textContent = '';
var strong = document.createElement('strong');
strong.textContent = t.available(n);
el.appendChild(strong);
el.hidden = false;
}
function renderFull(el, t) {
el.textContent = '';
var strong = document.createElement('strong');
strong.textContent = t.full;
el.appendChild(strong);
el.appendChild(document.createTextNode(' '));
var a = document.createElement('a');
a.href = t.href;
a.textContent = t.next;
el.appendChild(a);
el.hidden = false;
disableBuy();
}
function disableBuy() {
var buttons = document.querySelectorAll('form[action$="/shop/post_basket"] button[type="submit"]');
for (var i = 0; i < buttons.length; i++) {
buttons[i].disabled = true;
buttons[i].setAttribute('aria-disabled', 'true');
}
}
/* Unwrap [{data:[...]}] or {data:[...]} and read product 17's qty_available.
Returns a finite number >= 0, or null if the shape is not what we expect. */
function seatsFrom(json) {
var obj = json;
if (Array.isArray(obj)) obj = obj[0];
if (!obj || !Array.isArray(obj.data)) return null;
for (var i = 0; i < obj.data.length; i++) {
var row = obj.data[i];
if (row && Number(row.id) === PRODUCT_ID) {
var q = Number(row.qty_available);
return (isFinite(q) && q >= 0) ? q : null;
}
}
return null;
}
function run() {
var el = document.querySelector('[data-bc-seats]');
if (!el) return;
if (!window.fetch || typeof AbortController === 'undefined') return; /* keep fallback */
var t = TEXT[lang()];
var ctrl = new AbortController();
var timer = setTimeout(function () { ctrl.abort(); }, TIMEOUT_MS);
fetch(ENDPOINT, {
signal: ctrl.signal,
headers: { 'Accept': 'application/json' },
cache: 'no-store'
})
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(function (json) {
var n = seatsFrom(json);
if (n === null) return; /* unexpected payload -> keep fallback */
if (n === 0) renderFull(el, t);
else renderAvailable(el, n, t);
})
.catch(function () { /* keep fallback; Buy stays enabled */ })
.then(function () { clearTimeout(timer); });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run);
} else {
run();
}
})();
+186
View File
@@ -0,0 +1,186 @@
/* bootcamp-waitlist.js — page-local waiting-list form for the three bootcamp pages.
*
* The user's rule: "You want another timezone / language? Join the waiting list."
* The form posts to the same n8n CRM webhook the site's other forms use, with the
* site's own footer-message payload shape — area:'message', no `name` (that belongs to
* the `area:'quote'` shape of the Custom Quote modal).
*
* This page does not load /script.js, so the UTM helpers are copied verbatim from it
* (same as /static/black-friday.js does). If script.js's helpers change, change these.
*
* All copy — labels, placeholders, the "sending" label, success and error text — lives in
* the page markup, in the page's own language. This file carries no strings.
*
* A 200 from the webhook means the workflow accepted the request, not that a CRM record
* exists (the workflow answers before it runs) — same as the site's other forms.
*/
var ENDPOINT = 'https://n8n.odoo4projects.com/webhook/ODOO4projects/crm';
(function () {
'use strict';
/* ---- UTM tracking helpers (verbatim from /script.js) ---- */
function getCookie(name) {
var match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
return match ? decodeURIComponent(match[2]) : null;
}
function setCookie(name, value, days) {
var expires = '';
if (days) {
var date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
expires = '; expires=' + date.toUTCString();
}
document.cookie = name + '=' + encodeURIComponent(value) + expires + '; path=/; SameSite=Lax';
}
function parseUTM() {
var urlParams = new URLSearchParams(window.location.search);
var utm = {};
var keys = ['source', 'medium', 'campaign'];
for (var i = 0; i < keys.length; i++) {
var val = urlParams.get('utm_' + keys[i]);
if (val) {
utm[keys[i]] = val;
setCookie('utm_' + keys[i], val, 90);
}
}
return utm;
}
function getStoredUTM() {
var utm = {};
var keys = ['source', 'medium', 'campaign'];
for (var i = 0; i < keys.length; i++) {
var val = getCookie('utm_' + keys[i]);
if (val) utm[keys[i]] = val;
}
return utm;
}
function injectUTM(data) {
var stored = getStoredUTM();
if (Object.keys(stored).length > 0) {
data.utm_source = stored.source || null;
data.utm_medium = stored.medium || null;
data.utm_campaign = stored.campaign || null;
}
return data;
}
window.UTM = { parse: parseUTM, getStored: getStoredUTM, inject: injectUTM };
window.UTM.parse();
/* ---- Waiting list ---- */
function run() {
var form = document.getElementById('bcWaitlistForm');
if (!form) return;
var emailInput = document.getElementById('bcWaitlistEmail');
var commentInput = document.getElementById('bcWaitlistComment');
var submitBtn = form.querySelector('button[type="submit"]');
var success = document.getElementById('bcWaitlistSuccess');
var error = document.getElementById('bcWaitlistError');
var label = submitBtn ? submitBtn.textContent : '';
/* Both fields are required, exactly as the footer form requires both. */
form.addEventListener('submit', function (e) {
e.preventDefault();
var email = emailInput.value.trim();
var comment = commentInput.value.trim();
if (!email || !comment) return;
error.style.display = 'none';
submitBtn.textContent = submitBtn.getAttribute('data-sending') || label;
submitBtn.disabled = true;
fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(UTM.inject({
email: email,
requirements: comment,
area: 'message'
}))
}).then(function (r) {
if (!r.ok) throw new Error('bad status ' + r.status);
if (window.plausible) window.plausible('Bootcamp Waitlist');
form.style.display = 'none';
success.style.display = 'block';
}).catch(function () {
submitBtn.textContent = label;
submitBtn.disabled = false;
error.style.display = 'block';
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run);
} else {
run();
}
})();
/* ---- Footer contact form ------------------------------------------------------------
* These pages do NOT load /script.js, so its footer-form handler does not run here. This
* mirrors /script.js's handler one-for-one — same webhook, same payload shape
* ({ email, requirements, area:'message' } wrapped in UTM.inject), same success/failure
* behaviour — but reads its two strings from the form's own data-ok / data-err attributes
* so the message is in the page's language. Without it a submit would navigate the visitor
* to the webhook's raw JSON reply.
*/
(function () {
'use strict';
function runFooter() {
var form = document.getElementById('footerContactForm');
if (!form) return;
var emailInput = document.getElementById('footerContactEmail');
var textInput = document.getElementById('footerContactText');
var submitBtn = form.querySelector('.footer-send-btn');
var status = document.getElementById('footerFormStatus');
var okText = form.getAttribute('data-ok') || "Thanks! We'll get back to you shortly.";
var errText = form.getAttribute('data-err') || 'Something went wrong. Please try again.';
function showStatus(message, isError) {
status.textContent = message;
status.classList.toggle('error', !!isError);
status.classList.add('visible');
}
form.addEventListener('submit', function (e) {
e.preventDefault();
var email = emailInput.value.trim();
var text = textInput.value.trim();
if (!email || !text) return;
submitBtn.disabled = true;
status.classList.remove('visible');
fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(window.UTM.inject({
email: email,
requirements: text,
area: 'message'
}))
}).then(function (r) {
if (!r.ok) throw new Error('bad status ' + r.status);
form.reset();
submitBtn.disabled = false;
showStatus(okText, false);
}).catch(function () {
submitBtn.disabled = false;
showStatus(errText, true);
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', runFooter);
} else {
runFooter();
}
})();
+1 -1
View File
@@ -1,7 +1,7 @@
/* buy.js — page-local checkout helper (progressive enhancement). /* buy.js — page-local checkout helper (progressive enhancement).
* *
* The checkout itself is a native <form method="post" target="_blank"> that posts * The checkout itself is a native <form method="post" target="_blank"> that posts
* basket=[{"product_id":12,"qty":1}] + clear=1 to * basket=[{"product_id":17,"qty":1}] + clear=1 to
* https://20.odoo4projects.com/shop/post_basket. With JavaScript off it still works. * https://20.odoo4projects.com/shop/post_basket. With JavaScript off it still works.
* *
* This file only adds attribution: on submit it appends the utm_* hidden inputs * This file only adds attribution: on submit it appends the utm_* hidden inputs
+110
View File
@@ -0,0 +1,110 @@
/* The freelancer PHOTO — one endpoint, one constant, one fetch.
The photo does NOT live in the profile payload any more. It used to ride there as
inline base64 under the key `Image`; the webhook no longer sends that key, so the
payload is small and the photo is fetched separately, per card, by record id.
ONE constant holds BOTH halves of the address: the route and the query-parameter
name. Changing either is a one-line edit here, and both pages pick it up.
The parameter name is `id`, CONFIRMED by measurement against the live route:
`?id=<n>` answers 200 with the photo for that record, while `/4`, `?profile_id=`,
`?image_id=` and a missing id all fail.
THE ENDPOINT RETURNS JSON, NOT IMAGE BYTES. Measured on the live route:
GET …/odoo_experts_image?id=10 → 200 application/json
body: {"Image":"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ…"}
So a plain `<img src="…?id=N">` receives JSON, cannot decode it and fires `onerror`
— the monogram. That is why the card FETCHES the route, reads `Image` and puts the
DATA URI on an `<img>` only when it passes the `isImageSrc` allowlist. n8n sometimes
answers an array instead of an object, so both shapes are accepted.
`load(id)` resolves to a usable image source, or `null` on ANY failure — a non-2xx, an
empty `{}`, a missing/blank `Image`, a value that fails `isImageSrc`, or a request that
does not answer in `timeoutMs`. A `null` answer means "show the monogram": the caller
never sees a broken image. The decoded URI is cached by id (and a failure is remembered
too), so a re-render can never re-fetch a photo already in memory.
THE TIMEOUT. A request that neither succeeds nor errors leaves the fallback unable to
fire (measured live: an <img> pointed at this route sat `complete=false · naturalWidth=0`
for 20 s). Each fetch is therefore bounded by `timeoutMs` (default 8000): an
`AbortController` is aborted by a `setTimeout`, the abort rejects the fetch, and the
rejection lands in the same catch that returns `null` — the monogram. The timer is
cleared on every completion, so a fast answer never pays for the guard. `AbortController`
is universal in the browsers this site targets (it already needs `fetch`, `Promise` and
`IntersectionObserver`); if it were missing there is nothing to abort with, but the
abort is what turns a hang into the fallback, so it is the one capability worth naming.
The route answers with `Access-Control-Allow-Origin` echoing the caller and
`Cache-Control: public, max-age=3600`, so the browser may also serve a repeat from its
own HTTP cache. */
window.O4PExpertImage = {
endpoint: "https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_image",
idParam: "id",
// A photo request that hangs is a card with no photo AND no fallback: bound it.
timeoutMs: 8000,
/* The ONLY allowlist for an image source in this codebase — `https://` or a
`data:image/` URI. Both forms are what this endpoint has served across its life. */
isImageSrc: function (v) {
return typeof v === "string" &&
(v.indexOf("https://") === 0 || v.indexOf("data:image/") === 0);
},
url: function (id) {
return this.endpoint + "?" + this.idParam + "=" + encodeURIComponent(id);
},
// id -> decoded image source (or "" when the record has no usable photo)
cache: Object.create(null),
// id -> the in-flight Promise, so two observers of one id share ONE request
inflight: Object.create(null),
/* Fetch the photo for `id`. Resolves to an image source, or null when there is none.
Never rejects — a caller only has to check for a value. Every request is bounded by
`timeoutMs`: on expiry the fetch is ABORTED (not merely abandoned) and the rejection
resolves null, so a hung endpoint becomes the monogram instead of an empty box. */
load: function (id) {
var self = this;
var key = String(id);
if (key in self.cache) return Promise.resolve(self.cache[key] || null);
if (self.inflight[key]) return self.inflight[key];
var ctrl = (typeof AbortController !== "undefined") ? new AbortController() : null;
var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, self.timeoutMs) : 0;
var req = fetch(self.url(key), {
headers: { Accept: "application/json" },
signal: ctrl ? ctrl.signal : undefined
})
.then(function (res) {
if (!res.ok) throw new Error("HTTP " + res.status);
return res.json();
})
.then(function (body) {
// n8n sometimes wraps the record in an array — accept both shapes.
var d = Array.isArray(body) ? body[0] : body;
var img = d && d.Image;
if (!self.isImageSrc(img)) throw new Error("no image");
self.cache[key] = img;
return img;
})
.catch(function () {
// Every failure — non-2xx, no image, a decode-hostile value, or the timeout's
// abort — is the monogram. Remember it, so a shared id under a re-render does not
// ask the network twice.
self.cache[key] = "";
return null;
})
.then(function (out) {
if (timer) clearTimeout(timer);
delete self.inflight[key];
return out;
});
self.inflight[key] = req;
return req;
}
};
+22
View File
@@ -0,0 +1,22 @@
/* The ONE source of truth for the "skill profile" rule — the 15-point budget.
Two forms render this rule: the directory application form
(/odoo-community-developers/) and the private self-service edit form
(/freelancer-edit/). Both take the legend, the directive and the wrong-total
message from here, so the two cannot word the same rule differently again.
Loaded before each page's own script. */
(function () {
var TOTAL = 15;
window.O4PFocus = {
TOTAL: TOTAL,
MAX: 5,
LEGEND: "Skill profile — 15 points to distribute",
DIRECTIVE: "Distribute all 15 points across the five areas — they cannot all be 0.",
/* The wrong-total message, naming the direction to move. "" when the sum is right. */
message: function (sum) {
if (sum === TOTAL) return "";
if (sum === 0) return "You have 0 of " + TOTAL + " — place all " + TOTAL + " points.";
if (sum > TOTAL) return "You have " + sum + " of " + TOTAL + " — remove " + (sum - TOTAL) + ".";
return "You have " + sum + " of " + TOTAL + " — add " + (TOTAL - sum) + ".";
}
};
})();
+22
View File
@@ -0,0 +1,22 @@
# static/icons — the profile-link icon set
Four monochrome brand marks shown in the freelancer directory card's links row
(`/odoo-community-developers/`): LinkedIn, GitHub, Upwork, Fiverr.
**Source:** [Simple Icons](https://simpleicons.org) v13 — one uniform set, so the four
share one visual language by construction: the same 24 × 24 grid, the same internal
padding, the same monochrome mass, single-path art. Nothing here is hand-drawn and
nothing is fetched from a third party at runtime.
**How the page uses them.** The card inlines the same path data (see `LINK_ICONS` in the
page's script and in `work/build-directory.py`), so the links row makes **zero requests**
and each mark inherits the link's `currentColor`. These files are the self-hosted,
drop-in copy of that set — identical geometry — for any host that prefers a file over an
inline `<svg>`.
**Brand rule.** A brand mark is never redrawn and never restyled beyond a single
monochrome fill. The one styling the page applies is `fill="currentColor"`.
The `role`/`aria-label` here are for standalone use; the card strips them and marks the
inline SVG `aria-hidden="true" focusable="false"`, putting the accessible name on the
link (`aria-label` + `title`, e.g. "GitHub profile").
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Fiverr"><path fill="currentColor" d="M23.004 15.588a.995.995 0 1 0 .002-1.99.995.995 0 0 0-.002 1.99zm-.996-3.705h-.85c-.546 0-.84.41-.84 1.092v2.466h-1.61v-3.558h-.684c-.547 0-.84.41-.84 1.092v2.466h-1.61v-4.874h1.61v.74c.264-.574.626-.74 1.163-.74h1.972v.74c.264-.574.625-.74 1.162-.74h.527v1.316zm-6.786 1.501h-3.359c.088.546.43.858 1.006.858.43 0 .732-.175.83-.487l1.425.4c-.351.848-1.22 1.364-2.255 1.364-1.748 0-2.549-1.355-2.549-2.515 0-1.14.703-2.505 2.45-2.505 1.856 0 2.471 1.384 2.471 2.408 0 .224-.01.37-.02.477zm-1.562-.945c-.04-.42-.342-.81-.889-.81-.508 0-.81.225-.908.81h1.797zM7.508 15.44h1.416l1.767-4.874h-1.62l-.86 2.837-.878-2.837H5.72l1.787 4.874zm-6.6 0H2.51v-3.558h1.524v3.558h1.591v-4.874H2.51v-.302c0-.332.235-.536.606-.536h.918V8.412H2.85c-1.162 0-1.943.712-1.943 1.755v.4H0v1.316h.908v3.558z"/></svg>

After

Width:  |  Height:  |  Size: 912 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="GitHub"><path fill="currentColor" d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>

After

Width:  |  Height:  |  Size: 842 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="LinkedIn"><path fill="currentColor" d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>

After

Width:  |  Height:  |  Size: 630 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Upwork"><path fill="currentColor" d="M18.561 13.158c-1.102 0-2.135-.467-3.074-1.227l.228-1.076.008-.042c.207-1.143.849-3.06 2.839-3.06 1.492 0 2.703 1.212 2.703 2.703-.001 1.489-1.212 2.702-2.704 2.702zm0-8.14c-2.539 0-4.51 1.649-5.31 4.366-1.22-1.834-2.148-4.036-2.687-5.892H7.828v7.112c-.002 1.406-1.141 2.546-2.547 2.548-1.405-.002-2.543-1.143-2.545-2.548V3.492H0v7.112c0 2.914 2.37 5.303 5.281 5.303 2.913 0 5.283-2.389 5.283-5.303v-1.19c.529 1.107 1.182 2.229 1.974 3.221l-1.673 7.873h2.797l1.213-5.71c1.063.679 2.285 1.109 3.686 1.109 3 0 5.439-2.452 5.439-5.45 0-3-2.439-5.439-5.439-5.439z"/></svg>

After

Width:  |  Height:  |  Size: 689 B

+141
View File
@@ -0,0 +1,141 @@
/* The ONE source of truth for photo uploads — limits, validation and downscale.
Two forms call this: the directory application form (/odoo-community-developers/)
and the private self-service edit form (/freelancer-edit/). The limits live here
once, so the two cannot drift. Loaded before each page's own script.
Why this exists beyond page weight: re-encoding to a canvas DROPS EXIF entirely.
A phone photo carries GPS coordinates, so an un-stripped portrait publishes the
photographer's home location. Re-encoding is also a security step — a file whose
declared type was spoofed fails to decode and is refused here.
NOTE: the <input accept> attribute is a picker HINT, never validation. Only
validate() below decides, and it runs on the File object before any read. */
(function () {
var MAX_INPUT_BYTES = 8 * 1024 * 1024; // 8 MB — refuse before reading a byte
var ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
var MAX_SIDE = 384; // longest side of the stored image, px
var MAX_OUTPUT_BYTES = 80 * 1024; // 80 KB — the encoded JPEG target
var QUALITY = 0.82; // starting JPEG quality
var QUALITY_STEP = 0.05; // step down until the target is met
var QUALITY_FLOOR = 0.4; // never go below this
function kb(bytes) { return Math.round(bytes / 1024); }
function mb(bytes) { return (bytes / (1024 * 1024)).toFixed(1); }
/* Decide on the File's own metadata — no read happens here. */
function validate(file) {
if (!file) return { ok: false, reason: "No file was chosen." };
if (ALLOWED_TYPES.indexOf(file.type) === -1) {
return { ok: false, reason: "That file type is not allowed. Use a JPEG, PNG or WebP image." };
}
if (file.size > MAX_INPUT_BYTES) {
return { ok: false, reason: "That photo is " + mb(file.size) + " MB. The limit is 8 MB — please choose a smaller one." };
}
return { ok: true };
}
function readAsDataUrl(file) {
return new Promise(function (resolve, reject) {
var reader = new FileReader();
reader.onload = function () { resolve(reader.result); };
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function loadImage(url) {
return new Promise(function (resolve, reject) {
var img = new Image();
img.onload = function () { resolve(img); };
img.onerror = reject; // a spoofed "image/jpeg" that is not an image lands here
img.src = url;
});
}
function byteLength(dataUrl) {
return atob(dataUrl.slice(dataUrl.indexOf(",") + 1)).length;
}
/* Read → centre-crop square → cap longest side → encode JPEG → step quality down
until it fits. Resolves { dataUrl, width, height, bytes, quality }. */
function downscale(file) {
return readAsDataUrl(file).then(loadImage).then(function (img) {
var w = img.naturalWidth, h = img.naturalHeight;
var side = Math.min(w, h);
var sx = (w - side) / 2, sy = (h - side) / 2;
var out = Math.min(MAX_SIDE, side);
var canvas = document.createElement("canvas");
canvas.width = out;
canvas.height = out;
canvas.getContext("2d").drawImage(img, sx, sy, side, side, 0, 0, out, out);
var quality = QUALITY;
var dataUrl = canvas.toDataURL("image/jpeg", quality);
var bytes = byteLength(dataUrl);
while (bytes > MAX_OUTPUT_BYTES && quality > QUALITY_FLOOR) {
quality = Math.max(QUALITY_FLOOR, quality - QUALITY_STEP);
dataUrl = canvas.toDataURL("image/jpeg", quality);
bytes = byteLength(dataUrl);
}
return { dataUrl: dataUrl, width: out, height: out, bytes: bytes, quality: quality };
});
}
/* The one honest line the person sees. No "optimised for the web" — only what we can show. */
function resultLine(res) {
return "Resized to " + res.width + " × " + res.height + ", " + kb(res.bytes) + " KB";
}
/* Wire one file input to a preview <img> and a status line. onResult receives the
downscaled data URL, or null when there is no (valid) file. Both forms use this,
so the outcome shown is identical. */
function wire(input, preview, result, onResult) {
if (!input) return;
function say(text, state) {
if (!result) return;
result.textContent = text || "";
if (state) result.setAttribute("data-state", state);
else result.removeAttribute("data-state");
}
input.addEventListener("change", function () {
var file = input.files && input.files[0];
if (!file) {
if (preview) preview.hidden = true;
say("", null);
onResult(null);
return;
}
var v = validate(file);
if (!v.ok) {
if (preview) preview.hidden = true;
input.value = ""; // a refused file must never be submitted
say(v.reason, "err");
onResult(null);
return;
}
say("Processing…", null);
downscale(file).then(function (res) {
if (preview) { preview.src = res.dataUrl; preview.hidden = false; }
say(resultLine(res), "ok");
onResult(res.dataUrl, res);
}).catch(function () {
if (preview) preview.hidden = true;
input.value = "";
say("That file could not be read as an image. Please choose a JPEG, PNG or WebP.", "err");
onResult(null);
});
});
}
window.O4PPhoto = {
MAX_INPUT_BYTES: MAX_INPUT_BYTES,
ALLOWED_TYPES: ALLOWED_TYPES,
MAX_SIDE: MAX_SIDE,
MAX_OUTPUT_BYTES: MAX_OUTPUT_BYTES,
QUALITY: QUALITY,
validate: validate,
downscale: downscale,
resultLine: resultLine,
kb: kb,
wire: wire
};
})();
+13
View File
@@ -6,6 +6,19 @@
========================================================= */ ========================================================= */
* { box-sizing: border-box; } * { box-sizing: border-box; }
/* `hidden` must really hide — everywhere, not one element at a time. The UA's own
`[hidden] { display: none }` is the weakest possible declaration: any page rule that
sets `display` on the same element outranks it. A class rule (`.dir-pager`,
`.dev-card`, `.hero-notice`) already beats it, and an id-based state selector such as
`.js #directory[data-dir-state="ready"] #dirGrid > .dev-card` (two ids) beat the old
per-element patch — so setting `el.hidden = true` set the attribute and the element
stayed on screen (measured on /odoo-community-developers/: four cards visible, the
count line reading "Showing 1 of 4"). This one rule, with `!important`, beats every
class- or id-based `display`, so the attribute hides its element no matter which
later rule sets `display`. It is the single mechanism for `[hidden]` site-wide; the
per-element `[hidden]` rules are gone so nothing disagrees. */
[hidden] { display: none !important; }
body { body {
margin: 0; margin: 0;
font-family: Inter, 'Segoe UI', sans-serif; font-family: Inter, 'Segoe UI', sans-serif;
+8 -3
View File
@@ -29,7 +29,7 @@
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/> />
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css?v=061517baef" />
<style> <style>
/* Language selector — the page's top row: the back-link stays left, the selector travels right. /* Language selector — the page's top row: the back-link stays left, the selector travels right.
Cream canvas, so lang.js's own dark-brass active colour is the correct one (no override). */ Cream canvas, so lang.js's own dark-brass active colour is the correct one (no override). */
@@ -274,9 +274,10 @@
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}}; window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init() plausible.init()
</script> </script>
<script defer src="/static/lang.js"></script> <script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head> </head>
<body> <body>
<main>
<div class="container"> <div class="container">
<div class="blog-header"> <div class="blog-header">
<a href="index.html">&larr; Back to Home</a> <a href="index.html">&larr; Back to Home</a>
@@ -292,6 +293,8 @@
For the fastest help, please read our <strong>FAQ</strong> first — our support For the fastest help, please read our <strong>FAQ</strong> first — our support
bot can assist with quick fixes like <strong>git pull</strong> or restarts — or bot can assist with quick fixes like <strong>git pull</strong> or restarts — or
simply send a <strong>ticket</strong> to our support team for further assistance. simply send a <strong>ticket</strong> to our support team for further assistance.
Urgent topics can be handled by the <strong>AI agent</strong> we offer, and email
support answers within <strong>24 hours</strong>.
</p> </p>
</div> </div>
</section> </section>
@@ -439,6 +442,7 @@
</button> </button>
<div class="faq-answer"> <div class="faq-answer">
<p>If you've pushed a new module to the server and it prevents the service from starting, first revert your changes in Git and push the corrected version back to the server. Then open the support chat — you'll need your UUID (sent in the confirmation email). After you tell our chatbot to git pull and restart the server, you'll receive a confirmation email; once confirmed, we'll bring your Odoo instance back online.</p> <p>If you've pushed a new module to the server and it prevents the service from starting, first revert your changes in Git and push the corrected version back to the server. Then open the support chat — you'll need your UUID (sent in the confirmation email). After you tell our chatbot to git pull and restart the server, you'll receive a confirmation email; once confirmed, we'll bring your Odoo instance back online.</p>
<p>Before you check in a change like this, read <a href="/odoo-staging-environment/">test it safely first</a>: a staging environment is what stops a bad check-in taking your server down, and most small businesses do not need one.</p>
</div> </div>
</div> </div>
<div class="faq-item"> <div class="faq-item">
@@ -585,6 +589,7 @@
}); });
</script> </script>
<script defer src="/static/agent-odoo4projects/agent.js"></script> </main>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body> </body>
</html> </html>
+8 -6
View File
@@ -25,7 +25,7 @@
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/> />
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css?v=061517baef" />
<style> <style>
.legal-hero { .legal-hero {
padding: 4rem 0 2.5rem; padding: 4rem 0 2.5rem;
@@ -111,6 +111,7 @@
</script> </script>
</head> </head>
<body> <body>
<main>
<div class="container"> <div class="container">
<div class="blog-header"> <div class="blog-header">
<a href="index.html">&larr; Back to Home</a> <a href="index.html">&larr; Back to Home</a>
@@ -130,7 +131,7 @@
<div class="container"> <div class="container">
<div class="legal-card"> <div class="legal-card">
<h2>Who we are</h2> <h2>Who we are</h2>
<p>ODOO4projects LLC, 30 N Gould St, STE R, Sheridan, WY 82801, USA. Contact: <span class="contact-email">support@ODOO4projects.com</span></p> <p>ODOO4projects LLC, 30 N Gould St, STE R, Sheridan, WY 82801, USA. Contact: <span class="contact-email">support@odoo4projects.com</span></p>
<p>These terms cover the hosting service we sell on this website.</p> <p>These terms cover the hosting service we sell on this website.</p>
</div> </div>
@@ -153,17 +154,17 @@
<div class="legal-card"> <div class="legal-card">
<h2>Price and payment</h2> <h2>Price and payment</h2>
<p>Plans are billed yearly, in advance, in US dollars, plus tax where it applies. Payment is taken by card through Stripe. The current prices are the ones on the pricing page.</p> <p>Plans are billed yearly, in advance, in US dollars, tax included &mdash; the price you see is the price you pay. Payment is taken by card through Stripe. The current prices are the ones on the pricing page.</p>
</div> </div>
<div class="legal-card"> <div class="legal-card">
<h2>Cancelling</h2> <h2>Cancelling</h2>
<p>You can cancel at any time. Cancelling stops the next renewal. It does not refund the period you have already paid for. We make no refund promise beyond what is written here.</p> <p>You can cancel at any time. Cancelling stops the next renewal, and we refund the time you have not used &mdash; the part of the period you have paid for that is still ahead of you, pro rata against the period you paid for.</p>
</div> </div>
<div class="legal-card"> <div class="legal-card">
<h2>Version upgrades and migrations</h2> <h2>Version upgrades and migrations</h2>
<p>Ask our support team for a version upgrade. We migrate databases on the main Odoo releases 17, 18 and 19. We do not support patch releases such as 18.1.</p> <p>Updates and upgrades within your current Odoo version are included in your plan. Moving a database to a new major Odoo version is a migration: it is a separate service and is quoted separately, so ask our support team for a quote. Our migration service covers databases on the main Odoo releases 17, 18 and 19. New instances can run Odoo 20, the current release. We do not support patch releases such as 18.1.</p>
</div> </div>
<div class="legal-card"> <div class="legal-card">
@@ -193,6 +194,7 @@
</div> </div>
</section> </section>
<script defer src="/static/agent-odoo4projects/agent.js"></script> </main>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body> </body>
</html> </html>
+7 -5
View File
@@ -27,8 +27,7 @@
onload="this.media='all'" onload="this.media='all'"
/> />
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript> <noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="stylesheet" href="/style.css" media="print" onload="this.media='all'" /> <link rel="stylesheet" href="/style.css?v=061517baef" />
<noscript><link rel="stylesheet" href="/style.css" /></noscript>
<style> <style>
/* ---- "What Can I Use Odoo For?" guide page (page-scoped; additive). /* ---- "What Can I Use Odoo For?" guide page (page-scoped; additive).
The block below is odoo-community-upgrade.html's page-scoped CSS, reused so The block below is odoo-community-upgrade.html's page-scoped CSS, reused so
@@ -518,6 +517,7 @@
</div> </div>
<!-- Hero: the question, answered in the first hundred words --> <!-- Hero: the question, answered in the first hundred words -->
<main>
<section class="hero oc-hero"> <section class="hero oc-hero">
<div class="hero-bg"> <div class="hero-bg">
<img <img
@@ -787,7 +787,7 @@
<p> <p>
<strong>If you would rather learn it by doing it,</strong> the <strong>If you would rather learn it by doing it,</strong> the
<a href="/odoo-erp-automation-bootcamp/">ODOO ERP Automation Bootcamp</a> is a ten-session live <a href="/odoo-erp-automation-bootcamp/">ODOO ERP Automation Bootcamp</a> is a ten-session live
course at USD 465.00 with a year of hosted Odoo Community included, and the two-hour course at USD 595.00 with a year of hosted Odoo Community included, and the two-hour
<a href="/odoo-n8n-masterclass/">ODOO x N8N Masterclass</a> is USD 120.00. <a href="/odoo-n8n-masterclass/">ODOO x N8N Masterclass</a> is USD 120.00.
</p> </p>
</div> </div>
@@ -904,6 +904,7 @@
<!-- Footer (copied from odoo-community-upgrade.html; icon-font glyphs replaced with inline SVG so the page <!-- Footer (copied from odoo-community-upgrade.html; icon-font glyphs replaced with inline SVG so the page
carries no external icon dependency) --> carries no external icon dependency) -->
</main>
<footer class="footer"> <footer class="footer">
<div class="container"> <div class="container">
<div class="fgrid"> <div class="fgrid">
@@ -912,6 +913,7 @@
<ul> <ul>
<li><a href="/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li> <li><a href="/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li>
<li><a href="/odoo-hosting-2027.html">ODOO Hosting in 2027</a></li> <li><a href="/odoo-hosting-2027.html">ODOO Hosting in 2027</a></li>
<li><a href="/odoo-community-developers/">Odoo Freelancer Directory</a></li>
<li><a href="/support.html">Support</a></li> <li><a href="/support.html">Support</a></li>
<li><a href="/legal.html">Legal</a></li> <li><a href="/legal.html">Legal</a></li>
</ul> </ul>
@@ -935,7 +937,7 @@
<a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a> <a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a>
<a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a> <a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a>
</div> </div>
<form class="footer-contact-form" id="footerContactForm" action="mailto:hello@odoo4projects.com" method="post" enctype="text/plain"> <form class="footer-contact-form" id="footerContactForm" action="mailto:support@odoo4projects.com" method="post" enctype="text/plain">
<label class="oc-sr-only" for="footerContactEmail">Your email address</label> <label class="oc-sr-only" for="footerContactEmail">Your email address</label>
<input type="email" id="footerContactEmail" name="email" placeholder="Your email address" required /> <input type="email" id="footerContactEmail" name="email" placeholder="Your email address" required />
<div class="footer-textarea-wrap"> <div class="footer-textarea-wrap">
@@ -950,6 +952,6 @@
<div class="fcopy">Copyright &copy; ODOO4projects LLC</div> <div class="fcopy">Copyright &copy; ODOO4projects LLC</div>
</div> </div>
</footer> </footer>
<script defer src="/static/lang.js"></script> <script defer src="/static/lang.js?v=3ec49a7e62"></script>
</body> </body>
</html> </html>
+11 -9
View File
@@ -6,13 +6,13 @@
<title>ODOO Workshops and Classes: Live Courses from USD 120</title> <title>ODOO Workshops and Classes: Live Courses from USD 120</title>
<meta <meta
name="description" name="description"
content="Two live courses with Oliver Arnold: the two-hour ODOO x N8N Masterclass at USD 120.00 and the ten-session ODOO ERP Automation Bootcamp at USD 465.00." content="Two live courses with Oliver Arnold: the two-hour ODOO x N8N Masterclass at USD 120.00 and the ten-session ODOO ERP Automation Bootcamp at USD 595.00."
/> />
<link rel="canonical" href="https://odoo4projects.com/workshops/" /> <link rel="canonical" href="https://odoo4projects.com/workshops/" />
<meta property="og:title" content="ODOO Workshops and Classes: Live Courses from USD 120" /> <meta property="og:title" content="ODOO Workshops and Classes: Live Courses from USD 120" />
<meta <meta
property="og:description" property="og:description"
content="Two live courses with Oliver Arnold: the two-hour ODOO x N8N Masterclass at USD 120.00 and the ten-session ODOO ERP Automation Bootcamp at USD 465.00." content="Two live courses with Oliver Arnold: the two-hour ODOO x N8N Masterclass at USD 120.00 and the ten-session ODOO ERP Automation Bootcamp at USD 595.00."
/> />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:url" content="https://odoo4projects.com/workshops/" /> <meta property="og:url" content="https://odoo4projects.com/workshops/" />
@@ -27,8 +27,7 @@
onload="this.media='all'" onload="this.media='all'"
/> />
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript> <noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="stylesheet" href="/style.css" media="print" onload="this.media='all'" /> <link rel="stylesheet" href="/style.css?v=061517baef" />
<noscript><link rel="stylesheet" href="/style.css" /></noscript>
<style> <style>
/* ---- Workshops and Classes hub page (page-scoped; additive). /* ---- Workshops and Classes hub page (page-scoped; additive).
The block below is the bootcamp page's page-scoped CSS, reused so the two The block below is the bootcamp page's page-scoped CSS, reused so the two
@@ -477,7 +476,7 @@
"provider": { "@type": "Organization", "name": "ODOO4projects", "url": "https://odoo4projects.com/" }, "provider": { "@type": "Organization", "name": "ODOO4projects", "url": "https://odoo4projects.com/" },
"instructor": { "@type": "Person", "name": "Oliver Arnold", "jobTitle": "Technical Lead" }, "instructor": { "@type": "Person", "name": "Oliver Arnold", "jobTitle": "Technical Lead" },
"inLanguage": "en", "inLanguage": "en",
"offers": { "@type": "Offer", "price": "465.00", "priceCurrency": "USD", "category": "Paid" }, "offers": { "@type": "Offer", "price": "595.00", "priceCurrency": "USD", "category": "Paid" },
"hasCourseInstance": { "@type": "CourseInstance", "courseMode": "online", "courseWorkload": "PT10H" } "hasCourseInstance": { "@type": "CourseInstance", "courseMode": "online", "courseWorkload": "PT10H" }
} }
} }
@@ -506,6 +505,7 @@
</div> </div>
<!-- Hero --> <!-- Hero -->
<main>
<section class="hero bc-hero"> <section class="hero bc-hero">
<div class="container"> <div class="container">
<p class="hero-kicker">TWO LIVE COURSES &middot; ONLINE &middot; OLIVER ARNOLD &middot; ENGLISH</p> <p class="hero-kicker">TWO LIVE COURSES &middot; ONLINE &middot; OLIVER ARNOLD &middot; ENGLISH</p>
@@ -551,7 +551,7 @@
</li> </li>
<li> <li>
<h3>ODOO ERP Automation Bootcamp</h3> <h3>ODOO ERP Automation Bootcamp</h3>
<p class="bc-card-price">USD 465.00 &middot; 10 live sessions, 1 hour each</p> <p class="bc-card-price">USD 595.00 &middot; 10 live sessions, 1 hour each</p>
<p>You leave with a working ERP for your own company: your products, your customers, your quotes, your invoices and your stock in one system you can keep running yourself.</p> <p>You leave with a working ERP for your own company: your products, your customers, your quotes, your invoices and your stock in one system you can keep running yourself.</p>
<p class="bc-card-format">Live and online, every Thursday 18:00&ndash;19:00 Central Time &middot; One year of hosted Odoo Community Edition included &middot; Limited to 6 participants &middot; English</p> <p class="bc-card-format">Live and online, every Thursday 18:00&ndash;19:00 Central Time &middot; One year of hosted Odoo Community Edition included &middot; Limited to 6 participants &middot; English</p>
<p class="bc-card-cta"><a class="btn btn-brass" href="/odoo-erp-automation-bootcamp/">More Info</a></p> <p class="bc-card-cta"><a class="btn btn-brass" href="/odoo-erp-automation-bootcamp/">More Info</a></p>
@@ -700,7 +700,7 @@
<tr> <tr>
<th scope="row">Price</th> <th scope="row">Price</th>
<td>USD 120.00</td> <td>USD 120.00</td>
<td>USD 465.00</td> <td>USD 595.00</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Length</th> <th scope="row">Length</th>
@@ -740,6 +740,7 @@
<!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page <!-- Footer (copied from index.html; icon-font glyphs replaced with inline SVG so the page
carries no external icon dependency) --> carries no external icon dependency) -->
</main>
<footer class="footer"> <footer class="footer">
<div class="container"> <div class="container">
<div class="fgrid"> <div class="fgrid">
@@ -748,6 +749,7 @@
<ul> <ul>
<li><a href="/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li> <li><a href="/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li>
<li><a href="/odoo-hosting-2027.html">ODOO Hosting in 2027</a></li> <li><a href="/odoo-hosting-2027.html">ODOO Hosting in 2027</a></li>
<li><a href="/odoo-community-developers/">Odoo Freelancer Directory</a></li>
<li><a href="/support.html">Support</a></li> <li><a href="/support.html">Support</a></li>
<li><a href="/legal.html">Legal</a></li> <li><a href="/legal.html">Legal</a></li>
</ul> </ul>
@@ -771,7 +773,7 @@
<a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a> <a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a>
<a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a> <a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a>
</div> </div>
<form class="footer-contact-form" id="footerContactForm" action="mailto:hello@odoo4projects.com" method="post" enctype="text/plain"> <form class="footer-contact-form" id="footerContactForm" action="mailto:support@odoo4projects.com" method="post" enctype="text/plain">
<label class="bc-sr-only" for="footerContactEmail">Your email address</label> <label class="bc-sr-only" for="footerContactEmail">Your email address</label>
<input type="email" id="footerContactEmail" name="email" placeholder="Your email address" required /> <input type="email" id="footerContactEmail" name="email" placeholder="Your email address" required />
<div class="footer-textarea-wrap"> <div class="footer-textarea-wrap">
@@ -786,6 +788,6 @@
<div class="fcopy">Copyright &copy; ODOO4projects LLC</div> <div class="fcopy">Copyright &copy; ODOO4projects LLC</div>
</div> </div>
</footer> </footer>
<script defer src="/static/lang.js"></script> <script defer src="/static/lang.js?v=3ec49a7e62"></script>
</body> </body>
</html> </html>