Component library
Back to catalog

Area contact section

The end of a local-service page: copy and a ruled list of contact details (phone, email, address, hours — each only if given) with a call button and a main action, beside the towns served on a dark panel and, when a real embed is supplied, a map.

contactservice areamapclick-to-calllocal servicelight

Open preview in a new tab

Use this component

Three ways to get Area contact section 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 Area contact section from my Astro Component Library to this Astro project.

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

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: "Area contact section"
Library page: https://astro.baysixmedia.com/components/area-contact-section/
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/AreaContactSection.astro =====
---
/**
 * AreaContactSection
 *
 * The end of a local-service page: do you serve my area, how do I reach you,
 * what do I do next. Copy and a list of contact details on one side — phone,
 * email, address, hours, each only if given — with a call button and a main
 * action beneath. On the other side, the towns covered on a dark panel, and
 * above them a map when a real embed is supplied.
 *
 * Nothing about the business is assumed. No number is dialled, no address,
 * email, hours or town shown, and no map embedded unless it is passed — a map
 * only from an explicit https embed URL. With none of those it is a clean
 * single column with its heading and action, not a card with holes in it.
 *
 * No script. Reads --acs-* tokens from any ancestor, with fallbacks.
 * Step 34: --acs-grid-max / --acs-grid-gutter (desktop) put the content on a
 * site's page grid, and --acs-heading-size lets the site's heading scale size the heading;
 * with no input the design is exactly as it was.
 */

