feat: add reusable trial_modal.js component in components/

This commit is contained in:
oliver
2026-05-15 09:25:14 -03:00
parent 300a53abb9
commit 43eef37eef
+644
View File
@@ -0,0 +1,644 @@
/**
* trial_modal.js — Reusable Free-Trial Modal Component
* ======================================================
* Drop this script into any page. Configure via window.TrialModalConfig
* before the script loads, or pass nothing and accept the defaults.
*
* CONFIGURATION (all keys optional)
* ----------------------------------
* window.TrialModalConfig = {
* webhook : string — POST endpoint (multipart/form-data)
* product : string — hidden field value (default: "odoo_19")
* title : string — modal heading (default: "Start Your 4 Weeks Free Trial")
* subtitle : string — modal sub-heading (default: "...")
* submitLabel : string — submit button label (default: "Start Free Trial")
* confirmTitle : string — success heading (default: "Thank you! 🎉")
* confirmSub : string — success body (default: "...")
* triggerSelector : string — CSS selector(s) for open triggers
* (default: "#open-modal, .open-modal")
* locations : Array<{ value: string, label: string }>
* (default: 6 preset server locations)
* utm_source : string — overridden by ?utm_source (default: "homepage")
* utm_medium : string — overridden by ?utm_medium (default: "direct")
* utm_campaign : string — overridden by ?utm_campaign (default: "none")
* utm_term : string (default: "")
* utm_content : string (default: "")
* }
*
* PUBLIC API (window.TrialModal)
* --------------------------------
* TrialModal.open(plan?) — open the modal, optionally pre-filling plan
* TrialModal.close() — close the modal
*
* DESIGN TOKENS
* -------------
* The modal reads CSS custom properties from the host page when available.
* Fallback values match the my-biz.app warm coffee / burnt-orange theme.
* Override any token on your :root to re-theme without touching this file.
*
* TOKEN MAP
* --tm-bg modal overlay tint (rgba(62,39,35,0.84))
* --tm-card modal box background (#fff3e0)
* --tm-white heading / body text on light bg (#3e2723)
* --tm-silver muted body text (#6d4c41)
* --tm-grey placeholder / label text (#a1887f)
* --tm-gold primary accent / CTA (#e65100)
* --tm-gold-hi hover highlight (#fb8c00)
* --tm-gold-lo gradient end-stop (#bf360c)
* --tm-gold-glow focus ring glow (rgba(230,81,0,0.18))
* --tm-border field border (rgba(230,81,0,0.18))
* --tm-radius box border-radius (28px)
*/
(function () {
"use strict";
/* ------------------------------------------------------------------ */
/* 1. DEFAULTS */
/* ------------------------------------------------------------------ */
var DEFAULTS = {
webhook: "",
product: "odoo_19",
title: "Start Your 4 Weeks Free Trial",
subtitle: "Test drive your back-office in a 4-week free trial.",
submitLabel: "Start Free Trial",
confirmTitle: "Thank you! \uD83C\uDF89",
confirmSub:
"We\u2019ve received your request and a specialist will be in touch within one business day.",
triggerSelector: "#open-modal, .open-modal",
locations: [
{ value: "boston", label: "\uD83C\uDDFA\uD83C\uDDF8 Boston" },
{ value: "manchester", label: "\uD83C\uDDEC\uD83C\uDDE7 Manchester" },
{ value: "mumbai", label: "\uD83C\uDDEE\uD83C\uDDF3 Mumbai" },
{ value: "saopaulo", label: "\uD83C\uDDE7\uD83C\uDDF7 S\u00e3o Paulo" },
{ value: "meppel", label: "\uD83C\uDDF3\uD83C\uDDF1 Meppel" },
{ value: "sydney", label: "\uD83C\uDDE6\uD83C\uDDFA Sydney" },
],
utm_source: "homepage",
utm_medium: "direct",
utm_campaign: "none",
utm_term: "",
utm_content: "",
};
/* ------------------------------------------------------------------ */
/* 2. MERGE CONFIG */
/* ------------------------------------------------------------------ */
var cfg = {};
for (var k in DEFAULTS) {
if (Object.prototype.hasOwnProperty.call(DEFAULTS, k)) {
cfg[k] = DEFAULTS[k];
}
}
var userCfg = window.TrialModalConfig || {};
for (var k in userCfg) {
if (Object.prototype.hasOwnProperty.call(userCfg, k)) {
cfg[k] = userCfg[k];
}
}
/* Resolve UTM params from URL query string — URL wins over config */
var urlParams = new URLSearchParams(window.location.search);
["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"].forEach(function (p) {
var v = urlParams.get(p);
if (v) cfg[p] = v;
});
/* ------------------------------------------------------------------ */
/* 3. INJECT CSS */
/* ------------------------------------------------------------------ */
var CSS = [
/* --- Token fallbacks scoped to the overlay (inherited by children) ---
Host-page :root tokens take precedence automatically. */
"#tm-overlay {",
" --tm-bg: rgba(62,39,35,0.84);",
" --tm-card: #fff3e0;",
" --tm-white: #3e2723;",
" --tm-silver: #6d4c41;",
" --tm-grey: #a1887f;",
" --tm-gold: #e65100;",
" --tm-gold-hi: #fb8c00;",
" --tm-gold-lo: #bf360c;",
" --tm-gold-glow: rgba(230,81,0,0.18);",
" --tm-border: rgba(230,81,0,0.18);",
" --tm-radius: 28px;",
"}",
/* --- Overlay --- */
"#tm-overlay {",
" position: fixed;",
" inset: 0;",
" z-index: 9900;",
" display: none;",
" align-items: center;",
" justify-content: center;",
" padding: 20px;",
" background: var(--tm-bg);",
" backdrop-filter: blur(14px);",
" -webkit-backdrop-filter: blur(14px);",
" box-sizing: border-box;",
"}",
/* --- Box --- */
"#tm-box {",
" position: relative;",
" width: 100%;",
" max-width: 640px;",
" padding: 44px;",
" background: var(--tm-card);",
" border-radius: var(--tm-radius);",
" border: 1px solid rgba(230,81,0,0.24);",
" box-shadow: 0 30px 90px rgba(0,0,0,0.3);",
" box-sizing: border-box;",
"}",
/* --- Close button --- */
"#tm-close {",
" position: absolute;",
" top: 18px;",
" right: 18px;",
" width: 40px;",
" height: 40px;",
" border-radius: 50%;",
" color: var(--tm-grey);",
" background: rgba(93,64,55,0.05);",
" font-size: 1.2rem;",
" border: none;",
" cursor: pointer;",
" line-height: 1;",
"}",
"#tm-close:hover { color: var(--tm-gold); }",
/* --- Title --- */
"#tm-box .tm-title {",
" font-family: Georgia, 'Times New Roman', serif;",
" font-size: 2.2rem;",
" font-weight: 700;",
" line-height: 1.05;",
" margin: 0 0 10px;",
" background: linear-gradient(135deg, var(--tm-gold-lo) 0%, var(--tm-gold) 38%, var(--tm-gold-hi) 55%, var(--tm-gold) 75%, var(--tm-gold-lo) 100%);",
" -webkit-background-clip: text;",
" -webkit-text-fill-color: transparent;",
" background-clip: text;",
"}",
/* --- Subtitle --- */
"#tm-box .tm-subtitle {",
" font-size: 0.9rem;",
" color: var(--tm-silver);",
" margin: 0 0 26px;",
"}",
/* --- Grid --- */
".tm-grid {",
" display: grid;",
" grid-template-columns: repeat(2, minmax(0, 1fr));",
" gap: 0 16px;",
"}",
".tm-field-group {",
" margin-bottom: 18px;",
"}",
".tm-grid .tm-field-group.full {",
" grid-column: 1 / -1;",
"}",
/* --- Field label --- */
".tm-label {",
" display: block;",
" margin-bottom: 8px;",
" font-size: 0.62rem;",
" font-family: system-ui, -apple-system, sans-serif;",
" text-transform: uppercase;",
" letter-spacing: 0.08em;",
" color: var(--tm-gold);",
"}",
/* --- Field (input / select / custom button) --- */
".tm-field {",
" width: 100%;",
" padding: 14px 16px;",
" border-radius: 16px;",
" background: rgba(93,64,55,0.05);",
" border: 1px solid var(--tm-border);",
" color: var(--tm-white);",
" box-sizing: border-box;",
" font: inherit;",
" outline: none;",
"}",
".tm-field:focus {",
" border-color: var(--tm-gold);",
" box-shadow: 0 0 0 3px var(--tm-gold-glow);",
"}",
".tm-field::placeholder { color: var(--tm-grey); }",
/* --- Custom select --- */
".tm-select { position: relative; }",
".tm-select-btn {",
" position: relative;",
" text-align: left;",
" cursor: pointer;",
" padding-right: 42px;",
" background: none;",
" border: none;",
"}",
".tm-select-btn::after {",
" content: '';",
" position: absolute;",
" right: 16px;",
" top: 50%;",
" width: 14px;",
" height: 8px;",
" transform: translateY(-50%);",
" background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='8'%3E%3Cpath d='M1 1l6 6 6-6' stroke='%23E65100' stroke-width='1.8' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\");",
" background-repeat: no-repeat;",
" background-position: center;",
" pointer-events: none;",
"}",
".tm-select-btn.is-placeholder { color: var(--tm-grey); }",
".tm-select-menu {",
" position: absolute;",
" top: calc(100% + 8px);",
" left: 0;",
" right: 0;",
" z-index: 3;",
" max-height: 220px;",
" padding: 8px;",
" background: var(--tm-card);",
" border: 1px solid var(--tm-border);",
" border-radius: 16px;",
" box-shadow: 0 18px 40px rgba(62,39,35,0.16);",
" overflow-y: auto;",
" display: none;",
"}",
".tm-select.open .tm-select-menu { display: block; }",
".tm-select-option {",
" width: 100%;",
" padding: 10px 12px;",
" border: none;",
" border-radius: 12px;",
" background: transparent;",
" color: var(--tm-white);",
" text-align: left;",
" font: inherit;",
" cursor: pointer;",
" transition: background 0.2s, color 0.2s;",
"}",
".tm-select-option:hover,",
".tm-select-option:focus-visible,",
".tm-select-option.is-selected {",
" outline: none;",
" background: rgba(230,81,0,0.1);",
" color: var(--tm-gold);",
"}",
/* --- Submit button --- */
".tm-submit {",
" width: 100%;",
" margin-top: 8px;",
" padding: 15px 20px;",
" border-radius: 50px;",
" border: none;",
" cursor: pointer;",
" font-family: system-ui, -apple-system, sans-serif;",
" font-size: 0.95rem;",
" font-weight: 600;",
" letter-spacing: 0.02em;",
" color: var(--tm-card);",
" background: linear-gradient(135deg, var(--tm-gold-lo) 0%, var(--tm-gold) 38%, var(--tm-gold-hi) 55%, var(--tm-gold) 75%, var(--tm-gold-lo) 100%);",
" transition: opacity 0.2s;",
"}",
".tm-submit:hover { opacity: 0.88; }",
".tm-submit:disabled { opacity: 0.56; cursor: not-allowed; }",
/* --- Confirmation panel --- */
"#tm-confirm {",
" display: none;",
" text-align: center;",
" padding-top: 8px;",
"}",
"#tm-confirm .tm-confirm-title {",
" font-family: Georgia, 'Times New Roman', serif;",
" font-size: 1.6rem;",
" color: var(--tm-gold-hi);",
" margin: 0 0 12px;",
"}",
"#tm-confirm .tm-confirm-sub {",
" color: var(--tm-silver);",
" font-size: 0.9rem;",
" margin: 0 0 24px;",
"}",
/* --- Responsive --- */
"@media (max-width: 860px) {",
" #tm-box { padding: 34px 22px; }",
" .tm-grid { grid-template-columns: 1fr; }",
"}",
"@media (max-width: 480px) {",
" #tm-box .tm-title { font-size: 1.8rem; }",
"}",
].join("\n");
var styleEl = document.createElement("style");
styleEl.id = "tm-styles";
styleEl.textContent = CSS;
document.head.appendChild(styleEl);
/* ------------------------------------------------------------------ */
/* 4. BUILD & INJECT HTML */
/* ------------------------------------------------------------------ */
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function buildLocationOptions() {
return cfg.locations
.map(function (loc) {
return [
'<button type="button" class="tm-select-option"',
' data-value="' + escapeHtml(loc.value) + '">',
escapeHtml(loc.label),
"</button>",
].join("");
})
.join("\n");
}
var html = [
'<div id="tm-overlay" role="dialog" aria-modal="true" aria-labelledby="tm-title">',
' <div id="tm-box">',
' <button id="tm-close" aria-label="Close">&times;</button>',
' <h2 class="tm-title" id="tm-title">' + escapeHtml(cfg.title) + "</h2>",
' <p class="tm-subtitle">' + escapeHtml(cfg.subtitle) + "</p>",
' <form id="tm-form" novalidate>',
' <div class="tm-grid">',
/* Email */
' <div class="tm-field-group full">',
' <label class="tm-label" for="tm-email">Work Email</label>',
' <input type="email" id="tm-email" name="email"',
' class="tm-field" placeholder="you@company.com" required />',
" </div>",
/* Location custom select */
' <div class="tm-field-group full tm-select" id="tm-location-select">',
' <label class="tm-label" for="tm-location">Server Location</label>',
' <button type="button" class="tm-field tm-select-btn is-placeholder"',
' id="tm-location-btn"',
' aria-haspopup="listbox" aria-expanded="false"',
' aria-controls="tm-location-menu">',
" Select your server location",
" </button>",
' <div class="tm-select-menu" id="tm-location-menu"',
' role="listbox" aria-labelledby="tm-location-btn">',
buildLocationOptions(),
" </div>",
' <input type="hidden" id="tm-location" name="location" value="" required />',
" </div>",
" </div>", /* /tm-grid */
/* Hidden fields */
' <input type="hidden" id="tm-product" name="product" value="' + escapeHtml(cfg.product) + '" />',
' <input type="hidden" id="tm-plan" name="plan" value="General Free Trial" />',
' <input type="hidden" name="utm_source" value="' + escapeHtml(cfg.utm_source) + '" />',
' <input type="hidden" name="utm_medium" value="' + escapeHtml(cfg.utm_medium) + '" />',
' <input type="hidden" name="utm_campaign" value="' + escapeHtml(cfg.utm_campaign) + '" />',
' <input type="hidden" name="utm_term" value="' + escapeHtml(cfg.utm_term) + '" />',
' <input type="hidden" name="utm_content" value="' + escapeHtml(cfg.utm_content) + '" />',
' <button type="submit" class="tm-submit">' + escapeHtml(cfg.submitLabel) + "</button>",
" </form>",
/* Confirmation */
' <div id="tm-confirm">',
' <p class="tm-confirm-title">' + escapeHtml(cfg.confirmTitle) + "</p>",
' <p class="tm-confirm-sub">' + escapeHtml(cfg.confirmSub) + "</p>",
' <button id="tm-confirm-close" class="tm-submit">Close</button>',
" </div>",
" </div>",
"</div>",
].join("\n");
var container = document.createElement("div");
container.innerHTML = html;
document.body.appendChild(container.firstElementChild);
/* ------------------------------------------------------------------ */
/* 5. WIRE UP DOM REFERENCES */
/* ------------------------------------------------------------------ */
var overlay = document.getElementById("tm-overlay");
var box = document.getElementById("tm-box");
var closeBtn = document.getElementById("tm-close");
var form = document.getElementById("tm-form");
var confirmDiv = document.getElementById("tm-confirm");
var confirmClose = document.getElementById("tm-confirm-close");
var planField = document.getElementById("tm-plan");
var productField = document.getElementById("tm-product");
var locationField = document.getElementById("tm-location");
var locationSelect = document.getElementById("tm-location-select");
var locationBtn = document.getElementById("tm-location-btn");
var locationMenu = document.getElementById("tm-location-menu");
var defaultProduct = productField.value;
/* ------------------------------------------------------------------ */
/* 6. LOCATION PICKER LOGIC */
/* ------------------------------------------------------------------ */
function syncLocationLabel() {
var value = locationField.value || "";
var selected = null;
locationMenu.querySelectorAll(".tm-select-option").forEach(function (opt) {
var isSel = opt.getAttribute("data-value") === value;
if (isSel) selected = opt;
opt.classList.toggle("is-selected", isSel);
opt.setAttribute("aria-selected", isSel ? "true" : "false");
});
locationBtn.textContent = selected
? selected.textContent.trim()
: "Select your server location";
locationBtn.classList.toggle("is-placeholder", !selected);
locationBtn.setAttribute(
"aria-expanded",
locationSelect.classList.contains("open") ? "true" : "false"
);
}
function openLocationMenu() {
locationSelect.classList.add("open");
syncLocationLabel();
}
function closeLocationMenu() {
locationSelect.classList.remove("open");
syncLocationLabel();
}
locationBtn.addEventListener("click", function () {
locationSelect.classList.contains("open")
? closeLocationMenu()
: openLocationMenu();
});
locationMenu.querySelectorAll(".tm-select-option").forEach(function (opt) {
opt.addEventListener("click", function () {
locationField.value = opt.getAttribute("data-value") || "";
closeLocationMenu();
});
});
/* Close location dropdown on outside click */
document.addEventListener("click", function (e) {
if (!locationSelect.contains(e.target)) closeLocationMenu();
});
/* ------------------------------------------------------------------ */
/* 7. MODAL OPEN / CLOSE */
/* ------------------------------------------------------------------ */
function openModal(plan) {
planField.value = plan || "General Free Trial";
/* Keep product pinned to configured value — never expose or change */
productField.value = defaultProduct;
/* Apply tracking fields if window._mbsTracking is present */
if (window._mbsTracking) window._mbsTracking.applyToFields(form);
closeLocationMenu();
overlay.style.display = "flex";
document.body.style.overflow = "hidden";
}
function closeModal() {
overlay.style.display = "none";
document.body.style.overflow = "";
/* Reset after the fade (200 ms gives any CSS transition time) */
setTimeout(function () {
form.style.display = "";
confirmDiv.style.display = "none";
form.reset();
planField.value = "General Free Trial";
productField.value = defaultProduct;
locationField.value = "";
if (window._mbsTracking) window._mbsTracking.applyToFields(form);
closeLocationMenu();
var btn = form.querySelector(".tm-submit");
btn.textContent = cfg.submitLabel;
btn.disabled = false;
}, 200);
}
/* --- Built-in close triggers --- */
closeBtn.addEventListener("click", closeModal);
confirmClose.addEventListener("click", closeModal);
overlay.addEventListener("click", function (e) {
if (e.target === overlay || !box.contains(e.target)) closeModal();
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") {
if (locationSelect.classList.contains("open")) {
closeLocationMenu();
return;
}
if (overlay.style.display === "flex") closeModal();
}
});
/* ------------------------------------------------------------------ */
/* 8. WIRE TRIGGER ELEMENTS */
/* ------------------------------------------------------------------ */
function attachTriggers() {
/* Primary #open-modal (handled separately for legacy compat) */
var primary = document.getElementById("open-modal");
if (primary) {
primary.addEventListener("click", function (e) {
e.preventDefault();
openModal();
});
}
/* Any element matching triggerSelector */
try {
document.querySelectorAll(cfg.triggerSelector).forEach(function (el) {
/* Avoid double-binding the primary element */
if (el.id === "open-modal") return;
el.addEventListener("click", function (e) {
e.preventDefault();
openModal(el.getAttribute("data-plan") || undefined);
});
});
} catch (err) {
console.warn("[TrialModal] invalid triggerSelector:", cfg.triggerSelector, err);
}
}
/* Run immediately if DOM is ready, otherwise wait */
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", attachTriggers);
} else {
attachTriggers();
}
/* ------------------------------------------------------------------ */
/* 9. FORM SUBMISSION */
/* ------------------------------------------------------------------ */
form.addEventListener("submit", function (e) {
e.preventDefault();
if (!cfg.webhook) {
console.warn("[TrialModal] No webhook URL configured. Set window.TrialModalConfig.webhook.");
return;
}
var btn = form.querySelector(".tm-submit");
var formData = new FormData(form);
/* Append tracking data if available */
if (window._mbsTracking) {
formData = window._mbsTracking.appendToFormData(formData);
window._mbsTracking.trackEvent("Trial", {
source: planField.value || "General Free Trial",
});
}
btn.textContent = "Sending\u2026";
btn.disabled = true;
/* Show confirmation regardless of network outcome
(webhook may not return CORS-safe headers) */
fetch(cfg.webhook, { method: "POST", body: formData })
.then(function () {
form.style.display = "none";
confirmDiv.style.display = "block";
})
.catch(function () {
form.style.display = "none";
confirmDiv.style.display = "block";
});
});
/* ------------------------------------------------------------------ */
/* 10. PUBLIC API */
/* ------------------------------------------------------------------ */
window.TrialModal = {
/**
* Open the trial modal.
* @param {string} [plan] - Optional plan name pre-filled in hidden field.
*/
open: openModal,
/**
* Close the trial modal and reset the form.
*/
close: closeModal,
};
})();