Phase 4: implement full static site (Eleventy, 15 pages, CSS, JS)

This commit is contained in:
Tom Roth
2026-07-31 08:33:06 +02:00
parent ecbd9f94e5
commit 34f7f5a1f5
24 changed files with 5986 additions and 8 deletions

172
src/_data/services.json Normal file
View File

@@ -0,0 +1,172 @@
[
{
"name": "Painting",
"slug": "painting",
"eyebrow": "Interior & Exterior",
"lead": "From prep to final coat, Diana handles painting projects of all sizes — interior rooms, exterior siding, and trim work.",
"description": "Interior and exterior painting, including trim work.",
"images": [
{ "file": "painting-5-min.jpg", "alt": "Exterior of a dark brown house with red-framed windows, freshly painted." },
{ "file": "painting-6-min.jpg", "alt": "Spacious room with coffered ceiling and French doors painted in a warm neutral tone." },
{ "file": "painting-1-min.png", "alt": "Two-story beige house with white trim and columns at the entrance." }
],
"included": [
"Interior wall and ceiling painting",
"Exterior siding and trim painting",
"Prep work — patching, priming, and masking",
"Trim and door painting",
"Color consultation"
]
},
{
"name": "Tile Setting",
"slug": "tile-setting",
"eyebrow": "Kitchens, Bathrooms & More",
"lead": "Precise tile work for kitchens, bathrooms, entryways, and patios — all sizes and styles, installed right.",
"description": "Removing and installing all sizes of tiles in kitchens, bathrooms, entryways, and patios.",
"images": [
{ "file": "tile-setting-4-min.png", "alt": "Modern bathroom with brown tiled walls and a white sink, freshly tiled." },
{ "file": "tile-setting-6-min.png", "alt": "Modern kitchen with a black and white mosaic tile backsplash." },
{ "file": "tile-setting-5-min.png", "alt": "Kitchen with a tiled floor, beige cabinets, and a blue island." }
],
"included": [
"Tile removal and surface preparation",
"New tile installation — kitchens, bathrooms, entryways, patios",
"All tile sizes and types (ceramic, porcelain, natural stone)",
"Grouting and sealing",
"Backsplash installation"
]
},
{
"name": "Drywall & Sheetrocking",
"slug": "drywall",
"eyebrow": "Repair & Replacement",
"lead": "Patch holes, replace damaged sections, and finish walls so they look like they were never touched.",
"description": "Drywall and sheetrocking — removing, replacing, patching, and sanding.",
"images": [
{ "file": "sheetrocking-1-min.png", "alt": "Hands applying joint compound to patch a square hole in a wall." },
{ "file": "sheetrocking-2-min.png", "alt": "Unfinished room with new drywall installed, ready for finishing." },
{ "file": "sheetrocking-5-min.png", "alt": "Interior wall framing with insulation, ready for drywall." }
],
"included": [
"Drywall and sheetrocking installation",
"Hole and damage patching",
"Seam taping and joint compound",
"Sanding and texture matching",
"Wall preparation for painting"
]
},
{
"name": "Minor Plumbing",
"slug": "plumbing",
"eyebrow": "Sinks, Toilets & Showers",
"lead": "Quick, clean plumbing repairs and replacements — no mess left behind.",
"description": "Minor plumbing work including sink, toilet, and shower repairs.",
"images": [
{ "file": "plumbing-3-min.png", "alt": "Dual-basin stainless steel kitchen sink with a high-arc faucet, newly installed." },
{ "file": "plumbing-6-min.png", "alt": "Chrome bathroom sink faucet with two handles, polished and installed." },
{ "file": "plumbing-2-min.png", "alt": "White bathroom sink with running water from a chrome faucet." }
],
"included": [
"Sink repairs and faucet replacement",
"Toilet repairs and replacement",
"Shower and tub repairs",
"Showerhead and handle replacement",
"Under-sink plumbing fixes"
]
},
{
"name": "Minor Electrical",
"slug": "electrical",
"eyebrow": "Lights, Fans & Fixtures",
"lead": "Safe, clean electrical work for the fixtures and fittings that make your home feel finished.",
"description": "Minor electrical work, including installing lights and fans.",
"images": [
{ "file": "electrical-7-min.jpg", "alt": "Person installing a ceiling light fixture, connecting wires safely." },
{ "file": "electrical-1-min.jpg", "alt": "Bathroom vanity with multiple light fixtures installed above the mirror." },
{ "file": "electrical-3-min.jpg", "alt": "Ceiling fan with five wooden blades and four lights, mounted on a white ceiling." }
],
"included": [
"Light fixture installation and replacement",
"Ceiling fan installation",
"Outdoor and security lighting",
"Bathroom vanity lighting",
"Wall sconces and under-cabinet lights"
]
},
{
"name": "Carpentry & Mounting",
"slug": "carpentry-and-mounting",
"eyebrow": "Installation & Assembly",
"lead": "Precise carpentry and secure mounting work — shelves that are level, TVs that stay put, and trim that fits perfectly.",
"description": "Carpentry, shelving installation, TV mounting, cabinet repairs, and trim work.",
"images": [
{ "file": "wood-refinishing-4-min.png", "alt": "Electric sander smoothing a wooden surface for carpentry work." },
{ "file": "grab-bar-min.png", "alt": "Grab bar securely mounted to a wall." },
{ "file": "security-garage-door-min.png", "alt": "Hardware being installed on a wall-mounted track." }
],
"included": [
"Shelving and storage installation",
"TV and display mounting",
"Cabinet repairs and hardware replacement",
"Door and window trim work",
"Furniture assembly",
"Custom carpentry projects"
]
},
{
"name": "Flooring & Wood Refinishing",
"slug": "flooring",
"eyebrow": "Floors, Decks & Surfaces",
"lead": "Restore tired floors and wood surfaces to their original beauty — or better.",
"description": "Flooring touch-ups and wood refinishing for floors, decks, stairs, cabinets, and more.",
"images": [
{ "file": "wood-refinishing-7-min.png", "alt": "Hardwood floor half sanded and restored, showing the before and after." },
{ "file": "wood-refinishing-8-min.png", "alt": "Empty room with freshly refinished shiny wooden floors." },
{ "file": "wood-refinishing-1-min.png", "alt": "Person applying wood stain to a deck with a paintbrush." }
],
"included": [
"Hardwood floor sanding and refinishing",
"Floor staining and sealing",
"Flooring touch-ups and repairs",
"Exterior deck and stair refinishing",
"Cabinet and furniture refinishing",
"Interior and exterior wood surface restoration"
]
},
{
"name": "Security & Safety Equipment",
"slug": "security-safety",
"eyebrow": "Grab Bars, Locks & Cameras",
"lead": "Make your home safer and more secure — for aging in place, peace of mind, or both.",
"description": "Grab bars, security bars, window mesh, and security camera installation.",
"images": [
{ "file": "grab-bar-min.png", "alt": "White grab bar securely mounted to a bathroom wall for safety." },
{ "file": "security-garage-door-min.png", "alt": "Metal security hardware being installed on a garage door track." }
],
"included": [
"Grab bar installation for showers and stairways",
"Door security bars and reinforcement",
"Window security mesh installation",
"Security camera installation (mobile-monitored)",
"Extra lock installation"
]
},
{
"name": "Boat Work",
"slug": "boat-work",
"eyebrow": "Marine Woodwork & Hardware",
"lead": "Quality boat maintenance from someone who knows the Bay — woodwork, paint, and marine hardware done right.",
"description": "Boat woodwork, spray paint, and marine hardware replacement.",
"images": [
{ "file": "boat-work-min-scaled.jpg", "alt": "Yellow and white sailboat docked at a marina in the San Francisco Bay." }
],
"included": [
"Marine woodwork and structural repairs",
"Boat spray painting and refinishing",
"Marine hardware replacement",
"Deck maintenance and sealing",
"Interior boat carpentry"
]
}
]

