Files
smoothschedule/docs/SITE_BUILDER_DESIGN.md
poduck 29bcb27e76 Add Puck site builder with preview and draft functionality
Frontend:
- Add comprehensive Puck component library (Layout, Content, Booking, Contact)
- Add Services component with usePublicServices hook integration
- Add 150+ icons to IconList component organized by category
- Add preview modal with viewport toggles (desktop/tablet/mobile)
- Add draft save/discard functionality with localStorage persistence
- Add draft status indicator in PageEditor toolbar
- Fix useSites hooks to use correct API URLs (/pages/{id}/)

Backend:
- Add SiteConfig model for theme, header, footer configuration
- Add Page SEO fields (meta_title, meta_description, og_image, etc.)
- Add puck_data validation for component structure
- Add create_missing_sites management command
- Fix PageViewSet to use EntitlementService for permissions
- Add comprehensive tests for site builder functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 01:32:11 -05:00

16 KiB

Puck Site Builder - Design Document

Overview

This document describes the architecture, data model, migration strategy, and security decisions for the SmoothSchedule Puck-based site builder.

Goals

  1. Production-quality site builder - Enable tenants to build unique pages using nested layout primitives, theme tokens, and booking-native blocks
  2. Backward compatibility - Existing pages must continue to render
  3. Multi-tenant safety - Full tenant isolation for all page data
  4. Security - No arbitrary script injection; sanitized embeds only
  5. Feature gating - Hide/disable blocks based on plan without breaking existing content

Data Model

Current Schema (Existing)

Site
├── tenant (OneToOne → Tenant)
├── primary_domain
├── is_enabled
├── template_key
└── pages[] (Page)

Page
├── site (FK → Site)
├── slug
├── path
├── title
├── is_home
├── is_published
├── order
├── puck_data (JSONField - Puck Data payload)
└── version (int - for migrations)

New Schema Additions

SiteConfig (New Model)

Stores global theme tokens and chrome settings. One per Site, not duplicated per page.

class SiteConfig(models.Model):
    site = models.OneToOneField(Site, on_delete=models.CASCADE, related_name='config')

    # Theme Tokens
    theme = models.JSONField(default=dict)
    # Structure:
    # {
    #     "colors": {
    #         "primary": "#3b82f6",
    #         "secondary": "#64748b",
    #         "accent": "#f59e0b",
    #         "background": "#ffffff",
    #         "surface": "#f8fafc",
    #         "text": "#1e293b",
    #         "textMuted": "#64748b"
    #     },
    #     "typography": {
    #         "fontFamily": "Inter, system-ui, sans-serif",
    #         "headingFontFamily": null,  # null = use fontFamily
    #         "baseFontSize": "16px",
    #         "scale": 1.25  # type scale ratio
    #     },
    #     "buttons": {
    #         "borderRadius": "8px",
    #         "primaryStyle": "solid",  # solid | outline | ghost
    #         "secondaryStyle": "outline"
    #     },
    #     "sections": {
    #         "containerMaxWidth": "1280px",
    #         "defaultPaddingY": "80px"
    #     }
    # }

    # Global Chrome
    header = models.JSONField(default=dict)
    # Structure:
    # {
    #     "enabled": true,
    #     "logo": { "src": "", "alt": "", "width": 120 },
    #     "navigation": [
    #         { "label": "Home", "href": "/" },
    #         { "label": "Services", "href": "/services" },
    #         { "label": "Book Now", "href": "/book", "style": "button" }
    #     ],
    #     "sticky": true,
    #     "style": "default"  # default | transparent | minimal
    # }

    footer = models.JSONField(default=dict)
    # Structure:
    # {
    #     "enabled": true,
    #     "columns": [
    #         {
    #             "title": "Company",
    #             "links": [{ "label": "About", "href": "/about" }]
    #         }
    #     ],
    #     "copyright": "© 2024 {business_name}. All rights reserved.",
    #     "socialLinks": [
    #         { "platform": "facebook", "url": "" },
    #         { "platform": "instagram", "url": "" }
    #     ]
    # }

    version = models.PositiveIntegerField(default=1)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

