Skip to content

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


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.


React ComponentModule MapperNext.js (RSC)Content Delivery APIUmbraco EditorReact ComponentModule MapperNext.js (RSC)Content Delivery APIUmbraco EditorAdd modules to page via Block ListPublish pageFetch page contentJSON with modules arrayFor each module — map API response → typed propsTyped component propsRender with props + siteKey + localeHTML output

1. Figma Design

2. Storybook Story

3. React Component

4. Mapper + Types

5. Module Registry

6. Umbraco Element Type

7. Integration Test

StepOwnerOutput
1. Figma DesignDesign teamDesktop + mobile designs, interactions, responsive behavior
2. Storybook StoryFE developer.stories.tsx with all variants, linked to Figma
3. React ComponentFE developer.tsx + .scss (BEM CSS with SASS, themed via CSS variables)
4. Mapper + TypesFE developer.mapper.ts + .types.ts (API → props transformation)
5. Module RegistryFE developerRegister in registry.ts for ModuleRenderer
6. Umbraco Element TypeBE developerCreate matching Element Type with properties
7. Integration TestQA / openClawVisual regression, accessibility, responsive tests

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)
ConventionExample
Folderm05-hero-slider (module ID + kebab-case name)
ComponentHeroSlider (PascalCase)
StylesHeroSlider.scss (BEM block: .hero-slider)
BEM Block.hero-slider (kebab-case of component name)
PropsHeroSliderProps
MappermapHeroSlider()
Registry keyheroSlider (matches Umbraco Element Type alias)
Storybook titleModules/M05 — Hero Slider

Note: All title, subtitle, and label/eyebrow props use the HtmlHeading interface (see 06_Content_Modeling_Umbraco.md section 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).

packages/modules/src/m05-hero-slider/HeroSlider.tsx
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 input
function 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>
);
}

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.

HeroSlider.scss
// ── 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 ElementConventionExample
BlockComponent name in kebab-case.hero-slider
Element__ separator.hero-slider__title
Modifier-- separator.hero-slider--fullscreen
StateModifier 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 via var(--token-name), never via SASS variables — this ensures the CSS variables switch correctly when data-theme changes.

packages/modules/src/registry.ts
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
};

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

IDModule NameSprintTypeZoho TaskCode PathStatusTestedDelivered
M01HeaderSprint 1InteractiveP894-T359m01-header/:green_circle::white_circle::white_circle:
M02Main MenuSprint 1InteractiveP894-T602:white_circle::white_circle::white_circle:
M03Booking BarSprint 1InteractiveP894-T470m03-booking-bar/:large_blue_circle::white_circle::white_circle:
M04FooterSprint 1StaticP894-T384m04-footer/:green_circle::white_circle::white_circle:
M06Hero SimpleSprint 1StaticP894-T395m06-hero-simple/:green_circle::white_circle::white_circle:
M07Slider with CategoriesSprint 2InteractiveP894-T425:white_circle::white_circle::white_circle:
M08Item Cards (Accommodations)Sprint 2StaticP894-T397m08-item-cards/:green_circle::white_circle::white_circle:
M09QuotesSprint 2StaticP894-T427m09-quotes/:green_circle::white_circle::white_circle:
M10HighlightSprint 2StaticP894-T428m10-highlight/:green_circle::white_circle::white_circle:
M11BannerSprint 2StaticP894-T429m11-banner/:green_circle::white_circle::white_circle:
M12Image GallerySprint 3InteractiveP894-T484:white_circle::white_circle::white_circle:
M13FAQsSprint 3InteractiveP894-T532m13-faqs/:green_circle::white_circle::white_circle:
M14FormSprint 4InteractiveP894-T650:white_circle::white_circle::white_circle:
M15ModalSprint 4InteractiveP894-T656:white_circle::white_circle::white_circle:
M16Logo CarouselSprint 3InteractiveP894-T573m16-logo-carousel/:green_circle::white_circle::white_circle:
M17Slider Cross-Content CardsSprint 3InteractiveP894-T644:white_circle::white_circle::white_circle:
M20Item Cards (Documents)Sprint 2StaticP894-T638:white_circle::white_circle::white_circle:
M23Decorative TypeSprint 2StaticP894-T626:white_circle::white_circle::white_circle:
M24Full ImageSprint 2StaticP894-T620m24-full-image/:green_circle::white_circle::white_circle:
M25SliderSprint 3InteractiveP894-T430m25-slider/:green_circle::white_circle::white_circle:
M26RTESprint 2StaticP894-T614:white_circle::white_circle::white_circle:
M27Text ImageSprint 2StaticP894-T431m27-text-image/:green_circle::white_circle::white_circle:
M28Icon ListSprint 2StaticP894-T632:white_circle::white_circle::white_circle:
M29BreadcrumbsSprint 1StaticP894-T608:white_circle::white_circle::white_circle:
M30Social MediaSprint 4StaticP894-T662:white_circle::white_circle::white_circle:
M31Social ShareSprint 4InteractiveP894-T668:white_circle::white_circle::white_circle:
M33Newsletter SubscriptionSprint 4InteractiveP894-T674:white_circle::white_circle::white_circle:
SprintThemeModulesCount
Sprint 1Foundation & NavigationM01, M02, M03, M04, M06, M296
Sprint 2Core ContentM07, M08, M09, M10, M11, M20, M23, M24, M26, M27, M2811
Sprint 3Cards & SlidersM12, M13, M16, M17, M255
Sprint 4Interactive & SocialM14, M15, M30, M31, M335
  • 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)

Templates are page layouts that compose modules based on the content type. Each Umbraco Document Type maps to a template:

Document TypeTemplateLayoutKey Modules
homePageHomeTemplateFull-width, no sidebarHero Slider/Hero, Booking Bar, Featured Cards, Card Grid
contentPageContentTemplateContent-width with breadcrumbsPage Hero, Rich Text, Image+Text, Accordion
roomsListPageRoomsListTemplateGrid layout with optional filtersPage Hero, Card Grid (rooms), Booking Bar
roomDetailPageRoomDetailTemplateDetail layout with galleryImage Gallery, Rich Text, Amenities, Booking Bar
diningListPageDiningListTemplateGrid layoutPage Hero, Card Grid (restaurants)
diningDetailPageDiningDetailTemplateDetail with gallery and infoImage Gallery, Rich Text, Opening Hours, Map
galleryPageGalleryTemplateFull-width masonry/gridPage Hero, Image Gallery
contactPageContactTemplateSplit layout (form + info)Page Hero, Form Module, Map Block
faqPageFAQTemplateContent-widthPage Hero, Accordion
specialOffersPageOffersTemplateGrid layout with datesPage Hero, Offers Carousel, Card Grid
apps/web/src/templates/RoomDetailTemplate.tsx
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}</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} />
)}
</>
);
}

A shared UI component used by all modules that display images:

packages/ui/src/ResponsiveImage/ResponsiveImage.tsx
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>
);
}

  • 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 ResponsiveImage component (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