19
src/_data/site.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "Home Enhancement and Renovation",
"shortName": "HER",
"tagline": "San Francisco's trusted handywoman.",
"description": "Precise, reliable, and easy to work with — Diana brings aerospace-level project coordination to your home improvement project.",
"url": "https://her-sf.com",
"phone": "+16508339380",
"phoneDisplay": "(650) 833-9380",
"email": "diana@spotlighthis.com",
"serviceArea": "San Francisco and the Bay Area (within 50 miles)",
"serviceAreaCities": [
"San Francisco", "Oakland", "Berkeley", "San Jose", "Palo Alto",
"Marin County", "Tiburon", "Sausalito", "Daly City",
"South San Francisco", "Burlingame", "San Mateo", "Redwood City",
"Menlo Park", "Fremont", "Alameda"
],
"instagram": "https://instagram.com/spotlight_handy",
"defaultOgImage": "/assets/diana_photo-min-1-scaled.webp"
}

View File

@@ -0,0 +1,30 @@
[
{
"quote": "Diana can pretty much do anything. She saved me when I was selling my house and had a big list of miscellaneous tasks to be done. She is so resourceful — if she didn't readily know a solution she researched it and presented me with options. What impressed me was her work ethic. She doesn't have to be hounded to finish a job. She takes accountability and delivers. She's a rare find because she is so reliable, talented, hardworking and resourceful!",
"pullQuote": "She is so reliable, talented, hardworking and resourceful.",
"author": "Wendy Brummer",
"city": "San Francisco",
"featured": true
},
{
"quote": "I have hired Diana Carey for a variety of tasks. She is hardworking, trustworthy, and does excellent work. Bonus points for excellent communication and a keen eye on keeping things as affordable as possible. Highly recommended!",
"pullQuote": "Hardworking, trustworthy, and does excellent work.",
"author": "E. Blake Davis",
"city": "",
"featured": false
},
{
"quote": "Diana has helped me several times and she is now my go-to handyperson for all needs. She has been great! Very reliable, communicates well, and really dedicated to the best solution. Diana has a wide variety of skills and does quality work at reasonable rates. She gets my top rating.",
"pullQuote": "Really dedicated to the best solution.",
"author": "David Snyder",
"city": "Oakland",
"featured": true
},
{
"quote": "If you need a project done well and quickly with a combination of big picture perspective and meticulous attention to detail, Diana is THE person. It's hard to find a more competent person who is so thorough and really cares about doing the project well and on time.",
"pullQuote": "A combination of big picture perspective and meticulous attention to detail.",
"author": "Alice L. Harron",
"city": "",
"featured": false
}
]

View File

@@ -0,0 +1,8 @@
<section class="cta-band" aria-label="Call to action">
<div class="cta-band__inner">
<p class="section-eyebrow">Ready to get started?</p>
<h2 class="cta-band__title">Let's talk about your project.</h2>
<p class="cta-band__sub">Diana responds within 1&ndash;2 business days.</p>
<a href="/contact/" class="btn btn--ghost-white">Request a Quote</a>
</div>
</section>

View File

@@ -0,0 +1,33 @@
<footer class="footer">
<div class="footer__inner container">
<div>
<a href="/" class="footer__brand">HER</a>
<p class="footer__tagline">Home Enhancement and Renovation<br>San Francisco &amp; Bay Area</p>
</div>
<nav aria-label="Footer navigation">
<p class="footer__nav-heading">Services</p>
<ul class="footer__nav" role="list">
{% for service in services %}
<li><a href="/services/{{ service.slug }}/">{{ service.name }}</a></li>
{% endfor %}
</ul>
</nav>
<div>
<p class="footer__nav-heading">Contact</p>
<div class="footer__contact">
<a href="{{ site.phone | telHref }}">{{ site.phoneDisplay }}</a>
<a href="mailto:{{ site.email }}">{{ site.email }}</a>
</div>
</div>
</div>
<div class="footer__bottom">
<div class="container">
<p>&copy; {{ "" | currentYear }} Home Enhancement and Renovation &mdash; HER. All rights reserved.</p>
</div>
</div>
</footer>