Page Model Enhancements

Add SEO and navigation fields to existing Page model:

# Add to existing Page model:
meta_title = models.CharField(max_length=255, blank=True)
meta_description = models.TextField(blank=True)
og_image = models.URLField(blank=True)
canonical_url = models.URLField(blank=True)
noindex = models.BooleanField(default=False)
include_in_nav = models.BooleanField(default=True)
hide_chrome = models.BooleanField(default=False)  # Landing page mode

Puck Data Schema

The puck_data JSONField stores the Puck editor payload:

{
    "content": [
        {
            "type": "Section",
            "props": {
                "id": "section-abc123",
                "background": { "type": "color", "value": "#f8fafc" },
                "padding": "large",
                "containerWidth": "default",
                "anchorId": "hero"
            }
        }
    ],
    "root": {},
    "zones": {
        "section-abc123:content": [
            {
                "type": "Heading",
                "props": { "text": "Welcome", "level": "h1" }
            }
        ]
    }
}

Version Strategy

  • Page.version tracks payload schema version
  • SiteConfig.version tracks theme/chrome schema version
  • Migrations are handled on read (lazy migration)
  • On save, always write latest version

API Endpoints

Existing (No Changes)

Endpoint Method Purpose
GET /api/sites/me/ GET Get current site
GET /api/sites/me/pages/ GET List pages
POST /api/sites/me/pages/ POST Create page
PATCH /api/sites/me/pages/{id}/ PATCH Update page
DELETE /api/sites/me/pages/{id}/ DELETE Delete page
GET /api/public/page/ GET Get home page (public)

New Endpoints

Endpoint Method Purpose
GET /api/sites/me/config/ GET Get site config (theme, chrome)
PATCH /api/sites/me/config/ PATCH Update site config
GET /api/public/page/{slug}/ GET Get page by slug (public)

Component Library

Categories

  1. Layout - Section, Columns, Card, Spacer, Divider
  2. Content - Heading, RichText, Image, Button, IconList, Testimonial, FAQ
  3. Booking - BookingWidget, ServiceCatalog
  4. Contact - ContactForm, BusinessHours, Map

Component Specification

Section (Layout)

The fundamental building block for page sections.

{
    type: "Section",
    label: "Section",
    fields: {
        background: {
            type: "custom",  // Color picker, image upload, or gradient
            options: ["none", "color", "image", "gradient"]
        },
        overlay: {
            type: "custom",  // Overlay color + opacity
        },
        padding: {
            type: "select",
            options: ["none", "small", "medium", "large", "xlarge"]
        },
        containerWidth: {
            type: "select",
            options: ["narrow", "default", "wide", "full"]
        },
        anchorId: { type: "text" },
        hideOnMobile: { type: "checkbox" },
        hideOnTablet: { type: "checkbox" },
        hideOnDesktop: { type: "checkbox" }
    },
    render: ({ puck }) => (
        <section>
            <div className={containerClass}>
                <DropZone zone="content" />
            </div>
        </section>
    )
}

Columns (Layout)

Flexible column layout with nested drop zones.

{
    type: "Columns",
    fields: {
        columns: {
            type: "select",
            options: ["2", "3", "4", "2-1", "1-2"]  // ratios
        },
        gap: {
            type: "select",
            options: ["none", "small", "medium", "large"]
        },
        verticalAlign: {
            type: "select",
            options: ["top", "center", "bottom", "stretch"]
        },
        stackOnMobile: { type: "checkbox", default: true }
    },
    render: ({ columns, puck }) => (
        <div className="grid">
            {Array.from({ length: columnCount }).map((_, i) => (
                <DropZone zone={`column-${i}`} key={i} />
            ))}
        </div>
    )
}

BookingWidget (Booking)

