From 81feccdc1aa6c5bc33545cd02a9fd023654d5020 Mon Sep 17 00:00:00 2001 From: Concept Agent Date: Fri, 29 May 2026 18:56:56 +0200 Subject: [PATCH] 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 --- .dockerignore | 12 + .env.example | 2 + .planning/README.md | 50 +++ Dockerfile | 45 ++- about/index.html | 139 ------- api/.env.example | 6 - api/Dockerfile | 5 - api/server.py | 225 ----------- assets/css/components.css | 331 +++++++++++++++- assets/css/main.css | 138 +------ assets/css/promo-popup.css | 189 +++++++++ assets/css/tokens.css | 105 +++++ assets/js/altcha.min.js | 8 + assets/js/form.js | 306 +++++---------- assets/js/main.js | 40 +- assets/js/promo-popup.js | 123 ++++++ blog/index.html | 110 ------ components/footer.html | 94 ----- components/header.html | 60 --- contact/index.html | 200 ---------- docker-compose.yml | 22 +- index.html | 633 ------------------------------ infra/entrypoint.sh | 7 + infra/nginx.conf | 131 +++++++ infra/php-fpm-pool.conf | 14 + infra/supervisord.conf | 25 ++ locations/_template.html | 258 ------------ locations/amherst.html | 284 -------------- locations/buffalo.html | 293 -------------- locations/clarence.html | 284 -------------- locations/east-amherst.html | 284 -------------- locations/index.html | 181 --------- locations/lancaster.html | 284 -------------- locations/williamsville.html | 284 -------------- nginx.conf | 63 --- reviews/index.html | 145 ------- services/_template.html | 276 ------------- services/floor-installation.html | 276 ------------- services/floor-refinishing.html | 276 ------------- services/floor-restoration.html | 276 ------------- services/floor-sanding.html | 276 ------------- services/index.html | 167 -------- src/api/altcha-challenge.php | 12 + src/api/altcha.php | 45 +++ src/api/components/_footer.php | 102 +++++ src/api/components/_header.php | 75 ++++ src/api/contact.php | 196 +++++++++ src/api/data/blog.sqlite | Bin 0 -> 12288 bytes src/api/data/locations.sqlite | Bin 0 -> 36864 bytes src/api/data/pages.db | 0 src/api/data/pages.sqlite | Bin 0 -> 24576 bytes src/api/data/rate-limits/.gitkeep | 0 src/api/data/services.db | 0 src/api/data/services.sqlite | Bin 0 -> 28672 bytes src/api/data/testimonials.sqlite | Bin 0 -> 8192 bytes src/api/promo.php | 73 ++++ src/api/router.php | 31 ++ src/api/templates/blog.php | 102 +++++ src/api/templates/location.php | 122 ++++++ src/api/templates/page.php | 391 ++++++++++++++++++ src/api/templates/service.php | 131 +++++++ 61 files changed, 2460 insertions(+), 5747 deletions(-) create mode 100644 .env.example delete mode 100644 about/index.html delete mode 100644 api/.env.example delete mode 100644 api/Dockerfile delete mode 100644 api/server.py create mode 100644 assets/css/promo-popup.css create mode 100644 assets/css/tokens.css create mode 100644 assets/js/altcha.min.js create mode 100644 assets/js/promo-popup.js delete mode 100644 blog/index.html delete mode 100644 components/footer.html delete mode 100644 components/header.html delete mode 100644 contact/index.html delete mode 100644 index.html create mode 100644 infra/entrypoint.sh create mode 100644 infra/nginx.conf create mode 100644 infra/php-fpm-pool.conf create mode 100644 infra/supervisord.conf delete mode 100644 locations/_template.html delete mode 100644 locations/amherst.html delete mode 100644 locations/buffalo.html delete mode 100644 locations/clarence.html delete mode 100644 locations/east-amherst.html delete mode 100644 locations/index.html delete mode 100644 locations/lancaster.html delete mode 100644 locations/williamsville.html delete mode 100644 nginx.conf delete mode 100644 reviews/index.html delete mode 100644 services/_template.html delete mode 100644 services/floor-installation.html delete mode 100644 services/floor-refinishing.html delete mode 100644 services/floor-restoration.html delete mode 100644 services/floor-sanding.html delete mode 100644 services/index.html create mode 100644 src/api/altcha-challenge.php create mode 100644 src/api/altcha.php create mode 100644 src/api/components/_footer.php create mode 100644 src/api/components/_header.php create mode 100644 src/api/contact.php create mode 100644 src/api/data/blog.sqlite create mode 100644 src/api/data/locations.sqlite create mode 100644 src/api/data/pages.db create mode 100644 src/api/data/pages.sqlite create mode 100644 src/api/data/rate-limits/.gitkeep create mode 100644 src/api/data/services.db create mode 100644 src/api/data/services.sqlite create mode 100644 src/api/data/testimonials.sqlite create mode 100644 src/api/promo.php create mode 100644 src/api/router.php create mode 100644 src/api/templates/blog.php create mode 100644 src/api/templates/location.php create mode 100644 src/api/templates/page.php create mode 100644 src/api/templates/service.php diff --git a/.dockerignore b/.dockerignore index 45b17a7..5f51686 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,6 +7,18 @@ __pycache__ *.pyc *.md *.txt +.env +.env.* +!robots.txt +*.xml +!sitemap.xml review_*.png docker-compose.yml .DS_Store +.planning/README.md +.planning/build_locations.py +.planning/build_services.py +.planning/__pycache__ +.planning/review_*.png +.planning/DNS_*.txt +.planning/planning.db diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fd6c38a --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +RESEND_API_KEY=your_resend_api_key_here +ALTCHA_HMAC_KEY=your_altcha_hmac_key_here diff --git a/.planning/README.md b/.planning/README.md index e69de29..cb4bd1d 100644 --- a/.planning/README.md +++ b/.planning/README.md @@ -0,0 +1,50 @@ +# Floor It Hardwood Floors: Project Notes + +## Client +Floor It Hardwood Floors +Buffalo, NY +Phone: (716) 602-1429 +Email: floorithardwoods@gmail.com + +## Stack +Stack B: Static HTML + vanilla JS + Python API (Resend) + Docker/nginx +This site predates the AM programmatic stack and is maintained as static per SOP. + +## Dev +Port: 8096 +Run: docker compose up -d +URL: http://localhost:8096 + +## Status (2026-05-27) +Launch Prep: in progress. SOP compliance fixes complete. Awaiting Docker smoke test and production deploy. + +## Pages +- / index.html +- /about/ +- /contact/ +- /reviews/ +- /blog/ +- /services/ (floor-installation, refinishing, restoration, sanding) +- /locations/ (amherst, buffalo, clarence, east-amherst, lancaster, williamsville) + +## API +Python form service at /api/. Handles contact form POST, sends via Resend. +API key in api/.env (gitignored). + +## Key Decisions +- nginx: robots.txt and sitemap.xml served via exact-match location blocks before deny-all regex (2026-05-27) +- nginx: error_page 404 -> /404.html not /index.html (2026-05-27) +- nginx: _template.html files denied at the nginx layer (2026-05-27) +- Mobile nav breakpoint raised from 768px to 1023px. Header has 6 nav links + phone + CTA (2026-05-27) +- 360px ultra-narrow media query added for iPhone SE (2026-05-27) +- overflow-x: clip on html/body to prevent horizontal scroll (2026-05-27) +- residential.png converted to residential.webp via PIL. Original kept (2026-05-27) + +## DNS (Cloudflare) +DMARC TXT record: v=DMARC1; p=none; rua=mailto:dev@arisingmedia.us +See DNS_DMARC_RECORD.txt for full instructions. + +## Open Tasks +- Docker build + smoke test port 8096 +- Production deploy: remove auth_basic from nginx.conf, update DNS +- tokens.css split from main.css (low priority) diff --git a/Dockerfile b/Dockerfile index 6fc5a4c..614eb7b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,35 @@ -FROM nginx:alpine +FROM php:8.3-fpm-alpine -# nginx config (server-only, not served as a static file) -COPY nginx.conf /etc/nginx/conf.d/default.conf +RUN apk add --no-cache nginx supervisor curl openssl tini \ + && mkdir -p /run/nginx /var/cache/nginx /var/log/nginx /run/supervisord -# Copy only public website assets — everything else (api/, build scripts, -# Dockerfile, .env, docs, screenshots) stays out of the web root. -COPY index.html /usr/share/nginx/html/ -COPY assets /usr/share/nginx/html/assets/ -COPY components /usr/share/nginx/html/components/ -COPY about /usr/share/nginx/html/about/ -COPY blog /usr/share/nginx/html/blog/ -COPY contact /usr/share/nginx/html/contact/ -COPY locations /usr/share/nginx/html/locations/ -COPY reviews /usr/share/nginx/html/reviews/ -COPY services /usr/share/nginx/html/services/ +COPY infra/php-fpm-pool.conf /usr/local/etc/php-fpm.d/www.conf +COPY infra/supervisord.conf /etc/supervisord.conf +COPY infra/nginx.conf /etc/nginx/nginx.conf +COPY infra/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +COPY assets /var/www/html/assets/ +COPY src /var/www/html/src/ +COPY robots.txt /var/www/html/robots.txt +COPY sitemap.xml /var/www/html/sitemap.xml +COPY 404.html /var/www/html/404.html +COPY 500.html /var/www/html/500.html + +RUN chown -R www-data:www-data /var/www/html + +ENV RESEND_API_KEY="" \ + FROM_EMAIL="" \ + TO_EMAIL="" \ + ALTCHA_HMAC_KEY="" \ + RATE_LIMIT_PER_IP_PER_10MIN=5 \ + TIME_MIN_SECONDS=3 \ + TRUST_PROXY=1 EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -fsS http://127.0.0.1/ > /dev/null || exit 1 + +ENTRYPOINT ["/entrypoint.sh", "/sbin/tini", "--"] +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf", "-n"] diff --git a/about/index.html b/about/index.html deleted file mode 100644 index aa3dc06..0000000 --- a/about/index.html +++ /dev/null @@ -1,139 +0,0 @@ - - - - - - - About Floor It Hardwood Floors | Buffalo, NY - - - - - - - - - - - - -
- -
-
- - Our Story -

About Floor It Hardwood Floors

-

Three decades of expertise, 75 years of combined experience, and an unwavering commitment to craftsmanship throughout Western New York.

-
-
- - -
-
-
-
- Who We Are -

Craftsmanship is the Heart of Our Operation

-
-

We believe true craftsmanship combines skill, passion, and an unrelenting attention to detail.

-

At Floor It Hardwood Floors, we understand that every scratch has its story. After three decades in this business, we are experts at turning back the pages, breathing new life into your Buffalo home's flooring.

-

Our team brings 75 years of combined experience to every job. We are relentless in our pursuit of perfection, continually refining our skills and staying abreast of the latest trends and techniques in hardwood floor care.

-

We shine as the best pick for hardwood floor refinishing in Western New York. Your floors look new again, more beautiful and strong. When you choose us, you get a smooth process that boosts your home's worth and appearance.

-
-
- Floor It professional equipment -
-
-
-
- - -
-
-
- Our Credentials -

Why Western NY Homeowners Trust Us

-
-
-
-
75+
-
Years Combined Experience
-
-
-
500+
-
Projects Completed
-
-
-
4.9/5
-
Google Rating
-
-
-
30+
-
Years Serving Buffalo
-
-
-
-
- - -
-
-
- Where We Work -

Serving Buffalo and Erie County

-

We serve residential homeowners throughout Western New York with the same professional standards and care at every location.

-
- -
-
- - -
-
-

Ready to Restore Your Floors?

-

Contact our team today and take the first step toward beautiful hardwood floors.

- -
-
- -
- - - - - - - diff --git a/api/.env.example b/api/.env.example deleted file mode 100644 index de04fee..0000000 --- a/api/.env.example +++ /dev/null @@ -1,6 +0,0 @@ -RESEND_API_KEY=re_your_key_here -RECAPTCHA_SECRET=your_recaptcha_v3_secret_here -TO_EMAIL=floorithardwoods@gmail.com -FROM_EMAIL=estimates@floorithardwoodfloors.com -RECAPTCHA_MIN=0.5 -PORT=3001 diff --git a/api/Dockerfile b/api/Dockerfile deleted file mode 100644 index 2a83453..0000000 --- a/api/Dockerfile +++ /dev/null @@ -1,5 +0,0 @@ -FROM python:3.13-alpine -WORKDIR /app -COPY server.py . -EXPOSE 3001 -CMD ["python3", "-u", "server.py"] diff --git a/api/server.py b/api/server.py deleted file mode 100644 index 61850c4..0000000 --- a/api/server.py +++ /dev/null @@ -1,225 +0,0 @@ -#!/usr/bin/env python3 -""" -Floor It estimate-form API. -Pure Python 3 standard library only — no pip install, no packages. -Handles POST /estimate from the static site, validates fields, -verifies reCAPTCHA, sends via Resend, with idempotency + rate limiting. -""" - -import hashlib -import http.server -import json -import os -import re -import socketserver -import time -import urllib.parse -import urllib.request - -PORT = int(os.environ.get("PORT", "3001")) -RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "") -RECAPTCHA_SECRET = os.environ.get("RECAPTCHA_SECRET", "") -TO_EMAIL = os.environ.get("TO_EMAIL", "floorithardwoodfloors@gmail.com") -FROM_EMAIL = os.environ.get("FROM_EMAIL", "Floor It Hardwood Floors ") -RECAPTCHA_MIN = float(os.environ.get("RECAPTCHA_MIN", "0.5")) - -PHONE_RE = re.compile(r"^\(?\d{3}\)?[\s.\-]?\d{3}[\s.\-]?\d{4}$") -EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$") - -# In-memory rate limit: 5 requests / IP / 15 minutes -RATE_MAP = {} -RATE_WINDOW = 15 * 60 -RATE_MAX = 5 - - -def sanitize(s): - if not isinstance(s, str): - return "" - s = s.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) - return s.strip()[:2000] - - -def validate_fields(body): - errors = [] - if not body.get("name") or len((body.get("name") or "").strip()) < 2: - errors.append("name") - if not body.get("email") or not EMAIL_RE.match((body.get("email") or "").strip()): - errors.append("email") - phone_clean = (body.get("phone") or "").replace(" ", "") - if not phone_clean or not PHONE_RE.match(phone_clean): - errors.append("phone") - if not body.get("address") or len((body.get("address") or "").strip()) < 3: - errors.append("address") - if not (body.get("service") or "").strip(): - errors.append("service") - return errors - - -def verify_recaptcha(token): - if not RECAPTCHA_SECRET or not token: - return 0.0 - data = urllib.parse.urlencode({"secret": RECAPTCHA_SECRET, "response": token}).encode() - req = urllib.request.Request( - "https://www.google.com/recaptcha/api/siteverify", - data=data, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - try: - with urllib.request.urlopen(req, timeout=8) as resp: - return float(json.loads(resp.read()).get("score", 0)) - except Exception: - return 0.0 - - -def send_via_resend(fields): - safe = {k: sanitize(fields.get(k, "")) for k in - ["name", "email", "phone", "address", "city", "zip", "service", "condition", "message"]} - - html = f""" - -

A new estimate request was submitted on floorithardwoodfloors.com.

- - - - - - - - - - -
Name{safe['name']}
Email{safe['email']}
Phone{safe['phone']}
Address{safe['address']}
City{safe['city']}
Zip{safe['zip']}
Service{safe['service']}
Floor Condition{safe['condition']}
Message{safe['message']}
-

Reply directly to this email to respond to {safe['name']}.

