# 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.

```python
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:

```python
# 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:

```json
{
    "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.

```typescript
{
    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.

```typescript
{
    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.

```typescript
{
    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:
```typescript
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

```python
# 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:
```typescript
// 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

```typescript
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:
```typescript
function getComponentConfig(features: Features): Config {
    const components = { ...baseComponents };

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

    return { components };
}
```

2. **Rendering** always includes all component renderers:
```typescript
// 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
