diff --git a/docs/SITE_BUILDER_DESIGN.md b/docs/SITE_BUILDER_DESIGN.md new file mode 100644 index 0000000..7ddec21 --- /dev/null +++ b/docs/SITE_BUILDER_DESIGN.md @@ -0,0 +1,576 @@ +# 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 diff --git a/frontend/src/hooks/useSites.ts b/frontend/src/hooks/useSites.ts index ba019ec..d6e78c9 100644 --- a/frontend/src/hooks/useSites.ts +++ b/frontend/src/hooks/useSites.ts @@ -25,7 +25,7 @@ export const usePage = (pageId: string) => { return useQuery({ queryKey: ['page', pageId], queryFn: async () => { - const response = await api.get(`/sites/me/pages/${pageId}/`); + const response = await api.get(`/pages/${pageId}/`); return response.data; }, enabled: !!pageId, @@ -36,7 +36,7 @@ export const useUpdatePage = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ id, data }: { id: string; data: any }) => { - const response = await api.patch(`/sites/me/pages/${id}/`, data); + const response = await api.patch(`/pages/${id}/`, data); return response.data; }, onSuccess: (data, variables) => { @@ -63,7 +63,7 @@ export const useDeletePage = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (id: string) => { - await api.delete(`/sites/me/pages/${id}/`); + await api.delete(`/pages/${id}/`); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['pages'] }); @@ -81,3 +81,41 @@ export const usePublicPage = () => { retry: false, }); }; + +export const useSiteConfig = () => { + return useQuery({ + queryKey: ['siteConfig'], + queryFn: async () => { + const response = await api.get('/sites/me/config/'); + return response.data; + }, + }); +}; + +export const useUpdateSiteConfig = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data: { + theme?: Record; + header?: Record; + footer?: Record; + }) => { + const response = await api.patch('/sites/me/config/', data); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['siteConfig'] }); + }, + }); +}; + +export const usePublicSiteConfig = () => { + return useQuery({ + queryKey: ['publicSiteConfig'], + queryFn: async () => { + const response = await api.get('/public/site-config/'); + return response.data; + }, + retry: false, + }); +}; diff --git a/frontend/src/pages/PageEditor.tsx b/frontend/src/pages/PageEditor.tsx index eb343a2..933e3a5 100644 --- a/frontend/src/pages/PageEditor.tsx +++ b/frontend/src/pages/PageEditor.tsx @@ -1,15 +1,26 @@ -import React, { useState, useEffect } from 'react'; -import { Puck } from "@measured/puck"; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { Puck, Render } from "@measured/puck"; import "@measured/puck/puck.css"; -import { config } from "../puckConfig"; +import { puckConfig, getEditorConfig, renderConfig } from "../puck/config"; import { usePages, useUpdatePage, useCreatePage, useDeletePage } from "../hooks/useSites"; -import { Loader2, Plus, Trash2, FileText } from "lucide-react"; +import { Loader2, Plus, Trash2, FileText, Monitor, Tablet, Smartphone, Settings, Eye, X, ExternalLink, Save, RotateCcw } from "lucide-react"; import toast from 'react-hot-toast'; import { useEntitlements, FEATURE_CODES } from '../hooks/useEntitlements'; +// Draft storage key prefix +const DRAFT_KEY_PREFIX = 'puck_draft_'; + +type ViewportSize = 'desktop' | 'tablet' | 'mobile'; + +const VIEWPORT_WIDTHS: Record = { + desktop: null, // Full width + tablet: 768, + mobile: 375, +}; + export const PageEditor: React.FC = () => { const { data: pages, isLoading } = usePages(); - const { getLimit, isLoading: entitlementsLoading } = useEntitlements(); + const { getLimit, isLoading: entitlementsLoading, hasFeature } = useEntitlements(); const updatePage = useUpdatePage(); const createPage = useCreatePage(); const deletePage = useDeletePage(); @@ -17,6 +28,24 @@ export const PageEditor: React.FC = () => { const [currentPageId, setCurrentPageId] = useState(null); const [showNewPageModal, setShowNewPageModal] = useState(false); const [newPageTitle, setNewPageTitle] = useState(''); + const [viewport, setViewport] = useState('desktop'); + const [showPageSettings, setShowPageSettings] = useState(false); + const [showPreview, setShowPreview] = useState(false); + const [previewViewport, setPreviewViewport] = useState('desktop'); + const [previewData, setPreviewData] = useState(null); + const [hasDraft, setHasDraft] = useState(false); + const [publishedData, setPublishedData] = useState(null); + + // Get draft key for current page + const getDraftKey = useCallback((pageId: string) => { + return `${DRAFT_KEY_PREFIX}${pageId}`; + }, []); + + // Check if current data differs from published data + const hasUnsavedChanges = useMemo(() => { + if (!data || !publishedData) return false; + return JSON.stringify(data) !== JSON.stringify(publishedData); + }, [data, publishedData]); // Get max_public_pages from billing entitlements // null = unlimited, 0 = no access, >0 = limited pages @@ -25,19 +54,48 @@ export const PageEditor: React.FC = () => { const pageCount = pages?.length || 0; const canCreateMore = canCustomize && (maxPagesLimit === null || pageCount < maxPagesLimit); + // Feature-gated components + const features = { + can_use_contact_form: hasFeature('can_use_contact_form'), + can_use_service_catalog: hasFeature('can_use_service_catalog'), + }; + + // Get editor config with feature gating + const editorConfig = getEditorConfig(features); + const currentPage = pages?.find((p: any) => p.id === currentPageId) || pages?.find((p: any) => p.is_home) || pages?.[0]; useEffect(() => { - if (currentPage?.puck_data) { - // Ensure data structure is valid for Puck - const puckData = currentPage.puck_data; - if (!puckData.content) puckData.content = []; - if (!puckData.root) puckData.root = {}; + if (currentPage) { + // Ensure data structure is valid for Puck + const puckData = currentPage.puck_data || { content: [], root: {} }; + if (!puckData.content) puckData.content = []; + if (!puckData.root) puckData.root = {}; + + // Store the published data for comparison + setPublishedData(puckData); + + // Check for saved draft + const draftKey = getDraftKey(currentPage.id); + const savedDraft = localStorage.getItem(draftKey); + + if (savedDraft) { + try { + const draftData = JSON.parse(savedDraft); + setData(draftData); + setHasDraft(true); + } catch (e) { + // Invalid draft data, use published + setData(puckData); + setHasDraft(false); + localStorage.removeItem(draftKey); + } + } else { setData(puckData); - } else if (currentPage) { - setData({ content: [], root: {} }); + setHasDraft(false); + } } - }, [currentPage]); + }, [currentPage, getDraftKey]); const handlePublish = async (newData: any) => { if (!currentPage) return; @@ -50,6 +108,13 @@ export const PageEditor: React.FC = () => { try { await updatePage.mutateAsync({ id: currentPage.id, data: { puck_data: newData } }); + + // Clear draft after successful publish + const draftKey = getDraftKey(currentPage.id); + localStorage.removeItem(draftKey); + setHasDraft(false); + setPublishedData(newData); + toast.success("Page published successfully!"); } catch (error: any) { const errorMsg = error?.response?.data?.error || "Failed to publish page."; @@ -58,6 +123,34 @@ export const PageEditor: React.FC = () => { } }; + // Save draft to localStorage + const handleSaveDraft = useCallback(() => { + if (!currentPage || !data) return; + + if (!canCustomize) { + toast.error("Your plan does not include site customization."); + return; + } + + const draftKey = getDraftKey(currentPage.id); + localStorage.setItem(draftKey, JSON.stringify(data)); + setHasDraft(true); + toast.success("Draft saved!"); + }, [currentPage, data, canCustomize, getDraftKey]); + + // Discard draft and revert to published version + const handleDiscardDraft = useCallback(() => { + if (!currentPage || !publishedData) return; + + if (!confirm("Discard all changes and revert to the published version?")) return; + + const draftKey = getDraftKey(currentPage.id); + localStorage.removeItem(draftKey); + setData(publishedData); + setHasDraft(false); + toast.success("Draft discarded, reverted to published version."); + }, [currentPage, publishedData, getDraftKey]); + const handleCreatePage = async () => { if (!newPageTitle.trim()) { toast.error("Page title is required"); @@ -90,6 +183,30 @@ export const PageEditor: React.FC = () => { } }; + // Handle data changes from Puck editor + const handleDataChange = useCallback((newData: any) => { + setData(newData); + }, []); + + // Open preview with current data + const handlePreview = () => { + setPreviewData(data); + setPreviewViewport('desktop'); + setShowPreview(true); + }; + + // Open preview in new tab + const handlePreviewNewTab = () => { + // Store data in sessionStorage for the preview page + const previewKey = `preview_${currentPage?.id || 'new'}`; + sessionStorage.setItem(previewKey, JSON.stringify(data)); + + // Open the page in a new tab with preview mode + const baseUrl = window.location.origin; + const previewUrl = `${baseUrl}/?preview=${previewKey}`; + window.open(previewUrl, '_blank'); + }; + if (isLoading || entitlementsLoading) { return
; } @@ -103,6 +220,15 @@ export const PageEditor: React.FC = () => { // Display max pages as string for UI (null = unlimited shown as ∞) const maxPagesDisplay = maxPagesLimit === null ? '∞' : maxPagesLimit; + // Calculate iframe style for viewport preview + const viewportWidth = VIEWPORT_WIDTHS[viewport]; + const iframeStyle = viewportWidth ? { + maxWidth: `${viewportWidth}px`, + margin: '0 auto', + boxShadow: '0 0 0 1px rgba(0,0,0,0.1)', + borderRadius: '8px', + } : {}; + return (
{/* Permission Notice for Free Tier */} @@ -157,10 +283,114 @@ export const PageEditor: React.FC = () => { Delete )} + + {/* Divider */} +
+ + {/* Viewport Toggles */} +
+ + + +
+ + {/* Page Settings Button */} + + + {/* Divider */} +
+ + {/* Preview Button */} + + + {/* Divider */} +
+ + {/* Save Draft Button */} + + + {/* Discard Draft Button - Only show if there's a draft */} + {hasDraft && ( + + )}
-
- {pageCount} / {maxPagesDisplay} pages +
+ {/* Draft Status Indicator */} + {hasDraft && ( + + + Draft saved + + )} + {hasUnsavedChanges && !hasDraft && ( + + + Unsaved changes + + )} + + {pageCount} / {maxPagesDisplay} pages +
@@ -201,13 +431,306 @@ export const PageEditor: React.FC = () => {
)} - + {/* Page Settings Modal */} + {showPageSettings && currentPage && ( + setShowPageSettings(false)} + onSave={async (updates) => { + try { + await updatePage.mutateAsync({ id: currentPage.id, data: updates }); + toast.success("Page settings saved!"); + setShowPageSettings(false); + } catch (error: any) { + toast.error(error?.response?.data?.error || "Failed to save settings"); + } + }} + canEdit={canCustomize} + /> + )} + + {/* Preview Modal */} + {showPreview && previewData && ( +
+ {/* Preview Header */} +
+
+

+ Preview: {currentPage?.title} +

+ + {/* Viewport Toggles */} +
+ + + +
+
+ +
+ + +
+
+ + {/* Preview Content */} +
+
+ +
+
+
+ )} + + {/* Puck Editor with viewport width */} +
+ +
); }; +// Page Settings Modal Component +interface PageSettingsModalProps { + page: any; + onClose: () => void; + onSave: (updates: any) => Promise; + canEdit: boolean; +} + +function PageSettingsModal({ page, onClose, onSave, canEdit }: PageSettingsModalProps) { + const [settings, setSettings] = useState({ + title: page.title || '', + meta_title: page.meta_title || '', + meta_description: page.meta_description || '', + og_image: page.og_image || '', + canonical_url: page.canonical_url || '', + noindex: page.noindex || false, + include_in_nav: page.include_in_nav ?? true, + hide_chrome: page.hide_chrome || false, + }); + const [saving, setSaving] = useState(false); + + const handleSave = async () => { + setSaving(true); + try { + await onSave(settings); + } finally { + setSaving(false); + } + }; + + return ( +
+
+
+

+ Page Settings +

+
+ +
+ {/* Basic Settings */} +
+ + setSettings({ ...settings, title: e.target.value })} + disabled={!canEdit} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:opacity-50" + /> +
+ + {/* SEO Settings */} +
+

+ SEO Settings +

+ +
+
+ + setSettings({ ...settings, meta_title: e.target.value })} + disabled={!canEdit} + placeholder="Defaults to page title" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:opacity-50" + /> +
+ +
+ +