Phase 4: implement full static site (Eleventy, 15 pages, CSS, JS)
This commit is contained in:
28
.eleventy.js
Normal file
28
.eleventy.js
Normal file
@@ -0,0 +1,28 @@
|
||||
module.exports = function (eleventyConfig) {
|
||||
// Pass static assets through to _site/
|
||||
eleventyConfig.addPassthroughCopy("src/css");
|
||||
eleventyConfig.addPassthroughCopy("src/js");
|
||||
eleventyConfig.addPassthroughCopy({ "assets": "assets" });
|
||||
|
||||
// Format phone number as a tel: href (strips everything but digits and +)
|
||||
eleventyConfig.addFilter("telHref", (phone) =>
|
||||
"tel:" + phone.replace(/[^0-9+]/g, "")
|
||||
);
|
||||
|
||||
// Current year for footer copyright
|
||||
eleventyConfig.addFilter("currentYear", () =>
|
||||
new Date().getFullYear()
|
||||
);
|
||||
|
||||
return {
|
||||
templateFormats: ["njk", "html", "xml", "txt"],
|
||||
htmlTemplateEngine: "njk",
|
||||
markdownTemplateEngine: "njk",
|
||||
dir: {
|
||||
input: "src",
|
||||
output: "_site",
|
||||
includes: "_includes",
|
||||
data: "_data",
|
||||
},
|
||||
};
|
||||
};
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
_site/
|
||||
.DS_Store
|
||||
*.log
|
||||
2621
package-lock.json
generated
Normal file
2621
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
package.json
Normal file
12
package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "her-website",
|
||||
"version": "1.0.0",
|
||||
"description": "HER — Home Enhancement and Renovation website",
|
||||
"scripts": {
|
||||
"build": "eleventy",
|
||||
"dev": "eleventy --serve"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@11ty/eleventy": "^2.0.1"
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,25 @@ This file records stable architectural decisions and their rationale. Items here
|
||||
- Each grouped page must contain enough unique content to serve its target keywords
|
||||
- Internal linking from the Services hub must cover all sub-topics
|
||||
|
||||
## ADR-006 — Static site generator: Eleventy (11ty)
|
||||
- **Status**: Accepted
|
||||
- **Context**: The site has 15+ pages sharing a common nav, footer, and layout. Plain HTML would require copy-pasting these into every file, creating a maintenance burden when nav items change.
|
||||
- **Decision**: Use **Eleventy 2.x** (`@11ty/eleventy`) as the static site generator with **Nunjucks** as the template language.
|
||||
- **Rationale**:
|
||||
- Produces pure static HTML files — no runtime dependency
|
||||
- Nunjucks layout chaining and includes eliminate nav/footer duplication
|
||||
- Simple data cascade for service pages (frontmatter-driven)
|
||||
- Well-documented, widely used for exactly this use case
|
||||
- `npm run build` outputs to `_site/` which Caddy serves directly
|
||||
- **Alternatives considered**:
|
||||
- Plain HTML: zero tooling but nav/footer must be duplicated in every file
|
||||
- Hugo: fast but Go-based; less familiar template syntax
|
||||
- Astro: more powerful but heavier; overkill for a content/lead-gen site
|
||||
- **Consequences**:
|
||||
- Node.js + npm required in the build environment (not at runtime)
|
||||
- Output: `_site/` directory of static HTML, CSS, JS, and assets
|
||||
- Caddy should serve from `_site/`
|
||||
|
||||
## ADR-005 — No analytics or tracking in v1
|
||||
- **Status**: Accepted
|
||||
- **Context**: Stakeholder decided against tracking for simplicity.
|
||||
|
||||
@@ -43,14 +43,8 @@ This log tracks uncertainties and deferred decisions. Each item should be resolv
|
||||
- **Rationale**: Carpentry and Mounting share the same installation/assembly search intent. Flooring touch-ups and Wood Refinishing share the same surface/finish search intent and existing image assets.
|
||||
|
||||
## OQ-7 — Thank-you page vs. in-page success state after form submission
|
||||
- **Status**: Open — raised by `38-lead-capture-spec.md`
|
||||
- **Question**: After a successful form submission, should the site stay on `/contact/` and show an in-page success state (Option A), or redirect to a dedicated `/thank-you/` page (Option B)?
|
||||
- **Options**:
|
||||
- **Option A (recommended for v1)**: In-page success state. Form is replaced by `FormFeedback (success)` component on `/contact/`. Simpler. No redirect. No extra page to maintain. Fits v1 since there is no analytics to track conversion events (ADR-005).
|
||||
- **Option B**: Redirect to `/thank-you/`. Prevents back-button resubmission. Better for conversion tracking when analytics are added later. Requires a separate HTML page with `noindex`.
|
||||
- **Recommendation**: Option A for v1. Revisit when/if analytics are added.
|
||||
- **Blocking**: Implementation of `38-lead-capture-spec.md`.
|
||||
- **Decision needed from**: Diana / project owner.
|
||||
- **Status**: Resolved
|
||||
- **Resolution**: Use **Option A — in-page success state**. Form is replaced by the `FormFeedback (success)` component on `/contact/`. No redirect, no `/thank-you/` page required. Revisit if analytics are added later.
|
||||
|
||||
## OQ-6 — Brand naming and domain
|
||||
- **Status**: Resolved
|
||||
|
||||
172
src/_data/services.json
Normal file
172
src/_data/services.json
Normal 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
19
src/_data/site.json
Normal 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"
|
||||
}
|
||||
30
src/_data/testimonials.json
Normal file
30
src/_data/testimonials.json
Normal 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
|
||||
}
|
||||
]
|
||||
8
src/_includes/components/cta-band.njk
Normal file
8
src/_includes/components/cta-band.njk
Normal 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–2 business days.</p>
|
||||
<a href="/contact/" class="btn btn--ghost-white">Request a Quote</a>
|
||||
</div>
|
||||
</section>
|
||||
33
src/_includes/components/footer.njk
Normal file
33
src/_includes/components/footer.njk
Normal 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 & 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>© {{ "" | currentYear }} Home Enhancement and Renovation — HER. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
55
src/_includes/components/nav.njk
Normal file
55
src/_includes/components/nav.njk
Normal 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">✕</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>
|
||||
83
src/_includes/layouts/base.njk
Normal file
83
src/_includes/layouts/base.njk
Normal 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>
|
||||
82
src/_includes/layouts/service.njk
Normal file
82
src/_includes/layouts/service.njk
Normal 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
51
src/about.njk
Normal 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
161
src/contact.njk
Normal 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 1–2 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–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">✓</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–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
1831
src/css/main.css
Normal file
File diff suppressed because it is too large
Load Diff
150
src/index.njk
Normal file
150
src/index.njk
Normal 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 — 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">→</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">✓</div>
|
||||
<h3 class="trust-pillar__title">Reliable & Accountable</h3>
|
||||
<p class="trust-pillar__body">Diana sets a schedule and sticks to it. No chasing, no surprises — 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 — 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">“{{ testimonial.pullQuote }}”</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 — 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
479
src/js/main.js
Normal 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
4
src/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://her-sf.com/sitemap.xml
|
||||
43
src/services/index.njk
Normal file
43
src/services/index.njk
Normal 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 — 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">→</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# ── CTA band ─────────────────────────────────────────────────────────────── #}
|
||||
{% include "components/cta-band.njk" %}
|
||||
11
src/services/service-pages.njk
Normal file
11
src/services/service-pages.njk
Normal 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
52
src/sitemap.xml.njk
Normal 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
36
src/testimonials.njk
Normal 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">“{{ testimonial.quote }}”</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" %}
|
||||
Reference in New Issue
Block a user