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:
Concept Agent
2026-05-29 18:56:56 +02:00
parent 88ed4e6bda
commit 81feccdc1a
61 changed files with 2460 additions and 5747 deletions
+8
View File
File diff suppressed because one or more lines are too long
+107 -199
View File
@@ -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>';
}
});
}
});
+37 -3
View File
@@ -1,5 +1,5 @@
/* ============================================================
main.js Scroll animations, counters, FAQ, BA slider
main.js: Scroll animations, counters, FAQ, BA slider
============================================================ */
(function () {
@@ -128,7 +128,7 @@
const video = document.querySelector('.hero-video-wrap video');
if (!video) return;
video.play().catch(() => {
// autoplay blocked poster image is visible; nothing to do
// autoplay blocked: poster image is visible; nothing to do
});
}
@@ -145,8 +145,42 @@
track.setAttribute('tabindex', '0');
}
/* --- Boot ---------------------------------------------- */
/* --- Header scroll + mobile nav ------------------------- */
function initNav() {
var header = document.getElementById('site-header');
var mobileNav = document.getElementById('mobileNav');
var menuBtn = document.querySelector('.header-menu-btn');
var closeBtn = document.getElementById('mobileNavClose');
var overlay = document.getElementById('mobileNavOverlay');
if (!header) return;
window.addEventListener('scroll', function () {
header.classList.toggle('scrolled', window.scrollY > 40);
}, { passive: true });
function openNav() {
mobileNav.classList.add('open');
mobileNav.setAttribute('aria-hidden', 'false');
if (menuBtn) menuBtn.setAttribute('aria-expanded', 'true');
document.body.style.overflow = 'hidden';
}
function closeNav() {
mobileNav.classList.remove('open');
mobileNav.setAttribute('aria-hidden', 'true');
if (menuBtn) menuBtn.setAttribute('aria-expanded', 'false');
document.body.style.overflow = '';
}
if (menuBtn) menuBtn.addEventListener('click', openNav);
if (closeBtn) closeBtn.addEventListener('click', closeNav);
if (overlay) overlay.addEventListener('click', closeNav);
}
/* --- Boot: initialize all modules ------------------------ */
function boot() {
initNav();
initScrollAnimations();
initCounters();
initFAQ();
+123
View File
@@ -0,0 +1,123 @@
(function () {
var POPUP_KEY = 'flooritPromo2026';
var TOPBAR_KEY = 'flooritTopbar2026';
var DELAY_MS = 5000;
var EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
function isStored(key) {
try {
var val = localStorage.getItem(key);
return val && Date.now() < parseInt(val, 10);
} catch (e) { return false; }
}
function store(key) {
try { localStorage.setItem(key, String(Date.now() + EXPIRY_MS)); } catch (e) {}
}
/* --- Topbar -------------------------------------------- */
function showTopbar() {
var bar = document.getElementById('promo-topbar');
if (!bar) return;
bar.classList.add('visible');
document.body.classList.add('has-topbar');
}
function hideTopbar() {
var bar = document.getElementById('promo-topbar');
if (!bar) return;
bar.classList.remove('visible');
document.body.classList.remove('has-topbar');
store(TOPBAR_KEY);
}
function initTopbar() {
if (isStored(TOPBAR_KEY) || isStored(POPUP_KEY)) return;
showTopbar();
var closeBtn = document.getElementById('promo-topbar-close');
var offerBtn = document.getElementById('promo-topbar-btn');
if (closeBtn) closeBtn.addEventListener('click', hideTopbar);
if (offerBtn) offerBtn.addEventListener('click', function () {
hideTopbar();
openPopup();
});
}
/* --- Popup --------------------------------------------- */
function openPopup() {
var overlay = document.getElementById('promo-overlay');
if (!overlay) return;
overlay.style.display = 'flex';
requestAnimationFrame(function () {
requestAnimationFrame(function () { overlay.classList.add('visible'); });
});
}
function closePopup() {
var overlay = document.getElementById('promo-overlay');
if (overlay) {
overlay.classList.remove('visible');
setTimeout(function () { overlay.style.display = 'none'; }, 350);
}
store(POPUP_KEY);
hideTopbar();
}
function initPopup() {
if (isStored(POPUP_KEY)) return;
var closeBtn = document.getElementById('promo-close');
var overlay = document.getElementById('promo-overlay');
var form = document.getElementById('promo-form');
var submit = document.getElementById('promo-submit');
var errEl = document.getElementById('promo-error');
var success = document.getElementById('promo-success');
if (!overlay || !form) return;
if (closeBtn) closeBtn.addEventListener('click', closePopup);
overlay.addEventListener('click', function (e) {
if (e.target === overlay) closePopup();
});
form.addEventListener('submit', function (e) {
e.preventDefault();
errEl.style.display = 'none';
submit.disabled = true;
submit.textContent = 'Sending...';
var data = new FormData(form);
fetch('/promo/', { method: 'POST', body: data })
.then(function (r) { return r.json(); })
.then(function (res) {
if (res.ok) {
form.style.display = 'none';
success.style.display = 'block';
store(POPUP_KEY);
hideTopbar();
} else {
errEl.textContent = res.error || 'Something went wrong.';
errEl.style.display = 'block';
submit.disabled = false;
submit.textContent = 'Claim My Discount';
}
})
.catch(function () {
errEl.textContent = 'Network error. Please try again.';
errEl.style.display = 'block';
submit.disabled = false;
submit.textContent = 'Claim My Discount';
});
});
setTimeout(openPopup, DELAY_MS);
}
function init() {
initTopbar();
initPopup();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();