Embedded booking interface - SmoothSchedule's differentiator.

{
    type: "BookingWidget",
    fields: {
        serviceMode: {
            type: "select",
            options: [
                { label: "All Services", value: "all" },
                { label: "By Category", value: "category" },
                { label: "Specific Services", value: "specific" }
            ]
        },
        categoryId: { type: "text" },  // When mode = category
        serviceIds: { type: "array" },  // When mode = specific
        showDuration: { type: "checkbox", default: true },
        showPrice: { type: "checkbox", default: true },
        showDeposits: { type: "checkbox", default: true },
        requireLogin: { type: "checkbox", default: false },
        ctaAfterBooking: { type: "text" }
    }
}

Security Measures

1. XSS Prevention

All text content is rendered through React, which auto-escapes HTML by default.

For rich text (RichText component):

  • Store content as structured JSON (Slate/Tiptap document format), not raw HTML
  • Render using a safe renderer that only supports whitelisted elements (p, strong, em, a, ul, ol, li)
  • Never render raw HTML strings directly into the DOM
  • All user-provided content goes through React's safe text rendering

2. Embed/Script Injection

No arbitrary embeds allowed. Map component only supports:

  • Google Maps embed URLs (maps.google.com/*)
  • OpenStreetMap iframes

Implementation:

const ALLOWED_EMBED_DOMAINS = [
    'www.google.com/maps/embed',
    'maps.google.com',
    'www.openstreetmap.org'
];

function isAllowedEmbed(url: string): boolean {
    return ALLOWED_EMBED_DOMAINS.some(domain =>
        url.startsWith(`https://${domain}`)
    );
}

3. Backend Validation

# In PageSerializer.validate_puck_data()
def validate_puck_data(self, value):
    # 1. Size limit
    if len(json.dumps(value)) > 5_000_000:  # 5MB limit
        raise ValidationError("Page data too large")

    # 2. Validate structure
    if not isinstance(value.get('content'), list):
        raise ValidationError("Invalid puck_data structure")

    # 3. Scan for disallowed content
    serialized = json.dumps(value).lower()
    disallowed = ['<script', 'javascript:', 'onerror=', 'onload=']
    for pattern in disallowed:
        if pattern in serialized:
            raise ValidationError("Disallowed content detected")

    return value

4. Tenant Isolation

All queries are automatically tenant-scoped:

  • get_queryset() filters by site__tenant=request.tenant
  • perform_create() assigns site from tenant context
  • No cross-tenant data access possible

Migration Strategy

Current State Analysis

The existing implementation already uses Puck with 3 components:

  • Hero
  • TextSection
  • Booking

No "enum-based component list" migration needed - the system is already Puck-native.

Forward Migration

When adding new component types or changing prop schemas:

  1. Version field tracks schema version per page
  2. Lazy migration on read - transform old format to new
  3. Save updates version - always writes latest format

Example migration:

// v1 → v2: Hero.align was string, now object with breakpoint values
function migrateHeroV1toV2(props: any): any {
    if (typeof props.align === 'string') {
        return {
            ...props,
            align: {
                mobile: 'center',
                tablet: props.align,
                desktop: props.align
            }
        };
    }
    return props;
}

Migration Registry

const MIGRATIONS: Record<number, (data: PuckData) => PuckData> = {
    2: migrateV1toV2,
    3: migrateV2toV3,
};

function migratePuckData(data: PuckData, currentVersion: number): PuckData {
    let migrated = data;
    for (let v = currentVersion + 1; v <= LATEST_VERSION; v++) {
        if (MIGRATIONS[v]) {
            migrated = MIGRATIONS[v](migrated);
        }
    }
    return migrated;
}

Feature Gating

Plan-Based Component Access

Some components are gated by plan features:

  • ContactForm - requires can_use_contact_form feature
  • ServiceCatalog - requires can_use_service_catalog feature

Implementation

  1. Config generation passes feature flags to frontend:
function getComponentConfig(features: Features): Config {
    const components = { ...baseComponents };

    if (!features.can_use_contact_form) {
        delete components.ContactForm;
    }

    return { components };
}
  1. Rendering always includes all component renderers:
// Full config for rendering (never gated)
const renderConfig = { components: allComponents };

// Gated config for editing
const editorConfig = getComponentConfig(features);

This ensures pages with gated components still render correctly, even if the user can't add new instances.

Editor UX Enhancements

Viewport Toggles

Desktop (default), Tablet (768px), Mobile (375px)

Outline Navigation

Tree view of page structure with:

  • Drag to reorder
  • Click to select
  • Collapse/expand zones

Categorized Component Palette

  • Layout: Section, Columns, Card, Spacer, Divider
  • Content: Heading, RichText, Image, Button, IconList, Testimonial, FAQ
  • Booking: BookingWidget, ServiceCatalog
  • Contact: ContactForm, BusinessHours, Map

Page Settings Panel

Accessible via page icon in header:

  • Title & slug
  • Meta title & description
  • OG image
  • Canonical URL
  • Index/noindex toggle
  • Include in navigation toggle
  • Hide chrome toggle (landing page mode)

File Structure

Backend

smoothschedule/platform/tenant_sites/
├── models.py           # Site, SiteConfig, Page, Domain
├── serializers.py      # API serializers with validation
├── views.py            # ViewSets and API views
├── validators.py       # Puck data validation helpers
├── migrations/
│   └── 0002_siteconfig_page_seo_fields.py
└── tests/
    ├── __init__.py
    ├── test_models.py
    ├── test_serializers.py
    ├── test_views.py
    └── test_tenant_isolation.py

Frontend

frontend/src/
├── puck/
│   ├── config.ts              # Main Puck config export
│   ├── types.ts               # Component prop types
│   ├── migrations.ts          # Data migration functions
│   ├── components/
│   │   ├── layout/
│   │   │   ├── Section.tsx
│   │   │   ├── Columns.tsx
│   │   │   ├── Card.tsx
│   │   │   ├── Spacer.tsx
│   │   │   └── Divider.tsx
│   │   ├── content/
│   │   │   ├── Heading.tsx
│   │   │   ├── RichText.tsx
│   │   │   ├── Image.tsx
│   │   │   ├── Button.tsx
│   │   │   ├── IconList.tsx
│   │   │   ├── Testimonial.tsx
│   │   │   └── FAQ.tsx
│   │   ├── booking/
│   │   │   ├── BookingWidget.tsx
│   │   │   └── ServiceCatalog.tsx
│   │   └── contact/
│   │       ├── ContactForm.tsx
│   │       ├── BusinessHours.tsx
│   │       └── Map.tsx
│   └── fields/
│       ├── ColorPicker.tsx
│       ├── BackgroundPicker.tsx
│       └── RichTextEditor.tsx
├── pages/
│   ├── PageEditor.tsx         # Enhanced editor
│   └── PublicPage.tsx         # Public renderer
├── hooks/
│   └── useSites.ts            # Site/Page/Config hooks
└── __tests__/
    └── puck/
        ├── migrations.test.ts
        ├── components.test.tsx
        └── config.test.ts

Implementation Order

  1. Tests First (TDD)

    • Backend: tenant isolation, CRUD, validation
    • Frontend: migration, rendering, feature gating
  2. Data Model

    • Add SiteConfig model
    • Add Page SEO fields
    • Create migrations
  3. API

    • SiteConfig endpoints
    • Enhanced PageSerializer validation
  4. Components

    • Layout primitives (Section, Columns)
    • Content blocks (Heading, RichText, Image)
    • Booking blocks (enhanced BookingWidget)
    • Contact blocks (ContactForm, BusinessHours)
  5. Editor

    • Viewport toggles
    • Categorized palette
    • Page settings panel
  6. Public Rendering

    • Apply theme tokens
    • Render header/footer chrome