31 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
66 changed files with 3434 additions and 276 deletions

No files matched your search

+3 -2
View File
@@ -28,7 +28,7 @@
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" />
<link rel="stylesheet" href="style.css?v=061517baef" />
<style>
/* ---- Australia landing page (page-scoped; additive) ---- */
.cmp-sr-only {
@@ -391,7 +391,7 @@
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
<script defer src="/static/lang.js"></script>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head>
<body>
<!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. -->
@@ -742,6 +742,7 @@
<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>
+18
View File
@@ -1,5 +1,23 @@
{
"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",
@@ -16,7 +16,7 @@
<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" />
<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; }
+2 -2
View File
@@ -33,7 +33,7 @@
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>
:root {
--primary: #714b67;
@@ -325,6 +325,6 @@
</div>
</div>
</main>
<script defer src="/static/agent-odoo4projects/agent.js"></script>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body>
</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>
@@ -16,7 +16,7 @@
<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" />
<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; }
+2 -2
View File
@@ -36,7 +36,7 @@
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>
.blog-header {
padding: 2rem 0;
@@ -275,6 +275,6 @@
</div>
</div>
</main>
<script defer src="/static/agent-odoo4projects/agent.js"></script>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body>
</html>
@@ -16,7 +16,7 @@
<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" />
<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; }
+1 -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"
rel="stylesheet"
/>
<link rel="stylesheet" href="../../style.css" />
<link rel="stylesheet" href="../../style.css?v=061517baef" />
<style>
/* Blog post specific overrides — matching homepage light theme */
body {
+2 -2
View File
@@ -36,7 +36,7 @@
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>
:root {
--primary: #714b67;
@@ -277,6 +277,6 @@
</div>
</div>
</main>
<script defer src="/static/agent-odoo4projects/agent.js"></script>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body>
</html>
+2 -2
View File
@@ -36,7 +36,7 @@
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>
:root {
--primary: #714b67;
@@ -296,6 +296,6 @@ Admin credentials: sent via email</code></pre>
</div>
</div>
</main>
<script defer src="/static/agent-odoo4projects/agent.js"></script>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body>
</html>
+2 -2
View File
@@ -35,7 +35,7 @@
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>
:root {
--primary: #714b67;
@@ -263,7 +263,7 @@
</main>
<script
defer
src="/static/agent-odoo4projects/agent.js"
src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"
></script>
</body>
</html>
+2 -2
View File
@@ -36,7 +36,7 @@
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>
* {
box-sizing: border-box;
@@ -263,7 +263,7 @@
</main>
<script
defer
src="/static/agent-odoo4projects/agent.js"
src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"
></script>
</body>
</html>
@@ -16,7 +16,7 @@
<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" />
<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; }
@@ -34,7 +34,7 @@
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>
:root {
--primary: #714b67;
@@ -175,6 +175,6 @@
</div>
</div>
</main>
<script defer src="/static/agent-odoo4projects/agent.js"></script>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body>
</html>
@@ -17,7 +17,7 @@
<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" />
<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; }
@@ -16,7 +16,7 @@
<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" />
<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; }
@@ -16,7 +16,7 @@
<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" />
<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; }
+2 -2
View File
@@ -35,7 +35,7 @@
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>
/* Blog post specific styles — leverages CSS vars from style.css */
:root {
@@ -311,6 +311,6 @@
</div>
</div>
</main>
<script defer src="/static/agent-odoo4projects/agent.js"></script>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body>
</html>
+2 -2
View File
@@ -35,7 +35,7 @@
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>
.container {
width: 80%;
@@ -250,6 +250,6 @@ Odoo &gt; Settings &gt; Technical &gt; Scheduled Actions &gt; backup job</code><
</div>
</div>
</main>
<script defer src="/static/agent-odoo4projects/agent.js"></script>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body>
</html>
+2 -2
View File
@@ -35,7 +35,7 @@
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>
:root {
--primary: #714b67;
@@ -267,6 +267,6 @@ Password: (check your email)</code></pre>
</div>
</div>
</main>
<script defer src="/static/agent-odoo4projects/agent.js"></script>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body>
</html>
@@ -34,7 +34,7 @@
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>
:root {
--primary: #714b67;
@@ -165,6 +165,6 @@
</div>
</div>
</main>
<script defer src="/static/agent-odoo4projects/agent.js"></script>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</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>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>
+7 -5
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-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[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-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}
@@ -220,14 +222,14 @@
#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}}
</style>
<link rel="stylesheet" href="/style.css" />
<link rel="stylesheet" href="/style.css?v=061517baef" />
<!-- 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>
<script defer src="/static/lang.js"></script>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head>
<body>
<!--
@@ -672,7 +674,7 @@
</footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script src="/script.js"></script>
<script defer src="/static/agent-odoo4projects/agent.js"></script>
<script src="/script.js?v=d2046e3469"></script>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body>
</html>
+2 -2
View File
@@ -38,7 +38,7 @@
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" />
<link rel="stylesheet" href="/style.css?v=061517baef" />
<style>
/* ---- Odoo Community Upgrade page (page-scoped; additive) ---- */
.oc-sr-only {
@@ -479,7 +479,7 @@
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
<script defer src="/static/lang.js"></script>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head>
<body>
<!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. -->
+5 -5
View File
@@ -33,7 +33,7 @@
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="preload" as="image" href="/images/hero/bootcamp-hero-mobile-900.webp" media="(max-width: 640px)" fetchpriority="high" />
<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" />
<link rel="stylesheet" href="/style.css?v=061517baef" />
<style>
/* ---- ODOO ERP Automation Bootcamp landing page (page-scoped; additive) ---- */
.bc-sr-only {
@@ -1018,9 +1018,9 @@
<div class="fcopy">Copyright &copy; ODOO4projects LLC</div>
</div>
</footer>
<script defer src="/static/buy.js"></script>
<script defer src="/static/bootcamp-availability.js"></script>
<script defer src="/static/bootcamp-waitlist.js"></script>
<script defer src="/static/lang.js"></script>
<script defer src="/static/buy.js?v=31314de600"></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>
</html>
+2 -2
View File
@@ -31,7 +31,7 @@
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" />
<link rel="stylesheet" href="/style.css?v=061517baef" />
<style>
/* ---- Odoo Hosting 2027 landing page (page-scoped; additive) ---- */
.cmp-sr-only {
@@ -559,7 +559,7 @@
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
<script defer src="/static/lang.js"></script>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head>
<body>
<!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. -->
+3 -3
View File
@@ -29,7 +29,7 @@
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>
/* 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). */
@@ -274,7 +274,7 @@
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
<script defer src="/static/lang.js"></script>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head>
<body>
<main>
@@ -586,6 +586,6 @@
</script>
</main>
<script defer src="/static/agent-odoo4projects/agent.js"></script>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body>
</html>
+7 -5
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-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[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-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}
@@ -220,14 +222,14 @@
#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}}
</style>
<link rel="stylesheet" href="/style.css" />
<link rel="stylesheet" href="/style.css?v=061517baef" />
<!-- 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>
<script defer src="/static/lang.js"></script>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head>
<body>
<!--
@@ -672,7 +674,7 @@
</footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script src="/script.js"></script>
<script defer src="/static/agent-odoo4projects/agent.js"></script>
<script src="/script.js?v=d2046e3469"></script>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body>
</html>
+2 -2
View File
@@ -38,7 +38,7 @@
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" />
<link rel="stylesheet" href="/style.css?v=061517baef" />
<style>
/* ---- Odoo Community Upgrade page (page-scoped; additive) ---- */
.oc-sr-only {
@@ -479,7 +479,7 @@
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
<script defer src="/static/lang.js"></script>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head>
<body>
<!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. -->
+5 -5
View File
@@ -33,7 +33,7 @@
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="preload" as="image" href="/images/hero/bootcamp-hero-mobile-900.webp" media="(max-width: 640px)" fetchpriority="high" />
<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" />
<link rel="stylesheet" href="/style.css?v=061517baef" />
<style>
/* ---- ODOO ERP Automation Bootcamp landing page (page-scoped; additive) ---- */
.bc-sr-only {
@@ -1018,9 +1018,9 @@
<div class="fcopy">Copyright &copy; ODOO4projects LLC</div>
</div>
</footer>
<script defer src="/static/buy.js"></script>
<script defer src="/static/bootcamp-availability.js"></script>
<script defer src="/static/bootcamp-waitlist.js"></script>
<script defer src="/static/lang.js"></script>
<script defer src="/static/buy.js?v=31314de600"></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>
</html>
+2 -2
View File
@@ -31,7 +31,7 @@
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" />
<link rel="stylesheet" href="/style.css?v=061517baef" />
<style>
/* ---- Odoo Hosting 2027 landing page (page-scoped; additive) ---- */
.cmp-sr-only {
@@ -559,7 +559,7 @@
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
<script defer src="/static/lang.js"></script>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head>
<body>
<!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. -->
+3 -3
View File
@@ -29,7 +29,7 @@
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>
/* 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). */
@@ -274,7 +274,7 @@
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
<script defer src="/static/lang.js"></script>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head>
<body>
<main>
@@ -586,6 +586,6 @@
</script>
</main>
<script defer src="/static/agent-odoo4projects/agent.js"></script>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body>
</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>
+8 -5
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-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[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-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}
@@ -230,14 +232,14 @@
.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>
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="style.css?v=061517baef" />
<!-- 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>
<script defer src="/static/lang.js"></script>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head>
<body>
<!--
@@ -662,6 +664,7 @@
<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>
@@ -700,7 +703,7 @@
</footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script src="script.js"></script>
<script defer src="/static/agent-odoo4projects/agent.js"></script>
<script src="script.js?v=d2046e3469"></script>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body>
</html>
+2 -2
View File
@@ -25,7 +25,7 @@
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>
.legal-hero {
padding: 4rem 0 2.5rem;
@@ -204,6 +204,6 @@
</section>
</main>
<script defer src="/static/agent-odoo4projects/agent.js"></script>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body>
</html>
+2
View File
@@ -50,6 +50,8 @@
## 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.
+8 -7
View File
@@ -43,7 +43,7 @@
onload="this.media='all'"
/>
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" /></noscript>
<link rel="stylesheet" href="/style.css" />
<link rel="stylesheet" href="/style.css?v=061517baef" />
<style>
/* ---- Odoo Black Friday 2026 page (page-scoped; additive) ---- */
.bf-sr-only {
@@ -525,9 +525,9 @@
border-top: 2px solid var(--brass, #c9a227);
box-shadow: 0 -8px 30px rgba(13, 27, 46, 0.28);
}
.ads-consent[hidden] {
display: none;
}
/* `.ads-consent[hidden]{display:none}` used to live here. The banner sets no
`display` of its own, so the UA rule already hid it — the patch was redundant;
it is gone so the site-wide `[hidden]` rule in /style.css is the one mechanism. */
.ads-consent-inner {
width: 86%;
max-width: 1200px;
@@ -744,7 +744,7 @@
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
<script defer src="/static/lang.js"></script>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head>
<body>
<!-- Hero — copied from the homepage (index.html): same component, same behaviour. On load the
@@ -1196,6 +1196,7 @@
<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>
@@ -1274,7 +1275,7 @@
</div>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script defer src="/static/black-friday.js"></script>
<script defer src="/static/ads-consent.js"></script>
<script defer src="/static/black-friday.js?v=df1f299dc5"></script>
<script defer src="/static/ads-consent.js?v=0cc2bfb809"></script>
</body>
</html>
+3 -2
View File
@@ -23,7 +23,7 @@
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" />
<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
@@ -898,6 +898,7 @@
<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>
@@ -936,6 +937,6 @@
<div class="fcopy">Copyright &copy; ODOO4projects LLC</div>
</div>
</footer>
<script defer src="/static/lang.js"></script>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</body>
</html>
+1 -1
View File
@@ -21,7 +21,7 @@
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" />
<link rel="stylesheet" href="/style.css?v=061517baef" />
<style>
/* ---- Freelancer records — admin (page-scoped). Internal page, never linked. ---- */
.adm-bar {
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -38,7 +38,7 @@
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" />
<link rel="stylesheet" href="style.css?v=061517baef" />
<style>
/* ---- Odoo Community Upgrade page (page-scoped; additive) ---- */
.oc-sr-only {
@@ -479,7 +479,7 @@
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
<script defer src="/static/lang.js"></script>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head>
<body>
<!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. -->
@@ -996,6 +996,7 @@ sudo -u postgres pg_restore -d &lt;DB_NEW&gt; /path/backup/&lt;DB&gt;.dump
<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>
+6 -5
View File
@@ -33,7 +33,7 @@
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="preload" as="image" href="/images/hero/bootcamp-hero-mobile-900.webp" media="(max-width: 640px)" fetchpriority="high" />
<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" />
<link rel="stylesheet" href="/style.css?v=061517baef" />
<style>
/* ---- ODOO ERP Automation Bootcamp landing page (page-scoped; additive) ---- */
.bc-sr-only {
@@ -999,6 +999,7 @@
<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>
@@ -1038,9 +1039,9 @@
<div class="fcopy">Copyright &copy; ODOO4projects LLC</div>
</div>
</footer>
<script defer src="/static/buy.js"></script>
<script defer src="/static/bootcamp-availability.js"></script>
<script defer src="/static/bootcamp-waitlist.js"></script>
<script defer src="/static/lang.js"></script>
<script defer src="/static/buy.js?v=31314de600"></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>
</html>
+3 -2
View File
@@ -31,7 +31,7 @@
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" />
<link rel="stylesheet" href="style.css?v=061517baef" />
<style>
/* ---- Odoo Hosting 2027 landing page (page-scoped; additive) ---- */
.cmp-sr-only {
@@ -559,7 +559,7 @@
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
<script defer src="/static/lang.js"></script>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head>
<body>
<!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. -->
@@ -995,6 +995,7 @@
<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>
+3 -2
View File
@@ -35,7 +35,7 @@
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" />
<link rel="stylesheet" href="/style.css?v=061517baef" />
<style>
/* ---- Odoo Hosting Offers (evergreen hub) page (page-scoped; additive) ---- */
.of-sr-only {
@@ -449,7 +449,7 @@
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
<script defer src="/static/lang.js"></script>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head>
<body>
<!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. -->
@@ -577,6 +577,7 @@
<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>
+4 -3
View File
@@ -27,7 +27,7 @@
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" />
<link rel="stylesheet" href="/style.css?v=061517baef" />
<style>
/* ---- ODOO x N8N Masterclass landing page (page-scoped; additive).
The block below is the bootcamp page's page-scoped CSS, reused so the two
@@ -792,6 +792,7 @@
<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>
@@ -830,7 +831,7 @@
<div class="fcopy">Copyright &copy; ODOO4projects LLC</div>
</div>
</footer>
<script defer src="/static/buy.js"></script>
<script defer src="/static/lang.js"></script>
<script defer src="/static/buy.js?v=31314de600"></script>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</body>
</html>
+3 -2
View File
@@ -29,7 +29,7 @@
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" />
<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
@@ -783,6 +783,7 @@
<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>
@@ -821,6 +822,6 @@
<div class="fcopy">Copyright &copy; ODOO4projects LLC</div>
</div>
</footer>
<script defer src="/static/lang.js"></script>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</body>
</html>
+4 -3
View File
@@ -27,8 +27,8 @@
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" media="print" onload="this.media='all'" />
<noscript><link rel="stylesheet" href="/style.css" /></noscript>
<link rel="stylesheet" href="/style.css?v=061517baef" media="print" onload="this.media='all'" />
<noscript><link rel="stylesheet" href="/style.css?v=061517baef" /></noscript>
<style>
/* ---- Partner briefing for Andrew (page-scoped; additive) ---- */
.andrew-sr-only {
@@ -661,6 +661,7 @@
<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>
@@ -699,6 +700,6 @@
<div class="fcopy">Copyright &copy; ODOO4projects LLC</div>
</div>
</footer>
<script defer src="/static/lang.js"></script>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</body>
</html>
+2 -2
View File
@@ -25,7 +25,7 @@
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>
.legal-hero {
padding: 4rem 0 2.5rem;
@@ -205,6 +205,6 @@
</section>
</main>
<script defer src="/static/agent-odoo4projects/agent.js"></script>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body>
</html>
+12
View File
@@ -84,6 +84,18 @@
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</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>
+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; }
/* `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 {
margin: 0;
font-family: Inter, 'Segoe UI', sans-serif;
+3 -3
View File
@@ -29,7 +29,7 @@
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>
/* 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). */
@@ -274,7 +274,7 @@
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
<script defer src="/static/lang.js"></script>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</head>
<body>
<main>
@@ -590,6 +590,6 @@
</script>
</main>
<script defer src="/static/agent-odoo4projects/agent.js"></script>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body>
</html>
+2 -2
View File
@@ -25,7 +25,7 @@
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>
.legal-hero {
padding: 4rem 0 2.5rem;
@@ -195,6 +195,6 @@
</section>
</main>
<script defer src="/static/agent-odoo4projects/agent.js"></script>
<script defer src="/static/agent-odoo4projects/agent.js?v=c3bb0cee2b"></script>
</body>
</html>
+3 -2
View File
@@ -27,7 +27,7 @@
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" />
<link rel="stylesheet" href="/style.css?v=061517baef" />
<style>
/* ---- "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
@@ -913,6 +913,7 @@
<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>
@@ -951,6 +952,6 @@
<div class="fcopy">Copyright &copy; ODOO4projects LLC</div>
</div>
</footer>
<script defer src="/static/lang.js"></script>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</body>
</html>
+3 -2
View File
@@ -27,7 +27,7 @@
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" />
<link rel="stylesheet" href="/style.css?v=061517baef" />
<style>
/* ---- Workshops and Classes hub page (page-scoped; additive).
The block below is the bootcamp page's page-scoped CSS, reused so the two
@@ -749,6 +749,7 @@
<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>
@@ -787,6 +788,6 @@
<div class="fcopy">Copyright &copy; ODOO4projects LLC</div>
</div>
</footer>
<script defer src="/static/lang.js"></script>
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
</body>
</html>