diff --git a/assets/css/styles.css b/assets/css/styles.css index 5a608e1..95a9f71 100644 --- a/assets/css/styles.css +++ b/assets/css/styles.css @@ -2364,7 +2364,7 @@ body::before { /* ============ CENTRAL INSTITUTIONAL LAYOUT ============ */ .official-seal-large { - width: 260px; /* To hơn một chút theo yêu cầu */ + width: 260px; /* Slightly larger as requested */ height: auto; display: block; margin: 0 auto 30px auto; @@ -2664,7 +2664,7 @@ body::before { } .modal-box { - background: linear-gradient(145deg, #2A3B54, #1E293B); /* Sáng hơn một chút với dải màu nhẹ */ + background: linear-gradient(145deg, #2A3B54, #1E293B); /* Slightly brighter with a subtle gradient */ border: 1px solid rgba(255,255,255,0.15); border-radius: 20px; padding: 40px; @@ -2710,7 +2710,7 @@ body::before { .modal-input { width: 100%; - background: rgba(255, 255, 255, 0.06); /* Sáng hơn, trong suốt trên nền xanh đậm */ + background: rgba(255, 255, 255, 0.06); /* Slightly brighter, transparent on dark blue background */ border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 8px; padding: 14px 16px; @@ -2729,7 +2729,7 @@ body::before { } .modal-input::placeholder { - color: #64748B; /* Placeholder sáng hơn xíu để dễ đọc */ + color: #64748B; /* Slightly lighter placeholder for readability */ } @@ -2743,7 +2743,7 @@ body::before { /* Tablet (1024px and below) */ @media (max-width: 1024px) { html { - scroll-snap-type: none; /* Tắt snap để cuộn mượt hơn trên máy tính bảng */ + scroll-snap-type: none; /* Disable snap for smoother scrolling on tablets */ } .section { @@ -2773,7 +2773,7 @@ body::before { .section { height: auto; - min-height: auto; /* Cho phép co giãn hoàn toàn theo nội dung */ + min-height: auto; /* Allow full flex according to content */ padding: 80px 0 40px 0; } diff --git a/assets/js/hero.js b/assets/js/hero.js index d81660c..feee380 100644 --- a/assets/js/hero.js +++ b/assets/js/hero.js @@ -149,8 +149,8 @@ document.addEventListener("DOMContentLoaded", () => { runTypewriter(); - // Đã hủy bỏ bộ hẹn giờ auto-focus ở đây. - // Việc auto-focus sau 19s khiến trình duyệt tự động cuộn giật ngược lên đầu trang nếu user đang đọc ở dưới. + // Auto-focus timer removed here. + // Auto-focusing after 19s caused the browser to scroll back to the top while the user was reading below. // Switch between Source (Buy) and Sell Modes window.switchTab = function (mode) { diff --git a/assets/js/modals.js b/assets/js/modals.js index 5320ae9..ebe8200 100644 --- a/assets/js/modals.js +++ b/assets/js/modals.js @@ -2,8 +2,113 @@ // ── API Config ── const API_ENDPOINT = window.PROLOGY_CONFIG.API_URL; + // ── Per-field error messages ── + const FIELD_ERROR_MSG = { + name: "Please enter a valid name.", + email: "Please enter a valid email address.", + phone: "Please enter a valid Australian mobile number (04xx xxx xxx).", + message: "Please enter at least 10 characters.", + email_or_phone_required: "Please provide an email or phone number.", + }; + + // ── Client-side validators (mirror server rules) ── + const RX_EMAIL = /^[\w.+-]+@[\w-]+(\.[\w-]+)+$/; + const RX_NAME = /^[\p{L}\p{M}'\-\s.]{2,80}$/u; + + function isValidAuMobile(phone) { + const raw = (phone || "").trim(); + if (!raw || !/^[\d\s\-()]+$/.test(raw)) return false; + const digits = raw.replace(/\D/g, ""); + return /^04\d{8}$/.test(digits); + } + + function validateClient(fields) { + const bad = []; + if ("name" in fields && (!fields.name || !RX_NAME.test(fields.name.trim()))) + bad.push("name"); + if ("email" in fields && (!fields.email || !RX_EMAIL.test(fields.email.trim()))) + bad.push("email"); + if ("phone" in fields && !isValidAuMobile(fields.phone)) + bad.push("phone"); + if ("message" in fields && (!fields.message || fields.message.trim().length < 10)) + bad.push("message"); + return bad; + } + + function clearFieldErrors(sr) { + if (!sr) return; + sr.querySelectorAll(".field-error").forEach((el) => el.remove()); + sr.querySelectorAll(".invalid").forEach((el) => el.classList.remove("invalid")); + } + + function showFieldErrors(sr, badKeys, fieldMap) { + if (!sr || !fieldMap) return false; + let shown = 0; + badKeys.forEach((key) => { + let names = fieldMap[key]; + if (!names) return; + if (!Array.isArray(names)) names = [names]; + names.forEach((name) => { + const input = sr.querySelector('[name="' + name + '"]'); + if (!input) return; + input.classList.add("invalid"); + const field = input.closest(".field"); + if (field && !field.querySelector(".field-error")) { + const err = document.createElement("div"); + err.className = "field-error"; + err.textContent = FIELD_ERROR_MSG[key] || "Invalid value."; + field.appendChild(err); + shown++; + } + }); + }); + return shown > 0; + } + + function bindClearOnInput(sr) { + if (!sr || sr._clearBound) return; + sr._clearBound = true; + sr.querySelectorAll("input, textarea, select").forEach((el) => { + el.addEventListener("input", () => { + if (!el.classList.contains("invalid")) return; + el.classList.remove("invalid"); + const field = el.closest(".field"); + const err = field && field.querySelector(".field-error"); + if (err) err.remove(); + }); + }); + } + + function showButtonError(btnEl, msg) { + const origHTML = btnEl.dataset.origHtml || btnEl.innerHTML; + btnEl.dataset.origHtml = origHTML; + btnEl.innerHTML = "⚠ " + msg + ""; + setTimeout(() => { + btnEl.innerHTML = origHTML; + btnEl.disabled = false; + delete btnEl.dataset.origHtml; + }, 3000); + } + // ── Shared submit function ── - async function submitToAPI(payload, btnEl, onSuccess) { + async function submitToAPI(payload, btnEl, onSuccess, opts) { + const { sr, validateFields, fieldMap } = opts || {}; + + if (sr) { + clearFieldErrors(sr); + bindClearOnInput(sr); + } + + // Client-side validation — avoid burning an API call on obvious errors + if (validateFields) { + const bad = validateClient(validateFields); + if (bad.length) { + const shown = showFieldErrors(sr, bad, fieldMap); + if (!shown) showButtonError(btnEl, "Please check your inputs and try again."); + return; + } + } + btnEl.disabled = true; const origHTML = btnEl.innerHTML; btnEl.innerHTML = "Sending..."; @@ -33,8 +138,17 @@ if (res.ok && data.ok) { onSuccess(restore); } else { - const msg = - data.error || "Something went wrong. Please try again."; + let msg = data.error || "Something went wrong. Please try again."; + if (res.status === 429) { + msg = "Too many requests. Please try again later."; + } else if (/validation failed/i.test(msg)) { + const shown = showFieldErrors(sr, data.fields || [], fieldMap); + if (shown) { + restore(); + return; + } + msg = "Please check your inputs and try again."; + } btnEl.innerHTML = "⚠ " + msg + ""; setTimeout(restore, 3000); } @@ -125,6 +239,16 @@ border-color: #1A73E8; background: rgba(255,255,255,0.09); } + input.invalid, textarea.invalid, select.invalid { + border-color: #f87171; + background: rgba(248,113,113,0.06); + } + .field-error { + color: #f87171; + font-size: 0.78rem; + margin-top: 6px; + line-height: 1.35; + } input::placeholder, textarea::placeholder { color: #64748B; } textarea { min-height: 120px; resize: vertical; } select { @@ -190,11 +314,6 @@ this.shadowRoot .querySelector(".close") .addEventListener("click", () => this.close()); - this.shadowRoot - .querySelector(".overlay") - .addEventListener("click", (e) => { - if (e.target.classList.contains("overlay")) this.close(); - }); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && this.classList.contains("open")) this.close(); @@ -261,6 +380,11 @@ restore(); }, 1800); }, + { + sr, + validateFields: { email, message: inventory }, + fieldMap: { email: "email", message: "inventory" }, + }, ); }); } @@ -325,6 +449,11 @@ restore(); }, 1800); }, + { + sr, + validateFields: { email }, + fieldMap: { email: "email", message: "parts" }, + }, ); }); } @@ -348,7 +477,7 @@
-
+
@@ -395,6 +524,17 @@ restore(); }, 1800); }, + { + sr, + validateFields: { name, email, phone, message: requirements }, + fieldMap: { + name: "name", + email: "email", + phone: "phone", + message: "requirements", + email_or_phone_required: ["email", "phone"], + }, + }, ); }); } @@ -418,7 +558,7 @@
-
+
@@ -456,6 +596,17 @@ restore(); }, 1800); }, + { + sr, + validateFields: { name, email, phone, message: details }, + fieldMap: { + name: "name", + email: "email", + phone: "phone", + message: "details", + email_or_phone_required: ["email", "phone"], + }, + }, ); }); }