diff --git a/web/wwwroot/js/main.js b/web/wwwroot/js/main.js
index 372bea3..5c98128 100644
--- a/web/wwwroot/js/main.js
+++ b/web/wwwroot/js/main.js
@@ -47,6 +47,15 @@
"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.",
+ "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",
@@ -92,8 +101,11 @@
"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 returned.",
"cv.noItems": "No items returned.",
"cv.strengths": "Strengths",
@@ -140,6 +152,15 @@
"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.",
+ "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",
@@ -185,8 +206,11 @@
"cv.processingLong": "Se procesează PDF-ul și informațiile despre job.",
"cv.cvFailed": "Extragerea CV-ului 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.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.noItems": "Nu au fost returnate elemente.",
"cv.strengths": "Puncte forte",
@@ -364,8 +388,9 @@
}
}
- function submitMSG(valid, msg) {
- $('#msgSubmit').removeClass().addClass(valid ? 'form-message text-success' : 'form-message text-danger').text(msg);
+ function submitMSG(valid, msg, severity) {
+ var tone = valid ? 'text-success' : ('text-' + (severity || 'danger'));
+ $('#msgSubmit').removeClass().addClass('form-message ' + tone).text(msg);
}
function formSuccess() {
@@ -404,8 +429,9 @@
}).done(function (resp) {
if (resp && resp.ok === true) formSuccess();
else submitMSG(false, t('form.captchaFailed'));
- }).fail(function () {
- submitMSG(false, t('form.failed'));
+ }).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();
@@ -454,10 +480,11 @@
$msg.removeClass().addClass('form-message text-danger').text(t('cv.noConsent'));
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.css('display', 'flex');
if (window.grecaptcha && reCaptchaSiteKey) {
grecaptcha.ready(function () {
@@ -466,7 +493,7 @@
}).then(postCv);
});
} else {
- // Captcha unavailable: show clear error and restore UI
+ $cvLoader.hide();
$msg.removeClass().addClass('form-message text-danger').text(t('form.captchaFailed'));
$button.prop('disabled', false).text(t('cv.submit'));
return;
@@ -481,6 +508,7 @@
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
@@ -511,21 +539,87 @@
captchaToken: matchToken
})
});
+ 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);
- $msg.removeClass().addClass('form-message text-danger').text(err.message || t('cv.matchFailed'));
- $result.html('
' + escapeHtml(t('cv.backendMissing')) + '
');
+ 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.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();
+ var consent = $('#subscribeConsent').is(':checked');
+
+ function setMsg(severity, key) {
+ $msg.removeClass().addClass('form-message text-' + severity).text(t(key));
+ }
+
+ if (!consent) {
+ setMsg('danger', 'subscribe.noConsent');
+ return;
+ }
+ if (!email) {
+ setMsg('danger', 'form.failed');
+ return;
+ }
+
+ $loader.css('display', 'flex');
+ $button.prop('disabled', true);
+ $msg.removeClass().addClass('form-message').text('');
+
+ 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) {
var score = match.score || match.matchScore || 0;
var summary = match.summary || t('cv.noSummary');