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:
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/altcha.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
http_response_code(405);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(VYC_Altcha::create_challenge());
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
class VYC_Altcha {
|
||||
|
||||
private static function key(): string {
|
||||
return getenv('ALTCHA_HMAC_KEY') ?: '';
|
||||
}
|
||||
|
||||
public static function is_enabled(): bool {
|
||||
return !empty(self::key());
|
||||
}
|
||||
|
||||
public static function create_challenge(int $maxnumber = 100000): array {
|
||||
$salt = bin2hex(random_bytes(12));
|
||||
$number = random_int(0, $maxnumber);
|
||||
$challenge = hash('sha256', $salt . $number);
|
||||
$signature = hash_hmac('sha256', $challenge, self::key());
|
||||
return [
|
||||
'algorithm' => 'SHA-256',
|
||||
'challenge' => $challenge,
|
||||
'maxnumber' => $maxnumber,
|
||||
'salt' => $salt,
|
||||
'signature' => $signature,
|
||||
];
|
||||
}
|
||||
|
||||
public static function verify(string $payload): bool {
|
||||
if (!self::is_enabled()) return true;
|
||||
if (empty($payload)) return false;
|
||||
|
||||
$data = json_decode(base64_decode($payload, true), true);
|
||||
if (!is_array($data)) return false;
|
||||
|
||||
foreach (['algorithm', 'challenge', 'number', 'salt', 'signature'] as $key) {
|
||||
if (!isset($data[$key])) return false;
|
||||
}
|
||||
|
||||
if ($data['algorithm'] !== 'SHA-256') return false;
|
||||
|
||||
$expected_sig = hash_hmac('sha256', $data['challenge'], self::key());
|
||||
if (!hash_equals($expected_sig, $data['signature'])) return false;
|
||||
|
||||
$expected_challenge = hash('sha256', $data['salt'] . $data['number']);
|
||||
return hash_equals($expected_challenge, $data['challenge']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
</main>
|
||||
<footer class="site-footer">
|
||||
<div class="footer-top">
|
||||
<div class="container">
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand">
|
||||
<div class="footer-logo-text">Floor It<span>.</span></div>
|
||||
<p>Western New York's hardwood floor specialists. Serving Buffalo and surrounding communities since 1994.</p>
|
||||
<div class="footer-contact-list">
|
||||
<div class="footer-contact-item">
|
||||
<div class="footer-contact-icon">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<a href="tel:+17166021429">(716) 602-1429</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-contact-item">
|
||||
<div class="footer-contact-icon">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<a href="mailto:floorithardwoods@gmail.com">floorithardwoods@gmail.com</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Services</h5>
|
||||
<ul>
|
||||
<li><a href="/services/floor-refinishing/">Floor Refinishing</a></li>
|
||||
<li><a href="/services/floor-restoration/">Floor Restoration</a></li>
|
||||
<li><a href="/services/floor-sanding/">Floor Sanding</a></li>
|
||||
<li><a href="/services/floor-installation/">Floor Installation</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Locations</h5>
|
||||
<ul>
|
||||
<li><a href="/locations/buffalo/">Buffalo</a></li>
|
||||
<li><a href="/locations/amherst/">Amherst</a></li>
|
||||
<li><a href="/locations/williamsville/">Williamsville</a></li>
|
||||
<li><a href="/locations/clarence/">Clarence</a></li>
|
||||
<li><a href="/locations/east-amherst/">East Amherst</a></li>
|
||||
<li><a href="/locations/lancaster/">Lancaster</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h5>Company</h5>
|
||||
<ul>
|
||||
<li><a href="/about/">About Us</a></li>
|
||||
<li><a href="/reviews/">Reviews</a></li>
|
||||
<li><a href="/blog/">Blog</a></li>
|
||||
<li><a href="/contact/">Contact</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<div class="container">
|
||||
<p>© <?= date('Y') ?> Floor It Hardwood Floors. All rights reserved. Buffalo, NY.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<link rel="stylesheet" href="/assets/css/promo-popup.css">
|
||||
|
||||
<div id="promo-overlay" style="display:none">
|
||||
<div id="promo-box">
|
||||
<button id="promo-close" aria-label="Close">×</button>
|
||||
<span class="promo-badge">Limited Time</span>
|
||||
<h2>Summer Refinishing Special: Up to 15% Off Through June 30, 2026</h2>
|
||||
<p class="promo-sub">Save up to 15% on hardwood floor refinishing. Book by June 30 to lock in your discount.</p>
|
||||
<form id="promo-form" novalidate>
|
||||
<div class="promo-field">
|
||||
<label for="promo-email">Email Address</label>
|
||||
<input type="email" id="promo-email" name="email" placeholder="you@email.com" required>
|
||||
</div>
|
||||
<div class="promo-field">
|
||||
<label for="promo-phone">Phone Number</label>
|
||||
<input type="tel" id="promo-phone" name="phone" placeholder="(716) 000-0000" required>
|
||||
</div>
|
||||
<div class="promo-field">
|
||||
<label for="promo-sqft">Square Footage (approx.)</label>
|
||||
<input type="number" id="promo-sqft" name="sqft" placeholder="e.g. 800" min="50" max="50000" required>
|
||||
</div>
|
||||
<button type="submit" id="promo-submit" class="promo-submit">Claim My Discount</button>
|
||||
<p class="promo-error" id="promo-error"></p>
|
||||
</form>
|
||||
<div class="promo-success" id="promo-success">
|
||||
<p><strong>You are locked in.</strong><br>We will reach out within 24 hours to schedule your estimate.</p>
|
||||
</div>
|
||||
<p class="promo-fine">Offer valid through June 30, 2026. Buffalo, NY area only.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/main.js" defer></script>
|
||||
<script src="/assets/js/form.js" defer></script>
|
||||
<script src="/assets/js/promo-popup.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
$db_path = __DIR__ . '/../data/pages.sqlite';
|
||||
$db = new SQLite3($db_path, SQLITE3_OPEN_READONLY);
|
||||
$db->busyTimeout(3000);
|
||||
$nav_result = $db->query("SELECT slug, nav_label FROM pages WHERE in_nav=1 ORDER BY nav_order");
|
||||
$nav_items = [];
|
||||
while ($row = $nav_result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$nav_items[] = $row;
|
||||
}
|
||||
$db->close();
|
||||
$canonical = $canonical ?? '';
|
||||
$page_title = htmlspecialchars($page_title ?? 'Floor It Hardwood Floors', ENT_QUOTES);
|
||||
$page_meta = htmlspecialchars($page_meta ?? '', ENT_QUOTES);
|
||||
?><!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= $page_title ?></title>
|
||||
<meta name="description" content="<?= $page_meta ?>">
|
||||
<?php if ($canonical): ?><link rel="canonical" href="<?= htmlspecialchars($canonical, ENT_QUOTES) ?>">
|
||||
<?php endif; ?>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/assets/css/tokens.css">
|
||||
<link rel="stylesheet" href="/assets/css/main.css">
|
||||
<link rel="stylesheet" href="/assets/css/components.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="promo-topbar" role="complementary" aria-label="Summer promotion">
|
||||
<span id="promo-topbar-text">Summer Refinishing Savings: Save up to 15% through June 30, 2026</span>
|
||||
<button id="promo-topbar-btn" type="button">Get Offer</button>
|
||||
<button id="promo-topbar-close" type="button" aria-label="Dismiss promotion">×</button>
|
||||
</div>
|
||||
<header id="site-header" class="site-header">
|
||||
<div class="container">
|
||||
<div class="header-inner">
|
||||
<a href="/" class="header-logo" aria-label="Floor It Hardwood Floors">
|
||||
<img src="/assets/images/logo-header.png" alt="Floor It Hardwood Floors" style="height:42px;width:auto;object-fit:contain;">
|
||||
</a>
|
||||
<nav class="header-nav" aria-label="Main navigation">
|
||||
<?php foreach ($nav_items as $item):
|
||||
$href = $item['slug'] === 'home' ? '/' : '/' . $item['slug'] . '/';
|
||||
?><a href="<?= htmlspecialchars($href, ENT_QUOTES) ?>"><?= htmlspecialchars($item['nav_label'], ENT_QUOTES) ?></a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
<div class="header-cta">
|
||||
<a href="tel:+17166021429" class="header-phone" aria-label="Call (716) 602-1429">(716) 602-1429</a>
|
||||
<a href="/contact/" class="btn btn--primary btn--sm">Get Estimate</a>
|
||||
<button class="header-menu-btn" aria-label="Open menu" aria-expanded="false">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-nav" id="mobileNav" aria-hidden="true">
|
||||
<div class="mobile-nav-overlay" id="mobileNavOverlay"></div>
|
||||
<div class="mobile-nav-panel">
|
||||
<button class="mobile-nav-close" aria-label="Close menu" id="mobileNavClose">✕</button>
|
||||
<nav class="mobile-nav-links">
|
||||
<?php foreach ($nav_items as $item):
|
||||
$href = $item['slug'] === 'home' ? '/' : '/' . $item['slug'] . '/';
|
||||
?><a href="<?= htmlspecialchars($href, ENT_QUOTES) ?>"><?= htmlspecialchars($item['nav_label'], ENT_QUOTES) ?></a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
<div class="mobile-nav-cta">
|
||||
<a href="tel:+17166021429" class="mobile-nav-phone">(716) 602-1429</a>
|
||||
<a href="/contact/" class="btn btn--primary btn--full">Request Free Estimate</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
/**
|
||||
* Floor It Hardwood Floors — contact form handler.
|
||||
*
|
||||
* Pipeline:
|
||||
* 1. Read JSON body (32KB cap)
|
||||
* 2. Validate required fields
|
||||
* 3. Honeypot + time-on-page checks
|
||||
* 4. Altcha server-side verify
|
||||
* 5. Sliding-window per-IP rate limit (file-backed in /var/www/html/src/api/data/rate-limits/)
|
||||
* 6. POST to Resend → email to contact address
|
||||
* 7. JSON response
|
||||
*
|
||||
* Configuration is read entirely from environment variables — set these in
|
||||
* .env or the runtime environment. No hardcoded keys in this file.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/altcha.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
// ─── Config from environment ────────────────────────────────────────
|
||||
$RESEND_API_KEY = getenv('RESEND_API_KEY') ?: '';
|
||||
$FROM_EMAIL = getenv('FROM_EMAIL') ?: 'noreply@floorithardwoods.com';
|
||||
$TO_EMAIL = getenv('TO_EMAIL') ?: 'floorithardwoodfloors@gmail.com';
|
||||
$RATE_LIMIT = (int)(getenv('RATE_LIMIT_PER_IP_PER_10MIN') ?: 5);
|
||||
$TIME_MIN_SECONDS = (int)(getenv('TIME_MIN_SECONDS') ?: 3);
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────
|
||||
function send_json(array $data, int $status = 200): void {
|
||||
http_response_code($status);
|
||||
echo json_encode($data, JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
function fail(string $msg, int $status = 200): void {
|
||||
send_json(['ok' => false, 'error' => $msg], $status);
|
||||
}
|
||||
|
||||
function client_ip(): string {
|
||||
$trust = (getenv('TRUST_PROXY') === '1');
|
||||
if ($trust) {
|
||||
$fwd = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
|
||||
if ($fwd) return trim(explode(',', $fwd)[0]);
|
||||
}
|
||||
return $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
}
|
||||
|
||||
function rate_limit_check(string $ip, int $max, int $window_seconds): bool {
|
||||
$dir = '/var/www/html/src/api/data/rate-limits';
|
||||
if (!is_dir($dir)) @mkdir($dir, 0700, true);
|
||||
$file = $dir . '/' . preg_replace('/[^a-zA-Z0-9._:-]/', '_', $ip);
|
||||
$now = time();
|
||||
$events = [];
|
||||
if (is_file($file)) {
|
||||
$raw = @file_get_contents($file);
|
||||
$events = $raw ? array_filter(array_map('intval', explode("\n", $raw))) : [];
|
||||
$events = array_filter($events, fn($t) => $now - $t <= $window_seconds);
|
||||
}
|
||||
if (count($events) >= $max) return false;
|
||||
$events[] = $now;
|
||||
@file_put_contents($file, implode("\n", $events), LOCK_EX);
|
||||
return true;
|
||||
}
|
||||
|
||||
function http_post_json(string $url, array $headers, array $body, int $timeout = 15): array {
|
||||
$ch = curl_init($url);
|
||||
$payload = json_encode($body, JSON_UNESCAPED_SLASHES);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_HTTPHEADER => array_merge(['Content-Type: application/json'], $headers),
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
]);
|
||||
$resp = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
return ['status' => $code, 'body' => $resp, 'err' => $err];
|
||||
}
|
||||
|
||||
// ─── Method gate ────────────────────────────────────────────────────
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
fail('Method Not Allowed', 405);
|
||||
}
|
||||
|
||||
// ─── Read + decode JSON body (capped to prevent DoS via huge payloads) ─
|
||||
$MAX_BODY_BYTES = 32 * 1024; // 32 KB is plenty for this form
|
||||
$raw = '';
|
||||
$in = fopen('php://input', 'rb');
|
||||
if ($in) {
|
||||
$raw = stream_get_contents($in, $MAX_BODY_BYTES + 1);
|
||||
fclose($in);
|
||||
}
|
||||
if (strlen($raw) > $MAX_BODY_BYTES) {
|
||||
fail('Payload too large.', 413);
|
||||
}
|
||||
$body = json_decode($raw, true);
|
||||
if (!is_array($body)) {
|
||||
fail('Invalid request payload.');
|
||||
}
|
||||
|
||||
$request_id = bin2hex(random_bytes(6));
|
||||
$ip = client_ip();
|
||||
|
||||
// ─── Rate limit ─────────────────────────────────────────────────────
|
||||
if (!rate_limit_check($ip, $RATE_LIMIT, 600)) {
|
||||
error_log("[floorit.form] rate_limited request_id=$request_id ip=$ip");
|
||||
fail('Too many requests. Please wait a few minutes.', 429);
|
||||
}
|
||||
|
||||
// ─── Field extraction + validation ──────────────────────────────────
|
||||
$name = trim((string)($body['name'] ?? ''));
|
||||
$email = trim((string)($body['email'] ?? ''));
|
||||
$phone = trim((string)($body['phone'] ?? ''));
|
||||
$message = trim((string)($body['message'] ?? ''));
|
||||
$website = trim((string)($body['website'] ?? '')); // honeypot
|
||||
$form_loaded_at = trim((string)($body['form_loaded_at'] ?? ''));
|
||||
$altcha_payload = trim((string)($body['altcha'] ?? ''));
|
||||
|
||||
$errors = [];
|
||||
if (mb_strlen($name) < 2 || mb_strlen($name) > 80) $errors[] = 'name';
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = 'email';
|
||||
if (mb_strlen($phone) > 20) $errors[] = 'phone';
|
||||
if (mb_strlen($message) > 2000) $errors[] = 'message';
|
||||
|
||||
if ($errors) {
|
||||
error_log("[floorit.form] validation_error request_id=$request_id fields=" . implode(',', $errors));
|
||||
fail('Please check the form fields and try again.');
|
||||
}
|
||||
|
||||
// ─── Honeypot ───────────────────────────────────────────────────────
|
||||
if ($website !== '') {
|
||||
error_log("[floorit.form] honeypot_triggered request_id=$request_id ip=$ip");
|
||||
send_json(['ok' => true, 'ref' => $request_id]); // pretend success
|
||||
}
|
||||
|
||||
// ─── Time-on-page ───────────────────────────────────────────────────
|
||||
$flagged_review = false;
|
||||
if ($form_loaded_at !== '' && ctype_digit(ltrim($form_loaded_at, '-'))) {
|
||||
$loaded_ms = (int)$form_loaded_at;
|
||||
$elapsed = (microtime(true) * 1000 - $loaded_ms) / 1000.0;
|
||||
if ($elapsed < $TIME_MIN_SECONDS) $flagged_review = true;
|
||||
}
|
||||
|
||||
// ─── Altcha verify ──────────────────────────────────────────────────
|
||||
if (!VYC_Altcha::verify($altcha_payload)) {
|
||||
error_log("[floorit.form] altcha_verification_failed request_id=$request_id");
|
||||
fail('Spam check failed.');
|
||||
}
|
||||
|
||||
// ─── Compose email body ─────────────────────────────────────────────
|
||||
$subject_name = preg_replace('/[\x00-\x1F\x7F]/u', ' ', $name);
|
||||
$subject_prefix = $flagged_review ? '[REVIEW] ' : '';
|
||||
$subject = "{$subject_prefix}New estimate request from {$subject_name}";
|
||||
$text_body =
|
||||
"A new estimate request came in through floorithardwoods.com.\n\n" .
|
||||
"Name: {$name}\n" .
|
||||
"Email: {$email}\n" .
|
||||
"Phone: " . ($phone ?: 'not provided') . "\n\n" .
|
||||
"Message:\n" . ($message ?: '(no message)') . "\n\n" .
|
||||
"Submitted at: " . gmdate('Y-m-d\TH:i:s\Z') . "\n" .
|
||||
"Request id: {$request_id}\n";
|
||||
$html_body = nl2br(htmlspecialchars($text_body, ENT_QUOTES, 'UTF-8'));
|
||||
|
||||
// ─── Send via Resend ────────────────────────────────────────────────
|
||||
if ($RESEND_API_KEY !== '') {
|
||||
$r = http_post_json(
|
||||
'https://api.resend.com/emails',
|
||||
["Authorization: Bearer {$RESEND_API_KEY}"],
|
||||
[
|
||||
'from' => $FROM_EMAIL,
|
||||
'to' => [$TO_EMAIL],
|
||||
'reply_to' => $email,
|
||||
'subject' => $subject,
|
||||
'text' => $text_body,
|
||||
'html' => $html_body,
|
||||
]
|
||||
);
|
||||
if ($r['status'] >= 300) {
|
||||
error_log("[floorit.form] resend_send_failed request_id=$request_id status={$r['status']} body=" . substr($r['body'] ?? '', 0, 300));
|
||||
fail('Could not send the message. Please call (716) 602-1429 directly.');
|
||||
}
|
||||
$resend_id = (json_decode($r['body'] ?? '', true)['id'] ?? '');
|
||||
error_log("[floorit.form] resend_send_ok request_id=$request_id resend_id=$resend_id");
|
||||
} else {
|
||||
// Pre-launch / no Resend key: log only, return success
|
||||
error_log("[floorit.form] resend_skipped_no_key request_id=$request_id\nSubject: $subject\n$text_body");
|
||||
}
|
||||
|
||||
send_json(['ok' => true, 'ref' => $request_id]);
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$email = trim(strip_tags($_POST['email'] ?? ''));
|
||||
$phone = trim(strip_tags($_POST['phone'] ?? ''));
|
||||
$sqft = trim(strip_tags($_POST['sqft'] ?? ''));
|
||||
|
||||
if (!$email || !$phone || !$sqft) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'All fields are required.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid email address.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sqft_int = (int) preg_replace('/[^0-9]/', '', $sqft);
|
||||
if ($sqft_int < 50 || $sqft_int > 50000) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Please enter a valid square footage.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$api_key = getenv('RESEND_API_KEY');
|
||||
$from = getenv('FROM_EMAIL') ?: 'Floor It Hardwood Floors <webleads@floorithardwoods.com>';
|
||||
$to_email = getenv('TO_EMAIL') ?: 'floorithardwoods@gmail.com';
|
||||
if (!$api_key) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['ok' => false, 'error' => 'Server configuration error.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$body = "Summer Refinishing Savings Lead\n\nEmail: {$email}\nPhone: {$phone}\nSquare Footage: {$sqft_int} sq ft\n\nOffer: Save up to 15% off through June 30, 2026.";
|
||||
|
||||
$payload = json_encode([
|
||||
'from' => $from,
|
||||
'to' => [$to_email],
|
||||
'subject' => "Summer Promo Lead: {$sqft_int} sq ft from {$email}",
|
||||
'text' => $body,
|
||||
]);
|
||||
|
||||
$ch = curl_init('https://api.resend.com/emails');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . $api_key,
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($status >= 200 && $status < 300) {
|
||||
echo json_encode(['ok' => true]);
|
||||
} else {
|
||||
http_response_code(502);
|
||||
echo json_encode(['ok' => false, 'error' => 'Something went wrong. Please call (716) 602-1429.']);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$type = preg_replace('/[^a-z_]/', '', strtolower($_GET['type'] ?? 'page'));
|
||||
$slug = preg_replace('/[^a-z0-9-]/', '', strtolower($_GET['slug'] ?? 'home'));
|
||||
|
||||
$allowed = ['page', 'service', 'location', 'blog', 'blog_post'];
|
||||
if (!in_array($type, $allowed, true)) {
|
||||
$type = 'page';
|
||||
$slug = 'home';
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $type === 'page' && $slug === 'contact') {
|
||||
require __DIR__ . '/contact.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
$template = match($type) {
|
||||
'service' => __DIR__ . '/templates/service.php',
|
||||
'location' => __DIR__ . '/templates/location.php',
|
||||
'blog', 'blog_post' => __DIR__ . '/templates/blog.php',
|
||||
default => __DIR__ . '/templates/page.php',
|
||||
};
|
||||
|
||||
if (!file_exists($template)) {
|
||||
http_response_code(404);
|
||||
require __DIR__ . '/templates/page.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
require $template;
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$db = new SQLite3(__DIR__ . '/../data/blog.sqlite', SQLITE3_OPEN_READONLY);
|
||||
$db->busyTimeout(3000);
|
||||
|
||||
if ($slug === 'blog') {
|
||||
// Blog listing
|
||||
$posts_result = $db->query("SELECT slug, title, excerpt, created_at FROM posts WHERE published=1 ORDER BY created_at DESC");
|
||||
$posts = [];
|
||||
while ($row = $posts_result->fetchArray(SQLITE3_ASSOC)) { $posts[] = $row; }
|
||||
$db->close();
|
||||
|
||||
$page_title = 'Hardwood Floor Tips & Advice | Floor It Hardwood Floors Blog';
|
||||
$page_meta = 'Expert hardwood floor tips from Floor It Hardwood Floors. Refinishing, restoration, care advice for Buffalo and Erie County homeowners.';
|
||||
require __DIR__ . '/../components/_header.php';
|
||||
?>
|
||||
<section class="page-hero section section--dark">
|
||||
<div class="container">
|
||||
<div class="page-hero-content">
|
||||
<span class="eyebrow">Hardwood Floor Tips</span>
|
||||
<h1>The Floor It Blog</h1>
|
||||
<p class="lead">Expert advice on hardwood floor refinishing, restoration, and care from the Buffalo area's most experienced team.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section section--light">
|
||||
<div class="container">
|
||||
<?php if ($posts): ?>
|
||||
<div class="grid grid--3col blog-grid">
|
||||
<?php foreach ($posts as $post): ?>
|
||||
<article class="card card--blog">
|
||||
<div class="card-body">
|
||||
<h2><a href="/blog/<?= htmlspecialchars($post['slug'], ENT_QUOTES) ?>/"><?= htmlspecialchars($post['title'], ENT_QUOTES) ?></a></h2>
|
||||
<p><?= htmlspecialchars($post['excerpt'] ?? '', ENT_QUOTES) ?></p>
|
||||
<a href="/blog/<?= htmlspecialchars($post['slug'], ENT_QUOTES) ?>/" class="card-link">Read more →</a>
|
||||
</div>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p>No posts yet. Check back soon.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section section--amber cta-section">
|
||||
<div class="container cta-section-inner">
|
||||
<div class="cta-section-text">
|
||||
<h2>Questions About Your Floors?</h2>
|
||||
<p>Contact our team for a free estimate and expert advice.</p>
|
||||
</div>
|
||||
<a href="/contact/" class="btn btn--primary btn--lg">Request an Estimate</a>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
} else {
|
||||
// Individual post
|
||||
$post = $db->querySingle("SELECT * FROM posts WHERE slug='" . SQLite3::escapeString($slug) . "' AND published=1", true);
|
||||
$db->close();
|
||||
|
||||
if (!$post) {
|
||||
http_response_code(404);
|
||||
$page_title = '404: Post Not Found | Floor It Hardwood Floors';
|
||||
$page_meta = '';
|
||||
require __DIR__ . '/../components/_header.php';
|
||||
echo '<section class="section section--light"><div class="container"><h1>Post Not Found</h1><p><a href="/blog/">Back to blog</a></p></div></section>';
|
||||
require __DIR__ . '/../components/_footer.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
$page_title = $post['title'] . ' | Floor It Hardwood Floors';
|
||||
$page_meta = $post['excerpt'] ?? '';
|
||||
require __DIR__ . '/../components/_header.php';
|
||||
?>
|
||||
<section class="page-hero section section--dark">
|
||||
<div class="container">
|
||||
<div class="page-hero-content">
|
||||
<span class="eyebrow">Floor It Blog</span>
|
||||
<h1><?= htmlspecialchars($post['title'], ENT_QUOTES) ?></h1>
|
||||
<p class="lead"><?= htmlspecialchars($post['excerpt'] ?? '', ENT_QUOTES) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section section--light">
|
||||
<div class="container blog-post-body">
|
||||
<?= $post['body_html'] ?>
|
||||
<p style="margin-top:2rem;"><a href="/blog/">Back to blog</a></p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section section--amber cta-section">
|
||||
<div class="container cta-section-inner">
|
||||
<div class="cta-section-text">
|
||||
<h2>Ready to Restore Your Floors?</h2>
|
||||
<p>Request a free estimate from our Buffalo team.</p>
|
||||
</div>
|
||||
<a href="/contact/" class="btn btn--primary btn--lg">Get a Free Estimate</a>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
}
|
||||
|
||||
require __DIR__ . '/../components/_footer.php'; ?>
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$db = new SQLite3(__DIR__ . '/../data/locations.sqlite', SQLITE3_OPEN_READONLY);
|
||||
$db->busyTimeout(3000);
|
||||
$loc = $db->querySingle("SELECT * FROM locations WHERE slug='" . SQLite3::escapeString($slug) . "'", true);
|
||||
$db->close();
|
||||
|
||||
if (!$loc) {
|
||||
http_response_code(404);
|
||||
$page_title = '404: Location Not Found | Floor It Hardwood Floors';
|
||||
$page_meta = '';
|
||||
require __DIR__ . '/../components/_header.php';
|
||||
echo '<section class="section section--light"><div class="container"><h1>Location Not Found</h1><p><a href="/locations/">View all locations</a></p></div></section>';
|
||||
require __DIR__ . '/../components/_footer.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
$page_title = $loc['title'];
|
||||
$page_meta = $loc['meta_description'];
|
||||
$body = json_decode($loc['body_json'] ?? '{}', true) ?? [];
|
||||
$faqs = json_decode($loc['faqs_json'] ?? '[]', true) ?? [];
|
||||
$city = $loc['city'];
|
||||
|
||||
require __DIR__ . '/../components/_header.php';
|
||||
?>
|
||||
|
||||
<section class="page-hero section section--dark">
|
||||
<div class="container">
|
||||
<div class="page-hero-content">
|
||||
<span class="eyebrow"><?= htmlspecialchars($loc['hero_eyebrow'] ?? '', ENT_QUOTES) ?></span>
|
||||
<h1><?= htmlspecialchars($loc['hero_h1'] ?? '', ENT_QUOTES) ?></h1>
|
||||
<p class="lead"><?= htmlspecialchars($loc['hero_lead'] ?? '', ENT_QUOTES) ?></p>
|
||||
<a href="/contact/" class="btn btn--primary btn--lg">Request a <?= htmlspecialchars($city, ENT_QUOTES) ?> Estimate</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php if (!empty($body['overview_h2'])): ?>
|
||||
<section class="section section--light">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<?php if (!empty($body['overview_eyebrow'])): ?><span class="eyebrow"><?= htmlspecialchars($body['overview_eyebrow'], ENT_QUOTES) ?></span><?php endif; ?>
|
||||
<h2><?= htmlspecialchars($body['overview_h2'], ENT_QUOTES) ?></h2>
|
||||
<div class="divider"></div>
|
||||
<?php if (!empty($body['overview_body_1'])): ?><p><?= htmlspecialchars($body['overview_body_1'], ENT_QUOTES) ?></p><?php endif; ?>
|
||||
<?php if (!empty($body['overview_body_2'])): ?><p style="margin-top:1rem;color:var(--smoke);"><?= htmlspecialchars($body['overview_body_2'], ENT_QUOTES) ?></p><?php endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
$stats = [];
|
||||
for ($i = 1; $i <= 3; $i++) {
|
||||
if (!empty($body["stat_{$i}_num"])) {
|
||||
$stats[] = ['num' => $body["stat_{$i}_num"], 'label' => $body["stat_{$i}_label"] ?? '', 'sub' => $body["stat_{$i}_sub"] ?? ''];
|
||||
}
|
||||
}
|
||||
if ($stats): ?>
|
||||
<div class="stats-row">
|
||||
<?php foreach ($stats as $stat): ?>
|
||||
<div class="stat-item">
|
||||
<strong><?= htmlspecialchars($stat['num'], ENT_QUOTES) ?></strong>
|
||||
<span><?= htmlspecialchars($stat['label'], ENT_QUOTES) ?></span>
|
||||
<?php if ($stat['sub']): ?><small><?= htmlspecialchars($stat['sub'], ENT_QUOTES) ?></small><?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($body['services_intro'])): ?>
|
||||
<section class="section section--dark">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<span class="eyebrow">Services in <?= htmlspecialchars($city, ENT_QUOTES) ?></span>
|
||||
<h2>Hardwood Floor Services in <?= htmlspecialchars($city, ENT_QUOTES) ?>, NY</h2>
|
||||
<p class="lead"><?= htmlspecialchars($body['services_intro'], ENT_QUOTES) ?></p>
|
||||
</div>
|
||||
<div class="grid grid--3col services-local-grid">
|
||||
<?php for ($i = 1; $i <= 3; $i++): $tk = "service_{$i}_title"; $bk = "service_{$i}_body"; ?>
|
||||
<?php if (!empty($body[$tk])): ?>
|
||||
<div class="card card--service-local">
|
||||
<h3><?= htmlspecialchars($body[$tk], ENT_QUOTES) ?></h3>
|
||||
<p><?= htmlspecialchars($body[$bk] ?? '', ENT_QUOTES) ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($faqs): ?>
|
||||
<section class="section section--light">
|
||||
<div class="container faq-section">
|
||||
<div class="section-header">
|
||||
<span class="eyebrow"><?= htmlspecialchars($city, ENT_QUOTES) ?> FAQs</span>
|
||||
<h2>Common Questions from <?= htmlspecialchars($city, ENT_QUOTES) ?> Homeowners</h2>
|
||||
</div>
|
||||
<div class="faq-list">
|
||||
<?php foreach ($faqs as $faq): ?>
|
||||
<details class="faq-item">
|
||||
<summary><?= htmlspecialchars($faq['q'] ?? '', ENT_QUOTES) ?></summary>
|
||||
<p><?= htmlspecialchars($faq['a'] ?? '', ENT_QUOTES) ?></p>
|
||||
</details>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="section section--amber cta-section">
|
||||
<div class="container cta-section-inner">
|
||||
<div class="cta-section-text">
|
||||
<h2><?= htmlspecialchars($body['form_h2'] ?? "Request a {$city} Floor Estimate", ENT_QUOTES) ?></h2>
|
||||
<p>We respond to all <?= htmlspecialchars($city, ENT_QUOTES) ?> inquiries within 24 hours.</p>
|
||||
</div>
|
||||
<a href="/contact/" class="btn btn--primary btn--lg"><?= htmlspecialchars($body['form_submit'] ?? 'Send Estimate Request', ENT_QUOTES) ?></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php require __DIR__ . '/../components/_footer.php'; ?>
|
||||
@@ -0,0 +1,391 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$db = new SQLite3(__DIR__ . '/../data/pages.sqlite', SQLITE3_OPEN_READONLY);
|
||||
$db->busyTimeout(3000);
|
||||
$page = $db->querySingle("SELECT * FROM pages WHERE slug='" . SQLite3::escapeString($slug) . "'", true);
|
||||
$db->close();
|
||||
|
||||
if (!$page) {
|
||||
http_response_code(404);
|
||||
$page_title = '404: Page Not Found | Floor It Hardwood Floors';
|
||||
$page_meta = '';
|
||||
require __DIR__ . '/../components/_header.php';
|
||||
echo '<section class="section section--light"><div class="container"><h1>Page Not Found</h1><p><a href="/">Return home</a></p></div></section>';
|
||||
require __DIR__ . '/../components/_footer.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
$page_title = $page['title'];
|
||||
$page_meta = $page['meta_description'];
|
||||
$sections = json_decode($page['sections_json'], true) ?? [];
|
||||
|
||||
require __DIR__ . '/../components/_header.php';
|
||||
|
||||
foreach ($sections as $s) {
|
||||
$t = $s['type'] ?? '';
|
||||
switch ($t) {
|
||||
|
||||
case 'hero_video':
|
||||
$stats = $s['stats'] ?? [];
|
||||
?>
|
||||
<section class="hero" aria-label="Hero">
|
||||
<div class="hero-video-wrap">
|
||||
<video autoplay muted loop playsinline poster="<?= htmlspecialchars($s['poster'] ?? '', ENT_QUOTES) ?>" aria-hidden="true">
|
||||
<source src="<?= htmlspecialchars($s['video_webm'] ?? '', ENT_QUOTES) ?>" type="video/webm">
|
||||
<source src="<?= htmlspecialchars($s['video_mp4'] ?? '', ENT_QUOTES) ?>" type="video/mp4">
|
||||
</video>
|
||||
<div class="hero-overlay"></div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow">
|
||||
<div class="hero-eyebrow-line"></div>
|
||||
<span><?= htmlspecialchars($s['eyebrow'] ?? '', ENT_QUOTES) ?></span>
|
||||
</div>
|
||||
<h1><?= htmlspecialchars($s['h1'] ?? '', ENT_QUOTES) ?></h1>
|
||||
<p class="hero-sub"><?= htmlspecialchars($s['lead'] ?? '', ENT_QUOTES) ?></p>
|
||||
<div class="hero-actions">
|
||||
<a href="<?= htmlspecialchars($s['cta_primary_href'] ?? '/contact/', ENT_QUOTES) ?>" class="btn btn--primary btn--lg"><?= htmlspecialchars($s['cta_primary'] ?? 'Get Estimate', ENT_QUOTES) ?></a>
|
||||
<?php if (!empty($s['cta_secondary'])): ?>
|
||||
<a href="<?= htmlspecialchars($s['cta_secondary_href'] ?? '#', ENT_QUOTES) ?>" class="btn btn--ghost btn--lg"><?= htmlspecialchars($s['cta_secondary'], ENT_QUOTES) ?></a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if ($stats): ?>
|
||||
<div class="hero-trust">
|
||||
<?php foreach ($stats as $stat): ?>
|
||||
<div class="hero-trust-stat">
|
||||
<strong><?= htmlspecialchars($stat['num'] ?? '', ENT_QUOTES) ?></strong>
|
||||
<span><?= htmlspecialchars($stat['label'] ?? '', ENT_QUOTES) ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
break;
|
||||
|
||||
case 'hero_simple':
|
||||
?>
|
||||
<section class="page-hero section section--dark">
|
||||
<div class="container">
|
||||
<div class="page-hero-content">
|
||||
<?php if (!empty($s['eyebrow'])): ?><span class="eyebrow"><?= htmlspecialchars($s['eyebrow'], ENT_QUOTES) ?></span><?php endif; ?>
|
||||
<h1><?= htmlspecialchars($s['h1'] ?? '', ENT_QUOTES) ?></h1>
|
||||
<?php if (!empty($s['lead'])): ?><p class="lead"><?= htmlspecialchars($s['lead'], ENT_QUOTES) ?></p><?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
break;
|
||||
|
||||
case 'services_grid':
|
||||
$sdb = new SQLite3(__DIR__ . '/../data/services.sqlite', SQLITE3_OPEN_READONLY);
|
||||
$svc_result = $sdb->query("SELECT slug, service_name, hero_eyebrow, hero_lead, hero_image FROM services ORDER BY id");
|
||||
$services = [];
|
||||
while ($row = $svc_result->fetchArray(SQLITE3_ASSOC)) { $services[] = $row; }
|
||||
$sdb->close();
|
||||
$svc_images = [
|
||||
'floor-refinishing' => '/assets/images/project-1-after.webp',
|
||||
'floor-restoration' => '/assets/images/project-2-before.webp',
|
||||
'floor-sanding' => '/assets/images/project-3-before.webp',
|
||||
'floor-installation' => '/assets/images/project-1-before.webp',
|
||||
];
|
||||
?>
|
||||
<section class="section section--light" id="services">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<?php if (!empty($s['eyebrow'])): ?><span class="eyebrow"><?= htmlspecialchars($s['eyebrow'], ENT_QUOTES) ?></span><?php endif; ?>
|
||||
<?php if (!empty($s['h2'])): ?><h2><?= htmlspecialchars($s['h2'], ENT_QUOTES) ?></h2><?php endif; ?>
|
||||
<?php if (!empty($s['lead'])): ?><p class="lead"><?= htmlspecialchars($s['lead'], ENT_QUOTES) ?></p><?php endif; ?>
|
||||
</div>
|
||||
<div class="grid grid--4col services-grid">
|
||||
<?php foreach ($services as $svc): ?>
|
||||
<a href="/services/<?= htmlspecialchars($svc['slug'], ENT_QUOTES) ?>/" class="card card--service">
|
||||
<?php $img = $svc_images[$svc['slug']] ?? '/assets/images/project-1-after.webp'; ?>
|
||||
<div class="card-img-wrap"><img src="<?= $img ?>" alt="<?= htmlspecialchars($svc['service_name'], ENT_QUOTES) ?>" loading="lazy"></div>
|
||||
<div class="card-body">
|
||||
<h3><?= htmlspecialchars($svc['service_name'], ENT_QUOTES) ?></h3>
|
||||
<p><?= htmlspecialchars($svc['hero_lead'], ENT_QUOTES) ?></p>
|
||||
<span class="card-link">Learn more →</span>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
break;
|
||||
|
||||
case 'process_steps':
|
||||
$steps = $s['steps'] ?? [];
|
||||
?>
|
||||
<section class="section section--dark" id="process">
|
||||
<div class="container">
|
||||
<div class="section-header section-header--center">
|
||||
<?php if (!empty($s['eyebrow'])): ?><span class="eyebrow"><?= htmlspecialchars($s['eyebrow'], ENT_QUOTES) ?></span><?php endif; ?>
|
||||
<h2><?= htmlspecialchars($s['h2'] ?? '', ENT_QUOTES) ?></h2>
|
||||
</div>
|
||||
<div class="grid grid--3col process-grid">
|
||||
<?php foreach ($steps as $step): ?>
|
||||
<div class="process-step">
|
||||
<div class="process-num"><?= htmlspecialchars($step['num'] ?? '', ENT_QUOTES) ?></div>
|
||||
<h3><?= htmlspecialchars($step['title'] ?? '', ENT_QUOTES) ?></h3>
|
||||
<p><?= htmlspecialchars($step['body'] ?? '', ENT_QUOTES) ?></p>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
break;
|
||||
|
||||
case 'about_preview':
|
||||
?>
|
||||
<section class="section section--alt" id="about-preview">
|
||||
<div class="container about-preview-inner">
|
||||
<div class="about-preview-text">
|
||||
<?php if (!empty($s['eyebrow'])): ?><span class="eyebrow"><?= htmlspecialchars($s['eyebrow'], ENT_QUOTES) ?></span><?php endif; ?>
|
||||
<h2><?= htmlspecialchars($s['h2'] ?? '', ENT_QUOTES) ?></h2>
|
||||
<div class="divider"></div>
|
||||
<?php if (!empty($s['lead'])): ?><p class="lead"><?= htmlspecialchars($s['lead'], ENT_QUOTES) ?></p><?php endif; ?>
|
||||
<?php if (!empty($s['body'])): ?><p style="margin-top:1.25rem;color:var(--smoke);"><?= htmlspecialchars($s['body'], ENT_QUOTES) ?></p><?php endif; ?>
|
||||
<?php if (!empty($s['cta'])): ?>
|
||||
<div class="cta-group mt-8">
|
||||
<a href="<?= htmlspecialchars($s['cta_href'] ?? '/about/', ENT_QUOTES) ?>" class="btn btn--primary"><?= htmlspecialchars($s['cta'], ENT_QUOTES) ?></a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="about-preview-img">
|
||||
<img src="/assets/images/project-1-after.webp" alt="Hardwood floor refinishing in Western New York" loading="lazy">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
break;
|
||||
|
||||
case 'gallery':
|
||||
?>
|
||||
<section class="section section--bark" id="gallery">
|
||||
<div class="container">
|
||||
<div class="section-header section-header--center">
|
||||
<?php if (!empty($s['eyebrow'])): ?><span class="eyebrow"><?= htmlspecialchars($s['eyebrow'], ENT_QUOTES) ?></span><?php endif; ?>
|
||||
<h2><?= htmlspecialchars($s['h2'] ?? '', ENT_QUOTES) ?></h2>
|
||||
<?php if (!empty($s['lead'])): ?><p class="lead"><?= htmlspecialchars($s['lead'], ENT_QUOTES) ?></p><?php endif; ?>
|
||||
</div>
|
||||
<div class="gallery-grid">
|
||||
<div class="gallery-item"><img src="/assets/images/project-1-before.webp" alt="Before refinishing" loading="lazy"><span class="gallery-item-label">Before</span></div>
|
||||
<div class="gallery-item"><img src="/assets/images/project-1-after.webp" alt="After refinishing" loading="lazy"><span class="gallery-item-label gallery-item-label--after">After</span></div>
|
||||
<div class="gallery-item"><img src="/assets/images/project-2-before.webp" alt="Before restoration" loading="lazy"><span class="gallery-item-label">Before</span></div>
|
||||
<div class="gallery-item"><img src="/assets/images/project-3-before.webp" alt="Refinishing in progress" loading="lazy"><span class="gallery-item-label">In Progress</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
break;
|
||||
|
||||
case 'cta_banner':
|
||||
?>
|
||||
<section class="section section--amber cta-section">
|
||||
<div class="container cta-section-inner">
|
||||
<div class="cta-section-text">
|
||||
<h2><?= htmlspecialchars($s['h2'] ?? '', ENT_QUOTES) ?></h2>
|
||||
<?php if (!empty($s['body'])): ?><p><?= htmlspecialchars($s['body'], ENT_QUOTES) ?></p><?php endif; ?>
|
||||
</div>
|
||||
<a href="<?= htmlspecialchars($s['cta_href'] ?? '/contact/', ENT_QUOTES) ?>" class="btn btn--primary btn--lg"><?= htmlspecialchars($s['cta'] ?? 'Get Estimate', ENT_QUOTES) ?></a>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
break;
|
||||
|
||||
case 'contact_form':
|
||||
?>
|
||||
<section class="section section--light" id="contact">
|
||||
<div class="container contact-layout">
|
||||
<div class="contact-info">
|
||||
<h2><?= htmlspecialchars($s['h2'] ?? 'Send Us a Message', ENT_QUOTES) ?></h2>
|
||||
<div class="contact-detail"><strong>Phone</strong><a href="tel:+17166021429"><?= htmlspecialchars($s['phone'] ?? '(716) 602-1429', ENT_QUOTES) ?></a></div>
|
||||
<div class="contact-detail"><strong>Email</strong><a href="mailto:<?= htmlspecialchars($s['email'] ?? '', ENT_QUOTES) ?>"><?= htmlspecialchars($s['email'] ?? '', ENT_QUOTES) ?></a></div>
|
||||
<div class="contact-detail"><strong>Response Time</strong><span>Within 24 hours</span></div>
|
||||
<div class="contact-detail"><strong>Service Area</strong><span>Buffalo and Erie County, NY</span></div>
|
||||
</div>
|
||||
<form class="contact-form" id="contactForm" action="/contact/" method="POST" novalidate>
|
||||
<div class="form-group">
|
||||
<label for="name">Full Name <span aria-hidden="true">*</span></label>
|
||||
<input type="text" id="name" name="name" required autocomplete="name" placeholder="Your name">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email Address <span aria-hidden="true">*</span></label>
|
||||
<input type="email" id="email" name="email" required autocomplete="email" placeholder="your@email.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="phone">Phone Number</label>
|
||||
<input type="tel" id="phone" name="phone" autocomplete="tel" placeholder="(716) 555-0000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="message">Message <span aria-hidden="true">*</span></label>
|
||||
<textarea id="message" name="message" required rows="5" placeholder="Tell us about your floors and what you need..."></textarea>
|
||||
</div>
|
||||
<input type="text" name="website" style="display:none;" tabindex="-1" autocomplete="off">
|
||||
<input type="hidden" name="form_loaded_at" id="form_loaded_at">
|
||||
<div id="altcha-widget"></div>
|
||||
<button type="submit" id="contactFormSubmit" class="btn btn--primary btn--full">Send Message</button>
|
||||
<div id="formStatus" role="status" aria-live="polite"></div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<script src="/assets/js/altcha.min.js"></script>
|
||||
<script src="/assets/js/form.js"></script>
|
||||
<?php
|
||||
break;
|
||||
|
||||
case 'story':
|
||||
?>
|
||||
<section class="section section--light">
|
||||
<div class="container about-story">
|
||||
<div class="about-story-text">
|
||||
<?php if (!empty($s['eyebrow'])): ?><span class="eyebrow"><?= htmlspecialchars($s['eyebrow'], ENT_QUOTES) ?></span><?php endif; ?>
|
||||
<h2><?= htmlspecialchars($s['h2'] ?? '', ENT_QUOTES) ?></h2>
|
||||
<div class="divider"></div>
|
||||
<?php if (!empty($s['lead'])): ?><p class="lead"><?= htmlspecialchars($s['lead'], ENT_QUOTES) ?></p><?php endif; ?>
|
||||
<?php if (!empty($s['body'])): ?><p style="margin-top:1.25rem;color:var(--smoke);"><?= htmlspecialchars($s['body'], ENT_QUOTES) ?></p><?php endif; ?>
|
||||
</div>
|
||||
<div class="about-story-img">
|
||||
<img src="/assets/images/refinishing-machine.webp" alt="Floor It refinishing equipment" loading="lazy">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
break;
|
||||
|
||||
case 'credentials':
|
||||
?>
|
||||
<section class="section section--dark">
|
||||
<div class="container">
|
||||
<div class="section-header section-header--center">
|
||||
<?php if (!empty($s['eyebrow'])): ?><span class="eyebrow"><?= htmlspecialchars($s['eyebrow'], ENT_QUOTES) ?></span><?php endif; ?>
|
||||
<h2><?= htmlspecialchars($s['h2'] ?? '', ENT_QUOTES) ?></h2>
|
||||
</div>
|
||||
<div class="grid grid--4col credentials-grid">
|
||||
<div class="credential-item"><strong>30+</strong><span>Years in Business</span></div>
|
||||
<div class="credential-item"><strong>75+</strong><span>Years Combined Experience</span></div>
|
||||
<div class="credential-item"><strong>500+</strong><span>Projects Completed</span></div>
|
||||
<div class="credential-item"><strong>4.9/5</strong><span>Google Rating</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
break;
|
||||
|
||||
case 'service_areas':
|
||||
$ldb = new SQLite3(__DIR__ . '/../data/locations.sqlite', SQLITE3_OPEN_READONLY);
|
||||
$loc_result = $ldb->query("SELECT slug, city FROM locations ORDER BY id");
|
||||
$locations = [];
|
||||
while ($row = $loc_result->fetchArray(SQLITE3_ASSOC)) { $locations[] = $row; }
|
||||
$ldb->close();
|
||||
?>
|
||||
<section class="section section--light">
|
||||
<div class="container">
|
||||
<div class="section-header section-header--center">
|
||||
<?php if (!empty($s['eyebrow'])): ?><span class="eyebrow"><?= htmlspecialchars($s['eyebrow'], ENT_QUOTES) ?></span><?php endif; ?>
|
||||
<h2><?= htmlspecialchars($s['h2'] ?? '', ENT_QUOTES) ?></h2>
|
||||
<?php if (!empty($s['body'])): ?><p class="lead"><?= htmlspecialchars($s['body'], ENT_QUOTES) ?></p><?php endif; ?>
|
||||
</div>
|
||||
<div class="locations-pill-list">
|
||||
<?php foreach ($locations as $loc): ?>
|
||||
<a href="/locations/<?= htmlspecialchars($loc['slug'], ENT_QUOTES) ?>/" class="location-pill"><?= htmlspecialchars($loc['city'], ENT_QUOTES) ?>, NY</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
break;
|
||||
|
||||
case 'testimonials_grid':
|
||||
case 'testimonials_preview':
|
||||
$tdb = new SQLite3(__DIR__ . '/../data/testimonials.sqlite', SQLITE3_OPEN_READONLY);
|
||||
$limit = ($t === 'testimonials_preview') ? 3 : 20;
|
||||
$featured = ($t === 'testimonials_preview') ? 'WHERE featured=1' : '';
|
||||
$t_result = $tdb->query("SELECT * FROM testimonials {$featured} ORDER BY id LIMIT {$limit}");
|
||||
$testimonials = [];
|
||||
while ($row = $t_result->fetchArray(SQLITE3_ASSOC)) { $testimonials[] = $row; }
|
||||
$tdb->close();
|
||||
?>
|
||||
<section class="section section--alt" id="reviews">
|
||||
<div class="container">
|
||||
<div class="section-header section-header--center">
|
||||
<?php if (!empty($s['eyebrow'])): ?><span class="eyebrow"><?= htmlspecialchars($s['eyebrow'], ENT_QUOTES) ?></span><?php endif; ?>
|
||||
<h2><?= htmlspecialchars($s['h2'] ?? '', ENT_QUOTES) ?></h2>
|
||||
</div>
|
||||
<div class="grid grid--3col testimonials-grid">
|
||||
<?php foreach ($testimonials as $review): ?>
|
||||
<div class="testimonial">
|
||||
<div class="testimonial-stars"><span>★</span><span>★</span><span>★</span><span>★</span><span>★</span></div>
|
||||
<p class="testimonial-text">"<?= htmlspecialchars($review['quote'], ENT_QUOTES) ?>"</p>
|
||||
<div class="testimonial-author">
|
||||
<strong><?= htmlspecialchars($review['author'], ENT_QUOTES) ?></strong>
|
||||
<span><?= htmlspecialchars($review['location'] ?? '', ENT_QUOTES) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
break;
|
||||
|
||||
case 'blog_listing':
|
||||
$bdb = new SQLite3(__DIR__ . '/../data/blog.sqlite', SQLITE3_OPEN_READONLY);
|
||||
$b_result = $bdb->query("SELECT slug, title, excerpt, created_at FROM posts WHERE published=1 ORDER BY created_at DESC");
|
||||
$posts = [];
|
||||
while ($row = $b_result->fetchArray(SQLITE3_ASSOC)) { $posts[] = $row; }
|
||||
$bdb->close();
|
||||
?>
|
||||
<section class="section section--light">
|
||||
<div class="container">
|
||||
<div class="grid grid--3col blog-grid">
|
||||
<?php foreach ($posts as $post): ?>
|
||||
<article class="card card--blog">
|
||||
<div class="card-body">
|
||||
<h2><a href="/blog/<?= htmlspecialchars($post['slug'], ENT_QUOTES) ?>/"><?= htmlspecialchars($post['title'], ENT_QUOTES) ?></a></h2>
|
||||
<p><?= htmlspecialchars($post['excerpt'] ?? '', ENT_QUOTES) ?></p>
|
||||
<a href="/blog/<?= htmlspecialchars($post['slug'], ENT_QUOTES) ?>/" class="card-link">Read more →</a>
|
||||
</div>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
break;
|
||||
|
||||
case 'locations_grid':
|
||||
$ldb2 = new SQLite3(__DIR__ . '/../data/locations.sqlite', SQLITE3_OPEN_READONLY);
|
||||
$loc2_result = $ldb2->query("SELECT slug, city, hero_lead FROM locations ORDER BY id");
|
||||
$locs = [];
|
||||
while ($row = $loc2_result->fetchArray(SQLITE3_ASSOC)) { $locs[] = $row; }
|
||||
$ldb2->close();
|
||||
?>
|
||||
<section class="section section--light">
|
||||
<div class="container">
|
||||
<div class="grid grid--3col locations-grid">
|
||||
<?php foreach ($locs as $loc): ?>
|
||||
<a href="/locations/<?= htmlspecialchars($loc['slug'], ENT_QUOTES) ?>/" class="card card--location">
|
||||
<div class="card-body">
|
||||
<h3><?= htmlspecialchars($loc['city'], ENT_QUOTES) ?>, NY</h3>
|
||||
<p><?= htmlspecialchars(substr($loc['hero_lead'] ?? '', 0, 120), ENT_QUOTES) ?>...</p>
|
||||
<span class="card-link">View service area →</span>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
require __DIR__ . '/../components/_footer.php';
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$db = new SQLite3(__DIR__ . '/../data/services.sqlite', SQLITE3_OPEN_READONLY);
|
||||
$db->busyTimeout(3000);
|
||||
$svc = $db->querySingle("SELECT * FROM services WHERE slug='" . SQLite3::escapeString($slug) . "'", true);
|
||||
$db->close();
|
||||
|
||||
if (!$svc) {
|
||||
http_response_code(404);
|
||||
$page_title = '404: Service Not Found | Floor It Hardwood Floors';
|
||||
$page_meta = '';
|
||||
require __DIR__ . '/../components/_header.php';
|
||||
echo '<section class="section section--light"><div class="container"><h1>Service Not Found</h1><p><a href="/services/">View all services</a></p></div></section>';
|
||||
require __DIR__ . '/../components/_footer.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
$page_title = $svc['title'];
|
||||
$page_meta = $svc['meta_description'];
|
||||
$body = json_decode($svc['body_json'] ?? '{}', true) ?? [];
|
||||
$faqs = json_decode($svc['faqs_json'] ?? '[]', true) ?? [];
|
||||
|
||||
require __DIR__ . '/../components/_header.php';
|
||||
?>
|
||||
|
||||
<section class="page-hero section section--dark">
|
||||
<div class="container">
|
||||
<div class="page-hero-content">
|
||||
<span class="eyebrow"><?= htmlspecialchars($svc['hero_eyebrow'] ?? '', ENT_QUOTES) ?></span>
|
||||
<h1><?= htmlspecialchars($svc['hero_h1'] ?? '', ENT_QUOTES) ?></h1>
|
||||
<p class="lead"><?= htmlspecialchars($svc['hero_lead'] ?? '', ENT_QUOTES) ?></p>
|
||||
<a href="/contact/" class="btn btn--primary btn--lg">Get a Free Estimate</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php if (!empty($body['intro_h2'])): ?>
|
||||
<section class="section section--light">
|
||||
<div class="container service-intro">
|
||||
<div class="service-intro-text">
|
||||
<h2><?= htmlspecialchars($body['intro_h2'], ENT_QUOTES) ?></h2>
|
||||
<?php if (!empty($body['intro_body_1'])): ?><p><?= htmlspecialchars($body['intro_body_1'], ENT_QUOTES) ?></p><?php endif; ?>
|
||||
<?php if (!empty($body['intro_body_2'])): ?><p><?= htmlspecialchars($body['intro_body_2'], ENT_QUOTES) ?></p><?php endif; ?>
|
||||
</div>
|
||||
<?php if (!empty($svc['hero_image'])): ?>
|
||||
<div class="service-intro-img"><img src="<?= htmlspecialchars($svc['hero_image'], ENT_QUOTES) ?>" alt="<?= htmlspecialchars($svc['service_name'] ?? '', ENT_QUOTES) ?>" loading="lazy"></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($body['process_intro'])): ?>
|
||||
<section class="section section--dark">
|
||||
<div class="container">
|
||||
<div class="section-header section-header--center">
|
||||
<span class="eyebrow">Our Process</span>
|
||||
<h2>How We Do It</h2>
|
||||
<p class="lead"><?= htmlspecialchars($body['process_intro'], ENT_QUOTES) ?></p>
|
||||
</div>
|
||||
<div class="grid grid--3col process-grid">
|
||||
<?php for ($i = 1; $i <= 3; $i++): $title_key = "step_{$i}_title"; $body_key = "step_{$i}_body"; ?>
|
||||
<?php if (!empty($body[$title_key])): ?>
|
||||
<div class="process-step">
|
||||
<div class="process-num">0<?= $i ?></div>
|
||||
<h3><?= htmlspecialchars($body[$title_key], ENT_QUOTES) ?></h3>
|
||||
<p><?= htmlspecialchars($body[$body_key] ?? '', ENT_QUOTES) ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$benefits = [];
|
||||
for ($i = 1; $i <= 4; $i++) {
|
||||
if (!empty($body["benefit_{$i}_title"])) {
|
||||
$benefits[] = ['title' => $body["benefit_{$i}_title"], 'body' => $body["benefit_{$i}_body"] ?? ''];
|
||||
}
|
||||
}
|
||||
if ($benefits): ?>
|
||||
<section class="section section--alt">
|
||||
<div class="container">
|
||||
<div class="section-header section-header--center">
|
||||
<span class="eyebrow">Why Choose Us</span>
|
||||
<h2>What Sets Us Apart</h2>
|
||||
</div>
|
||||
<div class="grid grid--4col benefits-grid">
|
||||
<?php foreach ($benefits as $b): ?>
|
||||
<div class="benefit-item">
|
||||
<h3><?= htmlspecialchars($b['title'], ENT_QUOTES) ?></h3>
|
||||
<p><?= htmlspecialchars($b['body'], ENT_QUOTES) ?></p>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($faqs): ?>
|
||||
<section class="section section--light">
|
||||
<div class="container faq-section">
|
||||
<div class="section-header">
|
||||
<span class="eyebrow">Common Questions</span>
|
||||
<h2>Frequently Asked Questions</h2>
|
||||
</div>
|
||||
<div class="faq-list">
|
||||
<?php foreach ($faqs as $faq): ?>
|
||||
<details class="faq-item">
|
||||
<summary><?= htmlspecialchars($faq['q'] ?? '', ENT_QUOTES) ?></summary>
|
||||
<p><?= htmlspecialchars($faq['a'] ?? '', ENT_QUOTES) ?></p>
|
||||
</details>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="section section--amber cta-section">
|
||||
<div class="container cta-section-inner">
|
||||
<div class="cta-section-text">
|
||||
<h2><?= htmlspecialchars($body['form_h2'] ?? 'Get Your Free Estimate', ENT_QUOTES) ?></h2>
|
||||
<p>Contact our team and we will respond within 24 hours.</p>
|
||||
</div>
|
||||
<a href="/contact/" class="btn btn--primary btn--lg"><?= htmlspecialchars($body['form_submit'] ?? 'Request Estimate', ENT_QUOTES) ?></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php require __DIR__ . '/../components/_footer.php'; ?>
|
||||
Reference in New Issue
Block a user