# BW AI Schema Pro — Locations & Service Areas

**Status:** Specified 2026-07-22 · Target version 2.5.0
**Author:** rian/adi + Claude

## Why

Three client shapes, all currently unserved or mis-served:

1. **Storefronts** — a business with branches in several cities. The plugin has
   exactly **one** site-wide address and no way to express a second location.
2. **Service-area business (SAB)** — e.g. an air compressor supplier that drives
   to customers across Alberta and Saskatchewan. Today "areas served" is a single
   comma-separated free-text box, emitted either as bare strings or (on the
   per-post LocalBusiness path) as **structurally invalid `GeoCircle`s**.
3. **Hybrid** — a shop that also runs a service radius.

## Design decisions (locked 2026-07-22)

| Decision | Choice | Rationale |
|---|---|---|
| Where locations live | **Repeater in Settings**, no CPT | Client sites generally don't have per-branch pages; this keeps the module usable without restructuring their content. Costs each location its own page `@id` — mitigated by the stable synthetic `@id` scheme below. |
| Service-area precision | **Named places + optional radius** | "We serve Alberta and Saskatchewan" and "we drive 150 km from the shop" are both real, and neither subsumes the other. Named places need no coordinates; the radius is opt-in. |
| Hybrid sites | **Emit both, warn in admin** | Pairing a street address with a tight radius sends mixed storefront/SAB signals — but that's community guidance, not Google policy, so it's a note the client can overrule, not an enforced rule. |
| Per-location opening hours | **Deferred** — checkbox to reuse the main hours | Full per-branch hours UI is a large surface. The common case (all branches share hours) is covered by a checkbox; divergent per-branch hours are the top follow-up. |

## Schema facts this is built on (verified at schema.org)

- **`areaServed`** accepts `Text` | `Place` | `AdministrativeArea` | `GeoShape`;
  valid on `Organization` (and `Service`, `Offer`, `ContactPoint`).
- **`serviceArea` is superseded by `areaServed`** — never emit `serviceArea`.
- **`branchOf` is superseded by `parentOrganization`** (domain `LocalBusiness`,
  range `Organization`); the inverse is `subOrganization`.
- **`GeoCircle` requires `geoMidpoint` + `geoRadius`.** A GeoCircle with only a
  `name` is meaningless — see the bug being fixed below.
- **`geoRadius` is in metres** unless a `Distance` is supplied. The admin accepts
  km or miles and converts to metres on output, so the emitted number is
  unambiguous.
- **`addressCountry`** should be **ISO 3166-1 alpha-2** (`CA`, `US`, `GB`).
  **`addressRegion`** is the first-level administrative division — province in
  Canada, state in the US, and the local equivalent elsewhere.

**Google's documented position, for honesty:** `LocalBusiness` requires only
`name` + `address`; geo (≥5 decimals), `telephone`, `openingHoursSpecification`,
`priceRange`, `url` are recommended. Google's LocalBusiness page says **nothing**
about service-area businesses or `areaServed` — the "hide your address as an SAB"
rule is a *Google Business Profile* rule, not a structured-data one. And
`department` is documented for departments *inside* a store ("gMart Pharmacy"),
**not** for branches in other cities. This module does not use `department` for
branches.

## Graph shape

```
Organization / LocalBusiness  (#organization)      ← the parent company
  ├─ areaServed[]        site-wide service areas
  └─ subOrganization[]   → { "@id": ".../#location-<id>" }   (refs, every page)

LocalBusiness  (#location-<id>)                    ← one per branch
  ├─ parentOrganization  → { "@id": ".../#organization" }
  ├─ address (PostalAddress), geo (GeoCoordinates), telephone, url
  └─ areaServed[]        that branch's own service areas
```

**Full location nodes are emitted on the homepage**; other pages carry only the
compact `subOrganization[]` refs on the Organization node. Same split as the
Rooms module's `containsPlace[]`, and it keeps interior pages light. Refs
pointing at `@id`s defined on another page are normal JSON-LD.

### Stable `@id`

Each location row gets an **immutable** `id` (e.g. `loc_a1b2c3d4`) generated once
on first save and never regenerated — renaming a branch, reordering rows, or
changing its page URL must not change its `@id`. The `@id` is
`home_url('/') . '#location-' . $id`, independent of any page URL, because these
locations have no pages of their own. A location may still carry a `url`
property pointing at a page about it.

### Service-area entries

Each entry is one of:

| Kind | Emits |
|---|---|
| City | `{"@type": "City", "name": "Calgary"}` |
| Province / State | `{"@type": "State", "name": "Alberta"}` |
| Country | `{"@type": "Country", "name": "Canada"}` |
| Administrative area | `{"@type": "AdministrativeArea", "name": "…"}` |
| Radius | `{"@type": "GeoCircle", "geoMidpoint": {"@type": "GeoCoordinates", "latitude": …, "longitude": …}, "geoRadius": <metres>}` |
| Text | the bare string |

## Data storage

| Option | Purpose |
|---|---|
| `bw_schema_locations` | Array of location rows (see below). One array option, not option-sprawl. |
| `bw_schema_service_areas` | Site-wide structured service areas. |

Legacy `bw_schema_organization['areaServed']` (comma-separated strings) is
**still honoured** as a fallback when `bw_schema_service_areas` is empty, so
existing customer sites keep working untouched. Legacy strings emit as plain
`Text`, which is valid — never as GeoCircle.

Location row keys: `id`, `name`, `type`, `street`, `city`, `region`, `postal`,
`country`, `lat`, `lng`, `telephone`, `email`, `url`, `use_main_hours`, `areas[]`.

## Bugs fixed alongside

Both in `includes/schemas/class-bw-schema-localbusiness.php`:

1. **`addressCountry` defaulted to `'US'`** (line 48: `$address['country'] ?? 'US'`).
   Any site that left the country blank published itself as American — actively
   wrong for the Canadian client base, not merely incomplete. Now omitted when
   unset.
2. **`areaServed` emitted invalid `GeoCircle`s** (lines 131–136) — every area
   wrapped as `{"@type": "GeoCircle", "name": "Calgary"}` with no `geoMidpoint`
   or `geoRadius`. Now routed through the shared service-area builder.

## Non-goals for 2.5.0

- Per-location opening hours beyond the "same as main hours" checkbox.
- A locations CPT / per-branch pages (explicitly decided against).
- Geocoding addresses to lat/lng automatically (no API dependency).
- `hasPOS`, `department` (wrong tool for branches — see above).