export interface AreaContactSectionProps {
	heading: string;
	headingLevel?: "h2" | "h3";
	eyebrow?: string;
	body?: string;
	/** Click-to-call and the phone row. Only a real number; `href` defaults to `tel:` plus the digits. */
	phone?: { number: string; label?: string; href?: string } | null;
	email?: string;
	address?: string;
	/** A link for the address, e.g. a maps search URL. */
	addressHref?: string;
	/** Real opening hours: "Mon–Fri 7am–6pm". */
	hours?: string;
	/** Towns or neighbourhoods served. */
	serviceAreas?: readonly string[];
	serviceAreasLabel?: string;
	/** An embedded map. Rendered only from an https URL; `title` names the frame for screen readers. */
	map?: { embedUrl: string; title?: string } | null;
	/** The main action, e.g. a booking or estimate page. */
	cta?: { label: string; href: string } | null;
	/** Labels for the contact rows. */
	labels?: { phone?: string; email?: string; address?: string; hours?: string };
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends AreaContactSectionProps {}

const {
	heading,
	headingLevel = "h2",
	eyebrow = "",
	body = "",
	phone = null,
	email = "",
	address = "",
	addressHref = "",
	hours = "",
	serviceAreas = [],
	serviceAreasLabel = "Areas we serve",
	map = null,
	cta = null,
	labels = {},
	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;
const mail = email.trim();
const place = address.trim();
const open = hours.trim();
const areas = (serviceAreas ?? []).map((a) => a.trim()).filter(Boolean);
/* A map only from an explicit https embed: never a guessed location. */
const embed = map && /^https:\/\//i.test(map.embedUrl?.trim() ?? "") ? { src: map.embedUrl.trim(), title: map.title?.trim() || "Map" } : null;

const L = { phone: "Phone", email: "Email", address: "Address", hours: "Hours", ...labels };
const hasDetails = Boolean(call || mail || place || open);
const hasAside = Boolean(embed || areas.length > 0);
const Heading = headingLevel;
---

{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section class:list={["acs", { "acs--split": hasAside }, className]} id={id}>
	<div class="acs-inner">
		<div class="acs-main">
			{eyebrow && <p class="acs-eyebrow">{eyebrow}</p>}
			<Heading class="acs-title">{heading}</Heading>
			{body && <p class="acs-body">{body}</p>}

			{
				hasDetails && (
					<dl class="acs-details">
						{call && (
							<div class="acs-detail">
								<dt>{L.phone}</dt>
								<dd>
									<a href={call.href}>{call.number}</a>
								</dd>
							</div>
						)}
						{mail && (
							<div class="acs-detail">
								<dt>{L.email}</dt>
								<dd>
									<a href={`mailto:${mail}`}>{mail}</a>
								</dd>
							</div>
						)}
						{place && (
							<div class="acs-detail">
								<dt>{L.address}</dt>
								<dd>{addressHref.trim() ? <a href={safeHref(addressHref)}>{place}</a> : place}</dd>
							</div>
						)}
						{open && (
							<div class="acs-detail">
								<dt>{L.hours}</dt>
								<dd>{open}</dd>
							</div>
						)}
					</dl>
				)
			}

			{
				(call || cta) && (
					<div class="acs-actions">
						{call && (
							<a class="acs-btn acs-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>
						)}
						{cta && (
							<a class="acs-btn acs-btn--primary" href={safeHref(cta.href)}>
								{cta.label}
							</a>
						)}
					</div>
				)
			}
		</div>

		{
			hasAside && (
				<div class="acs-aside">
					{embed && (
						<div class="acs-map">
							<iframe src={embed.src} title={embed.title} loading="lazy" referrerpolicy="no-referrer-when-downgrade" />
						</div>
					)}
					{areas.length > 0 && (
						<div class="acs-areas">
							<p class="acs-areas-label">{serviceAreasLabel}</p>
							<ul class="acs-area-list" role="list">
								{areas.map((area) => (
									<li>{area}</li>
								))}
							</ul>
						</div>
					)}
				</div>
			)
		}
	</div>
</section>

<style>
	.acs {
		--acs-surface: var(--acs-bg, #f5f7fa);
		--acs-ink: var(--acs-text, #0f1b2d);
		--acs-muted: var(--acs-text-muted, #475569);
		--acs-accent: var(--acs-brand, #1d4ed8);
		--acs-accent-ink: var(--acs-brand-text, #ffffff);
		--acs-panel: var(--acs-panel-bg, #0f1b2d);
		--acs-rule: var(--acs-border, #d9dee7);
		--acs-font: var(--acs-family, Manrope, ui-sans-serif, system-ui, sans-serif);

		background: var(--acs-surface);
		color: var(--acs-ink);
		font-family: var(--acs-font);
	}

	.acs *,
	.acs *::before,
	.acs *::after {
		box-sizing: border-box;
	}

	.acs-inner {
		display: grid;
		gap: 32px;
		max-width: var(--acs-grid-max, 1200px);
		margin: 0 auto;
		padding: 56px 16px;
	}

	.acs-main {
		min-width: 0;
	}

	.acs-eyebrow {
		margin: 0 0 12px;
		color: var(--acs-accent);
		font: 700 13px/1.4 var(--acs-font);
		letter-spacing: 0.06em;
		text-transform: uppercase;
	}

	.acs-title {
		margin: 0;
		max-width: 20ch;
		font: 800 var(--acs-heading-size, clamp(28px, 5vw, 42px)) / 1.12 var(--acs-font);
		letter-spacing: -0.015em;
		overflow-wrap: anywhere;
	}

	.acs-body {
		max-width: 54ch;
		margin: 16px 0 0;
		color: var(--acs-muted);
		font: 500 17px/1.6 var(--acs-font);
		overflow-wrap: anywhere;
	}

	.acs-details {
		display: grid;
		margin: 28px 0 0;
		border-top: 1px solid var(--acs-rule);
	}

	.acs-detail {
		display: grid;
		grid-template-columns: minmax(80px, 110px) minmax(0, 1fr);
		gap: 12px;
		padding: 14px 0;
		border-bottom: 1px solid var(--acs-rule);
	}

	.acs-detail dt {
		color: var(--acs-muted);
		font: 700 14px/1.5 var(--acs-font);
	}

	.acs-detail dd {
		margin: 0;
		font: 700 16px/1.5 var(--acs-font);
		overflow-wrap: anywhere;
	}

	/* A 28px target however short the line: phone and email are the links people tap most. */
	.acs-detail a {
		display: inline-block;
		padding: 3px 0;
		color: var(--acs-ink);
		text-decoration: underline;
		text-decoration-color: var(--acs-rule);
		text-underline-offset: 4px;
	}

	.acs-detail a:hover {
		text-decoration-color: var(--acs-accent);
	}

	.acs-actions {
		display: flex;
		flex-direction: column;
		gap: 12px;
		margin-top: 28px;
	}

	.acs-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(--acs-font);
		text-align: center;
		text-decoration: none;
		transition:
			background-color 0.15s ease,
			color 0.15s ease;
		overflow-wrap: anywhere;
	}

	.acs-btn svg {
		flex: none;
		width: 20px;
		height: 20px;
	}

	.acs-btn--call {
		background: var(--acs-accent);
		color: var(--acs-accent-ink);
	}

	.acs-btn--call:hover {
		background: var(--acs-ink);
	}

	.acs-btn--primary {
		border-color: var(--acs-ink);
		color: var(--acs-ink);
	}

	.acs-btn--primary:hover {
		background: var(--acs-ink);
		color: var(--acs-surface);
	}

	.acs-btn:focus-visible,
	.acs-detail a:focus-visible {
		outline: 3px solid var(--acs-accent);
		outline-offset: 3px;
	}

	.acs-aside {
		display: grid;
		gap: 16px;
		align-content: start;
		min-width: 0;
	}

	.acs-map {
		overflow: hidden;
		border: 1px solid var(--acs-rule);
		border-radius: 16px;
		background: var(--acs-panel);
		aspect-ratio: 4 / 3;
	}

	.acs-map iframe {
		display: block;
		width: 100%;
		height: 100%;
		border: 0;
	}

	.acs-areas {
		padding: 24px;
		border-radius: 16px;
		background:
			linear-gradient(135deg, rgb(255 255 255 / 0.06) 25%, transparent 25%) 0 0 / 28px 28px,
			var(--acs-panel);
		color: #fff;
	}

	.acs-areas-label {
		margin: 0 0 12px;
		color: rgb(255 255 255 / 0.7);
		font: 700 12px/1.4 var(--acs-font);
		letter-spacing: 0.06em;
		text-transform: uppercase;
	}

	.acs-area-list {
		display: flex;
		flex-wrap: wrap;
		gap: 8px;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.acs-area-list li {
		max-width: 100%;
		padding: 6px 12px;
		border-radius: 999px;
		background: rgb(255 255 255 / 0.1);
		font: 600 14px/1.3 var(--acs-font);
		overflow-wrap: anywhere;
	}

	@media (min-width: 640px) {
		.acs-actions {
			flex-direction: row;
			flex-wrap: wrap;
		}
	}

	@media (min-width: 960px) {
		.acs-inner {
			padding: 88px var(--acs-grid-gutter, 32px);
		}

		.acs--split .acs-inner {
			grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
			gap: 56px;
			align-items: start;
		}

		/*
		 * Step 39: with no map and no area list there is nothing for the second column, so the section
		 * splits itself — eyebrow and heading on the left, the body, details and actions on the right,
		 * starting level with the heading — instead of leaving half of a wide grid empty. Explicit rows,
		 * so an absent body or detail list simply leaves an empty (zero-height) row.
		 */
		.acs:not(.acs--split) .acs-main {
			display: grid;
			grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
			column-gap: 56px;
			align-items: start;
		}

		.acs:not(.acs--split) .acs-eyebrow {
			grid-column: 1;
			grid-row: 1;
		}

		.acs:not(.acs--split) .acs-title {
			grid-column: 1;
			grid-row: 2 / span 3;
		}

		.acs:not(.acs--split) .acs-body {
			grid-column: 2;
			grid-row: 2;
			margin-top: 0;
		}

		.acs:not(.acs--split) .acs-details {
			grid-column: 2;
			grid-row: 3;
		}

		.acs:not(.acs--split) .acs-actions {
			grid-column: 2;
			grid-row: 4;
		}

		.acs-areas {
			padding: 32px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.acs-btn {
			transition: none;
		}
	}
</style>

===== END src/components/AreaContactSection.astro =====

===== FILE (supporting): src/components/AreaContactSection.md =====
# Area contact section

The end of a local-service page: do you serve my area, how do I reach you,
what do I do next. Copy and a ruled list of contact details — phone, email,
address, hours, each only if given — with a call button and a main action
beneath. Beside it, the towns covered on a dark panel, and a map above them
when a real embed is supplied. Pairs with the service area hero (same palette
and type) but depends on nothing.

Catalog page: `/components/area-contact-section`

## Files required

- **`AreaContactSection.astro`** — the whole component. No script, no images.
- `AreaContactSection.md` — this file.

## Basic usage

```astro
<AreaContactSection
  heading="Serving San Mateo and the Peninsula"
  body="Call for same-day help, or book a visit online."
  phone={{ number: "(650) 555-0142" }}
  email="hello@example.com"
  hours="Mon–Sat 7am–7pm"
  serviceAreas={["San Mateo", "Burlingame", "Foster City"]}
  map={{ embedUrl: "https://www.google.com/maps/embed?pb=…", title: "Map of our service area" }}
  cta={{ label: "Book a visit", href: "/book" }}
/>
```

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `heading` | `string` | **required** | The section heading. |
| `headingLevel` | `"h2" \| "h3"` | `"h2"` | |
| `eyebrow` | `string?` | `""` | Short line above the heading. |
| `body` | `string?` | `""` | One or two sentences. |
| `phone` | `{ number, label?, href? } \| null` | `null` | Phone row and call button. See below. |
| `email` | `string?` | `""` | Email row (a `mailto:` link). |
| `address` | `string?` | `""` | Address row. |
| `addressHref` | `string?` | `""` | Makes the address a link, e.g. a maps search. |
| `hours` | `string?` | `""` | Real hours: "Mon–Fri 7am–6pm". |
| `serviceAreas` | `string[]?` | `[]` | Towns on the dark panel. |
| `serviceAreasLabel` | `string?` | `"Areas we serve"` | Label above that list. |
| `map` | `{ embedUrl, title? } \| null` | `null` | Embedded map. https only. |
| `cta` | `{ label, href } \| null` | `null` | Outlined button beside the call button. |
| `labels` | `{ phone?, email?, address?, hours? }` | English | Row labels. |
| `fontStylesheet` | `string?` | Manrope | `""` skips it. |
| `class`, `id` | | | Root attributes. |

## The phone call to action

The call button and the phone row render **only** when `phone.number` is a
non-empty string; the component never supplies a number. The link is `tel:`
plus the digits (a leading `+` is kept); `phone.href` overrides it. The
button reads "Call (650) 555-0142" unless `phone.label` says otherwise.

## No form

This section has no form and doesn't claim one. When a page needs a quote or
contact form, use a form component (`contact-split-form`, `quote-request-panel`)
alongside or instead of it.

## Map

Rendered only from an explicit `map.embedUrl` that starts with `https://` —
anything else is ignored. It is a lazy-loaded iframe titled by `map.title`
("Map" if omitted). Without one there is no map, no placeholder and no
guessed location. Embedding a third-party map loads that provider's scripts
and cookies; the choice is the site's.

## Trust data

Phone, email, address, hours, towns and map each render only when passed.
Nothing about the business has a default. With none of them the section is a
clean single column with its heading, copy and action.

## Layout and media

| Supplied | Result |
| :--- | :--- |
| No towns, no map | One column. |
| Towns | Two columns from 960px; towns on a dark panel with a faint diagonal pattern. |
| A map | Two columns from 960px; the map (4:3) above the towns. |

## Responsive behaviour

- Below 640px the buttons stack full width, call first.
- Order on phones: heading, copy, details, buttons, then map and towns.
- Detail rows keep label and value side by side down to 320px.

## Motion

0.15s colour transitions on the buttons, removed under
`prefers-reduced-motion: reduce`.

## Accessibility

- Contact details are a `<dl>`; towns are a list.
- The phone icon is `aria-hidden`; the button's text carries the number.
- The map iframe has a `title`.
- Links and buttons show a 3px focus outline in the brand colour.

## Tokens

`--acs-bg`, `--acs-text`, `--acs-text-muted`, `--acs-brand`,
`--acs-brand-text`, `--acs-panel-bg`, `--acs-border`, `--acs-family` — set on
any ancestor to recolour it.

## Selection

Role `contact`. Annotated for home-services and local-business markets
because of its structure — click-to-call, a service-area list, an optional
map — not its look. Professional and minimal; single column by default, with
`split` alternates reached by `serviceAreas` or by a `map`; no motion; direct
pressure (call button). `form` is deliberately absent from its content, so it
never satisfies `mustSupport: ["form"]`.

===== END src/components/AreaContactSection.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
“Area contact section” from my Astro Component Library:
https://astro.baysixmedia.com/components/area-contact-section/

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/AreaContactSection.astro

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

---
/**
 * AreaContactSection
 *
 * The end of a local-service page: do you serve my area, how do I reach you,
 * what do I do next. Copy and a list of contact details on one side — phone,
 * email, address, hours, each only if given — with a call button and a main
 * action beneath. On the other side, the towns covered on a dark panel, and
 * above them a map when a real embed is supplied.
 *
 * Nothing about the business is assumed. No number is dialled, no address,
 * email, hours or town shown, and no map embedded unless it is passed — a map
 * only from an explicit https embed URL. With none of those it is a clean
 * single column with its heading and action, not a card with holes in it.
 *
 * No script. Reads --acs-* tokens from any ancestor, with fallbacks.
 * Step 34: --acs-grid-max / --acs-grid-gutter (desktop) put the content on a
 * site's page grid, and --acs-heading-size lets the site's heading scale size the heading;
 * with no input the design is exactly as it was.
 */

export interface AreaContactSectionProps {
	heading: string;
	headingLevel?: "h2" | "h3";
	eyebrow?: string;
	body?: string;
	/** Click-to-call and the phone row. Only a real number; `href` defaults to `tel:` plus the digits. */
	phone?: { number: string; label?: string; href?: string } | null;
	email?: string;
	address?: string;
	/** A link for the address, e.g. a maps search URL. */
	addressHref?: string;
	/** Real opening hours: "Mon–Fri 7am–6pm". */
	hours?: string;
	/** Towns or neighbourhoods served. */
	serviceAreas?: readonly string[];
	serviceAreasLabel?: string;
	/** An embedded map. Rendered only from an https URL; `title` names the frame for screen readers. */
	map?: { embedUrl: string; title?: string } | null;
	/** The main action, e.g. a booking or estimate page. */
	cta?: { label: string; href: string } | null;
	/** Labels for the contact rows. */
	labels?: { phone?: string; email?: string; address?: string; hours?: string };
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends AreaContactSectionProps {}

const {
	heading,
	headingLevel = "h2",
	eyebrow = "",
	body = "",
	phone = null,
	email = "",
	address = "",
	addressHref = "",
	hours = "",
	serviceAreas = [],
	serviceAreasLabel = "Areas we serve",
	map = null,
	cta = null,
	labels = {},
	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;
const mail = email.trim();
const place = address.trim();
const open = hours.trim();
const areas = (serviceAreas ?? []).map((a) => a.trim()).filter(Boolean);
/* A map only from an explicit https embed: never a guessed location. */
const embed = map && /^https:\/\//i.test(map.embedUrl?.trim() ?? "") ? { src: map.embedUrl.trim(), title: map.title?.trim() || "Map" } : null;

const L = { phone: "Phone", email: "Email", address: "Address", hours: "Hours", ...labels };
const hasDetails = Boolean(call || mail || place || open);
const hasAside = Boolean(embed || areas.length > 0);
const Heading = headingLevel;
---

{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section class:list={["acs", { "acs--split": hasAside }, className]} id={id}>
	<div class="acs-inner">
		<div class="acs-main">
			{eyebrow && <p class="acs-eyebrow">{eyebrow}</p>}
			<Heading class="acs-title">{heading}</Heading>
			{body && <p class="acs-body">{body}</p>}

			{
				hasDetails && (
					<dl class="acs-details">
						{call && (
							<div class="acs-detail">
								<dt>{L.phone}</dt>
								<dd>
									<a href={call.href}>{call.number}</a>
								</dd>
							</div>
						)}
						{mail && (
							<div class="acs-detail">
								<dt>{L.email}</dt>
								<dd>
									<a href={`mailto:${mail}`}>{mail}</a>
								</dd>
							</div>
						)}
						{place && (
							<div class="acs-detail">
								<dt>{L.address}</dt>
								<dd>{addressHref.trim() ? <a href={safeHref(addressHref)}>{place}</a> : place}</dd>
							</div>
						)}
						{open && (
							<div class="acs-detail">
								<dt>{L.hours}</dt>
								<dd>{open}</dd>
							</div>
						)}
					</dl>
				)
			}

			{
				(call || cta) && (
					<div class="acs-actions">
						{call && (
							<a class="acs-btn acs-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>
						)}
						{cta && (
							<a class="acs-btn acs-btn--primary" href={safeHref(cta.href)}>
								{cta.label}
							</a>
						)}
					</div>
				)
			}
		</div>

		{
			hasAside && (
				<div class="acs-aside">
					{embed && (
						<div class="acs-map">
							<iframe src={embed.src} title={embed.title} loading="lazy" referrerpolicy="no-referrer-when-downgrade" />
						</div>
					)}
					{areas.length > 0 && (
						<div class="acs-areas">
							<p class="acs-areas-label">{serviceAreasLabel}</p>
							<ul class="acs-area-list" role="list">
								{areas.map((area) => (
									<li>{area}</li>
								))}
							</ul>
						</div>
					)}
				</div>
			)
		}
	</div>
</section>

<style>
	.acs {
		--acs-surface: var(--acs-bg, #f5f7fa);
		--acs-ink: var(--acs-text, #0f1b2d);
		--acs-muted: var(--acs-text-muted, #475569);
		--acs-accent: var(--acs-brand, #1d4ed8);
		--acs-accent-ink: var(--acs-brand-text, #ffffff);
		--acs-panel: var(--acs-panel-bg, #0f1b2d);
		--acs-rule: var(--acs-border, #d9dee7);
		--acs-font: var(--acs-family, Manrope, ui-sans-serif, system-ui, sans-serif);

		background: var(--acs-surface);
		color: var(--acs-ink);
		font-family: var(--acs-font);
	}

	.acs *,
	.acs *::before,
	.acs *::after {
		box-sizing: border-box;
	}

	.acs-inner {
		display: grid;
		gap: 32px;
		max-width: var(--acs-grid-max, 1200px);
		margin: 0 auto;
		padding: 56px 16px;
	}

	.acs-main {
		min-width: 0;
	}

	.acs-eyebrow {
		margin: 0 0 12px;
		color: var(--acs-accent);
		font: 700 13px/1.4 var(--acs-font);
		letter-spacing: 0.06em;
		text-transform: uppercase;
	}

	.acs-title {
		margin: 0;
		max-width: 20ch;
		font: 800 var(--acs-heading-size, clamp(28px, 5vw, 42px)) / 1.12 var(--acs-font);
		letter-spacing: -0.015em;
		overflow-wrap: anywhere;
	}

	.acs-body {
		max-width: 54ch;
		margin: 16px 0 0;
		color: var(--acs-muted);
		font: 500 17px/1.6 var(--acs-font);
		overflow-wrap: anywhere;
	}

	.acs-details {
		display: grid;
		margin: 28px 0 0;
		border-top: 1px solid var(--acs-rule);
	}

	.acs-detail {
		display: grid;
		grid-template-columns: minmax(80px, 110px) minmax(0, 1fr);
		gap: 12px;
		padding: 14px 0;
		border-bottom: 1px solid var(--acs-rule);
	}

	.acs-detail dt {
		color: var(--acs-muted);
		font: 700 14px/1.5 var(--acs-font);
	}

	.acs-detail dd {
		margin: 0;
		font: 700 16px/1.5 var(--acs-font);
		overflow-wrap: anywhere;
	}

	/* A 28px target however short the line: phone and email are the links people tap most. */
	.acs-detail a {
		display: inline-block;
		padding: 3px 0;
		color: var(--acs-ink);
		text-decoration: underline;
		text-decoration-color: var(--acs-rule);
		text-underline-offset: 4px;
	}

	.acs-detail a:hover {
		text-decoration-color: var(--acs-accent);
	}

	.acs-actions {
		display: flex;
		flex-direction: column;
		gap: 12px;
		margin-top: 28px;
	}

	.acs-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(--acs-font);
		text-align: center;
		text-decoration: none;
		transition:
			background-color 0.15s ease,
			color 0.15s ease;
		overflow-wrap: anywhere;
	}

	.acs-btn svg {
		flex: none;
		width: 20px;
		height: 20px;
	}

	.acs-btn--call {
		background: var(--acs-accent);
		color: var(--acs-accent-ink);
	}

	.acs-btn--call:hover {
		background: var(--acs-ink);
	}

	.acs-btn--primary {
		border-color: var(--acs-ink);
		color: var(--acs-ink);
	}

	.acs-btn--primary:hover {
		background: var(--acs-ink);
		color: var(--acs-surface);
	}

	.acs-btn:focus-visible,
	.acs-detail a:focus-visible {
		outline: 3px solid var(--acs-accent);
		outline-offset: 3px;
	}

	.acs-aside {
		display: grid;
		gap: 16px;
		align-content: start;
		min-width: 0;
	}

	.acs-map {
		overflow: hidden;
		border: 1px solid var(--acs-rule);
		border-radius: 16px;
		background: var(--acs-panel);
		aspect-ratio: 4 / 3;
	}

	.acs-map iframe {
		display: block;
		width: 100%;
		height: 100%;
		border: 0;
	}

	.acs-areas {
		padding: 24px;
		border-radius: 16px;
		background:
			linear-gradient(135deg, rgb(255 255 255 / 0.06) 25%, transparent 25%) 0 0 / 28px 28px,
			var(--acs-panel);
		color: #fff;
	}

	.acs-areas-label {
		margin: 0 0 12px;
		color: rgb(255 255 255 / 0.7);
		font: 700 12px/1.4 var(--acs-font);
		letter-spacing: 0.06em;
		text-transform: uppercase;
	}

	.acs-area-list {
		display: flex;
		flex-wrap: wrap;
		gap: 8px;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.acs-area-list li {
		max-width: 100%;
		padding: 6px 12px;
		border-radius: 999px;
		background: rgb(255 255 255 / 0.1);
		font: 600 14px/1.3 var(--acs-font);
		overflow-wrap: anywhere;
	}

	@media (min-width: 640px) {
		.acs-actions {
			flex-direction: row;
			flex-wrap: wrap;
		}
	}

	@media (min-width: 960px) {
		.acs-inner {
			padding: 88px var(--acs-grid-gutter, 32px);
		}

		.acs--split .acs-inner {
			grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
			gap: 56px;
			align-items: start;
		}

		/*
		 * Step 39: with no map and no area list there is nothing for the second column, so the section
		 * splits itself — eyebrow and heading on the left, the body, details and actions on the right,
		 * starting level with the heading — instead of leaving half of a wide grid empty. Explicit rows,
		 * so an absent body or detail list simply leaves an empty (zero-height) row.
		 */
		.acs:not(.acs--split) .acs-main {
			display: grid;
			grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
			column-gap: 56px;
			align-items: start;
		}

		.acs:not(.acs--split) .acs-eyebrow {
			grid-column: 1;
			grid-row: 1;
		}

		.acs:not(.acs--split) .acs-title {
			grid-column: 1;
			grid-row: 2 / span 3;
		}

		.acs:not(.acs--split) .acs-body {
			grid-column: 2;
			grid-row: 2;
			margin-top: 0;
		}

		.acs:not(.acs--split) .acs-details {
			grid-column: 2;
			grid-row: 3;
		}

		.acs:not(.acs--split) .acs-actions {
			grid-column: 2;
			grid-row: 4;
		}

		.acs-areas {
			padding: 32px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.acs-btn {
			transition: none;
		}
	}
</style>