Public Access
Odoo freelancer directory for our clients, built as a draft (noindex, out of sitemap.xml and llms.txt, no inbound links, no announcement surface, no tracking). - The list is rendered at BUILD TIME from the single freelancer webhook (GET/POST .../odoo_experts) so it works with JavaScript disabled; the page script only filters that HTML. - Filters (country, odoo_versions, languages, specialities) are printed only when the live data actually supports them, so a filter that returns nothing never ships. No record has them yet, so none render today. - Three forms: freelancer application (POST, no id -> create), admin add/edit (POST with id -> update), and a client request form whose endpoint is a single inert constant until the workflow exists. - No reviews, no ratings, no claims beyond what a freelancer wrote and the count from our own records. No competitor named.
1203 lines
46 KiB
HTML
1203 lines
46 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. Contact a freelancer directly, or post your brief and we forward it to them anonymously. 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 contact directly — or post your brief and we forward it for you, anonymously." />
|
|
<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);
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
|
|
/* Collapsed admin block */
|
|
.dir-admin {
|
|
max-width: 860px;
|
|
margin: 1.75rem auto 0;
|
|
}
|
|
.dir-admin summary {
|
|
cursor: pointer;
|
|
list-style: none;
|
|
padding: 0.95rem 1.25rem;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
font-family: Marcellus, Georgia, serif;
|
|
font-size: 1.02rem;
|
|
color: var(--midnight);
|
|
background: var(--ivory);
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius-lg);
|
|
}
|
|
.dir-admin summary::-webkit-details-marker {
|
|
display: none;
|
|
}
|
|
.dir-admin summary::after {
|
|
content: "+";
|
|
font-family: Inter, sans-serif;
|
|
color: var(--brass);
|
|
font-size: 1.15rem;
|
|
line-height: 1;
|
|
}
|
|
.dir-admin[open] summary::after {
|
|
content: "\2212";
|
|
}
|
|
.dir-admin[open] summary {
|
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
|
}
|
|
.dir-admin .form-card {
|
|
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
|
border-top: 0;
|
|
}
|
|
|
|
/* 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. Contact a freelancer directly, or post your brief and we forward it to them anonymously.",
|
|
"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. Contact a freelancer directly, or post your brief and we forward it to them without your name attached.</p>
|
|
<div class="hero-actions">
|
|
<a href="#directory" class="btn btn-brass btn-lg">Browse the freelancers</a>
|
|
<a href="#request" class="btn btn-outline btn-lg">Post a request anonymously</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>You have an Odoo Community system and a job that needs a specialist. Below is a list of independent Odoo freelancers. Each one wrote their own profile — their focus, their languages, the work they take on — and we publish it as theirs. We do not rate them and we do not recommend one over another.</p>
|
|
<p>You have two ways to reach them. <strong>Contact them directly</strong> — every freelancer's own contact details are published for exactly that purpose, so you can write to whoever looks right. Or <strong>post your project and stay anonymous</strong>: we forward your brief to the freelancer or freelancers you pick, they answer, and we send their answer back to you. Your name and your contact details are not shown to them until you decide to engage.</p>
|
|
<p>Either way, the work is agreed between you and the freelancer. We list people and pass messages; 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>
|
|
</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 (form 3) -->
|
|
<section class="section-light" id="request">
|
|
<div class="container">
|
|
<h2 class="section-title">Post a request — anonymously</h2>
|
|
<div class="oc-body">
|
|
<p>Not sure who to ask? Write your brief once and we forward it to the freelancers you choose, with no name and no contact details attached. A freelancer who is interested answers us, and we send their answer straight back to you. You decide whether to take it further — your details are only shared once you choose to engage.</p>
|
|
</div>
|
|
<!-- FORM 3 — endpoint NOT BUILT YET. The form is complete; the destination is
|
|
a single clearly-named constant in the page script, left empty on purpose.
|
|
We do not guess a URL. Wire CLIENT_REQUEST_ENDPOINT when the workflow exists
|
|
(it must be the odoo_experts webhook or a new one the user names — nothing else). -->
|
|
<div class="dir-forms">
|
|
<form class="form-card" id="clientRequestForm" novalidate>
|
|
<div class="form-grid">
|
|
<div class="form-field">
|
|
<label for="crEmail">Your email</label>
|
|
<input id="crEmail" name="email" type="email" autocomplete="email" required />
|
|
<span class="hint">So we can send the freelancer's answer back to you. It is not shared with anyone until you decide to engage.</span>
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="crTargets">Freelancers to ask <span class="hint">(optional)</span></label>
|
|
<input id="crTargets" name="targets" type="text" placeholder="Names, or leave blank for all" />
|
|
</div>
|
|
<div class="form-field full">
|
|
<label for="crProject">What do you need done?</label>
|
|
<textarea id="crProject" name="project" rows="6" required placeholder="The job, your Odoo version, and anything the freelancer should know to give you a price."></textarea>
|
|
</div>
|
|
<div class="form-actions">
|
|
<button type="submit" class="btn btn-brass">Send my request</button>
|
|
<span class="form-status" id="clientRequestStatus" 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 pass your brief along 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">Used to confirm your listing before it goes live.</span>
|
|
</div>
|
|
<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="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="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>
|
|
|
|
<!-- Admin upload / edit (form 2) — the user's own form -->
|
|
<section class="section-light">
|
|
<div class="container">
|
|
<details class="dir-admin">
|
|
<summary>Admin: add or correct a freelancer record</summary>
|
|
<form class="form-card" id="adminForm" novalidate>
|
|
<div class="form-grid">
|
|
<div class="form-field">
|
|
<label for="adId">Record id <span class="hint">(leave blank to create a new record)</span></label>
|
|
<input id="adId" name="id" type="number" min="1" />
|
|
<span class="hint">With an id, the record is updated. Without one, a new record is created.</span>
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adClients">Projects on ODOO4projects</label>
|
|
<input id="adClients" name="odoo4projects_clients" type="number" min="0" />
|
|
<span class="hint">Our own count, not the freelancer's.</span>
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adName">Name</label>
|
|
<input id="adName" name="Name" type="text" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adEmail">Email</label>
|
|
<input id="adEmail" name="email" type="email" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adFocus">Focus</label>
|
|
<input id="adFocus" name="Focus" type="text" />
|
|
</div>
|
|
<div class="form-field full">
|
|
<label for="adDescription">Description</label>
|
|
<textarea id="adDescription" name="description" rows="4"></textarea>
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adLanguages">Languages</label>
|
|
<input id="adLanguages" name="languages" type="text" />
|
|
<span class="hint">Separate with commas.</span>
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adSpecialities">Specialities</label>
|
|
<input id="adSpecialities" name="specialities" type="text" />
|
|
<span class="hint">Separate with commas.</span>
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adLinkedin">LinkedIn URL</label>
|
|
<input id="adLinkedin" name="Linkedin" type="url" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adUpwork">Upwork URL</label>
|
|
<input id="adUpwork" name="upwork" type="url" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adFiverr">Fiverr URL</label>
|
|
<input id="adFiverr" name="fiverr" type="url" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label for="adImage">Image URL</label>
|
|
<input id="adImage" name="Image" type="url" />
|
|
</div>
|
|
<label class="form-check">
|
|
<input type="checkbox" name="OCA_member" value="1" />
|
|
<span>OCA member.</span>
|
|
</label>
|
|
<label class="form-check">
|
|
<input type="checkbox" name="is_individual" value="1" />
|
|
<span>Works as an individual.</span>
|
|
</label>
|
|
<div class="form-actions">
|
|
<button type="submit" class="btn btn-brass">Save this record</button>
|
|
<span class="form-status" id="adminStatus" role="status"></span>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</details>
|
|
</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>Send your brief and we will forward it to the freelancers who fit it — anonymously, if you prefer. Or write to a freelancer directly from their card.</p>
|
|
</div>
|
|
<div class="cmp-cta-actions">
|
|
<a href="#request" class="btn btn-brass btn-lg">Post a request</a>
|
|
<a href="#directory" class="btn btn-outline btn-lg">Browse the freelancers</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.
|
|
|
|
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";
|
|
|
|
/* FORM 3 — client request / anonymous get-quote.
|
|
NOT BUILT YET. Deliberately inert: we do not guess a URL. When the workflow
|
|
exists, set this to the odoo_experts webhook or a new endpoint the user names
|
|
(nothing else), and the form below starts working unchanged. */
|
|
var CLIENT_REQUEST_ENDPOINT = "";
|
|
|
|
/* OPTIONAL-LATER (do not build now): a mail-based "send me their details" gate
|
|
against bots — the client asks for a freelancer's contact and we mail it, so a
|
|
scraper reading the page gets nothing usable. Left out because the mail webhook
|
|
is currently 404 (spec §5). */
|
|
|
|
var MULTI_FIELDS = ["languages", "specialities"]; // comma-separated → array
|
|
var BOOL_FIELDS = ["OCA_member", "is_individual"];
|
|
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 (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");
|
|
}
|
|
|
|
function postToWebhook(payload) {
|
|
return fetch(ODOO_EXPERTS_WEBHOOK, {
|
|
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 postToWebhook(payload);
|
|
})
|
|
.then(function () {
|
|
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 2: admin upload / edit (update when an id is present) ---- */
|
|
function initAdminForm() {
|
|
var form = document.getElementById("adminForm");
|
|
if (!form) return;
|
|
var status = document.getElementById("adminStatus");
|
|
form.addEventListener("submit", function (event) {
|
|
event.preventDefault();
|
|
if (!form.reportValidity()) return;
|
|
var submit = form.querySelector('button[type="submit"]');
|
|
var hasId = !!form.querySelector('[name="id"]').value.trim();
|
|
submit.disabled = true;
|
|
setStatus(status, "Saving…", null);
|
|
|
|
var payload = buildPayload(form, true);
|
|
postToWebhook(payload)
|
|
.then(function () {
|
|
setStatus(
|
|
status,
|
|
hasId
|
|
? "Saved. Check your inbox to confirm the change before it is applied."
|
|
: "New record sent. Check your inbox to confirm it before it goes live.",
|
|
"ok"
|
|
);
|
|
})
|
|
.catch(function () {
|
|
setStatus(status, "That did not save. Please try again.", "err");
|
|
})
|
|
.then(function () {
|
|
submit.disabled = false;
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ---- Form 3: client request (inert until CLIENT_REQUEST_ENDPOINT exists) ---- */
|
|
function initClientRequestForm() {
|
|
var form = document.getElementById("clientRequestForm");
|
|
if (!form) return;
|
|
var status = document.getElementById("clientRequestStatus");
|
|
form.addEventListener("submit", function (event) {
|
|
event.preventDefault();
|
|
if (!form.reportValidity()) return;
|
|
if (!CLIENT_REQUEST_ENDPOINT) {
|
|
// Not wired yet — say so plainly rather than pretend.
|
|
setStatus(
|
|
status,
|
|
"This request form is not switched on yet. For now, email us at hello@odoo4projects.com and we will pass your brief along.",
|
|
"err"
|
|
);
|
|
return;
|
|
}
|
|
var payload = buildPayload(form, false);
|
|
fetch(CLIENT_REQUEST_ENDPOINT, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload)
|
|
})
|
|
.then(function () {
|
|
setStatus(status, "Sent. We will forward it and come back to you with any answers.", "ok");
|
|
form.reset();
|
|
})
|
|
.catch(function () {
|
|
setStatus(status, "That did not send. Please try again.", "err");
|
|
});
|
|
});
|
|
}
|
|
|
|
initFilters();
|
|
initApplyForm();
|
|
initAdminForm();
|
|
initClientRequestForm();
|
|
</script>
|
|
</body>
|
|
</html>
|