refactor: Extract shared JavaScript utilities (Step 1 of 6)
Create reusable utility modules to eliminate duplication across main.js, cv-matcher.js, and legal.js: - js/utils/form-helpers.js: showFieldError, clearFieldErrors, isValidEmail, extractApiError — shared form validation and error handling - js/utils/i18n.js: currentLang, t, applyLanguage, updateLegalLinks, browserLang — shared translation and language switching - js/utils/api.js: checkApiLive, getRecaptchaWebKey, getGoogleTagManagerId, loadGoogleTagManager — shared API configuration loading - js/modules/cookie-consent.js: getConsent, setConsent, initConsent, setupConsentHandlers — cookie banner and consent management All utilities exposed on window.MyAi namespace for use by existing pages. Full JSDoc headers and inline comments for maintainability. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Cookie Consent Management
|
||||||
|
*
|
||||||
|
* Handles cookie banner display, user consent preferences, and localStorage persistence.
|
||||||
|
* Integrates with analytics loading via applyConsent() callback.
|
||||||
|
*/
|
||||||
|
|
||||||
|
var CONSENT_KEY = "myai_cookie_consent";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse consent object from localStorage.
|
||||||
|
* Expected format: { necessary: true, analytics: bool, ts: iso-timestamp }
|
||||||
|
*
|
||||||
|
* @returns {object|null} - Parsed consent object or null if not found or invalid JSON
|
||||||
|
*/
|
||||||
|
function getConsent() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(CONSENT_KEY));
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist consent object to localStorage as JSON.
|
||||||
|
* Stores { necessary, analytics, ts } properties.
|
||||||
|
*
|
||||||
|
* @param {object} consent - Consent object with necessary, analytics, ts properties
|
||||||
|
*/
|
||||||
|
function setConsent(consent) {
|
||||||
|
localStorage.setItem(CONSENT_KEY, JSON.stringify(consent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply stored consent preferences.
|
||||||
|
* Currently loads Google Tag Manager if analytics consent is true.
|
||||||
|
* Hook for extending to other tracking/analytics systems.
|
||||||
|
*
|
||||||
|
* @param {object} consent - Consent object from localStorage
|
||||||
|
*/
|
||||||
|
function applyConsent(consent) {
|
||||||
|
if (window.MyAi && window.MyAi.applyConsent) {
|
||||||
|
window.MyAi.applyConsent(consent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show cookie consent banner with fade-in animation.
|
||||||
|
*/
|
||||||
|
function showBanner() {
|
||||||
|
$('#cookieBanner').fadeIn(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide cookie consent banner with fade-out animation.
|
||||||
|
*/
|
||||||
|
function hideBanner() {
|
||||||
|
$('#cookieBanner').fadeOut(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show "manage cookies" button.
|
||||||
|
* Appears after user has made a consent choice.
|
||||||
|
*/
|
||||||
|
function showManage() {
|
||||||
|
$('#cookieManage').show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize cookie consent flow on page load.
|
||||||
|
* If user has already made a choice, apply preferences and show manage button.
|
||||||
|
* If no choice yet, show consent banner.
|
||||||
|
*/
|
||||||
|
function initConsent() {
|
||||||
|
var consent = getConsent();
|
||||||
|
if (!consent) {
|
||||||
|
showBanner();
|
||||||
|
} else {
|
||||||
|
applyConsent(consent);
|
||||||
|
showManage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind cookie consent button handlers.
|
||||||
|
* Reject or Necessary-only button → analytics: false
|
||||||
|
* Accept button → analytics: true (and load GTM)
|
||||||
|
* Manage button → show banner again
|
||||||
|
*/
|
||||||
|
function setupConsentHandlers() {
|
||||||
|
$('#cookieReject, #cookieNecessary').on('click', function () {
|
||||||
|
setConsent({
|
||||||
|
necessary: true,
|
||||||
|
analytics: false,
|
||||||
|
ts: new Date().toISOString()
|
||||||
|
});
|
||||||
|
hideBanner();
|
||||||
|
showManage();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#cookieAccept').on('click', function () {
|
||||||
|
var consent = {
|
||||||
|
necessary: true,
|
||||||
|
analytics: true,
|
||||||
|
ts: new Date().toISOString()
|
||||||
|
};
|
||||||
|
setConsent(consent);
|
||||||
|
applyConsent(consent);
|
||||||
|
hideBanner();
|
||||||
|
showManage();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#cookieManage').on('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
showBanner();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose consent utilities on window.MyAi
|
||||||
|
window.MyAi = window.MyAi || {};
|
||||||
|
window.MyAi.getConsent = getConsent;
|
||||||
|
window.MyAi.setConsent = setConsent;
|
||||||
|
window.MyAi.initConsent = initConsent;
|
||||||
|
window.MyAi.showBanner = showBanner;
|
||||||
|
window.MyAi.hideBanner = hideBanner;
|
||||||
|
|
||||||
|
// Initialize on DOM ready
|
||||||
|
$(function() {
|
||||||
|
setupConsentHandlers();
|
||||||
|
initConsent();
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* API & Configuration Loading Utilities
|
||||||
|
*
|
||||||
|
* Shared helpers for API health checks and configuration retrieval.
|
||||||
|
* Handles reCaptcha key loading, Google Tag Manager setup, and API health checks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
var reCaptchaSiteKey = null;
|
||||||
|
var gTagManagerId = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check API /health/live endpoint and update status indicator if present.
|
||||||
|
* Updates #api-status element with "API: {status}" (green if OK, red if down).
|
||||||
|
* Logs result to console if element not found.
|
||||||
|
*
|
||||||
|
* @returns {object} - jQuery AJAX promise
|
||||||
|
*/
|
||||||
|
function checkApiLive() {
|
||||||
|
var $statusEl = $('#api-status');
|
||||||
|
return $.ajax({
|
||||||
|
url: '/api/health/live',
|
||||||
|
method: 'GET',
|
||||||
|
dataType: 'json',
|
||||||
|
timeout: 5000
|
||||||
|
}).done(function (data) {
|
||||||
|
var status = (data && data.status) ? data.status : 'unknown';
|
||||||
|
if ($statusEl.length) {
|
||||||
|
$statusEl.text('API: ' + status).removeClass('text-danger').addClass('text-success');
|
||||||
|
} else {
|
||||||
|
console.log('API live status:', status);
|
||||||
|
}
|
||||||
|
}).fail(function (jqXHR, textStatus, errorThrown) {
|
||||||
|
if ($statusEl.length) {
|
||||||
|
$statusEl.text('API: offline').removeClass('text-success').addClass('text-danger');
|
||||||
|
}
|
||||||
|
console.error('API health check failed:', textStatus, errorThrown);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load reCaptcha public site key from /api/captcha endpoint.
|
||||||
|
* Dynamically injects reCaptcha script if not already loaded.
|
||||||
|
* Stores key on window for use by grecaptcha.execute().
|
||||||
|
*
|
||||||
|
* @returns {object} - jQuery AJAX promise
|
||||||
|
*/
|
||||||
|
function getRecaptchaWebKey() {
|
||||||
|
return $.get('/api/captcha').done(function (res) {
|
||||||
|
reCaptchaSiteKey = res;
|
||||||
|
if (reCaptchaSiteKey && !window.__recaptcha_loaded) {
|
||||||
|
window.__recaptcha_loaded = true;
|
||||||
|
var script = document.createElement('script');
|
||||||
|
script.setAttribute('src', 'https://www.google.com/recaptcha/api.js?render=' + reCaptchaSiteKey);
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}
|
||||||
|
}).fail(function () {
|
||||||
|
console.warn('Could not load reCaptcha site key from /api/captcha');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load Google Tag Manager ID from /api/google/tagmanager endpoint.
|
||||||
|
* Does not load GTM script—that happens in loadGoogleTagManager() once consent is given.
|
||||||
|
*
|
||||||
|
* @returns {object} - jQuery AJAX promise
|
||||||
|
*/
|
||||||
|
function getGoogleTagManagerId() {
|
||||||
|
return $.get('/api/google/tagmanager').done(function (res) {
|
||||||
|
gTagManagerId = res;
|
||||||
|
}).fail(function () {
|
||||||
|
console.warn('Could not load Google Tag Manager id from /api/google/tagmanager');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and inject Google Tag Manager script if consent is given and not already loaded.
|
||||||
|
* Sets up dataLayer and injects GTM script from googleapis.com.
|
||||||
|
* Called only after user accepts analytics consent.
|
||||||
|
*
|
||||||
|
* Requires gTagManagerId to be loaded first via getGoogleTagManagerId().
|
||||||
|
*/
|
||||||
|
function loadGoogleTagManager() {
|
||||||
|
if (window.__gtm_loaded || !gTagManagerId) return;
|
||||||
|
window.__gtm_loaded = true;
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
window.dataLayer.push({
|
||||||
|
'gtm.start': new Date().getTime(),
|
||||||
|
event: 'gtm.js'
|
||||||
|
});
|
||||||
|
var script = document.createElement('script');
|
||||||
|
script.async = true;
|
||||||
|
script.src = 'https://www.googletagmanager.com/gtm/js?id=' + gTagManagerId;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the currently loaded reCaptcha site key.
|
||||||
|
* @returns {string|null} - reCaptcha site key or null if not yet loaded
|
||||||
|
*/
|
||||||
|
function getReCaptchaSiteKey() {
|
||||||
|
return reCaptchaSiteKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the currently loaded Google Tag Manager ID.
|
||||||
|
* @returns {string|null} - GTM ID or null if not yet loaded
|
||||||
|
*/
|
||||||
|
function getGoogleTagManagerId() {
|
||||||
|
return gTagManagerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose API utilities on window.MyAi for use by other scripts
|
||||||
|
window.MyAi = window.MyAi || {};
|
||||||
|
window.MyAi.checkApiLive = checkApiLive;
|
||||||
|
window.MyAi.getRecaptchaWebKey = getRecaptchaWebKey;
|
||||||
|
window.MyAi.getGoogleTagManagerId = getGoogleTagManagerId;
|
||||||
|
window.MyAi.loadGoogleTagManager = loadGoogleTagManager;
|
||||||
|
window.MyAi.getReCaptchaSiteKey = getReCaptchaSiteKey;
|
||||||
|
window.MyAi.applyConsent = function(consent) {
|
||||||
|
if (consent && consent.analytics === true) loadGoogleTagManager();
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Form Validation & Error Handling Utilities
|
||||||
|
*
|
||||||
|
* Shared helpers for form field validation and error display across all pages.
|
||||||
|
* Provides reusable patterns for: error messages, field validation, API error extraction.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a field error message and mark its container with is-invalid class.
|
||||||
|
* Finds the appropriate parent container (label, form group, consent row, etc.)
|
||||||
|
* and toggles the is-invalid class based on whether msg is provided.
|
||||||
|
*
|
||||||
|
* @param {string} errorId - HTML ID of the error message element
|
||||||
|
* @param {string} [msg] - Error message to display; empty/null to clear
|
||||||
|
*/
|
||||||
|
function showFieldError(errorId, msg) {
|
||||||
|
var $el = $('#' + errorId);
|
||||||
|
$el.text(msg || '');
|
||||||
|
var $parent = $el.parent();
|
||||||
|
var $container = $parent.is('label, .consent-inline') ? $parent : $el.prev();
|
||||||
|
if (!$container.length || !$container.is('label, .consent-inline, .file-drop, .subscribe-row')) {
|
||||||
|
$container = $el.closest('form');
|
||||||
|
}
|
||||||
|
$container.toggleClass('is-invalid', !!msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear multiple field errors at once.
|
||||||
|
* Calls showFieldError(id, '') for each error element ID.
|
||||||
|
*
|
||||||
|
* @param {string[]} errorIds - Array of HTML IDs to clear
|
||||||
|
*/
|
||||||
|
function clearFieldErrors(errorIds) {
|
||||||
|
for (var i = 0; i < errorIds.length; i++) {
|
||||||
|
showFieldError(errorIds[i], '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate email format using a simple regex pattern.
|
||||||
|
* Checks for: non-whitespace + @ + non-whitespace + . + non-whitespace
|
||||||
|
*
|
||||||
|
* @param {string} value - Email address to validate
|
||||||
|
* @returns {boolean} - True if email format is valid
|
||||||
|
*/
|
||||||
|
function isValidEmail(value) {
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract user-facing error message from API response.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - 429 (rate limit) → return rateLimitKey translation
|
||||||
|
* - 4xx with error body → return server's error message (intentional feedback)
|
||||||
|
* - 5xx or no body → return fallbackKey translation
|
||||||
|
*
|
||||||
|
* @param {object|null} body - Parsed JSON response body
|
||||||
|
* @param {number} status - HTTP status code
|
||||||
|
* @param {string} fallbackKey - i18n key for 5xx/unknown errors (e.g., 'form.failed')
|
||||||
|
* @param {string} [rateLimitKey] - Optional i18n key for 429 (defaults to 'form.rateLimited')
|
||||||
|
* @returns {string} - User-facing error message
|
||||||
|
*/
|
||||||
|
function extractApiError(body, status, fallbackKey, rateLimitKey) {
|
||||||
|
if (status === 429) {
|
||||||
|
return window.MyAi.t(rateLimitKey || 'form.rateLimited');
|
||||||
|
}
|
||||||
|
var msg = body && (body.error || body.Error || body.title);
|
||||||
|
return (status >= 400 && status < 500 && msg) ? msg : window.MyAi.t(fallbackKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose helpers on window.MyAi for use by other scripts
|
||||||
|
window.MyAi = window.MyAi || {};
|
||||||
|
window.MyAi.showFieldError = showFieldError;
|
||||||
|
window.MyAi.clearFieldErrors = clearFieldErrors;
|
||||||
|
window.MyAi.isValidEmail = isValidEmail;
|
||||||
|
window.MyAi.extractApiError = extractApiError;
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Internationalization (i18n) Utilities
|
||||||
|
*
|
||||||
|
* Shared helpers for language detection, translation lookup, and language switching.
|
||||||
|
* Expects window.MyAi.i18n dictionary to be populated by i18n.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
var LANG_KEY = "myai_lang";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect browser language preference from navigator.
|
||||||
|
* Returns 'ro' if browser language starts with 'ro', otherwise 'en'.
|
||||||
|
*
|
||||||
|
* @returns {string} - 'ro' or 'en'
|
||||||
|
*/
|
||||||
|
function browserLang() {
|
||||||
|
return ((navigator.language || navigator.userLanguage || 'en').toLowerCase().indexOf('ro') === 0) ? 'ro' : 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the currently active language preference.
|
||||||
|
* Uses localStorage if set, otherwise detects from browser.
|
||||||
|
*
|
||||||
|
* @returns {string} - 'ro' or 'en'
|
||||||
|
*/
|
||||||
|
function currentLang() {
|
||||||
|
return localStorage.getItem(LANG_KEY) || browserLang();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate a key into the current language.
|
||||||
|
* Falls back to English if key not found in the current language.
|
||||||
|
* Requires window.MyAi.i18n to be populated with en/ro dictionaries.
|
||||||
|
*
|
||||||
|
* Usage: MyAi.t('form.name') → "Name" (or "Nume" if lang is 'ro')
|
||||||
|
*
|
||||||
|
* @param {string} key - Translation key (e.g., 'form.name')
|
||||||
|
* @returns {string} - Translated text or fallback key if not found
|
||||||
|
*/
|
||||||
|
function t(key) {
|
||||||
|
var lang = currentLang();
|
||||||
|
return (window.MyAi.i18n[lang] && window.MyAi.i18n[lang][key]) ||
|
||||||
|
window.MyAi.i18n.en[key] || key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a language across the entire page.
|
||||||
|
* Updates:
|
||||||
|
* - localStorage with new language preference
|
||||||
|
* - <html lang> attribute
|
||||||
|
* - All [data-i18n] elements with translated text
|
||||||
|
* - All [data-i18n-placeholder] elements with translated placeholders
|
||||||
|
* - Legal page links to point to correct language version
|
||||||
|
* - Language button states (aria-pressed)
|
||||||
|
*
|
||||||
|
* @param {string} lang - Language code ('en' or 'ro')
|
||||||
|
*/
|
||||||
|
function applyLanguage(lang) {
|
||||||
|
localStorage.setItem(LANG_KEY, lang);
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
updateLegalLinks(lang);
|
||||||
|
$('[data-i18n]').each(function () {
|
||||||
|
$(this).text(t($(this).data('i18n')));
|
||||||
|
});
|
||||||
|
$('[data-i18n-placeholder]').each(function () {
|
||||||
|
$(this).attr('placeholder', t($(this).data('i18n-placeholder')));
|
||||||
|
});
|
||||||
|
$('.lang-flag').attr('aria-pressed', 'false');
|
||||||
|
$('.lang-flag[data-lang="' + lang + '"]').attr('aria-pressed', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update all legal page links to point to the active language version.
|
||||||
|
* Looks for [data-legal] attribute (e.g., data-legal="terms") and
|
||||||
|
* updates href to /legal/{page}-{lang}.html
|
||||||
|
*
|
||||||
|
* @param {string} lang - Language code ('en' or 'ro')
|
||||||
|
*/
|
||||||
|
function updateLegalLinks(lang) {
|
||||||
|
$('[data-legal]').each(function () {
|
||||||
|
var page = $(this).data('legal');
|
||||||
|
$(this).attr('href', '/legal/' + page + '-' + lang + '.html');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose i18n utilities on window.MyAi for use by other scripts
|
||||||
|
window.MyAi = window.MyAi || {};
|
||||||
|
window.MyAi.currentLang = currentLang;
|
||||||
|
window.MyAi.t = t;
|
||||||
|
window.MyAi.applyLanguage = applyLanguage;
|
||||||
|
window.MyAi.browserLang = browserLang;
|
||||||
Reference in New Issue
Block a user