The 1 file this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.
Read it first
Install kit: "Service area hero"
Library page: https://astro.baysixmedia.com/components/service-area-hero/
Files: 1 required, 1 supporting
Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.
===== FILE (required): src/components/ServiceAreaHero.astro =====
---
/**
* ServiceAreaHero
*
* A light, call-first hero for a business that serves a place: the service as
* the page's headline, the area it covers directly beneath, then the actions
* — a click-to-call button beside the main call to action — and any trust
* evidence the business actually has. On wider screens an action panel sits
* beside the copy: the phone number set large, the hours, and the towns
* covered. Pass a photo and it fills the panel, with the call card over it.
*
* Generator-safe by construction. Nothing in it is invented: no phone number,
* rating, licence, availability or area renders unless it is passed, and a
* hero given only a headline is a clean single column rather than a hero with
* holes in it. There are no demo defaults for anything that makes a claim.
*
* Phone order: headline, area, actions, trust, then the panel — the call
* button never sits below a photo.
*
* No script. Reads --sah-* tokens from any ancestor, with fallbacks.
* Step 34: --sah-grid-max / --sah-grid-gutter (desktop) put the content on a
* site's page grid, and --sah-display-size lets the site's heading scale size the heading;
* with no input the design is exactly as it was.
*/
export interface ServiceAreaHeroAction {
label: string;
href: string;
}
export interface ServiceAreaHeroRating {
/** 0–5, as the review source shows it. */
value: number;
/** How many reviews it is based on. */
count?: number;
/** Where the rating comes from, e.g. "Google". */
source?: string;
}
export interface ServiceAreaHeroProps {
/** The service, as the page's headline: "Emergency Plumber in San Mateo". */
heading: string;
headingLevel?: "h1" | "h2";
/** A short line above the headline. */
eyebrow?: string;
/** Where the business works, shown under the headline with a pin: "Serving San Mateo County". */
location?: string;
body?: string;
/** The main call to action — a quote or booking page. */
primaryCta?: ServiceAreaHeroAction | null;
/**
* The click-to-call button. Renders only with a real number: the
* component never supplies one. `href` defaults to `tel:` plus the digits.
*/
phone?: { number: string; label?: string; href?: string } | null;
/** A quieter third action, drawn as a text link. */
secondaryCta?: ServiceAreaHeroAction | null;
/** Real review data only. Omit it and no rating is shown. */
rating?: ServiceAreaHeroRating | null;
/** A licence or registration line, as issued: "CA Lic. #123456". */
license?: string;
/** Real hours or availability: "Open 24/7", "Mon–Sat, 7am–7pm". */
availability?: string;
/** Towns or neighbourhoods served, listed in the action panel. */
serviceAreas?: readonly string[];
/** Label above the list of areas. */
serviceAreasLabel?: string;
/** A photo for the panel. Omit it for the drawn panel. */
image?: { src: string; alt?: string } | null;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends ServiceAreaHeroProps {}
const {
heading,
headingLevel = "h1",
eyebrow = "",
location = "",
body = "",
primaryCta = null,
phone = null,
secondaryCta = null,
rating = null,
license = "",
availability = "",
serviceAreas = [],
serviceAreasLabel = "Areas we serve",
image = null,
fontStylesheet = "https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700;800&display=swap",
class: className,
id,
} = Astro.props;
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
/* A dialable href from a displayed number: keep a leading + and the digits. */
const telHref = (number: string) => {
const trimmed = number.trim();
const digits = trimmed.replace(/\D/g, "");
return digits ? `tel:${trimmed.startsWith("+") ? "+" : ""}${digits}` : "";
};
const phoneNumber = phone?.number?.trim() ?? "";
/* A call button only with somewhere to dial: words in `number` are not a number. */
const passedHref = phone?.href ? safeHref(phone.href) : "#";
const callHref = phoneNumber ? (passedHref !== "#" ? passedHref : telHref(phoneNumber)) : "";
const call = callHref
? {
number: phoneNumber,
label: phone?.label?.trim() || `Call ${phoneNumber}`,
href: callHref,
}
: null;
/* A rating is shown only when it is a real number in range. */
const ratingValue =
rating && typeof rating.value === "number" && Number.isFinite(rating.value) && rating.value >= 0 && rating.value <= 5
? rating.value
: null;
const ratingCount =
ratingValue !== null && typeof rating?.count === "number" && Number.isFinite(rating.count) && rating.count > 0
? Math.round(rating.count)
: null;
const ratingText =
ratingValue === null
? ""
: [
`${ratingValue.toFixed(1)} out of 5`,
ratingCount !== null ? `from ${ratingCount.toLocaleString("en-US")} reviews` : "",
rating?.source ? `on ${rating.source}` : "",
]
.filter(Boolean)
.join(" ");
const areas = (serviceAreas ?? []).map((a) => a.trim()).filter(Boolean);
const photo = image?.src ? { src: image.src, alt: image.alt ?? "" } : null;
const hasTrust = ratingValue !== null || Boolean(license.trim()) || Boolean(availability.trim());
/* The panel exists only when it has something real to hold. */
const hasPanel = Boolean(photo || call || areas.length > 0 || availability.trim());
const Heading = headingLevel;
---
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section class:list={["sah", { "sah--panel": hasPanel }, className]} id={id}>
<div class="sah-inner">
<div class="sah-copy">
{eyebrow && <p class="sah-eyebrow">{eyebrow}</p>}
<Heading class="sah-title">{heading}</Heading>
{
location && (
<p class="sah-location">
<svg class="sah-pin" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M12 21s-6.5-5.6-6.5-11a6.5 6.5 0 0 1 13 0c0 5.4-6.5 11-6.5 11Z" fill="none" stroke="currentColor" stroke-width="1.8" />
<circle cx="12" cy="10" r="2.4" fill="currentColor" />
</svg>
{location}
</p>
)
}
{body && <p class="sah-body">{body}</p>}
{
(call || primaryCta || secondaryCta) && (
<div class="sah-actions">
{call && (
<a class="sah-btn sah-btn--call" href={call.href}>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
d="M6.6 3.5h2.6l1.4 4-1.9 1.3a11.5 11.5 0 0 0 6.5 6.5l1.3-1.9 4 1.4v2.6c0 1.2-1 2.1-2.2 2A16.6 16.6 0 0 1 4.6 5.7C4.5 4.5 5.4 3.5 6.6 3.5Z"
fill="currentColor"
/>
</svg>
<span>{call.label}</span>
</a>
)}
{primaryCta && (
<a class="sah-btn sah-btn--primary" href={safeHref(primaryCta.href)}>
{primaryCta.label}
</a>
)}
{secondaryCta && (
<a class="sah-link" href={safeHref(secondaryCta.href)}>
{secondaryCta.label}
<span aria-hidden="true">→</span>
</a>
)}
</div>
)
}
{
hasTrust && (
<ul class="sah-trust" role="list">
{ratingValue !== null && (
<li class="sah-trust-item">
<svg class="sah-star" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="m12 2.8 2.8 5.8 6.4.9-4.6 4.5 1.1 6.3L12 17.3l-5.7 3 1.1-6.3-4.6-4.5 6.4-.9Z" fill="currentColor" />
</svg>
<span>{ratingText}</span>
</li>
)}
{license.trim() && <li class="sah-trust-item">{license}</li>}
{availability.trim() && <li class="sah-trust-item sah-trust-item--hours">{availability}</li>}
</ul>
)
}
</div>
{
hasPanel && (
<div class:list={["sah-panel", { "sah-panel--photo": photo }]}>
{photo && <img class="sah-photo" src={photo.src} alt={photo.alt} width="720" height="820" loading="eager" decoding="async" />}
{(call || availability.trim() || areas.length > 0) && (
<div class="sah-card">
{call && (
<a class="sah-card-number" href={call.href}>
{call.number}
</a>
)}
{availability.trim() && <p class="sah-card-hours">{availability}</p>}
{areas.length > 0 && (
<div class="sah-areas">
<p class="sah-areas-label">{serviceAreasLabel}</p>
<ul class="sah-area-list" role="list">
{areas.map((area) => (
<li>{area}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
)
}
</div>
</section>
<style>
.sah {
--sah-surface: var(--sah-bg, #f5f7fa);
--sah-ink: var(--sah-text, #0f1b2d);
--sah-muted: var(--sah-text-muted, #475569);
--sah-accent: var(--sah-brand, #1d4ed8);
--sah-accent-ink: var(--sah-brand-text, #ffffff);
--sah-call: var(--sah-call-bg, #0f1b2d);
--sah-rule: var(--sah-border, #d9dee7);
--sah-font: var(--sah-family, Manrope, ui-sans-serif, system-ui, sans-serif);
background: var(--sah-surface);
color: var(--sah-ink);
font-family: var(--sah-font);
}
.sah *,
.sah *::before,
.sah *::after {
box-sizing: border-box;
}
.sah-inner {
display: grid;
gap: 32px;
max-width: var(--sah-grid-max, 1200px);
margin: 0 auto;
padding: 48px 16px 56px;
}
.sah-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
}
.sah-eyebrow {
margin: 0 0 14px;
color: var(--sah-accent);
font: 700 13px/1.4 var(--sah-font);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.sah-title {
margin: 0;
max-width: 18ch;
font: 800 var(--sah-display-size, clamp(34px, 8vw, 58px)) / 1.06 var(--sah-font);
letter-spacing: -0.02em;
overflow-wrap: anywhere;
}
.sah-location {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 16px 0 0;
font: 700 18px/1.4 var(--sah-font);
overflow-wrap: anywhere;
}
.sah-pin {
flex: none;
width: 22px;
height: 22px;
margin-top: 1px;
color: var(--sah-accent);
}
.sah-body {
max-width: 56ch;
margin: 16px 0 0;
color: var(--sah-muted);
font: 500 17px/1.6 var(--sah-font);
overflow-wrap: anywhere;
}
.sah-actions {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 12px;
width: 100%;
margin-top: 28px;
}
.sah-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 52px;
padding: 12px 24px;
border: 2px solid transparent;
border-radius: 10px;
font: 700 17px/1.2 var(--sah-font);
text-align: center;
text-decoration: none;
transition:
background-color 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
}
.sah-btn svg {
flex: none;
width: 20px;
height: 20px;
}
.sah-btn--call {
background: var(--sah-accent);
color: var(--sah-accent-ink);
}
.sah-btn--call:hover {
background: var(--sah-ink);
}
.sah-btn--primary {
border-color: var(--sah-ink);
background: transparent;
color: var(--sah-ink);
}
.sah-btn--primary:hover {
background: var(--sah-ink);
color: var(--sah-surface);
}
.sah-link {
align-self: flex-start;
display: inline-flex;
gap: 6px;
padding: 6px 0;
color: var(--sah-ink);
font: 700 16px/1.4 var(--sah-font);
text-decoration: underline;
text-underline-offset: 4px;
}
.sah-btn:focus-visible,
.sah-link:focus-visible,
.sah-card-number:focus-visible {
outline: 3px solid var(--sah-accent);
outline-offset: 3px;
}
.sah-trust {
display: flex;
flex-wrap: wrap;
gap: 10px 20px;
margin: 24px 0 0;
padding: 16px 0 0;
border-top: 1px solid var(--sah-rule);
list-style: none;
width: 100%;
}
.sah-trust-item {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--sah-muted);
font: 600 14px/1.4 var(--sah-font);
overflow-wrap: anywhere;
}
.sah-star {
flex: none;
width: 18px;
height: 18px;
color: #d97706;
}
/* ---- the action panel ---- */
.sah-panel {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
min-width: 0;
overflow: hidden;
border-radius: 16px;
background:
linear-gradient(135deg, rgb(255 255 255 / 0.06) 25%, transparent 25%) 0 0 / 28px 28px,
var(--sah-call);
color: #fff;
}
.sah-panel--photo {
min-height: 320px;
background: var(--sah-call);
}
.sah-photo {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.sah-card {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
padding: 24px;
}
.sah-panel--photo .sah-card {
margin: 16px;
border-radius: 12px;
background: var(--sah-call);
}
.sah-card-number {
color: #fff;
font: 800 clamp(26px, 6vw, 34px) / 1.1 var(--sah-font);
letter-spacing: -0.01em;
text-decoration: none;
overflow-wrap: anywhere;
}
.sah-card-number:hover {
text-decoration: underline;
text-underline-offset: 5px;
}
.sah-card-hours {
margin: 0;
color: rgb(255 255 255 / 0.8);
font: 600 15px/1.4 var(--sah-font);
}
.sah-areas {
padding-top: 12px;
border-top: 1px solid rgb(255 255 255 / 0.16);
}
.sah-areas-label {
margin: 0 0 10px;
color: rgb(255 255 255 / 0.7);
font: 700 12px/1.4 var(--sah-font);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.sah-area-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.sah-area-list li {
padding: 6px 12px;
border-radius: 999px;
background: rgb(255 255 255 / 0.1);
font: 600 14px/1.3 var(--sah-font);
}
@media (min-width: 640px) {
.sah-actions {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
}
.sah-link {
align-self: center;
}
}
@media (min-width: 960px) {
.sah-inner {
padding: 88px var(--sah-grid-gutter, 32px);
}
.sah--panel .sah-inner {
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
align-items: stretch;
gap: 56px;
}
.sah-panel--photo {
min-height: 460px;
}
.sah-card {
padding: 32px;
}
/* Side by side, the panel already sets the hours under the number. On a
phone the panel comes last, so the trust row keeps them by the call button. */
.sah--panel .sah-trust-item--hours {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sah-btn {
transition: none;
}
}
</style>
===== END src/components/ServiceAreaHero.astro =====
===== FILE (supporting): src/components/ServiceAreaHero.md =====
# Service area hero
A light, call-first hero for a business that serves a place — a plumber, an
HVAC company, a roofer, an electrician, a sewer and drain specialist. The
service is the page's headline, the area it covers sits directly beneath it,
and the first action is a click-to-call button. On wider screens an action
panel beside the copy sets the phone number large, with the hours and the
towns covered.
Catalog page: `/components/service-area-hero`
## Files required
- **`ServiceAreaHero.astro`** — the whole component. No script, no images.
- `ServiceAreaHero.md` — this file.
## Basic usage
```astro
<ServiceAreaHero
heading="Sewer Line Repair in San Mateo"
location="Serving San Mateo, Burlingame and Foster City"
body="Camera inspection, trenchless repair and hydro-jetting from a local crew."
phone={{ number: "(650) 555-0142" }}
primaryCta={{ label: "Book an inspection", href: "/book" }}
availability="Mon–Sat, 7am–7pm"
serviceAreas={["San Mateo", "Burlingame", "Foster City"]}
/>
```
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `heading` | `string` | **required** | The service, as the page's headline. |
| `headingLevel` | `"h1" \| "h2"` | `"h1"` | `h2` for a second use on one page. |
| `eyebrow` | `string?` | `""` | Short line above the headline. |
| `location` | `string?` | `""` | The area served, with a pin. |
| `body` | `string?` | `""` | One or two sentences. |
| `phone` | `{ number, label?, href? } \| null` | `null` | Click-to-call. See below. |
| `primaryCta` | `{ label, href } \| null` | `null` | Outlined button beside the call button. |
| `secondaryCta` | `{ label, href } \| null` | `null` | Text link after the buttons. |
| `rating` | `{ value, count?, source? } \| null` | `null` | Real review data only. |
| `license` | `string?` | `""` | Licence line as issued. |
| `availability` | `string?` | `""` | Real hours: "Open 24/7", "Mon–Sat, 7am–7pm". |
| `serviceAreas` | `string[]?` | `[]` | Towns listed in the panel. |
| `serviceAreasLabel` | `string?` | `"Areas we serve"` | Label above that list. |
| `image` | `{ src, alt? } \| null` | `null` | Photo for the panel. |
| `fontStylesheet` | `string?` | Manrope | `""` skips it. |
| `class`, `id` | | | Root attributes. |
## The phone call to action
The call button renders **only** when `phone.number` is a non-empty string.
The component never supplies a number. The link is `tel:` plus the digits of
the number (a leading `+` is kept); pass `phone.href` to override it. The
button reads "Call (650) 555-0142" unless `phone.label` says otherwise. The
same number is set large in the panel and is a link there too.
## Trust data
Rating, licence and availability are each shown only when passed. A rating
must be a number from 0 to 5; anything else is ignored rather than shown. The
review count and source are optional and are never assumed. Nothing about the
business — its hours, its licence, its reviews, the towns it serves — has a
default, so a hero built from partial data shows only that data.
## Layout and media
| Supplied | Result |
| :--- | :--- |
| Headline only (plus copy, actions) | One column. No empty panel. |
| A phone number, hours or towns | Two columns from 960px; the panel holds them on a dark ground with a faint diagonal pattern. |
| A photo | The photo fills the panel; the call card sits over its lower edge. |
The no-photo panel is designed, not a placeholder: the number, hours and
towns are the point of it.
## Responsive behaviour
- Below 640px the actions stack full width, call first.
- Order on phones: headline, area, actions, trust, then the panel. The call
button never sits below a photo.
- Two columns (copy 1.25fr, panel 1fr) from 960px when there is a panel.
## Motion
None beyond 0.15s colour transitions on the buttons, which are removed under
`prefers-reduced-motion: reduce`.
## Accessibility
- One `<h1>` by default; `headingLevel="h2"` for a second instance.
- The pin, phone and star icons are `aria-hidden`; their text carries the meaning.
- Links show a 3px focus outline in the brand colour.
- Trust items and areas are lists.
## Tokens
`--sah-bg`, `--sah-text`, `--sah-text-muted`, `--sah-brand`,
`--sah-brand-text`, `--sah-call-bg`, `--sah-border`, `--sah-family` — set on
any ancestor to recolour it.
## Selection
Annotated for home-services and local-business markets because its structure
is local service — a click-to-call action, a service-area line, a list of
towns — not because of any artwork. Professional and bold in tone; no motion;
direct pressure. It needs no client material; a phone number adds the panel
and a photo fills it.
===== END src/components/ServiceAreaHero.md =====
--- VERIFIED IN THE COMPONENT LIBRARY ---
Enrolled for verification but never run.
No verification run has been recorded.
No checks have passed yet.
No usable source fingerprint, so these results cannot be tied to the files below.
--- NOT PROVEN ---
- ClientRouter: not applicable — this component ships no script.
--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.
--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.