07 — Modules and Templates
PRD Document · Savoy Signature Hotels — Multi-Site Headless Platform
Version: 1.0 · Date: 2026-03-04
Related docs:04_Frontend_Architecture.md,06_Content_Modeling_Umbraco.md,05_Design_System_and_Theming.md,A04_Module_Catalog.md
1. Purpose
Section titled “1. Purpose”This document defines the module system — the CMS-driven content blocks that compose every page. It covers the module lifecycle (Figma → Storybook → Next.js → Umbraco), the development conventions, and the specification template for each module.
2. Module Architecture Overview
Section titled “2. Module Architecture Overview”2.1 How Modules Work
Section titled “2.1 How Modules Work”2.2 Module Lifecycle
Section titled “2.2 Module Lifecycle”| Step | Owner | Output |
|---|---|---|
| 1. Figma Design | Design team | Desktop + mobile designs, interactions, responsive behavior |
| 2. Storybook Story | FE developer | .stories.tsx with all variants, linked to Figma |
| 3. React Component | FE developer | .tsx + .scss (BEM CSS with SASS, themed via CSS variables) |
| 4. Mapper + Types | FE developer | .mapper.ts + .types.ts (API → props transformation) |
| 5. Module Registry | FE developer | Register in registry.ts for ModuleRenderer |
| 6. Umbraco Element Type | BE developer | Create matching Element Type with properties |
| 7. Integration Test | QA / openClaw | Visual regression, accessibility, responsive tests |
3. Module Development Convention
Section titled “3. Module Development Convention”3.1 File Structure
Section titled “3.1 File Structure”packages/modules/src/m05-hero-slider/├── index.ts # Named export├── HeroSlider.tsx # React component (Server Component by default)├── HeroSlider.client.tsx # Client component (if interactivity needed)├── HeroSlider.scss # BEM CSS with SASS├── HeroSlider.types.ts # Props interface + Umbraco API types├── HeroSlider.mapper.ts # Transforms API response → component props├── HeroSlider.stories.tsx # Storybook stories (all variants)└── HeroSlider.test.tsx # Unit tests (Vitest)3.2 Naming Conventions
Section titled “3.2 Naming Conventions”| Convention | Example |
|---|---|
| Folder | m05-hero-slider (module ID + kebab-case name) |
| Component | HeroSlider (PascalCase) |
| Styles | HeroSlider.scss (BEM block: .hero-slider) |
| BEM Block | .hero-slider (kebab-case of component name) |
| Props | HeroSliderProps |
| Mapper | mapHeroSlider() |
| Registry key | heroSlider (matches Umbraco Element Type alias) |
| Storybook title | Modules/M05 — Hero Slider |
3.3 Component Template
Section titled “3.3 Component Template”Note: All title, subtitle, and label/eyebrow props use the
HtmlHeadinginterface (see06_Content_Modeling_Umbraco.mdsection 5.2). These are rendered with a dynamic semantic tag and support inline bold formatting from the CMS RTE. The HTML content is trusted (CMS-authored by editors, not user input).
import './HeroSlider.scss';import { HeroSliderProps, HtmlHeading } from './HeroSlider.types';import { ResponsiveImage } from '@savoy/ui';
// Heading helper — renders HtmlHeading with dynamic semantic tag// Content is trusted CMS-authored HTML (Umbraco RTE), not user inputfunction Heading({ heading, className }: { heading: HtmlHeading; className: string }) { const Tag = heading.html; return <Tag className={className} dangerouslySetInnerHTML={{ __html: heading.text }} />;}
export function HeroSlider({ title, subtitle, slides, autoplay = true, interval = 5000, siteKey, locale, moduleId,}: HeroSliderProps) { return ( <section className="hero-slider" data-module="heroSlider" data-module-id={moduleId}> <div className="hero-slider__content"> {title && <Heading heading={title} className="hero-slider__title" />} {subtitle && <Heading heading={subtitle} className="hero-slider__subtitle" />} </div> <div className="hero-slider__slides"> {slides.map((slide, index) => ( <div key={index} className="hero-slider__slide"> <ResponsiveImage desktop={slide.imageDesktop} mobile={slide.imageMobile} alt={slide.altText || title} priority={index === 0} /> {slide.caption && <p className="hero-slider__caption">{slide.caption}</p>} {slide.cta && ( <a href={slide.cta.href} className="hero-slider__cta"> {slide.cta.label} </a> )} </div> ))} </div> </section> );}3.4 Styles — BEM CSS with SASS
Section titled “3.4 Styles — BEM CSS with SASS”All module styles use the BEM (Block Element Modifier) methodology with SASS for nesting, variables, and mixins. Theme tokens are referenced via CSS custom properties.
// ── Block ──.hero-slider { position: relative; width: 100%; min-height: 60vh; overflow: hidden;
@media (min-width: 1024px) { min-height: 80vh; }
// ── Element ── &__content { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: center; padding: var(--space-8) var(--container-padding); z-index: 2; }
&__title { font-family: var(--font-heading); font-size: var(--text-4xl); font-weight: var(--font-weight-bold); color: var(--color-text-inverse); line-height: var(--leading-tight);
@media (min-width: 1024px) { font-size: var(--text-6xl); } }
&__subtitle { font-family: var(--font-body); font-size: var(--text-lg); color: var(--color-text-inverse); margin-top: var(--space-4); }
&__slides { position: relative; width: 100%; height: 100%; }
&__slide { position: absolute; inset: 0; }
&__caption { font-family: var(--font-body); font-size: var(--text-sm); color: var(--color-text-inverse); margin-top: var(--space-2); }
&__cta { display: inline-flex; padding: var(--space-3) var(--space-6); background-color: var(--color-secondary); color: var(--color-text-on-secondary); font-family: var(--font-body); font-weight: var(--font-weight-semibold); text-decoration: none; transition: background-color var(--transition-normal); margin-top: var(--space-6);
&:hover { background-color: var(--color-secondary-dark); } }
// ── Modifier ── &--fullscreen { min-height: 100vh; }
&--dark-overlay { &::after { content: ''; position: absolute; inset: 0; background: rgba(0, 0, 0, 0.4); z-index: 1; } }}BEM Naming Rules
Section titled “BEM Naming Rules”| BEM Element | Convention | Example |
|---|---|---|
| Block | Component name in kebab-case | .hero-slider |
| Element | __ separator | .hero-slider__title |
| Modifier | -- separator | .hero-slider--fullscreen |
| State | Modifier or JS class | .hero-slider--active, .is-visible |
[!NOTE] SASS
&nesting is used to keep BEM class names readable and co-located. Theme tokens are always consumed viavar(--token-name), never via SASS variables — this ensures the CSS variables switch correctly whendata-themechanges.
3.5 Module Registry
Section titled “3.5 Module Registry”import { HeroSlider } from './m05-hero-slider';import { mapHeroSlider } from './m05-hero-slider/HeroSlider.mapper';import { BookingBar } from './m03-booking-bar';import { mapBookingBar } from './m03-booking-bar/BookingBar.mapper';import { RichTextBlock } from './m06-rich-text-block';import { mapRichTextBlock } from './m06-rich-text-block/RichTextBlock.mapper';// ... import all modules
export interface ModuleRegistration { Component: React.ComponentType<any>; mapper: (element: any) => any;}
export const moduleRegistry: Record<string, ModuleRegistration> = { heroSlider: { component: HeroSlider, mapper: mapHeroSlider, moduleId: "M05" }, bookingBar: { component: BookingBar, mapper: mapBookingBar, moduleId: "M03" }, richTextBlock: { component: RichTextBlock, mapper: mapRichTextBlock, moduleId: "M06" }, // ... register all modules};4. Module Catalog (27 modules)
Section titled “4. Module Catalog (27 modules)”4.1 Master Module Table
Section titled “4.1 Master Module Table”Legend: Status: :white_circle: Not started · :large_blue_circle: Stub · :green_circle: Implemented | Tested: :white_circle: Pending · :green_circle: Passed | Delivered: :white_circle: Pending · :green_circle: Done
| ID | Module Name | Sprint | Type | Zoho Task | Code Path | Status | Tested | Delivered |
|---|---|---|---|---|---|---|---|---|
| M01 | Header | Sprint 1 | Interactive | P894-T359 | m01-header/ | :green_circle: | :white_circle: | :white_circle: |
| M02 | Main Menu | Sprint 1 | Interactive | P894-T602 | — | :white_circle: | :white_circle: | :white_circle: |
| M03 | Booking Bar | Sprint 1 | Interactive | P894-T470 | m03-booking-bar/ | :large_blue_circle: | :white_circle: | :white_circle: |
| M04 | Footer | Sprint 1 | Static | P894-T384 | m04-footer/ | :green_circle: | :white_circle: | :white_circle: |
| M06 | Hero Simple | Sprint 1 | Static | P894-T395 | m06-hero-simple/ | :green_circle: | :white_circle: | :white_circle: |
| M07 | Slider with Categories | Sprint 2 | Interactive | P894-T425 | — | :white_circle: | :white_circle: | :white_circle: |
| M08 | Item Cards (Accommodations) | Sprint 2 | Static | P894-T397 | m08-item-cards/ | :green_circle: | :white_circle: | :white_circle: |
| M09 | Quotes | Sprint 2 | Static | P894-T427 | m09-quotes/ | :green_circle: | :white_circle: | :white_circle: |
| M10 | Highlight | Sprint 2 | Static | P894-T428 | m10-highlight/ | :green_circle: | :white_circle: | :white_circle: |
| M11 | Banner | Sprint 2 | Static | P894-T429 | m11-banner/ | :green_circle: | :white_circle: | :white_circle: |
| M12 | Image Gallery | Sprint 3 | Interactive | P894-T484 | — | :white_circle: | :white_circle: | :white_circle: |
| M13 | FAQs | Sprint 3 | Interactive | P894-T532 | m13-faqs/ | :green_circle: | :white_circle: | :white_circle: |
| M14 | Form | Sprint 4 | Interactive | P894-T650 | — | :white_circle: | :white_circle: | :white_circle: |
| M15 | Modal | Sprint 4 | Interactive | P894-T656 | — | :white_circle: | :white_circle: | :white_circle: |
| M16 | Logo Carousel | Sprint 3 | Interactive | P894-T573 | m16-logo-carousel/ | :green_circle: | :white_circle: | :white_circle: |
| M17 | Slider Cross-Content Cards | Sprint 3 | Interactive | P894-T644 | — | :white_circle: | :white_circle: | :white_circle: |
| M20 | Item Cards (Documents) | Sprint 2 | Static | P894-T638 | — | :white_circle: | :white_circle: | :white_circle: |
| M23 | Decorative Type | Sprint 2 | Static | P894-T626 | — | :white_circle: | :white_circle: | :white_circle: |
| M24 | Full Image | Sprint 2 | Static | P894-T620 | m24-full-image/ | :green_circle: | :white_circle: | :white_circle: |
| M25 | Slider | Sprint 3 | Interactive | P894-T430 | m25-slider/ | :green_circle: | :white_circle: | :white_circle: |
| M26 | RTE | Sprint 2 | Static | P894-T614 | — | :white_circle: | :white_circle: | :white_circle: |
| M27 | Text Image | Sprint 2 | Static | P894-T431 | m27-text-image/ | :green_circle: | :white_circle: | :white_circle: |
| M28 | Icon List | Sprint 2 | Static | P894-T632 | — | :white_circle: | :white_circle: | :white_circle: |
| M29 | Breadcrumbs | Sprint 1 | Static | P894-T608 | — | :white_circle: | :white_circle: | :white_circle: |
| M30 | Social Media | Sprint 4 | Static | P894-T662 | — | :white_circle: | :white_circle: | :white_circle: |
| M31 | Social Share | Sprint 4 | Interactive | P894-T668 | — | :white_circle: | :white_circle: | :white_circle: |
| M33 | Newsletter Subscription | Sprint 4 | Interactive | P894-T674 | — | :white_circle: | :white_circle: | :white_circle: |
4.2 Sprint Grouping
Section titled “4.2 Sprint Grouping”| Sprint | Theme | Modules | Count |
|---|---|---|---|
| Sprint 1 | Foundation & Navigation | M01, M02, M03, M04, M06, M29 | 6 |
| Sprint 2 | Core Content | M07, M08, M09, M10, M11, M20, M23, M24, M26, M27, M28 | 11 |
| Sprint 3 | Cards & Sliders | M12, M13, M16, M17, M25 | 5 |
| Sprint 4 | Interactive & Social | M14, M15, M30, M31, M33 | 5 |
4.3 Implementation Status Summary
Section titled “4.3 Implementation Status Summary”- Implemented: 12 modules (M01, M04, M06, M08, M09, M10, M11, M13, M16, M24, M25, M27)
- Stub (scaffolded): 1 module (M03)
- Not started: 14 modules (M02, M07, M12, M14, M15, M17, M20, M23, M26, M28, M29, M30, M31, M33)
5. Templates (Page Types)
Section titled “5. Templates (Page Types)”Templates are page layouts that compose modules based on the content type. Each Umbraco Document Type maps to a template:
5.1 Template Registry
Section titled “5.1 Template Registry”| Document Type | Template | Layout | Key Modules |
|---|---|---|---|
homePage | HomeTemplate | Full-width, no sidebar | Hero Slider/Hero, Booking Bar, Featured Cards, Card Grid |
contentPage | ContentTemplate | Content-width with breadcrumbs | Page Hero, Rich Text, Image+Text, Accordion |
roomsListPage | RoomsListTemplate | Grid layout with optional filters | Page Hero, Card Grid (rooms), Booking Bar |
roomDetailPage | RoomDetailTemplate | Detail layout with gallery | Image Gallery, Rich Text, Amenities, Booking Bar |
diningListPage | DiningListTemplate | Grid layout | Page Hero, Card Grid (restaurants) |
diningDetailPage | DiningDetailTemplate | Detail with gallery and info | Image Gallery, Rich Text, Opening Hours, Map |
galleryPage | GalleryTemplate | Full-width masonry/grid | Page Hero, Image Gallery |
contactPage | ContactTemplate | Split layout (form + info) | Page Hero, Form Module, Map Block |
faqPage | FAQTemplate | Content-width | Page Hero, Accordion |
specialOffersPage | OffersTemplate | Grid layout with dates | Page Hero, Offers Carousel, Card Grid |
5.2 Template Structure
Section titled “5.2 Template Structure”import { ModuleRenderer } from '@savoy/modules';import { ResponsiveImage, Breadcrumbs } from '@savoy/ui';import { BookingBar } from '@savoy/modules/m03-booking-bar';import { RoomDetailData } from '@savoy/cms-client';
interface RoomDetailTemplateProps { data: RoomDetailData; siteKey: string; locale: string;}
export function RoomDetailTemplate({ data, siteKey, locale }: RoomDetailTemplateProps) { return ( <> <Breadcrumbs items={data.breadcrumbs} />
{/* Gallery Section */} <section className="room-gallery"> {data.gallery.map((image, i) => ( <ResponsiveImage key={i} desktop={image.imageDesktop} mobile={image.imageMobile} alt={image.altText} priority={i === 0} /> ))} </section>
{/* Room Info */} <section className="room-info container"> <h1>{data.roomName}</h1> <div className="room-meta"> {data.size && <span>{data.size} m²</span>} {data.maxGuests && <span>Max {data.maxGuests} guests</span>} {data.view && <span>{data.view}</span>} </div> <div dangerouslySetInnerHTML={{ __html: data.fullDescription }} /> </section>
{/* Amenities */} {data.amenities && ( <section className="room-amenities container"> {/* Rendered amenities list */} </section> )}
{/* Booking CTA */} {data.bookingCta && <BookingBar siteKey={siteKey} locale={locale} variant="inline" />}
{/* Additional CMS Modules */} {data.modules && ( <ModuleRenderer modules={data.modules} siteKey={siteKey} locale={locale} /> )} </> );}6. Responsive Image Component
Section titled “6. Responsive Image Component”A shared UI component used by all modules that display images:
import Image from 'next/image';
interface ResponsiveImageProps { desktop: { url: string; width: number; height: number }; mobile: { url: string; width: number; height: number }; alt: string; priority?: boolean; className?: string; sizes?: string;}
export function ResponsiveImage({ desktop, mobile, alt, priority = false, className, sizes = '100vw',}: ResponsiveImageProps) { return ( <picture className={className}> <source media="(min-width: 1024px)" srcSet={desktop.url} width={desktop.width} height={desktop.height} /> <Image src={mobile.url} width={mobile.width} height={mobile.height} alt={alt} priority={priority} sizes={sizes} /> </picture> );}7. Acceptance Criteria
Section titled “7. Acceptance Criteria”- All 27 modules have completed file structure (
component,mapper,types,stories,scss,test) - Module Registry maps every Umbraco Element Type alias to a React component
- ModuleRenderer gracefully handles unknown module types (warning in console, no crash)
- Every module renders correctly in all 8 themes (verified in Storybook)
- Every module is responsive across all breakpoints (sm → 2xl)
- All image modules use the
ResponsiveImagecomponent (desktop + mobile) - All interactive modules (Client Components) are accessible (WCAG 2.1 AA)
- Storybook stories exist for every variant of every module
- Module mapper unit tests validate transformation from API response to props
- Templates render complete pages with correct module composition
Next document: 08_API_Contracts.md