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:
2026-05-29 09:05:51 +03:00
parent 57e8cb3f4b
commit 98979b58f8
4 changed files with 420 additions and 0 deletions
+121
View File
@@ -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();
};
+77
View File
@@ -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;
+91
View File
@@ -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;