Skip to main content

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 with theme-config-service publish/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

FieldTypeNotes
headingLocaleStringH1 on booking home; max 60 chars
subheadingLocaleStringOptional; max 120 chars
backgroundMediaMediaRefImage or video (autoplay muted loop)
ctaLabelLocaleString"Check availability" by default
ctaVariant'primary' | 'secondary' | 'ghost'Mapped to design-token button variant
overlayOpacity0–100Brand-safe range enforced: 20–60
textPosition'left' | 'center' | 'right'RTL-aware; left → right in RTL
badgeLabelLocaleString | nulle.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

FieldTypeNotes
headingLocaleStringSection heading; optional
layout'grid-2' | 'grid-3' | 'list'Desktop; collapses to single on mobile
filterEnabledbooleanShow occupancy / bed-type quick filters
maxRoomsShownnumberDefault 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

FieldTypeNotes
imagesMediaRef[]Min 2, max 24
style'thumbnails' | 'filmstrip' | 'dots'Pagination indicator
autoplaybooleanDisabled by default (motion accessibility)
autoplayIntervalMsnumber3000–10000; ignored if autoplay: false
aspectRatio'16:9' | '4:3' | '1:1'
captionsbooleanIf 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

FieldTypeNotes
headingLocaleStringDefault "Amenities"
itemsAmenityItem[]Each: { icon: PhosphorIconName, label: LocaleString, highlight: boolean }
layout'icon-grid' | 'checklist'
columns2 | 3 | 4Desktop; mobile always 2
maxVisiblenumber | 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

FieldTypeNotes
latnumberProperty GPS latitude
lngnumberProperty GPS longitude
zoom8–18
markerLabelLocaleStringTooltip on marker pin
style'road' | 'satellite' | 'terrain'
showNearbyPOIsbooleanCurated 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

FieldTypeNotes
headingLocaleStringOptional
reviewsReviewItem[]Each: { author, rating, body, date, verified }
layout'carousel' | 'masonry' | 'list'
source'manual' | 'platform-reviews' | 'google' | 'tripadvisor'manual = tenant-entered; others via platform review aggregate
showRatingBarbooleanOverall average + breakdown
maxShownnumberDefault 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

FieldTypeNotes
headingLocaleString"Frequently asked questions"
itemsFAQItem[]Each: { question: LocaleString, answer: LocaleString }
layout'accordion' | 'flat'
maxOpen1 | nullAccordion: 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

FieldTypeNotes
policyType'cancellation' | 'privacy' | 'terms' | 'child' | 'pet' | 'smoking' | 'custom'
contentLocaleRichTextRendered as sanitized HTML; markdown supported
lastUpdatedISO8601DateDisplayed; affects "recently updated" SEO signal
showTableOfContentsbooleanAuto-generated from headings

Rendering: Rich text sanitized server-side (DOMPurify); no inline scripts. target="_blank" links get rel="noopener noreferrer".


3.9 OfferStrip

FieldTypeNotes
offersOfferItem[]Each: { label, badgeText, discount, ctaLabel, ctaHref, startDate, endDate, image }
layout'horizontal-scroll' | 'grid'
autoExpirebooleanIf true, strip is hidden after endDate
urgencyLabelLocaleString | nulle.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

FieldTypeNotes
headingLocaleStringe.g., "Explore Kabul"
bodyLocaleRichText
poisPOIItem[]Each: { name, category, distanceMin, icon, link? } — max 8
mapEmbedEnabledbooleanMini map with POI pins

SEO: Local knowledge signals positive for hospitality schema.org/Hotel.


3.11 ContactForm

FieldTypeNotes
fieldsFormFieldConfig[]Name, email, phone, subject, message — configurable
submitLabelLocaleString
successMessageLocaleString
recipientEmailstringTenant email; stored in theme-config-service secrets, not in block data
turnstileEnabledbooleanCloudflare 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

FieldTypeNotes
headingLocaleString
bodyLocaleString
inputPlaceholderLocaleString
ctaLabelLocaleString
consentLabelLocaleStringRequired for GDPR/PDPA; checkbox
doubleOptInbooleanSends confirmation email before adding to list
listIdstringMarketing 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:

FieldTypeDefaultNotes
_iduuidAutoBlock instance ID for telemetry
_anchorstring | nullnullNamed anchor for in-page links
_hiddenbooleanfalseSoft-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
_fullWidthbooleanfalseBreaks 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: Space to pick up, arrows to move, Enter/Escape to 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

RoleCan view page editorCan edit blocksCan publish
Tenant admin
Tenant manager❌ (requires admin approval)
Platform support✅ (read)

6. Preview / publish / rollback

Mirrors theme-config-service pattern:

StepAction
DraftAll edits in PageLayout.status = 'draft' — never live
PreviewGenerates a signed preview URL (valid 24 h); shareable with stakeholders
PublishTransitions status = 'published'; CDN cache purged; SSR pages revalidated (ISR 60 s)
RollbackRestores 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:

BlockStructured 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:

  1. Use semantic HTML (headings in correct outline order within the page)
  2. Provide alt text for all images (MediaRef.alt required)
  3. Not rely on color alone to convey meaning (SC 1.4.1)
  4. Meet 3:1 contrast for non-text UI elements (SC 1.4.11)
  5. Be operable by keyboard alone
  6. 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

BudgetTargetEnforcement
Each block's initial CSS≤ 3 kB gzippedChecked in CI via next-bundle-analyzer
Each block's JS (lazy)≤ 8 kB gzipped
Image weightsEnforced by Cloudflare Images responsive transformAuto; no manual constraint
First block LCP contribution≤ 2.5 s total page LCPLighthouse CI
Map block deferMap JS loaded after Hero interactiveIntersection 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-reverse on slide track; arrows swap visually (using RTL-mirrored Phosphor icons)
  • TestimonialWall (masonry): Uses columns CSS; 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:

  1. Tenant submits block source (React component + Zod schema) via platform portal
  2. Platform team reviews in block-sandbox environment (security scan + a11y scan + perf budget check)
  3. Approved blocks are published to tenant's private block registry
  4. Custom blocks are sandboxed via @ghasi/block-sandbox iframe 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:

  1. Platform runs migrate-to-blocks script: reads LayoutPreset → generates PageLayout with equivalent blocks
  2. Migration is non-destructive: LayoutPreset remains as fallback
  3. Tenant receives a "Your site has been upgraded to block editor" notification and 30-day preview before migration becomes permanent
  4. After 30 days, LayoutPreset is 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