View File

@@ -0,0 +1,55 @@
<a href="#main-content" class="skip-link">Skip to main content</a>
<header class="nav" role="banner">
<div class="nav__inner container">
<a href="/" class="nav__brand" aria-label="HER Home Enhancement and Renovation — Home">HER</a>
<nav aria-label="Main navigation">
<ul class="nav__links" role="list">
<li><a href="/about/">About</a></li>
<li><a href="/services/">Services</a></li>
<li><a href="/testimonials/">Testimonials</a></li>
<li><a href="/contact/">Contact</a></li>
</ul>
</nav>
<div class="nav__actions">
<a href="{{ site.phone | telHref }}" class="nav__phone" aria-label="Call Diana at {{ site.phoneDisplay }}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 12 19.79 19.79 0 0 1 1.61 3.4 2 2 0 0 1 3.6 1.22h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L7.91 8.78a16 16 0 0 0 6 6l.92-.92a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 21.5 16h.5a2 2 0 0 1 .02.92z"/>
</svg>
</a>
<a href="/contact/" class="btn btn--primary" style="display:none" id="nav-cta">Request a Quote</a>
<button
class="nav__toggle"
aria-label="Open menu"
aria-expanded="false"
aria-controls="nav-overlay"
>
<span class="nav__bar"></span>
<span class="nav__bar"></span>
<span class="nav__bar"></span>
</button>
</div>
</div>
{# Mobile overlay #}
<div class="nav__overlay" id="nav-overlay" hidden>
<button class="nav__overlay-close" aria-label="Close menu">&#x2715;</button>
<nav aria-label="Mobile navigation">
<ul role="list">
<li><a href="/about/">About</a></li>
<li><a href="/services/">Services</a></li>
<li><a href="/testimonials/">Testimonials</a></li>
<li><a href="/contact/">Contact</a></li>
</ul>
</nav>
<a href="/contact/" class="btn btn--ghost-white btn--full">Request a Quote</a>
<a href="{{ site.phone | telHref }}" class="nav__overlay-phone">{{ site.phoneDisplay }}</a>
</div>
</header>

View File

@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if title %}{{ title }} | HER{% else %}{{ site.shortName }}{% endif %}</title>
{% if description %}
<meta name="description" content="{{ description }}">
{% endif %}
<link rel="canonical" href="{{ site.url }}{{ page.url }}">
{# Open Graph #}
<meta property="og:title" content="{% if title %}{{ title }} | HER{% else %}{{ site.shortName }}{% endif %}">
{% if description %}<meta property="og:description" content="{{ description }}">{% endif %}
<meta property="og:type" content="website">
<meta property="og:url" content="{{ site.url }}{{ page.url }}">
<meta property="og:image" content="{{ site.url }}{% if ogImage %}/assets/{{ ogImage }}{% else %}{{ site.defaultOgImage }}{% endif %}">
{# Twitter Card #}
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{% if title %}{{ title }} | HER{% else %}{{ site.shortName }}{% endif %}">
{% if description %}<meta name="twitter:description" content="{{ description }}">{% endif %}
<meta name="twitter:image" content="{{ site.url }}{% if ogImage %}/assets/{{ ogImage }}{% else %}{{ site.defaultOgImage }}{% endif %}">
{# Google Fonts #}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@1,9..144,400&family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600&display=swap">
<link rel="stylesheet" href="/css/main.css">
{# Page-specific schema (e.g. Service schema passed via frontmatter) #}
{% if schema %}
{{ schema | safe }}
{% endif %}
{# LocalBusiness JSON-LD (always included) #}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "LocalBusiness",
"@id": "https://her-sf.com/#business",
"name": "Home Enhancement and Renovation — HER",
"url": "https://her-sf.com",
"telephone": "+16508339380",
"email": "diana@spotlighthis.com",
"image": "https://her-sf.com/assets/diana_photo-min-1-scaled.webp",
"priceRange": "$$",
"address": {
"@type": "PostalAddress",
"addressLocality": "San Francisco",
"addressRegion": "CA",
"addressCountry": "US"
},
"geo": {
"@type": "GeoCoordinates",
"latitude": 37.7749,
"longitude": -122.4194
},
"areaServed": [
"San Francisco", "Oakland", "Berkeley", "San Jose", "Palo Alto",
"Marin County", "Tiburon", "Sausalito", "Daly City",
"South San Francisco", "Burlingame", "San Mateo", "Redwood City",
"Menlo Park", "Fremont", "Alameda"
]
}
</script>
</head>
<body>
{% include "components/nav.njk" %}
<main id="main-content">
{{ content | safe }}
</main>
{% include "components/footer.njk" %}
<script src="/js/main.js" defer></script>
</body>
</html>

View File

@@ -0,0 +1,82 @@
---
layout: layouts/base.njk
---
{#
This layout supports two usage patterns:
1. Paginated from services.json — the `service` variable (pagination alias) is the full service object.
2. Individual page with frontmatter — individual `serviceTitle`, `eyebrow`, `lead`, `images`, `included` variables.
#}
{% if service %}
{% set _title = service.name %}
{% set _eyebrow = service.eyebrow %}
{% set _lead = service.lead %}
{% set _images = service.images %}
{% set _included = service.included %}
{% else %}
{% set _title = serviceTitle %}
{% set _eyebrow = eyebrow %}
{% set _lead = lead %}
{% set _images = images %}
{% set _included = included %}
{% endif %}
<section class="page-hero bg-graphite">
<div class="page-hero__inner container">
<nav class="breadcrumb" aria-label="Breadcrumb">
<ol role="list">
<li><a href="/">Home</a></li>
<li><a href="/services/">Services</a></li>
<li aria-current="page">{{ _title }}</li>
</ol>
</nav>
<p class="section-eyebrow">{{ _eyebrow }}</p>
<h1 class="page-hero__title">{{ _title }}</h1>
<p class="page-hero__lead">{{ _lead }}</p>
<a href="/contact/" class="btn btn--primary">Request a Quote</a>
</div>
</section>
<section class="service-detail section">
<div class="container">
{% if _images and _images.length %}
<div class="service-detail__gallery">
{% for image in _images %}
<figure>
<img
src="/assets/{{ image.file }}"
alt="{{ image.alt }}"
loading="{% if loop.first %}eager{% else %}lazy{% endif %}"
width="800"
height="600"
>
</figure>
{% endfor %}
</div>
{% endif %}
<div class="service-detail__body">
<h2>What's included</h2>
{% if _included and _included.length %}
<ul class="service-detail__list">
{% for item in _included %}
<li>{{ item }}</li>
{% endfor %}
</ul>
{% endif %}
<div class="service-detail__area">
<p>Diana serves San Francisco and the wider Bay Area within 50 miles, including Oakland, Berkeley, Marin County, Palo Alto, and beyond.</p>
</div>
<a href="/contact/" class="btn btn--primary">Request a Quote</a>
</div>
</div>
</section>
{# Page body content (Eleventy compatibility) #}
{{ content | safe }}
{% include "components/cta-band.njk" %}

51
src/about.njk Normal file
View File

@@ -0,0 +1,51 @@
---
layout: layouts/base.njk
title: "About Diana"
description: "Meet Diana — San Francisco's trusted handywoman. Decades of experience, aerospace project management background, and a passion for getting things done right."
ogImage: "diana_photo-min-1-scaled.webp"
---
{# ── Page hero ─────────────────────────────────────────────────────────────── #}
<section class="page-hero bg-graphite">
<div class="page-hero__inner container">
<p class="section-eyebrow">About Diana</p>
<h1 class="page-hero__title">Built to fix things.<br>Built to last.</h1>
</div>
</section>
{# ── About grid ────────────────────────────────────────────────────────────── #}
<section class="about-page section">
<div class="about-page__grid container">
<div class="about-page__image">
<img
src="/assets/diana_photo-min-1-scaled.webp"
alt="Diana on a sailboat with the Golden Gate Bridge in the background."
loading="eager"
width="800"
height="1067"
>
</div>
<div class="about-page__content">
<p class="section-eyebrow">My story</p>
<h2>A lifetime of craft,<br>sharpened by aerospace.</h2>
<div class="about-page__bio">
<p>My passion has always been building and fixing things. My early professional experience includes interior and exterior residential painting. As a tile setter, I realized I could provide better services and began my own tile company, where I provided both commercial and residential installations for over 200 customers.</p>
<p>As my passion continued to develop, it led me to an opportunity to work as an airline mechanic and satellite engineer. Aerospace gave me the opportunity to manage large multimillion-dollar projects where I gained project management, engineering design, and material procurement expertise.</p>
<p>After a long career in aerospace, I wanted to return to my passion of working with people and help them complete the project of their dreams.</p>
</div>
<blockquote class="about-page__pullquote">
<p>Over 200 tile installations. Multimillion-dollar aerospace projects. Now she's working on yours.</p>
</blockquote>
<a href="/contact/" class="btn btn--primary">Request a Quote</a>
</div>
</div>
</section>
{# ── CTA band ─────────────────────────────────────────────────────────────── #}
{% include "components/cta-band.njk" %}

161
src/contact.njk Normal file
View File

@@ -0,0 +1,161 @@
---
layout: layouts/base.njk
title: "Request a Quote"
description: "Request a quote from Diana — San Francisco's trusted handywoman. Tell her about your project and she'll get back to you within 12 business days."
---
{# ── Page hero ─────────────────────────────────────────────────────────────── #}
<section class="page-hero bg-graphite">
<div class="page-hero__inner container">
<p class="section-eyebrow">Get in touch</p>
<h1 class="page-hero__title">Request a Quote.</h1>
<p class="page-hero__lead">Tell Diana about your project. She responds within 1&ndash;2 business days.</p>
</div>
</section>
{# ── Contact grid ──────────────────────────────────────────────────────────── #}
<section class="contact-page section">
<div class="contact-page__grid container">
{# ── Form column ─────────────────────────────────────────────────────── #}
<div>
<form class="quote-form" id="quote-form" novalidate aria-label="Quote request form">
{# Honeypot — hidden from real users, catches bots #}
<div class="visually-trap" aria-hidden="true">
<label for="website">Leave this blank</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
</div>
{# Error banner — shown by JS when form has validation errors #}
<div class="form-error-banner" role="alert" aria-live="assertive" hidden>
<p>Please correct the errors below before sending.</p>
</div>
{# Name #}
<div class="form-field">
<label for="name">Your name <span aria-hidden="true">*</span></label>
<input
type="text"
id="name"
name="name"
required
autocomplete="name"
aria-required="true"
aria-describedby="name-error"
>
<p class="form-field__error" id="name-error" aria-live="polite"></p>
</div>
{# Address / Location #}
<div class="form-field">
<label for="address">Project address <span aria-hidden="true">*</span></label>
<input
type="text"
id="address"
name="address"
required
autocomplete="off"
aria-required="true"
aria-describedby="address-hint address-error"
>
<p class="form-field__hint" id="address-hint">Where is the work located? City or full address.</p>
<p class="form-field__error" id="address-error" aria-live="polite"></p>
</div>
{# Phone #}
<div class="form-field">
<label for="phone">Phone number <span aria-hidden="true">*</span></label>
<input
type="tel"
id="phone"
name="phone"
required
inputmode="tel"
autocomplete="tel"
aria-required="true"
aria-describedby="phone-error"
>
<p class="form-field__error" id="phone-error" aria-live="polite"></p>
</div>
{# Email #}
<div class="form-field">
<label for="email">Email address <span aria-hidden="true">*</span></label>
<input
type="email"
id="email"
name="email"
required
inputmode="email"
autocomplete="email"
aria-required="true"
aria-describedby="email-error"
>
<p class="form-field__error" id="email-error" aria-live="polite"></p>
</div>
{# Project description #}
<div class="form-field">
<label for="description">Project description <span aria-hidden="true">*</span></label>
<textarea
id="description"
name="description"
rows="5"
required
aria-required="true"
aria-describedby="description-hint description-char-count description-error"
></textarea>
<p class="form-field__hint" id="description-hint">What needs to be fixed, replaced, or improved?</p>
<p class="form-field__char-count" id="description-char-count" aria-live="polite"></p>
<p class="form-field__error" id="description-error" aria-live="polite"></p>
</div>
<button type="submit" class="btn btn--primary form__submit">Send My Request</button>
{# Success message — shown by JS after successful submission #}
<div class="form-success" role="status" hidden>
<p class="form-success__icon" aria-hidden="true">&#x2713;</p>
<h2 class="form-success__title">Your request is on its way!</h2>
<p class="form-success__body">Diana will review your request and get back to you within 1&ndash;2 business days. If your project is urgent, you can also reach her directly at <a href="{{ site.phone | telHref }}">{{ site.phoneDisplay }}</a>.</p>
</div>
</form>
</div>
{# ── Contact secondary column ─────────────────────────────────────────── #}
<aside class="contact-secondary">
<h2>Other ways to reach Diana</h2>
<div class="contact-secondary__method">
<span class="contact-secondary__icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 12 19.79 19.79 0 0 1 1.61 3.4 2 2 0 0 1 3.6 1.22h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L7.91 8.78a16 16 0 0 0 6 6l.92-.92a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 21.5 16h.5a2 2 0 0 1 .02.92z"/>
</svg>
</span>
<div>
<p class="contact-secondary__label">Call or text</p>
<a href="{{ site.phone | telHref }}" class="contact-secondary__value">{{ site.phoneDisplay }}</a>
</div>
</div>
<div class="contact-secondary__method">
<span class="contact-secondary__icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
</span>
<div>
<p class="contact-secondary__label">Email</p>
<a href="mailto:{{ site.email }}" class="contact-secondary__value">{{ site.email }}</a>
</div>
</div>
<p>For urgent repairs, calling is fastest.</p>
<p class="contact-secondary__area">Diana serves {{ site.serviceArea }}.</p>
</aside>
</div>
</section>

1831
src/css/main.css Normal file

File diff suppressed because it is too large Load Diff

150
src/index.njk Normal file
View File

@@ -0,0 +1,150 @@
---
layout: layouts/base.njk
title: "Handywoman San Francisco"
description: "San Francisco's trusted handywoman — precise, reliable, and easy to work with. Diana brings aerospace-level project coordination to your home. Request a quote."
ogImage: "diana_photo-min-1-scaled.webp"
---
{# ── Hero ─────────────────────────────────────────────────────────────────── #}
<section class="hero">
<div class="hero__inner container">
<div class="hero__content">
<p class="section-eyebrow">San Francisco Handywoman</p>
<h1 class="display-title">Fix it right.<br>The first time.</h1>
<p class="lead-text">Precise, reliable, and easy to work with — Diana brings aerospace-level project coordination to your home improvement project.</p>
<div class="hero__actions">
<a href="/contact/" class="btn btn--primary">Request a Quote</a>
<div class="hero__contact">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 12 19.79 19.79 0 0 1 1.61 3.4 2 2 0 0 1 3.6 1.22h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L7.91 8.78a16 16 0 0 0 6 6l.92-.92a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 21.5 16h.5a2 2 0 0 1 .02.92z"/>
</svg>
<a href="{{ site.phone | telHref }}">{{ site.phoneDisplay }}</a>
</div>
</div>
</div>
<div class="hero__image">
<img
src="/assets/diana_photo-min-1-scaled.webp"
alt="Diana on a sailboat with the Golden Gate Bridge in the background, smiling."
loading="eager"
fetchpriority="high"
width="1440"
height="1920"
>
</div>
</div>
</section>
{# ── Services ─────────────────────────────────────────────────────────────── #}
<section class="services-section section bg-linen">
<div class="services-section__header container">
<p class="section-eyebrow">What Diana does</p>
<h2>Services for every room &mdash; and beyond.</h2>
</div>
<div class="services-grid container">
{% for service in services %}
<a href="/services/{{ service.slug }}/" class="service-card fade-up">
<div class="service-card__image">
<img
src="/assets/{{ service.images[0].file }}"
alt="{{ service.images[0].alt }}"
loading="lazy"
>
</div>
<div class="service-card__body">
<p class="service-card__name">{{ service.name }}</p>
<p class="service-card__desc">{{ service.description }}</p>
<span class="service-card__arrow" aria-hidden="true">&rarr;</span>
</div>
</a>
{% endfor %}
</div>
</section>
{# ── Why HER ──────────────────────────────────────────────────────────────── #}
<section class="why-her section bg-mist">
<div class="why-her__header container">
<p class="section-eyebrow">Why choose HER</p>
<h2>What makes Diana different.</h2>
</div>
<div class="why-her__grid container">
<div class="trust-pillar fade-up">
<div class="trust-pillar__icon" aria-hidden="true">&#x2713;</div>
<h3 class="trust-pillar__title">Reliable &amp; Accountable</h3>
<p class="trust-pillar__body">Diana sets a schedule and sticks to it. No chasing, no surprises &mdash; just clear communication and work delivered on time.</p>
</div>
<div class="trust-pillar fade-up">
<div class="trust-pillar__icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<polyline points="23 4 23 10 17 10"/>
<polyline points="1 20 1 14 7 14"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
</div>
<h3 class="trust-pillar__title">Project Coordination</h3>
<p class="trust-pillar__body">With a background managing multimillion-dollar aerospace projects, Diana brings big-picture thinking and attention to detail to every home job.</p>
</div>
<div class="trust-pillar fade-up">
<div class="trust-pillar__icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
</div>
<h3 class="trust-pillar__title">Easy to Work With</h3>
<p class="trust-pillar__body">Homeowners consistently describe working with Diana as refreshingly straightforward &mdash; honest pricing, calm approach, and no attitude.</p>
</div>
</div>
</section>
{# ── Testimonials strip ────────────────────────────────────────────────────── #}
<section class="testimonials-strip section bg-linen">
<div class="testimonials-strip__header container">
<p class="section-eyebrow">Client reviews</p>
<h2>What people say.</h2>
<a href="/testimonials/" class="btn btn--secondary">All reviews</a>
</div>
<div class="testimonials-grid container">
{% for testimonial in testimonials %}
{% if testimonial.featured %}
<div class="testimonial-card fade-up">
<blockquote>
<p class="testimonial-card__quote">&#x201C;{{ testimonial.pullQuote }}&#x201D;</p>
<footer>
<p class="testimonial-card__author">{{ testimonial.author }}</p>
{% if testimonial.city %}
<p class="testimonial-card__city">{{ testimonial.city }}</p>
{% endif %}
</footer>
</blockquote>
</div>
{% endif %}
{% endfor %}
</div>
</section>
{# ── Service area ─────────────────────────────────────────────────────────── #}
<section class="service-area section">
<div class="container">
<h2 class="service-area__title">Serving San Francisco and the Bay Area</h2>
<p>Within 50 miles of San Francisco &mdash; from Marin County to Palo Alto and across the East Bay.</p>
<ul class="service-area__cities" role="list">
{% for city in site.serviceAreaCities %}
<li class="service-area__city">{{ city }}</li>
{% endfor %}
</ul>
</div>
</section>
{# ── CTA band ─────────────────────────────────────────────────────────────── #}
{% include "components/cta-band.njk" %}

479
src/js/main.js Normal file
View File

@@ -0,0 +1,479 @@
/**
* main.js — HER Home Enhancement and Renovation
*
* Sections:
* 1. Navigation — mobile hamburger (focus trap, Escape, ARIA)
* 2. Navigation — compact + CTA visibility on scroll
* 3. Scroll-triggered fade-up animation (IntersectionObserver)
* 4. Quote form — validation, character counter, submission
*/
(function () {
'use strict';
/* ============================================================
1. Navigation — Mobile Hamburger
============================================================ */
var navToggle = document.querySelector('.nav__toggle');
var navOverlay = document.querySelector('.nav__overlay');
var navOverlayClose = document.querySelector('.nav__overlay-close');
/**
* Returns an array of focusable elements within a container,
* in DOM order, skipping anything with tabindex="-1".
*/
function getFocusable(container) {
return Array.from(
container.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), ' +
'select:not([disabled]), textarea:not([disabled]), ' +
'[tabindex]:not([tabindex="-1"])'
)
);
}
function openNav() {
if (!navOverlay || !navToggle) return;
navOverlay.removeAttribute('hidden');
navToggle.setAttribute('aria-expanded', 'true');
navToggle.setAttribute('aria-label', 'Close menu');
document.body.classList.add('nav-open');
// Move focus into overlay — first focusable item
var focusable = getFocusable(navOverlay);
if (focusable.length) focusable[0].focus();
}
function closeNav() {
if (!navOverlay || !navToggle) return;
navOverlay.setAttribute('hidden', '');
navToggle.setAttribute('aria-expanded', 'false');
navToggle.setAttribute('aria-label', 'Open menu');
document.body.classList.remove('nav-open');
navToggle.focus();
}
if (navToggle) {
navToggle.addEventListener('click', function () {
if (navToggle.getAttribute('aria-expanded') === 'true') {
closeNav();
} else {
openNav();
}
});
}
if (navOverlayClose) {
navOverlayClose.addEventListener('click', closeNav);
}
if (navOverlay) {
// Close when a nav link inside the overlay is clicked
navOverlay.addEventListener('click', function (e) {
if (e.target && e.target.tagName === 'A') {
closeNav();
}
});
}
// Keyboard handling: focus trap (Tab/Shift-Tab) and Escape close
document.addEventListener('keydown', function (e) {
if (!navOverlay || navOverlay.hasAttribute('hidden')) return;
if (e.key === 'Escape') {
closeNav();
return;
}
if (e.key === 'Tab') {
var focusable = getFocusable(navOverlay);
if (!focusable.length) {
e.preventDefault();
return;
}
var first = focusable[0];
var last = focusable[focusable.length - 1];
if (e.shiftKey) {
// Shift-Tab on first → wrap to last
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
// Tab on last → wrap to first
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
});
/* ============================================================
2. Navigation — Compact on Scroll
============================================================ */
var navEl = document.querySelector('.nav');
// #nav-cta starts with style="display:none" in HTML; we manage via style.display
var navCta = document.getElementById('nav-cta');
function handleNavScroll() {
var y = window.scrollY;
if (navEl) {
if (y > 80) {
navEl.classList.add('nav--compact');
} else {
navEl.classList.remove('nav--compact');
}
}
if (navCta) {
// Remove the inline display:none to show; restore it to hide
navCta.style.display = y > 400 ? '' : 'none';
}
}
window.addEventListener('scroll', handleNavScroll, { passive: true });
// Apply immediately so state is correct before any scroll occurs
handleNavScroll();
/* ============================================================
3. Scroll-triggered Fade-Up Animation
============================================================ */
(function initFadeUp() {
// Respect user's motion preference — CSS already handles the fallback
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
if (!('IntersectionObserver' in window)) return;
var fadeEls = document.querySelectorAll('.fade-up');
if (!fadeEls.length) return;
var observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
observer.unobserve(entry.target); // one-shot — never toggle back
}
});
}, { threshold: 0.15 });
fadeEls.forEach(function (el) {
observer.observe(el);
});
}());
/* ============================================================
4. Quote Form
============================================================ */
(function initQuoteForm() {
/** POST destination — replace with your API Gateway invoke URL */
var FORM_ENDPOINT = 'REPLACE_WITH_API_GATEWAY_URL';
var form = document.getElementById('quote-form');
if (!form) return; // form not present on this page — bail out
// ── Element references ────────────────────────────────────
var honeypot = form.querySelector('input[name="website"]');
var errorBanner = form.querySelector('.form-error-banner');
var successPanel = form.querySelector('.form-success');
var submitBtn = form.querySelector('.form__submit');
var charCountEl = document.getElementById('description-char-count');
var descriptionEl = document.getElementById('description');
var FIELD_IDS = ['name', 'address', 'phone', 'email', 'description'];
// Save original submit button label so we can restore it after an error
var originalBtnText = submitBtn ? (submitBtn.textContent.trim() || 'Send Request') : 'Send Request';
// Ensure the error banner is focusable (needed for focus management)
if (errorBanner && !errorBanner.hasAttribute('tabindex')) {
errorBanner.setAttribute('tabindex', '-1');
}
// ── Validation rules ──────────────────────────────────────
var RULES = {
name: function (val) {
return val.trim().length >= 2
? null
: 'Please enter your name.';
},
address: function (val) {
return val.trim().length >= 3
? null
: 'Please enter the service address or city.';
},
phone: function (val) {
return /^[+\d][\d\s\-().]{6,}$/.test(val.trim())
? null
: 'Please enter a valid phone number.';
},
email: function (val) {
var v = val.trim();
return (v.indexOf('@') !== -1 && v.indexOf('.') !== -1)
? null
: 'Please enter a valid email address.';
},
description: function (val) {
return val.trim().length >= 10
? null
: 'Please describe your project (at least 10 characters).';
}
};
// ── Helper: show / clear individual field errors ──────────
/**
* Mark a field as invalid and display its error message.
* @param {string} fieldId - The field's id attribute value
* @param {string} message - The error text to display
*/
function showError(fieldId, message) {
var field = document.getElementById(fieldId);
var errorEl = document.getElementById(fieldId + '-error');
if (field) field.setAttribute('aria-invalid', 'true');
if (errorEl) {
errorEl.textContent = message;
errorEl.removeAttribute('hidden');
}
}
/**
* Mark a field as valid and hide its error message.
* @param {string} fieldId - The field's id attribute value
*/
function clearError(fieldId) {
var field = document.getElementById(fieldId);
var errorEl = document.getElementById(fieldId + '-error');
if (field) field.setAttribute('aria-invalid', 'false');
if (errorEl) {
errorEl.textContent = '';
errorEl.setAttribute('hidden', '');
}
}
// ── Helper: validate one field ────────────────────────────
/**
* Run validation for a single field. Shows or clears its error.
* @param {string} fieldId
* @returns {boolean} true if valid
*/
function validateField(fieldId) {
var field = document.getElementById(fieldId);
if (!field) return true; // element absent — treat as valid
var rule = RULES[fieldId];
if (!rule) return true;
var errorMsg = rule(field.value);
if (errorMsg) {
showError(fieldId, errorMsg);
return false;
}
clearError(fieldId);
return true;
}
// ── Helper: validate all fields ───────────────────────────
/**
* Run validation on every form field. Does NOT short-circuit —
* all fields are checked so every error is visible at once.
* @returns {boolean} true only if every field is valid
*/
function validateAll() {
var allValid = true;
FIELD_IDS.forEach(function (id) {
if (!validateField(id)) allValid = false;
});
return allValid;
}
// ── Blur-time validation ──────────────────────────────────
FIELD_IDS.forEach(function (id) {
var field = document.getElementById(id);
if (field) {
field.addEventListener('blur', function () {
validateField(id);
});
}
});
// ── Character counter ─────────────────────────────────────
if (descriptionEl && charCountEl) {
// Set initial display
charCountEl.textContent = '0 / 2000';
descriptionEl.addEventListener('input', function () {
var len = descriptionEl.value.length;
charCountEl.textContent = len + ' / 2000';
// classList.toggle(cls, force) is supported in all modern browsers
charCountEl.classList.toggle('warn', len > 1800);
});
}
// ── Banner helpers ────────────────────────────────────────
/**
* Display the error banner with a given message and move focus to it.
* @param {string} message
*/
function showBannerError(message) {
if (!errorBanner) return;
// Update the inner <p> if present to preserve markup; else use textContent
var bannerP = errorBanner.querySelector('p');
if (bannerP) {
bannerP.textContent = message;
} else {
errorBanner.textContent = message;
}
errorBanner.removeAttribute('hidden');
errorBanner.focus();
}
function hideBanner() {
if (!errorBanner) return;
errorBanner.setAttribute('hidden', '');
var bannerP = errorBanner.querySelector('p');
if (bannerP) {
bannerP.textContent = '';
} else {
errorBanner.textContent = '';
}
}
// ── Success display ───────────────────────────────────────
/**
* Hide all form content except the success panel, then show the
* success panel and move focus to its title.
* (.form-success lives inside <form> in the HTML, so we cannot
* simply hide the <form> element — instead we hide its siblings.)
*/
function showSuccess() {
// Hide every direct child of the form except .form-success
Array.from(form.children).forEach(function (child) {
if (!child.classList.contains('form-success')) {
child.setAttribute('hidden', '');
}
});
if (successPanel) {
successPanel.removeAttribute('hidden');
var title = successPanel.querySelector('.form-success__title');
if (title) {
// Ensure the heading is programmatically focusable
if (!title.hasAttribute('tabindex')) {
title.setAttribute('tabindex', '-1');
}
title.focus();
}
}
}
// ── Form state helpers ────────────────────────────────────
var allInputs; // captured at submit time, used in onError
function disableForm() {
allInputs = form.querySelectorAll('input, textarea, select, button');
allInputs.forEach(function (el) { el.disabled = true; });
if (submitBtn) submitBtn.textContent = 'Sending\u2026'; // "Sending…"
form.setAttribute('aria-busy', 'true');
}
function enableForm() {
if (allInputs) {
allInputs.forEach(function (el) { el.disabled = false; });
}
if (submitBtn) submitBtn.textContent = originalBtnText;
form.removeAttribute('aria-busy');
}
// ── Submit handler ────────────────────────────────────────
form.addEventListener('submit', function (e) {
e.preventDefault();
// Reset error banner from any previous attempt
hideBanner();
// 1. Validate — show all errors simultaneously
var isValid = validateAll();
if (!isValid) {
showBannerError('Please correct the errors above before submitting.');
return;
}
// 2. Honeypot check — silently fake success so bots see no difference
if (honeypot && honeypot.value) {
showSuccess();
return;
}
// 3. Lock the form while the request is in flight
disableForm();
// 4. Build the JSON payload
var payload = {
name: (document.getElementById('name') || { value: '' }).value,
address: (document.getElementById('address') || { value: '' }).value,
phone: (document.getElementById('phone') || { value: '' }).value,
email: (document.getElementById('email') || { value: '' }).value,
description: (document.getElementById('description') || { value: '' }).value,
honeypot: honeypot ? honeypot.value : ''
};
// 5. 10-second hard timeout via AbortController
var controller = new AbortController();
var abortTimer = setTimeout(function () {
controller.abort();
}, 10000);
// 6. POST to the API
fetch(FORM_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal
})
.then(function (response) {
// Treat any non-2xx HTTP status as an error
if (!response.ok) {
throw new Error('HTTP error ' + response.status);
}
return response.json();
})
.then(function (data) {
clearTimeout(abortTimer);
// Some API Gateway patterns return { ok: false } with HTTP 200
if (data && data.ok === false) {
throw new Error(data.message || 'Server returned an error.');
}
// 7. Success path
form.removeAttribute('aria-busy'); // clear before showSuccess hides children
showSuccess();
})
.catch(function () {
clearTimeout(abortTimer);
// 8. Error path — re-enable form and surface the error
enableForm();
showBannerError(
'Something went wrong sending your request. ' +
'Please try again, or contact Diana directly.'
);
});
});
}()); // end initQuoteForm
}()); // end top-level IIFE

4
src/robots.txt Normal file
View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://her-sf.com/sitemap.xml

43
src/services/index.njk Normal file
View File

@@ -0,0 +1,43 @@
---
layout: layouts/base.njk
title: "Handywoman Services in San Francisco"
description: "Browse Diana's services — painting, tile setting, drywall, plumbing, electrical, carpentry, flooring, security equipment, and boat work. San Francisco + 50 miles."
---
{# ── Page hero ─────────────────────────────────────────────────────────────── #}
<section class="page-hero bg-graphite">
<div class="page-hero__inner container">
<p class="section-eyebrow">Services</p>
<h1 class="page-hero__title">Services done right.</h1>
<p class="page-hero__lead">Every job, every trade &mdash; handled with precision, care, and a contractor you can actually trust.</p>
</div>
</section>
{# ── Services hub ─────────────────────────────────────────────────────────── #}
<section class="services-hub section bg-linen">
<div class="services-hub__intro container">
<p>From a leaky faucet to a full room repaint, Diana handles home improvement projects of all sizes across San Francisco and the Bay Area. Every job is approached with the same care and precision that defined her aerospace career.</p>
</div>
<div class="services-grid container">
{% for service in services %}
<a href="/services/{{ service.slug }}/" class="service-card fade-up">
<div class="service-card__image">
<img
src="/assets/{{ service.images[0].file }}"
alt="{{ service.images[0].alt }}"
loading="lazy"
>
</div>
<div class="service-card__body">
<p class="service-card__name">{{ service.name }}</p>
<p class="service-card__desc">{{ service.description }}</p>
<span class="service-card__arrow" aria-hidden="true">&rarr;</span>
</div>
</a>
{% endfor %}
</div>
</section>
{# ── CTA band ─────────────────────────────────────────────────────────────── #}
{% include "components/cta-band.njk" %}

View File

@@ -0,0 +1,11 @@
---
layout: layouts/service.njk
pagination:
data: services
size: 1
alias: service
permalink: "/services/{{ service.slug }}/"
eleventyComputed:
title: "{{ service.name }} in San Francisco"
description: "{{ service.lead }} Diana serves San Francisco and the Bay Area within 50 miles."
---

52
src/sitemap.xml.njk Normal file
View File

@@ -0,0 +1,52 @@
---
permalink: /sitemap.xml
eleventyExcludeFromCollections: true
---
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{# Homepage #}
<url>
<loc>{{ site.url }}/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
{# Services hub #}
<url>
<loc>{{ site.url }}/services/</loc>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
{# Individual service pages #}
{% for service in services %}
<url>
<loc>{{ site.url }}/services/{{ service.slug }}/</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
{% endfor %}
{# Contact #}
<url>
<loc>{{ site.url }}/contact/</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
{# About #}
<url>
<loc>{{ site.url }}/about/</loc>
<changefreq>yearly</changefreq>
<priority>0.7</priority>
</url>
{# Testimonials #}
<url>
<loc>{{ site.url }}/testimonials/</loc>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
</urlset>

36
src/testimonials.njk Normal file
View File

@@ -0,0 +1,36 @@
---
layout: layouts/base.njk
title: "Client Reviews"
description: "Read what Diana's clients say about her work — reliable, hardworking, and easy to work with across San Francisco and the Bay Area."
---
{# ── Page hero ─────────────────────────────────────────────────────────────── #}
<section class="page-hero bg-graphite">
<div class="page-hero__inner container">
<p class="section-eyebrow">Testimonials</p>
<h1 class="page-hero__title">What clients say.</h1>
<p class="page-hero__lead">From San Francisco homeowners who've worked with Diana.</p>
</div>
</section>
{# ── Testimonials grid ─────────────────────────────────────────────────────── #}
<section class="testimonials-page section bg-linen">
<div class="testimonials-page__grid container">
{% for testimonial in testimonials %}
<div class="testimonial-full fade-up">
<blockquote>
<p class="testimonial-full__quote">&#x201C;{{ testimonial.quote }}&#x201D;</p>
<footer>
<p class="testimonial-full__author">{{ testimonial.author }}</p>
{% if testimonial.city %}
<p class="testimonial-full__city">{{ testimonial.city }}</p>
{% endif %}
</footer>
</blockquote>
</div>
{% endfor %}
</div>
</section>
{# ── CTA band ─────────────────────────────────────────────────────────────── #}
{% include "components/cta-band.njk" %}