/** * MyAi Main Application Logic * * Core responsibilities: * - i18n initialization and language switching * - Header/nav behavior * - Cookie consent management * - Contact and subscribe forms (with reCaptcha) * - API health checks and configuration loading * * Depends on: jQuery, i18n.js (translation dict) * Shared utilities exposed on window.MyAi for use by other scripts. */ (function ($) { "use strict"; /* ============================================================ INITIALIZATION & STATE ============================================================ */ var reCaptchaSiteKey = null; var gTagManagerId = null; var CONSENT_KEY = "myai_cookie_consent"; var LANG_KEY = "myai_lang"; window.MyAi = window.MyAi || {}; /* ============================================================ TRANSLATION HELPERS ============================================================ */ /** * Get current language preference from localStorage or browser detection. * Defaults to English. */ function browserLang() { return ((navigator.language || navigator.userLanguage || 'en').toLowerCase().indexOf('ro') === 0) ? 'ro' : 'en'; } /** * Get the active language (localStorage takes precedence). */ 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. */ function t(key) { var lang = currentLang(); return (window.MyAi.i18n[lang] && window.MyAi.i18n[lang][key]) || window.MyAi.i18n.en[key] || key; } // Expose shared translation helpers for other scripts window.MyAi.currentLang = currentLang; window.MyAi.t = t; /** * Update all legal page links to point to the active language. */ function updateLegalLinks(lang) { $('[data-legal]').each(function () { var page = $(this).data('legal'); $(this).attr('href', '/legal/' + page + '-' + lang + '.html'); }); } /** * Apply language across the entire page: * - Update localStorage and attribute * - Translate all [data-i18n] and [data-i18n-placeholder] elements * - Update legal page links * - Update language button states */ 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'); } /* ============================================================ PAGE INITIALIZATION ============================================================ */ // Set footer year $('#year').text(new Date().getFullYear()); // Load and display app version $.getJSON('/api/health/version').done(function (data) { if (data && data.version) { $('#app-version').text('v' + data.version); } }); // Initialize language applyLanguage(currentLang()); $('.lang-flag').on('click', function () { applyLanguage($(this).data('lang')); }); /* ============================================================ HEADER / NAVIGATION ============================================================ */ /** * Toggle mobile hamburger menu. */ $('#menuToggle').on('click', function () { var $nav = $('#mainNav'); var open = !$nav.hasClass('is-open'); $nav.toggleClass('is-open', open); $(this).attr('aria-expanded', open ? 'true' : 'false'); }); /** * Close mobile menu when a nav link is clicked. */ $('.nav a').on('click', function () { $('#mainNav').removeClass('is-open'); $('#menuToggle').attr('aria-expanded', 'false'); }); /* ============================================================ SHARED FORM HELPERS ============================================================ */ /** * Display a field error and mark its container with is-invalid class. * @param {string} errorId - ID of the error element * @param {string} msg - Error message to display (or empty 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. * @param {string[]} errorIds - Array of error element IDs */ function clearFieldErrors(errorIds) { for (var i = 0; i < errorIds.length; i++) showFieldError(errorIds[i], ''); } /** * Validate email format with simple regex. * @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 a user-facing error message from a failed API response. * For 4xx responses, the server's error field is shown (intentional feedback). * For 5xx or missing body, a generic i18n fallback is returned. * @param {object|null} body - Parsed JSON response body * @param {number} status - HTTP status code * @param {string} fallbackKey - i18n key for 5xx/unknown errors * @param {string} [rateLimitKey] - i18n key for 429 responses * @returns {string} - User-facing error message */ function extractApiError(body, status, fallbackKey, rateLimitKey) { if (status === 429) return t(rateLimitKey || 'form.rateLimited'); var msg = body && (body.error || body.Error || body.title); return (status >= 400 && status < 500 && msg) ? msg : t(fallbackKey); } // Expose shared helpers for other scripts window.MyAi.showFieldError = showFieldError; window.MyAi.clearFieldErrors = clearFieldErrors; window.MyAi.isValidEmail = isValidEmail; window.MyAi.extractApiError = extractApiError; /* ============================================================ CONTACT FORM ============================================================ */ /** * Display success message and reset form. */ function formSuccess() { $('#contactForm')[0].reset(); submitMSG(true, t('form.thanks')); } /** * Play shake animation on form error. */ function formError() { $('#contactForm').removeClass().addClass('contact-form shake').one('animationend', function () { $(this).removeClass('shake'); }); } /** * Update form message element with success/error state and message. * @param {boolean} valid - True for success state, false for error * @param {string} msg - Message to display * @param {string} [severity] - CSS class suffix for color ('danger', 'warning', etc.) */ function submitMSG(valid, msg, severity) { var tone = valid ? 'text-success' : ('text-' + (severity || 'danger')); $('#msgSubmit').removeClass().addClass('form-message ' + tone).text(msg); } /** * Handle contact form submission. */ $('#contactForm').on('submit', function (event) { event.preventDefault(); var loader = $('#contactLoader'), button = $('#submit'); var name = $('#name').val().trim(); var email = $('#email').val().trim(); var message = $('#message').val().trim(); clearFieldErrors(['nameError', 'emailError', 'messageError']); $('#msgSubmit').removeClass().addClass('form-message').text(''); // Validate inputs var hasError = false; if (!name) { showFieldError('nameError', t('form.required.name')); hasError = true; } if (!email) { showFieldError('emailError', t('form.required.email')); hasError = true; } else if (!isValidEmail(email)) { showFieldError('emailError', t('form.required.emailInvalid')); hasError = true; } if (!message) { showFieldError('messageError', t('form.required.message')); hasError = true; } if (hasError) return; loader.addClass('loader-visible'); button.prop('disabled', true); /** * Post contact form with captcha token. */ function postContact(token) { var payload = { Name: name, Email: email, Subject: '[MyAi.ro contact request]', Message: message, CaptchaToken: token || '' }; $.ajax({ type: 'POST', url: '/api/contact', data: JSON.stringify(payload), contentType: 'application/json; charset=utf-8', dataType: 'json' }).done(function (resp) { if (resp && resp.ok === true) formSuccess(); else submitMSG(false, t('form.captchaFailed')); }).fail(function (jqXHR) { submitMSG(false, extractApiError(jqXHR.responseJSON, jqXHR.status, 'form.failed'), jqXHR.status === 429 ? 'warning' : 'danger'); formError(); }).always(function () { loader.removeClass('loader-visible'); button.prop('disabled', false); }); } if (window.grecaptcha && reCaptchaSiteKey) { grecaptcha.ready(function () { grecaptcha.execute(reCaptchaSiteKey, { action: 'contact' }).then(postContact); }); } else { submitMSG(false, t('form.captchaFailed')); formError(); loader.removeClass('loader-visible'); button.prop('disabled', false); } }); /* ============================================================ SUBSCRIBE FORM ============================================================ */ /** * Handle subscribe form submission. */ $('#subscribeForm').on('submit', function (event) { event.preventDefault(); var $msg = $('#subscribeMsg'); var $button = $('#subscribeSubmit'); var $loader = $('#contactLoader'); var email = $('#subscribeEmail').val().trim(); var consent = $('#subscribeConsent').is(':checked'); /** * Update subscribe form message. */ function setMsg(severity, key) { $msg.removeClass().addClass('form-message text-' + severity).text(t(key)); } clearFieldErrors(['subscribeEmailError', 'subscribeConsentError']); $msg.removeClass().addClass('form-message').text(''); // Validate inputs var hasError = false; if (!email) { showFieldError('subscribeEmailError', t('form.required.email')); hasError = true; } else if (!isValidEmail(email)) { showFieldError('subscribeEmailError', t('form.required.emailInvalid')); hasError = true; } if (!consent) { showFieldError('subscribeConsentError', t('subscribe.noConsent')); hasError = true; } if (hasError) return; $loader.addClass('loader-visible'); $button.prop('disabled', true); /** * Post subscribe with captcha token. */ function postSubscribe(token) { $.ajax({ type: 'POST', url: '/api/contact/subscribe', data: JSON.stringify({ Email: email, CaptchaToken: token || '' }), contentType: 'application/json; charset=utf-8', dataType: 'json' }).done(function (resp) { if (resp && resp.ok === true) { $('#subscribeForm')[0].reset(); setMsg('success', 'subscribe.thanks'); } else { setMsg('danger', 'form.captchaFailed'); } }).fail(function (jqXHR) { $msg.removeClass().addClass('form-message text-' + (jqXHR.status === 429 ? 'warning' : 'danger')) .text(extractApiError(jqXHR.responseJSON, jqXHR.status, 'subscribe.failed')); }).always(function () { $loader.removeClass('loader-visible'); $button.prop('disabled', false); }); } if (window.grecaptcha && reCaptchaSiteKey) { grecaptcha.ready(function () { grecaptcha.execute(reCaptchaSiteKey, { action: 'subscribe' }).then(postSubscribe); }); } else { $loader.removeClass('loader-visible'); $button.prop('disabled', false); setMsg('danger', 'form.captchaFailed'); } }); /* ============================================================ COOKIE CONSENT ============================================================ */ /** * Parse consent object from localStorage. * @returns {object|null} - Parsed consent object or null if not found */ function getConsent() { try { return JSON.parse(localStorage.getItem(CONSENT_KEY)); } catch (e) { return null; } } /** * Persist consent object to localStorage. */ function setConsent(consent) { localStorage.setItem(CONSENT_KEY, JSON.stringify(consent)); } /** * Apply stored consent preferences (e.g., load Google Tag Manager). */ function applyConsent(consent) { if (consent && consent.analytics === true) loadGoogleTagManager(); } /** * Show cookie banner. */ function showBanner() { $('#cookieBanner').fadeIn(200); } /** * Hide cookie banner. */ function hideBanner() { $('#cookieBanner').fadeOut(200); } /** * Show "manage cookies" button. */ function showManage() { $('#cookieManage').show(); } $('#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(); }); /** * Initialize consent banner based on stored preferences. */ function initConsent() { var consent = getConsent(); if (!consent) showBanner(); else { applyConsent(consent); showManage(); } } /* ============================================================ API & CONFIGURATION LOADING ============================================================ */ /** * Check API /health/live endpoint and update status indicator. */ 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. */ 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. */ 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 Google Tag Manager script if consent given and not already loaded. */ 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); } /* ============================================================ STARTUP ============================================================ */ $(window).on('load', function () { $.when(checkApiLive(), getRecaptchaWebKey(), getGoogleTagManagerId()).always(initConsent); }); })(jQuery);