diff --git a/components/trial_modal.js b/components/trial_modal.js new file mode 100644 index 0000000..db94d08 --- /dev/null +++ b/components/trial_modal.js @@ -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, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function buildLocationOptions() { + return cfg.locations + .map(function (loc) { + return [ + '", + ].join(""); + }) + .join("\n"); + } + + var html = [ + '", + ].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, + }; +})();