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>
577 lines
16 KiB
Markdown
577 lines
16 KiB
Markdown
# 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
|