Migrate to Stack A: PHP-fpm + nginx + supervisord, drop flat HTML + Python API
- Remove old flat HTML pages (index, about, blog, contact, reviews, services/*, locations/*) - Remove Python/Flask API container (api/) - Remove old root nginx.conf and components/ - Add infra/: full nginx.conf (http block at /etc/nginx/nginx.conf), php-fpm-pool.conf (TCP listen), supervisord.conf, entrypoint.sh (auto-generates ALTCHA_HMAC_KEY) - Add src/: PHP router, page/service/location/blog templates, contact handler, altcha handler, promo endpoint, SQLite data files - Rewrite Dockerfile: single container, tini PID 1, healthcheck, all env vars declared - Update docker-compose.yml: port 8096, env_file, healthcheck - Update .dockerignore: exclude .env.*, include robots.txt/sitemap.xml/404.html/500.html - Update assets: tokens.css, promo-popup.css/js, altcha.min.js, refactored form.js/main.js Verified: all 17 routes 200, protection audit PASS, Resend confirmed working Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+107
-199
@@ -1,207 +1,115 @@
|
||||
/* ============================================================
|
||||
form.js — Estimate form validation + submission
|
||||
Real-time validation, phone formatting, reCAPTCHA v3 hook
|
||||
============================================================ */
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('contactForm');
|
||||
const formLoadedAtInput = document.getElementById('form_loaded_at');
|
||||
const formStatusDiv = document.getElementById('formStatus');
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const PHONE = /^\(?\d{3}\)?[\s.\-]?\d{3}[\s.\-]?\d{4}$/;
|
||||
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const RECAPTCHA_SITE_KEY = '6LdqrB8rAAAAAOrBCYmtk43IzemkiK_Fb2EYU5q2';
|
||||
|
||||
/* --- Helpers -------------------------------------------- */
|
||||
function field(el) {
|
||||
return el.closest('.form-field');
|
||||
}
|
||||
|
||||
function setValid(el) {
|
||||
const f = field(el);
|
||||
if (!f) return;
|
||||
f.classList.remove('has-error');
|
||||
el.classList.add('valid');
|
||||
el.classList.remove('invalid');
|
||||
}
|
||||
|
||||
function setInvalid(el, msg) {
|
||||
const f = field(el);
|
||||
if (!f) return;
|
||||
f.classList.add('has-error');
|
||||
el.classList.add('invalid');
|
||||
el.classList.remove('valid');
|
||||
const errEl = f.querySelector('.err-msg');
|
||||
if (errEl && msg) errEl.textContent = msg;
|
||||
}
|
||||
|
||||
function clearState(el) {
|
||||
const f = field(el);
|
||||
if (!f) return;
|
||||
f.classList.remove('has-error');
|
||||
el.classList.remove('valid', 'invalid');
|
||||
}
|
||||
|
||||
/* --- Phone formatter ------------------------------------ */
|
||||
function formatPhone(raw) {
|
||||
const digits = raw.replace(/\D/g, '').slice(0, 10);
|
||||
if (digits.length < 4) return digits;
|
||||
if (digits.length < 7) return '(' + digits.slice(0,3) + ') ' + digits.slice(3);
|
||||
return '(' + digits.slice(0,3) + ') ' + digits.slice(3,6) + '-' + digits.slice(6);
|
||||
}
|
||||
|
||||
/* --- Validators ----------------------------------------- */
|
||||
function validateRequired(el) {
|
||||
if (!el.value.trim()) {
|
||||
setInvalid(el, 'This field is required.');
|
||||
return false;
|
||||
}
|
||||
setValid(el);
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateEmail(el) {
|
||||
if (!el.value.trim()) {
|
||||
setInvalid(el, 'Email address is required.');
|
||||
return false;
|
||||
}
|
||||
if (!EMAIL.test(el.value.trim())) {
|
||||
setInvalid(el, 'Please enter a valid email address.');
|
||||
return false;
|
||||
}
|
||||
setValid(el);
|
||||
return true;
|
||||
}
|
||||
|
||||
function validatePhone(el) {
|
||||
const val = el.value.replace(/\D/g, '');
|
||||
if (!val) {
|
||||
setInvalid(el, 'Phone number is required.');
|
||||
return false;
|
||||
}
|
||||
if (val.length !== 10) {
|
||||
setInvalid(el, 'Please enter a 10-digit phone number.');
|
||||
return false;
|
||||
}
|
||||
setValid(el);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* --- reCAPTCHA v3 token --------------------------------- */
|
||||
function getRecaptchaToken(action) {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof grecaptcha === 'undefined') {
|
||||
resolve('');
|
||||
return;
|
||||
}
|
||||
grecaptcha.ready(() => {
|
||||
grecaptcha.execute(RECAPTCHA_SITE_KEY, { action }).then(resolve);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* --- Form handler --------------------------------------- */
|
||||
function initForm(form) {
|
||||
const nameEl = form.querySelector('#name');
|
||||
const emailEl = form.querySelector('#email');
|
||||
const phoneEl = form.querySelector('#phone');
|
||||
const addrEl = form.querySelector('#address');
|
||||
const serviceEl = form.querySelector('#service');
|
||||
const msgEl = form.querySelector('#message');
|
||||
const submit = form.querySelector('[type="submit"]');
|
||||
const status = form.querySelector('.form-status');
|
||||
|
||||
if (!submit) return;
|
||||
|
||||
/* Phone real-time format */
|
||||
if (phoneEl) {
|
||||
phoneEl.addEventListener('input', () => {
|
||||
const pos = phoneEl.selectionStart;
|
||||
const prev = phoneEl.value;
|
||||
phoneEl.value = formatPhone(prev);
|
||||
/* restore cursor roughly */
|
||||
const diff = phoneEl.value.length - prev.length;
|
||||
try { phoneEl.setSelectionRange(pos + diff, pos + diff); } catch (_) {}
|
||||
});
|
||||
|
||||
phoneEl.addEventListener('blur', () => validatePhone(phoneEl));
|
||||
// Set form_loaded_at to current timestamp in milliseconds
|
||||
if (formLoadedAtInput) {
|
||||
formLoadedAtInput.value = Date.now().toString();
|
||||
}
|
||||
|
||||
/* Blur-time validation for other fields */
|
||||
if (nameEl) nameEl.addEventListener('blur', () => validateRequired(nameEl));
|
||||
if (emailEl) emailEl.addEventListener('blur', () => validateEmail(emailEl));
|
||||
if (addrEl) addrEl.addEventListener('blur', () => validateRequired(addrEl));
|
||||
if (serviceEl) serviceEl.addEventListener('change', () => validateRequired(serviceEl));
|
||||
|
||||
/* Submit */
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const checks = [
|
||||
nameEl ? validateRequired(nameEl) : true,
|
||||
emailEl ? validateEmail(emailEl) : true,
|
||||
phoneEl ? validatePhone(phoneEl) : true,
|
||||
addrEl ? validateRequired(addrEl) : true,
|
||||
serviceEl ? validateRequired(serviceEl) : true,
|
||||
];
|
||||
|
||||
if (checks.includes(false)) {
|
||||
const firstErr = form.querySelector('.invalid');
|
||||
if (firstErr) firstErr.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const origText = submit.textContent;
|
||||
submit.disabled = true;
|
||||
submit.textContent = 'Sending...';
|
||||
if (status) { status.className = 'form-status'; status.textContent = ''; }
|
||||
|
||||
const token = await getRecaptchaToken('estimate_form');
|
||||
|
||||
const payload = {
|
||||
name: nameEl ? nameEl.value.trim() : '',
|
||||
email: emailEl ? emailEl.value.trim() : '',
|
||||
phone: phoneEl ? phoneEl.value.trim() : '',
|
||||
address: addrEl ? addrEl.value.trim() : '',
|
||||
service: serviceEl ? serviceEl.value : '',
|
||||
message: msgEl ? msgEl.value.trim() : '',
|
||||
token,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/estimate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
// Initialize Altcha
|
||||
const altchaElement = document.getElementById('altcha-widget');
|
||||
if (altchaElement) {
|
||||
window.altcha = new Altcha({
|
||||
challengeUrl: '/altcha-challenge/',
|
||||
element: altchaElement
|
||||
});
|
||||
}
|
||||
|
||||
if (!status) { submit.disabled = false; submit.textContent = origText; return; }
|
||||
// Form submit handler
|
||||
if (form) {
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (res.ok) {
|
||||
status.className = 'form-status form-status--success';
|
||||
status.textContent = 'Thank you! We will get back to you within 1 business hour.';
|
||||
form.reset();
|
||||
form.querySelectorAll('input, textarea, select').forEach(clearState);
|
||||
} else {
|
||||
throw new Error(res.status);
|
||||
}
|
||||
} catch (_) {
|
||||
if (status) {
|
||||
status.className = 'form-status form-status--error';
|
||||
status.textContent = 'Something went wrong. Please call us directly at (716) 602-1429.';
|
||||
}
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
submit.textContent = origText;
|
||||
}
|
||||
});
|
||||
}
|
||||
// Clear previous status messages
|
||||
formStatusDiv.innerHTML = '';
|
||||
formStatusDiv.className = '';
|
||||
|
||||
function boot() {
|
||||
document.querySelectorAll('.estimate-form').forEach(initForm);
|
||||
}
|
||||
// Validate required fields
|
||||
const name = form.elements['name']?.value.trim();
|
||||
const email = form.elements['email']?.value.trim();
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
if (!name || !email) {
|
||||
formStatusDiv.className = 'form-status form-status--error';
|
||||
formStatusDiv.innerHTML = '<p>Please fill in all required fields.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!email.includes('@')) {
|
||||
formStatusDiv.className = 'form-status form-status--error';
|
||||
formStatusDiv.innerHTML = '<p>Please enter a valid email address.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check honeypot
|
||||
const honeypot = form.elements['website']?.value;
|
||||
if (honeypot) {
|
||||
formStatusDiv.className = 'form-status form-status--error';
|
||||
formStatusDiv.innerHTML = '<p>Form validation failed.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Solve Altcha if available
|
||||
let altchaPayload = '';
|
||||
if (window.altcha && !window.altcha.didSubmit) {
|
||||
try {
|
||||
await window.altcha.solve();
|
||||
altchaPayload = window.altcha.getFormData().altcha;
|
||||
} catch (err) {
|
||||
formStatusDiv.className = 'form-status form-status--error';
|
||||
formStatusDiv.innerHTML = '<p>Spam check failed. Please try again.</p>';
|
||||
return;
|
||||
}
|
||||
} else if (window.altcha) {
|
||||
const formData = window.altcha.getFormData();
|
||||
altchaPayload = formData.altcha || '';
|
||||
}
|
||||
|
||||
// Build JSON payload
|
||||
const payload = {
|
||||
name: form.elements['name'].value.trim(),
|
||||
email: form.elements['email'].value.trim(),
|
||||
phone: form.elements['phone']?.value.trim() || '',
|
||||
message: form.elements['message']?.value.trim() || '',
|
||||
website: form.elements['website']?.value || '',
|
||||
form_loaded_at: form.elements['form_loaded_at']?.value || '',
|
||||
altcha: altchaPayload
|
||||
};
|
||||
|
||||
// POST to /contact/
|
||||
try {
|
||||
const response = await fetch('/contact/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.ok) {
|
||||
formStatusDiv.className = 'form-status form-status--success';
|
||||
formStatusDiv.innerHTML = '<p>Thank you! Your message has been sent. We\'ll be in touch soon.</p>';
|
||||
form.reset();
|
||||
if (formLoadedAtInput) {
|
||||
formLoadedAtInput.value = Date.now().toString();
|
||||
}
|
||||
if (window.altcha) {
|
||||
window.altcha = new Altcha({
|
||||
challengeUrl: '/altcha-challenge/',
|
||||
element: document.getElementById('altcha-widget')
|
||||
});
|
||||
}
|
||||
} else {
|
||||
formStatusDiv.className = 'form-status form-status--error';
|
||||
formStatusDiv.innerHTML = '<p>' + (data.error || 'An error occurred. Please try again.') + '</p>';
|
||||
}
|
||||
} catch (err) {
|
||||
formStatusDiv.className = 'form-status form-status--error';
|
||||
formStatusDiv.innerHTML = '<p>Network error. Please try again.</p>';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user