Public Access
- languages/specialities now POST as comma-separated STRINGS ("English,German"),
not arrays; the build script splits them into separate filter options and tags.
- the client request form is no longer inert: it posts to odoo_experts_quote with
an explicit freelancer pick-list (never an implicit all) and a client address.
- new invite-a-friend form posts to odoo_experts_invite_friend (sender name +
friend email); we send the invitation, not the sender's address.
- both new forms show success only on a returned 200 (n8n answers 200 before the
workflow runs) and promise no timing.
- build-directory.py: render the pick-list, fix the country option casing.
- pages stay drafts (noindex, not in sitemap/llms, no inbound links).
1247 lines
49 KiB
HTML
1247 lines
49 KiB
HTML
<!doctype html>
|
|
<html lang="en-US">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Find an Odoo Freelancer for Your Project</title>
|
|
<meta name="description" content="Browse independent Odoo freelancers for your project and write to one directly, using the contact details they have published. Odoo Community specialists." />
|
|
<!-- DRAFT: not indexed, not linked, not in sitemap.xml / llms.txt. All five draft
|
|
conditions hold (noindex · not in sitemap · not in llms.txt · no inbound links ·
|
|
no announcement surface). Remove this robots meta on go-live. -->
|
|
<meta name="robots" content="noindex, nofollow" />
|
|
<link rel="canonical" href="https://odoo4projects.com/odoo-community-developers/" />
|
|
<meta property="og:title" content="Find an Odoo freelancer for your project" />
|
|
<meta property="og:description" content="Independent Odoo Community freelancers 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 name="twitter:card" content="summary" />
|
|
<link rel="icon" href="/images/favicon.ico" />
|
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
<link
|
|
rel="stylesheet"
|
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap"
|
|
media="print"
|
|
onload="this.media='all'"
|
|
/>
|
|
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Marcellus&display=swap" /></noscript>
|
|
<link rel="stylesheet" href="/style.css" />
|
|
<!--
|
|
NOTE ON TRACKING — deliberately absent.
|
|
This page carries NO Plausible snippet and NO goal markings: it is a draft,
|
|
it has no consent gate, and nothing about it fires until go-live. Add the
|
|
Plausible block (website-plausible-analytics) as the go-live step, not before.
|
|
-->
|
|
<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-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);
|
|
}
|
|
.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-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": "Find an Odoo Freelancer for Your Project",
|
|
"description": "Browse independent Odoo freelancers for your project and write to one directly, using the contact details they have published.",
|
|
"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>
|
|
</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 & 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 COMMUNITY FREELANCERS</p>
|
|
<h1>Find 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>
|
|
|
|
<!-- How this works (the client's view) -->
|
|
<section class="section-light">
|
|
<div class="container">
|
|
<h2 class="section-title">How this works</h2>
|
|
<div class="oc-body">
|
|
<p><strong>Write to a freelancer directly.</strong> Each card carries what that freelancer published — their focus, their languages, the work they take on, and their contact details where they chose to publish them. We do not rate them and we do not recommend one over another.</p>
|
|
<p><strong>Or let us ask them for you.</strong> Post your brief once and pick the freelancers you want it to reach. We forward it to them with no name attached, and we send their answers back to you — see <a href="#request">Post a request</a> below.</p>
|
|
<p>Either way the work is agreed between you and the freelancer. We publish the listings; the project and the contract are yours.</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">Everyone listed here works as an individual, not through an agency. Where a card shows a number of projects, that is a count from our own records — not a rating, a score or an endorsement. 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>
|
|
<!-- FILTERS-START -->
|
|
|
|
<!-- FILTERS-END -->
|
|
</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">
|
|
<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 count from our own records</dd></div></dl>
|
|
<p class="dev-contact-none">No contact details published for this freelancer.</p>
|
|
</article>
|
|
<!-- DIRECTORY-LIST-END -->
|
|
</div>
|
|
<p class="dir-empty" id="dirEmpty" hidden>No freelancer matches those filters. Clear them to see everyone.</p>
|
|
|
|
<div class="dir-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 — WIRED to the quote route
|
|
(odoo_experts_quote). 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". n8n answers 200 BEFORE the workflow runs and also on a bad
|
|
payload, so this form shows the success state ONLY on a returned 200 and
|
|
promises no timing the response cannot substantiate. 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>
|
|
<div class="oc-body">
|
|
<p>Pick the freelancers you want to hear from, describe the work in your own words, and leave an address for the answers. We forward your brief to them with <strong>no name attached</strong>, collect what they send back, and pass it on to you. The freelancers answer when they can — we do not promise a time.</p>
|
|
</div>
|
|
<div class="dir-forms">
|
|
<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>
|
|
<!-- 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 exactly what you write — no rating, no ranking, and no claim we cannot back. 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; 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>
|
|
<input id="apCountry" name="Country" type="text" placeholder="NL" />
|
|
<span class="hint">Where you work from. A two-letter code is enough (NL, DE, US).</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>
|
|
<div class="form-field">
|
|
<label for="apLanguages">Languages</label>
|
|
<input id="apLanguages" name="languages" type="text" placeholder="English, German" />
|
|
<span class="hint">Separate with commas.</span>
|
|
</div>
|
|
<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">A photo of a person is personal data. 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. POSTs to the invite route (odoo_experts_invite_friend). -->
|
|
<section class="section-light" id="invite">
|
|
<div class="container">
|
|
<h2 class="section-title">Know another Odoo freelancer?</h2>
|
|
<div class="oc-body">
|
|
<p>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. We use their address for that invitation only.</p>
|
|
</div>
|
|
<div class="dir-forms">
|
|
<form class="form-card" id="inviteForm" novalidate>
|
|
<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 <a href="#request">post your brief</a> and we will forward it to the freelancers you pick, with no name attached.</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 © 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 anonymous get-quote flow (client → us → freelancers → back to the client).
|
|
var ODOO_EXPERTS_QUOTE_WEBHOOK =
|
|
"https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_quote";
|
|
// A freelancer invites a friend to sign up.
|
|
var ODOO_EXPERTS_INVITE_WEBHOOK =
|
|
"https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_invite_friend";
|
|
|
|
// 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
|
|
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, '\\"') + '"]')
|
|
);
|
|
}
|
|
|
|
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 (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 (CSV_STRING_FIELDS.indexOf(name) !== -1) {
|
|
// Normalise to the wire shape: trim each entry, drop blanks, re-join.
|
|
// "English, German" and "a, b, ,c" → "English,German" and "a,b,c".
|
|
var norm = (els[0].value || "")
|
|
.split(",")
|
|
.map(function (s) {
|
|
return s.trim();
|
|
})
|
|
.filter(Boolean)
|
|
.join(",");
|
|
if (norm) payload[name] = norm; // an empty box 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)
|
|
});
|
|
}
|
|
|
|
/* ---- Directory filtering (an enhancement over the build-time HTML) ---- */
|
|
function initFilters() {
|
|
var bar = document.getElementById("dirFilters");
|
|
var grid = document.getElementById("dirGrid");
|
|
var count = document.getElementById("dirCount");
|
|
var empty = document.getElementById("dirEmpty");
|
|
if (!bar || !grid) return;
|
|
|
|
var selects = Array.prototype.slice.call(bar.querySelectorAll("select"));
|
|
if (!selects.length) return; // no data supports any filter → nothing to show
|
|
|
|
bar.hidden = false;
|
|
if (count) count.hidden = false;
|
|
var cards = Array.prototype.slice.call(grid.querySelectorAll(".dev-card"));
|
|
|
|
function apply() {
|
|
var shown = 0;
|
|
cards.forEach(function (card) {
|
|
var ok = selects.every(function (sel) {
|
|
var want = sel.value;
|
|
if (!want) return true;
|
|
var have = (card.getAttribute("data-" + sel.getAttribute("data-field")) || "").split("|");
|
|
return have.indexOf(want) !== -1;
|
|
});
|
|
card.hidden = !ok;
|
|
if (ok) shown++;
|
|
});
|
|
if (count) count.textContent = "Showing " + shown + " of " + cards.length;
|
|
if (empty) empty.hidden = shown !== 0;
|
|
}
|
|
|
|
selects.forEach(function (sel) {
|
|
sel.addEventListener("change", apply);
|
|
});
|
|
apply();
|
|
}
|
|
|
|
/* ---- 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 (posts to the quote route) ----
|
|
The client picks the freelancers, writes the brief and leaves an address for
|
|
the answers. n8n returns 200 BEFORE the workflow runs and also on a bad
|
|
payload, so a 200 is the only signal we have — the form cannot tell success
|
|
from failure. Hence: success only on a returned 200, and no promised timing. */
|
|
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 — your request is on its way to the freelancers you picked, with no name attached. We will come back to you with their answers.",
|
|
"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 (posts to the invite route) ----
|
|
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 will email them one invitation to sign up, and tell them you suggested it.",
|
|
"ok"
|
|
);
|
|
form.reset();
|
|
})
|
|
.catch(function () {
|
|
setStatus(status, "That did not send. Please try again.", "err");
|
|
})
|
|
.then(function () {
|
|
submit.disabled = false;
|
|
});
|
|
});
|
|
}
|
|
|
|
initFilters();
|
|
initApplyForm();
|
|
initRequestForm();
|
|
initInviteForm();
|
|
</script>
|
|
</body>
|
|
</html>
|