-""" - - text = ( - "New estimate request from floorithardwoodfloors.com\n\n" - f"Name: {safe['name']}\n" - f"Email: {safe['email']}\n" - f"Phone: {safe['phone']}\n" - f"Address: {safe['address']}\n" - f"City: {safe['city']}\n" - f"Zip: {safe['zip']}\n" - f"Service: {safe['service']}\n" - f"Floor Condition: {safe['condition']}\n\n" - "Message:\n" - f"{safe['message']}\n\n" - f"Reply directly to this email to respond to {safe['name']}." - ) - - payload_obj = { - "from": FROM_EMAIL, - "to": [TO_EMAIL], - "reply_to": (fields.get("email") or "").strip(), - "subject": f"New estimate request: {safe['name']} ({safe['city'] or 'Buffalo'})", - "html": html, - "text": text, - } - payload = json.dumps(payload_obj).encode("utf-8") - idem_key = hashlib.sha256(payload).hexdigest()[:64] - - req = urllib.request.Request( - "https://api.resend.com/emails", - data=payload, - headers={ - "Authorization": f"Bearer {RESEND_API_KEY}", - "Content-Type": "application/json", - "Idempotency-Key": idem_key, - "User-Agent": "FloorIt-Estimate-Form/1.0", - }, - ) - try: - with urllib.request.urlopen(req, timeout=10) as resp: - if resp.status < 200 or resp.status >= 300: - raise RuntimeError(f"Resend {resp.status}: {resp.read().decode('utf-8', 'ignore')}") - except urllib.error.HTTPError as e: - body = e.read().decode("utf-8", "ignore") - raise RuntimeError(f"Resend {e.code}: {body}") from None - - -def rate_limit(ip): - now = time.time() - entry = RATE_MAP.get(ip, {"count": 0, "start": now}) - if now - entry["start"] > RATE_WINDOW: - entry = {"count": 0, "start": now} - entry["count"] += 1 - RATE_MAP[ip] = entry - return entry["count"] > RATE_MAX - - -class Handler(http.server.BaseHTTPRequestHandler): - def log_message(self, fmt, *args): - print(f"[api] {fmt % args}", flush=True) - - def _json(self, code, obj): - body = json.dumps(obj).encode("utf-8") - self.send_response(code) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.send_header("Access-Control-Allow-Origin", "https://floorithardwoodfloors.com") - self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") - self.send_header("Access-Control-Allow-Headers", "Content-Type") - self.end_headers() - self.wfile.write(body) - - def do_OPTIONS(self): - self.send_response(204) - self.send_header("Access-Control-Allow-Origin", "https://floorithardwoodfloors.com") - self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") - self.send_header("Access-Control-Allow-Headers", "Content-Type") - self.end_headers() - - def do_GET(self): - if self.path == "/health": - self._json(200, {"ok": True}) - return - self._json(404, {"error": "not found"}) - - def do_POST(self): - if self.path != "/estimate": - self._json(404, {"error": "not found"}) - return - - ip = self.headers.get("x-forwarded-for", "").split(",")[0].strip() or self.client_address[0] - if rate_limit(ip): - self._json(429, {"error": "Too many requests. Please call us at (716) 602-1429."}) - return - - length = int(self.headers.get("Content-Length", "0")) - if length > 16384: - self._json(413, {"error": "Payload too large."}) - return - - try: - raw = self.rfile.read(length).decode("utf-8") - body = json.loads(raw) - except Exception: - self._json(400, {"error": "Invalid request."}) - return - - errors = validate_fields(body) - if errors: - self._json(422, {"error": "Validation failed.", "fields": errors}) - return - - score = verify_recaptcha(body.get("token", "")) - if RECAPTCHA_SECRET and score < RECAPTCHA_MIN: - self._json(403, {"error": "Request could not be verified. Please try again or call us directly."}) - return - - try: - send_via_resend(body) - self._json(200, {"ok": True}) - except Exception as e: - print(f"[estimate] {e}", flush=True) - self._json(500, {"error": "Could not send your message. Please call us at (716) 602-1429."}) - - -class ThreadedHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer): - daemon_threads = True - allow_reuse_address = True - - -if __name__ == "__main__": - print(f"[api] listening on :{PORT}", flush=True) - ThreadedHTTPServer(("0.0.0.0", PORT), Handler).serve_forever() diff --git a/assets/css/components.css b/assets/css/components.css index 732ee65..7cc732b 100644 --- a/assets/css/components.css +++ b/assets/css/components.css @@ -1,5 +1,5 @@ /* ============================================================ - FLOOR IT HARDWOOD FLOORS — Component Styles + FLOOR IT HARDWOOD FLOORS : Component Styles components.css: header, footer, hero, cards, faq, gallery ============================================================ */ @@ -580,6 +580,8 @@ border: 1px solid var(--border-light); transition: transform var(--transition), box-shadow var(--transition); position: relative; + display: flex; + flex-direction: column; } .service-card:hover { @@ -616,6 +618,7 @@ .service-card-body { padding: var(--space-6); + flex: 1; } .service-card-body h3 { @@ -680,7 +683,8 @@ z-index: 1; } -.process-step-num { +.process-step-num, +.process-num { width: 64px; height: 64px; background: var(--amber); @@ -1228,6 +1232,213 @@ box-shadow: var(--shadow-lg); } +.contact-detail { + display: flex; + align-items: baseline; + gap: var(--space-3); + padding: var(--space-3) 0; + border-bottom: 1px solid var(--border); + font-size: var(--text-sm); +} +.contact-detail:last-of-type { border-bottom: none; } +.contact-detail strong { + min-width: 130px; + font-weight: 700; + color: var(--ink); + flex-shrink: 0; +} +.contact-detail a { color: var(--bark); font-weight: 600; } +.contact-detail span { color: var(--smoke); } + +/* About preview two-column layout */ +.about-preview-inner { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-16); + align-items: center; +} +.about-preview-text .divider { + width: 48px; + height: 3px; + background: var(--amber); + border-radius: 2px; + margin: var(--space-4) 0 var(--space-5); +} +.about-preview-img { + border-radius: var(--radius-lg); + overflow: hidden; + aspect-ratio: 4/3; +} +.about-preview-img img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +/* About story two-column layout */ +.about-story { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-16); + align-items: center; +} +.about-story-img { + border-radius: var(--radius-lg); + overflow: hidden; + aspect-ratio: 4/3; +} +.about-story-img img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +/* Service intro two-column layout */ +.service-intro { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-12); + align-items: center; +} +.service-intro-img { + border-radius: var(--radius-lg); + overflow: hidden; + aspect-ratio: 4/3; +} +.service-intro-img img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +/* Location service cards */ +.card--service-local { + padding: var(--space-8) var(--space-6); +} +.card--service-local h3 { + font-size: var(--text-xl); + color: var(--ink); + margin-bottom: var(--space-3); +} +.card--service-local p { + font-size: var(--text-sm); + color: var(--smoke); + line-height: 1.7; +} + +/* Benefit item (service pages) */ +.benefit-item { + background: var(--white); + border-radius: var(--radius-lg); + border: 1px solid var(--border-light); + padding: var(--space-8) var(--space-6); + transition: box-shadow var(--transition), transform var(--transition); +} +.benefit-item:hover { + box-shadow: var(--shadow); + transform: translateY(-2px); +} +.benefit-item h3 { + font-size: var(--text-xl); + color: var(--ink); + margin-bottom: var(--space-3); +} +.benefit-item p { + font-size: var(--text-sm); + color: var(--smoke); + line-height: 1.7; +} + +/* Native details/summary FAQ */ +details.faq-item { + border: 1px solid var(--border-light); + border-radius: var(--radius); + background: var(--white); + overflow: hidden; +} +details.faq-item + details.faq-item { margin-top: var(--space-2); } +details.faq-item summary { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-5) var(--space-6); + font-size: var(--text-base); + font-weight: 600; + color: var(--charcoal); + cursor: pointer; + list-style: none; + gap: var(--space-4); +} +details.faq-item summary::-webkit-details-marker { display: none; } +details.faq-item summary::after { + content: '+'; + font-size: 1.25rem; + font-weight: 400; + color: var(--amber); + flex-shrink: 0; + transition: transform 0.2s ease; +} +details.faq-item[open] summary::after { transform: rotate(45deg); } +details.faq-item p { + padding: 0 var(--space-6) var(--space-5); + font-size: var(--text-sm); + color: var(--smoke); + line-height: 1.7; + margin: 0; +} + +/* --- Location pills --------------------------------------- */ +.locations-pill-list { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + justify-content: center; + margin-top: var(--space-10); +} +.location-pill { + display: inline-flex; + align-items: center; + padding: var(--space-3) var(--space-6); + background: var(--white); + border: 1.5px solid var(--border-light); + border-radius: 999px; + font-size: var(--text-sm); + font-weight: 600; + color: var(--bark); + box-shadow: 0 1px 4px rgba(0,0,0,0.07); + transition: background var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition); +} +.location-pill:hover { + background: var(--bark); + border-color: var(--bark); + color: var(--white); + box-shadow: 0 4px 12px rgba(0,0,0,0.12); +} + +/* --- Credential stats ------------------------------------- */ +.credential-item { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: var(--space-2); + padding: var(--space-8) var(--space-4); +} +.credential-item strong { + font-size: var(--text-4xl); + font-weight: 800; + color: var(--amber); + line-height: 1; +} +.credential-item span { + font-size: var(--text-sm); + color: var(--ash); + letter-spacing: 0.04em; +} + /* --- Responsive ------------------------------------------ */ @media (max-width: 1024px) { .footer-grid { grid-template-columns: 1fr 1fr; gap: var(--space-8); } @@ -1236,7 +1447,7 @@ .contact-layout { grid-template-columns: 1fr; } } -/* Header: switch to mobile menu earlier — desktop nav with logo + 6 links +/* Header: switch to mobile menu earlier : desktop nav with logo + 6 links + phone + CTA needs ~1024px to fit without overflowing. */ @media (max-width: 1023px) { .header-nav { display: none; } @@ -1268,7 +1479,7 @@ .header-logo-sub { display: none; } } -/* Ultra-narrow phones (iPhone SE portrait, 320px) — tighten header */ +/* Ultra-narrow phones (iPhone SE portrait, 320px) : tighten header */ @media (max-width: 360px) { .header-cta .btn--sm { padding-inline: 0.75rem; @@ -1326,7 +1537,7 @@ background: var(--border-dark); } -/* Luxury about split — larger image aspect */ +/* Luxury about split : larger image aspect */ .about-img-wrap { border-radius: var(--radius-xl); overflow: hidden; @@ -1363,7 +1574,7 @@ line-height: 1.1; } -/* Process step numbers — larger */ +/* Process step numbers : larger */ .process-step-num { width: 72px; height: 72px; @@ -1371,7 +1582,7 @@ box-shadow: 0 0 0 6px rgba(200,139,42,0.15); } -/* FAQ — premium border treatment */ +/* FAQ : premium border treatment */ .faq-item { border-left: 3px solid transparent; transition: border-color var(--transition), box-shadow var(--transition); @@ -1392,13 +1603,13 @@ border-color: rgba(200,139,42,0.25); } -/* Contact form wrap — elevated shadow */ +/* Contact form wrap : elevated shadow */ .contact-form-wrap { box-shadow: 0 32px 80px rgba(0,0,0,0.14), 0 8px 24px rgba(0,0,0,0.08); } /* ============================================================ - LUXURY ELEVATION — v2 + LUXURY ELEVATION : v2 ============================================================ */ /* Services 2×2 grid */ @@ -1419,7 +1630,7 @@ height: 300px; } -/* Service card — luxury border accent on hover */ +/* Service card : luxury border accent on hover */ .services-grid-2x2 .service-card { border-top: 3px solid transparent; transition: transform var(--transition), box-shadow var(--transition), border-color var(--transition); @@ -1430,7 +1641,7 @@ box-shadow: 0 24px 60px rgba(0,0,0,0.12); } -/* Testimonial cards — richer treatment */ +/* Testimonial cards : richer treatment */ .testimonial-card { background: var(--white); border-radius: var(--radius-lg); @@ -1512,7 +1723,7 @@ letter-spacing: 0.05em; } -/* Gallery grid — full-bleed dramatic layout */ +/* Gallery grid : full-bleed dramatic layout */ .gallery-grid { display: grid; grid-template-columns: repeat(2, 1fr); @@ -1559,7 +1770,7 @@ color: var(--ink); } -/* Location card — luxury elevated */ +/* Location card : luxury elevated */ .location-card { display: flex; align-items: center; @@ -1598,7 +1809,7 @@ letter-spacing: 0.06em; } -/* Section headers — more dramatic */ +/* Section headers : more dramatic */ .section-header h2, .section-header--center h2 { font-size: clamp(2rem, 4.5vw, 3.25rem); @@ -1615,7 +1826,7 @@ color: var(--white); } -/* Benefit icon — amber filled circle */ +/* Benefit icon : amber filled circle */ .benefit-icon { width: 52px; height: 52px; @@ -1667,7 +1878,7 @@ } /* ============================================================ - Mobile responsive overrides — inline grids must collapse + Mobile responsive overrides : inline grids must collapse to single column on narrow viewports. Inline styles win over CSS unless we use !important inside media queries. ============================================================ */ @@ -1705,7 +1916,7 @@ html, body { gap: 1.5rem !important; } - /* Order overrides — when 2-col uses order:1/2 to flip image/content, + /* Order overrides : when 2-col uses order:1/2 to flip image/content, reset on mobile so content always reads top-to-bottom */ [style*="order:1"], [style*="order: 1"] { @@ -1747,7 +1958,7 @@ html, body { width: 100%; } - /* Form/contact layout — prevent intrinsic input width from blowing out + /* Form/contact layout : prevent intrinsic input width from blowing out the grid track. Without min-width:0, defaults push the parent column wider than the viewport, causing horizontal scroll. */ .contact-layout { min-width: 0; } @@ -1794,3 +2005,87 @@ html, body { font-size: 1rem; } } + +/* CTA banner sections with photo background */ +.cta-section { + position: relative; + background-image: url('/assets/images/refinishing-machine.webp'); + background-size: cover; + background-position: center; + color: var(--text-on-dark); +} +.cta-section::before { + content: ''; + position: absolute; + inset: 0; + background: rgba(12, 8, 5, 0.78); +} +.cta-section-inner { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-8); + flex-wrap: wrap; +} +.cta-section-text h2 { color: var(--white); } +.cta-section-text p { color: rgba(240,232,218,0.8); max-width: 52ch; } +.cta-section .btn--primary { + flex-shrink: 0; + box-shadow: 0 4px 20px rgba(200,139,42,0.4); +} +@media (max-width: 768px) { + .cta-section-inner { flex-direction: column; text-align: center; } + .cta-section-text p { margin-inline: auto; } +} + +/* Service grid cards (home page) */ +.card--service { + display: flex; + flex-direction: column; + text-decoration: none; + color: var(--text-on-light); +} +.card--service .card-img-wrap { + height: 220px; + overflow: hidden; + flex-shrink: 0; +} +.card--service .card-img-wrap img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.5s ease; +} +.card--service:hover .card-img-wrap img { transform: scale(1.05); } +.card--service .card-body { + padding: var(--space-6); + flex: 1; + display: flex; + flex-direction: column; +} +.card--service .card-body h3 { + font-size: var(--text-xl); + color: var(--charcoal); + margin-bottom: var(--space-3); +} +.card--service .card-body p { + font-size: var(--text-sm); + color: var(--smoke); + line-height: 1.65; + max-width: none; + flex: 1; +} +.card--service .card-link { + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); + font-weight: 700; + color: var(--amber); + letter-spacing: 0.04em; + margin-top: var(--space-4); + transition: gap var(--transition); +} +.card--service:hover .card-link { gap: var(--space-3); } diff --git a/assets/css/main.css b/assets/css/main.css index 50599b8..3cd0be9 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -1,106 +1,9 @@ -/* ============================================================ - FLOOR IT HARDWOOD FLOORS — Design System - main.css: variables, reset, typography, layout, utilities - ============================================================ */ +@import url('tokens.css'); -/* --- Custom Properties ------------------------------------ */ -:root { - /* Color palette — warm wood tones, no gradients */ - --ink: #0c0805; - --charcoal: #1c1208; - --bark: #2e1d0a; - --bark-mid: #3d2710; - --amber: #c88b2a; - --amber-dark: #a87220; - --amber-light: #e8aa48; - --parchment: #f5f0e8; - --cream: #faf8f5; - --grain: #ede5d8; - --smoke: #7a6a56; - --ash: #b8a898; - --white: #ffffff; +/* FLOOR IT HARDWOOD FLOORS: Design System + main.css: reset, typography, layout, utilities */ - /* Semantic aliases */ - --bg-dark: var(--ink); - --bg-dark-alt: var(--charcoal); - --bg-mid-dark: var(--bark); - --bg-light: var(--cream); - --bg-light-alt: var(--parchment); - --bg-warm: var(--grain); - - --text-on-dark: #f0e8da; - --text-muted-dark:var(--ash); - --text-on-light: var(--charcoal); - --text-muted-light:var(--smoke); - - --cta: var(--amber); - --cta-hover: var(--amber-dark); - --cta-text: var(--ink); - - --border-dark: rgba(255,255,255,0.08); - --border-light: rgba(0,0,0,0.08); - - /* Typography */ - --font-body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - --font-display: 'Inter', Georgia, serif; - - /* Scale */ - --text-xs: 0.75rem; - --text-sm: 0.875rem; - --text-base: 1rem; - --text-md: 1.125rem; - --text-lg: 1.25rem; - --text-xl: 1.5rem; - --text-2xl: 2rem; - --text-3xl: 2.5rem; - --text-4xl: 3.25rem; - --text-5xl: 4.25rem; - --text-6xl: 5.5rem; - - /* Spacing */ - --space-1: 0.25rem; - --space-2: 0.5rem; - --space-3: 0.75rem; - --space-4: 1rem; - --space-5: 1.25rem; - --space-6: 1.5rem; - --space-8: 2rem; - --space-10: 2.5rem; - --space-12: 3rem; - --space-16: 4rem; - --space-20: 5rem; - --space-24: 6rem; - --space-32: 8rem; - - /* Section rhythm */ - --section-py: clamp(4rem, 8vw, 8rem); - --section-py-sm: clamp(2.5rem, 5vw, 5rem); - - /* Layout */ - --container-max: 1200px; - --container-wide: 1380px; - --container-px: clamp(1.25rem, 5vw, 2.5rem); - - /* Effects */ - --radius-sm: 4px; - --radius: 8px; - --radius-lg: 16px; - --radius-xl: 24px; - --radius-full: 9999px; - - --shadow-sm: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08); - --shadow: 0 4px 16px rgba(0,0,0,0.12), 0 2px 6px rgba(0,0,0,0.08); - --shadow-lg: 0 12px 40px rgba(0,0,0,0.18), 0 4px 12px rgba(0,0,0,0.10); - --shadow-xl: 0 24px 64px rgba(0,0,0,0.22); - - --transition: 0.25s ease; - --transition-slow: 0.5s ease; - - /* Header height */ - --header-h: 72px; -} - -/* --- Reset ------------------------------------------------ */ +/* Reset */ *, *::before, *::after { box-sizing: border-box; margin: 0; @@ -148,7 +51,7 @@ input, textarea, select { font-size: inherit; } -/* --- Typography ------------------------------------------- */ +/* Typography */ h1, h2, h3, h4, h5, h6 { font-family: var(--font-display); font-weight: 800; @@ -215,9 +118,9 @@ p { max-width: 68ch; } gap: var(--space-8); } -.grid--2 { grid-template-columns: repeat(2, 1fr); } -.grid--3 { grid-template-columns: repeat(3, 1fr); } -.grid--4 { grid-template-columns: repeat(4, 1fr); } +.grid--2, .grid--2col { grid-template-columns: repeat(2, 1fr); } +.grid--3, .grid--3col { grid-template-columns: repeat(3, 1fr); } +.grid--4, .grid--4col { grid-template-columns: repeat(4, 1fr); } .grid--auto-2 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); } .grid--auto-3 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); } @@ -253,7 +156,7 @@ p { max-width: 68ch; } .section--mid .lead, .section--bark .lead { color: var(--text-muted-dark); } -/* --- Buttons --------------------------------------------- */ +/* Buttons */ .btn { display: inline-flex; align-items: center; @@ -328,7 +231,7 @@ p { max-width: 68ch; } align-items: center; } -/* --- Forms ----------------------------------------------- */ +/* Forms */ .form-grid { display: grid; gap: var(--space-5); @@ -439,7 +342,7 @@ p { max-width: 68ch; } .form-status--success { background: #d4edda; color: #155724; display: block; } .form-status--error { background: #f8d7da; color: #721c24; display: block; } -/* --- Cards ----------------------------------------------- */ +/* Cards */ .card { background: var(--white); border-radius: var(--radius-lg); @@ -459,7 +362,7 @@ p { max-width: 68ch; } color: var(--text-on-dark); } -/* --- Scroll Animations ----------------------------------- */ +/* Scroll Animations */ [data-animate] { opacity: 0; transition: opacity 0.7s ease, transform 0.7s ease; @@ -482,7 +385,7 @@ p { max-width: 68ch; } [data-delay="5"] { transition-delay: 0.5s; } [data-delay="6"] { transition-delay: 0.6s; } -/* --- Utility Classes ------------------------------------- */ +/* Utility Classes */ .text-center { text-align: center; } .text-amber { color: var(--amber); } .text-muted { color: var(--smoke); } @@ -512,7 +415,7 @@ p { max-width: 68ch; } border: 0; } -/* --- Dividers -------------------------------------------- */ +/* Dividers */ .divider { width: 60px; height: 3px; @@ -524,22 +427,21 @@ p { max-width: 68ch; } .section--mid .divider, .section--bark .divider { background: var(--amber); } -/* --- Responsive ------------------------------------------ */ +/* Responsive */ @media (max-width: 1024px) { - .grid--4 { grid-template-columns: repeat(2, 1fr); } - .grid--3 { grid-template-columns: repeat(2, 1fr); } + .grid--4, .grid--4col { grid-template-columns: repeat(2, 1fr); } + .grid--3, .grid--3col { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 768px) { - .grid--2, - .grid--3, - .grid--4 { grid-template-columns: 1fr; } + .grid--2, .grid--2col, + .grid--3, .grid--3col, + .grid--4, .grid--4col { grid-template-columns: 1fr; } .cta-group { justify-content: center; } .cta-group .btn { min-width: 220px; } } @media (max-width: 480px) { - :root { --section-py: clamp(3rem, 8vw, 4rem); } p { max-width: 100%; } } diff --git a/assets/css/promo-popup.css b/assets/css/promo-popup.css new file mode 100644 index 0000000..5dc57ad --- /dev/null +++ b/assets/css/promo-popup.css @@ -0,0 +1,189 @@ +/* FLOOR IT HARDWOOD FLOORS. Summer Promo Topbar + Popup */ + +/* Topbar */ +#promo-topbar { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1001; + height: 44px; + background: #c8a96e; + color: #1a1207; + display: none; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 0 1rem; + font-size: 0.82rem; + font-weight: 600; +} +#promo-topbar.visible { display: flex; } +#promo-topbar-text { flex: 1; text-align: center; } +#promo-topbar-btn { + background: #1a1207; + color: #c8a96e; + border: none; + padding: 0.3rem 0.85rem; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + border-radius: 2px; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; +} +#promo-topbar-btn:hover { background: #0c0805; } +#promo-topbar-close { + background: none; + border: none; + color: #1a1207; + font-size: 1.3rem; + cursor: pointer; + line-height: 1; + padding: 0; + opacity: 0.6; + flex-shrink: 0; +} +#promo-topbar-close:hover { opacity: 1; } + +body.has-topbar #site-header { top: 44px; } + +@media (max-width: 600px) { + #promo-topbar-text { font-size: 0.72rem; } + #promo-topbar { gap: 0.5rem; } +} + +/* Popup */ +#promo-overlay { + position: fixed; + inset: 0; + background: rgba(30, 20, 10, 0.72); + z-index: 9000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; +} +#promo-overlay.visible { + opacity: 1; + pointer-events: all; +} +#promo-box { + background: #fff; + border-radius: 4px; + max-width: 480px; + width: 100%; + padding: 2.5rem 2rem 2rem; + position: relative; + box-shadow: 0 8px 40px rgba(0,0,0,0.3); +} +#promo-box .promo-badge { + display: inline-block; + background: #c8a96e; + color: #fff; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + padding: 0.25rem 0.75rem; + border-radius: 2px; + margin-bottom: 0.75rem; +} +#promo-box h2 { + font-size: 1.45rem; + line-height: 1.25; + color: #1a1207; + margin: 0 0 0.4rem; +} +#promo-box .promo-sub { + font-size: 0.9rem; + color: #6b5c3e; + margin: 0 0 1.5rem; +} +#promo-box .promo-field { + margin-bottom: 0.85rem; +} +#promo-box .promo-field label { + display: block; + font-size: 0.8rem; + font-weight: 600; + color: #1a1207; + margin-bottom: 0.3rem; + text-transform: uppercase; + letter-spacing: 0.06em; +} +#promo-box .promo-field input { + width: 100%; + padding: 0.65rem 0.85rem; + border: 1.5px solid #d5c9b6; + border-radius: 3px; + font-size: 0.95rem; + color: #1a1207; + background: #faf8f5; + box-sizing: border-box; + transition: border-color 0.2s; +} +#promo-box .promo-field input:focus { + outline: none; + border-color: #c8a96e; +} +#promo-box .promo-submit { + width: 100%; + padding: 0.85rem; + background: #c8a96e; + color: #fff; + border: none; + border-radius: 3px; + font-size: 1rem; + font-weight: 700; + letter-spacing: 0.04em; + cursor: pointer; + margin-top: 0.5rem; + transition: background 0.2s; +} +#promo-box .promo-submit:hover { background: #b5923d; } +#promo-box .promo-submit:disabled { background: #ccc; cursor: default; } +#promo-box .promo-error { + font-size: 0.85rem; + color: #b91c1c; + margin-top: 0.5rem; + display: none; +} +#promo-box .promo-success { + text-align: center; + padding: 1rem 0 0.5rem; + display: none; +} +#promo-box .promo-success p { + font-size: 1.05rem; + color: #1a1207; + margin: 0.5rem 0 0; +} +#promo-close { + position: absolute; + top: 0.85rem; + right: 1rem; + background: none; + border: none; + font-size: 1.5rem; + color: #9a8a72; + cursor: pointer; + line-height: 1; + padding: 0; +} +#promo-close:hover { color: #1a1207; } +#promo-box .promo-fine { + font-size: 0.72rem; + color: #9a8a72; + text-align: center; + margin-top: 1rem; +} +@media (max-width: 480px) { + #promo-box { padding: 2rem 1.25rem 1.5rem; } + #promo-box h2 { font-size: 1.2rem; } +} diff --git a/assets/css/tokens.css b/assets/css/tokens.css new file mode 100644 index 0000000..7b2c54a --- /dev/null +++ b/assets/css/tokens.css @@ -0,0 +1,105 @@ +/* ============================================================ + FLOOR IT HARDWOOD FLOORS: Design Tokens + tokens.css: CSS custom properties only. No rules, no selectors. + ============================================================ */ + +/* Custom Properties */ +:root { + /* Color palette: warm wood tones, no gradients */ + --ink: #0c0805; + --charcoal: #1c1208; + --bark: #2e1d0a; + --bark-mid: #3d2710; + --amber: #c88b2a; + --amber-dark: #a87220; + --amber-light: #e8aa48; + --parchment: #f5f0e8; + --cream: #faf8f5; + --grain: #ede5d8; + --smoke: #7a6a56; + --ash: #b8a898; + --white: #ffffff; + + /* Semantic aliases */ + --bg-dark: var(--ink); + --bg-dark-alt: var(--charcoal); + --bg-mid-dark: var(--bark); + --bg-light: var(--cream); + --bg-light-alt: var(--parchment); + --bg-warm: var(--grain); + + --text-on-dark: #f0e8da; + --text-muted-dark:var(--ash); + --text-on-light: var(--charcoal); + --text-muted-light:var(--smoke); + + --cta: var(--amber); + --cta-hover: var(--amber-dark); + --cta-text: var(--ink); + + --border-dark: rgba(255,255,255,0.08); + --border-light: rgba(0,0,0,0.08); + + /* Typography */ + --font-body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-display: 'Inter', Georgia, serif; + + /* Scale */ + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-md: 1.125rem; + --text-lg: 1.25rem; + --text-xl: 1.5rem; + --text-2xl: 2rem; + --text-3xl: 2.5rem; + --text-4xl: 3.25rem; + --text-5xl: 4.25rem; + --text-6xl: 5.5rem; + + /* Spacing */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-10: 2.5rem; + --space-12: 3rem; + --space-16: 4rem; + --space-20: 5rem; + --space-24: 6rem; + --space-32: 8rem; + + /* Section rhythm */ + --section-py: clamp(4rem, 8vw, 8rem); + --section-py-sm: clamp(2.5rem, 5vw, 5rem); + + /* Layout */ + --container-max: 1200px; + --container-wide: 1380px; + --container-px: clamp(1.25rem, 5vw, 2.5rem); + + /* Effects */ + --radius-sm: 4px; + --radius: 8px; + --radius-lg: 16px; + --radius-xl: 24px; + --radius-full: 9999px; + + --shadow-sm: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08); + --shadow: 0 4px 16px rgba(0,0,0,0.12), 0 2px 6px rgba(0,0,0,0.08); + --shadow-lg: 0 12px 40px rgba(0,0,0,0.18), 0 4px 12px rgba(0,0,0,0.10); + --shadow-xl: 0 24px 64px rgba(0,0,0,0.22); + + --transition: 0.25s ease; + --transition-slow: 0.5s ease; + + /* Header height */ + --header-h: 72px; +} + +@media (max-width: 480px) { + :root { --section-py: clamp(3rem, 8vw, 4rem); } +} diff --git a/assets/js/altcha.min.js b/assets/js/altcha.min.js new file mode 100644 index 0000000..f5edc32 --- /dev/null +++ b/assets/js/altcha.min.js @@ -0,0 +1,8 @@ +/** + * Minified by jsDelivr using Terser v5.39.0. + * Original file: /npm/altcha@2.3.0/dist/altcha.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +const Yn='(function(){"use strict";const d=new TextEncoder;function p(e){return[...new Uint8Array(e)].map(t=>t.toString(16).padStart(2,"0")).join("")}async function b(e,t,r){if(typeof crypto>"u"||!("subtle"in crypto)||!("digest"in crypto.subtle))throw new Error("Web Crypto is not available. Secure context is required (https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts).");return p(await crypto.subtle.digest(r.toUpperCase(),d.encode(e+t)))}function w(e,t,r="SHA-256",n=1e6,l=0){const o=new AbortController,a=Date.now();return{promise:(async()=>{for(let c=l;c<=n;c+=1){if(o.signal.aborted)return null;if(await b(t,c,r)===e)return{number:c,took:Date.now()-a}}return null})(),controller:o}}function h(e){const t=atob(e),r=new Uint8Array(t.length);for(let n=0;n{for(let i=n;i<=r;i+=1){if(o.signal.aborted||!c||!u)return null;try{const f=await crypto.subtle.decrypt({name:l,iv:g(i)},c,u);if(f)return{clearText:new TextDecoder().decode(f),took:Date.now()-a}}catch{}}return null};let c=null,u=null;try{u=h(e);const i=await crypto.subtle.digest("SHA-256",d.encode(t));c=await crypto.subtle.importKey("raw",i,l,!1,["decrypt"])}catch{return{promise:Promise.reject(),controller:o}}return{promise:s(),controller:o}}let y;onmessage=async e=>{const{type:t,payload:r,start:n,max:l}=e.data;let o=null;if(t==="abort")y?.abort(),y=void 0;else if(t==="work"){if("obfuscated"in r){const{key:a,obfuscated:s}=r||{};o=await m(s,a,l,n)}else{const{algorithm:a,challenge:s,salt:c}=r||{};o=w(s,c,a,l,n)}y=o.controller,o.promise.then(a=>{self.postMessage(a&&{...a,worker:!0})})}}})();\n',Dn=typeof self<"u"&&self.Blob&&new Blob(["(self.URL || self.webkitURL).revokeObjectURL(self.location.href);",Yn],{type:"text/javascript;charset=utf-8"});function Ni(e){let t;try{if(t=Dn&&(self.URL||self.webkitURL).createObjectURL(Dn),!t)throw"";const n=new Worker(t,{name:e?.name});return n.addEventListener("error",(()=>{(self.URL||self.webkitURL).revokeObjectURL(t)})),n}catch{return new Worker("data:text/javascript;charset=utf-8,"+encodeURIComponent(Yn),{name:e?.name})}}const Li="5";typeof window<"u"&&((window.__svelte??={}).v??=new Set).add(Li);const Pi=1,Oi=4,Fi=8,Mi=16,Vi=1,Ui=2,Mr="[",Zn="[!",zn="]",bt={},ae=Symbol(),ji="http://www.w3.org/1999/xhtml",Nn=!1;function Jn(e){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}var Kn=Array.isArray,qi=Array.prototype.indexOf,Bi=Array.from,or=Object.keys,Mt=Object.defineProperty,rt=Object.getOwnPropertyDescriptor,Hi=Object.getOwnPropertyDescriptors,Gi=Object.prototype,Wi=Array.prototype,Xn=Object.getPrototypeOf,Ln=Object.isExtensible;const yt=()=>{};function Qn(e){for(var t=0;t{var t=$;Re(a);var n=e();return Re(t),n};return r&&n.set("length",N(e.length)),new Proxy(e,{defineProperty(e,t,r){(!("value"in r)||!1===r.configurable||!1===r.enumerable||!1===r.writable)&&na();var o=n.get(t);return void 0===o?(o=l((()=>N(r.value))),n.set(t,o)):b(o,l((()=>Me(r.value)))),!0},deleteProperty(e,t){var i=n.get(t);if(void 0===i)t in e&&(n.set(t,l((()=>N(ae)))),Ir(o));else{if(r&&"string"==typeof t){var a=n.get("length"),s=Number(t);Number.isInteger(s)&&sN(Me(s?t[r]:ae)))),n.set(r,a)),void 0!==a){var c=i(a);return c===ae?void 0:c}return Reflect.get(t,r,o)},getOwnPropertyDescriptor(e,t){var r=Reflect.getOwnPropertyDescriptor(e,t);if(r&&"value"in r){var o=n.get(t);o&&(r.value=i(o))}else if(void 0===r){var a=n.get(t),l=a?.v;if(void 0!==a&&l!==ae)return{enumerable:!0,configurable:!0,value:l,writable:!0}}return r},has(e,t){if(t===Ot)return!0;var r=n.get(t),o=void 0!==r&&r.v!==ae||Reflect.has(e,t);if((void 0!==r||null!==S&&(!o||rt(e,t)?.writable))&&(void 0===r&&(r=l((()=>N(o?Me(e[t]):ae))),n.set(t,r)),i(r)===ae))return!1;return o},set(e,t,i,a){var s=n.get(t),c=t in e;if(r&&"length"===t)for(var u=i;uN(ae))),n.set(u+"",f))}void 0===s?(!c||rt(e,t)?.writable)&&(b(s=l((()=>N(void 0))),l((()=>Me(i)))),n.set(t,s)):(c=s.v!==ae,b(s,l((()=>Me(i)))));var d=Reflect.getOwnPropertyDescriptor(e,t);if(d?.set&&d.set.call(a,i),!c){if(r&&"string"==typeof t){var h=n.get("length"),v=Number(t);Number.isInteger(v)&&v>=h.v&&b(h,v+1)}Ir(o)}return!0},ownKeys(e){i(o);var t=Reflect.ownKeys(e).filter((e=>{var t=n.get(e);return void 0===t||t.v!==ae}));for(var[r,a]of n)a.v!==ae&&!(r in e)&&t.push(r);return t},setPrototypeOf(){oa()}})}function Ir(e,t=1){b(e,e.v+t)}var Pn,no,oo,io;function Tr(){if(void 0===Pn){Pn=window,no=/Firefox/.test(navigator.userAgent);var e=Element.prototype,t=Node.prototype,n=Text.prototype;oo=rt(t,"firstChild").get,io=rt(t,"nextSibling").get,Ln(e)&&(e.__click=void 0,e.__className=void 0,e.__attributes=null,e.__style=void 0,e.__e=void 0),Ln(n)&&(n.__t=void 0)}}function vr(e=""){return document.createTextNode(e)}function ve(e){return oo.call(e)}function Be(e){return io.call(e)}function z(e,t){if(!O)return ve(e);var n=ve(P);return null===n&&(n=P.appendChild(vr())),Ue(n),n}function Nt(e,t){if(!O){var n=ve(e);return n instanceof Comment&&""===n.data?Be(n):n}return P}function J(e,t=1,n=!1){let r=O?P:e;for(var o;t--;)o=r,r=Be(r);if(!O)return r;var i=r?.nodeType;if(n&&3!==i){var a=vr();return null===r?o?.after(a):r.before(a),Ue(a),a}return Ue(r),r}function sa(e){e.textContent=""}function ao(e){return e===this.v}function lo(e,t){return e!=e?t==t:e!==t||null!==e&&"object"==typeof e||"function"==typeof e}function jr(e){return!lo(e,this.v)}function gr(e){var t=2050,n=null!==$&&2&$.f?$:null;return null===S||null!==n&&n.f&fe?t|=fe:S.f|=to,{ctx:ne,deps:null,effects:null,equals:ao,f:t,fn:e,reactions:null,rv:0,v:null,wv:0,parent:n??S}}function Lt(e){const t=gr(e);return wo(t),t}function ua(e){const t=gr(e);return t.equals=jr,t}function so(e){var t=e.effects;if(null!==t){e.effects=null;for(var n=0;n{je(t)}}function va(e){const t=lt(64,e,!0);return(e={})=>new Promise((n=>{e.outro?Lr(t,(()=>{je(t),n(void 0)})):(je(t),n(void 0))}))}function Br(e){return lt(4,e,!1)}function Hr(e){return lt(8,e,!0)}function Ce(e,t=[],n=gr){const r=t.map(n);return fo((()=>e(...r.map(i))))}function fo(e,t=0){return lt(24|t,e,!0)}function Nr(e,t=!0){return lt(40,e,!0,t)}function ho(e){var t=e.teardown;if(null!==t){const e=qt,n=$;Fn(!0),Re(null);try{t.call(null)}finally{Fn(e),Re(n)}}}function vo(e,t=!1){var n=e.first;for(e.first=e.last=null;null!==n;){var r=n.next;64&n.f?n.parent=null:je(n,t),n=r}}function ga(e){for(var t=e.first;null!==t;){var n=t.next;!(32&t.f)&&je(t),t=n}}function je(e,t=!0){var n=!1;(t||!!(e.f&zi))&&null!==e.nodes_start&&(go(e.nodes_start,e.nodes_end),n=!0),vo(e,t&&!n),cr(e,0),_e(e,dr);var r=e.transitions;if(null!==r)for(const e of r)e.stop();ho(e);var o=e.parent;null!==o&&null!==o.first&&po(e),e.next=e.prev=e.teardown=e.ctx=e.deps=e.fn=e.nodes_start=e.nodes_end=null}function go(e,t){for(;null!==e;){var n=e===t?null:Be(e);e.remove(),e=n}}function po(e){var t=e.parent,n=e.prev,r=e.next;null!==n&&(n.next=r),null!==r&&(r.prev=n),null!==t&&(t.first===e&&(t.first=r),t.last===e&&(t.last=n))}function Lr(e,t){var n=[];mo(e,n,!0),pa(n,(()=>{je(e),t&&t()}))}function pa(e,t){var n=e.length;if(n>0){var r=()=>--n||t();for(var o of e)o.out(r)}else t()}function mo(e,t,n){if(!(e.f&wt)){if(e.f^=wt,null!==e.transitions)for(const r of e.transitions)(r.is_global||n)&&t.push(r);for(var r=e.first;null!==r;){var o=r.next;mo(r,t,!!(!!(r.f&Ur)||!!(32&r.f))&&n),r=o}}}function On(e){_o(e,!0)}function _o(e,t){if(e.f&wt){e.f^=wt,!(e.f&le)&&(e.f^=le),Bt(e)&&(_e(e,Ie),mr(e));for(var n=e.first;null!==n;){var r=n.next;_o(n,!!(!!(n.f&Ur)||!!(32&n.f))&&t),n=r}if(null!==e.transitions)for(const n of e.transitions)(n.is_global||t)&&n.in()}}const ma=typeof requestIdleCallback>"u"?e=>setTimeout(e,1):requestIdleCallback;let Vt=[],Ut=[];function bo(){var e=Vt;Vt=[],Qn(e)}function yo(){var e=Ut;Ut=[],Qn(e)}function Gr(e){0===Vt.length&&queueMicrotask(bo),Vt.push(e)}function _a(e){0===Ut.length&&ma(yo),Ut.push(e)}function ba(){Vt.length>0&&bo(),Ut.length>0&&yo()}let tr=!1,lr=!1,sr=null,nt=!1,qt=!1;function Fn(e){qt=e}let Ft=[],$=null,ke=!1;function Re(e){$=e}let S=null;function qe(e){S=e}let Te=null;function wo(e){null!==$&&$.f&Sr&&(null===Te?Te=[e]:Te.push(e))}let re=null,ce=0,he=null;function ya(e){he=e}let Eo=1,ur=0,Ve=!1;function xo(){return++Eo}function Bt(e){var t=e.f;if(t&Ie)return!0;if(t&at){var n=e.deps,r=!!(t&fe);if(null!==n){var o,i,a=!!(t&ar),l=r&&null!==S&&!Ve,s=n.length;if(a||l){var c=e,u=c.parent;for(o=0;oe.wv)return!0}(!r||null!==S&&!Ve)&&_e(e,le)}return!1}function wa(e,t){for(var n=t;null!==n;){if(n.f&ir)try{return void n.fn(e)}catch{n.f^=ir}n=n.parent}throw tr=!1,e}function Mn(e){return!(e.f&dr||null!==e.parent&&e.parent.f&ir)}function pr(e,t,n,r){if(tr){if(null===n&&(tr=!1),Mn(t))throw e}else if(null!==n&&(tr=!0),wa(e,t),Mn(t))throw e}function Co(e,t,n=!0){var r=e.reactions;if(null!==r)for(var o=0;o0)for(f.length=ce+re.length,d=0;d0;){t++>1e3&&xa();var n=Ft,r=n.length;Ft=[];for(var o=0;o{r.d=!0}))}function So(e){const t=ne;if(null!==t){void 0!==e&&(t.x=e);const a=t.e;if(null!==a){var n=S,r=$;t.e=null;try{for(var o=0;o{document.activeElement===t&&e.focus()}))}}let Un=!1;function Do(){Un||(Un=!0,document.addEventListener("reset",(e=>{Promise.resolve().then((()=>{if(!e.defaultPrevented)for(const t of e.target.elements)t.__on_r?.()}))}),{capture:!0}))}function No(e){var t=$,n=S;Re(null),qe(null);try{return e()}finally{Re(t),qe(n)}}function Ta(e,t,n,r=n){e.addEventListener(t,(()=>No(n)));const o=e.__on_r;e.__on_r=o?()=>{o(),r(!0)}:()=>r(!0),Do()}const Lo=new Set,Pr=new Set;function Da(e,t,n,r={}){function o(e){if(r.capture||Pt.call(t,e),!e.cancelBubble)return No((()=>n?.call(this,e)))}return e.startsWith("pointer")||e.startsWith("touch")||"wheel"===e?Gr((()=>{t.addEventListener(e,o,r)})):t.addEventListener(e,o,r),o}function Fe(e,t,n,r,o){var i={capture:r,passive:o},a=Da(e,t,n,i);(t===document.body||t===window||t===document)&&qr((()=>{t.removeEventListener(e,a,i)}))}function Na(e){for(var t=0;ti||n});var u=$,f=S;Re(null),qe(null);try{for(var d,h=[];null!==i;){var v=i.assignedSlot||i.parentNode||i.host||null;try{var p=i["__"+r];if(null!=p&&(!i.disabled||e.target===i))if(Kn(p)){var[g,...b]=p;g.apply(i,[e,...b])}else p.call(i,e)}catch(e){d?h.push(e):d=e}if(e.cancelBubble||v===t||null===v)break;i=v}if(d){for(let e of h)queueMicrotask((()=>{throw e}));throw d}}finally{e.__root=t,delete e.currentTarget,Re(u),qe(f)}}}function Zr(e){var t=document.createElement("template");return t.innerHTML=e,t.content}function Ae(e,t){var n=S;null===n.nodes_start&&(n.nodes_start=e,n.nodes_end=t)}function be(e,t){var n,r=!!(1&t),o=!!(2&t),i=!e.startsWith("");return()=>{if(O)return Ae(P,null),P;void 0===n&&(n=Zr(i?e:""+e),r||(n=ve(n)));var t=o||no?document.importNode(n,!0):n.cloneNode(!0);r?Ae(ve(t),t.lastChild):Ae(t,t);return t}}function _r(e,t,n="svg"){var r,o=`<${n}>${!e.startsWith("")?e:""+e}`;return()=>{if(O)return Ae(P,null),P;if(!r){var e=Zr(o);r=ve(ve(e))}var t=r.cloneNode(!0);return Ae(t,t),t}}function Xt(){if(O)return Ae(P,null),P;var e=document.createDocumentFragment(),t=document.createComment(""),n=vr();return e.append(t,n),Ae(t,n),e}function B(e,t){if(O)return S.nodes_end=P,void Et();null!==e&&e.before(t)}function La(e,t){var n=null==t?"":"object"==typeof t?t+"":t;n!==(e.__t??=e.nodeValue)&&(e.__t=n,e.nodeValue=n+"")}function Po(e,t){return Oo(e,t)}function Pa(e,t){Tr(),t.intro=t.intro??!1;const n=t.target,r=O,o=P;try{for(var i=ve(n);i&&(8!==i.nodeType||i.data!==Mr);)i=Be(i);if(!i)throw bt;_t(!0),Ue(i),Et();const r=Oo(e,{...t,anchor:i});if(null===P||8!==P.nodeType||P.data!==zn)throw hr(),bt;return _t(!1),r}catch(r){if(r===bt)return!1===t.recover&&ta(),Tr(),sa(n),_t(!1),Po(e,t);throw r}finally{_t(r),Ue(o)}}const pt=new Map;function Oo(e,{target:t,anchor:n,props:r={},events:o,context:i,intro:a=!0}){Tr();var l=new Set,s=e=>{for(var n=0;n{var a=n??t.appendChild(vr());return Nr((()=>{i&&($o({}),ne.c=i);o&&(r.$$events=o),O&&Ae(a,null),c=e(a,r)||{},O&&(S.nodes_end=P),i&&So()})),()=>{for(var e of l){t.removeEventListener(e,Pt);var r=pt.get(e);0==--r?(document.removeEventListener(e,Pt),pt.delete(e)):pt.set(e,r)}Pr.delete(s),a!==n&&a.parentNode?.removeChild(a)}}));return Or.set(c,u),c}let Or=new WeakMap;function Oa(e,t){const n=Or.get(e);return n?(Or.delete(e),n(t)):Promise.resolve()}function K(e,t,[n,r]=[0,0]){O&&0===n&&Et();var o=e,i=null,a=null,l=ae,s=!1;const c=(e,t=!0)=>{s=!0,u(t,e)},u=(e,t)=>{if(l===(l=e))return;let s=!1;if(O&&-1!==r){if(0===n){const e=o.data;e===Mr?r=0:e===Zn?r=1/0:(r=parseInt(e.substring(1)))!=r&&(r=l?1/0:-1)}!!l===r>n&&(Ue(o=aa()),_t(!1),s=!0,r=-1)}l?(i?On(i):t&&(i=Nr((()=>t(o)))),a&&Lr(a,(()=>{a=null}))):(a?On(a):t&&(a=Nr((()=>t(o,[n+1,r])))),i&&Lr(i,(()=>{i=null}))),s&&_t(!0)};fo((()=>{s=!1,t(c),s||u(null,null)}),n>0?Ur:0),O&&(o=P)}function tt(e,t,n=!1,r=!1,o=!1){var i=e,a="";Ce((()=>{var e=S;if(a!==(a=t()??"")){if(null!==e.nodes_start&&(go(e.nodes_start,e.nodes_end),e.nodes_start=e.nodes_end=null),""!==a){if(O){P.data;for(var o=Et(),l=o;null!==o&&(8!==o.nodeType||""!==o.data);)l=o,o=Be(o);if(null===o)throw hr(),bt;return Ae(P,l),void(i=Ue(o))}var s=a+"";n?s=`${s}`:r&&(s=`${s}`);var c=Zr(s);if((n||r)&&(c=ve(c)),Ae(ve(c),c.lastChild),n||r)for(;ve(c);)i.before(ve(c));else i.before(c)}}else O&&Et()}))}function Fa(e,t,n,r,o){O&&Et();var i=t.$$slots?.[n],a=!1;!0===i&&(i=t.children,a=!0),void 0===i||i(e,a?()=>r:r)}const jn=[..." \t\n\r\f \v\ufeff"];function Ma(e,t,n){var r=""+e;if(n)for(var o in n)if(n[o])r=r?r+" "+o:o;else if(r.length)for(var i=o.length,a=0;(a=r.indexOf(o,a))>=0;){var l=a+i;0!==a&&!jn.includes(r[a-1])||l!==r.length&&!jn.includes(r[l])?a=l:r=(0===a?"":r.substring(0,a))+r.substring(l+1)}return""===r?null:r}function Va(e,t,n,r,o,i){var a=e.__className;if(O||a!==n||void 0===a){var l=Ma(n,r,i);(!O||l!==e.getAttribute("class"))&&(null==l?e.removeAttribute("class"):e.className=l),e.__className=n}else if(i&&o!==i)for(var s in i){var c=!!i[s];(null==o||c!==!!o[s])&&e.classList.toggle(s,c)}return i}const Ua=Symbol("is custom element"),ja=Symbol("is html");function qn(e){if(O){var t=!1,n=()=>{if(!t){if(t=!0,e.hasAttribute("value")){var n=e.value;R(e,"value",null),e.value=n}if(e.hasAttribute("checked")){var r=e.checked;R(e,"checked",null),e.checked=r}}};e.__on_r=n,_a(n),Do()}}function qa(e,t){var n=Fo(e);n.value===(n.value=t??void 0)||e.value===t&&(0!==t||"PROGRESS"!==e.nodeName)||(e.value=t??"")}function R(e,t,n,r){var o=Fo(e);O&&(o[t]=e.getAttribute(t),"src"===t||"srcset"===t||"href"===t&&"LINK"===e.nodeName)||o[t]!==(o[t]=n)&&("loading"===t&&(e[Ji]=n),null==n?e.removeAttribute(t):"string"!=typeof n&&Ba(e).includes(t)?e[t]=n:e.setAttribute(t,n))}function Fo(e){return e.__attributes??={[Ua]:e.nodeName.includes("-"),[ja]:e.namespaceURI===ji}}var Bn=new Map;function Ba(e){var t=Bn.get(e.nodeName);if(t)return t;Bn.set(e.nodeName,t=[]);for(var n,r=e,o=Element.prototype;o!==r;){for(var i in n=Hi(r))n[i].set&&t.push(i);r=Xn(r)}return t}function Ha(e,t,n=t){Ta(e,"change",(t=>{var r=t?e.defaultChecked:e.checked;n(r)})),(O&&e.defaultChecked!==e.checked||null==ot(t))&&n(e.checked),Hr((()=>{var n=t();e.checked=!!n}))}function Hn(e,t){return e===t||e?.[Ot]===t}function Qt(e={},t,n,r){return Br((()=>{var r,o;return Hr((()=>{r=o,o=[],ot((()=>{e!==n(...o)&&(t(e,...o),r&&Hn(n(...r),e)&&t(null,...r))}))})),()=>{Gr((()=>{o&&Hn(n(...o),e)&&t(null,...o)}))}})),e}function Mo(e){null===ne&&Jn(),Dr((()=>{const t=ot(e);if("function"==typeof t)return t}))}function Ga(e){null===ne&&Jn(),Mo((()=>()=>ot(e)))}function Vo(e,t,n){if(null==e)return t(void 0),yt;const r=ot((()=>e.subscribe(t,n)));return r.unsubscribe?()=>r.unsubscribe():r}const mt=[];function Wa(e,t=yt){let n=null;const r=new Set;function o(t){if(lo(e,t)&&(e=t,n)){const t=!mt.length;for(const t of r)t[1](),mt.push(t,e);if(t){for(let e=0;e{r.delete(s),0===r.size&&n&&(n(),n=null)}}}}function rr(e){let t;return Vo(e,(e=>t=e))(),t}let Uo,er=!1,Fr=Symbol();function Ya(e,t,n){const r=n[t]??={store:null,source:Yr(void 0),unsubscribe:yt};if(r.store!==e&&!(Fr in n))if(r.unsubscribe(),r.store=e??null,null==e)r.source.v=void 0,r.unsubscribe=yt;else{var o=!0;r.unsubscribe=Vo(e,(e=>{o?r.source.v=e:b(r.source,e)})),o=!1}return e&&Fr in n?rr(e):i(r.source)}function Za(){const e={};return[e,function(){qr((()=>{for(var t in e)e[t].unsubscribe();Mt(e,Fr,{enumerable:!1,value:!0})}))}]}function za(e){var t=er;try{return er=!1,[e(),er]}finally{er=t}}function Gn(e){return e.ctx?.d??!1}function x(e,t,n,r){var o,a=!!(1&n),l=!!(8&n),s=!!(16&n),c=!1;l?[o,c]=za((()=>e[t])):o=e[t];var u,f=Ot in e||ro in e,d=l&&(rt(e,t)?.set??(f&&t in e&&(n=>e[t]=n)))||void 0,h=r,v=!0,p=!1,g=()=>(p=!0,v&&(v=!1,h=s?ot(r):r),h);if(void 0===o&&void 0!==r&&(d&&ra(),o=g(),d&&d(o)),u=()=>{var n=e[t];return void 0===n?g():(v=!0,p=!1,n)},!(4&n))return u;if(d){var m=e.$$legacy;return function(e,t){return arguments.length>0?((!t||m||c)&&d(t?u():e),e):u()}}var y=!1,w=Yr(o),x=gr((()=>{var e=u(),t=i(w);return y?(y=!1,t):w.v=e}));return l&&i(x),a||(x.equals=jr),function(e,t){if(arguments.length>0){const n=t?i(x):l?Me(e):e;if(!x.equals(n)){if(y=!0,b(w,n),p&&void 0!==h&&(h=n),Gn(x))return e;ot((()=>i(x)))}return e}return Gn(x)?x.v:i(x)}}function Ja(e){return new Ka(e)}class Ka{#e;#t;constructor(e){var t=new Map,n=(e,n)=>{var r=Yr(n);return t.set(e,r),r};const r=new Proxy({...e.props||{},$$events:{}},{get:(e,r)=>i(t.get(r)??n(r,Reflect.get(e,r))),has:(e,r)=>r===ro||(i(t.get(r)??n(r,Reflect.get(e,r))),Reflect.has(e,r)),set:(e,r,o)=>(b(t.get(r)??n(r,o),o),Reflect.set(e,r,o))});this.#t=(e.hydrate?Pa:Po)(e.component,{target:e.target,anchor:e.anchor,props:r,context:e.context,intro:e.intro??!1,recover:e.recover}),(!e?.props?.$$host||!1===e.sync)&&E(),this.#e=r.$$events;for(const e of Object.keys(this.#t))"$set"===e||"$destroy"===e||"$on"===e||Mt(this,e,{get(){return this.#t[e]},set(t){this.#t[e]=t},enumerable:!0});this.#t.$set=e=>{Object.assign(r,e)},this.#t.$destroy=()=>{Oa(this.#t)}}$set(e){this.#t.$set(e)}$on(e,t){this.#e[e]=this.#e[e]||[];const n=(...e)=>t.call(this,...e);return this.#e[e].push(n),()=>{this.#e[e]=this.#e[e].filter((e=>e!==n))}}$destroy(){this.#t.$destroy()}}function nr(e,t,n,r){const o=n[e]?.type;if(t="Boolean"===o&&"boolean"!=typeof t?null!=t:t,!r||!n[e])return t;if("toAttribute"===r)switch(o){case"Object":case"Array":return null==t?null:JSON.stringify(t);case"Boolean":return t?"":null;case"Number":return t??null;default:return t}else switch(o){case"Object":case"Array":return t&&JSON.parse(t);case"Boolean":default:return t;case"Number":return null!=t?+t:t}}function Xa(e){const t={};return e.childNodes.forEach((e=>{t[e.slot||"default"]=!0})),t}function Qa(e,t,n,r,o,i){let a=class extends Uo{constructor(){super(e,n,o),this.$$p_d=t}static get observedAttributes(){return or(t).map((e=>(t[e].attribute||e).toLowerCase()))}};return or(t).forEach((e=>{Mt(a.prototype,e,{get(){return this.$$c&&e in this.$$c?this.$$c[e]:this.$$d[e]},set(n){n=nr(e,n,t),this.$$d[e]=n;var r=this.$$c;if(r){var o=rt(r,e)?.get;o?r[e]=n:r.$set({[e]:n})}}})})),r.forEach((e=>{Mt(a.prototype,e,{get(){return this.$$c?.[e]}})})),e.element=a,a}"function"==typeof HTMLElement&&(Uo=class extends HTMLElement{$$ctor;$$s;$$c;$$cn=!1;$$d={};$$r=!1;$$p_d={};$$l={};$$l_u=new Map;$$me;constructor(e,t,n){super(),this.$$ctor=e,this.$$s=t,n&&this.attachShadow({mode:"open"})}addEventListener(e,t,n){if(this.$$l[e]=this.$$l[e]||[],this.$$l[e].push(t),this.$$c){const n=this.$$c.$on(e,t);this.$$l_u.set(t,n)}super.addEventListener(e,t,n)}removeEventListener(e,t,n){if(super.removeEventListener(e,t,n),this.$$c){const e=this.$$l_u.get(t);e&&(e(),this.$$l_u.delete(t))}}async connectedCallback(){if(this.$$cn=!0,!this.$$c){let e=function(e){return t=>{const n=document.createElement("slot");"default"!==e&&(n.name=e),B(t,n)}};if(await Promise.resolve(),!this.$$cn||this.$$c)return;const t={},n=Xa(this);for(const r of this.$$s)r in n&&("default"!==r||this.$$d.children?t[r]=e(r):(this.$$d.children=e(r),t.default=!0));for(const e of this.attributes){const t=this.$$g_p(e.name);t in this.$$d||(this.$$d[t]=nr(t,e.value,this.$$p_d,"toProp"))}for(const e in this.$$p_d)!(e in this.$$d)&&void 0!==this[e]&&(this.$$d[e]=this[e],delete this[e]);this.$$c=Ja({component:this.$$ctor,target:this.shadowRoot||this,props:{...this.$$d,$$slots:t,$$host:this}}),this.$$me=ha((()=>{Hr((()=>{this.$$r=!0;for(const e of or(this.$$c)){if(!this.$$p_d[e]?.reflect)continue;this.$$d[e]=this.$$c[e];const t=nr(e,this.$$d[e],this.$$p_d,"toAttribute");null==t?this.removeAttribute(this.$$p_d[e].attribute||e):this.setAttribute(this.$$p_d[e].attribute||e,t)}this.$$r=!1}))}));for(const e in this.$$l)for(const t of this.$$l[e]){const n=this.$$c.$on(e,t);this.$$l_u.set(t,n)}this.$$l={}}}attributeChangedCallback(e,t,n){this.$$r||(e=this.$$g_p(e),this.$$d[e]=nr(e,n,this.$$p_d,"toProp"),this.$$c?.$set({[e]:this.$$d[e]}))}disconnectedCallback(){this.$$cn=!1,Promise.resolve().then((()=>{!this.$$cn&&this.$$c&&(this.$$c.$destroy(),this.$$me(),this.$$c=void 0)}))}$$g_p(e){return or(this.$$p_d).find((t=>this.$$p_d[t].attribute===e||!this.$$p_d[t].attribute&&t.toLowerCase()===e))||e}});const jo=new TextEncoder;function el(e){return[...new Uint8Array(e)].map((e=>e.toString(16).padStart(2,"0"))).join("")}async function tl(e,t="SHA-256",n=1e5){const r=Date.now().toString(16);e||(e=Math.round(Math.random()*n));return{algorithm:t,challenge:await qo(r,e,t),salt:r,signature:""}}async function qo(e,t,n){if(typeof crypto>"u"||!("subtle"in crypto)||!("digest"in crypto.subtle))throw new Error("Web Crypto is not available. Secure context is required (https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts).");return el(await crypto.subtle.digest(n.toUpperCase(),jo.encode(e+t)))}function rl(e,t,n="SHA-256",r=1e6,o=0){const i=new AbortController,a=Date.now();return{promise:(async()=>{for(let l=o;l<=r;l+=1){if(i.signal.aborted)return null;if(await qo(t,l,n)===e)return{number:l,took:Date.now()-a}}return null})(),controller:i}}function Wn(){try{return Intl.DateTimeFormat().resolvedOptions().timeZone}catch{}}function nl(e){const t=atob(e),n=new Uint8Array(t.length);for(let e=0;e{for(let e=r;e<=n;e+=1){if(i.signal.aborted||!l||!s)return null;try{const t=await crypto.subtle.decrypt({name:o,iv:ol(e)},l,s);if(t)return{clearText:(new TextDecoder).decode(t),took:Date.now()-a}}catch{}}return null})(),controller:i}}var y=(e=>(e.CODE="code",e.ERROR="error",e.VERIFIED="verified",e.VERIFYING="verifying",e.UNVERIFIED="unverified",e.EXPIRED="expired",e))(y||{}),Q=(e=>(e.ERROR="error",e.LOADING="loading",e.PLAYING="playing",e.PAUSED="paused",e.READY="ready",e))(Q||{});globalThis.altchaPlugins=globalThis.altchaPlugins||[],globalThis.altchaI18n=globalThis.altchaI18n||{get:e=>rr(globalThis.altchaI18n.store)[e],set:(e,t)=>{Object.assign(rr(globalThis.altchaI18n.store),{[e]:t}),globalThis.altchaI18n.store.set(rr(globalThis.altchaI18n.store))},store:Wa({})};const al={ariaLinkLabel:"Visit Altcha.org",enterCode:"Enter code",enterCodeAria:"Enter code you hear. Press Space to play audio.",error:"Verification failed. Try again later.",expired:"Verification expired. Try again.",footer:'Protected by ALTCHA',getAudioChallenge:"Get an audio challenge",label:"I'm not a robot",loading:"Loading...",reload:"Reload",verify:"Verify",verificationRequired:"Verification required!",verified:"Verified",verifying:"Verifying...",waitAlert:"Verifying... please wait."};globalThis.altchaI18n.set("en",al);const $r=(e,t)=>{let n=ua((()=>Yi(t?.(),24)));var r=cl();Ce((()=>{R(r,"width",i(n)),R(r,"height",i(n))})),B(e,r)};function ll(e,t){"Space"===e.code&&(e.preventDefault(),e.stopImmediatePropagation(),t())}function sl(e,t){e.preventDefault(),t()}function ul(e,t,n,r,o,a,l,s){[y.UNVERIFIED,y.ERROR,y.EXPIRED,y.CODE].includes(i(t))?!1!==n()&&!1===i(r)?.reportValidity()?b(o,!1):a()?l():s():b(o,!0)}var cl=_r(''),fl=be(''),dl=be('
'),hl=_r(''),vl=_r(''),gl=_r(''),pl=be(''),ml=be(""),_l=be(''),bl=be("
"),yl=be("
"),wl=be('
'),El=be(''),xl=be('
'),Cl=be('
',1);function kl(e,t){$o(t,!0);const[n,r]=Za(),o=()=>Ya(X,"$altchaI18nStore",n);let a=x(t,"auto",7,void 0),l=x(t,"blockspam",7,void 0),s=x(t,"challengeurl",7,void 0),c=x(t,"challengejson",7,void 0),u=x(t,"credentials",7,void 0),f=x(t,"customfetch",7,void 0),d=x(t,"debug",7,!1),h=x(t,"delay",7,0),v=x(t,"disableautofocus",7,!1),p=x(t,"refetchonexpire",7,!0),g=x(t,"disablerefetchonexpire",23,(()=>!p())),m=x(t,"expire",7,void 0),w=x(t,"floating",7,void 0),$=x(t,"floatinganchor",7,void 0),C=x(t,"floatingoffset",7,void 0),_=x(t,"floatingpersist",7,!1),k=x(t,"hidefooter",7,!1),A=x(t,"hidelogo",7,!1),I=x(t,"id",7,void 0),S=x(t,"language",7,void 0),L=x(t,"name",7,"altcha"),O=x(t,"maxnumber",7,1e6),P=x(t,"mockerror",7,!1),D=x(t,"obfuscated",7,void 0),V=x(t,"overlay",7,void 0),M=x(t,"overlaycontent",7,void 0),j=x(t,"plugins",7,void 0),T=x(t,"sentinel",7,void 0),F=x(t,"spamfilter",7,!1),U=x(t,"strings",7,void 0),q=x(t,"test",7,!1),H=x(t,"verifyurl",7,void 0),G=x(t,"workers",23,(()=>Math.min(16,navigator.hardwareConcurrency||8))),W=x(t,"workerurl",7,void 0);const{altchaI18n:Y}=globalThis,X=Y.store,ee=["SHA-256","SHA-384","SHA-512"],te=(e,n)=>{t.$$host.dispatchEvent(new CustomEvent(e,{detail:n}))},ne=document.documentElement.lang?.split("-")?.[0],re=Lt((()=>s()&&new URL(s(),location.origin).host.endsWith(".altcha.org")&&!!s()?.includes("apiKey=ckey_"))),oe=Lt((()=>c()?Ye(c()):void 0)),ie=Lt((()=>U()?Ye(U()):{})),ae=Lt((()=>({...Se(o()),...i(ie)}))),le=Lt((()=>`${I()||L()}_checkbox_${Math.round(1e8*Math.random())}`));let se=N(null),ce=N(!1),ue=N(null),fe=N(Me(y.UNVERIFIED)),de=N(void 0),he=N(null),ve=N(null),pe=N(null),ge=N(null),be=N(null),me=N(null),ye=N(null),we=N(null),xe=null,$e=N(null),Ee=N(!1),Re=[],_e=N(!1),ke=N(null);function Ae(e,t){return btoa(JSON.stringify({algorithm:e.algorithm,challenge:e.challenge,number:t.number,salt:e.salt,signature:e.signature,test:!!q()||void 0,took:t.took}))}function Ne(){s()&&!g()&&i(fe)===y.VERIFIED?wt():gt(y.EXPIRED,i(ae).expired)}function Ie(){let e=fetch;if(f())if(Pe("using customfetch"),"string"==typeof f()){if(e=globalThis[f()]||null,!e)throw new Error(`Custom fetch function not found: ${f()}`)}else e=f();return e}function Se(e,t=[S()||"",document.documentElement.lang||"",...navigator.languages]){const n=Object.keys(e).map((e=>e.toLowerCase())),r=t.reduce(((t,r)=>(r=r.toLowerCase(),t||(e[r]?r:null)||n.find((e=>r.split("-")[0]===e.split("-")[0]))||null)),null);return e[r||"en"]}function Le(e){return[...i(me)?.querySelectorAll(e?.length?e.map((e=>`input[name="${e}"]`)).join(", "):'input[type="text"]:not([data-no-spamfilter]), textarea:not([data-no-spamfilter])')||[]].reduce(((e,t)=>{const n=t.name,r=t.value;return n&&r&&(e[n]=/\n/.test(r)?r.replace(new RegExp("(?e instanceof Error)))&&console[e[0]instanceof Error?"error":"log"]("ALTCHA",`[name=${L()}]`,...e)}function De(){b($e,Q.PAUSED,!0)}function Ve(e){b($e,Q.ERROR,!0)}function Be(){b($e,Q.READY,!0)}function je(){b($e,Q.LOADING,!0)}function Te(){b($e,Q.PLAYING,!0)}function Ue(){b($e,Q.PAUSED,!0)}function qe(e){if(e.preventDefault(),e.stopPropagation(),i(ue)){const t=new FormData(e.target),n=String(t.get("code"));if(H()?.startsWith("fn:")){const e=H().replace(/^fn:/,"");if(Pe(`calling ${e} function instead of verifyurl`),!(e in globalThis))throw new Error(`Global function "${e}" is undefined.`);return globalThis[e]({challenge:i(ue).challenge,code:n,solution:i(ue).solution})}b(Ee,!0),nt(Ae(i(ue).challenge,i(ue).solution),n).then((({reason:e,verified:t})=>{t?(b(ue,null),mt(y.VERIFIED),Pe("verified"),Rr().then((()=>{i(ge)?.focus(),te("verified",{payload:i(ke)}),"onsubmit"===a()?rt(i(ye)):V()&&vt()}))):(gt(),b(we,e||"Verification failed",!0))})).catch((e=>{b(ue,null),mt(y.ERROR,e),Pe("sentinel verification failed:",e)})).finally((()=>{b(Ee,!1)}))}}function Ze(e){const t=e.target;w()&&t&&!i(de).contains(t)&&(i(fe)===y.VERIFIED&&!1===_()||i(fe)===y.VERIFIED&&"focus"===_()&&!i(me)?.matches(":focus-within")||"off"===a()&&i(fe)===y.UNVERIFIED)&&vt()}function ze(){w()&&i(fe)!==y.UNVERIFIED&&pt()}function He(e){i(fe)===y.UNVERIFIED?wt():w()&&"focus"===_()&&i(fe)===y.VERIFIED&&yt()}function Ge(e){e.target?.hasAttribute("data-code-challenge-form")||(b(ye,e.submitter,!0),i(me)&&"onsubmit"===a()?(i(ye)?.blur(),i(fe)===y.UNVERIFIED?(e.preventDefault(),e.stopPropagation(),wt().then((()=>{rt(i(ye))}))):i(fe)!==y.VERIFIED&&(e.preventDefault(),e.stopPropagation(),i(fe)===y.VERIFYING&&Ke())):i(me)&&w()&&"off"===a()&&i(fe)===y.UNVERIFIED&&(e.preventDefault(),e.stopPropagation(),yt()))}function Je(){gt()}function Ke(){i(fe)===y.VERIFYING&&i(ae).waitAlert&&alert(i(ae).waitAlert)}function We(){i(ve)?i(ve).paused?(i(ve).currentTime=0,i(ve).play()):i(ve).pause():(b(_e,!0),requestAnimationFrame((()=>{i(ve)?.play()})))}function Qe(){w()&&pt()}function Ye(e){return JSON.parse(e)}function Xe(e){const t=new URLSearchParams(e.split("?")?.[1]),n=t.get("expires")||t.get("expire");if(n){const e=new Date(1e3*+n),t=isNaN(e.getTime())?0:e.getTime()-Date.now();t>0&&ot(t)}else xe&&(clearTimeout(xe),xe=null)}async function et(e){if(!H())throw new Error("Attribute verifyurl not set.");Pe("requesting server verification from",H());const t={payload:e};if(!1!==F()){const{blockedCountries:e,classifier:n,disableRules:r,email:o,expectedLanguages:a,expectedCountries:l,fields:s,ipAddress:c,text:u,timeZone:f}="ipAddress"===F()?{blockedCountries:void 0,classifier:void 0,disableRules:void 0,email:!1,expectedCountries:void 0,expectedLanguages:void 0,fields:!1,ipAddress:void 0,text:void 0,timeZone:void 0}:"object"==typeof F()?F():{blockedCountries:void 0,classifier:void 0,disableRules:void 0,email:void 0,expectedCountries:void 0,expectedLanguages:void 0,fields:void 0,ipAddress:void 0,text:void 0,timeZone:void 0};t.blockedCountries=e,t.classifier=n,t.disableRules=r,t.email=!1===o?void 0:function(e){const t=i(me)?.querySelector("string"==typeof e?`input[name="${e}"]`:'input[type="email"]:not([data-no-spamfilter])');return t?.value?.slice(t.value.indexOf("@"))||void 0}(o),t.expectedCountries=l,t.expectedLanguages=a||(ne?[ne]:void 0),t.fields=!1===s?void 0:Le(s),t.ipAddress=!1===c?void 0:c||"auto",t.text=u,t.timeZone=!1===f?void 0:f||Wn()}const n=await Ie()(H(),{body:JSON.stringify(t),headers:{"content-type":"application/json"},method:"POST"});if(!(n&&n instanceof Response))throw new Error("Custom fetch function did not return a response.");if(200!==n.status)throw new Error(`Server responded with ${n.status}.`);const r=await n.json();if(r?.payload&&b(ke,r.payload,!0),te("serververification",r),l()&&"BAD"===r.classification)throw new Error("SpamFilter returned negative classification.")}async function nt(e,t){if(!H())throw new Error("Attribute verifyurl not set.");Pe("requesting sentinel verification from",H());const n={code:t,payload:e};T()&&(n.fields=T().fields?Le():void 0,n.timeZone=T().timeZone?Wn():void 0);const r=await Ie()(H(),{body:JSON.stringify(n),headers:{"content-type":"application/json"},method:"POST"});if(!(r&&r instanceof Response))throw new Error("Fetch function did not return a response.");if(200!==r.status)throw new Error(`Server responded with ${r.status}.`);const o=await r.json();return o?.payload&&b(ke,o.payload,!0),te("sentinelverification",o),o}function rt(e){i(me)&&"requestSubmit"in i(me)?i(me).requestSubmit(e):i(me)?.reportValidity()&&(e?e.click():i(me).submit())}function ot(e){Pe("expire",e),xe&&(clearTimeout(xe),xe=null),e<1?Ne():xe=setTimeout(Ne,e)}function it(e){Pe("floating",e),w()!==e&&(i(de).style.left="",i(de).style.top=""),w(!0===e||""===e?"auto":!1===e||"false"===e?void 0:w()),w()?(a()||a("onsubmit"),document.addEventListener("scroll",ze),document.addEventListener("click",Ze),window.addEventListener("resize",Qe)):"onsubmit"===a()&&a(void 0)}function at(e){if(Pe("overlay",e),V(e),e){if(a()||a("onsubmit"),i(pe)&&i(de).parentElement&&i(pe).replaceWith(i(de).parentElement),i(de)?.parentElement?.parentElement){b(pe,document.createElement("div"),!0),i(de).parentElement.parentElement.appendChild(i(pe));const e=document.createElement("div"),t=document.createElement("button");t.type="button",t.innerHTML="×",t.addEventListener("click",(e=>{e.preventDefault(),gt()})),i(pe).classList.add("altcha-overlay-backdrop"),t.classList.add("altcha-overlay-close-button"),e.classList.add("altcha-overlay"),i(pe).append(e),e.append(t),M()&&e.append(...document.querySelectorAll(M())),e.append(i(de).parentElement)}}else i(pe)&&i(de).parentElement&&(i(pe).replaceWith(i(de).parentElement),i(de).style.display="block")}function lt(e){if(!e.algorithm)throw new Error("Invalid challenge. Property algorithm is missing.");if(void 0===e.signature)throw new Error("Invalid challenge. Property signature is missing.");if(!ee.includes(e.algorithm.toUpperCase()))throw new Error(`Unknown algorithm value. Allowed values: ${ee.join(", ")}`);if(!e.challenge||e.challenge.length<40)throw new Error("Challenge is too short. Min. 40 chars.");if(!e.salt||e.salt.length<10)throw new Error("Salt is too short. Min. 10 chars.")}async function st(e){let t=null,n=null;if("Worker"in window){try{t=function(e,t=("number"==typeof q()?q():e.maxNumber||e.maxnumber||O()),n=Math.ceil(G())){const r=new AbortController,o=[];n=Math.min(16,t,Math.max(1,n));for(let e=0;e{const t=await Promise.all(o.map(((t,n)=>{const a=n*i;return r.signal.addEventListener("abort",(()=>{t.postMessage({type:"abort"})})),new Promise((n=>{t.addEventListener("message",(e=>{if(e.data)for(const e of o)e!==t&&e.postMessage({type:"abort"});n(e.data)})),t.postMessage({payload:e,max:a+i,start:a,type:"work"})}))})));for(const e of o)e.terminate();return t.find((e=>!!e))||null})(),controller:r}}(e,e.maxNumber||e.maxnumber||O()),b(se,t.controller,!0),n=await t.promise}catch(e){Pe(e)}finally{b(se,null)}if(null===n||void 0!==n?.number||"obfuscated"in e)return{data:e,solution:n}}if("obfuscated"in e){const t=await il(e.obfuscated,e.key,e.maxNumber||e.maxnumber);return{data:e,solution:await t.promise}}t=rl(e.challenge,e.salt,e.algorithm,e.maxNumber||e.maxnumber||O()),b(se,t.controller,!0);try{n=await t.promise}catch(e){Pe(e)}finally{b(se,null)}return{data:e,solution:n}}async function ct(){if(!D())return void mt(y.ERROR);const e=Re.find((e=>"obfuscation"===e.constructor.pluginName));return e&&"clarify"in e?"clarify"in e&&"function"==typeof e.clarify?e.clarify():void 0:(mt(y.ERROR),void Pe("Plugin `obfuscation` not found. Import `altcha/plugins/obfuscation` to load it."))}function ut(e){void 0!==e.obfuscated&&D(e.obfuscated),void 0!==e.auto&&(a(e.auto),"onload"===a()&&(D()?ct():wt())),void 0!==e.blockspam&&l(!!e.blockspam),void 0!==e.customfetch&&f(e.customfetch),void 0!==e.floatinganchor&&$(e.floatinganchor),void 0!==e.delay&&h(e.delay),void 0!==e.floatingoffset&&C(e.floatingoffset),void 0!==e.floating&&it(e.floating),void 0!==e.expire&&(ot(e.expire),m(e.expire)),e.challenge&&(c("string"==typeof e.challenge?e.challenge:JSON.stringify(e.challenge)),lt(i(oe))),void 0!==e.challengeurl&&s(e.challengeurl),void 0!==e.debug&&d(!!e.debug),void 0!==e.hidefooter&&k(!!e.hidefooter),void 0!==e.hidelogo&&A(!!e.hidelogo),void 0!==e.language&&U(Se(o(),[e.language])),void 0!==e.maxnumber&&O(+e.maxnumber),void 0!==e.mockerror&&P(!!e.mockerror),void 0!==e.name&&L(e.name),void 0!==e.overlaycontent&&M(e.overlaycontent),void 0!==e.overlay&&at(e.overlay),void 0!==e.refetchonexpire&&g(!e.refetchonexpire),void 0!==e.disablerefetchonexpire&&g(!e.disablerefetchonexpire),void 0!==e.sentinel&&"object"==typeof e.sentinel&&T(e.sentinel),void 0!==e.spamfilter&&F("object"==typeof e.spamfilter?e.spamfilter:!!e.spamfilter),e.strings&&U("string"==typeof e.strings?e.strings:JSON.stringify(e.strings)),void 0!==e.test&&q("number"==typeof e.test?e.test:!!e.test),void 0!==e.verifyurl&&H(e.verifyurl),void 0!==e.workers&&G(+e.workers),void 0!==e.workerurl&&W(e.workerurl)}function ft(){return{auto:a(),blockspam:l(),challengeurl:s(),debug:d(),delay:h(),disableautofocus:v(),disablerefetchonexpire:g(),expire:m(),floating:w(),floatinganchor:$(),floatingoffset:C(),hidefooter:k(),hidelogo:A(),name:L(),maxnumber:O(),mockerror:P(),obfuscated:D(),overlay:V(),refetchonexpire:!g(),spamfilter:F(),strings:i(ae),test:q(),verifyurl:H(),workers:G(),workerurl:W()}}function dt(){return i(be)}function ht(){return i(fe)}function vt(){i(de).style.display="none",V()&&i(pe)&&(i(pe).style.display="none")}function pt(e=20){if(i(de))if(i(be)||b(be,($()?document.querySelector($()):i(me)?.querySelector('input[type="submit"], button[type="submit"], button:not([type="button"]):not([type="reset"])'))||i(me),!0),i(be)){const t=parseInt(C(),10)||12,n=i(be).getBoundingClientRect(),r=i(de).getBoundingClientRect(),o=document.documentElement.clientHeight,a=document.documentElement.clientWidth,l="auto"===w()?n.bottom+r.height+t+e>o:"top"===w(),s=Math.max(e,Math.min(a-e-r.width,n.left+n.width/2-r.width/2));if(i(de).style.top=l?n.top-(r.height+t)+"px":`${n.bottom+t}px`,i(de).style.left=`${s}px`,i(de).setAttribute("data-floating",l?"top":"bottom"),i(he)){const e=i(he).getBoundingClientRect();i(he).style.left=n.left-s+n.width/2-e.width/2+"px"}}else Pe("unable to find floating anchor element")}function gt(e=y.UNVERIFIED,t=null){i(se)&&(i(se).abort(),b(se,null)),b(ce,!1),b(ke,null),b(ue,null),b(_e,!1),b($e,null),mt(e,t)}function bt(e){b(be,e,!0)}function mt(e,t=null){b(fe,e,!0),b(we,t,!0),te("statechange",{payload:i(ke),state:i(fe)})}function yt(){i(de).style.display="block",w()&&pt(),V()&&i(pe)&&(i(pe).style.display="flex")}async function wt(){return gt(y.VERIFYING),await new Promise((e=>setTimeout(e,h()||0))),async function(){if(P())throw Pe("mocking error"),new Error("Mocked error.");if(i(oe))return Pe("using provided json data"),Xe(i(oe).salt),i(oe);if(q())return Pe("generating test challenge",{test:q()}),tl("boolean"!=typeof q()?+q():void 0);{if(!s()&&i(me)){const e=i(me).getAttribute("action");e?.includes("/form/")&&s(e+"/altcha")}if(!s())throw new Error("Attribute challengeurl not set.");Pe("fetching challenge from",s());const e={credentials:"boolean"==typeof u()?"include":u(),headers:!1!==F()?{"x-altcha-spam-filter":"1"}:{}},t=await Ie()(s(),e);if(!(t&&t instanceof Response))throw new Error("Custom fetch function did not return a response.");if(200!==t.status)throw new Error(`Server responded with ${t.status}.`);const n=t.headers.get("X-Altcha-Config"),r=await t.json();if(Xe(r.salt),n)try{const e=JSON.parse(n);e&&"object"==typeof e&&(e.verifyurl&&!e.verifyurl.startsWith("fn:")&&(e.verifyurl=Oe(e.verifyurl)),ut(e))}catch(e){Pe("unable to configure from X-Altcha-Config",e)}return r}}().then((e=>(lt(e),Pe("challenge",e),st(e)))).then((({data:e,solution:t})=>{if(Pe("solution",t),!t||e&&"challenge"in e&&!("clearText"in t))if(void 0!==t?.number&&"challenge"in e)if(H()&&"codeChallenge"in e)["INPUT","BUTTON","SELECT","TEXTAREA"].includes(document.activeElement?.tagName||"")&&!1===v()&&document.activeElement.blur(),b(ue,{challenge:e,solution:t},!0);else{if(H()&&void 0!==T())return nt(Ae(e,t));if(H())return et(Ae(e,t));b(ke,Ae(e,t),!0),Pe("payload",i(ke))}else if(i(fe)!==y.EXPIRED)throw Pe("Unable to find a solution. Ensure that the 'maxnumber' attribute is greater than the randomly generated number."),new Error("Unexpected result returned.")})).then((()=>{i(ue)?(mt(y.CODE),Rr().then((()=>{te("code",{codeChallenge:i(ue)})}))):i(ke)&&(mt(y.VERIFIED),Pe("verified"),Rr().then((()=>{te("verified",{payload:i(ke)}),V()&&vt()})))})).catch((e=>{Pe(e),mt(y.ERROR,e.message)}))}Dr((()=>{!function(){for(const e of Re)"function"==typeof e.onErrorChange&&e.onErrorChange(i(we))}(i(we))})),Dr((()=>{!function(){for(const e of Re)"function"==typeof e.onStateChange&&e.onStateChange(i(fe));w()&&i(fe)!==y.UNVERIFIED&&requestAnimationFrame((()=>{pt()})),b(ce,i(fe)===y.VERIFIED),V()&&i(pe)&&(i(fe)!==y.UNVERIFIED?yt():vt())}(i(fe))})),Ga((()=>{(function(){for(const e of Re)e.destroy()})(),b(ye,null),i(me)&&(i(me).removeEventListener("submit",Ge),i(me).removeEventListener("reset",Je),i(me).removeEventListener("focusin",He),b(me,null)),xe&&(clearTimeout(xe),xe=null),document.removeEventListener("click",Ze),document.removeEventListener("scroll",ze),window.removeEventListener("resize",Qe)})),Mo((()=>{Pe("mounted","2.2.4"),Pe("workers",G()),function(){const e=void 0!==j()?j().split(","):void 0;for(const t of globalThis.altchaPlugins)(!e||e.includes(t.pluginName))&&Re.push(new t({el:i(de),clarify:ct,dispatch:te,getConfiguration:ft,getFloatingAnchor:dt,getState:ht,log:Pe,reset:gt,solve:st,setState:mt,setFloatingAnchor:bt,verify:wt}))}(),Pe("plugins",Re.length?Re.map((e=>e.constructor.pluginName)).join(", "):"none"),q()&&Pe("using test mode"),m()&&ot(m()),void 0!==a()&&Pe("auto",a()),void 0!==w()&&it(w()),b(me,i(de)?.closest("form"),!0),i(me)&&(i(me).addEventListener("submit",Ge,{capture:!0}),i(me).addEventListener("reset",Je),("onfocus"===a()||"focus"===_())&&i(me).addEventListener("focusin",He)),V()&&at(!0),"onload"===a()&&(D()?ct():wt()),i(re)&&(k()||A())&&Pe("Attributes hidefooter and hidelogo ignored because usage with free API Keys requires attribution."),requestAnimationFrame((()=>{te("load")}))}));var xt=Cl(),$t=Nt(xt);Fa($t,t,"default",{});var Et=J($t,2),Ct=z(Et),Rt=z(Ct);let _t;var kt=z(Rt),At=e=>{$r(e)};K(kt,(e=>{i(fe)===y.VERIFYING&&e(At)}));var It=J(kt,2);qn(It),It.__change=[ul,fe,F,me,ce,D,ct,wt],Qt(It,(e=>b(ge,e)),(()=>i(ge))),Z(Rt);var St=J(Rt,2),Ot=z(St),Pt=e=>{var t=Xt();tt(Nt(t),(()=>i(ae).verified)),B(e,t)},Dt=(e,t)=>{var n=e=>{var t=Xt();tt(Nt(t),(()=>i(ae).verifying)),B(e,t)},r=(e,t)=>{var n=e=>{var t=Xt();tt(Nt(t),(()=>i(ae).verificationRequired)),B(e,t)},r=e=>{var t=Xt();tt(Nt(t),(()=>i(ae).label)),B(e,t)};K(e,(e=>{i(fe)===y.CODE?e(n):e(r,!1)}),t)};K(e,(e=>{i(fe)===y.VERIFYING?e(n):e(r,!1)}),t)};K(Ot,(e=>{i(fe)===y.VERIFIED?e(Pt):e(Dt,!1)})),Z(St);var Vt=J(St,2),Bt=e=>{var t=fl();qn(t),Ce((()=>{R(t,"name",L()),qa(t,i(ke))})),B(e,t)};K(Vt,(e=>{i(fe)===y.VERIFIED&&e(Bt)}));var Mt=J(Vt,2),jt=e=>{var t=dl(),n=z(t);R(n,"href","https://altcha.org/"),Z(t),Ce((()=>R(n,"aria-label",i(ae).ariaLinkLabel))),B(e,t)};K(Mt,(e=>{(!0!==A()||i(re))&&e(jt)}));var Tt=J(Mt,2),Ft=e=>{var t=_l(),n=J(z(t),2),r=z(n),o=J(r,2);Sa(o,!v()),o.__keydown=[ll,We];var a=J(o,2),l=z(a),s=z(l),c=e=>{var t=pl();t.__click=We;var n=z(t),r=e=>{$r(e,(()=>20))},o=(e,t)=>{var n=e=>{B(e,hl())},r=(e,t)=>{var n=e=>{B(e,vl())},r=e=>{B(e,gl())};K(e,(e=>{i($e)===Q.PLAYING?e(n):e(r,!1)}),t)};K(e,(e=>{i($e)===Q.ERROR?e(n):e(r,!1)}),t)};K(n,(e=>{i($e)===Q.LOADING?e(r):e(o,!1)})),Z(t),Ce((()=>{R(t,"title",i(ae).getAudioChallenge),t.disabled=i($e)===Q.LOADING||i($e)===Q.ERROR||i(Ee),R(t,"aria-label",i($e)===Q.LOADING?i(ae).loading:i(ae).getAudioChallenge)})),B(e,t)};K(s,(e=>{i(ue).challenge.codeChallenge.audio&&e(c)}));var u=J(s,2);u.__click=[sl,wt],Z(l);var f=J(l,2),d=z(f),h=e=>{$r(e,(()=>16))};K(d,(e=>{i(Ee)&&e(h)}));var p=J(d);Z(f),Z(a);var g=J(a,2),m=e=>{var t=ml(),n=z(t);Z(t),Qt(t,(e=>b(ve,e)),(()=>i(ve))),Ce((e=>R(n,"src",e)),[()=>Oe(i(ue).challenge.codeChallenge.audio,{language:S()})]),Fe("loadstart",t,je),Fe("canplay",t,Be),Fe("pause",t,Ue),Fe("playing",t,Te),Fe("ended",t,De),Fe("error",n,Ve),B(e,t)};K(g,(e=>{i(ue).challenge.codeChallenge.audio&&i(_e)&&e(m)})),Z(n),Z(t),Ce((()=>{R(t,"aria-label",i(ae).verificationRequired),R(r,"src",i(ue).challenge.codeChallenge.image),R(o,"minlength",i(ue).challenge.codeChallenge.length||1),R(o,"maxlength",i(ue).challenge.codeChallenge.length),R(o,"placeholder",i(ae).enterCode),R(o,"aria-label",i($e)===Q.LOADING?i(ae).loading:i($e)===Q.PLAYING?"":i(ae).enterCodeAria),R(o,"aria-live",i($e)?"assertive":"polite"),R(o,"aria-busy",i($e)===Q.LOADING),o.disabled=i(Ee),R(u,"aria-label",i(ae).reload),R(u,"title",i(ae).reload),u.disabled=i(Ee),f.disabled=i(Ee),R(f,"aria-label",i(ae).verify),La(p,` ${i(ae).verify??""}`)})),Fe("submit",n,qe,!0),B(e,t)};K(Tt,(e=>{i(ue)?.challenge.codeChallenge&&e(Ft)})),Z(Ct);var Ut=J(Ct,2),qt=e=>{var t=wl(),n=J(z(t),2),r=e=>{var t=bl();tt(z(t),(()=>i(ae).expired)),Z(t),Ce((()=>R(t,"title",i(we)))),B(e,t)},o=e=>{var t=yl();tt(z(t),(()=>i(ae).error)),Z(t),Ce((()=>R(t,"title",i(we)))),B(e,t)};K(n,(e=>{i(fe)===y.EXPIRED?e(r):e(o,!1)})),Z(t),B(e,t)};K(Ut,(e=>{(i(we)||i(fe)===y.EXPIRED)&&e(qt)}));var Zt=J(Ut,2),zt=e=>{var t=El(),n=z(t);tt(z(n),(()=>i(ae).footer)),Z(n),Z(t),B(e,t)};K(Zt,(e=>{i(ae).footer&&(!0!==k()||i(re))&&e(zt)}));var Ht=J(Zt,2),Gt=e=>{var t=xl();Qt(t,(e=>b(he,e)),(()=>i(he))),B(e,t)};K(Ht,(e=>{w()&&e(Gt)})),Z(Et),Qt(Et,(e=>b(de,e)),(()=>i(de))),Ce((e=>{R(Et,"data-state",i(fe)),R(Et,"data-floating",w()),R(Et,"data-overlay",V()),_t=Va(Rt,1,"altcha-checkbox",null,_t,e),R(It,"id",i(le)),It.required="onsubmit"!==a()&&(!w()||"off"!==a()),R(St,"for",i(le))}),[()=>({"altcha-checkbox-verifying":i(fe)===y.VERIFYING})]),Fe("invalid",It,Ke),Ha(It,(()=>i(ce)),(e=>b(ce,e))),B(e,xt);var Jt=So({clarify:ct,configure:ut,getConfiguration:ft,getFloatingAnchor:dt,getPlugin:function(e){return Re.find((t=>t.constructor.pluginName===e))},getState:ht,hide:vt,repositionFloating:pt,reset:gt,setFloatingAnchor:bt,setState:mt,show:yt,verify:wt,get auto(){return a()},set auto(e=void 0){a(e),E()},get blockspam(){return l()},set blockspam(e=void 0){l(e),E()},get challengeurl(){return s()},set challengeurl(e=void 0){s(e),E()},get challengejson(){return c()},set challengejson(e=void 0){c(e),E()},get credentials(){return u()},set credentials(e=void 0){u(e),E()},get customfetch(){return f()},set customfetch(e=void 0){f(e),E()},get debug(){return d()},set debug(e=!1){d(e),E()},get delay(){return h()},set delay(e=0){h(e),E()},get disableautofocus(){return v()},set disableautofocus(e=!1){v(e),E()},get refetchonexpire(){return p()},set refetchonexpire(e=!0){p(e),E()},get disablerefetchonexpire(){return g()},set disablerefetchonexpire(e=!p){g(e),E()},get expire(){return m()},set expire(e=void 0){m(e),E()},get floating(){return w()},set floating(e=void 0){w(e),E()},get floatinganchor(){return $()},set floatinganchor(e=void 0){$(e),E()},get floatingoffset(){return C()},set floatingoffset(e=void 0){C(e),E()},get floatingpersist(){return _()},set floatingpersist(e=!1){_(e),E()},get hidefooter(){return k()},set hidefooter(e=!1){k(e),E()},get hidelogo(){return A()},set hidelogo(e=!1){A(e),E()},get id(){return I()},set id(e=void 0){I(e),E()},get language(){return S()},set language(e=void 0){S(e),E()},get name(){return L()},set name(e="altcha"){L(e),E()},get maxnumber(){return O()},set maxnumber(e=1e6){O(e),E()},get mockerror(){return P()},set mockerror(e=!1){P(e),E()},get obfuscated(){return D()},set obfuscated(e=void 0){D(e),E()},get overlay(){return V()},set overlay(e=void 0){V(e),E()},get overlaycontent(){return M()},set overlaycontent(e=void 0){M(e),E()},get plugins(){return j()},set plugins(e=void 0){j(e),E()},get sentinel(){return T()},set sentinel(e=void 0){T(e),E()},get spamfilter(){return F()},set spamfilter(e=!1){F(e),E()},get strings(){return U()},set strings(e=void 0){U(e),E()},get test(){return q()},set test(e=!1){q(e),E()},get verifyurl(){return H()},set verifyurl(e=void 0){H(e),E()},get workers(){return G()},set workers(e=Math.min(16,navigator.hardwareConcurrency||8)){G(e),E()},get workerurl(){return W()},set workerurl(e=void 0){W(e),E()}});return r(),Jt}Na(["change","keydown","click"]),customElements.define("altcha-widget",Qa(kl,{blockspam:{type:"Boolean"},debug:{type:"Boolean"},delay:{type:"Number"},disableautofocus:{type:"Boolean"},disablerefetchonexpire:{type:"Boolean"},expire:{type:"Number"},floatingoffset:{type:"Number"},hidefooter:{type:"Boolean"},hidelogo:{type:"Boolean"},maxnumber:{type:"Number"},mockerror:{type:"Boolean"},refetchonexpire:{type:"Boolean"},test:{type:"Boolean"},workers:{type:"Number"},auto:{},challengeurl:{},challengejson:{},credentials:{},customfetch:{},floating:{},floatinganchor:{},floatingpersist:{},id:{},language:{},name:{},obfuscated:{},overlay:{},overlaycontent:{},plugins:{},sentinel:{},spamfilter:{},strings:{},verifyurl:{},workerurl:{}},["default"],["clarify","configure","getConfiguration","getFloatingAnchor","getPlugin","getState","hide","repositionFloating","reset","setFloatingAnchor","setState","show","verify"],!1));const Bo='@keyframes overlay-slidein{to{opacity:1;top:50%}}@keyframes altcha-spinner{to{transform:rotate(360deg)}}.altcha{background:var(--altcha-color-base, transparent);border:var(--altcha-border-width, 1px) solid var(--altcha-color-border, #a0a0a0);border-radius:var(--altcha-border-radius, 3px);color:var(--altcha-color-text, currentColor);display:flex;flex-direction:column;max-width:var(--altcha-max-width, 260px);position:relative}.altcha:focus-within{border-color:var(--altcha-color-border-focus, currentColor)}.altcha[data-floating]{background:var(--altcha-color-base, white);display:none;filter:drop-shadow(3px 3px 6px rgba(0,0,0,.2));left:-100%;position:fixed;top:-100%;width:var(--altcha-max-width, 260px);z-index:999999}.altcha[data-floating=top] .altcha-anchor-arrow{border-bottom-color:transparent;border-top-color:var(--altcha-color-border, #a0a0a0);bottom:-12px;top:auto}.altcha[data-floating=bottom]:focus-within::after{border-bottom-color:var(--altcha-color-border-focus, currentColor)}.altcha[data-floating=top]:focus-within::after{border-top-color:var(--altcha-color-border-focus, currentColor)}.altcha[data-floating]:not([data-state=unverified]){display:block}.altcha-anchor-arrow{border:6px solid transparent;border-bottom-color:var(--altcha-color-border, #a0a0a0);content:"";height:0;left:12px;position:absolute;top:-12px;width:0}.altcha-main{align-items:center;display:flex;gap:.4rem;padding:.7rem;position:relative}.altcha-code-challenge{background:var(--altcha-color-base, white);border:1px solid var(--altcha-color-border-focus, currentColor);border-radius:var(--altcha-border-radius, 3px);filter:drop-shadow(3px 3px 6px rgba(0,0,0,.2));padding:.5rem;position:absolute;top:2.5rem;z-index:9999999}.altcha-code-challenge>form{display:flex;flex-direction:column;gap:.5rem}.altcha-code-challenge-input{border:1px solid currentColor;border-radius:3px;box-sizing:border-box;outline:0;font-size:16px;padding:.35rem;width:220px}.altcha-code-challenge-input:focus{outline:2px solid color-mix(in srgb,var(--altcha-color-active, #1D1DC9) 20%,transparent)}.altcha-code-challenge-input:disabled{opacity:.7}.altcha-code-challenge-image{background-color:#fff;border:1px solid currentColor;border-radius:3px;box-sizing:border-box;object-fit:contain;height:50px;width:220px}.altcha-code-challenge-audio,.altcha-code-challenge-reload{background:color-mix(in srgb,var(--altcha-color-text, currentColor) 10%,transparent);border:0;border-radius:3px;color:var(--altcha-color-text, currentColor);cursor:pointer;display:flex;align-items:center;justify-content:center;padding:.35rem}.altcha-code-challenge-audio:disabled,.altcha-code-challenge-reload:disabled,.altcha-code-challenge-verify:disabled{opacity:.7;pointer-events:none}.altcha-code-challenge-audio>*,.altcha-code-challenge-reload>*{height:20px;width:20px}.altcha-code-challenge-buttons{display:flex;justify-content:space-between}.altcha-code-challenge-buttons-left{display:flex;gap:.25rem}.altcha-code-challenge-verify{align-items:center;background:var(--altcha-color-active, #1D1DC9);border:0;border-radius:3px;color:#fff;cursor:pointer;display:flex;gap:.5rem;font-size:100%;padding:.35rem 1rem}.altcha-code-challenge-arrow{border:6px solid transparent;border-bottom-color:var(--altcha-color-border, currentColor);content:"";height:0;left:.15rem;position:absolute;top:-12px;width:0}.altcha[data-floating=top] .altcha-code-challenge{top:-150px}.altcha[data-floating=top] .altcha-code-challenge-arrow{border-bottom-color:transparent;border-top-color:var(--altcha-color-border, currentColor);bottom:-12px;top:auto}.altcha-label{cursor:pointer;flex-grow:1}.altcha-logo{color:currentColor!important;opacity:.7}.altcha-footer:hover,.altcha-logo:hover{opacity:1}.altcha-error{color:var(--altcha-color-error-text, #f23939);display:flex;font-size:.85rem;gap:.3rem;padding:0 .7rem .7rem}.altcha-footer{align-items:center;background-color:var(--altcha-color-footer-bg, transparent);display:flex;font-size:.75rem;opacity:.7;justify-content:end;padding:.2rem .7rem}.altcha-footer a{color:currentColor}.altcha-checkbox{display:flex;align-items:center;justify-content:center;height:24px;position:relative;width:24px}.altcha-checkbox .altcha-spinner{bottom:0;left:0;position:absolute;right:0;top:0}.altcha-checkbox input{width:18px;height:18px;margin:0}.altcha-checkbox-verifying input{appearance:none;opacity:0;pointer-events:none}.altcha-spinner{animation:altcha-spinner .75s infinite linear;transform-origin:center}.altcha-overlay{--altcha-color-base:#fff;--altcha-color-text:#000;animation:overlay-slidein .5s forwards;display:flex;flex-direction:column;gap:.5rem;left:50%;width:260px;opacity:0;position:fixed;top:45%;transform:translate(-50%,-50%)}.altcha-overlay-backdrop{background:rgba(0,0,0,.5);bottom:0;display:none;left:0;position:fixed;right:0;top:0;z-index:99999999}.altcha-overlay-close-button{align-self:flex-end;background:0 0;border:0;padding:.25rem;cursor:pointer;color:currentColor;font-size:130%;line-height:1;opacity:.7}@media (max-height:450px){.altcha-overlay{top:10%!important;transform:translate(-50%,0)}}';function Ho(e,t="__altcha-css"){if(!document.getElementById(t)){const n=document.createElement("style");n.id=t,n.textContent=e,document.head.appendChild(n)}}globalThis.altchaCreateWorker=e=>e?new Worker(new URL(e)):new Ni,Ho(Bo),Ho(Bo);export{kl as Altcha}; +//# sourceMappingURL=/sm/2e0fd2382ba0f4c2525d5a6a0d13423ff9e02efb653e53f4d8cfa83af626b1ea.map \ No newline at end of file diff --git a/assets/js/form.js b/assets/js/form.js index 3cf4f73..ece1c03 100644 --- a/assets/js/form.js +++ b/assets/js/form.js @@ -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 = '

Please fill in all required fields.

'; + return; + } + + if (!email.includes('@')) { + formStatusDiv.className = 'form-status form-status--error'; + formStatusDiv.innerHTML = '

Please enter a valid email address.

'; + return; + } + + // Check honeypot + const honeypot = form.elements['website']?.value; + if (honeypot) { + formStatusDiv.className = 'form-status form-status--error'; + formStatusDiv.innerHTML = '

Form validation failed.

'; + 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 = '

Spam check failed. Please try again.

'; + 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 = '

Thank you! Your message has been sent. We\'ll be in touch soon.

'; + 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 = '

' + (data.error || 'An error occurred. Please try again.') + '

'; + } + } catch (err) { + formStatusDiv.className = 'form-status form-status--error'; + formStatusDiv.innerHTML = '

Network error. Please try again.

'; + } + }); + } +}); diff --git a/assets/js/main.js b/assets/js/main.js index 393ffce..87f7e73 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -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(); diff --git a/assets/js/promo-popup.js b/assets/js/promo-popup.js new file mode 100644 index 0000000..d329384 --- /dev/null +++ b/assets/js/promo-popup.js @@ -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(); + } +})(); diff --git a/blog/index.html b/blog/index.html deleted file mode 100644 index 0ace14e..0000000 --- a/blog/index.html +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - - Hardwood Floor Tips & Guides | Floor It Blog - - - - - - - - - - - - - -
- -
-
- - From the Floor It Team -

Hardwood Floor Tips & Guides

-

Practical advice and expert tips for Buffalo, NY homeowners. Learn how to maintain, protect, and care for your hardwood floors with guidance from the Western New York refinishing specialists.

-
-
- - -
-
-
- Latest Articles -

Hardwood Floor Care Resources

-
- -
- -
-
-

How to Tell If Your Floors Need Refinishing

-

Learn the warning signs that indicate your hardwood floors are ready for a professional refinish. From visible scratches to dull finishes, we explain what to look for and when to act.

-
-
- Read More -
-
- -
-
-

Hardwood vs. Engineered: Which Is Right for Your Home?

-

Considering a new floor installation or replacement? Discover the pros and cons of solid hardwood and engineered hardwood to make the best choice for your Buffalo home.

-
-
- Read More -
-
- -
-
-

What to Expect During a Floor Refinishing Project

-

Wondering what happens during a professional floor refinishing? Get a detailed walkthrough of the timeline, process, and what to expect from start to finish.

-
-
- Read More -
-
- -
-
-
- -
-
-

Ready to Transform Your Floors?

-

Request a free estimate and let Floor It help restore your hardwood floors to their original beauty.

- -
-
- -
- - - - - - - diff --git a/components/footer.html b/components/footer.html deleted file mode 100644 index 1a83aaf..0000000 --- a/components/footer.html +++ /dev/null @@ -1,94 +0,0 @@ - diff --git a/components/header.html b/components/header.html deleted file mode 100644 index b4bb486..0000000 --- a/components/header.html +++ /dev/null @@ -1,60 +0,0 @@ - - - diff --git a/contact/index.html b/contact/index.html deleted file mode 100644 index 4e35aa4..0000000 --- a/contact/index.html +++ /dev/null @@ -1,200 +0,0 @@ - - - - - - - Contact Floor It Hardwood Floors | Buffalo, NY (716) 602-1429 - - - - - - - - - - - - -
- -
-
- - Get in Touch -

Request Your Estimate

-

Fill out the form below or call us directly. We respond within 1 business hour, Monday through Saturday.

-
-
- -
-
-
- -
- Contact Information -

Reach Our Team

-
- -
-
-
- -
-
-
Phone
- (716) 602-1429 -
-
- -
-
- -
- -
- -
-
- -
-
-
Service Area
- Western NY & Erie County -
-
- -
-
- -
-
-
Business Hours
-

Monday to Saturday: 8:00 AM to 5:00 PM
Sunday: Closed

-
-
-
- -
-

What Happens Next

-
    -
  1. 1. We call or email you within 1 business hour
  2. -
  3. 2. We schedule a free onsite visit at your convenience
  4. -
  5. 3. You receive a detailed, multi-option quote within 2 business days
  6. -
-
-
- -
-

Send Us a Message

-
-
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- - -
-
- -
-
- - - -
-
- - -
-
- -
- - -
- -
-
1 business hour response
-
No obligation
-
Licensed & insured
-
- - - -
- -

Or call us directly: (716) 602-1429

-
-
- -
-
-
- - -
- -
- -
- - - - - - - - diff --git a/docker-compose.yml b/docker-compose.yml index ca5eacd..949ee9d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,27 +1,17 @@ services: web: - image: floorithardwoodfloors-static + image: floorithardwoodfloors + container_name: floorit-hardwood-web build: context: . dockerfile: Dockerfile ports: - "8096:80" - depends_on: - api: - condition: service_healthy + env_file: .env restart: unless-stopped - - api: - image: floorithardwoodfloors-api - build: - context: ./api - dockerfile: Dockerfile - env_file: ./api/.env - expose: - - "3001" healthcheck: - test: ["CMD", "python3", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:3001/health',timeout=3).status==200 else 1)"] - interval: 10s + test: ["CMD", "curl", "-fsS", "http://127.0.0.1/"] + interval: 30s timeout: 5s + start_period: 10s retries: 3 - restart: unless-stopped diff --git a/index.html b/index.html deleted file mode 100644 index d4795d3..0000000 --- a/index.html +++ /dev/null @@ -1,633 +0,0 @@ - - - - - - - Buffalo, NY Hardwood Floor Refinishing & Restoration | Floor It - - - - - - - - - - - - - -
- - -
-
- -
- -
-
-
-
- Serving Western New York Since 1994 -
- -

Floor Refinishing & Restoration in Buffalo, NY

- -

Whether you need refinishing, restoration, or new installation, our team brings 75 years of combined expertise to every project in Erie County.

- - - -
-
- 75+ - Years Combined Experience -
-
- 500+ - Projects Completed -
-
- 4.9/5 - Customer Rating -
-
-
-
- -
- - -
-
-
-
- 75+ - Years Combined Experience -
-
- 500+ - Local Projects -
-
- 4.9 - Google Rating -
-
- 24hr - Response Time -
-
-
-
- - -
-
-
- What We Do -

Flooring Services We Offer

-

Complete hardwood floor solutions for Buffalo-area homes, delivered with professional-grade equipment and care.

-
- -
-
-
- Beautifully refinished hardwood floor restored to original beauty -
-
-

Floor Refinishing

-

Multi-stage sanding, custom staining, and professional sealing that restores your floors to their original beauty. Over 100 stain color options.

-
- -
- -
-
- Water damaged hardwood floor awaiting professional restoration -
-
-

Floor Restoration

-

Water damage, deep scratches, warping, and structural damage repaired with precision. We also provide documentation for insurance claims.

-
- -
- -
-
- Professional floor sanding equipment used by Floor It Hardwood Floors -
-
-

Floor Sanding

-

Commercial-grade HEPA dust containment equipment. Multi-grit sanding process delivers a perfectly prepared surface every time.

-
- -
- -
-
- Hardwood floor in Buffalo area home ready for professional installation and transformation -
-
-

Floor Installation

-

Beautiful new hardwood floors installed professionally. Quality materials, precise installation, built to last generations in your home.

-
- -
-
-
-
- - -
-
-
- How It Works -

Get Started in 3 Easy Steps

-
- -
-
-
1
-

Schedule Your Estimate

-

Call (716) 602-1429 or fill out our form. We respond within 24 hours to arrange your free onsite visit.

-
- -
-
2
-

Onsite Consultation

-

We measure your floor, discuss all options, and answer every question so you know exactly what to expect.

-
- -
-
3
-

Receive Your Quote

-

Within 2 business days you receive a detailed, multi-option quote with clear pricing and timeline.

-
-
- - -
-
- - -
-
-
-
- Breathe with Assurance -

Professional Floor Refinishing in Western NY

-
-

Craftsmanship is the heart of our operation. We believe true craftsmanship combines skill, passion, and attention to detail.

-

Our team brings 75 years of combined experience to every job. We work not only in Buffalo but also in East Amherst, Amherst, Clarence, Williamsville, and Lancaster, serving homeowners with dedication across Western New York.

-

At Floor It Hardwood Floors, we understand that every scratch has its story. After three decades in this business, we are experts at breathing new life into your Buffalo home's flooring.

- -
- -
-
- Floor It professional refinishing equipment -
-
-
-
-
- - - - - -
-
-
- Our Difference -

Why Floor It Hardwood Floors

-
- -
-
-
- -
-
-

Revitalize Your Space

-

Our refinishing process begins with a detailed assessment of your floors to determine the best course of action. Nothing is assumed.

-
-
- -
-
- -
-
-

Customized Solutions

-

Every floor is unique. We provide tailored solutions with over 100 stain color options, matching your specific vision for your home.

-
-
- -
-
- -
-
-

HEPA Dustless Sanding

-

We utilize the latest dustless sanding technology to minimize mess, protect your family's air quality, and ensure a clean, safe environment.

-
-
- -
-
- -
-
-

Lasting Durable Finishes

-

We apply durable topcoats that protect your floors from scratches, moisture, and UV damage. Built for long-lasting results.

-
-
-
-
-
- - -
-
-
- Customer Reviews -

What Our Customers Say

-

Rated 4.9 out of 5 across Google Reviews. Buffalo and Erie County homeowners trust Floor It.

-
- -
-
-
- -
-

"Excellent work! They transformed our tired, worn floors into something beautiful. Professional, efficient, and honest with their pricing. Will absolutely use them again."

-
- -
- Jennifer M. - Buffalo, NY -
-
-
- -
-
- -
-

"Highly recommend! The team was professional, clean, and completed everything on schedule. Our floors look incredible, like they are brand new."

-
- -
- Sarah K. - Amherst, NY -
-
-
- -
-
- -
-

"Professional service from start to finish. They genuinely cared about the quality of the work and it shows. Absolutely worth the investment for any homeowner."

-
- -
- Michael D. - Hamburg, NY -
-
-
-
- - -
-
- - -
-
-
- -
- Common Questions -

Floor Refinishing FAQs

-
-

Still have questions? Call us at (716) 602-1429 and we will walk you through everything.

-
- -
-
-
-

How does humidity affect the floor refinishing process?

- -
-
-
Humidity plays a significant role in refinishing. High humidity prolongs drying time and can cause cloudy or uneven finishes. Low humidity causes finishes to dry too quickly, leading to brush marks. Floor It monitors humidity levels throughout the process and advises clients on maintaining optimal conditions after completion.
-
-
- -
-
-

How do I know if my floors need refinishing or just re-coating?

- -
-
-
If your floors have minor surface scratches and the finish is dull but intact, a re-coat may suffice. If you see deep scratches, wear through to the wood, discoloration, or significant damage, full refinishing is necessary. We can assess your floors during the free onsite visit and recommend the right approach.
-
-
- -
-
-

What safety measures does Floor It take during refinishing?

- -
-
-
We use proper PPE including masks, gloves, and eye protection. Our equipment is regularly maintained. We use HEPA dust containment systems to reduce airborne particles and use low-VOC products when possible. We follow all local regulations and ensure proper ventilation throughout every project.
-
-
- -
-
-

What types of wood floors can Floor It refinish?

- -
-
-
We refinish solid hardwood, engineered hardwood, and parquet floors. We work with a wide range of species including oak, maple, and other hardwoods. Our team is skilled in handling the unique characteristics of each wood type.
-
-
- -
-
-

Can you provide custom stain colors?

- -
-
-
Yes. We offer over 100 stain colors and can mix custom colors to match your vision. During the consultation we will apply a sample directly to your floor for approval before proceeding with the full project. Your satisfaction with the color is confirmed before we begin.
-
-
-
-
-
-
- - -
- -
- - -
-
-
-
- Free Estimate -

Request a Free Estimate

-
-

Fill out the form and we will respond within 1 business hour. No obligation.

- -
-
-
- -
-
-
Call Us Directly
- (716) 602-1429 -
-
-
-
- -
- -
-
-
- -
-
-
Hours
-

Monday to Saturday: 8 AM to 5 PM

-
-
-
-
- -
-
-
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
-
-
- - - -
-
- - -
-
-
- - -
- -
-
1 business hour response
-
No obligation
-
Licensed & insured
-
- - - -
-
-
-
-
-
- -
- - - - - - - - diff --git a/infra/entrypoint.sh b/infra/entrypoint.sh new file mode 100644 index 0000000..e24ab27 --- /dev/null +++ b/infra/entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -e +if [ -z "$ALTCHA_HMAC_KEY" ]; then + export ALTCHA_HMAC_KEY="$(openssl rand -hex 32)" + echo "Generated ALTCHA_HMAC_KEY" >&2 +fi +exec "$@" diff --git a/infra/nginx.conf b/infra/nginx.conf new file mode 100644 index 0000000..e558c4e --- /dev/null +++ b/infra/nginx.conf @@ -0,0 +1,131 @@ +user www-data; +worker_processes auto; +error_log /dev/stderr warn; +pid /run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent"'; + access_log /dev/stdout main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + gzip on; + gzip_types text/html text/css application/javascript image/svg+xml; + gzip_min_length 1024; + + limit_req_zone $binary_remote_addr zone=contact_limit:10m rate=5r/m; + + server { + listen 80 default_server; + server_name _; + root /var/www/html; + index index.php; + + server_tokens off; + client_max_body_size 16k; + + location = /robots.txt { access_log off; try_files $uri =404; } + location = /sitemap.xml { access_log off; try_files $uri =404; } + location = /404.html { internal; } + location = /500.html { internal; } + + location ~ /\. { + deny all; + return 404; + } + + location ~* \.(env|conf|yml|yaml|py|pyc|sh|sql|log|bak|swp|sqlite)$ { + deny all; + return 404; + } + + location ~* \.(css|js|webp|jpg|jpeg|png|svg|ico|woff2?|mp4|webm)$ { + expires 30d; + add_header Cache-Control "public, immutable"; + access_log off; + try_files $uri =404; + } + + location = /promo/ { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /var/www/html/src/api/promo.php; + fastcgi_param QUERY_STRING ""; + fastcgi_pass 127.0.0.1:9000; + } + + location = /altcha-challenge/ { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /var/www/html/src/api/altcha-challenge.php; + fastcgi_param QUERY_STRING ""; + fastcgi_pass 127.0.0.1:9000; + } + + location = /contact/ { + limit_req zone=contact_limit burst=3 nodelay; + limit_req_status 429; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /var/www/html/src/api/router.php; + fastcgi_param QUERY_STRING type=page&slug=contact; + fastcgi_pass 127.0.0.1:9000; + } + + set $router /var/www/html/src/api/router.php; + + location = / { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $router; + fastcgi_param QUERY_STRING type=page&slug=home; + fastcgi_pass 127.0.0.1:9000; + } + + location ~ ^/(about|reviews|blog|services|locations)/$ { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $router; + fastcgi_param QUERY_STRING type=page&slug=$1; + fastcgi_pass 127.0.0.1:9000; + } + + location ~ ^/services/([a-z0-9-]+)/$ { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $router; + fastcgi_param QUERY_STRING type=service&slug=$1; + fastcgi_pass 127.0.0.1:9000; + } + + location ~ ^/locations/([a-z0-9-]+)/$ { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $router; + fastcgi_param QUERY_STRING type=location&slug=$1; + fastcgi_pass 127.0.0.1:9000; + } + + location ~ ^/blog/([a-z0-9-]+)/$ { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $router; + fastcgi_param QUERY_STRING type=blog&slug=$1; + fastcgi_pass 127.0.0.1:9000; + } + + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + + error_page 404 /404.html; + error_page 500 502 503 504 /500.html; + } +} diff --git a/infra/php-fpm-pool.conf b/infra/php-fpm-pool.conf new file mode 100644 index 0000000..6808838 --- /dev/null +++ b/infra/php-fpm-pool.conf @@ -0,0 +1,14 @@ +[www] +user = www-data +group = www-data +listen = 127.0.0.1:9000 + +pm = dynamic +pm.max_children = 5 +pm.start_servers = 2 +pm.min_spare_servers = 1 +pm.max_spare_servers = 3 + +clear_env = no + +access.log = /dev/null diff --git a/infra/supervisord.conf b/infra/supervisord.conf new file mode 100644 index 0000000..3aef13a --- /dev/null +++ b/infra/supervisord.conf @@ -0,0 +1,25 @@ +[supervisord] +nodaemon=true +logfile=/dev/null +logfile_maxbytes=0 +pidfile=/var/run/supervisord.pid + +[program:php-fpm] +command=/usr/local/sbin/php-fpm --nodaemonize +autostart=true +autorestart=true +priority=10 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:nginx] +command=/usr/sbin/nginx -g "daemon off;" +autostart=true +autorestart=true +priority=20 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 diff --git a/locations/_template.html b/locations/_template.html deleted file mode 100644 index 97bbc12..0000000 --- a/locations/_template.html +++ /dev/null @@ -1,258 +0,0 @@ - - - - - - - {{title}} - - - - - - - - - - - - - -
- - -
-
- - {{hero_eyebrow}} -

{{hero_h1}}

-

{{hero_lead}}

- -
-
- - -
-
-
-
- {{overview_eyebrow}} -

{{overview_h2}}

-
-

{{overview_body_1}}

-

{{overview_body_2}}

- -
- -
-
-
-
{{stat_1_num}}
-
- {{stat_1_label}} - {{stat_1_sub}} -
-
-
-
{{stat_2_num}}
-
- {{stat_2_label}} - {{stat_2_sub}} -
-
-
-
{{stat_3_num}}
-
- {{stat_3_label}} - {{stat_3_sub}} -
-
-
-
-
-
-
- - -
-
-
- {{city}} Services -

Hardwood Floor Services in {{city}}, {{state}}

-

{{services_intro}}

-
-
-
-
-

{{service_1_title}}

-

{{service_1_body}}

-
- -
-
-
-

{{service_2_title}}

-

{{service_2_body}}

-
- -
-
-
-

{{service_3_title}}

-

{{service_3_body}}

-
- -
-
-
-
- - -
-
-
-
- {{city}} FAQ -

Common Questions from {{city}} Homeowners

-
-

Have a question specific to your {{city}} home? Call us at (716) 602-1429

-
-
- {{faq_items}} -
-
-
-
- - -
-
-
-
- {{city}} Estimate -

{{form_h2}}

-
-

Tell us about your floors and we will respond within 1 business hour.

-
-
-
- -
- -
-
-
- -
-
Hours

Monday to Saturday: 8:00 AM to 5:00 PM

-
-
-
- -
-

{{form_h2}}

-
- -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
-
- - - -
-
- - -
-
-
1 business hour response
-
No obligation
-
Licensed & insured
-
- -
-
-
-
-
-
- -
- - - - - - - - diff --git a/locations/amherst.html b/locations/amherst.html deleted file mode 100644 index 8e2aa7a..0000000 --- a/locations/amherst.html +++ /dev/null @@ -1,284 +0,0 @@ - - - - - - - Hardwood Floor Refinishing in Amherst, NY | Floor It - - - - - - - - - - - - - -
- - -
-
- - Amherst, New York -

Hardwood Floor Refinishing in Amherst, NY

-

Trusted hardwood floor refinishing, restoration, and installation for Amherst homeowners. Professional results, 24-hour response time, serving the entire Amherst community.

- -
-
- - -
-
-
-
- Amherst Specialists -

Floor Refinishing Services in Amherst, NY

-
-

Amherst homeowners deserve hardwood floor specialists who understand the local community and the homes in it. Floor It brings 75 years of combined experience and commercial-grade equipment to every Amherst project.

-

From residential refinishing to water damage restoration, we handle every aspect of hardwood floor care. Amherst is part of our core Erie County service area, and we respond to all inquiries within 24 hours and schedule onsite visits at your convenience.

- -
- -
-
-
-
75+
-
- Years Combined Experience - Across our full team -
-
-
-
24hr
-
- Response Time - For all Amherst inquiries -
-
-
-
4.9
-
- Google Rating - Verified customer reviews -
-
-
-
-
-
-
- - -
-
-
- Amherst Services -

Hardwood Floor Services in Amherst, NY

-

All three of our core hardwood floor services are available throughout Amherst, delivered with the same professional equipment and standards we bring to every Erie County project.

-
-
-
-
-

Floor Refinishing Amherst

-

Multi-stage sanding, multiple stain options, and professional-grade sealing for Amherst homes. Restore beauty and durability.

-
- -
-
-
-

Floor Restoration Amherst

-

Water damage, warping, and deep scratch repair for Amherst homes. Full restoration with insurance documentation support where needed.

-
- -
-
-
-

Floor Sanding Amherst

-

Commercial dustless sanding for Amherst residents. Clean, safe, and effective. Multi-grit process for a perfect surface.

-
- -
-
-
-
- - -
-
-
-
- Amherst FAQ -

Common Questions from Amherst Homeowners

-
-

Have a question specific to your Amherst home? Call us at (716) 602-1429

-
-
-
-
-

How quickly can you respond to Amherst inquiries?

- -
-
-
We respond to all Amherst estimate requests within 24 hours. Amherst is within our core Erie County service area, so scheduling is fast. We can typically arrange an onsite visit within a few days of initial contact.
-
-
-
-
-

What types of floors do you refinish in Amherst?

- -
-
-
We refinish all types of hardwood in Amherst homes including solid oak, maple, cherry, walnut, engineered hardwood, and parquet. We assess each floor individually and recommend the appropriate approach based on wood species, thickness, and current condition.
-
-
-
-
-

Are you licensed and insured to work in Amherst?

- -
-
-
Yes. Floor It is fully licensed and insured to work throughout Erie County including Amherst. We carry all required insurance and adhere to all New York State regulations for contractor work in residential homes.
-
-
-
-
-
-
- - -
-
-
-
- Amherst Estimate -

Request an Amherst Floor Estimate

-
-

Tell us about your floors and we will respond within 1 business hour.

-
-
-
- -
- -
-
-
- -
-
Hours

Monday to Saturday: 8:00 AM to 5:00 PM

-
-
-
- -
-

Request an Amherst Floor Estimate

-
- -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
-
- - - -
-
- - -
-
-
1 business hour response
-
No obligation
-
Licensed & insured
-
- -
-
-
-
-
-
- -
- - - - - - - - diff --git a/locations/buffalo.html b/locations/buffalo.html deleted file mode 100644 index 6bb00fa..0000000 --- a/locations/buffalo.html +++ /dev/null @@ -1,293 +0,0 @@ - - - - - - - Hardwood Floor Refinishing in Buffalo, NY | Floor It - - - - - - - - - - - - - -
- - -
-
- - Buffalo, New York -

Hardwood Floor Refinishing in Buffalo, NY

-

Western New York's most experienced hardwood floor specialists. 30+ years serving Buffalo homeowners, from historic Elmwood Village homes to modern neighborhoods throughout Erie County.

- -
-
- - -
-
-
-
- Serving Buffalo Since 1994 -

Buffalo's Trusted Floor Refinishing Specialists

-
-

Buffalo's climate presents unique challenges for hardwood floors. High humidity in summer, dry winters, and the dramatic temperature swings that come with Western New York weather require expertise that only comes from decades of local experience.

-

Our team has worked on hundreds of Buffalo homes, from the historic older homes of the West Side and Elmwood Village to newer construction throughout the suburbs. We understand the wood species common to this region and how to bring out the best in each floor.

- -
- -
-
-
-
30+
-
- Years Serving Buffalo - Our primary market since we launched -
-
-
-
500+
-
- Buffalo Projects Completed - Homes across every Buffalo neighborhood -
-
-
-
24hr
-
- Response Time - We get back to every Buffalo inquiry -
-
-
-
-
-
-
- - -
-
-
- Buffalo Services -

Hardwood Floor Services in Buffalo, NY

-

We provide all three core hardwood floor services throughout Buffalo and Erie County, with the same commercial-grade equipment and professional team on every project.

-
-
-
-
-

Floor Refinishing Buffalo

-

Multi-stage sanding, multiple stain options, and professional-grade sealing for Buffalo homes. Restore beauty and durability.

-
- -
-
-
-

Floor Restoration Buffalo

-

Water damage, warping, and deep scratch repair common in Buffalo's older homes. Full restoration with insurance documentation support.

-
- -
-
-
-

Floor Sanding Buffalo

-

Commercial dustless sanding throughout Buffalo. Multi-grit process for perfect surface preparation before staining and sealing.

-
- -
-
-
-
- - -
-
-
-
- Buffalo FAQ -

Common Questions from Buffalo Homeowners

-
-

Have a question specific to your Buffalo home? Call us at (716) 602-1429

-
-
-
-
-

How does Buffalo's humidity affect hardwood floors?

- -
-
-
Buffalo's climate swings create real challenges. High summer humidity causes floors to expand and absorb moisture, while dry winters cause contraction. Our team accounts for seasonal conditions when scheduling refinishing projects and advises homeowners on maintaining proper humidity levels (35 to 55 percent) year-round to protect their investment.
-
-
-
-
-

Can you restore old hardwood floors in historic Buffalo homes?

- -
-
-
Yes. Many Buffalo homes have original hardwood floors that are 80 to 100 years old or more. These floors are often superior to modern wood in terms of density and character. We have extensive experience restoring original oak, maple, and other species common in Buffalo's historic housing stock, bringing them back to their original beauty.
-
-
-
-
-

How long does floor refinishing take in my Buffalo home?

- -
-
-
Standard refinishing takes 7 to 10 business days from project start to final coat cure. Restoration projects involving water damage or structural repair take 10 to 14 business days. We provide a detailed timeline during the onsite estimate and keep you informed throughout the project.
-
-
-
-
-

Do you serve all Buffalo neighborhoods?

- -
-
-
Yes. We serve all Buffalo neighborhoods including Elmwood Village, Allentown, North Buffalo, South Buffalo, West Side, the East Side, and all surrounding suburbs throughout Erie County. Response time is the same 24 hours across all areas.
-
-
-
-
-
-
- - -
-
-
-
- Buffalo Estimate -

Request a Buffalo Floor Estimate

-
-

Tell us about your floors and we will respond within 1 business hour.

-
-
-
- -
- -
-
-
- -
-
Hours

Monday to Saturday: 8:00 AM to 5:00 PM

-
-
-
- -
-

Request a Buffalo Floor Estimate

-
- -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
-
- - - -
-
- - -
-
-
1 business hour response
-
No obligation
-
Licensed & insured
-
- -
-
-
-
-
-
- -
- - - - - - - - diff --git a/locations/clarence.html b/locations/clarence.html deleted file mode 100644 index 00556a3..0000000 --- a/locations/clarence.html +++ /dev/null @@ -1,284 +0,0 @@ - - - - - - - Hardwood Floor Refinishing in Clarence, NY | Floor It - - - - - - - - - - - - - -
- - -
-
- - Clarence, New York -

Hardwood Floor Refinishing in Clarence, NY

-

Hardwood floor refinishing, restoration, and installation for Clarence homeowners. Reliable scheduling, professional results, and a team that Erie County has trusted for over 30 years.

- -
-
- - -
-
-
-
- Clarence Service Area -

Floor Refinishing Services in Clarence, NY

-
-

Clarence homeowners trust Floor It for professional hardwood floor care. Our team brings 75 years of combined experience to every project in Clarence, delivering the same quality results we provide throughout Erie County.

-

We respond to all Clarence inquiries within 24 hours. From initial estimate to completed project, our process is straightforward and transparent: detailed quotes, clear timelines, and professional workmanship every step of the way.

- -
- -
-
-
-
75+
-
- Years Combined Experience - Serving Western New York -
-
-
-
24hr
-
- Response Time - For Clarence inquiries -
-
-
-
4.9
-
- Google Rating - Verified customer reviews -
-
-
-
-
-
-
- - -
-
-
- Clarence Services -

Hardwood Floor Services in Clarence, NY

-

Complete hardwood floor services throughout Clarence, with the same professional team and equipment on every project.

-
-
-
-
-

Floor Refinishing Clarence

-

Multi-stage sanding, multiple stain options, and durable professional sealing for Clarence homes.

-
- -
-
-
-

Floor Restoration Clarence

-

Water damage, scratch, and structural floor repair for Clarence homeowners. Insurance documentation available.

-
- -
-
-
-

Floor Sanding Clarence

-

Commercial dustless sanding for Clarence homes. Safe, clean, thorough preparation for refinishing.

-
- -
-
-
-
- - -
-
-
-
- Clarence FAQ -

Common Questions from Clarence Homeowners

-
-

Have a question specific to your Clarence home? Call us at (716) 602-1429

-
-
-
-
-

How long has Floor It been serving the Clarence area?

- -
-
-
Floor It has been serving Erie County, including Clarence, for over 30 years. Clarence is within our core service area and we have completed projects throughout the community.
-
-
-
-
-

What is the timeline for a refinishing project in Clarence?

- -
-
-
Standard refinishing typically takes 7 to 10 business days. Restoration projects can take 10 to 14 business days depending on the extent of damage. We provide a full timeline during the onsite estimate visit before any work begins.
-
-
-
-
-

Do you provide written quotes for Clarence projects?

- -
-
-
Yes. Every project receives a detailed written quote within 2 business days of the onsite estimate visit. The quote includes multiple options, clear pricing, and a project timeline, so you can make an informed decision with no pressure.
-
-
-
-
-
-
- - -
-
-
-
- Clarence Estimate -

Request a Clarence Floor Estimate

-
-

Tell us about your floors and we will respond within 1 business hour.

-
-
-
- -
- -
-
-
- -
-
Hours

Monday to Saturday: 8:00 AM to 5:00 PM

-
-
-
- -
-

Request a Clarence Floor Estimate

-
- -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
-
- - - -
-
- - -
-
-
1 business hour response
-
No obligation
-
Licensed & insured
-
- -
-
-
-
-
-
- -
- - - - - - - - diff --git a/locations/east-amherst.html b/locations/east-amherst.html deleted file mode 100644 index 6f2a814..0000000 --- a/locations/east-amherst.html +++ /dev/null @@ -1,284 +0,0 @@ - - - - - - - Hardwood Floor Refinishing in East Amherst, NY | Floor It - - - - - - - - - - - - - -
- - -
-
- - East Amherst, New York -

Hardwood Floor Refinishing in East Amherst, NY

-

Professional hardwood floor services for East Amherst homeowners. The same expert team, commercial equipment, and quality results we bring to every Erie County home.

- -
-
- - -
-
-
-
- East Amherst Service Area -

Floor Refinishing Services in East Amherst, NY

-
-

East Amherst homeowners trust Floor It for hardwood floor refinishing, restoration, and sanding. We bring 75 years of combined experience and commercial-grade equipment to every project, applying the same professional standards throughout Erie County.

-

We respond to all East Amherst inquiries within 24 hours and schedule onsite visits at your convenience. Our team is familiar with the home styles, wood species, and floor conditions common throughout the East Amherst area.

- -
- -
-
-
-
75+
-
- Years Combined Experience - Serving Western New York -
-
-
-
24hr
-
- Response Time - For East Amherst inquiries -
-
-
-
4.9
-
- Google Rating - Verified customer reviews -
-
-
-
-
-
-
- - -
-
-
- East Amherst Services -

Hardwood Floor Services in East Amherst, NY

-

All core hardwood floor services available throughout East Amherst, delivered with the same professional equipment and team on every project.

-
-
-
-
-

Floor Refinishing East Amherst

-

Multi-stage sanding, multiple stain options, and professional sealing for East Amherst homes. Restore beauty and durability.

-
- -
-
-
-

Floor Restoration East Amherst

-

Water damage, deep scratch, and structural floor repair for East Amherst homeowners. Full restoration with insurance documentation available.

-
- -
-
-
-

Floor Sanding East Amherst

-

Commercial dustless sanding for East Amherst homes. Safe, clean, effective surface preparation.

-
- -
-
-
-
- - -
-
-
-
- East Amherst FAQ -

Common Questions from East Amherst Homeowners

-
-

Have a question specific to your East Amherst home? Call us at (716) 602-1429

-
-
-
-
-

Do you serve East Amherst as part of your regular route?

- -
-
-
Yes. East Amherst is within our core Erie County service area. We schedule visits to East Amherst regularly and can typically arrange an onsite estimate within a few days of initial contact.
-
-
-
-
-

What hardwood floor services do you offer in East Amherst?

- -
-
-
We offer full hardwood floor refinishing, restoration, and sanding throughout East Amherst. This covers everything from surface re-coating to complete restoration of water-damaged or structurally compromised floors.
-
-
-
-
-

Is your dustless sanding safe for families with children and pets?

- -
-
-
Yes. Our dustless sanding system captures fine particles at the source, dramatically reducing airborne dust. We also use low-VOC finishes when possible. We recommend vacating the work area during sanding and for 24 to 48 hours after final coat application.
-
-
-
-
-
-
- - -
-
-
-
- East Amherst Estimate -

Request an East Amherst Floor Estimate

-
-

Tell us about your floors and we will respond within 1 business hour.

-
-
-
- -
- -
-
-
- -
-
Hours

Monday to Saturday: 8:00 AM to 5:00 PM

-
-
-
- -
-

Request an East Amherst Floor Estimate

-
- -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
-
- - - -
-
- - -
-
-
1 business hour response
-
No obligation
-
Licensed & insured
-
- -
-
-
-
-
-
- -
- - - - - - - - diff --git a/locations/index.html b/locations/index.html deleted file mode 100644 index 9208e46..0000000 --- a/locations/index.html +++ /dev/null @@ -1,181 +0,0 @@ - - - - - - - Hardwood Floor Refinishing Service Areas | Western NY | Floor It - - - - - - - - - - - - -
- -
-
- - Where We Work -

Hardwood Floor Services Across Western New York

-

Floor It serves homeowners throughout Buffalo and Erie County with the same professional standards, equipment, and care at every location.

-
-
- - -
-
-
- Our Service Areas -

Cities We Serve

-

Select a city to learn more about our hardwood floor services in your area.

-
- -
- -
-
- Buffalo, NY skyline -
-
- Primary Hub -

Buffalo, NY

-

Our primary service area. Expert hardwood floor refinishing, restoration, and installation throughout Buffalo, including older homes with historic hardwood floors.

- -
-
- -
-
- Amherst, NY neighborhood -
-
- Erie County -

Amherst, NY

-

Residential hardwood floor refinishing and restoration for Amherst homeowners. Community-focused service with fast scheduling and 24-hour response times.

- -
-
- -
-
- Williamsville, NY homes -
-
- Erie County -

Williamsville, NY

-

Premium hardwood floor refinishing for Williamsville homes. Upscale residential expertise with attention to the highest finish standards.

- -
-
- -
-
-
- Erie County -

East Amherst, NY

-

Professional hardwood floor services for East Amherst homeowners. Comprehensive service coverage with the same expert team and professional equipment.

- -
-
- -
-
-
- Erie County -

Clarence, NY

-

Hardwood floor refinishing and restoration for Clarence residents. Trusted by the local community with reliable scheduling and professional results.

- -
-
- -
-
-
- Erie County -

Lancaster, NY

-

Professional hardwood floor services for Lancaster homeowners. The same expert standards and care that Buffalo homeowners have trusted for over 30 years.

- -
-
- -
-
-
- - -
-
-
- Universal Service Standards -

The Same Quality at Every Location

-

No matter where you are in Erie County, you receive the same professional service, equipment, and results.

-
- -
-
-
-
-

Floor Refinishing

-

Multi-stage sanding, multiple stain options, and durable sealing. 7 to 10 business day timeline.

-
-
-
-
-
-

Floor Restoration

-

Water damage, deep scratches, and structural repairs. Insurance documentation available. 10 to 14 days.

-
-
-
-
-
-

Floor Sanding

-

Commercial-grade HEPA dustless equipment. Multi-grit process for a perfectly prepared surface.

-
-
-
-
-
- -
-
-

Serving Your Community in Western New York

-

Not sure if we cover your area? Call us. We serve all of Erie County and surrounding communities.

- -
-
- -
- - - - - - - diff --git a/locations/lancaster.html b/locations/lancaster.html deleted file mode 100644 index 0ae7716..0000000 --- a/locations/lancaster.html +++ /dev/null @@ -1,284 +0,0 @@ - - - - - - - Hardwood Floor Refinishing in Lancaster, NY | Floor It - - - - - - - - - - - - - -
- - -
-
- - Lancaster, New York -

Hardwood Floor Refinishing in Lancaster, NY

-

Professional hardwood floor refinishing and restoration for Lancaster homeowners. The same expert team and commercial equipment that Buffalo homeowners have trusted for over 30 years.

- -
-
- - -
-
-
-
- Lancaster Service Area -

Floor Refinishing Services in Lancaster, NY

-
-

Lancaster homeowners trust Floor It for professional hardwood floor care. We bring 75 years of combined experience and commercial-grade equipment to every Lancaster project, maintaining the same standards throughout Erie County.

-

Our team responds to all Lancaster inquiries within 24 hours. We schedule onsite visits at your convenience and provide detailed written quotes within 2 business days of the visit. No obligation, no pressure.

- -
- -
-
-
-
75+
-
- Years Combined Experience - Serving Western New York -
-
-
-
24hr
-
- Response Time - For Lancaster inquiries -
-
-
-
4.9
-
- Google Rating - Verified customer reviews -
-
-
-
-
-
-
- - -
-
-
- Lancaster Services -

Hardwood Floor Services in Lancaster, NY

-

Full hardwood floor services available throughout Lancaster: refinishing, restoration, and sanding with professional-grade equipment.

-
-
-
-
-

Floor Refinishing Lancaster

-

Multi-stage sanding, multiple stain options, and durable professional sealing for Lancaster homes.

-
- -
-
-
-

Floor Restoration Lancaster

-

Water damage, scratch, and structural floor repair for Lancaster homeowners. Insurance documentation support available.

-
- -
-
-
-

Floor Sanding Lancaster

-

Commercial dustless sanding for Lancaster homes. Safe for families and pets, thorough preparation.

-
- -
-
-
-
- - -
-
-
-
- Lancaster FAQ -

Common Questions from Lancaster Homeowners

-
-

Have a question specific to your Lancaster home? Call us at (716) 602-1429

-
-
-
-
-

Do you serve Lancaster as part of your regular route?

- -
-
-
Yes. Lancaster is within our Erie County service area and we schedule visits there regularly. We can typically arrange an onsite estimate within a few days of your initial inquiry.
-
-
-
-
-

What hardwood species do you work with in Lancaster homes?

- -
-
-
We work with all common hardwood species found in Lancaster homes: oak, maple, cherry, walnut, ash, and engineered hardwood. We also work with less common domestic and exotic species. Each floor is assessed individually before we recommend an approach.
-
-
-
-
-

How do I prepare my Lancaster home before your team arrives?

- -
-
-
Remove furniture and area rugs from the rooms being worked on. Secure or cover any valuables in adjacent areas. We handle all floor preparation, sanding, and finishing. We advise keeping pets and children out of the work area and for 24 to 48 hours after final coat application.
-
-
-
-
-
-
- - -
-
-
-
- Lancaster Estimate -

Request a Lancaster Floor Estimate

-
-

Tell us about your floors and we will respond within 1 business hour.

-
-
-
- -
- -
-
-
- -
-
Hours

Monday to Saturday: 8:00 AM to 5:00 PM

-
-
-
- -
-

Request a Lancaster Floor Estimate

-
- -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
-
- - - -
-
- - -
-
-
1 business hour response
-
No obligation
-
Licensed & insured
-
- -
-
-
-
-
-
- -
- - - - - - - - diff --git a/locations/williamsville.html b/locations/williamsville.html deleted file mode 100644 index 35c0c39..0000000 --- a/locations/williamsville.html +++ /dev/null @@ -1,284 +0,0 @@ - - - - - - - Hardwood Floor Refinishing in Williamsville, NY | Floor It - - - - - - - - - - - - - -
- - -
-
- - Williamsville, New York -

Hardwood Floor Refinishing in Williamsville, NY

-

Premium hardwood floor refinishing and restoration for Williamsville homes. Upscale residential expertise with the highest finish standards in Erie County.

- -
-
- - -
-
-
-
- Williamsville Specialists -

Expert Floor Refinishing in Williamsville, NY

-
-

Williamsville homes deserve a level of craftsmanship that matches their quality. Floor It brings 75 years of combined experience to every Williamsville project, applying the same professional-grade equipment and attention to detail that has made us Western New York's most trusted floor refinishing team.

-

Whether you have original hardwood floors in a classic Williamsville home or newer engineered wood in a modern build, our team assesses each floor individually and recommends the right approach for lasting, beautiful results.

- -
- -
-
-
-
75+
-
- Years Combined Experience - Serving Western New York -
-
-
-
24hr
-
- Response Time - For Williamsville inquiries -
-
-
-
4.9
-
- Google Rating - Verified customer reviews -
-
-
-
-
-
-
- - -
-
-
- Williamsville Services -

Hardwood Floor Services in Williamsville, NY

-

Complete hardwood floor services available throughout Williamsville: refinishing, restoration, and sanding with commercial-grade equipment.

-
-
-
-
-

Floor Refinishing Williamsville

-

Premium multi-stage sanding, staining, and sealing for Williamsville homes. Stain sample approval before full project begins.

-
- -
-
-
-

Floor Restoration Williamsville

-

Water damage, deep scratch, and structural floor repair. We restore Williamsville floors to like-new condition with insurance documentation available.

-
- -
-
-
-

Floor Sanding Williamsville

-

Dustless sanding for Williamsville homes. Clean, safe process that prepares your floor perfectly for staining and sealing.

-
- -
-
-
-
- - -
-
-
-
- Williamsville FAQ -

Common Questions from Williamsville Homeowners

-
-

Have a question specific to your Williamsville home? Call us at (716) 602-1429

-
-
-
-
-

Do you work on upscale homes in Williamsville?

- -
-
-
Yes. We regularly work on premium homes throughout Williamsville and have extensive experience with high-end wood species, custom stain colors, and finish options that match the quality of the home. We treat every project with the same professional care regardless of home value.
-
-
-
-
-

Can you match the stain on my existing Williamsville floors?

- -
-
-
In most cases, yes. We apply a test sample directly on your floor before proceeding so you can verify the color match. For custom blends, our team mixes stains on-site to get the closest possible match to your existing floors.
-
-
-
-
-

How much disruption should I expect during the project?

- -
-
-
We use dustless sanding equipment that dramatically reduces airborne particles. You will need to vacate the rooms being worked on during sanding and for 24 to 48 hours after final coat application. We work efficiently to minimize disruption to your daily routine.
-
-
-
-
-
-
- - -
-
-
-
- Williamsville Estimate -

Request a Williamsville Floor Estimate

-
-

Tell us about your floors and we will respond within 1 business hour.

-
-
-
- -
- -
-
-
- -
-
Hours

Monday to Saturday: 8:00 AM to 5:00 PM

-
-
-
- -
-

Request a Williamsville Floor Estimate

-
- -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
-
- - - -
-
- - -
-
-
1 business hour response
-
No obligation
-
Licensed & insured
-
- -
-
-
-
-
-
- -
- - - - - - - - diff --git a/nginx.conf b/nginx.conf deleted file mode 100644 index 1d393a8..0000000 --- a/nginx.conf +++ /dev/null @@ -1,63 +0,0 @@ -server { - listen 80; - server_name _; - root /usr/share/nginx/html; - index index.html; - - # Deny dotfiles, configs, scripts, source — defense in depth - location ~ /\. { - deny all; - return 404; - } - location ~* \.(env|env\.example|conf|yml|yaml|py|pyc|md|sh|sql|log|bak|old|swp|dockerfile)$ { - deny all; - return 404; - } - location = /Dockerfile { - deny all; - return 404; - } - - location ~* /_template\.html$ { - deny all; - return 404; - } - - location = /robots.txt { access_log off; } - location = /sitemap.xml { access_log off; } - - # API proxy — strip /api/ prefix, forward to Python API service - location /api/ { - proxy_pass http://api:3001/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_read_timeout 10s; - proxy_connect_timeout 5s; - } - - # Flat HTML — serve /locations/buffalo as /locations/buffalo.html - location / { - try_files $uri $uri/ $uri.html =404; - } - - # Cache static assets - location ~* \.(jpg|jpeg|png|webp|svg|ico|css|js|woff2?|mp4|webm)$ { - expires 30d; - add_header Cache-Control "public, immutable"; - access_log off; - } - - # Security headers - add_header X-Frame-Options "SAMEORIGIN"; - add_header X-Content-Type-Options "nosniff"; - add_header Referrer-Policy "strict-origin-when-cross-origin"; - - # Gzip - gzip on; - gzip_types text/html text/css application/javascript image/svg+xml; - gzip_min_length 1024; - - error_page 404 /404.html; -} diff --git a/reviews/index.html b/reviews/index.html deleted file mode 100644 index bac8003..0000000 --- a/reviews/index.html +++ /dev/null @@ -1,145 +0,0 @@ - - - - - - - Customer Reviews | Floor It Hardwood Floors | Buffalo, NY - - - - - - - - - - - - -
- -
-
- - Customer Testimonials -

What Our Customers Say

-

Rated 4.9 out of 5 stars across Google Reviews. Buffalo and Erie County homeowners trust Floor It for professional hardwood floor refinishing.

-
-
- - -
-
-
-
- 4.9 - Out of 5 Stars -
-
- ★★★★★ - Google Reviews -
-
- 500+ - Projects Completed -
-
- 100% - Satisfaction Goal -
-
-
-
- - -
-
-
- Verified Reviews -

Buffalo Homeowners Trust Floor It

-
- -
- -
-
-

"Excellent work. They transformed our tired, worn floors into something beautiful. Professional, efficient, and honest with their pricing. Will absolutely use them again for future projects."

-
-
J
-
Jennifer M.Buffalo, NY
-
-
- -
-
-

"Highly recommend. The team was professional, clean, and completed everything on schedule. Our floors look incredible, like brand new. The dustless sanding was a huge plus."

-
-
S
-
Sarah K.Amherst, NY
-
-
- -
-
-

"Professional service from start to finish. They genuinely cared about the quality of the work and it shows. Absolutely worth the investment for any homeowner with hardwood floors."

-
-
M
-
Michael D.Hamburg, NY
-
-
- -
-
-

"I had significant water damage on my oak floors and was worried they would need full replacement. Floor It assessed the situation and restored them completely. I could not believe how good they look now."

-
-
R
-
Robert T.Williamsville, NY
-
-
- -
-
-

"The custom stain color matching was exactly what I wanted. They did a sample on my floor before committing to the full project. That extra step really set them apart from other companies I contacted."

-
-
L
-
Linda C.Clarence, NY
-
-
- -
-
-

"Called on a Monday, had a free estimate visit by Wednesday, and floors were done by the following Friday. Fast, clean, and the results are stunning. Our 100-year-old oak floors look better than they ever have."

-
-
D
-
David P.Lancaster, NY
-
-
- -
-
-
- -
-
-

Ready to Join Our Satisfied Customers?

-

Request an estimate today and experience the Floor It difference in your own home.

- -
-
- -
- - - - - - - diff --git a/services/_template.html b/services/_template.html deleted file mode 100644 index cabd9ee..0000000 --- a/services/_template.html +++ /dev/null @@ -1,276 +0,0 @@ - - - - - - - {{title}} - - - - - - - - - - - - - -
- - -
-
- - {{hero_eyebrow}} -

{{hero_h1}}

-

{{hero_lead}}

- -
-
- - -
-
-
-
- What We Do -

{{intro_h2}}

-
-

{{intro_body_1}}

-

{{intro_body_2}}

- -
-
-
- {{service_name}} | Floor It Hardwood Floors -
-
-
-
-
- - -
-
-
- How It Works -

Our {{service_name}} Process

-

{{process_intro}}

-
-
-
-
1
-

{{step_1_title}}

-

{{step_1_body}}

-
-
-
2
-

{{step_2_title}}

-

{{step_2_body}}

-
-
-
3
-

{{step_3_title}}

-

{{step_3_body}}

-
-
-
-
- - -
-
-
- Why Choose Us -

What Sets Our {{service_name}} Apart

-
-
-
-
- -
-
-

{{benefit_1_title}}

-

{{benefit_1_body}}

-
-
-
-
- -
-
-

{{benefit_2_title}}

-

{{benefit_2_body}}

-
-
-
-
- -
-
-

{{benefit_3_title}}

-

{{benefit_3_body}}

-
-
-
-
- -
-
-

{{benefit_4_title}}

-

{{benefit_4_body}}

-
-
-
-
-
- - -
-
-
-
- {{service_name}} FAQ -

Common Questions

-
-

Have a question? Call us at (716) 602-1429 and we will answer it directly.

-
-
-
-
-

{{faq_1_q}}

- -
-
-
{{faq_1_a}}
-
-
-
-
-

{{faq_2_q}}

- -
-
-
{{faq_2_a}}
-
-
-
-
-

{{faq_3_q}}

- -
-
-
{{faq_3_a}}
-
-
-
-
-
-
- - -
-
-
-
- Free Estimate -

{{form_h2}}

-
-

Tell us about your floors and we will respond within 1 business hour.

-
-
-
- -
- -
-
-
- -
-
Hours

Monday to Saturday: 8:00 AM to 5:00 PM

-
-
-
-
-

{{form_h2}}

-
- -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
-
- - -
-
-
1 business hour response
-
No obligation
-
Licensed & insured
-
- -
-
-
-
-
-
- -
- - - - - - - - diff --git a/services/floor-installation.html b/services/floor-installation.html deleted file mode 100644 index f503fba..0000000 --- a/services/floor-installation.html +++ /dev/null @@ -1,276 +0,0 @@ - - - - - - - Hardwood Floor Installation in Buffalo, NY | Floor It Hardwood Floors - - - - - - - - - - - - - -
- - -
-
- - Buffalo and Erie County -

Hardwood Floor Installation

-

New hardwood floors installed with precision. Quality materials, proper subfloor preparation, and expert craftsmanship built to last.

- -
-
- - -
-
-
-
- What We Do -

Expert Hardwood Floor Installation in Western New York

-
-

Installing new hardwood floors is an investment that lasts decades, but only if installed correctly. Improper subfloor preparation, inadequate acclimation, or poor fastening technique shows up years later as squeaks, gaps, and movement. We do it right the first time.

-

We work with solid hardwood, engineered hardwood, and parquet in a wide range of species and widths. Our installation process begins with a subfloor assessment and moisture testing, followed by a proper acclimation period for your new material.

- -
-
-
- Floor Installation | Floor It Hardwood Floors -
-
-
-
-
- - -
-
-
- How It Works -

Our Floor Installation Process

-

Proper hardwood installation requires careful preparation before a single board goes down.

-
-
-
-
1
-

Material Selection

-

We help you select the right species, width, and grade for your space based on your subfloor type, lifestyle, and existing flooring if matching is needed.

-
-
-
2
-

Subfloor Preparation

-

We assess subfloor flatness, moisture content, and structural integrity. Any issues are addressed before installation begins. This is where most problems start.

-
-
-
3
-

Installation and Finish

-

Boards are installed with proper fastening pattern and spacing. We complete with the full sand, stain, and seal process for a finished result.

-
-
-
-
- - -
-
-
- Why Choose Us -

What Sets Our Floor Installation Apart

-
-
-
-
- -
-
-

All Wood Species

-

Oak, maple, hickory, cherry, walnut, and more. We source quality material from trusted suppliers and can match existing floors in your home.

-
-
-
-
- -
-
-

Subfloor Expertise

-

The subfloor makes or breaks an installation. We assess, level, and prepare every subfloor to manufacturer specifications before any boards go down.

-
-
-
-
- -
-
-

Moisture Testing

-

Wood and subfloor moisture content must be within range of each other before installation. We test and acclimate all material on-site.

-
-
-
-
- -
-
-

Built to Last

-

Properly installed hardwood floors last for decades. We stand behind our installation workmanship and ensure every board is set correctly.

-
-
-
-
-
- - -
-
-
-
- Floor Installation FAQ -

Common Questions

-
-

Have a question? Call us at (716) 602-1429 and we will answer it directly.

-
-
-
-
-

How long does hardwood floor installation take?

- -
-
-
Most rooms take two to three days including subfloor prep, installation, then sand and finish. Larger projects or those requiring extensive subfloor work take longer. We provide a detailed schedule during the estimate.
-
-
-
-
-

Solid versus engineered hardwood, which is right for me?

- -
-
-
Solid hardwood is ideal for above-grade installations on wood subfloors. Engineered hardwood is better for concrete subfloors, basements, or high-humidity areas. We help you make the right choice for your situation.
-
-
-
-
-

Can you match my existing hardwood floors?

- -
-
-
In most cases, yes. We sample your existing floor and source matching material by species, width, and grade. After installation and finishing, new and existing sections blend well.
-
-
-
-
-
-
- - -
-
-
-
- Free Estimate -

Get Your Free Installation Estimate

-
-

Tell us about your floors and we will respond within 1 business hour.

-
-
-
- -
- -
-
-
- -
-
Hours

Monday to Saturday: 8:00 AM to 5:00 PM

-
-
-
-
-

Get Your Free Installation Estimate

-
- -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
-
- - -
-
-
1 business hour response
-
No obligation
-
Licensed & insured
-
- -
-
-
-
-
-
- -
- - - - - - - - diff --git a/services/floor-refinishing.html b/services/floor-refinishing.html deleted file mode 100644 index 725d914..0000000 --- a/services/floor-refinishing.html +++ /dev/null @@ -1,276 +0,0 @@ - - - - - - - Hardwood Floor Refinishing in Buffalo, NY | Floor It Hardwood Floors - - - - - - - - - - - - - -
- - -
-
- - Buffalo and Erie County -

Hardwood Floor Refinishing

-

Restore worn, dull, or damaged hardwood floors to their original beauty with our multi-stage refinishing process. Custom stain colors available.

- -
-
- - -
-
-
-
- What We Do -

Expert Floor Refinishing in Western New York

-
-

Floor refinishing is the most effective way to transform tired, worn hardwood without the cost of full replacement. Our process begins with a thorough assessment of your floors, including wood species, existing finish condition, and depth of scratches, to determine the exact approach your floors need.

-

We use dustless sanding equipment that minimizes dust throughout the project, protecting your home and family. After sanding, we apply your chosen stain and seal with a professional-grade topcoat built for long-lasting durability.

- -
-
-
- Floor Refinishing | Floor It Hardwood Floors -
-
-
-
-
- - -
-
-
- How It Works -

Our Floor Refinishing Process

-

Our refinishing process follows a proven multi-stage system that delivers consistent, beautiful results on every project.

-
-
-
-
1
-

Floor Assessment

-

We inspect wood species, thickness, existing finish, and damage level to determine the right sanding grit sequence and stain approach.

-
-
-
2
-

Dustless Sanding

-

Our sanding equipment removes the existing finish and opens the wood grain. Multiple grit passes deliver a perfectly smooth, even surface.

-
-
-
3
-

Stain and Seal

-

We apply a test patch for your approval before proceeding with the full stain. Finish with your selected topcoat: oil-based, water-based, or wax.

-
-
-
-
- - -
-
-
- Why Choose Us -

What Sets Our Floor Refinishing Apart

-
-
-
-
- -
-
-

Custom Stain Colors

-

From natural clear finishes to deep espresso tones. We apply samples directly to your floor during the estimate visit so you can approve before we begin.

-
-
-
-
- -
-
-

Dustless Sanding

-

Our sanding equipment captures dust at the source, keeping your home clean and your family comfortable throughout the project.

-
-
-
-
- -
-
-

Long-Lasting Finish

-

Professional-grade polyurethane, oil-modified, and water-based topcoats that stand up to daily wear, pets, and Buffalo winters.

-
-
-
-
- -
-
-

Free On-Site Estimate

-

We visit your home, assess the floors, and provide a detailed written estimate before any work begins. No guessing, no surprises.

-
-
-
-
-
- - -
-
-
-
- Floor Refinishing FAQ -

Common Questions

-
-

Have a question? Call us at (716) 602-1429 and we will answer it directly.

-
-
-
-
-

How long does floor refinishing take?

- -
-
-
Most rooms take two to three days: one day for sanding, one for stain and first coat, one for final coats. We provide a precise timeline during the estimate.
-
-
-
-
-

How many times can a hardwood floor be refinished?

- -
-
-
Solid hardwood can typically be refinished multiple times over its lifetime depending on the wood thickness. Engineered hardwood depends on the wear layer. We measure your floor during the assessment.
-
-
-
-
-

Do I need to leave my home during refinishing?

- -
-
-
With our dustless sanding system you can usually remain in your home. During staining and sealing we recommend staying out for several hours per coat for ventilation. We advise based on your specific layout.
-
-
-
-
-
-
- - -
-
-
-
- Free Estimate -

Get Your Free Refinishing Estimate

-
-

Tell us about your floors and we will respond within 1 business hour.

-
-
-
- -
- -
-
-
- -
-
Hours

Monday to Saturday: 8:00 AM to 5:00 PM

-
-
-
-
-

Get Your Free Refinishing Estimate

-
- -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
-
- - -
-
-
1 business hour response
-
No obligation
-
Licensed & insured
-
- -
-
-
-
-
-
- -
- - - - - - - - diff --git a/services/floor-restoration.html b/services/floor-restoration.html deleted file mode 100644 index a06cb05..0000000 --- a/services/floor-restoration.html +++ /dev/null @@ -1,276 +0,0 @@ - - - - - - - Hardwood Floor Restoration in Buffalo, NY | Floor It Hardwood Floors - - - - - - - - - - - - - -
- - -
-
- - Buffalo and Erie County -

Hardwood Floor Restoration

-

Water damage, deep scratches, warping, cupping, and structural damage. We diagnose, repair, and restore your hardwood floors completely.

- -
-
- - -
-
-
-
- What We Do -

Full-Service Floor Restoration in Western New York

-
-

Restoration goes beyond refinishing. When floors have suffered water damage, deep gouging, board warping, nail pops, or subfloor issues, a simple sand and seal is not enough. Our restoration process starts with a structural diagnosis to identify every issue before any work begins.

-

We repair or replace damaged boards, address subfloor problems, re-nail loose planks, and eliminate gaps, then complete the process with our full refinishing system. We also provide written documentation for insurance claims with before-and-after photography and a detailed scope-of-work report.

- -
-
-
- Floor Restoration | Floor It Hardwood Floors -
-
-
-
-
- - -
-
-
- How It Works -

Our Floor Restoration Process

-

Restoration requires a thorough assessment and multi-phase repair process before refinishing can begin.

-
-
-
-
1
-

Damage Assessment

-

We inspect for water damage, structural movement, subfloor issues, and board condition. All findings are documented with photography for insurance purposes.

-
-
-
2
-

Structural Repair

-

Replace damaged boards, re-nail loose planks, fill gaps, address subfloor problems, and eliminate squeaks before any finishing work begins.

-
-
-
3
-

Refinish and Seal

-

Once the floor is structurally sound, we complete the full refinishing process including sanding, stain, and topcoat for a seamless final result.

-
-
-
-
- - -
-
-
- Why Choose Us -

What Sets Our Floor Restoration Apart

-
-
-
-
- -
-
-

Insurance Documentation

-

Written scope-of-work reports and before and after photography for your insurance adjuster. We work with your timeline to support the claims process.

-
-
-
-
- -
-
-

Board Replacement

-

We source matching replacement boards for damaged sections by species, grain, and width so repairs are invisible after finishing.

-
-
-
-
- -
-
-

Subfloor Repair

-

Water damage often affects the subfloor. We assess and repair subfloor issues to ensure your finished floor is structurally sound.

-
-
-
-
- -
-
-

Cupping and Warping

-

Cupped or warped boards from humidity changes can often be corrected without replacement. We assess each board individually to minimize material cost.

-
-
-
-
-
- - -
-
-
-
- Floor Restoration FAQ -

Common Questions

-
-

Have a question? Call us at (716) 602-1429 and we will answer it directly.

-
-
-
-
-

Can water-damaged floors be saved?

- -
-
-
In most cases, yes, if the damage is addressed promptly. Floors that have dried out and cupped can often be sanded flat. Severely swollen or buckled boards may need replacement, but we always maximize what can be saved.
-
-
-
-
-

Do you provide insurance estimates?

- -
-
-
Yes. We provide written damage assessments, scope-of-work documentation, and before-and-after photography suitable for insurance claims. We have experience working alongside adjusters.
-
-
-
-
-

How long does restoration take?

- -
-
-
Restoration timelines vary by damage extent. Minor repairs and refinishing can be completed in three to four days. Extensive structural work with board replacement may take longer. We provide a detailed timeline during the assessment.
-
-
-
-
-
-
- - -
-
-
-
- Free Estimate -

Get Your Free Restoration Assessment

-
-

Tell us about your floors and we will respond within 1 business hour.

-
-
-
- -
- -
-
-
- -
-
Hours

Monday to Saturday: 8:00 AM to 5:00 PM

-
-
-
-
-

Get Your Free Restoration Assessment

-
- -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
-
- - -
-
-
1 business hour response
-
No obligation
-
Licensed & insured
-
- -
-
-
-
-
-
- -
- - - - - - - - diff --git a/services/floor-sanding.html b/services/floor-sanding.html deleted file mode 100644 index a32cb4f..0000000 --- a/services/floor-sanding.html +++ /dev/null @@ -1,276 +0,0 @@ - - - - - - - Hardwood Floor Sanding in Buffalo, NY | Floor It Hardwood Floors - - - - - - - - - - - - - -
- - -
-
- - Buffalo and Erie County -

Professional Hardwood Floor Sanding

-

Commercial-grade dustless sanding equipment and a precise multi-grit process deliver the perfect surface every time.

- -
-
- - -
-
-
-
- What We Do -

Professional Floor Sanding in Western New York

-
-

Sanding is the foundation of any successful floor refinishing project. Our sanding technicians use professional belt sanders paired with dust containment systems for clean, precise results across every project.

-

Our multi-grit process starts aggressive to remove the existing finish and flatten the surface, then moves through progressively finer grits to deliver a surface that is perfectly smooth and ready for stain. We edge-sand and handle corners with care so there are no swirl marks or chatter marks.

- -
-
-
- Floor Sanding | Floor It Hardwood Floors -
-
-
-
-
- - -
-
-
- How It Works -

Our Floor Sanding Process

-

Proper sanding requires the right sequence of grits and careful technique for each section of your floor.

-
-
-
-
1
-

Initial Assessment

-

We check moisture content, identify problem areas like cupping or high-grain boards, and select the correct starting grit for your specific floor condition.

-
-
-
2
-

Multi-Grit Sanding

-

We progress through multiple grit sequences for a consistently smooth surface across the entire floor, from the field to the edges.

-
-
-
3
-

Edge and Detail Work

-

Edges are sanded with an edger, corners done by hand. Every square inch is sanded to the same standard as the center of the room.

-
-
-
-
- - -
-
-
- Why Choose Us -

What Sets Our Floor Sanding Apart

-
-
-
-
- -
-
-

Dustless Equipment

-

Our sanding equipment captures dust at the source, keeping your home cleaner and your family more comfortable throughout the process.

-
-
-
-
- -
-
-

No Swirl Marks

-

We use the correct grit sequence and orbital finishing to eliminate chatter and swirl marks that are common with inexperienced operators.

-
-
-
-
- -
-
-

Moisture Control

-

We check wood moisture content before sanding and monitor throughout. Sanding wood at the wrong moisture level causes problems that show up months later.

-
-
-
-
- -
-
-

Clean Results

-

Our process and equipment leave your floor ready for stain the same day, keeping your project on schedule with minimal disruption.

-
-
-
-
-
- - -
-
-
-
- Floor Sanding FAQ -

Common Questions

-
-

Have a question? Call us at (716) 602-1429 and we will answer it directly.

-
-
-
-
-

How many times can a floor be sanded?

- -
-
-
Solid hardwood can typically be sanded multiple times over its lifetime. Engineered hardwood depends on the wear layer thickness. We measure your floor before committing to sanding.
-
-
-
-
-

Will sanding remove deep scratches?

- -
-
-
Yes. Sanding removes the surface layer of wood, taking most scratches with it. Very deep gouges may require wood filler before sanding. We assess each area during the estimate.
-
-
-
-
-

How dusty is the sanding process?

- -
-
-
With our dust containment system, very little dust escapes into your home. We seal off doorways and use containment equipment so the rest of your home stays clean.
-
-
-
-
-
-
- - -
-
-
-
- Free Estimate -

Get Your Free Sanding Estimate

-
-

Tell us about your floors and we will respond within 1 business hour.

-
-
-
- -
- -
-
-
- -
-
Hours

Monday to Saturday: 8:00 AM to 5:00 PM

-
-
-
-
-

Get Your Free Sanding Estimate

-
- -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
-
- - -
-
-
1 business hour response
-
No obligation
-
Licensed & insured
-
- -
-
-
-
-
-
- -
- - - - - - - - diff --git a/services/index.html b/services/index.html deleted file mode 100644 index d1cdb00..0000000 --- a/services/index.html +++ /dev/null @@ -1,167 +0,0 @@ - - - - - - - Hardwood Floor Services | Refinishing, Restoration, Sanding | Floor It - - - - - - - - - - - - -
- -
-
- - What We Do -

Hardwood Floor Services in Buffalo, NY

-

Complete hardwood floor solutions: refinishing, restoration, sanding, and installation, delivered by Western New York's most experienced team.

-
-
- - -
-
-
-
- Service 01 -

Floor Refinishing

-
-

Restore your floors to their original beauty with professional sanding, staining, and sealing.

-

Our multi-stage refinishing process sands away years of wear and damage, then applies your choice of stain from over 100 color options, and seals with a durable topcoat that lasts 10 or more years.

-
    -
  • Multiple stain color options
  • -
  • Dustless sanding system
  • -
  • Long-lasting professional finish
  • -
  • Sample approval before full project
  • -
  • Timeline: 7 to 10 business days
  • -
- -
-
- Hardwood floor refinishing service -
-
-
-
- - -
-
-
-
- Hardwood floor restoration service -
-
- Service 02 -

Floor Restoration

-
-

Water damage, deep scratches, warping, and buckling repaired with expert precision.

-

When damage goes beyond surface wear, restoration is the answer. We repair structural damage, replace boards when needed, and restore your floor to like-new condition. We also provide documentation to support insurance claims.

-
    -
  • Water and moisture damage repair
  • -
  • Warping and buckling correction
  • -
  • Deep scratch and gouge restoration
  • -
  • Insurance documentation support
  • -
  • Timeline: 10 to 14 business days
  • -
- -
-
-
-
- - -
-
-
-
- Service 03 -

Floor Sanding

-
-

Commercial-grade equipment and dust containment for a perfectly prepared surface.

-

Professional sanding is the foundation of any successful refinishing project. Our multi-grit sanding process removes old finishes, levels imperfections, and creates the ideal surface for staining and sealing, while containing dust throughout the process.

-
    -
  • Commercial-grade equipment (not rentals)
  • -
  • Dustless sanding system
  • -
  • Multi-grit sanding process
  • -
  • Safe for family and pets
  • -
- -
-
- Hardwood floor sanding service -
-
-
-
- - -
-
-
-
- New hardwood floor installation -
-
- Service 04 -

Floor Installation

-
-

New hardwood floors installed with precision. Quality materials, expert finish, built to last generations.

-

Whether you are renovating a room or building new, our installation team brings the same care and attention to new floors as we do to restoration. We source quality materials and ensure a flawless finished result.

-
    -
  • Solid and engineered hardwood
  • -
  • Wide range of wood species
  • -
  • Residential and commercial
  • -
  • Professional site preparation
  • -
- -
-
-
-
- -
-
-

Not Sure Which Service You Need?

-

Call us and we will help you determine the right approach for your floors at no obligation.

- -
-
- -
- - - - - - - diff --git a/src/api/altcha-challenge.php b/src/api/altcha-challenge.php new file mode 100644 index 0000000..2d5edc6 --- /dev/null +++ b/src/api/altcha-challenge.php @@ -0,0 +1,12 @@ + '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']); + } +} diff --git a/src/api/components/_footer.php b/src/api/components/_footer.php new file mode 100644 index 0000000..9b37561 --- /dev/null +++ b/src/api/components/_footer.php @@ -0,0 +1,102 @@ + + + + + + + + + + + + diff --git a/src/api/components/_header.php b/src/api/components/_header.php new file mode 100644 index 0000000..a827189 --- /dev/null +++ b/src/api/components/_header.php @@ -0,0 +1,75 @@ +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); +?> + + + + + <?= $page_title ?> + + + + + + + + + + + + + +
diff --git a/src/api/contact.php b/src/api/contact.php new file mode 100644 index 0000000..f5b23a9 --- /dev/null +++ b/src/api/contact.php @@ -0,0 +1,196 @@ + 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]); diff --git a/src/api/data/blog.sqlite b/src/api/data/blog.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..01da22e85866cfcecffe8618b76c533f6956389c GIT binary patch literal 12288 zcmeHMU2hx56{V!Ki32<8n}9rBs0d^MTt**ulL%>A)uv-Lik#>JsSpIhaCeqF)$Gn@ zW>z#`4YWvs6n*Ni>2K(3e@pw;b7#0DqZqKC3ItsOGRfWH{W$mBb1&($FOQX%lp9+L zPy4-J^j21Sza{GRdaL+b!(aPY!HYNAH+bgvuX?QZ27mp}z4d?hR^J)--Wjj|^Hm?{ z?so`u2y_T^2y_T^2y_T^2y_T^2y_T^2>hfGc#*EWxv{aa@~8K_7-?A>=Uw<`_0j3@ z@O((;hrc@>Qn*C--=^6^Wps3MKKyieN?)8FeSUa)NuLcb`}6xwH)A><{^6W1PL963 z7}CkJb2_;=K3*L0%4-?V{P?DPo=RK$aC`BQk;$f4g)jB3>!R_6vC-D!;giFQ<8#`2 zq7AMc!G;v&*20JvV|VN5*WWDNubYupu8@liTa&vBBdL|b%j^nY%|n*$k^31gdsWK& zTa}q?ZEpuewzpS*zVYjgm7c0H`P_Z0@y-4UL7VXYDu8>n8=m-k{aehad-z*><7d6Q ze|zf`Pu-(KphMvQ4T0}|zjn9x%)EH>`|GXG@9a+s;S+BX`Mj2?PqN0UYMfY^s|t#R z*I(fPc|*f+fF6efBq(a(>Ea-LVa*?5REWBkm80ki zmTGHq>6|i^(3A&>UGmSj*=(P3Yf8$*luRYb7uGc60?+tCQ}HKLBmCgw`jgL~l88Gx zL*Oy9*4@-JmR@3>3aM*HxJ|Vd6^SaNQd~_m2_aE+upZ3kc(Yp5c_oydYt%x`Dxo@&%$TOKDSuz+F5^^4mt$rL6pbzLMc; z%q}3JDk>Z8RgD2Rw#rj2oRh8(u!Z%UuNaRl!+0Y@@bdyPJaT}-ISfbSFn2@OQpS`y zjZ#vorKkpNW3O$(xAV1pAS=Qv{rmsdJWKg_`GU^W(ltXn<9|V^?_;3+l+90x7fQ&>6?ty?QLv1VNak~u_ATmlS z1DMPoz=U7(c{9+caEn+#xP@#$k4)u7>Zu86j3okQ!pO+iA1pLqZ!Y!4W`A==db|Sd z+~zlXJA1!Mb{-~sAJFcH2M>1-c6PrzSiRf(X!>IR`(JKd+}SUL%_hcV$&E{7HAYm@ z%IxXvD&4pN4d<7EaiP+JjvSo^B)O+9xu>3jjK4WE8XIA2F_eB`a&dixe#ASF9_3Xw zVi(c3!79k6fMzBkI^c$>A_rYF3Y6)SHmTs^63ECD;a2Def7(C_y|g8bjnIz8N##VqD zA*+xlSAyC+0X_CsX4gp0^$!g<7Zv6aK8Dyxf#DB#cq;7f>?BhuENPX89kkD)$oWHo zV2gKZtsz-JvBgSN5IG-p3@#C9WjtI4#lx}+WkARy;Uu4dOQ;Vjl@v{>&}LKUq!a)< zroG_HqUMEg=m4D36mqIZhA zT0u9;DFnNu7=c@uyKok!jSE%f%G?qTRRK?}19q)m^fwp%`cm=ubG34}cbUF;|1bA= z69E)VIS)c5Do-Mh6IS%)b>t}k0|MtbMMrtmix7;X6C9b)V5(&@MH?g1Q4=aRrOZU<7`e)15Q-(4X4fUgXc(nY z0eQU=R#Xr@P8KTeTM}?z%pzyUT>Wc_UI>}P(uDD1p10k1)>z0C*xo{k&tQdvVB%by ze_<<|tB9z^STfL7h?9GUUBpcU?Tfi-EQ@Sh-FQz3txk`zh}`xuHn4)2*h1)MsFKt< zk`IHFN*?J;JI`#3;zdVmEQ=r`cW(;?`U389&>x?yYnHGQ+fH2XW)Y&A&}p8kKkt3{ literal 0 HcmV?d00001 diff --git a/src/api/data/locations.sqlite b/src/api/data/locations.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..8769bf18bc3502ce905e3b882687a5b9b22015aa GIT binary patch literal 36864 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 C%5(n! literal 0 HcmV?d00001 diff --git a/src/api/data/pages.db b/src/api/data/pages.db new file mode 100644 index 0000000..e69de29 diff --git a/src/api/data/pages.sqlite b/src/api/data/pages.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..8c16fc7b53e8b82235ffa04dca8a84431d3d7a21 GIT binary patch literal 24576 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 literal 0 HcmV?d00001 diff --git a/src/api/data/rate-limits/.gitkeep b/src/api/data/rate-limits/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/api/data/services.db b/src/api/data/services.db new file mode 100644 index 0000000..e69de29 diff --git a/src/api/data/services.sqlite b/src/api/data/services.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..6d325b82410ef6df16843a9f2cd6f304f516c3e6 GIT binary patch literal 28672 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 literal 0 HcmV?d00001 diff --git a/src/api/data/testimonials.sqlite b/src/api/data/testimonials.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..6b91d26e66fa343b48cdf881f54d9cdc136b780d GIT binary patch literal 8192 zcmeI1L2nyH6vyqfDWuSX!~t=L^iUKElCudy+;LKenmTE49EC_o)p+-%Co^#f6> z)!JCR@ljoE{C&9i9zMeT|6Loc=+D2Oz^eW9uNGd-)evY1Gz1y~4S|M0L!cqh5NHTA z1R4Sjf%i<{?z8p>pYQIr-%2mWiak3obEBnDZvFel(XiJY^=Q;R9Q0_tg}#1Fi%TZd zKOOac><#H`*gxqGujpm(s&n7|rZCu4k9xn1==2rd7lXm7Q4~Hi_P<({iG`O&SM94# zj_pjw^_Z()Ell;~-k0aS^MBlG0QWn0jyz+Nxb5J?^P1u`1di;CqldqnhK1n{7 zAN_%w=4uEu1R4Sjfrdaspdru@Xb8MV0(TD{ZMEL+fB9^`{o#>N3aeGDe+8Wworrmd zGLhgewaip4+6C6uGwGzK@tiI>(d;Tap%Y4#G1gJZmMJlsgNe_=0hdyy42$JsD`6ec zv2eaav0|Y+6uJg8%dSw~kwACn3$4*3qF05bZx0Uk<}B=GeKbJhr;#@qKKoMXIdb&)bfy}1e_LTkXUoAQ7JuskcI zMjv<8;07tiNC$|UTKM3y9e=387_rLsv?^ED&djCF3vb9 z6xZdrsPjq(sbip#*wlAO$!pZS4f<*>D~Ob!k`$n$Aat@UA3<4xT$`%GML&t09Sx&@ zCGdIOVe|H~jksQK??A!cP0Rogk`~ApGNDqm3y0{C$Y8ec5*~GegscwoB!=3mltJE0iz-EXn3jSdNQirp3&)G{oM6s~&V>~j QokWL4nhIssb91HGKQin`O#lD@ literal 0 HcmV?d00001 diff --git a/src/api/promo.php b/src/api/promo.php new file mode 100644 index 0000000..f01cffd --- /dev/null +++ b/src/api/promo.php @@ -0,0 +1,73 @@ + 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 '; +$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.']); +} diff --git a/src/api/router.php b/src/api/router.php new file mode 100644 index 0000000..8933366 --- /dev/null +++ b/src/api/router.php @@ -0,0 +1,31 @@ + __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; diff --git a/src/api/templates/blog.php b/src/api/templates/blog.php new file mode 100644 index 0000000..b392069 --- /dev/null +++ b/src/api/templates/blog.php @@ -0,0 +1,102 @@ +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'; + ?> +
+
+
+ Hardwood Floor Tips +

The Floor It Blog

+

Expert advice on hardwood floor refinishing, restoration, and care from the Buffalo area's most experienced team.

+
+
+
+
+
+ +
+ + + +
+ +

No posts yet. Check back soon.

+ +
+
+
+
+
+

Questions About Your Floors?

+

Contact our team for a free estimate and expert advice.

+
+ Request an Estimate +
+
+ 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 '

Post Not Found

Back to blog

'; + require __DIR__ . '/../components/_footer.php'; + exit; + } + + $page_title = $post['title'] . ' | Floor It Hardwood Floors'; + $page_meta = $post['excerpt'] ?? ''; + require __DIR__ . '/../components/_header.php'; + ?> +
+
+
+ Floor It Blog +

+

+
+
+
+
+
+ +

Back to blog

+
+
+
+
+
+

Ready to Restore Your Floors?

+

Request a free estimate from our Buffalo team.

+
+ Get a Free Estimate +
+
+ \ No newline at end of file diff --git a/src/api/templates/location.php b/src/api/templates/location.php new file mode 100644 index 0000000..808a1b1 --- /dev/null +++ b/src/api/templates/location.php @@ -0,0 +1,122 @@ +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 '

Location Not Found

View all locations

'; + 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'; +?> + +
+
+
+ +

+

+ Request a Estimate +
+
+
+ + +
+
+
+ +

+
+

+

+
+ $body["stat_{$i}_num"], 'label' => $body["stat_{$i}_label"] ?? '', 'sub' => $body["stat_{$i}_sub"] ?? '']; + } + } + if ($stats): ?> +
+ +
+ + + +
+ +
+ +
+
+ + + +
+
+
+ Services in +

Hardwood Floor Services in , NY

+

+
+
+ + +
+

+

+
+ + +
+
+
+ + + +
+
+
+ FAQs +

Common Questions from Homeowners

+
+
+ +
+ +

+
+ +
+
+
+ + +
+
+
+

+

We respond to all inquiries within 24 hours.

+
+ +
+
+ + diff --git a/src/api/templates/page.php b/src/api/templates/page.php new file mode 100644 index 0000000..9d2f2ca --- /dev/null +++ b/src/api/templates/page.php @@ -0,0 +1,391 @@ +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 '

Page Not Found

Return home

'; + 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'] ?? []; + ?> +
+
+ +
+
+
+
+
+
+ +
+

+

+
+ + + + +
+ +
+ +
+ + +
+ +
+ +
+
+
+ +
+
+
+ +

+

+
+
+
+ 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', + ]; + ?> +
+
+
+ +

+

+
+ +
+
+ +
+
+
+ +

+
+
+ +
+
+

+

+
+ +
+
+
+ +
+
+
+ +

+
+

+

+ +
+ +
+ +
+
+ Hardwood floor refinishing in Western New York +
+
+
+ + + +
+
+
+

+

+
+ +
+
+ +
+
+
+

+
Phone
+
Email
+
Response TimeWithin 24 hours
+
Service AreaBuffalo and Erie County, NY
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+
+
+
+ + + +
+
+
+ +

+
+

+

+
+
+ Floor It refinishing equipment +
+
+
+ +
+
+
+ +

+
+
+
30+Years in Business
+
75+Years Combined Experience
+
500+Projects Completed
+
4.9/5Google Rating
+
+
+
+ query("SELECT slug, city FROM locations ORDER BY id"); + $locations = []; + while ($row = $loc_result->fetchArray(SQLITE3_ASSOC)) { $locations[] = $row; } + $ldb->close(); + ?> +
+
+
+ +

+

+
+
+ + , NY + +
+
+
+ query("SELECT * FROM testimonials {$featured} ORDER BY id LIMIT {$limit}"); + $testimonials = []; + while ($row = $t_result->fetchArray(SQLITE3_ASSOC)) { $testimonials[] = $row; } + $tdb->close(); + ?> +
+
+
+ +

+
+
+ +
+
+

""

+
+ + +
+
+ +
+
+
+ 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(); + ?> +
+
+
+ + + +
+
+
+ query("SELECT slug, city, hero_lead FROM locations ORDER BY id"); + $locs = []; + while ($row = $loc2_result->fetchArray(SQLITE3_ASSOC)) { $locs[] = $row; } + $ldb2->close(); + ?> +
+ +
+ 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 '

Service Not Found

View all services

'; + 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'; +?> + +
+
+ +
+
+ + +
+
+
+

+

+

+
+ +
<?= htmlspecialchars($svc['service_name'] ?? '', ENT_QUOTES) ?>
+ +
+
+ + + +
+
+
+ Our Process +

How We Do It

+

+
+
+ + +
+
0
+

+

+
+ + +
+
+
+ + + $body["benefit_{$i}_title"], 'body' => $body["benefit_{$i}_body"] ?? '']; + } +} +if ($benefits): ?> +
+
+
+ Why Choose Us +

What Sets Us Apart

+
+
+ +
+

+

+
+ +
+
+
+ + + +
+
+
+ Common Questions +

Frequently Asked Questions

+
+
+ +
+ +

+
+ +
+
+
+ + +
+
+
+

+

Contact our team and we will respond within 24 hours.

+
+ +
+
+ +