07 — Block & Page Builder Spec
Scope: The hospitality analog of
BlockSpec— a typed registry of reusable, theme-aware page sections that tenants compose into their booking site, marketing pages, and email templates without writing code. Gates the booking-site CMS work. Aligned withtheme-config-servicepublish/preview/rollback.
Companions:
06-theming-and-tenant-config.md·../web/13-tenant-booking-web-specification.md·../web/14-control-plane-web-specification.md
1. Goals and non-goals
Goals:
- Tenants can compose their booking site's pages from a curated set of typed, accessible, theme-aware blocks without writing HTML/CSS.
- Each block is independently versioned, validatable, and migrateable.
- Blocks enforce brand tokens, RTL layout, and i18n — tenants cannot produce broken layouts.
- Platform team can iterate block designs without tenant intervention (non-breaking schema changes).
- The block registry is the single source of truth for what content is allowed on a tenant booking site.
Non-goals (R1):
- Custom blocks authored by tenants (Phase 2 — requires a review/sandbox pipeline first).
- Full WYSIWYG drag-and-drop editor in-browser (R1 uses structured form + live preview; drag-and-drop in Phase 2).
- Shared block registry with email templates (email blocks are a separate registry with MIME constraints).
2. The BlockSpec interface
Every block in the registry conforms to the following TypeScript interface (canonical in packages/block-registry/src/types.ts):
interface BlockSpec<TData extends BlockData = BlockData> {
/** Machine name — kebab-case, globally unique in registry */
type: string;
/** Human name (EN base; translated by i18n runtime) */
label: string;
/** Semver version of this block schema */
schemaVersion: string;
/** Zod schema for the data payload */
dataSchema: z.ZodType<TData>;
/** Default data values (used when tenant first inserts block) */
defaultData: TData;
/** Which pages may contain this block */
allowedIn: PageType[];
/** Max instances per page (null = unlimited) */
maxPerPage: number | null;
/** Whether this block is required on its allowed pages */
required: boolean;
/** Accessibility: what ARIA landmark this block creates (main, complementary, etc.) */
ariaLandmark?: ARIARole;
/** SEO: structured-data type emitted by this block */
structuredDataType?: 'Hotel' | 'Offer' | 'Review' | 'FAQPage' | 'LocalBusiness' | null;
/** Telemetry: event emitted on block render (for above-fold detection) */
impressionEvent?: string;
}
Page types: booking-home | room-listing | room-detail | checkout | confirmation | about | contact | policy | offers | gallery | custom
3. Built-in block registry (R1)
3.1 Hero
| Field | Type | Notes |
|---|---|---|
heading | LocaleString | H1 on booking home; max 60 chars |
subheading | LocaleString | Optional; max 120 chars |
backgroundMedia | MediaRef | Image or video (autoplay muted loop) |
ctaLabel | LocaleString | "Check availability" by default |
ctaVariant | 'primary' | 'secondary' | 'ghost' | Mapped to design-token button variant |
overlayOpacity | 0–100 | Brand-safe range enforced: 20–60 |
textPosition | 'left' | 'center' | 'right' | RTL-aware; left → right in RTL |
badgeLabel | LocaleString | null | e.g., "Book direct & save 10%" |
Allowed in: booking-home, custom
Max per page: 1
Required: Yes on booking-home
SEO: None (block is decorative; heading is semantic H1)
Telemetry: hero.impression, hero.cta_click
Accessibility: Background video must expose a pause control (SC 2.2.2). Overlay opacity ≥ 20 maintains text contrast (AA). textPosition adjusts text-align via logical properties; never use left/right directly.
RTL: textPosition: 'left' maps to start in CSS, which is right in RTL — authors should use 'left' (start) for all locales.
3.2 RoomGrid
| Field | Type | Notes |
|---|---|---|
heading | LocaleString | Section heading; optional |
layout | 'grid-2' | 'grid-3' | 'list' | Desktop; collapses to single on mobile |
filterEnabled | boolean | Show occupancy / bed-type quick filters |
maxRoomsShown | number | Default 6; "Show all" loads rest |
sortOrder | 'price-asc' | 'recommended' | 'availability' | |
cta | 'book-now' | 'see-details' | Where card primary CTA navigates |
Allowed in: booking-home, room-listing
Max per page: 1
Required: Yes on room-listing
SEO: Emits schema.org/Offer per room card
Telemetry: room_grid.impression, room_card.click, room_card.book_click
3.3 GalleryCarousel
| Field | Type | Notes |
|---|---|---|
images | MediaRef[] | Min 2, max 24 |
style | 'thumbnails' | 'filmstrip' | 'dots' | Pagination indicator |
autoplay | boolean | Disabled by default (motion accessibility) |
autoplayIntervalMs | number | 3000–10000; ignored if autoplay: false |
aspectRatio | '16:9' | '4:3' | '1:1' | |
captions | boolean | If true, MediaRef.caption is rendered |
Allowed in: All page types
Max per page: 3
RTL: Swipe direction inverted; arrow icons mirrored.
Accessibility: role="region", aria-label="Photo gallery". Autoplay disabled for prefers-reduced-motion. Previous/Next buttons keyboard accessible (arrow keys).
3.4 AmenitiesGrid
| Field | Type | Notes |
|---|---|---|
heading | LocaleString | Default "Amenities" |
items | AmenityItem[] | Each: { icon: PhosphorIconName, label: LocaleString, highlight: boolean } |
layout | 'icon-grid' | 'checklist' | |
columns | 2 | 3 | 4 | Desktop; mobile always 2 |
maxVisible | number | null | "Show more" collapses overflow |
Amenity icons: Subset of Phosphor Icons; extended with 30 hospitality glyphs (see C11-iconography.md).
SEO: Contributes to schema.org/Hotel amenityFeature.
3.5 MapEmbed
| Field | Type | Notes |
|---|---|---|
lat | number | Property GPS latitude |
lng | number | Property GPS longitude |
zoom | 8–18 | |
markerLabel | LocaleString | Tooltip on marker pin |
style | 'road' | 'satellite' | 'terrain' | |
showNearbyPOIs | boolean | Curated POI layer from theme-config-service |
height | 'sm' | 'md' | 'lg' | 240 / 400 / 560 px |
provider | 'mapbox' | 'google' | Tenant selects; API key via platform secrets |
Performance: Map tile loads are deferred (intersection observer). Initial render shows a blurred static image snapshot (LQIP); map SDK loads after.
Accessibility: Map iframe has title="Map showing [property name]". Provide a text address fallback adjacent to the map (SC 1.3.1).
3.6 TestimonialWall
| Field | Type | Notes |
|---|---|---|
heading | LocaleString | Optional |
reviews | ReviewItem[] | Each: { author, rating, body, date, verified } |
layout | 'carousel' | 'masonry' | 'list' | |
source | 'manual' | 'platform-reviews' | 'google' | 'tripadvisor' | manual = tenant-entered; others via platform review aggregate |
showRatingBar | boolean | Overall average + breakdown |
maxShown | number | Default 6 |
SEO: Emits schema.org/Review for each item; overall schema.org/AggregateRating.
Ethics guardrail: Only verified-stay reviews may use verified: true. Unverified testimonials display without verified badge.
Telemetry: testimonial_wall.impression, review.expand
3.7 FAQ
| Field | Type | Notes |
|---|---|---|
heading | LocaleString | "Frequently asked questions" |
items | FAQItem[] | Each: { question: LocaleString, answer: LocaleString } |
layout | 'accordion' | 'flat' | |
maxOpen | 1 | null | Accordion: 1 = only one open at a time |
SEO: Emits schema.org/FAQPage + schema.org/Question per item.
Accessibility: <details>/<summary> or ARIA Accordion pattern (SC 4.1.2). Keyboard: Enter/Space to toggle.
3.8 PolicyPage
| Field | Type | Notes |
|---|---|---|
policyType | 'cancellation' | 'privacy' | 'terms' | 'child' | 'pet' | 'smoking' | 'custom' | |
content | LocaleRichText | Rendered as sanitized HTML; markdown supported |
lastUpdated | ISO8601Date | Displayed; affects "recently updated" SEO signal |
showTableOfContents | boolean | Auto-generated from headings |
Rendering: Rich text sanitized server-side (DOMPurify); no inline scripts. target="_blank" links get rel="noopener noreferrer".
3.9 OfferStrip
| Field | Type | Notes |
|---|---|---|
offers | OfferItem[] | Each: { label, badgeText, discount, ctaLabel, ctaHref, startDate, endDate, image } |
layout | 'horizontal-scroll' | 'grid' | |
autoExpire | boolean | If true, strip is hidden after endDate |
urgencyLabel | LocaleString | null | e.g., "3 rooms left at this rate" — accuracy required, no fabrication |
Ethics: urgencyLabel MUST be real-time data from room-availability-service. Displaying fabricated scarcity is prohibited (cf. docs/standards/ETHICAL_UX_POLICY.md).
Telemetry: offer_strip.impression, offer.click
3.10 AboutLocal
| Field | Type | Notes |
|---|---|---|
heading | LocaleString | e.g., "Explore Kabul" |
body | LocaleRichText | |
pois | POIItem[] | Each: { name, category, distanceMin, icon, link? } — max 8 |
mapEmbedEnabled | boolean | Mini map with POI pins |
SEO: Local knowledge signals positive for hospitality schema.org/Hotel.
3.11 ContactForm
| Field | Type | Notes |
|---|---|---|
fields | FormFieldConfig[] | Name, email, phone, subject, message — configurable |
submitLabel | LocaleString | |
successMessage | LocaleString | |
recipientEmail | string | Tenant email; stored in theme-config-service secrets, not in block data |
turnstileEnabled | boolean | Cloudflare Turnstile CAPTCHA (preferred over reCAPTCHA for privacy) |
Backend: Submissions routed through notification-service email action.
Validation: React Hook Form + Zod; server-side re-validation in BFF.
3.12 NewsletterCapture
| Field | Type | Notes |
|---|---|---|
heading | LocaleString | |
body | LocaleString | |
inputPlaceholder | LocaleString | |
ctaLabel | LocaleString | |
consentLabel | LocaleString | Required for GDPR/PDPA; checkbox |
doubleOptIn | boolean | Sends confirmation email before adding to list |
listId | string | Marketing list identifier in notification-service |
GDPR: consentLabel and doubleOptIn: true are mandatory for EU tenants. The BFF validates consent: true before writing to any marketing list.
4. Per-block schema fields: shared conventions
Every block's data payload supports these optional shared fields:
| Field | Type | Default | Notes |
|---|---|---|---|
_id | uuid | Auto | Block instance ID for telemetry |
_anchor | string | null | null | Named anchor for in-page links |
_hidden | boolean | false | Soft-hidden; doesn't render but preserved in schema |
_paddingTop | 'none' | 'sm' | 'md' | 'lg' | 'md' | Mapped to design token spacing |
_paddingBottom | 'none' | 'sm' | 'md' | 'lg' | 'md' | |
_backgroundVariant | 'surface' | 'surface-alt' | 'primary' | 'transparent' | 'surface' | Mapped to tenant color token |
_fullWidth | boolean | false | Breaks out of content-max-width container |
i18n fields: Any field typed as LocaleString is a Record<LocaleCode, string>. Missing locales fall back to 'en'. The BFF validates at publish: all active tenant locales must have a translation.
Media refs: MediaRef = { src: CloudflareImagesId, alt: LocaleString, width: number, height: number, caption?: LocaleString }. Images are served via Cloudflare Images; never raw origin URLs.
5. Editing UX in the control plane
The control plane's Block Editor (/control-plane/sites/[siteId]/pages/[pageId]) provides:
5.1 Page canvas
- Left panel: Block palette (grouped by category: Content, Media, Commerce, Utility)
- Center: Page preview (live SSR iframe of the page in current theme)
- Right panel: Block properties form (rendered by block schema)
5.2 Block insertion
- Drag from palette to canvas — OR — click "+" between blocks to open palette modal
- Block is inserted at the cursor position with
defaultData - Preview updates in real-time (debounced 300 ms)
5.3 Block reordering
- Drag handle (grabbed by keyboard:
Spaceto pick up, arrows to move,Enter/Escapeto drop/cancel) - Order persisted in
PageLayout.blocks[]
5.4 Block configuration
- Clicking a block in canvas opens its properties panel
- Locale switcher in header to configure translations
- Media picker opens CDN asset manager (
cloudflare-images-service) - Form validation mirrors
C7-forms-and-validation-patterns.md
5.5 Permissions
| Role | Can view page editor | Can edit blocks | Can publish |
|---|---|---|---|
| Tenant admin | ✅ | ✅ | ✅ |
| Tenant manager | ✅ | ✅ | ❌ (requires admin approval) |
| Platform support | ✅ (read) | ❌ | ❌ |
6. Preview / publish / rollback
Mirrors theme-config-service pattern:
| Step | Action |
|---|---|
| Draft | All edits in PageLayout.status = 'draft' — never live |
| Preview | Generates a signed preview URL (valid 24 h); shareable with stakeholders |
| Publish | Transitions status = 'published'; CDN cache purged; SSR pages revalidated (ISR 60 s) |
| Rollback | Restores previous published snapshot; CDN cache purged |
Versioning: PageLayout is append-only (event-sourced). Each publish creates a new LayoutVersion. The published pointer is moveable (rollback = move pointer back).
Draft conflict: If two editors open the same page, last-write-wins on the draft version. A "someone else is editing" banner warns concurrent editors (presence tracked via channel-service presence feed).
7. SEO and structured data per block
The BFF renders <script type="application/ld+json"> structured data for:
| Block | Structured data |
|---|---|
Hero | @type: LodgingBusiness (from property profile) |
RoomGrid | @type: HotelRoom + Offer per room |
TestimonialWall | @type: Review[] + AggregateRating |
FAQ | @type: FAQPage |
OfferStrip | @type: Offer[] |
OpenGraph: Every page emits og:title, og:description, og:image from first MediaRef in first media-containing block.
<link rel="canonical"> is set to https://{tenant}.melmastoon.app/{page-slug}.
8. Accessibility per block
Every block component must:
- Use semantic HTML (headings in correct outline order within the page)
- Provide
alttext for all images (MediaRef.altrequired) - Not rely on color alone to convey meaning (SC 1.4.1)
- Meet 3:1 contrast for non-text UI elements (SC 1.4.11)
- Be operable by keyboard alone
- Work with VoiceOver (iOS/macOS) and TalkBack (Android) in mobile contexts
Block-specific accessibility notes are documented in §3 per block above.
9. Performance budgets per block
| Budget | Target | Enforcement |
|---|---|---|
| Each block's initial CSS | ≤ 3 kB gzipped | Checked in CI via next-bundle-analyzer |
| Each block's JS (lazy) | ≤ 8 kB gzipped | |
| Image weights | Enforced by Cloudflare Images responsive transform | Auto; no manual constraint |
| First block LCP contribution | ≤ 2.5 s total page LCP | Lighthouse CI |
| Map block defer | Map JS loaded after Hero interactive | Intersection observer required |
10. RTL behaviour per block
All blocks use CSS logical properties exclusively (padding-inline-start, margin-inline-end, etc.). The dir="rtl" attribute on <html> causes automatic mirroring.
Block-specific RTL notes:
- GalleryCarousel:
rtl:flex-row-reverseon slide track; arrows swap visually (using RTL-mirrored Phosphor icons) - TestimonialWall (masonry): Uses
columnsCSS; no explicit direction needed - ContactForm: Input fields are
dir="auto"for mixed content (Pashto in contact body, Latin in email)
Testing: Every block has an RTL screenshot snapshot in Chromatic (Storybook story with dir="rtl" decorator).
11. Custom (tenant-extension) blocks — Phase 2 governance
Phase 2 will allow tenants to submit custom blocks through a review pipeline:
- Tenant submits block source (React component + Zod schema) via platform portal
- Platform team reviews in
block-sandboxenvironment (security scan + a11y scan + perf budget check) - Approved blocks are published to tenant's private block registry
- Custom blocks are sandboxed via
@ghasi/block-sandboxiframe isolation
Security: Custom block code runs in a sandboxed iframe with CSP: sandbox allow-scripts — no access to parent DOM or platform tokens.
12. Migration of existing tenant content to blocks
Tenants onboarded before block builder ships have layout-preset configurations (from 06-theming-and-tenant-config.md). Migration path:
- Platform runs
migrate-to-blocksscript: readsLayoutPreset→ generatesPageLayoutwith equivalent blocks - Migration is non-destructive:
LayoutPresetremains as fallback - Tenant receives a "Your site has been upgraded to block editor" notification and 30-day preview before migration becomes permanent
- After 30 days,
LayoutPresetis archived (not deleted) and block layout becomes canonical
Open Questions
- Custom blocks Phase 2: allow at tenant level or only at platform level (white-label resellers)?
- Block versioning: content-migration runner on schema evolution vs freeze-at-published-version?
- Email template registry: separate spec or extend this one (email constraints: MIME, dark mode, image proxy)?
- AI-assisted block generation ("Describe your hotel and we'll generate your booking page") — Phase 3?
References
06-theming-and-tenant-config.md../web/13-tenant-booking-web-specification.md../web/14-control-plane-web-specification.md../catalogs/C7-forms-and-validation-patterns.md../catalogs/C9-print-pdf-email-template-gallery.md../design-ops/DO3-asset-image-pipeline.md../../03-microservices/theme-config-service.md