Initial trades demo starter
pauljd/davidson-ci: Davidson CI / quality (push) Successful in 1m35s
pauljd/davidson-ci: Davidson CI / ai-review (push) Successful in 29s

This commit is contained in:
2026-09-30 17:41:50 +01:00
commit f2cc99dd94
10 changed files with 1201 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
# Local Service Demo Starter
A small, reusable one-page website for local service businesses. It uses plain HTML, CSS, JavaScript, and JSON; there is no package manager, build step, or framework.
## Run locally
The page loads `data/business.json` with `fetch`, so open it through a local web server rather than directly as a `file://` URL.
From this directory, run:
```sh
python3 -m http.server 8000
```
Then visit <http://localhost:8000>.
## Make it yours
1. Edit the business details, navigation, hero, services, about copy, and contact details in `data/business.json`.
2. Add a logo under `assets/logo/` and set `logo` and `logoAlt` in the JSON. Leave `logo` empty to use the business name as a text mark.
3. Replace `hero.image` with an image path in `assets/images/` (or a hosted image URL), and update `hero.imageAlt`.
4. Change the four `--brand-*` values at the top of `css/theme.css` to update the palette.
The sample business and hero photo are demo content. Replace both before publishing. The sample photo is remotely hosted; use a local image if the demo needs to work without an internet connection.
## Files
- `index.html` contains the page structure.
- `css/base.css` contains resets, shared type, and layout foundations.
- `css/components.css` contains the page sections and responsive styles.
- `css/theme.css` contains the replaceable brand variables.
- `js/main.js` loads the JSON and fills the page content.
- `data/business.json` contains the sample business content.
- `assets/images/`, `assets/icons/`, and `assets/logo/` are for replaceable media.
## Demo workflow
This starter is designed for fast prospect demos.
1. Clone the repository.
2. Replace the sample business data in `data/business.json`.
3. Add the prospect's logo and hero image.
4. Update the brand colours in `css/theme.css`.
5. Run the site locally.
6. Check mobile layout, links, contact details, and placeholders.
7. Run Davidson CI and Lighthouse.
8. Deploy to a temporary public URL.
9. Verify the live URL before sending it to the prospect.
## Demo rules
- Do not invent reviews, certifications, services, locations, or claims.
- Replace all sample content before publishing.
- Keep structural changes minimal unless the business genuinely needs them.
## Goal
A real business should be able to go from "yes, show me" to a personalised live demo in under 45 minutes.
View File
View File
View File
+96
View File
@@ -0,0 +1,96 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
scroll-padding-top: 5rem;
}
body {
margin: 0;
background: var(--brand-bg);
color: var(--brand-text);
font-family: var(--font-body);
font-size: 16px;
-webkit-font-smoothing: antialiased;
}
img {
display: block;
max-width: 100%;
}
a {
color: inherit;
text-decoration: none;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
button:focus-visible,
a:focus-visible {
outline: 3px solid var(--brand-accent);
outline-offset: 4px;
}
::selection {
background: var(--brand-accent);
color: var(--brand-primary);
}
.page-width {
width: min(100% - 48px, 1200px);
margin-inline: auto;
}
.section-pad {
padding-block: 112px;
}
.eyebrow {
margin: 0 0 22px;
color: var(--brand-accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.13em;
line-height: 1.4;
text-transform: uppercase;
}
h1,
h2,
p {
margin-top: 0;
}
h1,
h2 {
font-family: var(--font-display);
}
@media (max-width: 640px) {
.page-width {
width: min(100% - 40px, 1200px);
}
.section-pad {
padding-block: 76px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
+691
View File
@@ -0,0 +1,691 @@
.site-header {
position: absolute;
z-index: 5;
inset: 0 0 auto;
color: #fff;
}
.header-inner {
display: flex;
min-height: 92px;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgb(255 255 255 / 24%);
}
.brand,
.footer-brand {
display: inline-flex;
align-items: center;
gap: 12px;
font-family: var(--font-display);
font-size: 17px;
font-weight: 800;
letter-spacing: 0;
}
.brand-logo {
width: 42px;
height: 42px;
object-fit: contain;
}
.main-nav {
display: flex;
align-items: center;
gap: 36px;
margin-left: auto;
margin-right: 48px;
color: rgb(255 255 255 / 82%);
font-size: 13px;
font-weight: 500;
}
.main-nav a,
.header-call,
.contact-email {
transition: color 160ms ease;
}
.main-nav a:hover,
.contact-email:hover {
color: var(--brand-accent);
}
.header-call {
display: grid;
gap: 3px;
text-align: right;
}
.header-call-label {
color: rgb(255 255 255 / 65%);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.header-phone {
font-size: 14px;
font-weight: 700;
}
.menu-toggle {
display: none;
width: 42px;
height: 42px;
padding: 12px 9px;
border: 1px solid rgb(255 255 255 / 45%);
background: transparent;
color: #fff;
cursor: pointer;
}
.menu-toggle span {
display: block;
height: 1px;
margin: 5px 0;
background: currentColor;
}
.hero {
position: relative;
display: flex;
min-height: min(760px, 100svh);
align-items: center;
overflow: hidden;
background: #292e2b;
color: #fff;
}
.hero-image,
.hero-shade {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.hero-image {
object-fit: cover;
object-position: center 48%;
animation: hero-arrive 900ms ease-out both;
}
.hero-shade {
background: rgb(13 20 20 / 62%);
}
.hero-content {
position: relative;
z-index: 1;
padding-top: 108px;
padding-bottom: 92px;
animation: content-arrive 650ms 100ms ease-out both;
}
.hero-eyebrow {
color: #ffb17c;
}
.hero-title {
max-width: 790px;
margin-bottom: 22px;
font-size: clamp(48px, 7vw, 88px);
font-weight: 600;
letter-spacing: 0;
line-height: 0.99;
}
.hero-description {
max-width: 500px;
margin-bottom: 32px;
color: rgb(255 255 255 / 78%);
font-size: 17px;
line-height: 1.7;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.button {
display: inline-flex;
min-height: 52px;
align-items: center;
justify-content: center;
padding: 0 23px;
font-size: 13px;
font-weight: 700;
transition:
background 160ms ease,
color 160ms ease,
border-color 160ms ease;
}
.button-accent {
background: var(--brand-accent);
color: #171b1b;
}
.button-accent:hover {
background: #ff9850;
}
.button-outline {
border: 1px solid rgb(255 255 255 / 50%);
color: #fff;
}
.button-outline:hover {
border-color: #fff;
background: rgb(255 255 255 / 10%);
}
.hero-proof {
display: flex;
flex-wrap: wrap;
gap: 0;
margin-top: 64px;
color: rgb(255 255 255 / 85%);
font-size: 12px;
}
.hero-proof span {
padding-inline: 18px;
border-left: 1px solid rgb(255 255 255 / 35%);
}
.hero-proof span:first-child {
padding-left: 0;
border-left: 0;
}
.hero-scroll {
position: absolute;
z-index: 1;
right: max(24px, calc((100vw - 1200px) / 2));
bottom: 39px;
width: 22px;
height: 36px;
border: 1px solid rgb(255 255 255 / 65%);
border-radius: 12px;
}
.hero-scroll span {
position: absolute;
top: 7px;
left: 9px;
width: 2px;
height: 7px;
background: var(--brand-accent);
animation: scroll-hint 1.6s infinite;
}
.section-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 40px;
margin-bottom: 48px;
}
.section-heading h2,
.about-title,
.contact-title {
margin-bottom: 0;
font-size: clamp(34px, 4vw, 52px);
font-weight: 600;
letter-spacing: 0;
line-height: 1.08;
}
.section-heading .eyebrow,
.about-copy .eyebrow,
.contact-copy .eyebrow {
margin-bottom: 15px;
}
.section-intro {
max-width: 360px;
margin: 0 0 3px;
color: var(--brand-muted);
font-size: 14px;
line-height: 1.75;
}
.service-list {
display: grid;
grid-template-columns: repeat(3, 1fr);
border-top: 1px solid #d9d9d6;
}
.service-item {
min-height: 210px;
padding: 25px 28px 26px 0;
border-bottom: 1px solid #d9d9d6;
}
.service-item:not(:nth-child(3n + 1)) {
padding-left: 28px;
border-left: 1px solid #d9d9d6;
}
.service-index {
display: block;
margin-bottom: 28px;
color: var(--brand-accent);
font-family: var(--font-display);
font-size: 12px;
font-weight: 800;
}
.service-item h3 {
margin: 0 0 9px;
font-family: var(--font-display);
font-size: 19px;
font-weight: 700;
}
.service-item p {
max-width: 310px;
margin: 0;
color: var(--brand-muted);
font-size: 13px;
line-height: 1.7;
}
.about {
background: var(--brand-soft);
}
.about-inner {
display: grid;
grid-template-columns: 0.45fr 1.4fr 0.9fr;
align-items: start;
gap: 54px;
}
.about-marker {
display: flex;
align-items: center;
gap: 16px;
padding-top: 7px;
color: var(--brand-accent);
font-family: var(--font-display);
font-size: 13px;
font-weight: 800;
}
.about-rule {
width: 48px;
height: 1px;
background: var(--brand-accent);
}
.about-title {
max-width: 540px;
}
.about-description {
max-width: 530px;
margin: 24px 0 20px;
color: #545a5c;
font-size: 15px;
line-height: 1.8;
}
.text-link {
display: inline-flex;
align-items: center;
gap: 12px;
font-size: 13px;
font-weight: 700;
}
.text-link::after {
width: 25px;
height: 1px;
background: var(--brand-accent);
content: "";
transition: width 160ms ease;
}
.text-link:hover::after {
width: 38px;
}
.about-note {
position: relative;
margin-top: 43px;
padding: 28px 0 0 25px;
border-left: 1px solid #c7c5be;
}
.note-mark {
position: absolute;
top: -17px;
left: 21px;
color: var(--brand-accent);
font-family: Georgia, serif;
font-size: 45px;
line-height: 1;
}
.about-quote {
margin-bottom: 18px;
font-family: var(--font-display);
font-size: 16px;
font-weight: 600;
line-height: 1.65;
}
.about-quote-byline {
color: var(--brand-muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.contact-inner {
display: grid;
grid-template-columns: 1.5fr 1fr 0.9fr;
align-items: center;
gap: 50px;
}
.contact-title {
max-width: 540px;
}
.contact-description {
max-width: 490px;
margin: 18px 0 0;
color: var(--brand-muted);
font-size: 14px;
line-height: 1.7;
}
.contact-actions {
display: grid;
justify-items: start;
gap: 15px;
}
.button-dark {
background: var(--brand-primary);
color: #fff;
}
.button-dark:hover {
background: #2b3743;
}
.contact-email {
font-size: 13px;
font-weight: 600;
}
.contact-location {
display: grid;
gap: 8px;
padding-left: 26px;
border-left: 1px solid #d9d9d6;
font-size: 13px;
line-height: 1.5;
}
.contact-location-label {
margin-bottom: 4px;
color: var(--brand-muted);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.contact-hours {
color: var(--brand-muted);
}
.site-footer {
background: var(--brand-primary);
color: #fff;
}
.footer-inner {
display: flex;
min-height: 88px;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.footer-brand {
font-size: 13px;
}
.footer-location,
.footer-copyright {
margin: 0;
color: rgb(255 255 255 / 60%);
font-size: 11px;
}
@keyframes hero-arrive {
from {
transform: scale(1.04);
}
to {
transform: scale(1);
}
}
@keyframes content-arrive {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scroll-hint {
0%,
100% {
transform: translateY(0);
opacity: 1;
}
65% {
transform: translateY(9px);
opacity: 0;
}
}
@media (max-width: 900px) {
.header-inner {
min-height: 78px;
}
.main-nav {
gap: 22px;
margin-right: 25px;
}
.about-inner {
grid-template-columns: 0.25fr 1.3fr 0.85fr;
gap: 28px;
}
.contact-inner {
grid-template-columns: 1.2fr 0.8fr;
gap: 36px;
}
.contact-location {
grid-column: 2;
padding-top: 20px;
padding-left: 0;
border-top: 1px solid #d9d9d6;
border-left: 0;
}
}
@media (max-width: 680px) {
.header-inner {
min-height: 72px;
}
.brand {
position: relative;
z-index: 2;
max-width: calc(100% - 105px);
font-size: 15px;
}
.brand-logo {
width: 34px;
height: 34px;
}
.header-call {
display: none;
}
.menu-toggle {
position: relative;
z-index: 2;
display: block;
}
.main-nav {
position: absolute;
inset: 0 0 auto;
display: none;
align-items: stretch;
gap: 0;
margin: 0;
padding: 82px 24px 18px;
background: var(--brand-primary);
}
.main-nav.is-open {
display: grid;
}
.main-nav a {
padding: 15px 0;
border-top: 1px solid rgb(255 255 255 / 17%);
}
.hero {
min-height: 700px;
min-height: max(680px, 100svh);
align-items: end;
}
.hero-image {
object-position: 58% center;
}
.hero-shade {
background: rgb(13 20 20 / 67%);
}
.hero-content {
padding-top: 120px;
padding-bottom: 74px;
}
.hero-title {
max-width: 500px;
font-size: clamp(46px, 13vw, 68px);
}
.hero-description {
max-width: 420px;
font-size: 15px;
}
.hero-proof {
gap: 12px 0;
margin-top: 42px;
}
.hero-proof span {
padding-inline: 10px;
font-size: 10px;
}
.hero-scroll {
right: 20px;
bottom: 23px;
}
.section-heading {
display: block;
margin-bottom: 32px;
}
.section-heading h2,
.about-title,
.contact-title {
font-size: 36px;
}
.section-intro {
margin-top: 18px;
}
.service-list {
grid-template-columns: 1fr;
}
.service-item,
.service-item:not(:nth-child(3n + 1)) {
min-height: 0;
padding: 20px 0 22px;
border-left: 0;
}
.service-index {
margin-bottom: 14px;
}
.about-inner {
grid-template-columns: 1fr;
gap: 23px;
}
.about-marker {
gap: 12px;
}
.about-note {
max-width: 420px;
margin: 16px 0 0;
}
.contact-inner {
grid-template-columns: 1fr;
gap: 28px;
}
.contact-location {
grid-column: auto;
padding-top: 20px;
}
.footer-inner {
min-height: 0;
align-items: flex-start;
flex-direction: column;
gap: 10px;
padding-block: 25px;
}
}
+10
View File
@@ -0,0 +1,10 @@
:root {
--brand-primary: #111827;
--brand-accent: #f97316;
--brand-bg: #ffffff;
--brand-text: #111111;
--brand-muted: #687078;
--brand-soft: #f3f1ed;
--font-body: "DM Sans", sans-serif;
--font-display: "Manrope", sans-serif;
}
+76
View File
@@ -0,0 +1,76 @@
{
"metaTitle": "Northline Electrical | Local Electrical Services",
"metaDescription": "Reliable electrical work, clear communication, and a tidy finish from Northline Electrical.",
"name": "Northline Electrical",
"homeLabel": "Northline Electrical home",
"logo": "",
"logoAlt": "",
"phone": "+1 503 555 0148",
"phoneDisplay": "(503) 555-0148",
"email": "hello@northline.example",
"location": "Portland, Oregon",
"hours": "Monday–Saturday, 7:30am–6pm",
"headerCallLabel": "Talk to an electrician",
"navigation": {
"services": "Services",
"about": "Our approach",
"contact": "Contact"
},
"hero": {
"eyebrow": "Residential electrical · Portland, OR",
"title": "Good work.\nNo loose ends.",
"description": "Thoughtful electrical service for the projects that keep your home working beautifully.",
"image": "https://images.unsplash.com/photo-1621905251189-08b45d6a269e?auto=format&fit=crop&w=2200&q=85",
"imageAlt": "Electrician carefully working on a residential electrical installation",
"primaryAction": "Call to talk through your project",
"secondaryAction": "Explore our services"
},
"highlights": ["Licensed & insured", "Up-front estimates", "Locally owned"],
"servicesSection": {
"eyebrow": "What we do",
"title": "The right fix,\ndone with care.",
"description": "From a small repair to a bigger upgrade, expect clear options and work done to last."
},
"services": [
{
"name": "Repairs & troubleshooting",
"description": "Get to the source of flickering lights, faulty outlets, tripped breakers, and other electrical issues."
},
{
"name": "Lighting & fixtures",
"description": "Refresh a room with well-placed lighting, new fixtures, dimmers, and outdoor illumination."
},
{
"name": "Panel upgrades",
"description": "Bring your electrical panel up to date and make room for the way your household uses power."
},
{
"name": "EV charger installation",
"description": "Get a home charging setup planned and installed around your vehicle, panel, and parking."
},
{
"name": "Remodels & additions",
"description": "Coordinate the wiring, circuits, and lighting needed to make your next space work."
},
{
"name": "Safety inspections",
"description": "Understand the condition of your electrical system and what deserves attention first."
}
],
"about": {
"number": "01",
"eyebrow": "A better service call",
"title": "Respect for your home is part of the job.",
"description": "We show up when we say we will, explain what we find, and leave the work area clean. You get straightforward recommendations and a team that treats your home like its own.",
"linkLabel": "Meet your local electrician",
"quote": "The work was excellent, the options were clear, and you wouldn't even know they'd been here.",
"quoteByline": "A note from a Portland homeowner"
},
"contact": {
"eyebrow": "Tell us what you need",
"title": "Let's get it sorted.",
"description": "Call for a quick conversation about your project or send a note and we'll get back to you shortly.",
"phoneLabel": "Call (503) 555-0148",
"locationLabel": "Serving the greater area"
}
}
+141
View File
@@ -0,0 +1,141 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#111827" />
<meta
name="description"
content="Local service from a team you can trust."
/>
<title>Local Service Business</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="css/theme.css" />
<link rel="stylesheet" href="css/base.css" />
<link rel="stylesheet" href="css/components.css" />
<script src="js/main.js" defer></script>
</head>
<body>
<header class="site-header">
<div class="header-inner page-width">
<a class="brand" href="#top" aria-label="Home">
<img class="brand-logo" alt="" hidden />
<span class="brand-name"></span>
</a>
<nav class="main-nav" aria-label="Main navigation">
<a data-nav-services href="#services"></a>
<a data-nav-about href="#about"></a>
<a data-nav-contact href="#contact"></a>
</nav>
<a class="header-call" data-phone-link>
<span class="header-call-label"></span>
<span class="header-phone"></span>
</a>
<button
class="menu-toggle"
type="button"
aria-label="Open navigation"
aria-expanded="false"
>
<span></span><span></span>
</button>
</div>
</header>
<main id="top">
<section class="hero" aria-labelledby="hero-title">
<img class="hero-image" alt="" />
<div class="hero-shade"></div>
<div class="hero-content page-width">
<p class="eyebrow hero-eyebrow"></p>
<h1 id="hero-title" class="hero-title"></h1>
<p class="hero-description"></p>
<div class="hero-actions">
<a class="button button-accent hero-primary"></a>
<a class="button button-outline hero-secondary"></a>
</div>
<div class="hero-proof" aria-label="Business highlights"></div>
</div>
<a class="hero-scroll" href="#services" aria-label="Scroll to services"
><span></span
></a>
</section>
<section
class="services section-pad"
id="services"
aria-labelledby="services-title"
>
<div class="page-width">
<div class="section-heading">
<div>
<p class="eyebrow services-eyebrow"></p>
<h2 id="services-title" class="services-title"></h2>
</div>
<p class="section-intro services-intro"></p>
</div>
<div class="service-list"></div>
</div>
</section>
<section
class="about section-pad"
id="about"
aria-labelledby="about-title"
>
<div class="about-inner page-width">
<div class="about-marker">
<span class="about-number"></span><span class="about-rule"></span>
</div>
<div class="about-copy">
<p class="eyebrow about-eyebrow"></p>
<h2 id="about-title" class="about-title"></h2>
<p class="about-description"></p>
<a class="text-link about-link"></a>
</div>
<div class="about-note">
<span class="note-mark" aria-hidden="true">“</span>
<p class="about-quote"></p>
<span class="about-quote-byline"></span>
</div>
</div>
</section>
<section
class="contact section-pad"
id="contact"
aria-labelledby="contact-title"
>
<div class="contact-inner page-width">
<div class="contact-copy">
<p class="eyebrow contact-eyebrow"></p>
<h2 id="contact-title" class="contact-title"></h2>
<p class="contact-description"></p>
</div>
<div class="contact-actions">
<a class="button button-dark contact-phone" data-phone-link></a>
<a class="contact-email"></a>
</div>
<div class="contact-location">
<span class="contact-location-label"></span>
<span class="contact-address"></span>
<span class="contact-hours"></span>
</div>
</div>
</section>
</main>
<footer class="site-footer">
<div class="footer-inner page-width">
<a class="footer-brand" href="#top"></a>
<p class="footer-location"></p>
<p class="footer-copyright"></p>
</div>
</footer>
</body>
</html>
+129
View File
@@ -0,0 +1,129 @@
const text = (selector, value) => {
const element = document.querySelector(selector);
if (element) element.textContent = value ?? "";
};
const setLink = (selector, href, label) => {
const element = document.querySelector(selector);
if (!element) return;
element.href = href;
element.textContent = label;
};
const addText = (parent, tag, className, value) => {
const element = document.createElement(tag);
element.className = className;
element.textContent = value;
parent.append(element);
return element;
};
const renderBusiness = (business) => {
document.title = business.metaTitle;
document.querySelector('meta[name="description"]').content =
business.metaDescription;
text(".brand-name", business.name);
text(".footer-brand", business.name);
text(".header-call-label", business.headerCallLabel);
text(".header-phone", business.phoneDisplay);
text("[data-nav-services]", business.navigation.services);
text("[data-nav-about]", business.navigation.about);
text("[data-nav-contact]", business.navigation.contact);
const brand = document.querySelector(".brand");
brand.setAttribute("aria-label", business.homeLabel);
const logo = document.querySelector(".brand-logo");
if (business.logo) {
logo.src = business.logo;
logo.alt = business.logoAlt || business.name;
logo.hidden = false;
}
document.querySelectorAll("[data-phone-link]").forEach((link) => {
link.href = `tel:${business.phone.replace(/[^\d+]/g, "")}`;
});
setLink(
".hero-primary",
`tel:${business.phone.replace(/[^\d+]/g, "")}`,
business.hero.primaryAction,
);
setLink(".hero-secondary", "#services", business.hero.secondaryAction);
text(".hero-eyebrow", business.hero.eyebrow);
text(".hero-title", business.hero.title);
text(".hero-description", business.hero.description);
text(".hero-proof", "");
const proof = document.querySelector(".hero-proof");
business.highlights.forEach((item) => addText(proof, "span", "", item));
const heroImage = document.querySelector(".hero-image");
heroImage.src = business.hero.image;
heroImage.alt = business.hero.imageAlt;
text(".services-eyebrow", business.servicesSection.eyebrow);
text(".services-title", business.servicesSection.title);
text(".services-intro", business.servicesSection.description);
const serviceList = document.querySelector(".service-list");
business.services.forEach((service, index) => {
const item = document.createElement("article");
item.className = "service-item";
addText(item, "span", "service-index", String(index + 1).padStart(2, "0"));
addText(item, "h3", "", service.name);
addText(item, "p", "", service.description);
serviceList.append(item);
});
text(".about-marker .about-number", business.about.number);
text(".about-eyebrow", business.about.eyebrow);
text(".about-title", business.about.title);
text(".about-description", business.about.description);
setLink(".about-link", "#contact", business.about.linkLabel);
text(".about-quote", business.about.quote);
text(".about-quote-byline", business.about.quoteByline);
text(".contact-eyebrow", business.contact.eyebrow);
text(".contact-title", business.contact.title);
text(".contact-description", business.contact.description);
const contactPhone = document.querySelector(".contact-phone");
contactPhone.href = `tel:${business.phone.replace(/[^\d+]/g, "")}`;
contactPhone.textContent = business.contact.phoneLabel;
setLink(".contact-email", `mailto:${business.email}`, business.email);
text(".contact-location-label", business.contact.locationLabel);
text(".contact-address", business.location);
text(".contact-hours", business.hours);
text(".footer-location", business.location);
text(".footer-copyright", `© ${new Date().getFullYear()} ${business.name}`);
};
const menuToggle = document.querySelector(".menu-toggle");
const navigation = document.querySelector(".main-nav");
menuToggle.addEventListener("click", () => {
const isOpen = menuToggle.getAttribute("aria-expanded") === "true";
menuToggle.setAttribute("aria-expanded", String(!isOpen));
menuToggle.setAttribute(
"aria-label",
isOpen ? "Open navigation" : "Close navigation",
);
navigation.classList.toggle("is-open", !isOpen);
});
navigation.addEventListener("click", (event) => {
if (event.target.closest("a")) {
navigation.classList.remove("is-open");
menuToggle.setAttribute("aria-expanded", "false");
menuToggle.setAttribute("aria-label", "Open navigation");
}
});
fetch("data/business.json")
.then((response) => {
if (!response.ok)
throw new Error(`Could not load business config: ${response.status}`);
return response.json();
})
.then(renderBusiness)
.catch((error) => {
console.error(error);
document.body.innerHTML =
'<main class="config-error"><h1>Business details could not be loaded.</h1><p>Run this site from a local web server and check data/business.json.</p></main>';
});