# 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 }) => (
) } ``` #### 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 }) => (
{Array.from({ length: columnCount }).map((_, i) => ( ))}
) } ``` #### 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 = [' 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