Files
ODOO4projects.com/odoo-community-developers/index.html
T
webmaster 5ce14ea666 fix(freelancer-directory): render an uploaded photo (data:image/), not only a linked URL
Both card renderers emitted an <img> only for a URL, so a record whose Image is an
inline data:image/... URI (what the upload step stores today) fell through to the
monogram. The avatar check now allowlists exactly two schemes — https:// and
data:image/ — in build-directory.py and in the page script; anything else (empty,
junk like "gineoirn", another scheme) still falls back to the monogram, and the
value never reaches src unchecked. The lede copy is corrected to match: a photo
shows where the freelancer gave a link OR uploaded an image.
2026-11-03 13:04:06 +00:00

2210 lines
151 KiB
HTML

<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hire an Odoo Developer for Your Project</title>
<meta name="description" content="Hire an Odoo developer or freelancer for your project. Browse independent Odoo specialists and write to one directly, using the contact details they publish." />
<link rel="canonical" href="https://odoo4projects.com/odoo-community-developers/" />
<meta property="og:title" content="Hire an Odoo developer for your project" />
<meta property="og:description" content="Independent Odoo developers you can write to directly, using the contact details they publish on their card." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://odoo4projects.com/odoo-community-developers/" />
<meta property="og:image" content="https://odoo4projects.com/images/hero/amsterdam.webp" />
<meta property="og:image:alt" content="Amsterdam at dusk, one of the six regions our servers run in" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://odoo4projects.com/images/hero/amsterdam.webp" />
<link rel="icon" href="/images/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap"
media="print"
onload="this.media='all'"
/>
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
<link rel="stylesheet" href="/style.css" />
<style>
/* ---- "Odoo Community developers" directory (page-scoped; additive).
Reuses the top bar, band rhythm, hero, FAQ and final-CTA treatment already
shipped on odoo-staging-environment/ and odoo-community-ai/.
Additions this page needs and no other: .dir-* (the directory, filters and forms),
.dev-* (one freelancer card), .form-* (the three forms). ---- */
.oca-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Top bar (page-scoped: the site has no global header) */
.cmp-topbar {
background: var(--midnight-2);
border-bottom: 1px solid var(--line-brass);
padding: 0.85rem 0;
}
.cmp-topbar .container {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.cmp-brand {
font-family: Marcellus, Georgia, serif;
font-size: 1.15rem;
letter-spacing: 0.04em;
color: #fff;
}
.cmp-brand span {
color: var(--brass);
}
.cmp-topbar-links {
display: flex;
gap: 1.25rem;
font-size: 0.82rem;
margin-left: auto;
}
.cmp-topbar-links a {
color: rgba(255, 255, 255, 0.7);
}
.cmp-topbar-links a:hover {
color: var(--brass);
}
.cmp-topbar .lang-switch-slot {
display: flex;
align-items: center;
padding-left: 1rem;
border-left: 1px solid rgba(201, 162, 39, 0.45);
}
.cmp-topbar .lang-switch {
color: rgba(255, 255, 255, 0.7);
}
.cmp-topbar .lang-switch a:hover,
.cmp-topbar .lang-switch a:focus-visible {
color: var(--brass-bright);
}
.cmp-topbar .lang-switch a[aria-current="true"] {
color: var(--brass);
text-decoration: underline;
text-underline-offset: 3px;
}
/* Hero */
.oc-hero {
min-height: 0;
padding: 3.5rem 0 3rem;
}
.oc-hero .container {
position: relative;
z-index: 1;
}
.oc-hero .hero-sub {
max-width: 760px;
}
.oc-hero-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
opacity: 0.25;
filter: brightness(1.15) saturate(1.1);
}
/* Prose column */
.oc-body {
max-width: 760px;
margin: 0 auto;
color: var(--ink-soft);
line-height: 1.75;
}
.section-dark .oc-body {
color: rgba(255, 255, 255, 0.78);
}
.oc-body p:last-child {
margin-bottom: 0;
}
.oc-body strong {
color: var(--ink);
}
.section-dark .oc-body strong {
color: #fff;
}
.section-dark .oc-body a {
color: var(--brass-bright);
}
.oc-body h3 {
font-family: Marcellus, Georgia, serif;
font-weight: 400;
letter-spacing: 0.01em;
color: var(--midnight);
margin: 1.7rem 0 0.4rem;
}
.section-dark .oc-body h3 {
color: #fff;
}
/* The no-liability statement: a midnight plaque with a brass hairline */
.dir-note {
max-width: 860px;
margin: 1.75rem auto 0;
padding: 1.35rem 1.5rem;
background: var(--midnight);
border: 1px solid var(--line-brass);
border-radius: var(--radius);
color: rgba(255, 255, 255, 0.82);
line-height: 1.75;
}
.section-light .dir-note {
background: var(--ivory);
border-color: var(--line-brass);
color: var(--ink-soft);
}
.dir-note p {
margin: 0;
}
.dir-note a {
color: var(--brass-bright);
}
.section-light .dir-note a {
color: var(--brass-deep);
}
/* Directory lede + filters */
.dir-lede {
max-width: 720px;
margin: 0 auto 1.5rem;
text-align: center;
color: rgba(255, 255, 255, 0.72);
line-height: 1.75;
}
.dir-filters {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: center;
max-width: 900px;
margin: 0 auto 1.75rem;
}
.dir-filter {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 180px;
}
.dir-filter label {
font-size: 0.78rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--brass);
}
.dir-filter select {
font-family: Inter, sans-serif;
font-size: 0.9rem;
padding: 0.5rem 0.7rem;
border-radius: var(--radius);
border: 1px solid var(--line-brass);
background: var(--midnight);
color: #fff;
}
.dir-search {
min-width: 240px;
}
/* `hidden` must really hide. The filter bar and each card set `display` in their own
class rules, and a class rule beats the UA's [hidden] rule — so without this a
filtered-out card (and a dead bar with JavaScript off) would stay on screen. */
.dir-filters[hidden],
.dev-card[hidden] {
display: none;
}
.dir-filter input[type="search"] {
font-family: Inter, sans-serif;
font-size: 0.9rem;
padding: 0.5rem 0.7rem;
border-radius: var(--radius);
border: 1px solid var(--line-brass);
background: var(--midnight);
color: #fff;
}
.dir-clear {
font-family: Inter, sans-serif;
font-size: 0.85rem;
cursor: pointer;
padding: 0.5rem 0.9rem;
border-radius: var(--radius);
border: 1px solid var(--line-brass);
background: transparent;
color: #fff;
}
.dir-clear:hover {
background: rgba(201, 162, 39, 0.14);
}
.dir-clear:focus-visible {
outline: 3px solid var(--brass-bright);
outline-offset: 2px;
}
/* The standardised language multi-select: a closed checklist, scrollable so the
form stays short. The level <select> is folded in beside a language only once
it is checked — pure CSS, so it needs no script. */
.lang-field {
min-width: 0;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.85rem 0.9rem 0.6rem;
}
.lang-field legend {
font-size: 0.8rem;
font-weight: 600;
color: var(--midnight);
padding: 0 0.35rem;
}
.section-dark .lang-field {
border-color: var(--line-brass);
}
.section-dark .lang-field legend {
color: rgba(255, 255, 255, 0.85);
}
.lang-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 220px;
overflow: auto;
padding-right: 0.35rem;
}
.lang-pick {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.88rem;
color: var(--ink);
}
.section-dark .lang-pick {
color: rgba(255, 255, 255, 0.85);
}
.lang-pick input {
width: auto;
margin: 0;
flex-shrink: 0;
}
.lang-pick .lang-level {
display: none;
width: auto;
max-width: 9rem;
padding: 0.25rem 0.4rem;
font-size: 0.8rem;
}
.lang-pick input:checked ~ .lang-level {
display: inline-block;
}
.dir-count {
text-align: center;
font-size: 0.82rem;
color: rgba(255, 255, 255, 0.6);
margin: 0 0 1.25rem;
}
.dir-empty {
text-align: center;
color: rgba(255, 255, 255, 0.72);
max-width: 560px;
margin: 1.5rem auto 0;
}
/* The grid of freelancer cards */
.dir-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.25rem;
max-width: 1000px;
margin: 0 auto;
}
.dev-card {
background: var(--ivory);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 1.25rem 1.35rem;
box-shadow: var(--shadow-rest);
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.dev-card > :last-child {
margin-bottom: 0;
}
.dev-head {
display: flex;
align-items: center;
gap: 0.85rem;
}
.dev-avatar {
flex-shrink: 0;
width: 72px;
height: 72px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: Marcellus, Georgia, serif;
font-size: 1.5rem;
color: var(--ivory);
background: var(--midnight);
border: 1px solid var(--line-brass);
}
.dev-photo {
flex-shrink: 0;
width: 72px;
height: 72px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--line-brass);
}
.dev-name {
font-family: Marcellus, Georgia, serif;
font-weight: 400;
font-size: 1.3rem;
color: var(--midnight);
margin: 0;
}
.dev-focus {
margin: 0.15rem 0 0;
font-size: 0.82rem;
color: var(--brass-deep);
}
.dev-bio {
margin: 0;
font-size: 0.9rem;
line-height: 1.65;
color: var(--ink-soft);
}
.dev-tags {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
padding: 0;
margin: 0;
}
.dev-tag {
font-size: 0.78rem;
letter-spacing: 0.02em;
padding: 0.2rem 0.6rem;
border-radius: var(--radius-pill, 999px);
background: rgba(201, 162, 39, 0.14);
border: 1px solid rgba(138, 111, 31, 0.35);
color: var(--ink);
}
.dev-facts {
margin: 0;
font-size: 0.82rem;
}
.dev-facts dt {
color: var(--midnight);
font-weight: 600;
}
.dev-facts dd {
margin: 0.1rem 0 0;
color: var(--ink-soft);
}
.dev-links {
margin: 0;
font-size: 0.85rem;
color: var(--ink-soft);
}
.dev-links a {
color: var(--brass-deep);
}
/* A card with no published contact says so — it never shows an empty space. */
.dev-contact-none {
margin: 0;
font-size: 0.82rem;
font-style: italic;
color: var(--ink-soft);
}
/* The three forms */
.dir-forms {
max-width: 860px;
margin: 1.75rem auto 0;
}
.form-card {
background: var(--ivory);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-rest);
}
.section-dark .form-card {
background: var(--midnight-2);
border-color: var(--line-brass);
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.form-field.full {
grid-column: 1 / -1;
}
.form-field label {
font-size: 0.8rem;
font-weight: 600;
color: var(--midnight);
}
.section-dark .form-field label {
color: rgba(255, 255, 255, 0.85);
}
.form-field .hint {
font-size: 0.78rem;
color: var(--ink-soft);
}
.section-dark .form-field .hint {
color: rgba(255, 255, 255, 0.6);
}
.form-noscript {
max-width: 860px;
margin: 1rem auto 0;
font-size: 0.9rem;
line-height: 1.7;
color: var(--ink-soft);
}
.section-dark .form-noscript {
color: rgba(255, 255, 255, 0.78);
}
.form-noscript a {
color: var(--brass-deep);
}
/* A relay form whose route is not built yet: the form is hidden and this plain
line stands in its place — not a warning, just the current state. */
.form-notlive {
max-width: 860px;
margin: 1.25rem auto 0;
font-size: 0.95rem;
line-height: 1.7;
color: var(--ink-soft);
}
.form-notlive a {
color: var(--brass-deep);
}
.section-dark .form-notlive {
color: rgba(255, 255, 255, 0.78);
}
.form-lede {
margin: 0 0 1rem;
font-size: 0.92rem;
line-height: 1.7;
color: var(--ink-soft);
}
.form-field input,
.form-field textarea,
.form-field select {
font-family: Inter, sans-serif;
font-size: 0.9rem;
padding: 0.6rem 0.7rem;
border-radius: var(--radius);
border: 1px solid var(--line);
background: #fff;
color: var(--ink);
width: 100%;
}
.section-dark .form-field input,
.section-dark .form-field textarea,
.section-dark .form-field select {
background: var(--midnight);
border-color: var(--line-brass);
color: #fff;
}
.form-check {
grid-column: 1 / -1;
display: flex;
align-items: flex-start;
gap: 0.6rem;
font-size: 0.85rem;
color: var(--ink-soft);
}
.section-dark .form-check {
color: rgba(255, 255, 255, 0.78);
}
.form-check input {
width: auto;
margin-top: 0.2rem;
flex-shrink: 0;
}
.form-actions {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
margin-top: 0.25rem;
}
.form-status {
font-size: 0.85rem;
color: var(--ink-soft);
}
.section-dark .form-status {
color: rgba(255, 255, 255, 0.78);
}
.form-status[data-state="ok"] {
color: var(--success, #3f7d4f);
}
.form-status[data-state="err"] {
color: #b3452f;
}
/* The explicit freelancer pick-list (form 3). A real list over the directory —
never a free-text box and never an implicit "all". */
.dir-pickfield {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.85rem 0.9rem 0.6rem;
min-width: 0;
}
.section-dark .dir-pickfield {
border-color: var(--line-brass);
}
.dir-pickfield legend {
font-size: 0.8rem;
font-weight: 600;
color: var(--midnight);
padding: 0 0.35rem;
}
.section-dark .dir-pickfield legend {
color: rgba(255, 255, 255, 0.85);
}
.dir-picklist {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
}
.dir-picklist .pick {
display: flex;
align-items: baseline;
gap: 0.45rem;
font-size: 0.9rem;
color: var(--ink);
}
.section-dark .dir-picklist .pick {
color: rgba(255, 255, 255, 0.85);
}
.dir-picklist .pick input {
width: auto;
margin: 0;
align-self: center;
}
.dir-picklist .pick .hint {
font-size: 0.78rem;
color: var(--ink-soft);
}
.section-dark .dir-picklist .pick .hint {
color: rgba(255, 255, 255, 0.6);
}
.dir-picklist-empty {
margin: 0;
font-size: 0.9rem;
color: var(--ink-soft);
}
/* Final CTA */
.cmp-cta {
text-align: center;
}
.cmp-cta .cmp-lead {
font-size: 1.1rem;
color: rgba(255, 255, 255, 0.72);
max-width: 720px;
line-height: 1.75;
margin: 0 auto 1.5rem;
}
.cmp-cta.section-light .cmp-lead {
color: var(--ink-soft);
}
.cmp-cta.section-dark .cmp-lead a {
color: var(--brass-bright);
}
.cmp-cta.section-light .cmp-lead p:last-child {
margin-bottom: 0;
}
.cmp-cta-actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.section-light .cmp-cta-actions .btn-outline {
color: var(--ink);
border-color: rgba(23, 28, 38, 0.22);
}
.section-light .cmp-cta-actions .btn-outline:hover {
color: var(--brass-deep);
border-color: var(--brass-deep);
background: rgba(201, 162, 39, 0.08);
}
/* Focus states on every CTA */
.btn:focus-visible,
.cmp-topbar-links a:focus-visible {
outline: 3px solid var(--brass-bright);
outline-offset: 2px;
border-radius: var(--radius);
}
.btn-brass:focus-visible {
outline-color: #fff;
}
.section-light .btn-brass:focus-visible {
outline-color: var(--midnight);
}
@media (max-width: 768px) {
.form-grid {
grid-template-columns: 1fr;
}
.oc-hero {
padding: 3rem 0 2.5rem;
}
}
</style>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebPage",
"@id": "https://odoo4projects.com/odoo-community-developers/#webpage",
"url": "https://odoo4projects.com/odoo-community-developers/",
"name": "Hire an Odoo Developer for Your Project",
"description": "Hire an Odoo developer or freelancer for your project. Browse independent Odoo specialists and write to one directly, using the contact details they publish.",
"inLanguage": "en-US",
"isPartOf": {
"@id": "https://odoo4projects.com/#organization"
},
"breadcrumb": {
"@id": "https://odoo4projects.com/odoo-community-developers/#breadcrumb"
}
},
{
"@type": "BreadcrumbList",
"@id": "https://odoo4projects.com/odoo-community-developers/#breadcrumb",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://odoo4projects.com/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Odoo freelancers",
"item": "https://odoo4projects.com/odoo-community-developers/"
}
]
}
]
}
</script>
<!-- Privacy-friendly analytics by Plausible -->
<script async src="https://plausible.office.odoo4projects.com/js/pa-NhA7Rf-79VopXFQXhSS4e.js"></script>
<script>
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>
</head>
<body>
<!-- Page-scoped top bar: the site has no global header, so the page carries its own way home. -->
<div class="cmp-topbar">
<div class="container">
<a class="cmp-brand" href="/index.html">ODOO4<span>projects</span></a>
<nav class="cmp-topbar-links" aria-label="Page links">
<a href="/index.html#pricing">Plans &amp; pricing</a>
<a href="/support.html">Support</a>
</nav>
<div data-lang-switcher class="lang-switch-slot"></div>
</div>
</div>
<main>
<!-- Hero -->
<section class="hero oc-hero">
<div class="hero-bg">
<img
class="oc-hero-img"
src="/images/hero/amsterdam.webp"
alt="Amsterdam at dusk, one of the six regions our servers run in"
width="1920"
height="768"
fetchpriority="high"
decoding="async"
/>
</div>
<div class="container">
<p class="hero-kicker">ODOO FREELANCERS</p>
<h1>Hire an Odoo freelancer for your next project</h1>
<p class="hero-sub">Independent Odoo specialists you can hire for the piece you cannot do yourself — a custom module, a migration, an integration, an app your business needs. Write to a freelancer directly, using the contact details they publish on their card.</p>
<div class="hero-actions">
<a href="#directory" class="btn btn-brass btn-lg">Browse the freelancers</a>
<a href="mailto:hello@odoo4projects.com?subject=Find%20me%20an%20Odoo%20freelancer" class="btn btn-outline btn-lg">Ask us to find one</a>
</div>
</div>
</section>
<!-- What we do / don't — a client's first question about the listings answered with
our limits, not with a claim about anyone. Folds the old "How this works" block;
the contract point links to the note under the listings rather than copying it. -->
<section class="section-light">
<div class="container">
<h2 class="section-title">What we do, and what we don't</h2>
<div class="oc-body">
<p>You are about to write to someone you have not worked with before. Here is exactly what this page does, and what it does not.</p>
<p><strong>You deal with the freelancer, not us.</strong> Each card carries the contact details they chose to publish, and the brief, the price and the delivery are settled between the two of you; we are not a party, as <a href="#listing-note">the note under the listings</a> says. If you would rather not write first, email us your brief and we pass it to the freelancers you name.</p>
<p><strong>We rank nobody.</strong> Nothing here is graded: no score, no ranking, no "best" order. A listing is not a recommendation from us.</p>
<p><strong>We verify nothing.</strong> A card is that person's own words. Where a line is the freelancer's own declaration (that they work alone, that they are an OCA member), we label it (as declared): we pass it on, we do not check it.</p>
<p><strong>An invitation is one click to refuse.</strong> When a freelancer is suggested to us, we invite them once, we say who suggested them, and we give them a single click to opt out.</p>
</div>
</div>
</section>
<!-- The directory -->
<section class="section-dark" id="directory">
<div class="container">
<h2 class="section-title">Freelancers available</h2>
<p class="dir-lede">Listings are for freelancers who work on their own — that is the declaration we ask each of them for, and we do not verify it. Where a card shows a number of projects, that is a number we recorded — not a rating, a score or an endorsement. A card shows a photo whether the freelancer gave us a link to one or uploaded an image; otherwise it shows their initials. We make no claim about a freelancer beyond what they wrote and what we can count ourselves.</p>
<!-- Rendered at build time from our freelancer webhook (one call, no CRM),
so the list works with JavaScript disabled. The filter controls below are
printed only when the data actually supports them; the page improves by
itself as the fields land. Rebuild the list with:
python3 /root/workspace/webmaster/work/build-directory.py -->
<div class="dir-filters" id="dirFilters" hidden>
<div class="dir-filter dir-search">
<label for="dirSearch">Search</label>
<input type="search" id="dirSearch" data-search-box="1" placeholder="Name, focus, skills…" autocomplete="off" />
</div>
<!-- FILTERS-START -->
<div class="dir-filter"><label for="f-country">Country</label><select id="f-country" data-field="country"><option value="">All</option><option value="py">Paraguay</option></select></div>
<div class="dir-filter"><label for="f-languages">Language</label><select id="f-languages" data-field="languages"><option value="">All</option><option value="english">English</option><option value="german">German</option><option value="spanish">Spanish</option></select></div>
<div class="dir-filter"><label for="f-specialities">Speciality</label><select id="f-specialities" data-field="specialities"><option value="">All</option><option value="functional consulting">Functional Consulting</option></select></div>
<div class="dir-filter"><label for="f-oca">OCA membership</label><select id="f-oca" data-field="oca"><option value="">All</option><option value="1">OCA member</option><option value="0">Not an OCA member</option></select></div>
<!-- FILTERS-END -->
<div class="dir-filter dir-clear-wrap">
<button type="button" id="dirClear" class="dir-clear" hidden>Clear</button>
</div>
</div>
<p class="dir-count" id="dirCount" hidden></p>
<div class="dir-grid" id="dirGrid">
<!-- DIRECTORY-LIST-START -->
<article class="dev-card" data-name="oliver" data-search="oliver ginwwgnri vieoem " data-country="" data-languages="" data-specialities="" data-oca="1">
<header class="dev-head">
<span class="dev-avatar" aria-hidden="true">OL</span>
<div class="dev-id">
<h3 class="dev-name">oliver</h3>
<p class="dev-focus">vieoem</p>
</div></header>
<p class="dev-bio">ginwwgnri</p>
<ul class="dev-tags">
<li class="dev-tag">OCA member (as declared)</li>
</ul>
<dl class="dev-facts"><div><dt>Projects on ODOO4projects</dt><dd>4 projects — a number we recorded</dd></div></dl>
<p class="dev-contact-none">No contact details published for this freelancer.</p>
</article>
<article class="dev-card" data-name="oliver - the oc guy" data-search="oliver - the oc guy i love to see how far i can push the odoo community version for my clients. besides the module implementation i am quite happy with adding to odoo using hermes agent, n8n and progressive web apps agent automation, manufacturing, e-commerce functional consulting german english spanish - beginners" data-country="py" data-languages="german|english|spanish" data-specialities="functional consulting" data-oca="0">
<header class="dev-head">
<span class="dev-avatar" aria-hidden="true">OG</span>
<div class="dev-id">
<h3 class="dev-name">Oliver - The OC Guy</h3>
<p class="dev-focus">Agent Automation, Manufacturing, E-commerce</p>
</div></header>
<p class="dev-bio">I love to see how far I can push the ODOO Community version for my clients. Besides the module implementation i am quite happy with adding to odoo using Hermes Agent, N8N and Progressive Web Apps</p>
<ul class="dev-tags">
<li class="dev-tag">Works as an individual (as declared)</li>
<li class="dev-tag">German</li>
<li class="dev-tag">English</li>
<li class="dev-tag">Spanish - beginners</li>
<li class="dev-tag">Functional Consulting</li>
</ul>
<dl class="dev-facts"><div><dt>Projects on ODOO4projects</dt><dd>4 projects — a number we recorded</dd></div></dl>
<p class="dev-links"><a href="mailto:oliver@odoo4projects.com">Email this freelancer</a></p>
</article>
<!-- DIRECTORY-LIST-END -->
</div>
<p class="dir-empty" id="dirEmpty" hidden>No freelancer matches that search and those filters. Use Clear to see everyone.</p>
<div class="dir-note" id="listing-note">
<p>You deal with the freelancer directly. The contract for the work is between you and them, and the terms, the price and the delivery are yours to agree. We are not a party to it — we publish the listings and, if you ask, pass your message along.</p>
</div>
</div>
</section>
<!-- Client request / anonymous get-quote. The route it posts to
(odoo_experts_quote) is a STUB the user has yet to build, so the form ships
DARK: its endpoint constant is empty and the form stays hidden behind the
"not open yet" line below — no way to submit, so no false confirmation. When the
workflow exists, set ODOO_EXPERTS_QUOTE_WEBHOOK and the note hides itself.
The freelancer selection is an explicit pick-list over the
directory, built at build time — NEVER a free-text box and NEVER an
implicit "all". Rebuild the pick-list with:
python3 /root/workspace/webmaster/work/build-directory.py -->
<section class="section-light" id="request">
<div class="container">
<h2 class="section-title">Post a request</h2>
<p class="form-notlive" id="requestNotLive">Not open yet. Email <a href="mailto:hello@odoo4projects.com?subject=Post%20a%20request">hello@odoo4projects.com</a> with your brief and the freelancers you want it to reach — we will pass it on by hand and send their answers back to you.</p>
<div class="dir-forms" id="requestFormWrap" hidden>
<form class="form-card" id="requestForm" novalidate>
<div class="form-grid">
<fieldset class="form-field full dir-pickfield">
<legend>Freelancers to ask <span class="hint">(pick at least one — we never send to everyone)</span></legend>
<div class="dir-picklist" id="reqPicklist">
<!-- REQUEST-PICKLIST-START -->
<label class="pick"><input type="checkbox" name="freelancers" value="oliver" data-id="1" /> <span>oliver</span> <span class="hint">vieoem</span></label>
<label class="pick"><input type="checkbox" name="freelancers" value="Oliver - The OC Guy" data-id="2" /> <span>Oliver - The OC Guy</span> <span class="hint">Agent Automation, Manufacturing, E-commerce</span></label>
<!-- REQUEST-PICKLIST-END -->
</div>
</fieldset>
<div class="form-field full">
<label for="rqBrief">Your brief</label>
<textarea id="rqBrief" name="brief" rows="6" required placeholder="What you need built, the Odoo version you run, and anything else a freelancer would need to quote."></textarea>
</div>
<div class="form-field full">
<label for="rqEmail">Your email</label>
<input id="rqEmail" name="email" type="email" autocomplete="email" required />
<span class="hint">Only so we can send their answers back to you. The freelancers never see it.</span>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-brass">Send my request</button>
<span class="form-status" id="requestStatus" role="status"></span>
</div>
</div>
</form>
</div>
<noscript><p class="form-noscript">This form needs JavaScript. Email <a href="mailto:hello@odoo4projects.com">hello@odoo4projects.com</a> with your brief and we will pass it on by hand.</p></noscript>
</div>
</section>
<!-- Freelancer application (form 1) -->
<section class="section-dark" id="apply">
<div class="container">
<h2 class="section-title">Are you an Odoo freelancer? Get listed</h2>
<div class="oc-body">
<p>If you work as an independent Odoo developer and want clients to find you here, apply below. We publish what you write — after we check it: your photo, your email address and for spam. No rating and no ranking. We ask for your email so we can confirm it is really you before your listing goes live. The directory is for freelancers working on their own — we ask each applicant to declare that; it is not open to agencies.</p>
</div>
<div class="dir-forms">
<form class="form-card" id="applyForm" novalidate>
<div class="form-grid">
<div class="form-field">
<label for="apName">Name</label>
<input id="apName" name="Name" type="text" autocomplete="name" required />
</div>
<div class="form-field">
<label for="apEmail">Email</label>
<input id="apEmail" name="email" type="email" autocomplete="email" required />
<span class="hint">We confirm it before your listing goes live — and it is published on your card (see the consent below).</span>
</div>
<label class="form-check">
<input type="checkbox" name="email_public_consent" value="1" required />
<span>My email address is published on my public listing. I have read that and I agree. <span class="hint">(Your declaration — required.)</span></span>
</label>
<div class="form-field full">
<label for="apFocus">Your focus</label>
<input id="apFocus" name="Focus" type="text" placeholder="e.g. accounting and stock for small manufacturers" />
</div>
<div class="form-field full">
<label for="apDescription">About you, in your own words</label>
<textarea id="apDescription" name="description" rows="5" placeholder="What you do, who you do it for, how you work."></textarea>
</div>
<div class="form-field">
<label for="apCountry">Country</label>
<select id="apCountry" name="Country" required>
<!-- COUNTRY-OPTIONS-START -->
<option value="">— choose —</option>
<option value="AD">Andorra</option>
<option value="AE">United Arab Emirates</option>
<option value="AF">Afghanistan</option>
<option value="AG">Antigua and Barbuda</option>
<option value="AI">Anguilla</option>
<option value="AL">Albania</option>
<option value="AM">Armenia</option>
<option value="AO">Angola</option>
<option value="AQ">Antarctica</option>
<option value="AR">Argentina</option>
<option value="AS">American Samoa</option>
<option value="AT">Austria</option>
<option value="AU">Australia</option>
<option value="AW">Aruba</option>
<option value="AX">Aland</option>
<option value="AZ">Azerbaijan</option>
<option value="BA">Bosnia and Herzegovina</option>
<option value="BB">Barbados</option>
<option value="BD">Bangladesh</option>
<option value="BE">Belgium</option>
<option value="BF">Burkina Faso</option>
<option value="BG">Bulgaria</option>
<option value="BH">Bahrain</option>
<option value="BI">Burundi</option>
<option value="BJ">Benin</option>
<option value="BL">Saint Barthelemy</option>
<option value="BM">Bermuda</option>
<option value="BN">Brunei</option>
<option value="BO">Bolivia</option>
<option value="BQ">Bonaire</option>
<option value="BR">Brazil</option>
<option value="BS">Bahamas</option>
<option value="BT">Bhutan</option>
<option value="BV">Bouvet Island</option>
<option value="BW">Botswana</option>
<option value="BY">Belarus</option>
<option value="BZ">Belize</option>
<option value="CA">Canada</option>
<option value="CC">Cocos (Keeling) Islands</option>
<option value="CD">Democratic Republic of the Congo</option>
<option value="CF">Central African Republic</option>
<option value="CG">Republic of the Congo</option>
<option value="CH">Switzerland</option>
<option value="CI">Ivory Coast</option>
<option value="CK">Cook Islands</option>
<option value="CL">Chile</option>
<option value="CM">Cameroon</option>
<option value="CN">China</option>
<option value="CO">Colombia</option>
<option value="CR">Costa Rica</option>
<option value="CU">Cuba</option>
<option value="CV">Cape Verde</option>
<option value="CW">Curacao</option>
<option value="CX">Christmas Island</option>
<option value="CY">Cyprus</option>
<option value="CZ">Czech Republic</option>
<option value="DE">Germany</option>
<option value="DJ">Djibouti</option>
<option value="DK">Denmark</option>
<option value="DM">Dominica</option>
<option value="DO">Dominican Republic</option>
<option value="DZ">Algeria</option>
<option value="EC">Ecuador</option>
<option value="EE">Estonia</option>
<option value="EG">Egypt</option>
<option value="EH">Western Sahara</option>
<option value="ER">Eritrea</option>
<option value="ES">Spain</option>
<option value="ET">Ethiopia</option>
<option value="FI">Finland</option>
<option value="FJ">Fiji</option>
<option value="FK">Falkland Islands</option>
<option value="FM">Micronesia</option>
<option value="FO">Faroe Islands</option>
<option value="FR">France</option>
<option value="GA">Gabon</option>
<option value="GB">United Kingdom</option>
<option value="GD">Grenada</option>
<option value="GE">Georgia</option>
<option value="GF">French Guiana</option>
<option value="GG">Guernsey</option>
<option value="GH">Ghana</option>
<option value="GI">Gibraltar</option>
<option value="GL">Greenland</option>
<option value="GM">Gambia</option>
<option value="GN">Guinea</option>
<option value="GP">Guadeloupe</option>
<option value="GQ">Equatorial Guinea</option>
<option value="GR">Greece</option>
<option value="GS">South Georgia and the South Sandwich Islands</option>
<option value="GT">Guatemala</option>
<option value="GU">Guam</option>
<option value="GW">Guinea-Bissau</option>
<option value="GY">Guyana</option>
<option value="HK">Hong Kong</option>
<option value="HM">Heard Island and McDonald Islands</option>
<option value="HN">Honduras</option>
<option value="HR">Croatia</option>
<option value="HT">Haiti</option>
<option value="HU">Hungary</option>
<option value="ID">Indonesia</option>
<option value="IE">Ireland</option>
<option value="IL">Israel</option>
<option value="IM">Isle of Man</option>
<option value="IN">India</option>
<option value="IO">British Indian Ocean Territory</option>
<option value="IQ">Iraq</option>
<option value="IR">Iran</option>
<option value="IS">Iceland</option>
<option value="IT">Italy</option>
<option value="JE">Jersey</option>
<option value="JM">Jamaica</option>
<option value="JO">Jordan</option>
<option value="JP">Japan</option>
<option value="KE">Kenya</option>
<option value="KG">Kyrgyzstan</option>
<option value="KH">Cambodia</option>
<option value="KI">Kiribati</option>
<option value="KM">Comoros</option>
<option value="KN">Saint Kitts and Nevis</option>
<option value="KP">North Korea</option>
<option value="KR">South Korea</option>
<option value="KW">Kuwait</option>
<option value="KY">Cayman Islands</option>
<option value="KZ">Kazakhstan</option>
<option value="LA">Laos</option>
<option value="LB">Lebanon</option>
<option value="LC">Saint Lucia</option>
<option value="LI">Liechtenstein</option>
<option value="LK">Sri Lanka</option>
<option value="LR">Liberia</option>
<option value="LS">Lesotho</option>
<option value="LT">Lithuania</option>
<option value="LU">Luxembourg</option>
<option value="LV">Latvia</option>
<option value="LY">Libya</option>
<option value="MA">Morocco</option>
<option value="MC">Monaco</option>
<option value="MD">Moldova</option>
<option value="ME">Montenegro</option>
<option value="MF">Saint Martin</option>
<option value="MG">Madagascar</option>
<option value="MH">Marshall Islands</option>
<option value="MK">North Macedonia</option>
<option value="ML">Mali</option>
<option value="MM">Myanmar (Burma)</option>
<option value="MN">Mongolia</option>
<option value="MO">Macao</option>
<option value="MP">Northern Mariana Islands</option>
<option value="MQ">Martinique</option>
<option value="MR">Mauritania</option>
<option value="MS">Montserrat</option>
<option value="MT">Malta</option>
<option value="MU">Mauritius</option>
<option value="MV">Maldives</option>
<option value="MW">Malawi</option>
<option value="MX">Mexico</option>
<option value="MY">Malaysia</option>
<option value="MZ">Mozambique</option>
<option value="NA">Namibia</option>
<option value="NC">New Caledonia</option>
<option value="NE">Niger</option>
<option value="NF">Norfolk Island</option>
<option value="NG">Nigeria</option>
<option value="NI">Nicaragua</option>
<option value="NL">Netherlands</option>
<option value="NO">Norway</option>
<option value="NP">Nepal</option>
<option value="NR">Nauru</option>
<option value="NU">Niue</option>
<option value="NZ">New Zealand</option>
<option value="OM">Oman</option>
<option value="PA">Panama</option>
<option value="PE">Peru</option>
<option value="PF">French Polynesia</option>
<option value="PG">Papua New Guinea</option>
<option value="PH">Philippines</option>
<option value="PK">Pakistan</option>
<option value="PL">Poland</option>
<option value="PM">Saint Pierre and Miquelon</option>
<option value="PN">Pitcairn Islands</option>
<option value="PR">Puerto Rico</option>
<option value="PS">Palestine</option>
<option value="PT">Portugal</option>
<option value="PW">Palau</option>
<option value="PY">Paraguay</option>
<option value="QA">Qatar</option>
<option value="RE">Reunion</option>
<option value="RO">Romania</option>
<option value="RS">Serbia</option>
<option value="RU">Russia</option>
<option value="RW">Rwanda</option>
<option value="SA">Saudi Arabia</option>
<option value="SB">Solomon Islands</option>
<option value="SC">Seychelles</option>
<option value="SD">Sudan</option>
<option value="SE">Sweden</option>
<option value="SG">Singapore</option>
<option value="SH">Saint Helena</option>
<option value="SI">Slovenia</option>
<option value="SJ">Svalbard and Jan Mayen</option>
<option value="SK">Slovakia</option>
<option value="SL">Sierra Leone</option>
<option value="SM">San Marino</option>
<option value="SN">Senegal</option>
<option value="SO">Somalia</option>
<option value="SR">Suriname</option>
<option value="SS">South Sudan</option>
<option value="ST">Sao Tome and Principe</option>
<option value="SV">El Salvador</option>
<option value="SX">Sint Maarten</option>
<option value="SY">Syria</option>
<option value="SZ">Eswatini</option>
<option value="TC">Turks and Caicos Islands</option>
<option value="TD">Chad</option>
<option value="TF">French Southern Territories</option>
<option value="TG">Togo</option>
<option value="TH">Thailand</option>
<option value="TJ">Tajikistan</option>
<option value="TK">Tokelau</option>
<option value="TL">East Timor</option>
<option value="TM">Turkmenistan</option>
<option value="TN">Tunisia</option>
<option value="TO">Tonga</option>
<option value="TR">Turkey</option>
<option value="TT">Trinidad and Tobago</option>
<option value="TV">Tuvalu</option>
<option value="TW">Taiwan</option>
<option value="TZ">Tanzania</option>
<option value="UA">Ukraine</option>
<option value="UG">Uganda</option>
<option value="UM">U.S. Minor Outlying Islands</option>
<option value="US">United States</option>
<option value="UY">Uruguay</option>
<option value="UZ">Uzbekistan</option>
<option value="VA">Vatican City</option>
<option value="VC">Saint Vincent and the Grenadines</option>
<option value="VE">Venezuela</option>
<option value="VG">British Virgin Islands</option>
<option value="VI">U.S. Virgin Islands</option>
<option value="VN">Vietnam</option>
<option value="VU">Vanuatu</option>
<option value="WF">Wallis and Futuna</option>
<option value="WS">Samoa</option>
<option value="YE">Yemen</option>
<option value="YT">Mayotte</option>
<option value="ZA">South Africa</option>
<option value="ZM">Zambia</option>
<option value="ZW">Zimbabwe</option>
<!-- COUNTRY-OPTIONS-END -->
</select>
<span class="hint">Where you work from. Pick one — this becomes a filter in the directory.</span>
</div>
<div class="form-field">
<label for="apVersions">Odoo versions</label>
<input id="apVersions" name="odoo_versions" type="text" placeholder="16, 17, 18" />
<span class="hint">Separate with commas. The versions you actually work in.</span>
</div>
<fieldset class="form-field full lang-field">
<legend>Languages</legend>
<div class="lang-list" id="apLanguages">
<!-- LANGUAGE-OPTIONS-START -->
<span class="lang-pick"><input type="checkbox" id="ap-lang-afrikaans" name="languages" value="Afrikaans" /><label for="ap-lang-afrikaans">Afrikaans</label><select class="lang-level" name="language_level" data-language="Afrikaans" aria-label="Level in Afrikaans"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-amharic" name="languages" value="Amharic" /><label for="ap-lang-amharic">Amharic</label><select class="lang-level" name="language_level" data-language="Amharic" aria-label="Level in Amharic"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-arabic" name="languages" value="Arabic" /><label for="ap-lang-arabic">Arabic</label><select class="lang-level" name="language_level" data-language="Arabic" aria-label="Level in Arabic"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-aymara" name="languages" value="Aymara" /><label for="ap-lang-aymara">Aymara</label><select class="lang-level" name="language_level" data-language="Aymara" aria-label="Level in Aymara"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-azerbaijani" name="languages" value="Azerbaijani" /><label for="ap-lang-azerbaijani">Azerbaijani</label><select class="lang-level" name="language_level" data-language="Azerbaijani" aria-label="Level in Azerbaijani"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-belarusian" name="languages" value="Belarusian" /><label for="ap-lang-belarusian">Belarusian</label><select class="lang-level" name="language_level" data-language="Belarusian" aria-label="Level in Belarusian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-bulgarian" name="languages" value="Bulgarian" /><label for="ap-lang-bulgarian">Bulgarian</label><select class="lang-level" name="language_level" data-language="Bulgarian" aria-label="Level in Bulgarian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-bislama" name="languages" value="Bislama" /><label for="ap-lang-bislama">Bislama</label><select class="lang-level" name="language_level" data-language="Bislama" aria-label="Level in Bislama"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-bengali" name="languages" value="Bengali" /><label for="ap-lang-bengali">Bengali</label><select class="lang-level" name="language_level" data-language="Bengali" aria-label="Level in Bengali"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-bosnian" name="languages" value="Bosnian" /><label for="ap-lang-bosnian">Bosnian</label><select class="lang-level" name="language_level" data-language="Bosnian" aria-label="Level in Bosnian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-catalan" name="languages" value="Catalan" /><label for="ap-lang-catalan">Catalan</label><select class="lang-level" name="language_level" data-language="Catalan" aria-label="Level in Catalan"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-chamorro" name="languages" value="Chamorro" /><label for="ap-lang-chamorro">Chamorro</label><select class="lang-level" name="language_level" data-language="Chamorro" aria-label="Level in Chamorro"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-czech" name="languages" value="Czech" /><label for="ap-lang-czech">Czech</label><select class="lang-level" name="language_level" data-language="Czech" aria-label="Level in Czech"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-danish" name="languages" value="Danish" /><label for="ap-lang-danish">Danish</label><select class="lang-level" name="language_level" data-language="Danish" aria-label="Level in Danish"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-german" name="languages" value="German" /><label for="ap-lang-german">German</label><select class="lang-level" name="language_level" data-language="German" aria-label="Level in German"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-divehi" name="languages" value="Divehi" /><label for="ap-lang-divehi">Divehi</label><select class="lang-level" name="language_level" data-language="Divehi" aria-label="Level in Divehi"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-dzongkha" name="languages" value="Dzongkha" /><label for="ap-lang-dzongkha">Dzongkha</label><select class="lang-level" name="language_level" data-language="Dzongkha" aria-label="Level in Dzongkha"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-greek" name="languages" value="Greek" /><label for="ap-lang-greek">Greek</label><select class="lang-level" name="language_level" data-language="Greek" aria-label="Level in Greek"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-english" name="languages" value="English" /><label for="ap-lang-english">English</label><select class="lang-level" name="language_level" data-language="English" aria-label="Level in English"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-spanish" name="languages" value="Spanish" /><label for="ap-lang-spanish">Spanish</label><select class="lang-level" name="language_level" data-language="Spanish" aria-label="Level in Spanish"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-estonian" name="languages" value="Estonian" /><label for="ap-lang-estonian">Estonian</label><select class="lang-level" name="language_level" data-language="Estonian" aria-label="Level in Estonian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-basque" name="languages" value="Basque" /><label for="ap-lang-basque">Basque</label><select class="lang-level" name="language_level" data-language="Basque" aria-label="Level in Basque"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-persian" name="languages" value="Persian" /><label for="ap-lang-persian">Persian</label><select class="lang-level" name="language_level" data-language="Persian" aria-label="Level in Persian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-peul" name="languages" value="Peul" /><label for="ap-lang-peul">Peul</label><select class="lang-level" name="language_level" data-language="Peul" aria-label="Level in Peul"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-finnish" name="languages" value="Finnish" /><label for="ap-lang-finnish">Finnish</label><select class="lang-level" name="language_level" data-language="Finnish" aria-label="Level in Finnish"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-fijian" name="languages" value="Fijian" /><label for="ap-lang-fijian">Fijian</label><select class="lang-level" name="language_level" data-language="Fijian" aria-label="Level in Fijian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-faroese" name="languages" value="Faroese" /><label for="ap-lang-faroese">Faroese</label><select class="lang-level" name="language_level" data-language="Faroese" aria-label="Level in Faroese"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-french" name="languages" value="French" /><label for="ap-lang-french">French</label><select class="lang-level" name="language_level" data-language="French" aria-label="Level in French"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-irish" name="languages" value="Irish" /><label for="ap-lang-irish">Irish</label><select class="lang-level" name="language_level" data-language="Irish" aria-label="Level in Irish"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-galician" name="languages" value="Galician" /><label for="ap-lang-galician">Galician</label><select class="lang-level" name="language_level" data-language="Galician" aria-label="Level in Galician"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-guarani" name="languages" value="Guarani" /><label for="ap-lang-guarani">Guarani</label><select class="lang-level" name="language_level" data-language="Guarani" aria-label="Level in Guarani"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-manx" name="languages" value="Manx" /><label for="ap-lang-manx">Manx</label><select class="lang-level" name="language_level" data-language="Manx" aria-label="Level in Manx"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-hebrew" name="languages" value="Hebrew" /><label for="ap-lang-hebrew">Hebrew</label><select class="lang-level" name="language_level" data-language="Hebrew" aria-label="Level in Hebrew"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-hindi" name="languages" value="Hindi" /><label for="ap-lang-hindi">Hindi</label><select class="lang-level" name="language_level" data-language="Hindi" aria-label="Level in Hindi"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-croatian" name="languages" value="Croatian" /><label for="ap-lang-croatian">Croatian</label><select class="lang-level" name="language_level" data-language="Croatian" aria-label="Level in Croatian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-haitian" name="languages" value="Haitian" /><label for="ap-lang-haitian">Haitian</label><select class="lang-level" name="language_level" data-language="Haitian" aria-label="Level in Haitian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-hungarian" name="languages" value="Hungarian" /><label for="ap-lang-hungarian">Hungarian</label><select class="lang-level" name="language_level" data-language="Hungarian" aria-label="Level in Hungarian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-armenian" name="languages" value="Armenian" /><label for="ap-lang-armenian">Armenian</label><select class="lang-level" name="language_level" data-language="Armenian" aria-label="Level in Armenian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-indonesian" name="languages" value="Indonesian" /><label for="ap-lang-indonesian">Indonesian</label><select class="lang-level" name="language_level" data-language="Indonesian" aria-label="Level in Indonesian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-icelandic" name="languages" value="Icelandic" /><label for="ap-lang-icelandic">Icelandic</label><select class="lang-level" name="language_level" data-language="Icelandic" aria-label="Level in Icelandic"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-italian" name="languages" value="Italian" /><label for="ap-lang-italian">Italian</label><select class="lang-level" name="language_level" data-language="Italian" aria-label="Level in Italian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-japanese" name="languages" value="Japanese" /><label for="ap-lang-japanese">Japanese</label><select class="lang-level" name="language_level" data-language="Japanese" aria-label="Level in Japanese"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-georgian" name="languages" value="Georgian" /><label for="ap-lang-georgian">Georgian</label><select class="lang-level" name="language_level" data-language="Georgian" aria-label="Level in Georgian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-kongo" name="languages" value="Kongo" /><label for="ap-lang-kongo">Kongo</label><select class="lang-level" name="language_level" data-language="Kongo" aria-label="Level in Kongo"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-kazakh" name="languages" value="Kazakh" /><label for="ap-lang-kazakh">Kazakh</label><select class="lang-level" name="language_level" data-language="Kazakh" aria-label="Level in Kazakh"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-greenlandic" name="languages" value="Greenlandic" /><label for="ap-lang-greenlandic">Greenlandic</label><select class="lang-level" name="language_level" data-language="Greenlandic" aria-label="Level in Greenlandic"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-cambodian" name="languages" value="Cambodian" /><label for="ap-lang-cambodian">Cambodian</label><select class="lang-level" name="language_level" data-language="Cambodian" aria-label="Level in Cambodian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-korean" name="languages" value="Korean" /><label for="ap-lang-korean">Korean</label><select class="lang-level" name="language_level" data-language="Korean" aria-label="Level in Korean"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-kurdish" name="languages" value="Kurdish" /><label for="ap-lang-kurdish">Kurdish</label><select class="lang-level" name="language_level" data-language="Kurdish" aria-label="Level in Kurdish"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-kyrgyz" name="languages" value="Kyrgyz" /><label for="ap-lang-kyrgyz">Kyrgyz</label><select class="lang-level" name="language_level" data-language="Kyrgyz" aria-label="Level in Kyrgyz"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-latin" name="languages" value="Latin" /><label for="ap-lang-latin">Latin</label><select class="lang-level" name="language_level" data-language="Latin" aria-label="Level in Latin"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-luxembourgish" name="languages" value="Luxembourgish" /><label for="ap-lang-luxembourgish">Luxembourgish</label><select class="lang-level" name="language_level" data-language="Luxembourgish" aria-label="Level in Luxembourgish"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-lingala" name="languages" value="Lingala" /><label for="ap-lang-lingala">Lingala</label><select class="lang-level" name="language_level" data-language="Lingala" aria-label="Level in Lingala"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-laotian" name="languages" value="Laotian" /><label for="ap-lang-laotian">Laotian</label><select class="lang-level" name="language_level" data-language="Laotian" aria-label="Level in Laotian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-lithuanian" name="languages" value="Lithuanian" /><label for="ap-lang-lithuanian">Lithuanian</label><select class="lang-level" name="language_level" data-language="Lithuanian" aria-label="Level in Lithuanian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-luba-katanga" name="languages" value="Luba-Katanga" /><label for="ap-lang-luba-katanga">Luba-Katanga</label><select class="lang-level" name="language_level" data-language="Luba-Katanga" aria-label="Level in Luba-Katanga"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-latvian" name="languages" value="Latvian" /><label for="ap-lang-latvian">Latvian</label><select class="lang-level" name="language_level" data-language="Latvian" aria-label="Level in Latvian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-malagasy" name="languages" value="Malagasy" /><label for="ap-lang-malagasy">Malagasy</label><select class="lang-level" name="language_level" data-language="Malagasy" aria-label="Level in Malagasy"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-marshallese" name="languages" value="Marshallese" /><label for="ap-lang-marshallese">Marshallese</label><select class="lang-level" name="language_level" data-language="Marshallese" aria-label="Level in Marshallese"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-maori" name="languages" value="Maori" /><label for="ap-lang-maori">Maori</label><select class="lang-level" name="language_level" data-language="Maori" aria-label="Level in Maori"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-macedonian" name="languages" value="Macedonian" /><label for="ap-lang-macedonian">Macedonian</label><select class="lang-level" name="language_level" data-language="Macedonian" aria-label="Level in Macedonian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-mongolian" name="languages" value="Mongolian" /><label for="ap-lang-mongolian">Mongolian</label><select class="lang-level" name="language_level" data-language="Mongolian" aria-label="Level in Mongolian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-malay" name="languages" value="Malay" /><label for="ap-lang-malay">Malay</label><select class="lang-level" name="language_level" data-language="Malay" aria-label="Level in Malay"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-maltese" name="languages" value="Maltese" /><label for="ap-lang-maltese">Maltese</label><select class="lang-level" name="language_level" data-language="Maltese" aria-label="Level in Maltese"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-burmese" name="languages" value="Burmese" /><label for="ap-lang-burmese">Burmese</label><select class="lang-level" name="language_level" data-language="Burmese" aria-label="Level in Burmese"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-nauruan" name="languages" value="Nauruan" /><label for="ap-lang-nauruan">Nauruan</label><select class="lang-level" name="language_level" data-language="Nauruan" aria-label="Level in Nauruan"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-norwegian-bokmål" name="languages" value="Norwegian Bokmål" /><label for="ap-lang-norwegian-bokmål">Norwegian Bokmål</label><select class="lang-level" name="language_level" data-language="Norwegian Bokmål" aria-label="Level in Norwegian Bokmål"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-north-ndebele" name="languages" value="North Ndebele" /><label for="ap-lang-north-ndebele">North Ndebele</label><select class="lang-level" name="language_level" data-language="North Ndebele" aria-label="Level in North Ndebele"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-nepali" name="languages" value="Nepali" /><label for="ap-lang-nepali">Nepali</label><select class="lang-level" name="language_level" data-language="Nepali" aria-label="Level in Nepali"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-dutch" name="languages" value="Dutch" /><label for="ap-lang-dutch">Dutch</label><select class="lang-level" name="language_level" data-language="Dutch" aria-label="Level in Dutch"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-norwegian-nynorsk" name="languages" value="Norwegian Nynorsk" /><label for="ap-lang-norwegian-nynorsk">Norwegian Nynorsk</label><select class="lang-level" name="language_level" data-language="Norwegian Nynorsk" aria-label="Level in Norwegian Nynorsk"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-norwegian" name="languages" value="Norwegian" /><label for="ap-lang-norwegian">Norwegian</label><select class="lang-level" name="language_level" data-language="Norwegian" aria-label="Level in Norwegian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-south-ndebele" name="languages" value="South Ndebele" /><label for="ap-lang-south-ndebele">South Ndebele</label><select class="lang-level" name="language_level" data-language="South Ndebele" aria-label="Level in South Ndebele"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-chichewa" name="languages" value="Chichewa" /><label for="ap-lang-chichewa">Chichewa</label><select class="lang-level" name="language_level" data-language="Chichewa" aria-label="Level in Chichewa"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-occitan" name="languages" value="Occitan" /><label for="ap-lang-occitan">Occitan</label><select class="lang-level" name="language_level" data-language="Occitan" aria-label="Level in Occitan"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-panjabi-/-punjabi" name="languages" value="Panjabi / Punjabi" /><label for="ap-lang-panjabi-/-punjabi">Panjabi / Punjabi</label><select class="lang-level" name="language_level" data-language="Panjabi / Punjabi" aria-label="Level in Panjabi / Punjabi"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-polish" name="languages" value="Polish" /><label for="ap-lang-polish">Polish</label><select class="lang-level" name="language_level" data-language="Polish" aria-label="Level in Polish"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-pashto" name="languages" value="Pashto" /><label for="ap-lang-pashto">Pashto</label><select class="lang-level" name="language_level" data-language="Pashto" aria-label="Level in Pashto"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-portuguese" name="languages" value="Portuguese" /><label for="ap-lang-portuguese">Portuguese</label><select class="lang-level" name="language_level" data-language="Portuguese" aria-label="Level in Portuguese"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-quechua" name="languages" value="Quechua" /><label for="ap-lang-quechua">Quechua</label><select class="lang-level" name="language_level" data-language="Quechua" aria-label="Level in Quechua"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-kirundi" name="languages" value="Kirundi" /><label for="ap-lang-kirundi">Kirundi</label><select class="lang-level" name="language_level" data-language="Kirundi" aria-label="Level in Kirundi"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-romanian" name="languages" value="Romanian" /><label for="ap-lang-romanian">Romanian</label><select class="lang-level" name="language_level" data-language="Romanian" aria-label="Level in Romanian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-russian" name="languages" value="Russian" /><label for="ap-lang-russian">Russian</label><select class="lang-level" name="language_level" data-language="Russian" aria-label="Level in Russian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-rwandi" name="languages" value="Rwandi" /><label for="ap-lang-rwandi">Rwandi</label><select class="lang-level" name="language_level" data-language="Rwandi" aria-label="Level in Rwandi"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-sango" name="languages" value="Sango" /><label for="ap-lang-sango">Sango</label><select class="lang-level" name="language_level" data-language="Sango" aria-label="Level in Sango"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-sinhalese" name="languages" value="Sinhalese" /><label for="ap-lang-sinhalese">Sinhalese</label><select class="lang-level" name="language_level" data-language="Sinhalese" aria-label="Level in Sinhalese"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-slovak" name="languages" value="Slovak" /><label for="ap-lang-slovak">Slovak</label><select class="lang-level" name="language_level" data-language="Slovak" aria-label="Level in Slovak"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-slovenian" name="languages" value="Slovenian" /><label for="ap-lang-slovenian">Slovenian</label><select class="lang-level" name="language_level" data-language="Slovenian" aria-label="Level in Slovenian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-samoan" name="languages" value="Samoan" /><label for="ap-lang-samoan">Samoan</label><select class="lang-level" name="language_level" data-language="Samoan" aria-label="Level in Samoan"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-shona" name="languages" value="Shona" /><label for="ap-lang-shona">Shona</label><select class="lang-level" name="language_level" data-language="Shona" aria-label="Level in Shona"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-somalia" name="languages" value="Somalia" /><label for="ap-lang-somalia">Somalia</label><select class="lang-level" name="language_level" data-language="Somalia" aria-label="Level in Somalia"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-albanian" name="languages" value="Albanian" /><label for="ap-lang-albanian">Albanian</label><select class="lang-level" name="language_level" data-language="Albanian" aria-label="Level in Albanian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-serbian" name="languages" value="Serbian" /><label for="ap-lang-serbian">Serbian</label><select class="lang-level" name="language_level" data-language="Serbian" aria-label="Level in Serbian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-swati" name="languages" value="Swati" /><label for="ap-lang-swati">Swati</label><select class="lang-level" name="language_level" data-language="Swati" aria-label="Level in Swati"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-southern-sotho" name="languages" value="Southern Sotho" /><label for="ap-lang-southern-sotho">Southern Sotho</label><select class="lang-level" name="language_level" data-language="Southern Sotho" aria-label="Level in Southern Sotho"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-swedish" name="languages" value="Swedish" /><label for="ap-lang-swedish">Swedish</label><select class="lang-level" name="language_level" data-language="Swedish" aria-label="Level in Swedish"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-swahili" name="languages" value="Swahili" /><label for="ap-lang-swahili">Swahili</label><select class="lang-level" name="language_level" data-language="Swahili" aria-label="Level in Swahili"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-tamil" name="languages" value="Tamil" /><label for="ap-lang-tamil">Tamil</label><select class="lang-level" name="language_level" data-language="Tamil" aria-label="Level in Tamil"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-tajik" name="languages" value="Tajik" /><label for="ap-lang-tajik">Tajik</label><select class="lang-level" name="language_level" data-language="Tajik" aria-label="Level in Tajik"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-thai" name="languages" value="Thai" /><label for="ap-lang-thai">Thai</label><select class="lang-level" name="language_level" data-language="Thai" aria-label="Level in Thai"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-tigrinya" name="languages" value="Tigrinya" /><label for="ap-lang-tigrinya">Tigrinya</label><select class="lang-level" name="language_level" data-language="Tigrinya" aria-label="Level in Tigrinya"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-turkmen" name="languages" value="Turkmen" /><label for="ap-lang-turkmen">Turkmen</label><select class="lang-level" name="language_level" data-language="Turkmen" aria-label="Level in Turkmen"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-tswana" name="languages" value="Tswana" /><label for="ap-lang-tswana">Tswana</label><select class="lang-level" name="language_level" data-language="Tswana" aria-label="Level in Tswana"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-tonga" name="languages" value="Tonga" /><label for="ap-lang-tonga">Tonga</label><select class="lang-level" name="language_level" data-language="Tonga" aria-label="Level in Tonga"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-turkish" name="languages" value="Turkish" /><label for="ap-lang-turkish">Turkish</label><select class="lang-level" name="language_level" data-language="Turkish" aria-label="Level in Turkish"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-tsonga" name="languages" value="Tsonga" /><label for="ap-lang-tsonga">Tsonga</label><select class="lang-level" name="language_level" data-language="Tsonga" aria-label="Level in Tsonga"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-ukrainian" name="languages" value="Ukrainian" /><label for="ap-lang-ukrainian">Ukrainian</label><select class="lang-level" name="language_level" data-language="Ukrainian" aria-label="Level in Ukrainian"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-urdu" name="languages" value="Urdu" /><label for="ap-lang-urdu">Urdu</label><select class="lang-level" name="language_level" data-language="Urdu" aria-label="Level in Urdu"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-uzbek" name="languages" value="Uzbek" /><label for="ap-lang-uzbek">Uzbek</label><select class="lang-level" name="language_level" data-language="Uzbek" aria-label="Level in Uzbek"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-venda" name="languages" value="Venda" /><label for="ap-lang-venda">Venda</label><select class="lang-level" name="language_level" data-language="Venda" aria-label="Level in Venda"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-vietnamese" name="languages" value="Vietnamese" /><label for="ap-lang-vietnamese">Vietnamese</label><select class="lang-level" name="language_level" data-language="Vietnamese" aria-label="Level in Vietnamese"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-xhosa" name="languages" value="Xhosa" /><label for="ap-lang-xhosa">Xhosa</label><select class="lang-level" name="language_level" data-language="Xhosa" aria-label="Level in Xhosa"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-chinese" name="languages" value="Chinese" /><label for="ap-lang-chinese">Chinese</label><select class="lang-level" name="language_level" data-language="Chinese" aria-label="Level in Chinese"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<span class="lang-pick"><input type="checkbox" id="ap-lang-zulu" name="languages" value="Zulu" /><label for="ap-lang-zulu">Zulu</label><select class="lang-level" name="language_level" data-language="Zulu" aria-label="Level in Zulu"><option value="">—</option><option value="native">native</option><option value="fluent">fluent</option><option value="advanced">advanced</option><option value="intermediate">intermediate</option><option value="beginner">beginner</option></select></span>
<!-- LANGUAGE-OPTIONS-END -->
</div>
<span class="hint">Pick the languages you work in. Each is stored by its standard name, so the filter never gets a name like “Spanish - beginners”. A level is optional and kept apart from the name.</span>
</fieldset>
<div class="form-field">
<label for="apSpecialities">Specialities</label>
<input id="apSpecialities" name="specialities" type="text" placeholder="accounting, stock, migration" />
<span class="hint">Separate with commas.</span>
</div>
<div class="form-field">
<label for="apGithub">GitHub URL <span class="hint">(optional)</span></label>
<input id="apGithub" name="github" type="url" placeholder="https://github.com/…" />
</div>
<div class="form-field">
<label for="apOca">OCA profile URL <span class="hint">(optional)</span></label>
<input id="apOca" name="oca_profile" type="url" placeholder="https://github.com/OCA" />
</div>
<div class="form-field">
<label for="apLinkedin">LinkedIn URL <span class="hint">(optional)</span></label>
<input id="apLinkedin" name="Linkedin" type="url" placeholder="https://www.linkedin.com/in/…" />
</div>
<div class="form-field">
<label for="apUpwork">Upwork URL <span class="hint">(optional)</span></label>
<input id="apUpwork" name="upwork" type="url" placeholder="https://www.upwork.com/freelancers/…" />
</div>
<div class="form-field">
<label for="apFiverr">Fiverr URL <span class="hint">(optional)</span></label>
<input id="apFiverr" name="fiverr" type="url" placeholder="https://www.fiverr.com/…" />
</div>
<div class="form-field">
<label for="apImage">Photo <span class="hint">(optional)</span></label>
<input id="apImage" name="Image" type="file" accept="image/*" />
<span class="hint">Leave it out if you prefer. If you add one it goes on your card, and it is checked before it is published.</span>
</div>
<label class="form-check">
<input type="checkbox" name="OCA_member" value="1" />
<span>I am an OCA member. <span class="hint">(Your declaration; we do not verify it.)</span></span>
</label>
<label class="form-check">
<input type="checkbox" name="is_individual" value="1" required />
<span>I work as an individual freelancer, not as an agency. <span class="hint">(Required — this directory is for freelancers.)</span></span>
</label>
<div class="form-actions">
<button type="submit" class="btn btn-brass">Apply to be listed</button>
<span class="form-status" id="applyStatus" role="status"></span>
</div>
</div>
</form>
</div>
<noscript><p class="form-noscript">This form needs JavaScript. Email <a href="mailto:hello@odoo4projects.com">hello@odoo4projects.com</a> and we will add your listing by hand.</p></noscript>
</div>
</section>
<!-- Invite a friend (form 4) — freelancer-facing, placed next to the application
form: it is a freelancer-to-freelancer action, not something a client does.
WE send the invitation from our own address; the sender's address sends
nothing. The route it posts to (odoo_experts_invite_friend) is a STUB the user
has yet to build, so the form ships DARK: empty endpoint constant, hidden behind
the "not open yet" line below — no submit, no false confirmation. The invitation
must carry a visible one-click opt-out before this form is switched on. -->
<section class="section-light" id="invite">
<div class="container">
<h2 class="section-title">Know another Odoo freelancer?</h2>
<p class="form-notlive" id="inviteNotLive">Not open yet. Email <a href="mailto:hello@odoo4projects.com?subject=Invite%20a%20freelancer">hello@odoo4projects.com</a> a name and an address and we will invite them by hand — the invitation says who suggested them and carries a one-click opt-out.</p>
<div class="dir-forms" id="inviteFormWrap" hidden>
<form class="form-card" id="inviteForm" novalidate>
<p class="form-lede">Send us your name and a fellow freelancer's email address. <strong>We</strong> email them one invitation to sign up — from ODOO4projects, not from your address — and we tell them you suggested it. The invitation carries a one-click opt-out, and we use their address for that invitation only.</p>
<div class="form-grid">
<div class="form-field">
<label for="ivName">Your name</label>
<input id="ivName" name="Name" type="text" autocomplete="name" required />
</div>
<div class="form-field">
<label for="ivEmail">Your friend's email</label>
<input id="ivEmail" name="friend_email" type="email" autocomplete="email" required />
<span class="hint">We email them one invitation and say you suggested it. Nothing goes out from your address.</span>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-brass">Invite a friend</button>
<span class="form-status" id="inviteStatus" role="status"></span>
</div>
</div>
</form>
</div>
<noscript><p class="form-noscript">This form needs JavaScript. Email <a href="mailto:hello@odoo4projects.com">hello@odoo4projects.com</a> a name and an address and we will invite them by hand.</p></noscript>
</div>
</section>
<!-- Final CTA -->
<section class="section-dark cmp-cta" id="start">
<div class="container">
<h2 class="section-title">Start</h2>
<div class="cmp-lead">
<p>Browse the freelancers and write to one directly from their card — or email us your brief and we will pass it to the freelancers you pick.</p>
</div>
<div class="cmp-cta-actions">
<a href="#directory" class="btn btn-brass btn-lg">Browse the freelancers</a>
<a href="mailto:hello@odoo4projects.com?subject=Find%20me%20an%20Odoo%20freelancer" class="btn btn-outline btn-lg">Ask us to find one</a>
</div>
</div>
</section>
</main>
<!-- Footer (copied from a currently live selling page; no link to this page is added anywhere) -->
<footer class="footer">
<div class="container">
<div class="fgrid">
<div>
<h4>Useful Links</h4>
<ul>
<li><a href="/odoo-erp-automation-bootcamp/">ODOO Bootcamp</a></li>
<li><a href="/odoo-hosting-2027.html">ODOO Hosting in 2027</a></li>
<li><a href="/support.html">Support</a></li>
<li><a href="/legal.html">Legal</a></li>
</ul>
</div>
<div>
<h4>Partners</h4>
<div class="partner-list">
<a href="https://oc-guy.com" target="_blank" rel="noopener" class="partner-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9a227" stroke-width="2" aria-hidden="true"><path d="M20 7h-9M14 17H5M20 7l-4-4M20 7l-4 4M4 17l4 4M4 17l4-4" /></svg> OC Guy</a>
<a href="https://oca-py.org" target="_blank" rel="noopener" class="partner-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9a227" stroke-width="2" aria-hidden="true"><path d="M20 7h-9M14 17H5M20 7l-4-4M20 7l-4 4M4 17l4 4M4 17l4-4" /></svg> OCA Paraguay</a>
<a href="https://OD8N.com" target="_blank" rel="noopener" class="partner-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9a227" stroke-width="2" aria-hidden="true"><path d="M20 7h-9M14 17H5M20 7l-4-4M20 7l-4 4M4 17l4 4M4 17l4-4" /></svg> OD8N</a>
</div>
</div>
<div class="fcol-desc">
<h4>About us</h4>
<p>We build smart, scalable business solutions by combining the <strong>stability</strong> of Odoo ERP with the <strong>power</strong> of AI Agents and N8N automations.</p>
<p>From streamlined sales to global rollouts, our <strong>lean, eco-friendly</strong> approach helps businesses cut costs, boost efficiency, and grow without limits.</p>
</div>
<div>
<h4>Follow us on</h4>
<div class="social">
<a href="https://www.linkedin.com/company/odoo4projects/" target="_blank" aria-label="LinkedIn"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.76-1.95 4.02 0 4.76 2.5 4.76 5.75V21h-4v-5.5c0-1.31-.02-3-1.9-3-1.9 0-2.19 1.42-2.19 2.9V21H9z" /></svg></a>
<a href="https://www.youtube.com/@ODOO4projects" target="_blank" aria-label="YouTube"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.41-4.73a2.5 2.5 0 0 0-1.76-1.77C19.3 5.1 12 5.1 12 5.1s-7.3 0-8.83.4A2.5 2.5 0 0 0 1.4 7.27C1 8.8 1 12 1 12s0 3.2.41 4.73a2.5 2.5 0 0 0 1.76 1.77c1.53.4 8.83.4 8.83.4s7.3 0 8.83-.4a2.5 2.5 0 0 0 1.76-1.77C23 15.2 23 12 23 12zM9.75 15.02V8.98L15.5 12z" /></svg></a>
</div>
<form class="footer-contact-form" id="footerContactForm" action="mailto:hello@odoo4projects.com" method="post" enctype="text/plain">
<label class="oca-sr-only" for="footerContactEmail">Your email address</label>
<input type="email" id="footerContactEmail" name="email" placeholder="Your email address" required />
<div class="footer-textarea-wrap">
<label class="oca-sr-only" for="footerContactText">Your message</label>
<textarea id="footerContactText" name="message" placeholder="Your message..." required></textarea>
<button type="submit" class="footer-send-btn" aria-label="Send message"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M2 21l21-9L2 3v7l15 2-15 2z" /></svg></button>
</div>
<div class="footer-form-status" id="footerFormStatus" role="status"></div>
</form>
</div>
</div>
<div class="fcopy">Copyright &copy; ODOO4projects LLC</div>
</div>
</footer>
<script defer src="/static/lang.js"></script>
<script>
/* ============================================================================
Odoo freelancer directory — page script.
This page FILTERS the build-time HTML and posts the freelancer application.
The owner's add/edit form is NOT here — it lives on its own draft page. Its URL
is recorded in the wiki and the handoff, and it is deliberately not printed or
linked here.
ONE webhook, for everything. The freelancer platform is a SEPARATE system
from the CRM: there is no /crm, no /crm_odoo and nothing from the CRM skill
here. If you find yourself reaching for a CRM endpoint, stop — wrong system.
Write contract (same URL, id decides):
POST with an id → UPDATE that record
POST without an id → CREATE a new record
Email confirmation applies to both paths.
============================================================================ */
var ODOO_EXPERTS_WEBHOOK =
"https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts";
// The two relay routes below are STUBS the user has yet to build. While an
// endpoint is empty, the form that posts to it stays hidden behind a plain
// "not open yet" line (see #requestNotLive / #inviteNotLive) — the visitor can
// never submit, so the page can never confirm an action nothing performs.
// Set the URL to switch a form on; its note then hides itself. Nothing else changes.
//
// Setting this endpoint switches the REQUEST form on. The success copy in
// initRequestForm() must already be a RECEIPT ("we have your request"), never a
// delivery claim ("on its way", "we will send you their answers") — a visitor sees
// it the moment the form goes live, and the endpoint does not repair the copy.
var ODOO_EXPERTS_QUOTE_WEBHOOK = "";
//
// Setting this endpoint switches the INVITE form on. The success copy in
// initInviteForm() must already be a RECEIPT ("we have recorded your invitation"),
// never a delivery claim ("we will email them one invitation") — same reason.
var ODOO_EXPERTS_INVITE_WEBHOOK = "";
// Wire shape, per the user: `languages` and `specialities` are COMMA-SEPARATED
// STRINGS ("English,German"), NOT arrays — so the workflow can split one field.
// `odoo_versions` is still sent as an array. The build script splits BOTH shapes.
var MULTI_FIELDS = ["odoo_versions"]; // comma-separated → array
// Country VALUE FORMAT — what the Country select sends to the webhook.
// "code" → the ISO 3166-1 alpha-2 code, e.g. "DE" (default; stable, exact-matchable)
// "name" → the full English name, e.g. "Germany" (switch to this line if the
// workflow expects names instead of codes)
// The select always DISPLAYS the English name; this only changes the SUBMITTED value.
var COUNTRY_VALUE_FORMAT = "code";
var CSV_STRING_FIELDS = ["languages", "specialities"]; // comma-separated → STRING
var BOOL_FIELDS = ["OCA_member", "is_individual", "email_public_consent"];
var NUM_FIELDS = ["odoo4projects_clients"];
function fieldElements(form, name) {
return Array.prototype.slice.call(
form.querySelectorAll('[name="' + name.replace(/"/g, '\\"') + '"]')
);
}
/* Lower-case and strip accents, so "Zürich" matches "zurich" (search + dedupe). */
function fold(text) {
return String(text == null ? "" : text)
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
}
/* Comma-separated tokens from any shape of field: a text box split on commas, or a
group of checkboxes (the standardised `languages` field) read as the checked
values. Trims and drops blanks — one wire shape either way. */
function collectCsvTokens(els) {
var out = [];
els.forEach(function (el) {
if (el.type === "checkbox" || el.type === "radio") {
if (el.checked && el.value) out.push(el.value.trim());
} else {
(el.value || "").split(",").forEach(function (part) { out.push(part.trim()); });
}
});
return out.filter(Boolean);
}
/* The standard language names, read off this page's own language checklist — the
list is already in the form, so no second copy is shipped. */
function canonicalLanguages() {
var names = [];
Array.prototype.forEach.call(
document.querySelectorAll('input[name="languages"]'),
function (box) { if (box.value) names.push(box.value); }
);
return names;
}
/* Map stored language tokens to standard names: "Spanish - beginners" -> "Spanish".
A token matching no standard name yields nothing (it still shows as a tag). */
function canonicalFor(tokens) {
var names = canonicalLanguages();
var out = [];
tokens.forEach(function (token) {
var ft = fold(token), best = null;
names.forEach(function (n) {
var fn = fold(n);
if (ft === fn || ft.indexOf(fn + " ") === 0 || ft.indexOf(fn + "-") === 0 || ft.indexOf(fn + "(") === 0) {
if (best === null || n.length > best.length) best = n;
}
});
if (best && out.map(fold).indexOf(fold(best)) === -1) out.push(best);
});
return out;
}
/* The Country value to submit: the ISO code by default, the English name when
COUNTRY_VALUE_FORMAT is "name". Empty until the visitor picks a country. */
function countryValue(form) {
var sel = form.querySelector('select[name="Country"]');
if (!sel || !sel.value) return "";
if (COUNTRY_VALUE_FORMAT === "name") {
var opt = sel.options[sel.selectedIndex];
return opt ? opt.textContent.trim() : "";
}
return sel.value.trim();
}
function buildPayload(form, withId) {
var payload = {};
var names = [];
Array.prototype.forEach.call(form.querySelectorAll("[name]"), function (el) {
if (names.indexOf(el.name) === -1) names.push(el.name);
});
names.forEach(function (name) {
var els = fieldElements(form, name);
if (!els.length) return;
if (name === "id") {
if (!withId) return;
var raw = els[0].value.trim();
if (raw) payload.id = Number(raw);
return;
}
if (name === "Country") {
var countryVal = countryValue(form);
if (countryVal !== "") payload[name] = countryVal;
return;
}
if (MULTI_FIELDS.indexOf(name) !== -1) {
var text = els[0].value || "";
var list = text
.split(",")
.map(function (s) {
return s.trim();
})
.filter(Boolean);
if (list.length) payload[name] = list; // an empty box must not wipe the field
return;
}
if (name === "language_level") {
// Proficiency is a SEPARATE AXIS: a level rides in `language_levels`
// ("Spanish:beginner"), never inside a language name. The language NAME in
// `languages` is always a standard one, so the filter can never see a
// free-text "Spanish - beginners".
var pairs = [];
Array.prototype.forEach.call(
form.querySelectorAll('input[name="languages"]:checked'),
function (box) {
var row = box.closest(".lang-pick");
var sel = row ? row.querySelector("select") : null;
if (sel && sel.value) pairs.push(box.value + ":" + sel.value);
}
);
if (pairs.length) payload.language_levels = pairs.join(",");
return;
}
if (CSV_STRING_FIELDS.indexOf(name) !== -1) {
// Normalise to the wire shape: trim each entry, drop blanks, re-join. A text
// box is split on commas; the `languages` checklist is read as its checked
// values. "English, German" → "English,German"; nothing checked → omitted.
var norm = collectCsvTokens(els).join(",");
if (norm) payload[name] = norm; // an empty selection must not wipe the field
return;
}
if (BOOL_FIELDS.indexOf(name) !== -1) {
payload[name] = !!els[0].checked;
return;
}
if (NUM_FIELDS.indexOf(name) !== -1) {
var n = els[0].value.trim();
if (n !== "") payload[name] = Number(n);
return;
}
var value = els[0].value.trim();
if (value !== "") payload[name] = value;
});
return payload;
}
function readImageAsDataUrl(file) {
return new Promise(function (resolve, reject) {
var reader = new FileReader();
reader.onload = function () {
resolve(reader.result);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function setStatus(el, message, state) {
el.textContent = message;
if (state) el.setAttribute("data-state", state);
else el.removeAttribute("data-state");
}
/* One POST helper for all three routes. The endpoint is the only thing that
differs; the shape of what each form sends is its own contract. */
function postTo(url, payload) {
return fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
/* Switch a relay form on only when its route exists. While the endpoint is
empty the form stays hidden and the "not open yet" note stands — no submit,
no false confirmation. Set the endpoint constant to reveal the form. */
function wireRelay(formWrapId, noteId, endpoint) {
if (!endpoint) return;
var wrap = document.getElementById(formWrapId);
var note = document.getElementById(noteId);
if (wrap) wrap.hidden = false;
if (note) note.hidden = true;
}
/* ---- The live list: render the directory from the webhook at page load ----
build-directory.py writes the cards into the page at BUILD time, which is why a
record added afterwards never reached the page. So the build-time cards stay in
the HTML exactly as they are and act as the FALLBACK: this fetch replaces them
only when it succeeds. A visitor with JavaScript off, or arriving while the
webhook is down, still sees the last built list — an empty directory is worse
than a stale one, so an empty or failed response never overwrites a populated one.
NOT BUILT — the confirmation gate. The user's flow creates a record only after
the email is confirmed, so an unconfirmed record must not show. This endpoint
exposes NO way to tell the two apart: there is no `status`, no `email_verified`,
nothing but the record's own fields. So nothing is guessed here — every record
the endpoint returns with a `Name` is rendered, exactly as build-directory.py
does it. The workflow has to expose a marker before this can gate on one. */
var LIVE_FILTERS = [
["Country", "Country", "country", "value"],
["odoo_versions", "Odoo version", "odoo-versions", "value"],
["languages", "Language", "languages", "language"],
["specialities", "Speciality", "specialities", "value"],
["OCA_member", "OCA membership", "oca", "bool"],
["odoo4projects_clients", "Projects on ODOO4projects", "projects", "min"]
];
// The webhook's casing has drifted once already. Accept the known aliases too —
// the same list as FIELD_ALIASES in build-directory.py, so both renderers agree.
var LIVE_ALIASES = { Country: ["country"], odoo_versions: ["Odoo_versions"] };
function liveValues(rec, field) {
var names = [field].concat(LIVE_ALIASES[field] || []);
var raw = null;
for (var i = 0; i < names.length; i++) {
var v = rec[names[i]];
if (v !== null && v !== undefined && v !== "") { raw = v; break; }
}
if (raw === null) return [];
var list = Array.isArray(raw) ? raw : String(raw).split(",");
return list.map(function (s) { return String(s).trim(); }).filter(Boolean);
}
function isLink(v) {
return v.indexOf("http://") === 0 || v.indexOf("https://") === 0 || v.indexOf("/") === 0;
}
/* A card renders a photo only for an allowlisted scheme: an https URL or an inline
data:image/ URI. Anything else — empty, junk like "gineoirn", another scheme —
falls back to the monogram. The freelancer controls this field, so it never
reaches src unchecked. (Separate from isLink(), which the *link* renderer also
uses and which accepts http:// and relative paths.) */
function isImageSrc(v) {
return v.indexOf("https://") === 0 || v.indexOf("data:image/") === 0;
}
function liveEl(tag, cls, text) {
var node = document.createElement(tag);
if (cls) node.className = cls;
if (text !== undefined) node.textContent = text;
return node;
}
/* Everything the freelancer wrote about themselves, folded, for the search box:
Name, description, Focus, specialities and the language names. */
function liveSearchText(rec) {
return fold([
String(rec.Name || ""),
String(rec.description || ""),
String(rec.Focus || ""),
liveValues(rec, "specialities").join(" "),
liveValues(rec, "languages").join(" ")
].join(" "));
}
/* The card's data-<attr> value for one active filter — mirrors attr_value() in
build-directory.py, field for field. */
function liveAttrValue(rec, pair) {
var field = pair[0], kind = pair[3];
if (kind === "bool") return rec[field] === true ? "1" : "0";
if (kind === "min") {
var n = parseInt(rec[field], 10);
return isNaN(n) ? "" : String(n);
}
if (kind === "language") return canonicalFor(liveValues(rec, field)).map(fold).join("|");
return liveValues(rec, field).map(fold).join("|");
}
/* One card, field for field the same as render_card() in build-directory.py.
Everything a freelancer wrote goes in as textContent, never as HTML. */
function liveCard(rec, active) {
var name = String(rec.Name || "").trim();
var card = liveEl("article", "dev-card");
card.setAttribute("data-name", name.toLowerCase());
card.setAttribute("data-search", liveSearchText(rec));
active.forEach(function (pair) {
card.setAttribute("data-" + pair[2], liveAttrValue(rec, pair));
});
var photo = String(rec.Image || "").trim();
var avatar;
if (isImageSrc(photo)) {
avatar = liveEl("img", "dev-photo"); // https URL or data:image/ URI only
avatar.src = photo;
avatar.alt = "";
avatar.width = 72;
avatar.height = 72;
avatar.loading = "lazy";
avatar.decoding = "async";
} else {
var parts = name.split(/\s+/).filter(Boolean);
var mark = parts.length > 1
? parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
: (parts[0] || "?").slice(0, 2);
avatar = liveEl("span", "dev-avatar", mark.toUpperCase());
avatar.setAttribute("aria-hidden", "true");
}
var head = liveEl("header", "dev-head");
var who = liveEl("div", "dev-id");
who.appendChild(liveEl("h3", "dev-name", name));
var focus = String(rec.Focus || "").trim();
if (focus) who.appendChild(liveEl("p", "dev-focus", focus));
head.appendChild(avatar);
head.appendChild(who);
card.appendChild(head);
var bio = String(rec.description || "").trim();
if (bio) card.appendChild(liveEl("p", "dev-bio", bio));
// Tags: only what the freelancer themselves declared.
var tags = [];
if (rec.OCA_member === true) tags.push("OCA member (as declared)");
if (rec.is_individual === true) tags.push("Works as an individual (as declared)");
liveValues(rec, "languages").forEach(function (v) { tags.push(v); });
liveValues(rec, "specialities").forEach(function (v) { tags.push(v); });
if (tags.length) {
var ul = liveEl("ul", "dev-tags");
tags.forEach(function (t) { ul.appendChild(liveEl("li", "dev-tag", t)); });
card.appendChild(ul);
}
// The one number we publish about a person, and only one we recorded ourselves.
var clients = parseInt(rec.odoo4projects_clients, 10);
if (!isNaN(clients)) {
var dl = liveEl("dl", "dev-facts");
var cell = document.createElement("div");
cell.appendChild(liveEl("dt", null, "Projects on ODOO4projects"));
cell.appendChild(liveEl("dd", null, clients + (clients === 1 ? " project" : " projects") +
" — a number we recorded"));
dl.appendChild(cell);
card.appendChild(dl);
}
var email = String(rec.email || "").trim();
var hasEmail = email.indexOf("@") !== -1;
var links = [];
[["Linkedin", "LinkedIn"], ["github", "GitHub"], ["oca_profile", "OCA profile"],
["upwork", "Upwork"], ["fiverr", "Fiverr"]].forEach(function (pair) {
var val = String(rec[pair[0]] || "").trim();
if (isLink(val)) {
var a = liveEl("a", null, pair[1]);
a.href = val;
a.target = "_blank";
a.rel = "noopener nofollow";
links.push(a);
}
});
if (hasEmail) {
var mail = liveEl("a", null, "Email this freelancer");
mail.href = "mailto:" + email;
links.push(mail);
}
if (links.length) {
var row = liveEl("p", "dev-links");
links.forEach(function (a, i) {
if (i) row.appendChild(document.createTextNode(" · "));
row.appendChild(a);
});
card.appendChild(row);
}
if (!hasEmail) {
card.appendChild(liveEl("p", "dev-contact-none",
"No contact details published for this freelancer."));
}
return card;
}
/* ISO code → English name, read off the application form's own Country <select>:
the 249 options are already on the page, so no second copy is shipped. */
function countryNames() {
var map = {};
var sel = document.getElementById("apCountry");
if (sel) {
Array.prototype.forEach.call(sel.options, function (o) {
if (o.value) map[o.value.toUpperCase()] = o.textContent.trim();
});
}
return map;
}
/* The filter bar, rebuilt from the records just fetched. Same rule as the build: a
filter appears only when the data supports it. A project minimum is additionally
printed only when a threshold can narrow the list (two distinct counts); with one
count it would return everyone, so it is not shipped. */
function liveFilters(records) {
var bar = document.getElementById("dirFilters");
var countries = countryNames();
var clearWrap = bar.querySelector(".dir-clear-wrap");
Array.prototype.slice.call(bar.querySelectorAll(".dir-filter")).forEach(function (node) {
if (!node.classList.contains("dir-search") && !node.classList.contains("dir-clear-wrap")) {
bar.removeChild(node);
}
});
var active = [];
LIVE_FILTERS.forEach(function (pair) {
var field = pair[0], label = pair[1], attr = pair[2], kind = pair[3];
var options = null;
if (kind === "bool") {
var anyBool = records.some(function (r) {
return r[field] !== null && r[field] !== undefined && r[field] !== "";
});
if (anyBool) options = [["1", "OCA member"], ["0", "Not an OCA member"]];
} else if (kind === "min") {
var counts = [];
records.forEach(function (r) {
var n = parseInt(r[field], 10);
if (!isNaN(n) && counts.indexOf(n) === -1) counts.push(n);
});
counts.sort(function (a, b) { return a - b; });
if (counts.length >= 2) {
options = counts.slice(1).map(function (c) { return [String(c), c + "+ projects"]; });
}
} else {
var seen = [];
records.forEach(function (rec) {
var vals = kind === "language" ? canonicalFor(liveValues(rec, field)) : liveValues(rec, field);
vals.forEach(function (v) {
if (!seen.some(function (s) { return fold(s) === fold(v); })) seen.push(v);
});
});
if (seen.length) {
seen.sort(function (a, b) { return fold(a) < fold(b) ? -1 : 1; });
options = seen.map(function (v) {
var shown = field === "Country" ? (countries[v.trim().toUpperCase()] || v) : v;
return [fold(v), shown];
});
}
}
if (!options) return; // no data → no filter
active.push(pair);
var wrap = liveEl("div", "dir-filter");
var id = "f-" + attr;
var lbl = liveEl("label", null, label);
lbl.setAttribute("for", id);
var sel = document.createElement("select");
sel.id = id;
sel.setAttribute("data-field", attr);
if (kind === "min") sel.setAttribute("data-match", "min");
sel.appendChild(new Option("All", ""));
options.forEach(function (o) { sel.appendChild(new Option(o[1], o[0])); });
wrap.appendChild(lbl);
wrap.appendChild(sel);
// Keep the selects between the static Search box and the static Clear button.
if (clearWrap) bar.insertBefore(wrap, clearWrap);
else bar.appendChild(wrap);
});
return active;
}
function initLiveList() {
var grid = document.getElementById("dirGrid");
if (!grid || !window.fetch || !ODOO_EXPERTS_WEBHOOK) return;
fetch(ODOO_EXPERTS_WEBHOOK, { headers: { Accept: "application/json" } })
.then(function (res) {
if (!res.ok) throw new Error("HTTP " + res.status);
return res.json();
})
.then(function (payload) {
var all = Array.isArray(payload) ? payload : (payload && payload.data) || [];
// A record with no Name is not a freelancer (build-directory.py skips it too).
var records = all.filter(function (r) { return r && String(r.Name || "").trim(); });
if (!records.length) return; // never trade a stale list for an empty one
// Deterministic, NEUTRAL order by Name — never by project count (no ranking),
// and never the endpoint's own order, which alternates between reads.
records.sort(function (a, b) {
var fa = fold(a.Name), fb = fold(b.Name);
return fa < fb ? -1 : (fa > fb ? 1 : 0);
});
var active = liveFilters(records);
while (grid.firstChild) grid.removeChild(grid.firstChild);
records.forEach(function (rec) { grid.appendChild(liveCard(rec, active)); });
applyFilters();
})
.catch(function () { /* the build-time list already on the page stands */ });
}
/* ---- Directory filtering (an enhancement over the build-time HTML) ---- */
function initFilters() {
var bar = document.getElementById("dirFilters");
if (!bar) return;
// One delegated listener for the life of the page: it re-reads the controls on
// every change, so the same code filters the build-time list and a runtime one.
bar.addEventListener("change", function (event) {
if (event.target && event.target.tagName === "SELECT") applyFilters();
});
bar.addEventListener("input", function (event) {
if (event.target && event.target.hasAttribute("data-search-box")) applyFilters();
});
var clear = document.getElementById("dirClear");
if (clear) {
clear.addEventListener("click", function () {
var box = bar.querySelector("[data-search-box]");
if (box) box.value = "";
Array.prototype.forEach.call(bar.querySelectorAll("select"), function (s) { s.value = ""; });
applyFilters();
});
}
applyFilters();
}
/* Search AND filters combine: a card shows only when it matches every control —
a folded substring for the search box, equality for a value filter, a minimum
for the project count. The same logic covers the build-time list and a runtime
one. A filter that matches nobody shows the explained empty state, never a blank
page. */
function applyFilters() {
var bar = document.getElementById("dirFilters");
var grid = document.getElementById("dirGrid");
var count = document.getElementById("dirCount");
var empty = document.getElementById("dirEmpty");
var clear = document.getElementById("dirClear");
if (!bar || !grid) return;
var search = bar.querySelector("[data-search-box]");
var query = search ? fold(search.value.trim()) : "";
var selects = Array.prototype.slice.call(bar.querySelectorAll("select"));
var cards = Array.prototype.slice.call(grid.querySelectorAll(".dev-card"));
var nothing = cards.length === 0;
bar.hidden = nothing;
if (count) count.hidden = nothing;
if (clear) {
clear.hidden = nothing || (!query && !selects.some(function (s) { return s.value; }));
}
var shown = 0;
cards.forEach(function (card) {
var ok = true;
if (query) ok = (card.getAttribute("data-search") || "").indexOf(query) !== -1;
if (ok) {
selects.forEach(function (sel) {
if (!ok) return;
var want = sel.value;
if (!want) return;
var have = card.getAttribute("data-" + sel.getAttribute("data-field")) || "";
if (sel.getAttribute("data-match") === "min") {
var n = parseInt(have, 10);
if (isNaN(n) || n < parseInt(want, 10)) ok = false;
} else if (have.split("|").indexOf(want) === -1) {
ok = false;
}
});
}
card.hidden = !ok;
if (ok) shown++;
});
if (count && !count.hidden) count.textContent = "Showing " + shown + " of " + cards.length;
if (empty) empty.hidden = nothing ? true : shown !== 0;
}
/* ---- Form 1: freelancer application (create — no id) ---- */
function initApplyForm() {
var form = document.getElementById("applyForm");
if (!form) return;
var status = document.getElementById("applyStatus");
form.addEventListener("submit", function (event) {
event.preventDefault();
if (!form.reportValidity()) return;
var submit = form.querySelector('button[type="submit"]');
submit.disabled = true;
setStatus(status, "Sending…", null);
var payload = buildPayload(form, false); // no id → create
var file = form.querySelector('[name="Image"]').files[0];
var withImage = file
? readImageAsDataUrl(file).then(function (dataUrl) {
payload.Image = dataUrl;
})
: Promise.resolve();
withImage
.then(function () {
return postTo(ODOO_EXPERTS_WEBHOOK, payload);
})
.then(function (res) {
if (!res.ok) throw new Error("HTTP " + res.status);
setStatus(
status,
"Thank you — check your inbox to confirm your email. Your listing goes live once it is confirmed and checked.",
"ok"
);
form.reset();
})
.catch(function () {
setStatus(
status,
"That did not send. Please try again, or email us and we will add your listing by hand.",
"err"
);
})
.then(function () {
submit.disabled = false;
});
});
}
/* ---- Form 3: anonymous client request. SHIPS DARK — the route is a stub, so
wireRelay keeps it hidden until ODOO_EXPERTS_QUOTE_WEBHOOK is set; these
handlers only run once the workflow exists. The client picks the freelancers,
writes the brief and leaves an address for the answers. */
function initRequestForm() {
var form = document.getElementById("requestForm");
if (!form) return;
var status = document.getElementById("requestStatus");
form.addEventListener("submit", function (event) {
event.preventDefault();
if (!form.reportValidity()) return;
var picks = Array.prototype.slice.call(
form.querySelectorAll('input[name="freelancers"]:checked')
);
if (!picks.length) {
setStatus(status, "Pick at least one freelancer to send your request to.", "err");
return;
}
var payload = {
brief: form.querySelector('[name="brief"]').value.trim(),
email: form.querySelector('[name="email"]').value.trim(),
freelancers: picks.map(function (box) {
var id = Number(box.getAttribute("data-id"));
return { id: isNaN(id) ? null : id, name: box.value };
})
};
var submit = form.querySelector('button[type="submit"]');
submit.disabled = true;
setStatus(status, "Sending…", null);
postTo(ODOO_EXPERTS_QUOTE_WEBHOOK, payload)
.then(function (res) {
if (!res.ok) throw new Error("HTTP " + res.status);
setStatus(
status,
"Thank you — we have your request. We will come back to you by email at the address you gave.",
"ok"
);
form.reset();
})
.catch(function () {
setStatus(
status,
"That did not send. Please try again, or email us at hello@odoo4projects.com and we will pass it on.",
"err"
);
})
.then(function () {
submit.disabled = false;
});
});
}
/* ---- Form 4: invite a friend. SHIPS DARK — the route is a stub, so wireRelay
keeps it hidden until ODOO_EXPERTS_INVITE_WEBHOOK is set. Two fields: the
sender's name and a friend's email. WE send the invitation, from our own
address — never from the sender's — and we say who suggested it. */
function initInviteForm() {
var form = document.getElementById("inviteForm");
if (!form) return;
var status = document.getElementById("inviteStatus");
form.addEventListener("submit", function (event) {
event.preventDefault();
if (!form.reportValidity()) return;
var submit = form.querySelector('button[type="submit"]');
submit.disabled = true;
setStatus(status, "Sending…", null);
postTo(ODOO_EXPERTS_INVITE_WEBHOOK, buildPayload(form, false))
.then(function (res) {
if (!res.ok) throw new Error("HTTP " + res.status);
setStatus(
status,
"Thank you — we have recorded your invitation. If we send it, it names you as the person who suggested them and carries a one-click opt-out.",
"ok"
);
form.reset();
})
.catch(function () {
setStatus(status, "That did not send. Please try again.", "err");
})
.then(function () {
submit.disabled = false;
});
});
}
wireRelay("requestFormWrap", "requestNotLive", ODOO_EXPERTS_QUOTE_WEBHOOK);
wireRelay("inviteFormWrap", "inviteNotLive", ODOO_EXPERTS_INVITE_WEBHOOK);
initFilters();
initLiveList();
initApplyForm();
initRequestForm();
initInviteForm();
</script>
</body>
</html>