# BW AI Schema Pro — Accommodations (Rooms) Module

**Status:** Specified 2026-07-22 · Target version 2.4.0
**Author:** rian + Claude

## Why

Resort/hotel clients (Turks & Caicos luxury properties and similar) have a page per
room, suite, or villa. Today the plugin can describe the *property* — `localbusiness:Resort`
routes to `BW_Schema_LocalBusiness::add_lodging_properties()` and emits `starRating`,
`checkinTime`, `checkoutTime`, `numberOfRooms`, `petsAllowed`, `availableLanguage` —
but it has **no room-level type at all**. No `Accommodation`, `HotelRoom`, `Suite`,
`bed`, `occupancy`, `floorSize`. A "Two Bedroom Oceanfront Suite" page falls through
to the `article:Article` default and says nothing about the room.

This module closes that gap the same way the People module closed the Person gap.

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

| Decision | Choice | Rationale |
|---|---|---|
| Resort identity | The **site Organization** (`#organization`) is the Resort | One property per site — the common client shape. The Setup Wizard already sets the site's schema type; picking `Resort` makes `#organization` the Resort node. No second identity layer. |
| Room source | A **Rooms/Accommodations CPT** | Mirrors People exactly. Each room gets a real URL, so it gets a real `@id` and can be referenced from the graph. |
| Rates | **In scope** — a "from" rate | Clients want price signal. Emitted as a *starting* price (`minPrice`), never as a bookable price assertion, and paired with a staleness guard. |
| Multi-property | **Out of scope** for 2.4.0 | A brand with several resorts on one site needs a Resort CPT and a room→resort relation. Deferred; the `@id` scheme below leaves room for it. |

## Schema shape

```
Organization/Resort (#organization)
  └─ containsPlace[] ──▶ HotelRoom (#room)
                          └─ containedInPlace ──▶ {"@id": ".../#organization"}
```

`Resort` sits at `Thing → Place → LocalBusiness → LodgingBusiness → Resort`. Because it
is a `Place`, `containsPlace` is valid on it. `HotelRoom` sits at
`Thing → Place → Accommodation → Room → HotelRoom` and contributes only two properties
of its own — `bed` and `occupancy`; everything else is inherited from `Accommodation`
and `Place`.

### Emitted node (single-room page)

```json
{
  "@type": "HotelRoom",
  "@id": "https://example.com/rooms/oceanfront-suite/#room",
  "name": "Two Bedroom Oceanfront Suite",
  "url": "https://example.com/rooms/oceanfront-suite/",
  "description": "…",
  "image": "…",
  "containedInPlace": { "@id": "https://example.com/#organization" },
  "occupancy": { "@type": "QuantitativeValue", "unitCode": "C62", "maxValue": 6 },
  "bed": [
    { "@type": "BedDetails", "typeOfBed": "King", "numberOfBeds": 1 },
    { "@type": "BedDetails", "typeOfBed": "Queen", "numberOfBeds": 2 }
  ],
  "numberOfBedrooms": 2,
  "numberOfBathroomsTotal": 3,
  "numberOfFullBathrooms": 2,
  "numberOfPartialBathrooms": 1,
  "floorSize": { "@type": "QuantitativeValue", "value": 1850, "unitCode": "FTK" },
  "floorLevel": "3",
  "amenityFeature": [
    { "@type": "LocationFeatureSpecification", "name": "Private plunge pool", "value": true }
  ],
  "petsAllowed": false,
  "smokingAllowed": false,
  "permittedUsage": "Maximum 6 guests including children",
  "accommodationFloorPlan": {
    "@type": "FloorPlan",
    "numberOfBedrooms": 2,
    "numberOfBathroomsTotal": 3,
    "floorSize": { "@type": "QuantitativeValue", "value": 1850, "unitCode": "FTK" },
    "layoutImage": "…"
  },
  "tourBookingPage": "https://example.com/book/"
}
```

### Offers live on the Resort's `makesOffer[]`, not on the room

**`offers` is NOT valid on `Accommodation`.** Its domain is Product / Service /
CreativeWork / Event / MenuItem / Trip / AggregateOffer /
EducationalOccupationalProgram — `Accommodation` descends from `Place` and is on
none of those lists. The relationship therefore runs the other way: an `Offer`
whose `itemOffered` references the room's `@id`.

**That Offer belongs on `Resort.makesOffer[]`** (valid on `Organization`, which
`Resort` inherits from), *not* as a top-level sibling node in the `@graph`. A
top-level Offer is referenced by nothing, which makes it an additional **root**
of the graph — and a validator that roots its tree at an unreferenced node then
renders the whole property as a nested detail of the Offer, so `Resort` stops
being the headline entity. Shipping it that way was caught in review on
2026-07-22 and corrected. Semantically this is also the better read: the
property makes the offers; each offer points at the room it is for.

For the same reason the room node carries **no `datePublished` / `dateModified`** —
those are `CreativeWork` properties with no meaning on a Place. (Both mistakes
were made in the first cut and caught by validator.schema.org on 2026-07-22;
the team-member module *does* legitimately carry them, because that node is a
`ProfilePage`.)

```json
// inside the Resort node: "makesOffer": [ … ]
{
  "@type": "Offer",
  "@id": "https://example.com/rooms/oceanfront-suite/#offer",
  "itemOffered": { "@id": "https://example.com/rooms/oceanfront-suite/#room" },
  "availability": "https://schema.org/InStock",
  "url": "https://book.example.com/oceanfront-suite",
  "priceSpecification": {
    "@type": "UnitPriceSpecification",
    "minPrice": 1450,
    "priceCurrency": "USD",
    "unitCode": "DAY"
  }
}
```

