diff --git a/web/wwwroot/js/modules/cookie-consent.js b/web/wwwroot/js/modules/cookie-consent.js new file mode 100644 index 0000000..799e8e3 --- /dev/null +++ b/web/wwwroot/js/modules/cookie-consent.js @@ -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(); +}); diff --git a/web/wwwroot/js/utils/api.js b/web/wwwroot/js/utils/api.js new file mode 100644 index 0000000..4212a15 --- /dev/null +++ b/web/wwwroot/js/utils/api.js @@ -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(); +}; diff --git a/web/wwwroot/js/utils/form-helpers.js b/web/wwwroot/js/utils/form-helpers.js new file mode 100644 index 0000000..85786ae --- /dev/null +++ b/web/wwwroot/js/utils/form-helpers.js @@ -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; diff --git a/web/wwwroot/js/utils/i18n.js b/web/wwwroot/js/utils/i18n.js new file mode 100644 index 0000000..618f3b1 --- /dev/null +++ b/web/wwwroot/js/utils/i18n.js @@ -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 + * - 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;