Component library
Back to catalog

Service area hero

A light, call-first hero for a business that serves a place: the service as the headline, the area beneath it, a click-to-call button beside the main action, and — only when supplied — rating, licence and hours. A panel beside it sets the number large and lists the towns covered.

herolocal serviceclick-to-callservice arealightno photo needed

Open preview in a new tab

Use this component

Three ways to get Service area hero into one of your own projects. The first two carry the component's files with them, so nothing has to fetch this page.

Start here

Placement prompt

The whole thing: what to do, where it goes, what must not change, and every one of the component's 1 file appended underneath with the path each belongs at. Paste it into Claude Code inside the destination project — it fetches nothing and needs nothing else.

Read it first — the files are added underneath when you copy
Add Service area hero from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/service-area-hero/

Place it in:
[ENTER PAGE, FILE, OR ROUTE]

Position:
[ENTER WHERE IT SHOULD APPEAR]

Every file you need is appended below, each fenced with the path it belongs at. Write them there, relative to the project root. Do not import anything from the deployed library at runtime, and do not fetch the library page — the files below are the source of truth.

Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.

Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.

Install kit

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.

Component reference

A fragment for a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving branding and content open. It does not carry the code.

Read it first
“Service area hero” from my Astro Component Library:
https://astro.baysixmedia.com/components/service-area-hero/

Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.

The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.

Component readiness

Judged on recorded evidence, not on how it looks in the frame above. A description of a component is not a result.

Interactivity
Static
Profile
core only
Installation files
1 required + 1 supporting
Verified
Never
Client navigation
Not applicable — ships no script
Source fingerprint
No fingerprint recorded
Production ready
Not yet

Not yet verified

Enrolled for verification, but never run. Silence is reported as silence rather than assumed to be fine.

Open the verification fixture

src/components/library/ServiceAreaHero.astro

one fileNo imports, no companion files, no packages. Copy ServiceAreaHero.astro into any Astro project's components folder and render it.

---
/**
 * 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>