2 Commits

Author SHA1 Message Date
claude 19e3526430 Changes
Build and Push Docker Images / build (push) Successful in 18s
2026-05-12 11:11:06 +03:00
claude 4eaae45cba Changes 2026-05-12 10:38:04 +03:00
4 changed files with 392 additions and 63 deletions
+163 -13
View File
@@ -307,29 +307,81 @@ img {
font-weight: 700 font-weight: 700
} }
.ai-panel input, .ai-panel textarea, .contact-form input, .contact-form textarea { .ai-panel input:not([type="checkbox"]):not([type="file"]),
.ai-panel textarea,
.contact-form input:not([type="checkbox"]):not([type="file"]),
.contact-form textarea,
.subscribe-form input[type="email"] {
width: 100%; width: 100%;
border: 1px solid rgba(255,255,255,.12); border: 1px solid #d9e1f0;
border-radius: 18px; border-radius: 6px;
background: rgba(0,0,0,.25); background: #fff;
color: #fff; color: #0e1e3a;
padding: 15px 16px; padding: 12px 14px;
outline: none outline: none;
font-family: inherit;
font-size: 1rem;
line-height: 1.4;
transition: border-color .15s ease, box-shadow .15s ease
} }
.ai-panel input:focus, .ai-panel textarea:focus, .contact-form input:focus, .contact-form textarea:focus { .ai-panel input:not([type="checkbox"]):not([type="file"])::placeholder,
border-color: rgba(95,160,255,.65) .ai-panel textarea::placeholder,
.contact-form input:not([type="checkbox"]):not([type="file"])::placeholder,
.contact-form textarea::placeholder,
.subscribe-form input[type="email"]::placeholder {
color: #97a4b8
} }
.ai-panel input:not([type="checkbox"]):not([type="file"]):focus,
.ai-panel textarea:focus,
.contact-form input:not([type="checkbox"]):not([type="file"]):focus,
.contact-form textarea:focus,
.subscribe-form input[type="email"]:focus {
border-color: #5fa0ff;
box-shadow: 0 0 0 3px rgba(95,160,255,.18)
}
.ai-panel label.is-invalid input:not([type="checkbox"]):not([type="file"]),
.ai-panel label.is-invalid textarea,
.contact-form label.is-invalid input:not([type="checkbox"]):not([type="file"]),
.contact-form label.is-invalid textarea,
.subscribe-form .subscribe-row.is-invalid input[type="email"] {
border-color: #ff8a8a;
box-shadow: 0 0 0 3px rgba(255,138,138,.18)
}
.field-error {
display: block;
margin-top: 6px;
color: #ff8a8a;
font-size: .85rem;
font-weight: 600;
line-height: 1.35
}
.field-error:empty {
display: none
}
.consent-inline.is-invalid label {
color: #ff8a8a
}
.file-drop { .file-drop {
display: block; display: block;
border: 1px dashed rgba(143,184,255,.45); border: 1px dashed rgba(143,184,255,.45);
border-radius: 22px; border-radius: 6px;
background: rgba(95,160,255,.07); background: rgba(95,160,255,.07);
padding: 22px; padding: 22px;
cursor: pointer cursor: pointer
} }
.file-drop.is-invalid {
border-color: #ff8a8a;
background: rgba(255,138,138,.06)
}
.file-drop input { .file-drop input {
display: none display: none
} }
@@ -433,6 +485,34 @@ img {
margin-top: 14px margin-top: 14px
} }
.form-message:empty {
display: none
}
.form-message:not(:empty) {
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,.08);
background: rgba(0,0,0,.18);
font-weight: 700;
line-height: 1.4
}
.form-message.text-success:not(:empty) {
background: rgba(126,242,167,.08);
border-color: rgba(126,242,167,.35)
}
.form-message.text-danger:not(:empty) {
background: rgba(255,138,138,.08);
border-color: rgba(255,138,138,.35)
}
.form-message.text-warning:not(:empty) {
background: rgba(247,212,136,.08);
border-color: rgba(247,212,136,.35)
}
.text-success { .text-success {
color: #7ef2a7 !important color: #7ef2a7 !important
} }
@@ -441,6 +521,44 @@ img {
color: #ff8a8a !important color: #ff8a8a !important
} }
.text-warning {
color: #f7d488 !important
}
.subscribe-form {
margin-top: 28px;
padding: 28px;
border-radius: 24px;
background: rgba(255,255,255,.03);
border: 1px solid rgba(255,255,255,.08)
}
.subscribe-form h3 {
margin: 0 0 6px;
font-size: 1.1rem
}
.subscribe-form p {
margin: 0 0 14px;
color: var(--muted);
font-size: .92rem
}
.subscribe-form .subscribe-row {
display: flex;
gap: 10px;
flex-wrap: wrap
}
.subscribe-form .subscribe-row input[type="email"] {
flex: 1 1 220px;
min-width: 0
}
.subscribe-form .consent-inline {
margin-top: 12px
}
.footer { .footer {
padding: 26px 0; padding: 26px 0;
border-top: 1px solid rgba(255,255,255,.08); border-top: 1px solid rgba(255,255,255,.08);
@@ -513,15 +631,47 @@ img {
z-index: 80; z-index: 80;
background: rgba(0,0,0,.55); background: rgba(0,0,0,.55);
align-items: center; align-items: center;
justify-content: center justify-content: center;
backdrop-filter: blur(2px)
} }
.loader-box { .loader-box {
padding: 20px 30px; max-width: 360px;
padding: 22px 28px;
border-radius: 18px; border-radius: 18px;
background: #071326; background: #071326;
border: 1px solid rgba(255,255,255,.16); border: 1px solid rgba(255,255,255,.16);
font-weight: 800 text-align: center;
box-shadow: var(--shadow)
}
.loader-box strong {
display: block;
font-weight: 800;
margin-bottom: 6px
}
.loader-box span {
display: block;
color: var(--muted);
font-weight: 500;
font-size: .92rem;
line-height: 1.5
}
.loader-spinner {
display: block;
width: 28px;
height: 28px;
margin: 0 auto 14px;
border-radius: 50%;
border: 3px solid rgba(255,255,255,.18);
border-top-color: #9cc5ff;
animation: loader-spin .8s linear infinite
}
@keyframes loader-spin {
to { transform: rotate(360deg) }
} }
.shake { .shake {
+22 -6
View File
@@ -89,7 +89,7 @@
</section> </section>
<section class="section" id="matcher"> <section class="section" id="matcher">
<div class="container matcher-grid"> <div class="container matcher-grid">
<form class="ai-panel" id="cvMatcherForm"> <form class="ai-panel" id="cvMatcherForm" novalidate>
<span class="eyebrow" data-i18n="cv.input">Input</span> <span class="eyebrow" data-i18n="cv.input">Input</span>
<h2 data-i18n="cv.details">CV and job details</h2> <h2 data-i18n="cv.details">CV and job details</h2>
<label class="file-drop" for="cvFile"> <label class="file-drop" for="cvFile">
@@ -97,6 +97,7 @@
<span id="cvFileName" data-i18n="cv.fileHint">PDF only, max size handled by backend</span> <span id="cvFileName" data-i18n="cv.fileHint">PDF only, max size handled by backend</span>
<input type="file" id="cvFile" accept="application/pdf" required /> <input type="file" id="cvFile" accept="application/pdf" required />
</label> </label>
<small class="field-error" id="cvFileError"></small>
<label> <label>
<span data-i18n="cv.jobLink">Job link</span> <span data-i18n="cv.jobLink">Job link</span>
<input type="url" id="jobUrl" placeholder="https://company.com/careers/job" /> <input type="url" id="jobUrl" placeholder="https://company.com/careers/job" />
@@ -104,6 +105,7 @@
<label> <label>
<span data-i18n="cv.jobDescription">Or paste job description</span> <span data-i18n="cv.jobDescription">Or paste job description</span>
<textarea id="jobDescription" rows="8" data-i18n-placeholder="cv.jobPlaceholder" placeholder="Paste the job description if the page cannot be crawled."></textarea> <textarea id="jobDescription" rows="8" data-i18n-placeholder="cv.jobPlaceholder" placeholder="Paste the job description if the page cannot be crawled."></textarea>
<small class="field-error" id="cvJobError"></small>
</label> </label>
<label> <label>
<span data-i18n="form.email">Email</span> <span data-i18n="form.email">Email</span>
@@ -113,8 +115,9 @@
<input type="checkbox" id="gdprConsent" required /> <input type="checkbox" id="gdprConsent" required />
<label for="gdprConsent" data-i18n="cv.gdpr">I agree that my CV is processed and stored.</label> <label for="gdprConsent" data-i18n="cv.gdpr">I agree that my CV is processed and stored.</label>
</div> </div>
<small class="field-error" id="cvConsentError"></small>
<button id="matchSubmit" type="submit" class="btn btn-primary" data-i18n="cv.submit">Extract CV and match job</button> <button id="matchSubmit" type="submit" class="btn btn-primary" data-i18n="cv.submit">Extract CV and match job</button>
<strong id="matcherMsg" class="form-message"></strong> <strong id="matcherMsg" class="form-message" role="status" aria-live="polite"></strong>
</form> </form>
<aside class="ai-panel result-panel"> <aside class="ai-panel result-panel">
<span class="eyebrow" data-i18n="cv.result">Result</span> <span class="eyebrow" data-i18n="cv.result">Result</span>
@@ -149,21 +152,24 @@
</div> </div>
</div> </div>
<form class="contact-form" id="contactForm"> <form class="contact-form" id="contactForm" novalidate>
<label> <label>
<span data-i18n="form.name">Name</span> <span data-i18n="form.name">Name</span>
<input type="text" id="name" data-i18n-placeholder="form.namePlaceholder" placeholder="Your name" required /> <input type="text" id="name" data-i18n-placeholder="form.namePlaceholder" placeholder="Your name" required />
<small class="field-error" id="nameError"></small>
</label> </label>
<label> <label>
<span data-i18n="form.email">Email</span> <span data-i18n="form.email">Email</span>
<input type="email" id="email" data-i18n-placeholder="form.emailPlaceholder" placeholder="name@company.com" required /> <input type="email" id="email" data-i18n-placeholder="form.emailPlaceholder" placeholder="name@company.com" required />
<small class="field-error" id="emailError"></small>
</label> </label>
<label> <label>
<span data-i18n="form.message">Message</span> <span data-i18n="form.message">Message</span>
<textarea id="message" rows="6" data-i18n-placeholder="form.messagePlaceholder" placeholder="Tell me what you want to build." required></textarea> <textarea id="message" rows="6" data-i18n-placeholder="form.messagePlaceholder" placeholder="Tell me what you want to build." required></textarea>
<small class="field-error" id="messageError"></small>
</label> </label>
<button id="submit" type="submit" class="btn btn-primary" data-i18n="form.send">Send message</button> <button id="submit" type="submit" class="btn btn-primary" data-i18n="form.send">Send message</button>
<strong id="msgSubmit" class="form-message"></strong> <strong id="msgSubmit" class="form-message" role="status" aria-live="polite"></strong>
</form> </form>
</div> </div>
</section> </section>
@@ -184,8 +190,18 @@
</div> </div>
</footer> </footer>
</div> </div>
<div id="contactLoader" class="loader-overlay" style="display:none;"> <div id="contactLoader" class="loader-overlay" style="display:none;" role="status" aria-live="polite">
<div class="loader-box" data-i18n="status.sending">Sending...</div> <div class="loader-box">
<span class="loader-spinner" aria-hidden="true"></span>
<strong data-i18n="status.sending">Sending...</strong>
</div>
</div>
<div id="cvLoader" class="loader-overlay" style="display:none;" role="status" aria-live="polite">
<div class="loader-box">
<span class="loader-spinner" aria-hidden="true"></span>
<strong data-i18n="cv.loaderTitle">Working on your CV</strong>
<span data-i18n="cv.loaderText">Extracting text, retrieving context and scoring the match. This can take a moment.</span>
</div>
</div> </div>
<div id="cookieBanner" class="cookie-overlay" style="display:none;"> <div id="cookieBanner" class="cookie-overlay" style="display:none;">
<div class="cookie-box"> <div class="cookie-box">
+41 -18
View File
@@ -138,22 +138,42 @@
</div> </div>
</div> </div>
</div> </div>
<form class="contact-form" id="contactForm"> <div>
<label> <form class="contact-form" id="contactForm" novalidate>
<span data-i18n="form.name">Name</span> <label>
<input type="text" id="name" data-i18n-placeholder="form.namePlaceholder" placeholder="Your name" required /> <span data-i18n="form.name">Name</span>
</label> <input type="text" id="name" data-i18n-placeholder="form.namePlaceholder" placeholder="Your name" required />
<label> <small class="field-error" id="nameError"></small>
<span data-i18n="form.email">Email</span> </label>
<input type="email" id="email" data-i18n-placeholder="form.emailPlaceholder" placeholder="name@company.com" required /> <label>
</label> <span data-i18n="form.email">Email</span>
<label> <input type="email" id="email" data-i18n-placeholder="form.emailPlaceholder" placeholder="name@company.com" required />
<span data-i18n="form.message">Message</span> <small class="field-error" id="emailError"></small>
<textarea id="message" rows="6" data-i18n-placeholder="form.messagePlaceholder" placeholder="Tell me what you want to build." required></textarea> </label>
</label> <label>
<button id="submit" type="submit" class="btn btn-primary" data-i18n="form.send">Send message</button> <span data-i18n="form.message">Message</span>
<strong id="msgSubmit" class="form-message"></strong> <textarea id="message" rows="6" data-i18n-placeholder="form.messagePlaceholder" placeholder="Tell me what you want to build." required></textarea>
</form> <small class="field-error" id="messageError"></small>
</label>
<button id="submit" type="submit" class="btn btn-primary" data-i18n="form.send">Send message</button>
<strong id="msgSubmit" class="form-message" role="status" aria-live="polite"></strong>
</form>
<form class="subscribe-form" id="subscribeForm" novalidate>
<h3 data-i18n="subscribe.title">Stay in the loop</h3>
<p data-i18n="subscribe.text">Get a short note when a new AI demo is published. One email at most every few weeks.</p>
<div class="subscribe-row">
<input type="email" id="subscribeEmail" data-i18n-placeholder="subscribe.emailPlaceholder" placeholder="name@company.com" required />
<button id="subscribeSubmit" type="submit" class="btn btn-primary" data-i18n="subscribe.submit">Subscribe</button>
</div>
<small class="field-error" id="subscribeEmailError"></small>
<div class="consent-inline">
<input type="checkbox" id="subscribeConsent" required />
<label for="subscribeConsent" data-i18n="subscribe.gdpr">I agree to receive occasional emails about new demos.</label>
</div>
<small class="field-error" id="subscribeConsentError"></small>
<strong id="subscribeMsg" class="form-message" role="status" aria-live="polite"></strong>
</form>
</div>
</div> </div>
</section> </section>
</main> </main>
@@ -173,8 +193,11 @@
</div> </div>
</footer> </footer>
</div> </div>
<div id="contactLoader" class="loader-overlay" style="display:none;"> <div id="contactLoader" class="loader-overlay" style="display:none;" role="status" aria-live="polite">
<div class="loader-box" data-i18n="status.sending">Sending...</div> <div class="loader-box">
<span class="loader-spinner" aria-hidden="true"></span>
<strong data-i18n="status.sending">Sending...</strong>
</div>
</div> </div>
<div id="cookieBanner" class="cookie-overlay" style="display:none;"> <div id="cookieBanner" class="cookie-overlay" style="display:none;">
<div class="cookie-box"> <div class="cookie-box">
+166 -26
View File
@@ -47,6 +47,19 @@
"form.thanks": "Thank you for your message.", "form.thanks": "Thank you for your message.",
"form.captchaFailed": "Captcha verification failed.", "form.captchaFailed": "Captcha verification failed.",
"form.failed": "Failed to send the message.", "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.rights": "All rights reserved",
"footer.top": "Back to top", "footer.top": "Back to top",
"legal.terms": "Terms", "legal.terms": "Terms",
@@ -92,8 +105,11 @@
"cv.processingLong": "Processing CV PDF and job input.", "cv.processingLong": "Processing CV PDF and job input.",
"cv.cvFailed": "CV extraction failed", "cv.cvFailed": "CV extraction failed",
"cv.matchFailed": "Job matching 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.completed": "Match completed.",
"cv.backendMissing": "There was an error while processing the CV.", "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 returned.", "cv.noSummary": "No summary returned.",
"cv.noItems": "No items returned.", "cv.noItems": "No items returned.",
"cv.strengths": "Strengths", "cv.strengths": "Strengths",
@@ -140,6 +156,19 @@
"form.thanks": "Mulțumesc pentru mesaj.", "form.thanks": "Mulțumesc pentru mesaj.",
"form.captchaFailed": "Verificarea Captcha a eșuat.", "form.captchaFailed": "Verificarea Captcha a eșuat.",
"form.failed": "Mesajul nu a putut fi trimis.", "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.rights": "Toate drepturile rezervate",
"footer.top": "Înapoi sus", "footer.top": "Înapoi sus",
"legal.terms": "Termeni", "legal.terms": "Termeni",
@@ -185,8 +214,11 @@
"cv.processingLong": "Se procesează PDF-ul și informațiile despre job.", "cv.processingLong": "Se procesează PDF-ul și informațiile despre job.",
"cv.cvFailed": "Extragerea CV-ului a eșuat", "cv.cvFailed": "Extragerea CV-ului a eșuat",
"cv.matchFailed": "Matching-ul jobului a eșuat", "cv.matchFailed": "Matching-ul 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.completed": "Matching finalizat.",
"cv.backendMissing": "A aparut o eroare la pcocesarea CV-ului.", "cv.backendMissing": "A aparut o eroare la pcocesarea 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": "Nu a fost returnat niciun sumar.", "cv.noSummary": "Nu a fost returnat niciun sumar.",
"cv.noItems": "Nu au fost returnate elemente.", "cv.noItems": "Nu au fost returnate elemente.",
"cv.strengths": "Puncte forte", "cv.strengths": "Puncte forte",
@@ -364,8 +396,30 @@
} }
} }
function submitMSG(valid, msg) { function submitMSG(valid, msg, severity) {
$('#msgSubmit').removeClass().addClass(valid ? 'form-message text-success' : 'form-message text-danger').text(msg); 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() { function formSuccess() {
@@ -383,29 +437,43 @@
event.preventDefault(); event.preventDefault();
var loader = $('#contactLoader'), var loader = $('#contactLoader'),
button = $('#submit'); 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'); loader.css('display', 'flex');
button.prop('disabled', true); button.prop('disabled', true);
$('#msgSubmit').text('');
function postContact(token) { function postContact(token) {
var message = { var payload = {
Name: $('#name').val(), Name: name,
Email: $('#email').val(), Email: email,
Subject: '[MyAi.ro contact request]', Subject: '[MyAi.ro contact request]',
Message: $('#message').val(), Message: message,
CaptchaToken: token || '' CaptchaToken: token || ''
}; };
$.ajax({ $.ajax({
type: 'POST', type: 'POST',
url: '/api/contact', url: '/api/contact',
data: JSON.stringify(message), data: JSON.stringify(payload),
contentType: 'application/json; charset=utf-8', contentType: 'application/json; charset=utf-8',
dataType: 'json' dataType: 'json'
}).done(function (resp) { }).done(function (resp) {
if (resp && resp.ok === true) formSuccess(); if (resp && resp.ok === true) formSuccess();
else submitMSG(false, t('form.captchaFailed')); else submitMSG(false, t('form.captchaFailed'));
}).fail(function () { }).fail(function (jqXHR) {
submitMSG(false, t('form.failed')); var isRateLimited = jqXHR && jqXHR.status === 429;
submitMSG(false, isRateLimited ? t('form.rateLimited') : t('form.failed'), isRateLimited ? 'warning' : 'danger');
formError(); formError();
}).always(function () { }).always(function () {
loader.hide(); loader.hide();
@@ -442,22 +510,20 @@
var $msg = $('#matcherMsg'), var $msg = $('#matcherMsg'),
$button = $('#matchSubmit'), $button = $('#matchSubmit'),
$result = $('#matchResult'); $result = $('#matchResult');
if (!file) {
$msg.removeClass().addClass('form-message text-danger').text(t('cv.noFile')); clearFieldErrors(['cvFileError', 'cvJobError', 'cvConsentError']);
return; $msg.removeClass().addClass('form-message').text('');
}
if (!jobUrl && !jobDescription) { var hasError = false;
$msg.removeClass().addClass('form-message text-danger').text(t('cv.noJob')); if (!file) { showFieldError('cvFileError', t('cv.noFile')); hasError = true; }
return; if (!jobUrl && !jobDescription) { showFieldError('cvJobError', t('cv.noJob')); hasError = true; }
} if (!consent) { showFieldError('cvConsentError', t('cv.noConsent')); hasError = true; }
if (!consent) { if (hasError) return;
$msg.removeClass().addClass('form-message text-danger').text(t('cv.noConsent')); var $cvLoader = $('#cvLoader');
return;
}
$button.prop('disabled', true).text(t('cv.processing')); $button.prop('disabled', true).text(t('cv.processing'));
$msg.removeClass().addClass('form-message').text(t('cv.extracting')); $msg.removeClass().addClass('form-message').text(t('cv.extracting'));
$result.html('<div class="empty-result">' + escapeHtml(t('cv.processingLong')) + '</div>'); $result.html('<div class="empty-result">' + escapeHtml(t('cv.processingLong')) + '</div>');
$cvLoader.css('display', 'flex');
if (window.grecaptcha && reCaptchaSiteKey) { if (window.grecaptcha && reCaptchaSiteKey) {
grecaptcha.ready(function () { grecaptcha.ready(function () {
@@ -466,7 +532,7 @@
}).then(postCv); }).then(postCv);
}); });
} else { } else {
// Captcha unavailable: show clear error and restore UI $cvLoader.hide();
$msg.removeClass().addClass('form-message text-danger').text(t('form.captchaFailed')); $msg.removeClass().addClass('form-message text-danger').text(t('form.captchaFailed'));
$button.prop('disabled', false).text(t('cv.submit')); $button.prop('disabled', false).text(t('cv.submit'));
return; return;
@@ -481,6 +547,7 @@
method: 'POST', method: 'POST',
body: formData body: formData
}); });
if (cvResponse.status === 429) throw new Error(t('cv.rateLimited'));
if (!cvResponse.ok) throw new Error(t('cv.cvFailed')); if (!cvResponse.ok) throw new Error(t('cv.cvFailed'));
var cvData = await cvResponse.json(); var cvData = await cvResponse.json();
// Before calling match, obtain a fresh captcha token for the match action // Before calling match, obtain a fresh captcha token for the match action
@@ -511,21 +578,94 @@
captchaToken: matchToken captchaToken: matchToken
}) })
}); });
if (matchResponse.status === 429) throw new Error(t('cv.rateLimited'));
if (!matchResponse.ok) throw new Error(t('cv.matchFailed')); if (!matchResponse.ok) throw new Error(t('cv.matchFailed'));
var match = await matchResponse.json(); var match = await matchResponse.json();
renderMatchResult(match); renderMatchResult(match);
$msg.removeClass().addClass('form-message text-success').text(t('cv.completed')); $msg.removeClass().addClass('form-message text-success').text(t('cv.completed'));
} catch (err) { } catch (err) {
console.error(err); console.error(err);
$msg.removeClass().addClass('form-message text-danger').text(err.message || t('cv.matchFailed')); var isRateLimited = err && err.message === t('cv.rateLimited');
$result.html('<div class="empty-result">' + escapeHtml(t('cv.backendMissing')) + '</div>'); 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 { } finally {
$cvLoader.hide();
$button.prop('disabled', false).text(t('cv.submit')); $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: 'contact' }).then(postSubscribe);
});
} else {
$loader.hide();
$button.prop('disabled', false);
setMsg('danger', 'form.captchaFailed');
}
});
function renderMatchResult(match) { function renderMatchResult(match) {
var score = match.score || match.matchScore || 0; var score = match.score || match.matchScore || 0;
var summary = match.summary || t('cv.noSummary'); var summary = match.summary || t('cv.noSummary');