diff --git a/web/wwwroot/js/main.js b/web/wwwroot/js/main.js index e40a27c..4004763 100644 --- a/web/wwwroot/js/main.js +++ b/web/wwwroot/js/main.js @@ -1,245 +1,66 @@ +/** + * 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"; - var i18n = { - en: { - "brand.subtitle": "AI engineering showcase", - "nav.demos": "Demos", - "nav.contact": "Contact", - "nav.navigator": "Navigator", - "nav.matcher": "Matcher", - "home.eyebrow": "Applied AI lab", - "home.title": "Production-minded AI demos, not generic chatbot wrappers.", - "home.text": "MyAi.ro is a technical showcase for practical AI systems: document understanding, retrieval, matching, automation and decision support.", - "home.openCv": "Open CV Matcher", - "home.step1": "CV.pdf", - "home.step2": "skills, projects, experience", - "home.step3": "relevant CV context", - "home.step4": "job match + gaps", - "home.navigator": "Navigator", - "home.selectDemo": "Select an AI demo", - "home.selectText": "Our first steps in AI integrations.", - "tag.available": "Available", - "tag.next": "Next", - "demo.cv.title": "CV Matcher", - "demo.cv.text": "Upload a CV PDF, add a job link or description, and get a match score with strengths and gaps.", - "demo.open": "Open demo →", - "demo.rag.title": "RAG Playground", - "demo.rag.text": "Experiment with chunk size, retrieval count and source context transparency.", - "demo.agent.title": "Agent Automation", - "demo.agent.text": "Job discovery, filtering, ranking and notification workflows.", - "contact.title": "Discuss an AI integration or custom software project", - "contact.text": "Fill in the form and we'll get back to you.", - "contact.person": "Contact person", - "contact.phone": "Phone", - "form.name": "Name", - "form.namePlaceholder": "Your name", - "form.email": "Email", - "form.emailPlaceholder": "name@company.com", - "form.message": "Message", - "form.messagePlaceholder": "Tell me what you want to build.", - "form.send": "Send message", - "form.thanks": "Thank you for your message.", - "form.captchaFailed": "Captcha verification failed.", - "form.failed": "Failed to send the message.", - "form.rateLimited": "Too many requests from your network. Please wait a moment and try again.", - "form.required.name": "Please enter your name.", - "form.required.email": "Please enter your email address.", - "form.required.emailInvalid": "Please enter a valid email address.", - "form.required.message": "Please write a message.", - "subscribe.title": "Stay in the loop", - "subscribe.text": "Get a short note when a new AI demo is published. One email at most every few weeks.", - "subscribe.emailPlaceholder": "name@company.com", - "subscribe.gdpr": "I agree to receive occasional emails about new demos.", - "subscribe.submit": "Subscribe", - "subscribe.thanks": "Thanks! You are subscribed.", - "subscribe.failed": "Could not subscribe right now. Please try again later.", - "subscribe.noConsent": "Please confirm your consent first.", - "footer.rights": "All rights reserved", - "footer.top": "Back to top", - "legal.terms": "Terms", - "legal.privacy": "Privacy", - "legal.cookies": "Cookies", - "status.sending": "Sending...", - "cookies.title": "Cookies", - "cookies.text": "We use necessary cookies and, with your consent, analytics through Google Tag Manager.", - "cookies.policy": "Privacy policy", - "cookies.reject": "Reject", - "cookies.necessary": "Necessary only", - "cookies.accept": "Accept analytics", - "cookies.settings": "Cookie settings", - "cv.brand": "CV Matcher", - "cv.eyebrow": "AI CV Matcher", - "cv.title": "Upload your CV, add a job link, and see how well they match.", - "cv.text": "We read your CV, find the parts most relevant to the job, then produce a match score with strengths, gaps and suggested next steps.", - "cv.try": "Try matcher", - "cv.back": "Back to navigator", - "cv.step1": "PDF text extraction", - "cv.step2": "CV chunking + embeddings", - "cv.step3": "Job URL/description parsing", - "cv.step4": "Match score + evidence", - "cv.input": "Input", - "cv.details": "CV and job details", - "cv.upload": "Upload CV PDF", - "cv.fileHint": "PDF only.", - "cv.jobLink": "Job link", - "cv.jobDescription": "Or paste job description", - "cv.jobPlaceholder": "Paste the job description if the page can't be read automatically.", - "cv.gdpr": "I agree that my CV is processed and stored.", - "cv.submit": "Extract CV and match job", - "cv.result": "Result", - "cv.analysis": "Match analysis", - "cv.empty": "Upload a CV and provide a job link or description to generate a result.", - "cv.contactTitle": "Want this adapted for your workflow?", - "cv.contactText": "Send a quick note and we'll get back to you.", - "cv.noFile": "Please upload a CV PDF.", - "cv.noJob": "Add a job link or paste a job description.", - "cv.noConsent": "GDPR consent is required.", - "cv.processing": "Processing...", - "cv.extracting": "Extracting CV and matching job...", - "cv.processingLong": "Processing CV PDF and job input.", - "cv.cvFailed": "CV extraction failed", - "cv.matchFailed": "Job matching failed", - "cv.rateLimited": "Too many CV matcher requests from your network. Please try again in a few minutes.", - "cv.completed": "Match completed.", - "cv.backendMissing": "There was an error while processing the CV.", - "cv.loaderTitle": "Working on your CV", - "cv.loaderText": "Extracting text, retrieving context and scoring the match. This can take a moment.", - "cv.noSummary": "No summary available.", - "cv.noItems": "No items yet.", - "cv.strengths": "Strengths", - "cv.gaps": "Gaps", - "cv.evidence": "Supporting CV excerpts" - }, - ro: { - "brand.subtitle": "prezentare inginerie AI", - "nav.demos": "Demo-uri", - "nav.contact": "Contact", - "nav.navigator": "Navigator", - "nav.matcher": "Matcher", - "home.eyebrow": "Laborator AI aplicat", - "home.title": "Demo-uri AI orientate spre producție, nu simple wrapper-e de chatbot.", - "home.text": "MyAi.ro este o prezentare tehnică pentru sisteme AI practice: înțelegere documente, retrieval, potrivire, automatizare și suport decizional.", - "home.openCv": "Deschide CV Matcher", - "home.step1": "CV.pdf", - "home.step2": "competențe, proiecte, experiență", - "home.step3": "context relevant din CV", - "home.step4": "potrivire job + lipsuri", - "home.navigator": "Navigator", - "home.selectDemo": "Alege un demo AI", - "home.selectText": "Primele noastre proiecte.", - "tag.available": "Disponibil", - "tag.next": "Urmează", - "demo.cv.title": "CV Matcher", - "demo.cv.text": "Încarcă un CV PDF, adaugă un link sau o descriere de job și primește un scor de potrivire cu puncte forte și lipsuri.", - "demo.open": "Deschide demo →", - "demo.rag.title": "RAG Playground", - "demo.rag.text": "Experimentează cu dimensiunea chunk-urilor, numărul de rezultate și transparența surselor.", - "demo.agent.title": "Automatizare cu agenți", - "demo.agent.text": "Fluxuri pentru descoperire joburi, filtrare, ordonare și notificări.", - "contact.title": "Discută o integrare AI sau un proiect software custom", - "contact.text": "Completează formularul și te vom contacta.", - "contact.person": "Persoană de contact", - "contact.phone": "Telefon", - "form.name": "Nume", - "form.namePlaceholder": "Numele tău", - "form.email": "Email", - "form.emailPlaceholder": "nume@companie.ro", - "form.message": "Mesaj", - "form.messagePlaceholder": "Spune-mi ce vrei să construiești.", - "form.send": "Trimite mesajul", - "form.thanks": "Mulțumesc pentru mesaj.", - "form.captchaFailed": "Verificarea Captcha a eșuat.", - "form.failed": "Mesajul nu a putut fi trimis.", - "form.rateLimited": "Prea multe cereri din rețeaua ta. Te rugăm să aștepți câteva momente și să încerci din nou.", - "form.required.name": "Te rugăm să introduci numele.", - "form.required.email": "Te rugăm să introduci adresa de email.", - "form.required.emailInvalid": "Te rugăm să introduci o adresă de email validă.", - "form.required.message": "Te rugăm să scrii un mesaj.", - "subscribe.title": "Rămâi la curent", - "subscribe.text": "Primești o notă scurtă când publicăm un nou demo AI. Cel mult un email la câteva săptămâni.", - "subscribe.emailPlaceholder": "nume@companie.ro", - "subscribe.gdpr": "Sunt de acord să primesc ocazional emailuri despre noile demo-uri.", - "subscribe.submit": "Abonează-mă", - "subscribe.thanks": "Mulțumesc! Ești abonat.", - "subscribe.failed": "Nu am putut realiza abonarea acum. Te rugăm să încerci mai târziu.", - "subscribe.noConsent": "Te rugăm să confirmi întâi consimțământul.", - "footer.rights": "Toate drepturile rezervate", - "footer.top": "Înapoi sus", - "legal.terms": "Termeni", - "legal.privacy": "Confidențialitate", - "legal.cookies": "Cookies", - "status.sending": "Se trimite...", - "cookies.title": "Cookies", - "cookies.text": "Folosim cookies necesare și, cu acordul tău, analytics prin Google Tag Manager.", - "cookies.policy": "Politica de confidențialitate", - "cookies.reject": "Respinge", - "cookies.necessary": "Doar necesare", - "cookies.accept": "Accept analytics", - "cookies.settings": "Setări cookies", - "cv.brand": "CV Matcher", - "cv.eyebrow": "AI CV Matcher", - "cv.title": "Încarcă CV-ul, adaugă un link de job și vezi cât de bine se potrivesc.", - "cv.text": "Îți citim CV-ul, găsim părțile cele mai relevante pentru job și calculăm un scor de potrivire cu puncte forte, lipsuri și pași următori sugerați.", - "cv.try": "Încearcă matcherul", - "cv.back": "Înapoi la navigator", - "cv.step1": "Extragere text PDF", - "cv.step2": "Chunking CV + embeddings", - "cv.step3": "Parsare URL/descriere job", - "cv.step4": "Scor potrivire + dovezi", - "cv.input": "Date de intrare", - "cv.details": "CV și detalii job", - "cv.upload": "Încarcă CV PDF", - "cv.fileHint": "Doar PDF.", - "cv.jobLink": "Link job", - "cv.jobDescription": "Sau lipește descrierea jobului", - "cv.jobPlaceholder": "Lipește descrierea jobului dacă pagina nu poate fi citită automat.", - "cv.gdpr": "Sunt de acord ca CV-ul meu să fie procesat și stocat.", - "cv.submit": "Extrage CV și compară jobul", - "cv.result": "Rezultat", - "cv.analysis": "Analiză potrivire", - "cv.empty": "Încarcă un CV și adaugă un link sau o descriere de job pentru a genera rezultatul.", - "cv.contactTitle": "Vrei o variantă adaptată fluxului tău de lucru?", - "cv.contactText": "Lasă-ne un mesaj și revenim cu detalii.", - "cv.noFile": "Te rog încarcă un CV PDF.", - "cv.noJob": "Adaugă un link de job sau lipește descrierea jobului.", - "cv.noConsent": "Consimțământul GDPR este obligatoriu.", - "cv.processing": "Se procesează...", - "cv.extracting": "Se extrage CV-ul și se compară jobul...", - "cv.processingLong": "Se procesează PDF-ul și informațiile despre job.", - "cv.cvFailed": "Extragerea CV-ului a eșuat", - "cv.matchFailed": "Potrivirea jobului a eșuat", - "cv.rateLimited": "Prea multe cereri către CV matcher din rețeaua ta. Te rugăm să încerci din nou peste câteva minute.", - "cv.completed": "Matching finalizat.", - "cv.backendMissing": "A apărut o eroare la procesarea CV-ului.", - "cv.loaderTitle": "Procesăm CV-ul tău", - "cv.loaderText": "Extragem textul, recuperăm contextul și calculăm scorul potrivirii. Poate dura câteva momente.", - "cv.noSummary": "Niciun sumar disponibil.", - "cv.noItems": "Niciun element.", - "cv.strengths": "Puncte forte", - "cv.gaps": "Lipsuri", - "cv.evidence": "Fragmente relevante din CV" - } - }; + 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 (i18n[lang] && i18n[lang][key]) || i18n.en[key] || key; + 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'); @@ -247,6 +68,13 @@ }); } + /** + * 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; @@ -261,173 +89,60 @@ $('.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'); }); - 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); - }); - } - 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'); - }); - } - - 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'); - }); - } - - 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); - } - - function getConsent() { - try { - return JSON.parse(localStorage.getItem(CONSENT_KEY)); - } catch (e) { - return null; - } - } - - function setConsent(consent) { - localStorage.setItem(CONSENT_KEY, JSON.stringify(consent)); - } - - function applyConsent(consent) { - if (consent && consent.analytics === true) loadGoogleTagManager(); - } - - function showBanner() { - $('#cookieBanner').fadeIn(200); - } - - function hideBanner() { - $('#cookieBanner').fadeOut(200); - } - - 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(); - }); - - function initConsent() { - var consent = getConsent(); - if (!consent) showBanner(); - else { - applyConsent(consent); - showManage(); - } - } + /* ============================================================ + SHARED FORM HELPERS + ============================================================ */ /** - * Extracts a user-facing error message from a failed API response body. - * For 4xx responses the server's error field is shown directly (it is intentional user feedback). - * For 5xx or missing body a generic i18n fallback is returned instead. - * @param {object|null} body Parsed JSON response body, or null if unparseable. - * @param {number} status HTTP status code. - * @param {string} fallbackKey i18n key to use for 5xx / unknown errors. - * @param {string} [rateLimitKey] i18n key for 429 responses; defaults to 'form.rateLimited'. + * 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 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); - } - - function submitMSG(valid, msg, severity) { - var tone = valid ? 'text-success' : ('text-' + (severity || 'danger')); - $('#msgSubmit').removeClass().addClass('form-message ' + tone).text(msg); - } - function showFieldError(errorId, msg) { var $el = $('#' + errorId); $el.text(msg || ''); - // Look first at the parent (for errors nested inside a label), - // then at the previous sibling block (for errors after consent/file-drop/subscribe-row). 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')) { @@ -436,25 +151,80 @@ $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'), @@ -466,6 +236,7 @@ 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; } @@ -473,9 +244,12 @@ if (!message) { showFieldError('messageError', t('form.required.message')); hasError = true; } if (hasError) return; - loader.css('display', 'flex'); + loader.addClass('loader-visible'); button.prop('disabled', true); + /** + * Post contact form with captcha token. + */ function postContact(token) { var payload = { Name: name, @@ -497,134 +271,30 @@ submitMSG(false, extractApiError(jqXHR.responseJSON, jqXHR.status, 'form.failed'), jqXHR.status === 429 ? 'warning' : 'danger'); formError(); }).always(function () { - loader.hide(); + loader.removeClass('loader-visible'); button.prop('disabled', false); }); } + if (window.grecaptcha && reCaptchaSiteKey) { grecaptcha.ready(function () { - grecaptcha.execute(reCaptchaSiteKey, { - action: 'contact' - }).then(postContact); + grecaptcha.execute(reCaptchaSiteKey, { action: 'contact' }).then(postContact); }); } else { - // Captcha unavailable: show clear error and restore UI submitMSG(false, t('form.captchaFailed')); formError(); - loader.hide(); + loader.removeClass('loader-visible'); button.prop('disabled', false); - return; } }); - $('#cvFile').on('change', function () { - var file = this.files && this.files[0]; - $('#cvFileName').text(file ? file.name : t('cv.fileHint')); - }); - $('#cvMatcherForm').on('submit', async function (event) { - event.preventDefault(); - var file = $('#cvFile')[0] && $('#cvFile')[0].files[0]; - var jobUrl = $('#jobUrl').val(); - var jobDescription = $('#jobDescription').val(); - var matchEmail = $('#matchEmail').val(); - var consent = $('#gdprConsent').is(':checked'); - var $msg = $('#matcherMsg'), - $button = $('#matchSubmit'), - $result = $('#matchResult'); - - clearFieldErrors(['cvFileError', 'cvJobError', 'cvConsentError']); - $msg.removeClass().addClass('form-message').text(''); - - var hasError = false; - if (!file) { showFieldError('cvFileError', t('cv.noFile')); hasError = true; } - if (!jobUrl && !jobDescription) { showFieldError('cvJobError', t('cv.noJob')); hasError = true; } - if (!consent) { showFieldError('cvConsentError', t('cv.noConsent')); hasError = true; } - if (hasError) return; - var $cvLoader = $('#cvLoader'); - $button.prop('disabled', true).text(t('cv.processing')); - $msg.removeClass().addClass('form-message').text(t('cv.extracting')); - $result.html('
' + escapeHtml(t('cv.noItems')) + '
'; - return '' + escapeHtml(summary) + '