**Why `minPrice` on a `UnitPriceSpecification` rather than `Offer.price`.** A resort's
nightly rate is seasonal. `price` asserts *the* price; `minPrice` asserts a floor, which
is what "from $1,450/night" actually means. `unitCode: DAY` (or `WK`) carries the "per
night" / "per week" semantics that a bare number can't.

### Emitted on the Organization node

```json
"containsPlace": [
  { "@type": "HotelRoom", "@id": ".../#room", "name": "…", "url": "…", "image": "…" }
]
```

Compact reference nodes only — the full room description lives on the room's own page,
addressed by the same `@id`, so the graph stays connected without duplicating every field
onto the homepage.

## Rich results — set expectations honestly

Google's lodging rich results (`VacationRental`, hotel price cards) require **Hotel Center
participation and a feed**; markup alone will not produce a property card, and
`VacationRental` additionally demands ≥8 photos and precise geo. This module does not
chase that. Its payoff is (a) AI/LLM answer engines, which is this plugin's stated
purpose, and (b) being feed-ready if a client later joins Hotel Center. Do not promise a
client a rich result from this.

## Data storage

**Meta keys are namespaced `_bw_schema_room_*`** — not bare `_bw_schema_*`. The generic
`BW_Schema_LocalBusiness` path already claims `_bw_schema_amenities`, and the Person
module claims `_bw_schema_awards` / `_bw_schema_credentials`. Namespacing means a room
post can never collide with either, and it keeps the 3.0.0 rename inventory unambiguous.

### Options

| Option | Type | Purpose |
|---|---|---|
| `bw_schema_room_post_type` | string | The CPT that holds rooms. Module is inert until set. Mirrored into `bw_schema_settings['content']['room_post_type']`, matching the People convention. |
| `bw_schema_accommodation` | array | Module defaults: `currency`, `rate_unit`, `rate_stale_days`. One array option, not a row per setting. |

### Post meta

| Key | Type | Emits |
|---|---|---|
| `_bw_schema_room_type` | string | `@type` — `HotelRoom` (default), `Suite`, `House`, `Apartment`, `CampingPitch`, `Room` |
| `_bw_schema_room_override_name` | string | `name` (else post title) |
| `_bw_schema_room_override_description` | string | `description` (else excerpt) |
| `_bw_schema_room_override_image` | url | `image` (else featured image) |
| `_bw_schema_room_override_url` | url | `url` (else permalink) |
| `_bw_schema_room_occupancy` | array `{min,max}` | `occupancy` (QuantitativeValue, unitCode C62) |
| `_bw_schema_room_beds` | array of `{type,count}` | `bed[]` (BedDetails) |
| `_bw_schema_room_layout` | array | `numberOfBedrooms`, `numberOfBathroomsTotal`, `numberOfFullBathrooms`, `numberOfPartialBathrooms`, `numberOfRooms`, `floorSize`, `floorLevel` |
| `_bw_schema_room_amenities` | array of strings | `amenityFeature[]` (LocationFeatureSpecification, `value: true`) |
| `_bw_schema_room_policies` | array | `petsAllowed`, `smokingAllowed`, `permittedUsage` |
| `_bw_schema_room_offer` | array | `offers` (Offer + UnitPriceSpecification) |
| `_bw_schema_room_floorplan_image` | url | `accommodationFloorPlan.layoutImage` |
| `_bw_schema_room_tour_booking_page` | url | `tourBookingPage` |
| `_bw_schema_room_video` | array | `subjectOf` (VideoObject) |
| `_bw_schema_room_exclude` | '1' | Drops the room from `containsPlace[]` and suppresses its node |

## Rate staleness guard

`_bw_schema_room_offer['reviewed']` stores the date the rate was last confirmed. When it
is older than `rate_stale_days` (default 180), the room edit screen shows a warning and
**the `offers` block is withheld from the output**. A wrong price in structured data is
worse than no price — seasonal resort rates go stale silently, and nobody notices until a
guest quotes last winter's rate back at the front desk. Clearing the warning is one click
(re-confirm the date).

## UI

New **Rooms** tab on the Settings page, between People and Contact:
- CPT picker (`bw_schema_room_post_type`), same control as the People tab.
- Module defaults: currency, rate unit, staleness window.
- A table of published rooms with their type, from-rate, rate age, and an
  exclude-from-schema checkbox — mirroring the People tab's team table.

Per-room meta box **"Accommodation Schema Settings"**, using the People module's
detect-then-override idiom: each core field shows its auto-detected value and source
("Two Bedroom Oceanfront Suite — (Post Title)") with an override input beneath.

## Non-goals for 2.4.0

- Multi-property (Resort CPT + room→resort relation).
- A rooms-archive `ItemList` node (the People module has one for team pages; rooms
  fall through to `CollectionPage` for now).
- `VacationRental` / Hotel Center feed output.
- Availability calendars or real-time pricing.

## Consolidation note

The Person side is split across `BW_Schema_Person` (generic routing path) and
`BW_Schema_Team_Member` (the module), with duplicated meta-key fallback chains and two
different Person builders. **This module deliberately does not repeat that.** One class,
`BW_Schema_Room`, holds the meta box, the save handler, and the single
`get_room_schema_data()` provider; every consumer — the single-room page, the
Organization's `containsPlace[]`, and the settings table — reads from that one provider.
