b6878e3b45
The frontend sends the active language code (currentLang()) with every match request. CvMatcherService injects a language instruction into the system prompt so the LLM returns summary, strengths, gaps, recommendations, and evidence in the correct language. The match result cache (CvMatchResults) now includes Language as part of the lookup key so Romanian and English results are stored and retrieved independently. Existing cached rows default to 'en'. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
708 lines
27 KiB
JavaScript
708 lines
27 KiB
JavaScript
(function ($) {
|
|
"use strict";
|
|
|
|
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"
|
|
}
|
|
};
|
|
|
|
function browserLang() {
|
|
return ((navigator.language || navigator.userLanguage || 'en').toLowerCase().indexOf('ro') === 0) ? 'ro' : 'en';
|
|
}
|
|
|
|
function currentLang() {
|
|
return localStorage.getItem(LANG_KEY) || browserLang();
|
|
}
|
|
|
|
function t(key) {
|
|
var lang = currentLang();
|
|
return (i18n[lang] && i18n[lang][key]) || i18n.en[key] || key;
|
|
}
|
|
|
|
function updateLegalLinks(lang) {
|
|
$('[data-legal]').each(function () {
|
|
var page = $(this).data('legal');
|
|
$(this).attr('href', '/legal/' + page + '-' + lang + '.html');
|
|
});
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
$('#year').text(new Date().getFullYear());
|
|
|
|
$.getJSON('/api/health/version').done(function (data) {
|
|
if (data && data.version) {
|
|
$('#app-version').text('v' + data.version);
|
|
}
|
|
});
|
|
|
|
applyLanguage(currentLang());
|
|
$('.lang-flag').on('click', function () {
|
|
applyLanguage($(this).data('lang'));
|
|
});
|
|
|
|
$('#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');
|
|
});
|
|
$('.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();
|
|
}
|
|
}
|
|
|
|
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')) {
|
|
$container = $el.closest('form');
|
|
}
|
|
$container.toggleClass('is-invalid', !!msg);
|
|
}
|
|
|
|
function clearFieldErrors(errorIds) {
|
|
for (var i = 0; i < errorIds.length; i++) showFieldError(errorIds[i], '');
|
|
}
|
|
|
|
function isValidEmail(value) {
|
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value || '').trim());
|
|
}
|
|
|
|
function formSuccess() {
|
|
$('#contactForm')[0].reset();
|
|
submitMSG(true, t('form.thanks'));
|
|
}
|
|
|
|
function formError() {
|
|
$('#contactForm').removeClass().addClass('contact-form shake').one('animationend', function () {
|
|
$(this).removeClass('shake');
|
|
});
|
|
}
|
|
|
|
$('#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('');
|
|
|
|
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.css('display', 'flex');
|
|
button.prop('disabled', true);
|
|
|
|
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) {
|
|
var isRateLimited = jqXHR && jqXHR.status === 429;
|
|
submitMSG(false, isRateLimited ? t('form.rateLimited') : t('form.failed'), isRateLimited ? 'warning' : 'danger');
|
|
formError();
|
|
}).always(function () {
|
|
loader.hide();
|
|
button.prop('disabled', false);
|
|
});
|
|
}
|
|
if (window.grecaptcha && reCaptchaSiteKey) {
|
|
grecaptcha.ready(function () {
|
|
grecaptcha.execute(reCaptchaSiteKey, {
|
|
action: 'contact'
|
|
}).then(postContact);
|
|
});
|
|
} else {
|
|
// Captcha unavailable: show clear error and restore UI
|
|
submitMSG(false, t('form.captchaFailed'));
|
|
formError();
|
|
loader.hide();
|
|
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('<div class="empty-result">' + escapeHtml(t('cv.processingLong')) + '</div>');
|
|
$cvLoader.css('display', 'flex');
|
|
|
|
if (window.grecaptcha && reCaptchaSiteKey) {
|
|
grecaptcha.ready(function () {
|
|
grecaptcha.execute(reCaptchaSiteKey, {
|
|
action: 'cv_upload'
|
|
}).then(postCv);
|
|
});
|
|
} else {
|
|
$cvLoader.hide();
|
|
$msg.removeClass().addClass('form-message text-danger').text(t('form.captchaFailed'));
|
|
$button.prop('disabled', false).text(t('cv.submit'));
|
|
return;
|
|
}
|
|
async function postCv(token) {
|
|
try {
|
|
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.status === 429) throw new Error(t('cv.rateLimited'));
|
|
if (!cvResponse.ok) throw new Error(t('cv.cvFailed'));
|
|
var cvData = await cvResponse.json();
|
|
// Before calling match, obtain a fresh captcha token for the match action
|
|
if (!(window.grecaptcha && reCaptchaSiteKey)) {
|
|
throw new Error(t('form.captchaFailed'));
|
|
}
|
|
|
|
// get match token
|
|
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); }
|
|
});
|
|
|
|
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.status === 429) throw new Error(t('cv.rateLimited'));
|
|
if (!matchResponse.ok) throw new Error(t('cv.matchFailed'));
|
|
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('<div class="empty-result">' + escapeHtml(isRateLimited ? t('cv.rateLimited') : t('cv.backendMissing')) + '</div>');
|
|
} finally {
|
|
$cvLoader.hide();
|
|
$button.prop('disabled', false).text(t('cv.submit'));
|
|
}
|
|
}
|
|
|
|
});
|
|
|
|
$('#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');
|
|
|
|
function setMsg(severity, key) {
|
|
$msg.removeClass().addClass('form-message text-' + severity).text(t(key));
|
|
}
|
|
|
|
clearFieldErrors(['subscribeEmailError', 'subscribeConsentError']);
|
|
$msg.removeClass().addClass('form-message').text('');
|
|
|
|
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.css('display', 'flex');
|
|
$button.prop('disabled', true);
|
|
|
|
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) {
|
|
if (jqXHR && jqXHR.status === 429) {
|
|
setMsg('warning', 'form.rateLimited');
|
|
} else {
|
|
setMsg('danger', 'subscribe.failed');
|
|
}
|
|
}).always(function () {
|
|
$loader.hide();
|
|
$button.prop('disabled', false);
|
|
});
|
|
}
|
|
|
|
if (window.grecaptcha && reCaptchaSiteKey) {
|
|
grecaptcha.ready(function () {
|
|
grecaptcha.execute(reCaptchaSiteKey, { action: 'subscribe' }).then(postSubscribe);
|
|
});
|
|
} else {
|
|
$loader.hide();
|
|
$button.prop('disabled', false);
|
|
setMsg('danger', 'form.captchaFailed');
|
|
}
|
|
});
|
|
|
|
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 || [];
|
|
|
|
function list(items) {
|
|
if (!items || !items.length) return '<p class="empty-result">' + escapeHtml(t('cv.noItems')) + '</p>';
|
|
return '<ul class="result-list">' + items.map(function (x) {
|
|
var text = typeof x === 'string' ? x : (x.text || x.title || JSON.stringify(x));
|
|
return '<li>' + escapeHtml(text) + '</li>';
|
|
}).join('') + '</ul>';
|
|
}
|
|
$('#matchResult').html('<div class="score-badge">' + Number(score).toFixed(0) + '%</div><p>' + escapeHtml(summary) + '</p><h3>' + t('cv.strengths') + '</h3>' + list(strengths) + '<h3>' + t('cv.gaps') + '</h3>' + list(gaps) + '<h3>' + t('cv.evidence') + '</h3>' + list(evidence));
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value).replace(/[&<>'"]/g, function (char) {
|
|
return ({
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
"'": ''',
|
|
'"': '"'
|
|
})[char];
|
|
});
|
|
}
|
|
$(window).on('load', function () {
|
|
$.when(checkApiLive(), getRecaptchaWebKey(), getGoogleTagManagerId()).always(initConsent);
|
|
});
|
|
})(jQuery); |