diff --git a/web/wwwroot/js/cv-matcher.js b/web/wwwroot/js/cv-matcher.js new file mode 100644 index 0000000..fefe36f --- /dev/null +++ b/web/wwwroot/js/cv-matcher.js @@ -0,0 +1,256 @@ +/** + * MyAi CV Matcher Form + * + * Handles CV upload, job description input, and match result rendering. + * Depends on: jQuery, i18n.js, main.js (for shared utilities) + */ +(function ($) { + "use strict"; + + var reCaptchaSiteKey = null; + var LANG_KEY = "myai_lang"; + + /** + * Retrieve translation by key. + * Falls back to English if key not found in current language. + */ + function t(key) { + var lang = localStorage.getItem(LANG_KEY) || 'en'; + return (window.MyAi.i18n[lang] && window.MyAi.i18n[lang][key]) || + window.MyAi.i18n.en[key] || key; + } + + /** + * Get current language from localStorage or detect from browser. + */ + function currentLang() { + return localStorage.getItem(LANG_KEY) || + (((navigator.language || navigator.userLanguage || 'en').toLowerCase().indexOf('ro') === 0) ? 'ro' : 'en'); + } + + /** + * Display field error message and mark container with is-invalid class. + */ + 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. + */ + function clearFieldErrors(errorIds) { + for (var i = 0; i < errorIds.length; i++) showFieldError(errorIds[i], ''); + } + + /** + * Validate email format with simple regex. + */ + function isValidEmail(value) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value || '').trim()); + } + + /** + * Update displayed CV filename when file is selected. + */ + $('#cvFile').on('change', function () { + var file = this.files && this.files[0]; + $('#cvFileName').text(file ? file.name : t('cv.fileHint')); + }); + + /** + * Handle CV Matcher form submission. + */ + $('#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(''); + + // Validate inputs + 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.processingLong')) + '
'); + $cvLoader.addClass('loader-visible'); + + // Require reCaptcha and get CV upload token + if (window.grecaptcha && reCaptchaSiteKey) { + grecaptcha.ready(function () { + grecaptcha.execute(reCaptchaSiteKey, { + action: 'cv_upload' + }).then(postCv); + }); + } else { + $cvLoader.removeClass('loader-visible'); + $msg.removeClass().addClass('form-message text-danger').text(t('form.captchaFailed')); + $button.prop('disabled', false).text(t('cv.submit')); + return; + } + + /** + * Upload CV and request match score. + */ + async function postCv(token) { + try { + // Upload CV PDF + var formData = new FormData(); + formData.append('file', file); + formData.append('gdprConsent', String(consent)); + formData.append('captchaToken', token || ''); + var cvResponse = await fetch('/api/cv-matcher/upload', { + method: 'POST', + body: formData + }); + if (!cvResponse.ok) { + var cvErrBody = null; + try { cvErrBody = await cvResponse.json(); } catch (_) {} + throw new Error(extractApiError(cvErrBody, cvResponse.status, 'cv.cvFailed', 'cv.rateLimited')); + } + var cvData = await cvResponse.json(); + + // Get fresh captcha token for match action + if (!(window.grecaptcha && reCaptchaSiteKey)) { + throw new Error(t('form.captchaFailed')); + } + var matchToken = await new Promise(function (resolve, reject) { + try { + grecaptcha.ready(function () { + grecaptcha.execute(reCaptchaSiteKey, { action: 'match_job' }).then(resolve).catch(reject); + }); + } catch (e) { reject(e); } + }); + + // Request match + var matchResponse = await fetch('/api/cv-matcher/match-job', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + cvDocumentId: cvData.documentId || cvData.cvDocumentId, + jobUrl: jobUrl, + jobDescription: jobDescription, + email: matchEmail, + gdprConsent: consent, + captchaToken: matchToken, + language: currentLang() + }) + }); + if (!matchResponse.ok) { + var matchErrBody = null; + try { matchErrBody = await matchResponse.json(); } catch (_) {} + throw new Error(extractApiError(matchErrBody, matchResponse.status, 'cv.matchFailed', 'cv.rateLimited')); + } + var match = await matchResponse.json(); + renderMatchResult(match); + $msg.removeClass().addClass('form-message text-success').text(t('cv.completed')); + } catch (err) { + console.error(err); + var isRateLimited = err && err.message === t('cv.rateLimited'); + var tone = isRateLimited ? 'text-warning' : 'text-danger'; + $msg.removeClass().addClass('form-message ' + tone).text(err.message || t('cv.matchFailed')); + $result.html('
' + escapeHtml(isRateLimited ? t('cv.rateLimited') : t('cv.backendMissing')) + '
'); + } finally { + $cvLoader.removeClass('loader-visible'); + $button.prop('disabled', false).text(t('cv.submit')); + } + } + }); + + /** + * Render CV match result into #matchResult panel. + */ + function renderMatchResult(match) { + var score = match.score || match.matchScore || 0; + var summary = match.summary || t('cv.noSummary'); + var strengths = match.strengths || []; + var gaps = match.gaps || match.missingSkills || []; + var evidence = match.evidence || match.retrievedChunks || []; + + /** + * Render list of items (strengths, gaps, evidence). + */ + function list(items) { + if (!items || !items.length) { + return '

' + escapeHtml(t('cv.noItems')) + '

'; + } + return ''; + } + + $('#matchResult').html( + '
' + Number(score).toFixed(0) + '%
' + + '

' + escapeHtml(summary) + '

' + + '

' + t('cv.strengths') + '

' + list(strengths) + + '

' + t('cv.gaps') + '

' + list(gaps) + + '

' + t('cv.evidence') + '

' + list(evidence) + ); + } + + /** + * Escape HTML special characters to prevent XSS. + */ + function escapeHtml(value) { + return String(value).replace(/[&<>'"]/g, function (char) { + return ({ + '&': '&', + '<': '<', + '>': '>', + "'": ''', + '"': '"' + })[char]; + }); + } + + /** + * Extract user-facing error message from failed API response. + * For 4xx: show server's error field (intentional user feedback). + * For 5xx or missing body: show generic i18n fallback. + */ + 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); + } + + /** + * Load reCaptcha site key on page load (needed for CV upload). + */ + $(window).on('load', function () { + $.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'); + }); + }); + +})(jQuery);