Public Access
feat(freelancer-directory): anonymous-contact modal live, description cap, copy removals, Get-listed modals, Best Practices accordion
Publishes the accumulated freelancer-directory work (six held passes) plus the
contact-modal success copy the user supplied today.
1. Anonymous-question button + modal — LIVE. ODOO_EXPERTS_QUOTE_WEBHOOK is now
set, so the per-card "Ask question anonymously" button and its modal render.
The button is added by script only on cards marked data-relay (a record we can
relay to); the modal posts {freelancers:[{id,name}], brief, email} to the
odoo_experts_quote route. With the constant empty the surface stays dark and
no dead button is rendered.
2. Description cap 230 — the "About you" textarea is capped at 230 characters
with a live counter (DESC_MAX_CHARS); the same cap truncates a longer legacy
value on the render path. The matching change to the owner's edit form lives
in odoo-community-developers/admin/index.html (included here).
3. Removal of "Ask us to find one" — the dead call to action is gone.
4. Copy removals — the invitation swap, four lede sentences, the whole invite
section, and the Start line are removed.
5. Two "Get listed" buttons + the application form as a modal — the ONE
#applyForm stays inline in the served HTML (works with JS off) and is moved
into #applyModal once when the script runs; the data-apply-open anchors then
open the dialog instead of jumping, and are working links with JS off.
6. Best Practices accordion — 11 <details name="best-practices">, all CLOSED on
load; at most one open via the shared name attribute plus a JS fallback.
Contact-modal success copy (user, 2026-11-03): the receipt clause is kept and the
user's two promises are used in their words —
"Thank you — we have your question. We will contact the freelancer, and as soon
as we have their answer we will come back to you."
The sending and error states are unchanged. NOTE: both promises (the relay and
the reply) depend on the n8n workflow that does not exist yet — the endpoint is a
stub. The owner took that decision knowingly; the copy ships as asked.
QC: verify-html.py 0 · check-inline-js OK · competitor QC exit 3, 74 site-wide
pre-existing, 0 in our files · impeccable detect --no-advisory dir 14 (0 new).
No form submitted.
This commit is contained in:
1 parent
a0ef97b48d
commit
5535f10e7d
2 files changed
+600
-120
No files matched your search
@@ -350,8 +350,9 @@
|
|||||||
<span class="focus-counter" id="adFocusCount" aria-live="polite">0 / 70</span>
|
<span class="focus-counter" id="adFocusCount" aria-live="polite">0 / 70</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field full">
|
<div class="form-field full">
|
||||||
<label for="adDescription">Description</label>
|
<label for="adDescription">Description <span class="hint">(at most 230 characters)</span></label>
|
||||||
<textarea id="adDescription" name="description" rows="4"></textarea>
|
<textarea id="adDescription" name="description" rows="4" maxlength="230" aria-describedby="adDescriptionCount"></textarea>
|
||||||
|
<span class="focus-counter" id="adDescriptionCount" aria-live="polite">0 / 230</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label for="adCountry">Country</label>
|
<label for="adCountry">Country</label>
|
||||||
@@ -727,6 +728,8 @@
|
|||||||
var FOCUS_TOTAL = 15;
|
var FOCUS_TOTAL = 15;
|
||||||
var FOCUS_MAX = 5;
|
var FOCUS_MAX = 5;
|
||||||
var FOCUS_MAX_CHARS = 70;
|
var FOCUS_MAX_CHARS = 70;
|
||||||
|
// The description is capped at 230 characters, same as the public application form.
|
||||||
|
var DESC_MAX_CHARS = 230;
|
||||||
var LANG_FIELDS = [["english", "English"], ["german", "German"], ["spanish", "Spanish"]];
|
var LANG_FIELDS = [["english", "English"], ["german", "German"], ["spanish", "Spanish"]];
|
||||||
// Country VALUE FORMAT — what the Country select sends to the webhook.
|
// Country VALUE FORMAT — what the Country select sends to the webhook.
|
||||||
// "code" → the ISO 3166-1 alpha-2 code, e.g. "DE" (default)
|
// "code" → the ISO 3166-1 alpha-2 code, e.g. "DE" (default)
|
||||||
@@ -874,6 +877,11 @@
|
|||||||
if (focusInput && focusCount) {
|
if (focusInput && focusCount) {
|
||||||
focusCount.textContent = focusInput.value.length + " / " + FOCUS_MAX_CHARS;
|
focusCount.textContent = focusInput.value.length + " / " + FOCUS_MAX_CHARS;
|
||||||
}
|
}
|
||||||
|
var descInput = document.getElementById("adDescription");
|
||||||
|
var descCount = document.getElementById("adDescriptionCount");
|
||||||
|
if (descInput && descCount) {
|
||||||
|
descCount.textContent = descInput.value.length + " / " + DESC_MAX_CHARS;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadRecord() {
|
function loadRecord() {
|
||||||
@@ -1001,6 +1009,17 @@
|
|||||||
focusInput.addEventListener("input", refreshCount);
|
focusInput.addEventListener("input", refreshCount);
|
||||||
refreshCount();
|
refreshCount();
|
||||||
}
|
}
|
||||||
|
var descInput = document.getElementById("adDescription");
|
||||||
|
var descCount = document.getElementById("adDescriptionCount");
|
||||||
|
if (descInput && descCount) {
|
||||||
|
var refreshDescCount = function () {
|
||||||
|
var n = descInput.value.length;
|
||||||
|
descCount.textContent = n + " / " + DESC_MAX_CHARS;
|
||||||
|
descCount.setAttribute("data-over", n > DESC_MAX_CHARS ? "1" : "0");
|
||||||
|
};
|
||||||
|
descInput.addEventListener("input", refreshDescCount);
|
||||||
|
refreshDescCount();
|
||||||
|
}
|
||||||
refreshFocus(form);
|
refreshFocus(form);
|
||||||
|
|
||||||
form.addEventListener("submit", function (event) {
|
form.addEventListener("submit", function (event) {
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Hire an Odoo Developer for Your Project</title>
|
<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." />
|
<meta name="description" content="Hire an Odoo developer or freelancer for your project. Browse independent Odoo specialists and ask one a question anonymously — we pass it on without your name." />
|
||||||
<link rel="canonical" href="https://odoo4projects.com/odoo-community-developers/" />
|
<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: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:description" content="Independent Odoo developers you can ask a question anonymously — we pass it on, and their answer comes back to you." />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:url" content="https://odoo4projects.com/odoo-community-developers/" />
|
<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" content="https://odoo4projects.com/images/hero/amsterdam.webp" />
|
||||||
@@ -429,7 +429,22 @@
|
|||||||
.dev-links a {
|
.dev-links a {
|
||||||
color: var(--brass-deep);
|
color: var(--brass-deep);
|
||||||
}
|
}
|
||||||
/* A card with no published contact says so — it never shows an empty space. */
|
/* The "Ask question anonymously" trigger — added by script only while its route is
|
||||||
|
set. Styled as a link so it sits in the links row where the mailto chip was. */
|
||||||
|
.dev-links button {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--brass-deep);
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.dev-links button:focus-visible {
|
||||||
|
outline: 3px solid var(--brass-deep);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
/* A card with no contact route of any kind says so — it never shows an empty space. */
|
||||||
.dev-contact-none {
|
.dev-contact-none {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
@@ -437,6 +452,99 @@
|
|||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- The ask-a-question modal (ships dark; opened only when wired) ---- */
|
||||||
|
.contact-modal[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.contact-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 60;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(13, 27, 46, 0.72);
|
||||||
|
}
|
||||||
|
.contact-modal-card {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
background: var(--ivory);
|
||||||
|
border: 1px solid var(--line-brass);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.5rem 1.6rem 1.3rem;
|
||||||
|
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
|
||||||
|
max-height: 92vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.contact-modal-card h3 {
|
||||||
|
font-family: Marcellus, Georgia, serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
color: var(--midnight);
|
||||||
|
}
|
||||||
|
.contact-modal-lede {
|
||||||
|
font-size: 0.86rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
margin: 0 0 0.9rem;
|
||||||
|
}
|
||||||
|
.contact-modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.6rem;
|
||||||
|
right: 0.7rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
}
|
||||||
|
.contact-modal-close:focus-visible {
|
||||||
|
outline: 3px solid var(--brass-deep);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.contact-modal .form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
.contact-modal .form-field label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--midnight);
|
||||||
|
}
|
||||||
|
.contact-modal .form-field input,
|
||||||
|
.contact-modal .form-field textarea {
|
||||||
|
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%;
|
||||||
|
}
|
||||||
|
.contact-modal-to {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ink);
|
||||||
|
margin: 0 0 0.8rem;
|
||||||
|
}
|
||||||
|
/* The apply modal's card is wider than the question modal: the apply form is a
|
||||||
|
two-column grid. Card stays ivory, so the form reads the same moved or inline. */
|
||||||
|
.apply-modal-card {
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
/* A "Get listed" trigger: centred under the copy it belongs to. */
|
||||||
|
.dir-cta {
|
||||||
|
text-align: center;
|
||||||
|
margin: 1.75rem auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* The three forms */
|
/* The three forms */
|
||||||
.dir-forms {
|
.dir-forms {
|
||||||
max-width: 860px;
|
max-width: 860px;
|
||||||
@@ -850,6 +958,79 @@
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Best Practices accordion (page-scoped, additive). Reuses the house FAQ card
|
||||||
|
and "+/-" marker; items are full-width, single column. Exclusivity comes from the
|
||||||
|
native <details name> attribute, with a small JS fallback in the page script. All
|
||||||
|
items are CLOSED on load — nothing carries `open`. ---- */
|
||||||
|
.bp-lede {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.bp-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
.bp-item {
|
||||||
|
background: var(--ivory);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 0.35rem 1.25rem;
|
||||||
|
box-shadow: var(--shadow-rest);
|
||||||
|
transition: box-shadow 0.25s;
|
||||||
|
}
|
||||||
|
.bp-item[open],
|
||||||
|
.bp-item:hover {
|
||||||
|
box-shadow: var(--shadow-hover);
|
||||||
|
}
|
||||||
|
.bp-item summary {
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.9rem 0;
|
||||||
|
font-family: Marcellus, Georgia, serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--midnight);
|
||||||
|
}
|
||||||
|
.bp-item summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.bp-item summary::after {
|
||||||
|
content: "+";
|
||||||
|
font-family: Inter, sans-serif;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--brass);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.25s;
|
||||||
|
}
|
||||||
|
.bp-item[open] summary::after {
|
||||||
|
content: "\2212";
|
||||||
|
}
|
||||||
|
.bp-item summary:focus-visible {
|
||||||
|
outline: 2px solid var(--brass);
|
||||||
|
outline-offset: 3px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.bp-q {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.bp-a {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
padding: 0 0 1rem;
|
||||||
|
}
|
||||||
|
.bp-a p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
{
|
{
|
||||||
@@ -860,7 +1041,7 @@
|
|||||||
"@id": "https://odoo4projects.com/odoo-community-developers/#webpage",
|
"@id": "https://odoo4projects.com/odoo-community-developers/#webpage",
|
||||||
"url": "https://odoo4projects.com/odoo-community-developers/",
|
"url": "https://odoo4projects.com/odoo-community-developers/",
|
||||||
"name": "Hire an Odoo Developer for Your Project",
|
"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.",
|
"description": "Hire an Odoo developer or freelancer for your project. Browse independent Odoo specialists and ask one a question anonymously — we pass it on without your name.",
|
||||||
"inLanguage": "en-US",
|
"inLanguage": "en-US",
|
||||||
"isPartOf": {
|
"isPartOf": {
|
||||||
"@id": "https://odoo4projects.com/#organization"
|
"@id": "https://odoo4projects.com/#organization"
|
||||||
@@ -927,10 +1108,10 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<p class="hero-kicker">ODOO FREELANCERS</p>
|
<p class="hero-kicker">ODOO FREELANCERS</p>
|
||||||
<h1>Hire an Odoo freelancer for your next project</h1>
|
<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>
|
<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. Ask a freelancer a question anonymously — we pass it on without your name or address, and their answer comes back to you.</p>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a href="#directory" class="btn btn-brass btn-lg">Browse the freelancers</a>
|
<a href="#directory" class="btn btn-brass btn-lg">Browse the freelancers</a>
|
||||||
<a href="mailto:support@odoo4projects.com?subject=Find%20me%20an%20Odoo%20freelancer" class="btn btn-outline btn-lg">Ask us to find one</a>
|
<a href="#apply" class="btn btn-outline btn-lg" data-apply-open>Get listed</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -942,12 +1123,12 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="section-title">What we do, and what we don't</h2>
|
<h2 class="section-title">What we do, and what we don't</h2>
|
||||||
<div class="oc-body">
|
<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>You are about to get in touch with 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. Write to the freelancer whose card fits — the contact is theirs, and their answer comes straight back to you.</p>
|
<p><strong>You deal with the freelancer, not us.</strong> Send your question from the card and we pass it on for you — without your name or address, so the freelancer never sees who you are. The brief, the price and the delivery are settled between the two of you; the contract is yours, and we are not a party to it, as <a href="#listing-note">the note under the listings</a> says. Your email comes to us, and when the freelancer answers, we send it back to you.</p>
|
||||||
<p><strong>Nobody pays us to be listed here.</strong> There is no fee to the freelancer and no fee to you for using this page — we charge neither side for the introduction.</p>
|
<p><strong>Nobody pays us to be listed here.</strong> There is no fee to the freelancer and no fee to you for using this page — we charge neither side for the introduction.</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 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> Everything on a card is that person's own words — including anything we add from a link they gave us, such as the OCA Member Profile badge. We pass it on, we do not check it.</p>
|
<p><strong>We verify nothing.</strong> Everything on a card is that person's own words — including anything we add from a link they gave us, such as the OCA Member Profile badge. 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>
|
<p><strong>The only thing we add to a card is a count.</strong> Where a card shows a number of clients, that is how many client instances that freelancer has on our platform — our own count of what we can measure, not a rating, a score or an endorsement.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -956,7 +1137,6 @@
|
|||||||
<section class="section-dark" id="directory">
|
<section class="section-dark" id="directory">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="section-title">Freelancers available</h2>
|
<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 clients, that is our own count — 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),
|
<!-- Rendered at build time from our freelancer webhook (one call, no CRM),
|
||||||
so the list works with JavaScript disabled. The filter controls below are
|
so the list works with JavaScript disabled. The filter controls below are
|
||||||
@@ -986,9 +1166,9 @@
|
|||||||
<p class="dev-clients"><span class="dev-clients-n">2</span> <span class="dev-clients-w">clients on ODOO4projects</span></p>
|
<p class="dev-clients"><span class="dev-clients-n">2</span> <span class="dev-clients-w">clients on ODOO4projects</span></p>
|
||||||
<p class="dev-focus">vieoem</p>
|
<p class="dev-focus">vieoem</p>
|
||||||
<p class="dev-bio">ginwwgnri</p>
|
<p class="dev-bio">ginwwgnri</p>
|
||||||
<p class="dev-contact-none">No contact details published for this freelancer.</p>
|
<p class="dev-contact-none">We have no way to pass your question to this freelancer.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="dev-card" 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 english german spanish" data-id="2" data-name="Oliver - The OC Guy" data-country="py" data-languages="english|german|spanish" data-oca="1">
|
<article class="dev-card" 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 english german spanish" data-id="2" data-name="Oliver - The OC Guy" data-country="py" data-languages="english|german|spanish" data-oca="1" data-relay="1">
|
||||||
<header class="dev-head">
|
<header class="dev-head">
|
||||||
<img class="dev-photo" src="data:image/jpeg;base64,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 truncated
|
<img class="dev-photo" src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wgARCAMgAyADASIAAhEBAxEB/8QAHAAAAQUBAQEAAAAAAAAAAAAAAwECBAUGAAcI/8QAGwEAAwEBAQEBAAAAAAAAAAAAAAECAwQFBgf/2gAMAwEAAhADEAAAAd+J4/iP0bnsIPmmqHNqQgXm4CgWsg0Ga5lsI18oE4K3IAo3bMtrKDXGo2dJd6cKOYTl9RCPcSNXmcwSERuMp0mgPVrTxq0OXnAjXoHIThhFOEDMrrMjtxWt/n9E+YQ3B5fXIrOH0WaNh2vG5UZ+AahCyxFGe5BFNS6k+NGForCfRaaWhLWowqLn76p2zwerzWi9DwrWRVp5/uXEnN841Y88RzeDr2zU9jhzUl0NgXTKRxGjiUo27U1Giq9BVx6m+fRonex60oSa+TC6fI2MYjeP2JHP6RaC/rbzv4iz4KYsuM7CVSOJJKzjKyFVSWpkNiFSquzS84pVKZxHSImfZMPnpl4Wpq7nnMjxJC0lKCOKejEQaDCcyYWsGFuufiOdV2Ujs2bMqRu+zEuSSG7rWPKa6tLn1ymV8XTPTBiuzOA7rhjXwLxtoViyboa7Wtowcbfjswa7YxWMdukm8nV+idNecv8AQmswfbpHGGJuSBgW+gol58fdjToH3zEUJbiHWdTIh2WnMFb4/L6eXDrVKzB9GqrOw9Zw6Mtt03WPs2qoKyyp1RNLK08/Ct1cO7ohbUVxgNPj9vXJWi1acvdll06F5dNNnjYx3XRFOayQyq+tEHTAvIhrXDuCFVJrp1c9GDRjTziaIw8dXeg43XEltkdZpgtpFsubuAklZQKm8qndMOMXrzFHOGd7u5izOLYsdOUUzJUTpFq7ONbu5rJfIRzOUzg43c4rs8y/sau4x72IosurkahQ3kcAhyWIDxWAIFjHZDehhkchEmIRqG85ACkh7qBHmxdOfK3MGd2+NevezzvfVXuQikYJEIg2Kjgc17BMc5wWUqNKvzYdXbVF9Ngxg9o843Pnm504dByO8/01RioZm9RSV0Puay2cdyKc/NVg0BJGWxhGGknnK8Eezml5qg/FbXCbc8XZYvRaxpnify9D1EqS1dtUVWYNLm9M594SLfWyMtI550AcH156ePmpmtXBa2wnTQd3co5yNaj47WZXp8+5uKq1y7RNc3LsejQtGRGKpfRSkOe5rlAS444vKFaHdHelMSM5zwisVjJ0gBwraFtx5K1qNH1+VeCnR+H2YLorY3tQxuJlxxNKIVQBIYwYSUAMWmlVx785abm6a2YE658v3+O2WnHbDrpvD6748avdzYddM0u8lDiZ42b6B1Y3fU5wso1fXOr59CY00aUDXloOqGJXL6sZN15/o8rtzklpN1jQTcozn21fYdjW+yVVQawzM0FV6nmWQcwDfl0MWji7Y2saD15TCV6hc22RWdPXr7wbufp+hdT8nuy1+t4nzTr8dPey+cW3F26tubNnvcpTKy/ZnHub4dQ0q9dR8TZdCj1nZ1r3NTQtfNgkglojtmRxrNrhLS/ZTSlJWS21zpXWJFrmk0o2Uy3TWqCTauTrI984WfPfCrKiHMFry1pfRKXn9DIG9APWXmkrfvT88X0GtFhxy110GLW2mawj/Q7wy8Yr/a0a8Uf6P11gLuo01YVlP6eHD0fPm+oFeflRPUlF5070JirDJuFVYldsFPIrrnKsY3aqjAs2mNt1WIk4n0vKFTDB3eaVg1vNgTivNj06oTu4CorJtqp1Qvc8fPb0aTfafCyc/X9hSPkf2nyPf9XYZfP9ADZCjirJEEOpvai9JFFb1HX49hKhW2PU9wyc3U/n8KMGWF1FV4nohhkSO/iKGkR4RxSkHCSQAtXrJSCR7CVe19RiWrX+n8/67TXtJ5ntzzIpAWFSbK0cus/NRzAdtbOxqNByzGachmcg3PmpUWM+/wAv0Gdv+7yt69hOH1yK8jwFz+JG1XFB5ylvVznkFCtGxpGzUHzm4+bu7jSnfI9nwKzrCHeQ3iOxrSLLY55QhpKi1LSDcLkc1nK3hP5jyuVipmkQCTf0B618Z+seN7/ubszZeb69oJ3TUKvmV96uz+t8/wC3wtnaVs7k9CSisw6TEjyBDDIhOhxzDrRp4BAtXifEFcIpLStQGjWM7MUZkcNzSUc4N54mHYj9L531egvsd53vaKbS35nGadghyF5ThodovYpWjzGq52x7XzMrhiMayuvM8+3zLTZy67/K9KeMnD60gg31zk5nOFG8JQngfPRJcxjwL0Y6bop/JNM/MsUQPu+AEkx95N6eqedfolCi65iqgDlwmowjAvMbSdSdHnRkgq5LlFUiYFMwY1XnJCNLGmq9a8H7j9D67f4f6P5PsWcGFJz6W0NgPr8rTsg1fP1692MdGmyXDNc7uPi2s14co96aB+XMzclwZ4z3cfNx0aceWWovgVz27Z9K8V8tEWS/Zn5lTAknpejzthXYsM9G7m+dxw9FFh5RnqnZ+yjWYHo6cu1xgLjdH8vYn60DzUIvTa7Gnb5tpXaY6ONVPz3mNjFCbHbJVNZKInDbOIKH0wSRYkuuayPlU2H6fnwHdZ7ZRLzU3GPTh5OxkRpi5O3nTfnYfTEH5JU+5UbnxIHotLvy5NmgFc1INNJRjQ6ltLKm0YnFElwEVMWX1zWOlRag5oB5qb7T4jdYb+9ERnl+ujdZIxvKx9qirDRfRYtZ+fSbGPtkPrnXY6eeD9ADnvhJO1MGJZ6Od8/m8TS5Pq5bNbM3N6tWO/6VQvukKp+tkKrSTCKo4LdyViy9CcVUO4fNUjb96M67UtrHwv0DzP13qlsia3n6Y73tmjdxiGNTk+QvMEUYxyUiK3IaQKnpUEzZgGiNOMzqp3mXpHhW+Pl1ZLN6vkD9kfo+L1IoLF3P2w5ySExSulKnONJbr63QxayxeY9PodebzyN6DCucYzUPZm5GqkJ4yHu4TnARN9X3hiH6xGsHS+nZHbDL80XTxG5qNe57z5f+hPH+k3ZDj8j1mOKjBSQSHlhtDlydfL6hBr7TkcJ3LPUjm8E+fW3unm+ZUltkvQ4d2yUzg+gcVqyuVqDRhgunkgTBSFQYtcOW2vHiOOpSSBGeBY0uurLwb2HxX2bfotBShc28dziF8RXGURr2rZZIJjyhjlK3HdJcT0GzhExZQ5ho0E4V4xWzUd0fzB7z4D289P6Vm/YLXGNI4PXCQxhjdObUsPx2mnadykeajiriXZWqCv10RPCA1QWs7Ms2IqIVzHc0kC8FU0ke8hUV9BpAaZeW5/0TE93kwSKbTni6nMkWv17Pwe8+Y+wQ4yZ6MQpqyw0fQP6MzaCk0OEV6GbO4mmRU+1qJtcuPw26yvpePobKvsuH6F3K2AfNQowGxlrINDOKYsXnn6Cfx+/6fB9Bb5vbQ9i/ETU9f5/fZxrBeyeRbjY248iXGtUzMsdaVuLmLa/XJq3sZ2EK8dwvn7TLcMw4VtvExAg35sRHcegk8p6o9bXynqiR4p6J590vUb6vtOX0myHzc9xvnSLiCWWV5iIYmmMdZS1nHbIYiIUcw0iRpw5dHHtBzpBj2sQVGO0AFZHsIrVdFnw7UIMyNcUOB9SxHVwYfpCdfnDgzY9LX+ueA+q8PdtH1oOXe6NS2Sl6Q20rGMB4juisCYMMoOkhbLmuEQzmATo2GE/OYzizWVMm1eqrrGR0uK88pHm8b0iTpr5xG9Wcq8qkegqn55daZBZCH6Lw8NP2h5rAH2yp4t+w5LBSNpQtSS+Zeh3zwYGolx0YmRvSXHnZdzGjbGk2Dx4M235rLN1ZA8nzGmbtnrZaTOP0pEyOVwTmEqGlQ2kI16vNCtfUDe8LQjNWbEGQCdI7CCm2xCjHXqoRhC4TgNdOiXMSHYQLiLmdHU7c3n1doqbt8yE8jbyFuMXsYrYei+We/wDl+lSFtQcPo1WX9B8+2DWNJI0eyJNsObnz5bULdclicMvVXdM+0Tix9A1vT63POrlSCRmAhJBEWt0+QZMx2r807OP2RJLOf0wcQiqospAyRqVQA2SgRCSOGFy8kjxq0+jusazz31rHbHfjtJ0YuPWZpGViIBwTvykaNELJeYelRaz8X02Z25tYyCFw3BNC9o0cYLmaavDU3EmgfWd86pNWdgGEcTyxyDYwQJ0NHFFVEFBEOU2OND4zo1SWHIHSr402LWdXWWNftjmaLY53p8+oDMjbYRtXnNElefQXzf8ARnk+sRpA+b7cnA73D75wCBJ0Gz0WF2XEpEeQJc4ZccpVTSXdBfc+BLjasuwyGzyyiu7sV0uNMrJ2R0+Xt12Y2uX9DzfVZHdh0p3Na5idNu7kBFKgkR3UhK9UxoZwo+D3WZKoNZmtHpyX7+NPQxshjiGJ6Z9LCMkDfzG1lKGyDeXnO0zGox67IsQUuS3isAj0YzpbNJrmS21LLCtmNEVoxHINiBieAY489oquJZQRAGZGojSgJIwQmjwpYRUlfawd8aek09btzZiPYw9+OHdU+jEv0X4p7D5vpWgJqeZ7LMhuMjqs0SYDqmw22A1vLrfIj44ucrHNZmrmsfojBYw9GuvyGoyzMCwTPnrpksTQslpKCuh+S2+Q7fL3bcQkvaPxElrXtyMcnWhzxU7aRBgtWbMu640jsrCqd/L87JNeg1WbGFrqMJMee/Dj4U7bSvx4dM9SOk5OzCADUt9WNzeRWQprQXdfJ5PYEsGNeenPnrRu5PVTBSwk6piccTqBOjq5kRUGEkCIASBdTM4RZhsGXFJjRbOM1URrWqpOiiFUyGQuLine truncated
|
||||||
<div class="dev-id">
|
<div class="dev-id">
|
||||||
@@ -1014,26 +1194,122 @@
|
|||||||
<li class="dev-axis"><span class="dev-axis-name">German</span><span class="dev-axis-squares" role="img" aria-label="5 of 5"><span class="dev-sq on"></span><span class="dev-sq on"></span><span class="dev-sq on"></span><span class="dev-sq on"></span><span class="dev-sq on"></span></span></li>
|
<li class="dev-axis"><span class="dev-axis-name">German</span><span class="dev-axis-squares" role="img" aria-label="5 of 5"><span class="dev-sq on"></span><span class="dev-sq on"></span><span class="dev-sq on"></span><span class="dev-sq on"></span><span class="dev-sq on"></span></span></li>
|
||||||
<li class="dev-axis"><span class="dev-axis-name">Spanish</span><span class="dev-axis-squares" role="img" aria-label="1 of 5"><span class="dev-sq on"></span><span class="dev-sq"></span><span class="dev-sq"></span><span class="dev-sq"></span><span class="dev-sq"></span></span></li>
|
<li class="dev-axis"><span class="dev-axis-name">Spanish</span><span class="dev-axis-squares" role="img" aria-label="1 of 5"><span class="dev-sq on"></span><span class="dev-sq"></span><span class="dev-sq"></span><span class="dev-sq"></span><span class="dev-sq"></span></span></li>
|
||||||
</ul></div>
|
</ul></div>
|
||||||
<p class="dev-links"><a href="https://www.linkedin.com/in/oliver-arnold-2760278b/" target="_blank" rel="noopener nofollow">LinkedIn</a> · <a href="mailto:oliver@odoo4projects.com">Email this freelancer</a></p>
|
<p class="dev-links"><a href="https://www.linkedin.com/in/oliver-arnold-2760278b/" target="_blank" rel="noopener nofollow">LinkedIn</a></p>
|
||||||
</article>
|
</article>
|
||||||
<!-- DIRECTORY-LIST-END -->
|
<!-- DIRECTORY-LIST-END -->
|
||||||
</div>
|
</div>
|
||||||
<p class="dir-empty" id="dirEmpty" hidden>Nothing matches. Widen your search, or <button type="button" class="dir-empty-reset" id="dirEmptyReset">set the filters back to All</button>.</p>
|
<p class="dir-empty" id="dirEmpty" hidden>Nothing matches. Widen your search, or <button type="button" class="dir-empty-reset" id="dirEmptyReset">set the filters back to All</button>.</p>
|
||||||
|
|
||||||
<div class="dir-note" id="listing-note">
|
<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 nothing more; the conversation is between you and the freelancer.</p>
|
<p>You deal with the freelancer. 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 carry your question to the freelancer and their answer back to you, nothing more.</p>
|
||||||
|
</div>
|
||||||
|
<p class="dir-cta"><a href="#apply" class="btn btn-outline" data-apply-open>Get listed</a></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Best Practices — advice for the visitor. Not a claim about our service, not a
|
||||||
|
verdict on anyone listed: we vet nobody. Every item is CLOSED on load (no `open`);
|
||||||
|
opening one closes the others via the native <details name> attribute, with a
|
||||||
|
small JS fallback near the end of the page. Placed after the listings and before
|
||||||
|
the "Get listed" band, so it reads as advice right where a visitor is weighing one. -->
|
||||||
|
<section class="section-light" id="best-practices">
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="section-title">Best Practices</h2>
|
||||||
|
<p class="bp-lede">Hiring a freelancer goes better when a handful of things are settled before the work starts. These are not rules we impose, and they are not a verdict on anyone listed here — they are simply the points where this kind of work most often goes wrong.</p>
|
||||||
|
<div class="bp-list">
|
||||||
|
|
||||||
|
<details class="bp-item" name="best-practices">
|
||||||
|
<summary id="bp1"><span class="bp-q">Agree the shape of the work first — workpackage or hours</span></summary>
|
||||||
|
<div class="bp-a" role="region" aria-labelledby="bp1">
|
||||||
|
<p>A fixed workpackage suits module development and form customisation; time and materials suits consulting and support. Deciding which one you are buying before the work starts is what prevents the argument about scope halfway through.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="bp-item" name="best-practices">
|
||||||
|
<summary id="bp2"><span class="bp-q">Clear communication and expectations</span></summary>
|
||||||
|
<div class="bp-a" role="region" aria-labelledby="bp2">
|
||||||
|
<p>Say what you need the system to do, in your own words, and keep the questions and the answers in one place. The clearer you and the freelancer are about what is expected and by when, the fewer surprises there are on either side.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="bp-item" name="best-practices">
|
||||||
|
<summary id="bp3"><span class="bp-q">Ask for code a third party can read</span></summary>
|
||||||
|
<div class="bp-a" role="region" aria-labelledby="bp3">
|
||||||
|
<p>Names that mean something, and a comment where the reasoning is not obvious. You will hire someone else eventually, and so will the next developer — code written to be read is what survives the person who wrote it.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="bp-item" name="best-practices">
|
||||||
|
<summary id="bp4"><span class="bp-q">For a workpackage, write the acceptance criteria</span></summary>
|
||||||
|
<div class="bp-a" role="region" aria-labelledby="bp4">
|
||||||
|
<p>What “done” looks like, as a list. Without it, a workpackage is a guess on both sides of the table.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="bp-item" name="best-practices">
|
||||||
|
<summary id="bp5"><span class="bp-q">Expect a good contractor to ask questions until they understand the job</span></summary>
|
||||||
|
<div class="bp-a" role="region" aria-labelledby="bp5">
|
||||||
|
<p>A freelancer who understands the work will ask about your data, your process and the edge cases before they start, not after. Questions early are cheaper than rework later — treat them as a sign the job is being taken seriously.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="bp-item" name="best-practices">
|
||||||
|
<summary id="bp6"><span class="bp-q">Keep it simple</span></summary>
|
||||||
|
<div class="bp-a" role="region" aria-labelledby="bp6">
|
||||||
|
<p>The smallest change that does the job is the one that survives. A plain solution you can maintain is worth more than a clever one nobody can explain to the next person.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="bp-item" name="best-practices">
|
||||||
|
<summary id="bp7"><span class="bp-q">Prefer a proven third-party module — check the OCA first</span></summary>
|
||||||
|
<div class="bp-a" role="region" aria-labelledby="bp7">
|
||||||
|
<p>Before commissioning something bespoke, look at what already exists. A maintained community module beats one you then have to maintain yourself, and the Odoo Community Association’s app store is a good place to start — <a href="https://apps.odoo-community.org/" target="_blank" rel="noopener">apps.odoo-community.org</a>.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="bp-item" name="best-practices">
|
||||||
|
<summary id="bp8"><span class="bp-q">Test the change on a copy before it goes live</span></summary>
|
||||||
|
<div class="bp-a" role="region" aria-labelledby="bp8">
|
||||||
|
<p>Do not try a new module or a configuration change on the system your business runs on. Put a copy in front of it first, and move the change across only once it works — <a href="/odoo-staging-environment/">a staging environment</a> is what that copy is.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="bp-item" name="best-practices">
|
||||||
|
<summary id="bp9"><span class="bp-q">Agree how extra work is requested</span></summary>
|
||||||
|
<div class="bp-a" role="region" aria-labelledby="bp9">
|
||||||
|
<p>A workpackage ends. When you want something more after it, that is a new piece of work — settle up front how you will ask for it and how it will be priced, so a small addition does not turn into a debate.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="bp-item" name="best-practices">
|
||||||
|
<summary id="bp10"><span class="bp-q">Agree the handover</span></summary>
|
||||||
|
<div class="bp-a" role="region" aria-labelledby="bp10">
|
||||||
|
<p>Settle who holds the access, where the code lives, and what happens to both when the engagement ends. An arrangement you can leave cleanly is easier to start.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="bp-item" name="best-practices">
|
||||||
|
<summary id="bp11"><span class="bp-q">Put the agreement in writing</span></summary>
|
||||||
|
<div class="bp-a" role="region" aria-labelledby="bp11">
|
||||||
|
<p>It does not have to be a contract. A short email after the call that repeats what you both understood — scope, price and timing — is enough to make sure you are remembering the same conversation.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Freelancer application (form 1) -->
|
<!-- Freelancer application (form 1). ONE instance of #applyForm: left inline here so
|
||||||
|
the form still works with JavaScript off; when the script runs it is MOVED into
|
||||||
|
#applyModal and this band opens it through a trigger. -->
|
||||||
<section class="section-dark" id="apply">
|
<section class="section-dark" id="apply">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="section-title">Are you an Odoo freelancer? Get listed</h2>
|
<h2 class="section-title">Are you an Odoo freelancer? Get listed</h2>
|
||||||
<div class="oc-body">
|
<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>
|
<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 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, and to pass client questions to you; it is never shown on your card. 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>
|
||||||
<div class="dir-forms">
|
<p class="dir-cta"><a href="#apply" class="btn btn-outline" data-apply-open>Get listed</a></p>
|
||||||
|
<div class="dir-forms" id="applyFormWrap">
|
||||||
<form class="form-card" id="applyForm" novalidate>
|
<form class="form-card" id="applyForm" novalidate>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
@@ -1043,11 +1319,11 @@
|
|||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label for="apEmail">Email</label>
|
<label for="apEmail">Email</label>
|
||||||
<input id="apEmail" name="email" type="email" autocomplete="email" required />
|
<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>
|
<span class="hint">We confirm it before your listing goes live, and we use it to pass client questions to you — it is never shown on your card.</span>
|
||||||
</div>
|
</div>
|
||||||
<label class="form-check">
|
<label class="form-check">
|
||||||
<input type="checkbox" name="email_public_consent" value="1" required />
|
<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>
|
<span>I agree that ODOO4projects may email me client questions, passed on without the client's identity. <span class="hint">(Your declaration — required.)</span></span>
|
||||||
</label>
|
</label>
|
||||||
<div class="form-field full">
|
<div class="form-field full">
|
||||||
<label for="apFocus">Your focus <span class="hint">(one line, at most 70 characters)</span></label>
|
<label for="apFocus">Your focus <span class="hint">(one line, at most 70 characters)</span></label>
|
||||||
@@ -1055,8 +1331,9 @@
|
|||||||
<span class="focus-counter" id="apFocusCount" aria-live="polite">0 / 70</span>
|
<span class="focus-counter" id="apFocusCount" aria-live="polite">0 / 70</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field full">
|
<div class="form-field full">
|
||||||
<label for="apDescription">About you, in your own words</label>
|
<label for="apDescription">About you, in your own words <span class="hint">(at most 230 characters)</span></label>
|
||||||
<textarea id="apDescription" name="description" rows="5" placeholder="What you do, who you do it for, how you work."></textarea>
|
<textarea id="apDescription" name="description" rows="5" maxlength="230" aria-describedby="apDescriptionCount" placeholder="What you do, who you do it for, how you work."></textarea>
|
||||||
|
<span class="focus-counter" id="apDescriptionCount" aria-live="polite">0 / 230</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label for="apCountry">Country</label>
|
<label for="apCountry">Country</label>
|
||||||
@@ -1421,51 +1698,53 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Invite a friend (form 4) — freelancer-facing, placed next to the application
|
<!-- Ask-a-question modal — sends the shape {freelancers:[{id,name}], brief, email} to
|
||||||
form: it is a freelancer-to-freelancer action, not something a client does.
|
the odoo_experts_quote route, with ONE freelancer: the card whose button was
|
||||||
WE send the invitation from our own address; the sender's address sends
|
clicked. It ships DARK: the button that opens it is added by the page script only
|
||||||
nothing. The route it posts to (odoo_experts_invite_friend) is a STUB the user
|
while ODOO_EXPERTS_QUOTE_WEBHOOK is set, so a stub route can never be submitted to
|
||||||
has yet to build, so the form ships DARK: empty endpoint constant, hidden behind
|
and no dead button is ever rendered. The client's email comes to US, never to the
|
||||||
the "not open yet" line below — no submit, no false confirmation. The invitation
|
freelancer — "anonymous" means the freelancer never sees who the client is. -->
|
||||||
must carry a visible one-click opt-out before this form is switched on. -->
|
<div class="contact-modal" id="contactModal" hidden role="dialog" aria-modal="true" aria-labelledby="contactModalTitle">
|
||||||
<section class="section-light" id="invite">
|
<div class="contact-modal-card" role="document">
|
||||||
<div class="container">
|
<button type="button" class="contact-modal-close" id="contactModalClose" aria-label="Close">×</button>
|
||||||
<h2 class="section-title">Know another Odoo freelancer?</h2>
|
<h3 id="contactModalTitle">Ask this freelancer a question</h3>
|
||||||
<p class="form-notlive" id="inviteNotLive">Not open yet. Email <a href="mailto:support@odoo4projects.com?subject=Invite%20a%20freelancer">support@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>
|
<p class="contact-modal-lede">We pass your question on <strong>without your name or address</strong>, so the freelancer never sees who you are. Your email comes to us — ODOO4projects — not to them, and we use it to send their answer back to you. You agree the work itself with them directly.</p>
|
||||||
<div class="dir-forms" id="inviteFormWrap" hidden>
|
<p class="contact-modal-to" id="contactModalTo"></p>
|
||||||
<form class="form-card" id="inviteForm" novalidate>
|
<form id="contactModalForm" 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-field">
|
||||||
<div class="form-grid">
|
<label for="contactModalEmail">Your email address</label>
|
||||||
<div class="form-field">
|
<input type="email" id="contactModalEmail" name="email" autocomplete="email" required />
|
||||||
<label for="ivName">Your name</label>
|
</div>
|
||||||
<input id="ivName" name="Name" type="text" autocomplete="name" required />
|
<div class="form-field">
|
||||||
</div>
|
<label for="contactModalQuestion">Your question</label>
|
||||||
<div class="form-field">
|
<textarea id="contactModalQuestion" name="brief" rows="4" required></textarea>
|
||||||
<label for="ivEmail">Your friend's email</label>
|
</div>
|
||||||
<input id="ivEmail" name="friend_email" type="email" autocomplete="email" required />
|
<div class="form-actions">
|
||||||
<span class="hint">We email them one invitation and say you suggested it. Nothing goes out from your address.</span>
|
<button type="submit" class="btn btn-brass">Send my question</button>
|
||||||
</div>
|
<span class="form-status" id="contactModalStatus" role="status"></span>
|
||||||
<div class="form-actions">
|
</div>
|
||||||
<button type="submit" class="btn btn-brass">Invite a friend</button>
|
</form>
|
||||||
<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:support@odoo4projects.com">support@odoo4projects.com</a> a name and an address and we will invite them by hand.</p></noscript>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
|
<!-- The apply modal — LIVE. It holds the ONE #applyForm, moved in by the page script
|
||||||
|
when it runs; it is NOT gated by any endpoint constant, so the anon-question
|
||||||
|
modal's dark state (ODOO_EXPERTS_QUOTE_WEBHOOK empty) cannot disable it. With
|
||||||
|
JavaScript off nothing is moved: the form stays inline in #apply. -->
|
||||||
|
<div class="contact-modal" id="applyModal" hidden role="dialog" aria-modal="true" aria-labelledby="applyModalTitle">
|
||||||
|
<div class="contact-modal-card apply-modal-card" role="document">
|
||||||
|
<button type="button" class="contact-modal-close" id="applyModalClose" aria-label="Close">×</button>
|
||||||
|
<h3 id="applyModalTitle">Are you an Odoo freelancer? Get listed</h3>
|
||||||
|
<div id="applyModalBody"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Final CTA -->
|
<!-- Final CTA -->
|
||||||
<section class="section-dark cmp-cta" id="start">
|
<section class="section-dark cmp-cta" id="start">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="section-title">Start</h2>
|
<h2 class="section-title">Start</h2>
|
||||||
<div class="cmp-lead">
|
|
||||||
<p>Browse the freelancers and write to one directly from their card. The contact details are theirs; their answer comes straight back to you.</p>
|
|
||||||
</div>
|
|
||||||
<div class="cmp-cta-actions">
|
<div class="cmp-cta-actions">
|
||||||
<a href="#directory" class="btn btn-brass btn-lg">Browse the freelancers</a>
|
<a href="#directory" class="btn btn-brass btn-lg">Browse the freelancers</a>
|
||||||
<a href="mailto:support@odoo4projects.com?subject=Find%20me%20an%20Odoo%20freelancer" class="btn btn-outline btn-lg">Ask us to find one</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1545,15 +1824,16 @@
|
|||||||
============================================================================ */
|
============================================================================ */
|
||||||
var ODOO_EXPERTS_WEBHOOK =
|
var ODOO_EXPERTS_WEBHOOK =
|
||||||
"https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts";
|
"https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts";
|
||||||
// Below this line: one stub the user has yet to build. While its endpoint is
|
// Below this line: the stubs the user has yet to build. While an endpoint is empty,
|
||||||
// empty, the surface that posts to it stays hidden — the visitor can never
|
// the surface that posts to it stays hidden — the visitor can never submit, so the
|
||||||
// submit, so the page can never confirm an action nothing performs. Set the URL
|
// page can never confirm an action nothing performs. Set the URL to switch it on;
|
||||||
// to switch it on; its note then hides itself. Nothing else changes.
|
// its note (or its button) then appears by itself. Nothing else changes.
|
||||||
//
|
//
|
||||||
// Setting this endpoint switches the INVITE form on. Its success copy must already
|
// Setting this endpoint switches the per-card "Ask question anonymously" button and
|
||||||
// be a RECEIPT ("we have recorded your invitation"), never a delivery claim — a
|
// its modal on. Its success copy must already be a RECEIPT ("we have your question"),
|
||||||
// visitor sees it the moment the endpoint goes live.
|
// never a delivery claim — n8n answers 200 before the workflow runs, so the form
|
||||||
var ODOO_EXPERTS_INVITE_WEBHOOK = "";
|
// cannot detect failure and must not promise an outcome.
|
||||||
|
var ODOO_EXPERTS_QUOTE_WEBHOOK = "https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_quote";
|
||||||
|
|
||||||
// ---- The shared constants (the same lists live in build-directory.py) --------
|
// ---- The shared constants (the same lists live in build-directory.py) --------
|
||||||
// The five self-assessed focus axes — each a SEPARATE numeric field (0-5) on the
|
// The five self-assessed focus axes — each a SEPARATE numeric field (0-5) on the
|
||||||
@@ -1573,6 +1853,9 @@
|
|||||||
var OCA_LINK_FIELD = "OCA_link";
|
var OCA_LINK_FIELD = "OCA_link";
|
||||||
var OCA_MEMBER_PREFIX = "https://apps.odoo-community.org/community/";
|
var OCA_MEMBER_PREFIX = "https://apps.odoo-community.org/community/";
|
||||||
var FOCUS_MAX_CHARS = 70;
|
var FOCUS_MAX_CHARS = 70;
|
||||||
|
// The description ("About you") is capped at 230 characters on the form. Same cap on
|
||||||
|
// the render path, so a longer legacy value cannot break the card.
|
||||||
|
var DESC_MAX_CHARS = 230;
|
||||||
// Country VALUE FORMAT — what the Country select sends.
|
// Country VALUE FORMAT — what the Country select sends.
|
||||||
// "code" → the ISO 3166-1 alpha-2 code, e.g. "DE" (default)
|
// "code" → the ISO 3166-1 alpha-2 code, e.g. "DE" (default)
|
||||||
// "name" → the full English name, e.g. "Germany"
|
// "name" → the full English name, e.g. "Germany"
|
||||||
@@ -1606,12 +1889,24 @@
|
|||||||
|
|
||||||
function clamp05(n) { return Math.max(0, Math.min(FOCUS_MAX, n)); }
|
function clamp05(n) { return Math.max(0, Math.min(FOCUS_MAX, n)); }
|
||||||
|
|
||||||
/* The Focus line, capped at FOCUS_MAX_CHARS. A longer stored value shows a visible
|
/* One cap, two fields. A longer stored value shows a visible ellipsis, so it can
|
||||||
ellipsis, so it can never break the card. */
|
never break the card. Focus is capped at FOCUS_MAX_CHARS, the description at
|
||||||
function capFocus(text) {
|
DESC_MAX_CHARS. */
|
||||||
|
function capText(text, max) {
|
||||||
text = String(text == null ? "" : text).trim();
|
text = String(text == null ? "" : text).trim();
|
||||||
if (text.length <= FOCUS_MAX_CHARS) return text;
|
if (text.length <= max) return text;
|
||||||
return text.slice(0, FOCUS_MAX_CHARS - 1).replace(/\s+$/, "") + "\u2026";
|
return text.slice(0, max - 1).replace(/\s+$/, "") + "\u2026";
|
||||||
|
}
|
||||||
|
function capFocus(text) { return capText(text, FOCUS_MAX_CHARS); }
|
||||||
|
function capDescription(text) { return capText(text, DESC_MAX_CHARS); }
|
||||||
|
|
||||||
|
/* The ONE predicate for the "Ask question anonymously" button: the record carries an
|
||||||
|
address we can relay to. It is NOT "the address is shown on the card" — the card
|
||||||
|
no longer shows any address. The button is added only when this is true AND the
|
||||||
|
route (ODOO_EXPERTS_QUOTE_WEBHOOK) is set. Id=1 has no address, so it shows no
|
||||||
|
button and gets the "no way to pass your question" note instead. */
|
||||||
|
function canAsk(rec) {
|
||||||
|
return String((rec && rec.email) || "").trim().indexOf("@") !== -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The Country value to submit: the ISO code by default, the English name when
|
/* The Country value to submit: the ISO code by default, the English name when
|
||||||
@@ -1688,16 +1983,18 @@
|
|||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The Focus character counter (70 max). */
|
/* The character counter for a capped field. Defaults to the Focus cap; the
|
||||||
function initFocusCounter(form, inputId, countId) {
|
description passes its own (DESC_MAX_CHARS). Mirrors the Focus pattern exactly. */
|
||||||
|
function initFocusCounter(form, inputId, countId, max) {
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
|
var cap = (max === undefined) ? FOCUS_MAX_CHARS : max;
|
||||||
var input = document.getElementById(inputId);
|
var input = document.getElementById(inputId);
|
||||||
var count = document.getElementById(countId);
|
var count = document.getElementById(countId);
|
||||||
if (!input || !count) return;
|
if (!input || !count) return;
|
||||||
function refresh() {
|
function refresh() {
|
||||||
var n = input.value.length;
|
var n = input.value.length;
|
||||||
count.textContent = n + " / " + FOCUS_MAX_CHARS;
|
count.textContent = n + " / " + cap;
|
||||||
count.setAttribute("data-over", n > FOCUS_MAX_CHARS ? "1" : "0");
|
count.setAttribute("data-over", n > cap ? "1" : "0");
|
||||||
}
|
}
|
||||||
input.addEventListener("input", refresh);
|
input.addEventListener("input", refresh);
|
||||||
refresh();
|
refresh();
|
||||||
@@ -1727,14 +2024,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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 payload the application form sends. Only fields the visitor set are sent; an
|
/* The payload the application form sends. Only fields the visitor set are sent; an
|
||||||
empty box must never wipe a stored value. The focus axes ride along only when
|
empty box must never wipe a stored value. The focus axes ride along only when
|
||||||
the block totals 15 (guaranteed by the gate on this form). */
|
the block totals 15 (guaranteed by the gate on this form). */
|
||||||
@@ -1974,7 +2263,7 @@
|
|||||||
var focus = capFocus(rec.Focus);
|
var focus = capFocus(rec.Focus);
|
||||||
if (focus) card.appendChild(liveEl("p", "dev-focus", focus));
|
if (focus) card.appendChild(liveEl("p", "dev-focus", focus));
|
||||||
|
|
||||||
var bio = String(rec.description || "").trim();
|
var bio = capDescription(rec.description);
|
||||||
if (bio) card.appendChild(liveEl("p", "dev-bio", bio));
|
if (bio) card.appendChild(liveEl("p", "dev-bio", bio));
|
||||||
|
|
||||||
var rows = liveAxisRows(rec);
|
var rows = liveAxisRows(rec);
|
||||||
@@ -1992,8 +2281,6 @@
|
|||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
var email = String(rec.email || "").trim();
|
|
||||||
var hasEmail = email.indexOf("@") !== -1;
|
|
||||||
var links = [];
|
var links = [];
|
||||||
[["Linkedin", "LinkedIn"], ["github", "GitHub"], ["upwork", "Upwork"],
|
[["Linkedin", "LinkedIn"], ["github", "GitHub"], ["upwork", "Upwork"],
|
||||||
["fiverr", "Fiverr"]].forEach(function (pair) {
|
["fiverr", "Fiverr"]].forEach(function (pair) {
|
||||||
@@ -2006,11 +2293,11 @@
|
|||||||
links.push(a);
|
links.push(a);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (hasEmail) {
|
// Contact runs through us, never the card: a record with an address we can relay
|
||||||
var mail = liveEl("a", null, "Email this freelancer");
|
// to is MARKED for the "Ask question anonymously" button (the script adds it only
|
||||||
mail.href = "mailto:" + email;
|
// while ODOO_EXPERTS_QUOTE_WEBHOOK is set). The address itself is never rendered —
|
||||||
links.push(mail);
|
// that is the whole point of the button.
|
||||||
}
|
if (canAsk(rec)) card.setAttribute("data-relay", "1");
|
||||||
if (links.length) {
|
if (links.length) {
|
||||||
var row = liveEl("p", "dev-links");
|
var row = liveEl("p", "dev-links");
|
||||||
links.forEach(function (a, i) {
|
links.forEach(function (a, i) {
|
||||||
@@ -2019,9 +2306,9 @@
|
|||||||
});
|
});
|
||||||
card.appendChild(row);
|
card.appendChild(row);
|
||||||
}
|
}
|
||||||
if (!hasEmail) {
|
if (!canAsk(rec)) {
|
||||||
card.appendChild(liveEl("p", "dev-contact-none",
|
card.appendChild(liveEl("p", "dev-contact-none",
|
||||||
"No contact details published for this freelancer."));
|
"We have no way to pass your question to this freelancer."));
|
||||||
}
|
}
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
@@ -2111,6 +2398,114 @@
|
|||||||
return active;
|
return active;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Ask-a-question modal + the per-card button ----------------------------
|
||||||
|
BOTH ship DARK: they are wired only when ODOO_EXPERTS_QUOTE_WEBHOOK is set, so a
|
||||||
|
stub route can never be submitted to and no dead button is ever rendered. The
|
||||||
|
client's email goes to US, never to the freelancer — "anonymous" means the
|
||||||
|
freelancer never learns who asked. */
|
||||||
|
var askLastFocus = null;
|
||||||
|
|
||||||
|
function askEls() {
|
||||||
|
return {
|
||||||
|
modal: document.getElementById("contactModal"),
|
||||||
|
to: document.getElementById("contactModalTo"),
|
||||||
|
form: document.getElementById("contactModalForm"),
|
||||||
|
email: document.getElementById("contactModalEmail"),
|
||||||
|
question: document.getElementById("contactModalQuestion"),
|
||||||
|
status: document.getElementById("contactModalStatus"),
|
||||||
|
close: document.getElementById("contactModalClose")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAskModal(id, name) {
|
||||||
|
var m = askEls();
|
||||||
|
if (!m.modal || !m.form) return;
|
||||||
|
askLastFocus = document.activeElement;
|
||||||
|
m.form.dataset.flId = (id === undefined || id === null) ? "" : String(id);
|
||||||
|
m.form.dataset.flName = name || "";
|
||||||
|
if (m.to) m.to.textContent = name ? ("To: " + name) : "";
|
||||||
|
setStatus(m.status, "", null);
|
||||||
|
m.modal.hidden = false;
|
||||||
|
if (m.email) m.email.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAskModal() {
|
||||||
|
var m = askEls();
|
||||||
|
if (!m.modal) return;
|
||||||
|
m.modal.hidden = true;
|
||||||
|
if (askLastFocus && askLastFocus.focus) askLastFocus.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAskModal() {
|
||||||
|
if (!ODOO_EXPERTS_QUOTE_WEBHOOK) return; // dark
|
||||||
|
var m = askEls();
|
||||||
|
if (!m.modal || !m.form) return;
|
||||||
|
if (m.close) m.close.addEventListener("click", closeAskModal);
|
||||||
|
m.modal.addEventListener("click", function (e) {
|
||||||
|
if (e.target === m.modal) closeAskModal();
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Escape" && !m.modal.hidden) closeAskModal();
|
||||||
|
});
|
||||||
|
m.form.addEventListener("submit", function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!m.form.reportValidity()) return;
|
||||||
|
var idRaw = m.form.dataset.flId;
|
||||||
|
var payload = {
|
||||||
|
freelancers: [{
|
||||||
|
id: idRaw === "" ? null : Number(idRaw),
|
||||||
|
name: m.form.dataset.flName || ""
|
||||||
|
}],
|
||||||
|
brief: m.question ? m.question.value.trim() : "",
|
||||||
|
email: m.email ? m.email.value.trim() : ""
|
||||||
|
};
|
||||||
|
var submit = m.form.querySelector('button[type="submit"]');
|
||||||
|
if (submit) submit.disabled = true;
|
||||||
|
setStatus(m.status, "Sending…", null);
|
||||||
|
postTo(ODOO_EXPERTS_QUOTE_WEBHOOK, payload)
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||||
|
// A RECEIPT, never a delivery claim: n8n answers 200 before the workflow
|
||||||
|
// runs, so the form cannot know the question was passed on.
|
||||||
|
setStatus(m.status,
|
||||||
|
"Thank you — we have your question. We will contact the freelancer, and as soon as we have their answer we will come back to you.",
|
||||||
|
"ok");
|
||||||
|
m.form.reset();
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
setStatus(m.status,
|
||||||
|
"That did not send. Please try again, or email us at support@odoo4projects.com and we will pass it on.",
|
||||||
|
"err");
|
||||||
|
})
|
||||||
|
.then(function () { if (submit) submit.disabled = false; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add the button to one card — ONLY while the route exists AND the card is marked
|
||||||
|
(data-relay) as a record with an address we can relay to. Nothing is added
|
||||||
|
otherwise, so the button is never dead and never promises a delivery. */
|
||||||
|
function enhanceAsk(card) {
|
||||||
|
if (!ODOO_EXPERTS_QUOTE_WEBHOOK) return;
|
||||||
|
if (card.getAttribute("data-relay") !== "1") return;
|
||||||
|
if (card.querySelector(".dev-ask-btn")) return;
|
||||||
|
var btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "dev-ask-btn";
|
||||||
|
btn.textContent = "Ask question anonymously";
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
openAskModal(card.getAttribute("data-id"), card.getAttribute("data-name") || "");
|
||||||
|
});
|
||||||
|
var row = card.querySelector(".dev-links");
|
||||||
|
if (row) {
|
||||||
|
row.appendChild(document.createTextNode(" \u00b7 "));
|
||||||
|
row.appendChild(btn);
|
||||||
|
} else {
|
||||||
|
var p = liveEl("p", "dev-links");
|
||||||
|
p.appendChild(btn);
|
||||||
|
card.appendChild(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initLiveList() {
|
function initLiveList() {
|
||||||
var grid = document.getElementById("dirGrid");
|
var grid = document.getElementById("dirGrid");
|
||||||
if (!grid || !window.fetch || !ODOO_EXPERTS_WEBHOOK) return;
|
if (!grid || !window.fetch || !ODOO_EXPERTS_WEBHOOK) return;
|
||||||
@@ -2128,6 +2523,8 @@
|
|||||||
var active = liveFilters(records);
|
var active = liveFilters(records);
|
||||||
while (grid.firstChild) grid.removeChild(grid.firstChild);
|
while (grid.firstChild) grid.removeChild(grid.firstChild);
|
||||||
records.forEach(function (rec) { grid.appendChild(liveCard(rec, active)); });
|
records.forEach(function (rec) { grid.appendChild(liveCard(rec, active)); });
|
||||||
|
// The button is added after the cards exist — only while the route is set.
|
||||||
|
Array.prototype.forEach.call(grid.querySelectorAll(".dev-card"), enhanceAsk);
|
||||||
applyFilters();
|
applyFilters();
|
||||||
})
|
})
|
||||||
.catch(function () { /* the build-time list already on the page stands */ });
|
.catch(function () { /* the build-time list already on the page stands */ });
|
||||||
@@ -2235,6 +2632,7 @@
|
|||||||
form.reset();
|
form.reset();
|
||||||
initFocusUI(form, "apFocusWarn", "apFocusTotal");
|
initFocusUI(form, "apFocusWarn", "apFocusTotal");
|
||||||
initFocusCounter(form, "apFocus", "apFocusCount");
|
initFocusCounter(form, "apFocus", "apFocusCount");
|
||||||
|
initFocusCounter(form, "apDescription", "apDescriptionCount", DESC_MAX_CHARS);
|
||||||
})
|
})
|
||||||
.catch(function () {
|
.catch(function () {
|
||||||
setStatus(
|
setStatus(
|
||||||
@@ -2247,39 +2645,102 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Form 4: invite a friend. SHIPS DARK ---- */
|
/* ---- The apply modal: ONE #applyForm, moved into the dialog when JS runs ----
|
||||||
function initInviteForm() {
|
The form stays inline in #apply in the served HTML, so a visitor without JS still
|
||||||
var form = document.getElementById("inviteForm");
|
sees and can fill it (it could never POST without JS anyway — the <form> has no
|
||||||
if (!form) return;
|
action and no method; it is JS-submitted). With JS the SAME single node is MOVED
|
||||||
var status = document.getElementById("inviteStatus");
|
into #applyModal once, so the form is never in two places; the "Get listed"
|
||||||
form.addEventListener("submit", function (event) {
|
anchors (href="#apply") then open the modal instead of jumping — and each one is
|
||||||
event.preventDefault();
|
a working link to the inline form when JS is off. */
|
||||||
if (!form.reportValidity()) return;
|
var applyLastFocus = null;
|
||||||
var submit = form.querySelector('button[type="submit"]');
|
|
||||||
submit.disabled = true;
|
function applyModalEls() {
|
||||||
setStatus(status, "Sending…", null);
|
return {
|
||||||
postTo(ODOO_EXPERTS_INVITE_WEBHOOK, buildPayload(form, false))
|
modal: document.getElementById("applyModal"),
|
||||||
.then(function (res) {
|
body: document.getElementById("applyModalBody"),
|
||||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
form: document.getElementById("applyForm"),
|
||||||
setStatus(status,
|
wrap: document.getElementById("applyFormWrap"),
|
||||||
"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.",
|
close: document.getElementById("applyModalClose")
|
||||||
"ok");
|
};
|
||||||
form.reset();
|
}
|
||||||
})
|
|
||||||
.catch(function () {
|
function openApplyModal(trigger) {
|
||||||
setStatus(status, "That did not send. Please try again.", "err");
|
var m = applyModalEls();
|
||||||
})
|
if (!m.modal || !m.form) return;
|
||||||
.then(function () { submit.disabled = false; });
|
applyLastFocus = trigger || document.activeElement;
|
||||||
|
m.modal.hidden = false;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
var first = m.form.querySelector("input, select, textarea, button");
|
||||||
|
if (first) first.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeApplyModal() {
|
||||||
|
var m = applyModalEls();
|
||||||
|
if (!m.modal || m.modal.hidden) return;
|
||||||
|
m.modal.hidden = true;
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
if (applyLastFocus && applyLastFocus.focus) applyLastFocus.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initApplyModal() {
|
||||||
|
var m = applyModalEls();
|
||||||
|
if (!m.modal || !m.form || !m.body) return;
|
||||||
|
// Move the ONE form into the dialog, then hide its now-empty inline wrapper so the
|
||||||
|
// #apply band reads as heading + copy + trigger instead of an empty column.
|
||||||
|
m.body.appendChild(m.form);
|
||||||
|
if (m.wrap) m.wrap.hidden = true;
|
||||||
|
if (m.close) m.close.addEventListener("click", closeApplyModal);
|
||||||
|
m.modal.addEventListener("click", function (e) {
|
||||||
|
if (e.target === m.modal) closeApplyModal();
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (m.modal.hidden) return;
|
||||||
|
if (e.key === "Escape") { closeApplyModal(); return; }
|
||||||
|
if (e.key !== "Tab") return;
|
||||||
|
// Keep Tab inside the dialog while it is open.
|
||||||
|
var focusables = m.modal.querySelectorAll(
|
||||||
|
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||||
|
);
|
||||||
|
if (!focusables.length) return;
|
||||||
|
var first = focusables[0], last = focusables[focusables.length - 1];
|
||||||
|
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||||
|
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||||
|
});
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll("[data-apply-open]"), function (trigger) {
|
||||||
|
trigger.addEventListener("click", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
openApplyModal(trigger);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
wireRelay("inviteFormWrap", "inviteNotLive", ODOO_EXPERTS_INVITE_WEBHOOK);
|
|
||||||
initFocusUI(document.getElementById("applyForm"), "apFocusWarn", "apFocusTotal");
|
initFocusUI(document.getElementById("applyForm"), "apFocusWarn", "apFocusTotal");
|
||||||
initFocusCounter(document.getElementById("applyForm"), "apFocus", "apFocusCount");
|
initFocusCounter(document.getElementById("applyForm"), "apFocus", "apFocusCount");
|
||||||
|
initFocusCounter(document.getElementById("applyForm"), "apDescription", "apDescriptionCount", DESC_MAX_CHARS);
|
||||||
|
initAskModal();
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll(".dev-card"), enhanceAsk);
|
||||||
initFilters();
|
initFilters();
|
||||||
initLiveList();
|
initLiveList();
|
||||||
initApplyForm();
|
initApplyForm();
|
||||||
initInviteForm();
|
initApplyModal();
|
||||||
|
</script>
|
||||||
|
<!-- Best Practices accordion: at most one item open. Current browsers enforce this
|
||||||
|
with the shared `name` attribute on <details> alone; this is the fallback for ones
|
||||||
|
that do not support exclusive <details name>, so exclusivity holds everywhere.
|
||||||
|
Nothing here sends an event — it only toggles the `open` state. -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var items = document.querySelectorAll("details.bp-item");
|
||||||
|
if (!items.length) return;
|
||||||
|
Array.prototype.forEach.call(items, function (item) {
|
||||||
|
item.addEventListener("toggle", function () {
|
||||||
|
if (!item.open) return;
|
||||||
|
Array.prototype.forEach.call(items, function (other) {
|
||||||
|
if (other !== item && other.open) other.open = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in new issue
Block a user