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>
This commit is contained in:
poduck
2025-12-13 01:32:11 -05:00
parent 41caccd31a
commit 29bcb27e76
40 changed files with 6626 additions and 45 deletions

View File

@@ -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<string, unknown>;
header?: Record<string, unknown>;
footer?: Record<string, unknown>;
}) => {
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,
});
};

View File

@@ -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<ViewportSize, number | null> = {
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<string | null>(null);
const [showNewPageModal, setShowNewPageModal] = useState(false);
const [newPageTitle, setNewPageTitle] = useState('');
const [viewport, setViewport] = useState<ViewportSize>('desktop');
const [showPageSettings, setShowPageSettings] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [previewViewport, setPreviewViewport] = useState<ViewportSize>('desktop');
const [previewData, setPreviewData] = useState<any>(null);
const [hasDraft, setHasDraft] = useState(false);
const [publishedData, setPublishedData] = useState<any>(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 <div className="flex justify-center p-10"><Loader2 className="animate-spin" /></div>;
}
@@ -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 (
<div className="h-screen flex flex-col">
{/* Permission Notice for Free Tier */}
@@ -157,10 +283,114 @@ export const PageEditor: React.FC = () => {
Delete
</button>
)}
{/* Divider */}
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600" />
{/* Viewport Toggles */}
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
<button
onClick={() => setViewport('desktop')}
className={`p-1.5 rounded ${
viewport === 'desktop'
? 'bg-white dark:bg-gray-600 shadow-sm'
: 'hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
title="Desktop view"
>
<Monitor size={18} className={viewport === 'desktop' ? 'text-indigo-600' : 'text-gray-500'} />
</button>
<button
onClick={() => setViewport('tablet')}
className={`p-1.5 rounded ${
viewport === 'tablet'
? 'bg-white dark:bg-gray-600 shadow-sm'
: 'hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
title="Tablet view (768px)"
>
<Tablet size={18} className={viewport === 'tablet' ? 'text-indigo-600' : 'text-gray-500'} />
</button>
<button
onClick={() => setViewport('mobile')}
className={`p-1.5 rounded ${
viewport === 'mobile'
? 'bg-white dark:bg-gray-600 shadow-sm'
: 'hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
title="Mobile view (375px)"
>
<Smartphone size={18} className={viewport === 'mobile' ? 'text-indigo-600' : 'text-gray-500'} />
</button>
</div>
{/* Page Settings Button */}
<button
onClick={() => setShowPageSettings(true)}
className="p-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
title="Page Settings"
>
<Settings size={18} className="text-gray-500" />
</button>
{/* Divider */}
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600" />
{/* Preview Button */}
<button
onClick={handlePreview}
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 text-sm"
title="Preview page"
>
<Eye size={16} />
Preview
</button>
{/* Divider */}
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600" />
{/* Save Draft Button */}
<button
onClick={handleSaveDraft}
disabled={!canCustomize || !hasUnsavedChanges}
className="flex items-center gap-1.5 px-3 py-1.5 bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-800/50 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
title={hasUnsavedChanges ? "Save draft" : "No unsaved changes"}
>
<Save size={16} />
Save Draft
</button>
{/* Discard Draft Button - Only show if there's a draft */}
{hasDraft && (
<button
onClick={handleDiscardDraft}
disabled={!canCustomize}
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg hover:bg-red-200 dark:hover:bg-red-800/50 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
title="Discard draft and revert to published version"
>
<RotateCcw size={16} />
Discard Draft
</button>
)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{pageCount} / {maxPagesDisplay} pages
<div className="flex items-center gap-3">
{/* Draft Status Indicator */}
{hasDraft && (
<span className="flex items-center gap-1.5 px-2 py-1 bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded text-xs font-medium">
<span className="w-2 h-2 bg-amber-500 rounded-full animate-pulse"></span>
Draft saved
</span>
)}
{hasUnsavedChanges && !hasDraft && (
<span className="flex items-center gap-1.5 px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded text-xs font-medium">
<span className="w-2 h-2 bg-gray-400 rounded-full"></span>
Unsaved changes
</span>
)}
<span className="text-sm text-gray-600 dark:text-gray-400">
{pageCount} / {maxPagesDisplay} pages
</span>
</div>
</div>
@@ -201,13 +431,306 @@ export const PageEditor: React.FC = () => {
</div>
)}
<Puck
config={config}
data={data}
onPublish={handlePublish}
/>
{/* Page Settings Modal */}
{showPageSettings && currentPage && (
<PageSettingsModal
page={currentPage}
onClose={() => 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 && (
<div className="fixed inset-0 bg-black/80 flex flex-col z-50">
{/* Preview Header */}
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Preview: {currentPage?.title}
</h3>
{/* Viewport Toggles */}
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
<button
onClick={() => setPreviewViewport('desktop')}
className={`p-1.5 rounded ${
previewViewport === 'desktop'
? 'bg-white dark:bg-gray-600 shadow-sm'
: 'hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
title="Desktop view"
>
<Monitor size={18} className={previewViewport === 'desktop' ? 'text-indigo-600' : 'text-gray-500'} />
</button>
<button
onClick={() => setPreviewViewport('tablet')}
className={`p-1.5 rounded ${
previewViewport === 'tablet'
? 'bg-white dark:bg-gray-600 shadow-sm'
: 'hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
title="Tablet view (768px)"
>
<Tablet size={18} className={previewViewport === 'tablet' ? 'text-indigo-600' : 'text-gray-500'} />
</button>
<button
onClick={() => setPreviewViewport('mobile')}
className={`p-1.5 rounded ${
previewViewport === 'mobile'
? 'bg-white dark:bg-gray-600 shadow-sm'
: 'hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
title="Mobile view (375px)"
>
<Smartphone size={18} className={previewViewport === 'mobile' ? 'text-indigo-600' : 'text-gray-500'} />
</button>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={handlePreviewNewTab}
className="flex items-center gap-1.5 px-3 py-1.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 text-sm"
title="Open preview in new tab"
>
<ExternalLink size={16} />
Open in new tab
</button>
<button
onClick={() => setShowPreview(false)}
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
title="Close preview"
>
<X size={20} />
</button>
</div>
</div>
{/* Preview Content */}
<div className="flex-1 overflow-auto bg-gray-100 dark:bg-gray-900 p-4">
<div
className="mx-auto bg-white dark:bg-gray-800 min-h-full shadow-xl rounded-lg overflow-hidden"
style={{
width: VIEWPORT_WIDTHS[previewViewport] || '100%',
maxWidth: '100%',
transition: 'width 0.3s ease-in-out',
}}
>
<Render config={renderConfig} data={previewData} />
</div>
</div>
</div>
)}
{/* Puck Editor with viewport width */}
<div className="flex-1 overflow-hidden" style={iframeStyle}>
<Puck
config={editorConfig}
data={data}
onPublish={handlePublish}
onChange={handleDataChange}
/>
</div>
</div>
);
};
// Page Settings Modal Component
interface PageSettingsModalProps {
page: any;
onClose: () => void;
onSave: (updates: any) => Promise<void>;
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 (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 overflow-y-auto">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-lg mx-4 my-8 max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Page Settings
</h3>
</div>
<div className="p-6 space-y-6">
{/* Basic Settings */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Page Title
</label>
<input
type="text"
value={settings.title}
onChange={(e) => 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"
/>
</div>
{/* SEO Settings */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-4 uppercase tracking-wide">
SEO Settings
</h4>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Meta Title
</label>
<input
type="text"
value={settings.meta_title}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Meta Description
</label>
<textarea
value={settings.meta_description}
onChange={(e) => setSettings({ ...settings, meta_description: e.target.value })}
disabled={!canEdit}
rows={3}
placeholder="Brief description for search engines"
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
OG Image URL
</label>
<input
type="url"
value={settings.og_image}
onChange={(e) => setSettings({ ...settings, og_image: e.target.value })}
disabled={!canEdit}
placeholder="https://..."
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Canonical URL
</label>
<input
type="url"
value={settings.canonical_url}
onChange={(e) => setSettings({ ...settings, canonical_url: e.target.value })}
disabled={!canEdit}
placeholder="https://..."
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"
/>
</div>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={settings.noindex}
onChange={(e) => setSettings({ ...settings, noindex: e.target.checked })}
disabled={!canEdit}
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
Hide from search engines (noindex)
</span>
</label>
</div>
</div>
{/* Navigation & Display */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-4 uppercase tracking-wide">
Navigation & Display
</h4>
<div className="space-y-4">
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={settings.include_in_nav}
onChange={(e) => setSettings({ ...settings, include_in_nav: e.target.checked })}
disabled={!canEdit}
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
Include in site navigation
</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={settings.hide_chrome}
onChange={(e) => setSettings({ ...settings, hide_chrome: e.target.checked })}
disabled={!canEdit}
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
Hide header/footer (landing page mode)
</span>
</label>
</div>
</div>
</div>
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-6 py-4 flex gap-3 justify-end">
<button
onClick={onClose}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={!canEdit || saving}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save Settings'}
</button>
</div>
</div>
</div>
);
}
export default PageEditor;

View File

@@ -1,24 +1,295 @@
import React from 'react';
import React, { useEffect, useMemo } from 'react';
import { Render } from "@measured/puck";
import { config } from "../puckConfig";
import { usePublicPage } from "../hooks/useSites";
import { renderConfig } from "../puck/config";
import { usePublicPage, usePublicSiteConfig } from "../hooks/useSites";
import { Loader2 } from "lucide-react";
import type { Theme, HeaderConfig, FooterConfig } from "../puck/types";
export const PublicPage: React.FC = () => {
const { data, isLoading, error } = usePublicPage();
// Theme token to CSS custom property mapping
function themeToCSSVars(theme: Theme): Record<string, string> {
const vars: Record<string, string> = {};
if (isLoading) {
return <div className="min-h-screen flex items-center justify-center"><Loader2 className="animate-spin" /></div>;
// Colors
if (theme.colors) {
const { colors } = theme;
if (colors.primary) vars['--color-primary'] = colors.primary;
if (colors.secondary) vars['--color-secondary'] = colors.secondary;
if (colors.accent) vars['--color-accent'] = colors.accent;
if (colors.background) vars['--color-background'] = colors.background;
if (colors.surface) vars['--color-surface'] = colors.surface;
if (colors.text) vars['--color-text'] = colors.text;
if (colors.textMuted) vars['--color-text-muted'] = colors.textMuted;
}
if (error || !data) {
return <div className="min-h-screen flex items-center justify-center">Page not found or site disabled.</div>;
// Typography
if (theme.typography) {
const { typography } = theme;
if (typography.fontFamily) vars['--font-family'] = typography.fontFamily;
if (typography.headingFamily) vars['--font-heading'] = typography.headingFamily;
if (typography.baseFontSize) vars['--font-size-base'] = typography.baseFontSize;
}
// Buttons
if (theme.buttons) {
const { buttons } = theme;
if (buttons.borderRadius) vars['--button-radius'] = buttons.borderRadius;
if (buttons.paddingX) vars['--button-padding-x'] = buttons.paddingX;
if (buttons.paddingY) vars['--button-padding-y'] = buttons.paddingY;
}
// Sections
if (theme.sections) {
const { sections } = theme;
if (sections.maxWidth) vars['--section-max-width'] = sections.maxWidth;
if (sections.defaultPadding) vars['--section-padding'] = sections.defaultPadding;
if (sections.containerPadding) vars['--section-container-padding'] = sections.containerPadding;
}
return vars;
}
// Site header component
function SiteHeader({ config, pages }: { config: HeaderConfig; pages?: { title: string; slug: string; include_in_nav: boolean }[] }) {
const navPages = pages?.filter(p => p.include_in_nav) || [];
if (config.style === 'none') {
return null;
}
return (
<div className="public-page">
<Render config={config} data={data.puck_data} />
</div>
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<div className="flex items-center">
{config.logoUrl ? (
<img
src={config.logoUrl}
alt={config.businessName || 'Logo'}
className="h-8"
/>
) : config.businessName ? (
<span className="text-xl font-bold text-gray-900 dark:text-white">
{config.businessName}
</span>
) : null}
</div>
{/* Navigation */}
{config.showNavigation && navPages.length > 0 && (
<nav className="hidden md:flex items-center space-x-6">
{navPages.map((page) => (
<a
key={page.slug}
href={`/${page.slug}`}
className="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
>
{page.title}
</a>
))}
</nav>
)}
{/* CTA Button */}
{config.ctaText && config.ctaLink && (
<a
href={config.ctaLink}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors text-sm font-medium"
>
{config.ctaText}
</a>
)}
</div>
</div>
</header>
);
}
// Site footer component
function SiteFooter({ config }: { config: FooterConfig }) {
if (config.style === 'none') {
return null;
}
return (
<footer className="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
{/* Copyright */}
{config.copyrightText && (
<p className="text-sm text-gray-600 dark:text-gray-400">
{config.copyrightText}
</p>
)}
{/* Social Links */}
{config.socialLinks && Object.keys(config.socialLinks).length > 0 && (
<div className="flex items-center space-x-4">
{Object.entries(config.socialLinks).map(([platform, url]) => (
url && (
<a
key={platform}
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label={platform}
>
{/* Simple text fallback - could be replaced with icons */}
<span className="text-sm capitalize">{platform}</span>
</a>
)
))}
</div>
)}
</div>
</div>
</footer>
);
}
// SEO Meta tags component
function SEOHead({
title,
metaTitle,
metaDescription,
ogImage,
canonicalUrl,
noindex,
}: {
title: string;
metaTitle?: string;
metaDescription?: string;
ogImage?: string;
canonicalUrl?: string;
noindex?: boolean;
}) {
useEffect(() => {
// Set page title
document.title = metaTitle || title;
// Helper to update or create meta tag
const setMeta = (name: string, content: string, property = false) => {
if (!content) return;
const attr = property ? 'property' : 'name';
let tag = document.querySelector(`meta[${attr}="${name}"]`);
if (!tag) {
tag = document.createElement('meta');
tag.setAttribute(attr, name);
document.head.appendChild(tag);
}
tag.setAttribute('content', content);
};
// Meta description
if (metaDescription) {
setMeta('description', metaDescription);
}
// Open Graph tags
setMeta('og:title', metaTitle || title, true);
if (metaDescription) {
setMeta('og:description', metaDescription, true);
}
if (ogImage) {
setMeta('og:image', ogImage, true);
}
// Robots tag
if (noindex) {
setMeta('robots', 'noindex, nofollow');
}
// Canonical URL
if (canonicalUrl) {
let link = document.querySelector('link[rel="canonical"]') as HTMLLinkElement;
if (!link) {
link = document.createElement('link');
link.rel = 'canonical';
document.head.appendChild(link);
}
link.href = canonicalUrl;
}
// Cleanup function - remove tags when component unmounts
return () => {
// We don't remove tags on unmount to avoid flickering
};
}, [title, metaTitle, metaDescription, ogImage, canonicalUrl, noindex]);
return null;
}
export const PublicPage: React.FC = () => {
const { data: pageData, isLoading: pageLoading, error: pageError } = usePublicPage();
const { data: siteConfig, isLoading: configLoading } = usePublicSiteConfig();
const isLoading = pageLoading || configLoading;
// Compute CSS variables from theme
const cssVars = useMemo(() => {
if (siteConfig?.merged_theme) {
return themeToCSSVars(siteConfig.merged_theme);
}
return {};
}, [siteConfig?.merged_theme]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-indigo-600" />
</div>
);
}
if (pageError || !pageData) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Page Not Found
</h1>
<p className="text-gray-600 dark:text-gray-400">
This page doesn't exist or the site is disabled.
</p>
</div>
</div>
);
}
const hideChrome = pageData.hide_chrome;
const header = siteConfig?.header || {};
const footer = siteConfig?.footer || {};
return (
<>
{/* SEO Meta Tags */}
<SEOHead
title={pageData.title}
metaTitle={pageData.meta_title}
metaDescription={pageData.meta_description}
ogImage={pageData.og_image}
canonicalUrl={pageData.canonical_url}
noindex={pageData.noindex}
/>
{/* Apply theme as CSS custom properties */}
<div
className="public-page min-h-screen flex flex-col"
style={cssVars}
>
{/* Header (unless hide_chrome is set) */}
{!hideChrome && <SiteHeader config={header} />}
{/* Page Content */}
<main className="flex-1">
<Render config={renderConfig} data={pageData.puck_data} />
</main>
{/* Footer (unless hide_chrome is set) */}
{!hideChrome && <SiteFooter config={footer} />}
</div>
</>
);
};

View File

@@ -0,0 +1,113 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { BookingWidgetProps } from '../../types';
import BookingWidgetComponent from '../../../components/booking/BookingWidget';
export const BookingWidget: ComponentConfig<BookingWidgetProps> = {
label: 'Booking Widget',
fields: {
serviceMode: {
type: 'select',
label: 'Service Display Mode',
options: [
{ label: 'All Services', value: 'all' },
{ label: 'By Category', value: 'category' },
{ label: 'Specific Services', value: 'specific' },
],
},
categoryId: {
type: 'text',
label: 'Category ID (for category mode)',
},
serviceIds: {
type: 'array',
arrayFields: {
id: { type: 'text', label: 'Service ID' },
},
label: 'Service IDs (for specific mode)',
},
headline: {
type: 'text',
label: 'Headline',
},
subheading: {
type: 'text',
label: 'Subheading',
},
showDuration: {
type: 'radio',
label: 'Show Duration',
options: [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
],
},
showPrice: {
type: 'radio',
label: 'Show Price',
options: [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
],
},
showDeposits: {
type: 'radio',
label: 'Show Deposits',
options: [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
],
},
requireLogin: {
type: 'radio',
label: 'Require Login',
options: [
{ label: 'No', value: false },
{ label: 'Yes', value: true },
],
},
ctaAfterBooking: {
type: 'text',
label: 'CTA After Booking',
},
},
defaultProps: {
serviceMode: 'all',
headline: 'Schedule Your Appointment',
subheading: 'Choose a service and time that works for you',
showDuration: true,
showPrice: true,
showDeposits: true,
requireLogin: false,
},
render: ({
headline,
subheading,
}) => {
// Use the existing BookingWidget component
// Advanced filtering (serviceMode, categoryId, serviceIds) would be
// implemented in the BookingWidget component itself
return (
<div className="py-8">
<div className="text-center mb-8">
{headline && (
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{headline}
</h2>
)}
{subheading && (
<p className="text-lg text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
{subheading}
</p>
)}
</div>
<BookingWidgetComponent
headline=""
subheading=""
/>
</div>
);
},
};
export default BookingWidget;

View File

@@ -0,0 +1,166 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { ServiceCatalogProps } from '../../types';
import { usePublicServices } from '../../../hooks/useBooking';
import { Loader2, Clock, DollarSign } from 'lucide-react';
export const ServiceCatalog: ComponentConfig<ServiceCatalogProps> = {
label: 'Service Catalog',
fields: {
layout: {
type: 'select',
options: [
{ label: 'Cards', value: 'cards' },
{ label: 'List', value: 'list' },
],
},
showCategoryFilter: {
type: 'radio',
label: 'Show Category Filter',
options: [
{ label: 'No', value: false },
{ label: 'Yes', value: true },
],
},
categoryId: {
type: 'text',
label: 'Filter by Category ID (optional)',
},
bookButtonText: {
type: 'text',
label: 'Book Button Text',
},
},
defaultProps: {
layout: 'cards',
showCategoryFilter: false,
bookButtonText: 'Book Now',
},
render: ({ layout, bookButtonText }) => {
return <ServiceCatalogDisplay layout={layout} bookButtonText={bookButtonText} />;
},
};
// Separate component for hooks
function ServiceCatalogDisplay({
layout,
bookButtonText,
}: {
layout: 'cards' | 'list';
bookButtonText: string;
}) {
const { data: services, isLoading, error } = usePublicServices();
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-indigo-600" />
</div>
);
}
if (error) {
return (
<div className="text-center py-12 text-gray-600 dark:text-gray-400">
Unable to load services. Please try again later.
</div>
);
}
if (!services || services.length === 0) {
return (
<div className="text-center py-12 text-gray-600 dark:text-gray-400">
No services available.
</div>
);
}
if (layout === 'list') {
return (
<div className="space-y-4">
{services.map((service: any) => (
<div
key={service.id}
className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"
>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white">
{service.name}
</h3>
{service.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{service.description}
</p>
)}
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500 dark:text-gray-400">
<span className="flex items-center gap-1">
<Clock className="w-4 h-4" />
{service.duration} min
</span>
<span className="flex items-center gap-1">
<DollarSign className="w-4 h-4" />
${(service.price_cents / 100).toFixed(2)}
</span>
</div>
</div>
<a
href={`/book?service=${service.id}`}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors text-sm font-medium"
>
{bookButtonText}
</a>
</div>
))}
</div>
);
}
// Cards layout
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{services.map((service: any) => (
<div
key={service.id}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-lg transition-shadow"
>
{service.photos?.[0] && (
<div className="aspect-video bg-gray-100 dark:bg-gray-700">
<img
src={service.photos[0]}
alt={service.name}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
{service.name}
</h3>
{service.description && (
<p className="text-gray-600 dark:text-gray-400 text-sm mb-4 line-clamp-2">
{service.description}
</p>
)}
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1">
<Clock className="w-4 h-4" />
{service.duration} min
</span>
<span className="text-lg font-bold text-indigo-600 dark:text-indigo-400">
${(service.price_cents / 100).toFixed(2)}
</span>
</div>
<a
href={`/book?service=${service.id}`}
className="block w-full py-2 px-4 bg-indigo-600 text-white text-center rounded-lg hover:bg-indigo-700 transition-colors font-medium"
>
{bookButtonText}
</a>
</div>
</div>
))}
</div>
);
}
export default ServiceCatalog;

View File

@@ -0,0 +1,486 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import { Clock, DollarSign, Image as ImageIcon, Loader2, ArrowRight } from 'lucide-react';
import { usePublicServices, PublicService } from '../../../hooks/useBooking';
export interface ServicesProps {
title: string;
subtitle: string;
layout: '1-column' | '2-columns' | '3-columns';
cardStyle: 'horizontal' | 'vertical';
padding: 'none' | 'small' | 'medium' | 'large' | 'xlarge';
showDuration: boolean;
showPrice: boolean;
showDescription: boolean;
showDeposit: boolean;
buttonText: string;
buttonStyle: 'primary' | 'secondary' | 'outline' | 'link';
categoryFilter: string;
maxServices: number;
}
const LAYOUT_CLASSES = {
'1-column': 'grid-cols-1',
'2-columns': 'grid-cols-1 md:grid-cols-2',
'3-columns': 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
};
const PADDING_CLASSES = {
none: 'p-0',
small: 'p-4',
medium: 'p-8',
large: 'p-12',
xlarge: 'p-16 md:p-20',
};
const BUTTON_STYLES = {
primary: 'bg-indigo-600 text-white hover:bg-indigo-700',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600',
outline: 'border-2 border-indigo-600 text-indigo-600 hover:bg-indigo-50 dark:border-indigo-400 dark:text-indigo-400 dark:hover:bg-indigo-900/20',
link: 'text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 underline-offset-2 hover:underline',
};
// Horizontal card layout (image on left)
function HorizontalServiceCard({
service,
showDuration,
showPrice,
showDescription,
showDeposit,
buttonText,
buttonStyle,
}: {
service: PublicService;
showDuration: boolean;
showPrice: boolean;
showDescription: boolean;
showDeposit: boolean;
buttonText: string;
buttonStyle: keyof typeof BUTTON_STYLES;
}) {
const hasImage = service.photos && service.photos.length > 0;
const priceDisplay = service.price_cents ? (service.price_cents / 100).toFixed(2) : '0.00';
const hasDeposit = service.deposit_amount_cents && service.deposit_amount_cents > 0;
const depositDisplay = hasDeposit ? `$${(service.deposit_amount_cents! / 100).toFixed(2)}` : null;
return (
<div className="relative overflow-hidden rounded-xl border-2 border-gray-200 dark:border-gray-700 hover:border-indigo-300 dark:hover:border-indigo-600 hover:shadow-lg transition-all duration-200 group bg-white dark:bg-gray-800">
<div className="flex h-full min-h-[160px]">
{hasImage && (
<div className="w-1/3 bg-gray-100 dark:bg-gray-700 relative flex-shrink-0">
<img
src={service.photos![0]}
alt={service.name}
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
)}
<div className={`${hasImage ? 'w-2/3' : 'w-full'} p-5 flex flex-col justify-between`}>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors">
{service.name}
</h3>
{showDescription && service.description && (
<p className="mt-1.5 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{service.description}
</p>
)}
</div>
<div className="mt-4 space-y-3">
{/* Duration and Price */}
<div className="flex items-center justify-between text-sm">
{showDuration && (
<div className="flex items-center text-gray-600 dark:text-gray-400">
<Clock className="w-4 h-4 mr-1.5" />
{service.duration} mins
</div>
)}
{showPrice && (
<div className="flex items-center font-semibold text-gray-900 dark:text-white">
<DollarSign className="w-4 h-4" />
{priceDisplay}
</div>
)}
</div>
{/* Deposit info */}
{showDeposit && hasDeposit && depositDisplay && (
<div className="text-xs text-indigo-600 dark:text-indigo-400 font-medium">
Deposit required: {depositDisplay}
</div>
)}
{/* Book button */}
{buttonText && (
<a
href={`/book?service=${service.id}`}
className={`inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${BUTTON_STYLES[buttonStyle]}`}
>
{buttonText}
<ArrowRight className="w-4 h-4" />
</a>
)}
</div>
</div>
</div>
</div>
);
}
// Vertical card layout (image on top)
function VerticalServiceCard({
service,
showDuration,
showPrice,
showDescription,
showDeposit,
buttonText,
buttonStyle,
}: {
service: PublicService;
showDuration: boolean;
showPrice: boolean;
showDescription: boolean;
showDeposit: boolean;
buttonText: string;
buttonStyle: keyof typeof BUTTON_STYLES;
}) {
const hasImage = service.photos && service.photos.length > 0;
const priceDisplay = service.price_cents ? (service.price_cents / 100).toFixed(2) : '0.00';
const hasDeposit = service.deposit_amount_cents && service.deposit_amount_cents > 0;
const depositDisplay = hasDeposit ? `$${(service.deposit_amount_cents! / 100).toFixed(2)}` : null;
return (
<div className="relative overflow-hidden rounded-xl border-2 border-gray-200 dark:border-gray-700 hover:border-indigo-300 dark:hover:border-indigo-600 hover:shadow-lg transition-all duration-200 group bg-white dark:bg-gray-800 flex flex-col">
{/* Image */}
{hasImage ? (
<div className="aspect-[4/3] bg-gray-100 dark:bg-gray-700 relative">
<img
src={service.photos![0]}
alt={service.name}
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
) : (
<div className="aspect-[4/3] bg-gray-100 dark:bg-gray-700 flex items-center justify-center">
<ImageIcon className="w-12 h-12 text-gray-300 dark:text-gray-600" />
</div>
)}
{/* Content */}
<div className="p-5 flex flex-col flex-1">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors">
{service.name}
</h3>
{showDescription && service.description && (
<p className="mt-1.5 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{service.description}
</p>
)}
</div>
<div className="mt-4 space-y-3">
{/* Duration and Price */}
<div className="flex items-center justify-between text-sm">
{showDuration && (
<div className="flex items-center text-gray-600 dark:text-gray-400">
<Clock className="w-4 h-4 mr-1.5" />
{service.duration} mins
</div>
)}
{showPrice && (
<div className="flex items-center font-semibold text-gray-900 dark:text-white">
<DollarSign className="w-4 h-4" />
{priceDisplay}
</div>
)}
</div>
{/* Deposit info */}
{showDeposit && hasDeposit && depositDisplay && (
<div className="text-xs text-indigo-600 dark:text-indigo-400 font-medium">
Deposit required: {depositDisplay}
</div>
)}
{/* Book button */}
{buttonText && (
<a
href={`/book?service=${service.id}`}
className={`inline-flex items-center justify-center gap-2 w-full px-4 py-2 rounded-lg text-sm font-medium transition-colors ${BUTTON_STYLES[buttonStyle]}`}
>
{buttonText}
<ArrowRight className="w-4 h-4" />
</a>
)}
</div>
</div>
</div>
);
}
export const Services: ComponentConfig<ServicesProps> = {
label: 'Services',
fields: {
title: {
type: 'text',
label: 'Title',
},
subtitle: {
type: 'text',
label: 'Subtitle',
},
layout: {
type: 'select',
label: 'Layout',
options: [
{ label: '1 Column', value: '1-column' },
{ label: '2 Columns', value: '2-columns' },
{ label: '3 Columns', value: '3-columns' },
],
},
cardStyle: {
type: 'select',
label: 'Card Style',
options: [
{ label: 'Horizontal (Image Left)', value: 'horizontal' },
{ label: 'Vertical (Image Top)', value: 'vertical' },
],
},
padding: {
type: 'select',
label: 'Padding',
options: [
{ label: 'None', value: 'none' },
{ label: 'Small', value: 'small' },
{ label: 'Medium', value: 'medium' },
{ label: 'Large', value: 'large' },
{ label: 'Extra Large', value: 'xlarge' },
],
},
showDuration: {
type: 'radio',
label: 'Show Duration',
options: [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
],
},
showPrice: {
type: 'radio',
label: 'Show Price',
options: [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
],
},
showDescription: {
type: 'radio',
label: 'Show Description',
options: [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
],
},
showDeposit: {
type: 'radio',
label: 'Show Deposit Info',
options: [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
],
},
buttonText: {
type: 'text',
label: 'Button Text (leave empty to hide)',
},
buttonStyle: {
type: 'select',
label: 'Button Style',
options: [
{ label: 'Primary', value: 'primary' },
{ label: 'Secondary', value: 'secondary' },
{ label: 'Outline', value: 'outline' },
{ label: 'Link', value: 'link' },
],
},
categoryFilter: {
type: 'text',
label: 'Category Filter (optional)',
},
maxServices: {
type: 'number',
label: 'Max Services to Show (0 = all)',
},
},
defaultProps: {
title: 'Our Services',
subtitle: 'Choose from our range of professional services',
layout: '2-columns',
cardStyle: 'horizontal',
padding: 'medium',
showDuration: true,
showPrice: true,
showDescription: true,
showDeposit: true,
buttonText: 'Book Now',
buttonStyle: 'primary',
categoryFilter: '',
maxServices: 0,
},
render: (props) => {
return <ServicesDisplay {...props} />;
},
};
// Separate component that can use hooks
function ServicesDisplay({
title,
subtitle,
layout,
cardStyle,
padding,
showDuration,
showPrice,
showDescription,
showDeposit,
buttonText,
buttonStyle,
categoryFilter,
maxServices,
}: ServicesProps) {
const { data: services, isLoading, error } = usePublicServices();
// Filter and limit services
let displayServices = services || [];
if (categoryFilter && displayServices.length > 0) {
displayServices = displayServices.filter((s) =>
s.name.toLowerCase().includes(categoryFilter.toLowerCase())
);
}
if (maxServices > 0) {
displayServices = displayServices.slice(0, maxServices);
}
const layoutClass = LAYOUT_CLASSES[layout] || LAYOUT_CLASSES['2-columns'];
const paddingClass = PADDING_CLASSES[padding] || PADDING_CLASSES['medium'];
const CardComponent = cardStyle === 'vertical' ? VerticalServiceCard : HorizontalServiceCard;
// Loading state
if (isLoading) {
return (
<div className={paddingClass}>
{(title || subtitle) && (
<div className="text-center mb-8">
{title && (
<h2 className="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white">
{title}
</h2>
)}
{subtitle && (
<p className="mt-2 text-gray-500 dark:text-gray-400">
{subtitle}
</p>
)}
</div>
)}
<div className="flex justify-center items-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-indigo-600" />
</div>
</div>
);
}
// Error state
if (error) {
return (
<div className={paddingClass}>
{(title || subtitle) && (
<div className="text-center mb-8">
{title && (
<h2 className="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white">
{title}
</h2>
)}
{subtitle && (
<p className="mt-2 text-gray-500 dark:text-gray-400">
{subtitle}
</p>
)}
</div>
)}
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
Unable to load services
</div>
</div>
);
}
// Empty state
if (displayServices.length === 0) {
return (
<div className={paddingClass}>
{(title || subtitle) && (
<div className="text-center mb-8">
{title && (
<h2 className="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white">
{title}
</h2>
)}
{subtitle && (
<p className="mt-2 text-gray-500 dark:text-gray-400">
{subtitle}
</p>
)}
</div>
)}
<div className="text-center py-12">
<ImageIcon className="w-16 h-16 mx-auto text-gray-300 dark:text-gray-600 mb-4" />
<p className="text-gray-500 dark:text-gray-400">
No services available at this time.
</p>
</div>
</div>
);
}
return (
<div className={paddingClass}>
{/* Header */}
{(title || subtitle) && (
<div className="text-center mb-10">
{title && (
<h2 className="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white">
{title}
</h2>
)}
{subtitle && (
<p className="mt-2 text-gray-500 dark:text-gray-400 max-w-2xl mx-auto">
{subtitle}
</p>
)}
</div>
)}
{/* Services Grid */}
<div className={`grid ${layoutClass} gap-6`}>
{displayServices.map((service) => (
<CardComponent
key={service.id}
service={service}
showDuration={showDuration}
showPrice={showPrice}
showDescription={showDescription}
showDeposit={showDeposit}
buttonText={buttonText}
buttonStyle={buttonStyle}
/>
))}
</div>
</div>
);
}
export default Services;

View File

@@ -0,0 +1,3 @@
export { BookingWidget } from './BookingWidget';
export { ServiceCatalog } from './ServiceCatalog';
export { Services } from './Services';

View File

@@ -0,0 +1,102 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { BusinessHoursProps } from '../../types';
import { Clock, CheckCircle, XCircle } from 'lucide-react';
const DEFAULT_HOURS = [
{ day: 'Monday', hours: '9:00 AM - 5:00 PM', isOpen: true },
{ day: 'Tuesday', hours: '9:00 AM - 5:00 PM', isOpen: true },
{ day: 'Wednesday', hours: '9:00 AM - 5:00 PM', isOpen: true },
{ day: 'Thursday', hours: '9:00 AM - 5:00 PM', isOpen: true },
{ day: 'Friday', hours: '9:00 AM - 5:00 PM', isOpen: true },
{ day: 'Saturday', hours: '10:00 AM - 2:00 PM', isOpen: true },
{ day: 'Sunday', hours: 'Closed', isOpen: false },
];
export const BusinessHours: ComponentConfig<BusinessHoursProps> = {
label: 'Business Hours',
fields: {
showCurrent: {
type: 'radio',
label: 'Highlight Current Day',
options: [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
],
},
title: {
type: 'text',
label: 'Title',
},
},
defaultProps: {
showCurrent: true,
title: 'Business Hours',
},
render: ({ showCurrent, title }) => {
const today = new Date().toLocaleDateString('en-US', { weekday: 'long' });
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
{title && (
<div className="flex items-center gap-3 mb-6">
<Clock className="w-6 h-6 text-indigo-600 dark:text-indigo-400" />
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">
{title}
</h3>
</div>
)}
<div className="space-y-3">
{DEFAULT_HOURS.map(({ day, hours, isOpen }) => {
const isToday = showCurrent && day === today;
return (
<div
key={day}
className={`flex items-center justify-between py-2 px-3 rounded-lg ${
isToday
? 'bg-indigo-50 dark:bg-indigo-900/20'
: ''
}`}
>
<div className="flex items-center gap-2">
{isOpen ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : (
<XCircle className="w-4 h-4 text-red-400" />
)}
<span
className={`font-medium ${
isToday
? 'text-indigo-600 dark:text-indigo-400'
: 'text-gray-700 dark:text-gray-300'
}`}
>
{day}
{isToday && (
<span className="ml-2 text-xs bg-indigo-600 text-white px-2 py-0.5 rounded-full">
Today
</span>
)}
</span>
</div>
<span
className={`${
isOpen
? 'text-gray-600 dark:text-gray-400'
: 'text-red-500 dark:text-red-400'
}`}
>
{hours}
</span>
</div>
);
})}
</div>
</div>
);
},
};
export default BusinessHours;

View File

@@ -0,0 +1,253 @@
import React, { useState } from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { ContactFormProps } from '../../types';
import { Send, Loader2, CheckCircle } from 'lucide-react';
export const ContactForm: ComponentConfig<ContactFormProps> = {
label: 'Contact Form',
fields: {
fields: {
type: 'array',
arrayFields: {
name: { type: 'text', label: 'Field Name' },
type: {
type: 'select',
options: [
{ label: 'Text', value: 'text' },
{ label: 'Email', value: 'email' },
{ label: 'Phone', value: 'phone' },
{ label: 'Text Area', value: 'textarea' },
],
},
label: { type: 'text' },
required: {
type: 'radio',
label: 'Required',
options: [
{ label: 'No', value: false },
{ label: 'Yes', value: true },
],
},
},
getItemSummary: (item) => item.label || item.name || 'Field',
},
submitButtonText: {
type: 'text',
label: 'Submit Button Text',
},
successMessage: {
type: 'text',
label: 'Success Message',
},
includeConsent: {
type: 'radio',
label: 'Include Consent Checkbox',
options: [
{ label: 'No', value: false },
{ label: 'Yes', value: true },
],
},
consentText: {
type: 'text',
label: 'Consent Text',
},
},
defaultProps: {
fields: [
{ name: 'name', type: 'text', label: 'Your Name', required: true },
{ name: 'email', type: 'email', label: 'Email Address', required: true },
{ name: 'phone', type: 'phone', label: 'Phone Number', required: false },
{ name: 'message', type: 'textarea', label: 'Message', required: true },
],
submitButtonText: 'Send Message',
successMessage: 'Thank you! Your message has been sent.',
includeConsent: true,
consentText: 'I agree to be contacted regarding my inquiry.',
},
render: (props) => {
return <ContactFormDisplay {...props} />;
},
};
// Separate component for state management
function ContactFormDisplay({
fields,
submitButtonText,
successMessage,
includeConsent,
consentText,
}: ContactFormProps) {
const [formData, setFormData] = useState<Record<string, string>>({});
const [consent, setConsent] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
// Honeypot field for spam prevention
const [honeypot, setHoneypot] = useState('');
const validateForm = () => {
const newErrors: Record<string, string> = {};
fields.forEach((field) => {
if (field.required && !formData[field.name]?.trim()) {
newErrors[field.name] = `${field.label} is required`;
}
if (field.type === 'email' && formData[field.name]) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData[field.name])) {
newErrors[field.name] = 'Please enter a valid email address';
}
}
});
if (includeConsent && !consent) {
newErrors.consent = 'You must agree to the terms';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Honeypot check
if (honeypot) {
return;
}
if (!validateForm()) {
return;
}
setIsSubmitting(true);
try {
// TODO: Replace with actual API call
await new Promise((resolve) => setTimeout(resolve, 1000));
setIsSubmitted(true);
} catch (error) {
console.error('Form submission error:', error);
} finally {
setIsSubmitting(false);
}
};
if (isSubmitted) {
return (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-8 text-center">
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-4" />
<p className="text-lg text-green-700 dark:text-green-300">
{successMessage}
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Honeypot field - hidden from users */}
<input
type="text"
name="website"
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
tabIndex={-1}
autoComplete="off"
style={{ position: 'absolute', left: '-9999px' }}
aria-hidden="true"
/>
{fields.map((field) => (
<div key={field.name}>
<label
htmlFor={field.name}
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
{field.label}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
{field.type === 'textarea' ? (
<textarea
id={field.name}
name={field.name}
rows={4}
value={formData[field.name] || ''}
onChange={(e) =>
setFormData({ ...formData, [field.name]: e.target.value })
}
className={`w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${
errors[field.name]
? 'border-red-500'
: 'border-gray-300 dark:border-gray-600'
}`}
/>
) : (
<input
type={field.type}
id={field.name}
name={field.name}
value={formData[field.name] || ''}
onChange={(e) =>
setFormData({ ...formData, [field.name]: e.target.value })
}
className={`w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${
errors[field.name]
? 'border-red-500'
: 'border-gray-300 dark:border-gray-600'
}`}
/>
)}
{errors[field.name] && (
<p className="mt-1 text-sm text-red-500">{errors[field.name]}</p>
)}
</div>
))}
{includeConsent && (
<div className="flex items-start gap-3">
<input
type="checkbox"
id="consent"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
className="mt-1 h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<label
htmlFor="consent"
className="text-sm text-gray-600 dark:text-gray-400"
>
{consentText}
</label>
</div>
)}
{errors.consent && (
<p className="text-sm text-red-500">{errors.consent}</p>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full sm:w-auto px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-medium flex items-center justify-center gap-2 disabled:opacity-50"
>
{isSubmitting ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Sending...
</>
) : (
<>
<Send className="w-5 h-5" />
{submitButtonText}
</>
)}
</button>
</form>
);
}
export default ContactForm;

View File

@@ -0,0 +1,91 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { MapProps } from '../../types';
import { MapPin, AlertTriangle } from 'lucide-react';
// Allowlisted embed domains for security
const ALLOWED_EMBED_DOMAINS = [
'www.google.com/maps/embed',
'maps.google.com',
'www.openstreetmap.org',
];
function isAllowedEmbed(url: string): boolean {
if (!url) return false;
if (!url.startsWith('https://')) return false;
return ALLOWED_EMBED_DOMAINS.some((domain) =>
url.startsWith(`https://${domain}`)
);
}
export const Map: ComponentConfig<MapProps> = {
label: 'Map',
fields: {
embedUrl: {
type: 'text',
label: 'Google Maps Embed URL',
},
height: {
type: 'number',
label: 'Height (px)',
},
},
defaultProps: {
embedUrl: '',
height: 400,
},
render: ({ embedUrl, height }) => {
// Validate embed URL
if (!embedUrl) {
return (
<div
className="bg-gray-100 dark:bg-gray-800 rounded-lg flex flex-col items-center justify-center"
style={{ height: `${height}px` }}
>
<MapPin className="w-12 h-12 text-gray-400 mb-4" />
<p className="text-gray-500 dark:text-gray-400 text-center">
Add a Google Maps embed URL to display a map
</p>
<p className="text-sm text-gray-400 dark:text-gray-500 mt-2 text-center max-w-md">
Go to Google Maps, search for your location, click "Share" "Embed a map" and copy the src URL from the iframe code.
</p>
</div>
);
}
if (!isAllowedEmbed(embedUrl)) {
return (
<div
className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex flex-col items-center justify-center"
style={{ height: `${height}px` }}
>
<AlertTriangle className="w-12 h-12 text-red-400 mb-4" />
<p className="text-red-600 dark:text-red-400 text-center font-medium">
Invalid embed URL
</p>
<p className="text-sm text-red-500 dark:text-red-400/80 mt-2 text-center max-w-md">
Only Google Maps and OpenStreetMap embeds are allowed for security reasons.
</p>
</div>
);
}
return (
<div className="rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">
<iframe
src={embedUrl}
width="100%"
height={height}
style={{ border: 0 }}
allowFullScreen
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
title="Location Map"
/>
</div>
);
},
};
export default Map;

View File

@@ -0,0 +1,3 @@
export { ContactForm } from './ContactForm';
export { BusinessHours } from './BusinessHours';
export { Map } from './Map';

View File

@@ -0,0 +1,78 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { ButtonProps } from '../../types';
const VARIANT_CLASSES = {
primary: 'bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600',
outline: 'border-2 border-indigo-600 text-indigo-600 hover:bg-indigo-50 dark:border-indigo-400 dark:text-indigo-400 dark:hover:bg-indigo-950',
ghost: 'text-indigo-600 hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-950',
};
const SIZE_CLASSES = {
small: 'px-4 py-2 text-sm',
medium: 'px-6 py-3 text-base',
large: 'px-8 py-4 text-lg',
};
export const Button: ComponentConfig<ButtonProps> = {
label: 'Button',
fields: {
text: {
type: 'text',
label: 'Button Text',
},
href: {
type: 'text',
label: 'Link URL',
},
variant: {
type: 'select',
options: [
{ label: 'Primary', value: 'primary' },
{ label: 'Secondary', value: 'secondary' },
{ label: 'Outline', value: 'outline' },
{ label: 'Ghost', value: 'ghost' },
],
},
size: {
type: 'select',
options: [
{ label: 'Small', value: 'small' },
{ label: 'Medium', value: 'medium' },
{ label: 'Large', value: 'large' },
],
},
fullWidth: {
type: 'radio',
label: 'Full Width',
options: [
{ label: 'No', value: false },
{ label: 'Yes', value: true },
],
},
},
defaultProps: {
text: 'Click here',
href: '#',
variant: 'primary',
size: 'medium',
fullWidth: false,
},
render: ({ text, href, variant, size, fullWidth }) => {
const variantClass = VARIANT_CLASSES[variant] || VARIANT_CLASSES.primary;
const sizeClass = SIZE_CLASSES[size] || SIZE_CLASSES.medium;
const widthClass = fullWidth ? 'w-full' : 'inline-block';
return (
<a
href={href}
className={`${variantClass} ${sizeClass} ${widthClass} font-semibold rounded-lg transition-colors duration-200 text-center block`}
>
{text}
</a>
);
},
};
export default Button;

View File

@@ -0,0 +1,88 @@
import React, { useState } from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { FaqProps } from '../../types';
import { ChevronDown } from 'lucide-react';
export const FAQ: ComponentConfig<FaqProps> = {
label: 'FAQ',
fields: {
title: {
type: 'text',
label: 'Section Title',
},
items: {
type: 'array',
arrayFields: {
question: { type: 'text', label: 'Question' },
answer: { type: 'textarea', label: 'Answer' },
},
getItemSummary: (item) => item.question || 'Question',
},
},
defaultProps: {
title: 'Frequently Asked Questions',
items: [
{
question: 'How do I book an appointment?',
answer: 'You can book an appointment by clicking the "Book Now" button and selecting your preferred service and time.',
},
{
question: 'What is your cancellation policy?',
answer: 'You can cancel or reschedule your appointment up to 24 hours before the scheduled time without any charge.',
},
{
question: 'Do you accept walk-ins?',
answer: 'While we accept walk-ins when available, we recommend booking in advance to ensure you get your preferred time slot.',
},
],
},
render: ({ title, items }) => {
return <FaqAccordion title={title} items={items} />;
},
};
// Separate component for state management
function FaqAccordion({ title, items }: { title?: string; items: FaqProps['items'] }) {
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<div className="w-full">
{title && (
<h2 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white mb-8 text-center">
{title}
</h2>
)}
<div className="space-y-4 max-w-3xl mx-auto">
{items.map((item, index) => (
<div
key={index}
className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"
>
<button
onClick={() => setOpenIndex(openIndex === index ? null : index)}
className="w-full flex items-center justify-between p-4 text-left bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
>
<span className="font-medium text-gray-900 dark:text-white pr-4">
{item.question}
</span>
<ChevronDown
className={`w-5 h-5 text-gray-500 transition-transform ${
openIndex === index ? 'rotate-180' : ''
}`}
/>
</button>
{openIndex === index && (
<div className="px-4 pb-4 bg-white dark:bg-gray-800">
<p className="text-gray-600 dark:text-gray-400 whitespace-pre-wrap">
{item.answer}
</p>
</div>
)}
</div>
))}
</div>
</div>
);
}
export default FAQ;

View File

@@ -0,0 +1,65 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { HeadingProps } from '../../types';
const ALIGN_CLASSES = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
};
const HEADING_CLASSES = {
h1: 'text-4xl sm:text-5xl lg:text-6xl font-bold',
h2: 'text-3xl sm:text-4xl font-bold',
h3: 'text-2xl sm:text-3xl font-semibold',
h4: 'text-xl sm:text-2xl font-semibold',
h5: 'text-lg sm:text-xl font-medium',
h6: 'text-base sm:text-lg font-medium',
};
export const Heading: ComponentConfig<HeadingProps> = {
label: 'Heading',
fields: {
text: {
type: 'text',
label: 'Text',
},
level: {
type: 'select',
options: [
{ label: 'H1 - Page Title', value: 'h1' },
{ label: 'H2 - Section Title', value: 'h2' },
{ label: 'H3 - Subsection Title', value: 'h3' },
{ label: 'H4 - Small Title', value: 'h4' },
{ label: 'H5 - Mini Title', value: 'h5' },
{ label: 'H6 - Smallest', value: 'h6' },
],
},
align: {
type: 'radio',
options: [
{ label: 'Left', value: 'left' },
{ label: 'Center', value: 'center' },
{ label: 'Right', value: 'right' },
],
},
},
defaultProps: {
text: 'Heading',
level: 'h2',
align: 'left',
},
render: ({ text, level, align }) => {
const Tag = level as keyof JSX.IntrinsicElements;
const alignClass = ALIGN_CLASSES[align] || ALIGN_CLASSES.left;
const headingClass = HEADING_CLASSES[level] || HEADING_CLASSES.h2;
return (
<Tag className={`${headingClass} ${alignClass} text-gray-900 dark:text-white mb-4`}>
{text}
</Tag>
);
},
};
export default Heading;

View File

@@ -0,0 +1,874 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { IconListProps } from '../../types';
import {
// Checkmarks & Status
Check,
CheckCircle,
CheckCircle2,
CheckSquare,
X,
XCircle,
AlertCircle,
AlertTriangle,
Info,
HelpCircle,
// Stars & Ratings
Star,
Sparkles,
Award,
Trophy,
Medal,
Crown,
ThumbsUp,
ThumbsDown,
// Arrows & Navigation
ArrowRight,
ArrowLeft,
ArrowUp,
ArrowDown,
ChevronRight,
ChevronLeft,
ChevronUp,
ChevronDown,
ChevronsRight,
MoveRight,
ExternalLink,
// Hearts & Emotions
Heart,
HeartHandshake,
Smile,
Frown,
Meh,
Laugh,
// Security & Protection
Shield,
ShieldCheck,
Lock,
Unlock,
Key,
Fingerprint,
Eye,
EyeOff,
// Energy & Power
Zap,
Battery,
BatteryCharging,
Flame,
Lightbulb,
Sun,
Moon,
// Communication
Mail,
MailOpen,
MessageCircle,
MessageSquare,
Phone,
PhoneCall,
Video,
Mic,
Volume2,
Bell,
BellRing,
Send,
Inbox,
// Time & Calendar
Clock,
Timer,
Calendar,
CalendarCheck,
CalendarDays,
Hourglass,
History,
AlarmClock,
// People & Users
User,
Users,
UserPlus,
UserCheck,
UserCircle,
Contact,
PersonStanding,
Baby,
// Business & Commerce
Briefcase,
Building,
Building2,
Store,
ShoppingCart,
ShoppingBag,
CreditCard,
Wallet,
Receipt,
BadgeDollarSign,
DollarSign,
Banknote,
PiggyBank,
TrendingUp,
TrendingDown,
BarChart,
BarChart2,
PieChart,
LineChart,
// Documents & Files
File,
FileText,
FileCheck,
Files,
Folder,
FolderOpen,
Clipboard,
ClipboardCheck,
ClipboardList,
BookOpen,
Book,
Notebook,
// Tools & Settings
Settings,
Wrench,
Hammer,
Cog,
SlidersHorizontal,
Palette,
Paintbrush,
Scissors,
// Technology
Laptop,
Monitor,
Smartphone,
Tablet,
Watch,
Wifi,
Bluetooth,
Signal,
Database,
Server,
Cloud,
CloudDownload,
CloudUpload,
Download,
Upload,
Link,
QrCode,
// Media & Entertainment
Play,
Pause,
Music,
Headphones,
Camera,
Image,
Film,
Tv,
Radio,
Gamepad2,
// Location & Travel
MapPin,
Map,
Navigation,
Compass,
Globe,
Plane,
Car,
Bus,
Train,
Ship,
Bike,
// Nature & Weather
Leaf,
TreePine,
Flower2,
Mountain,
Waves,
Droplet,
Snowflake,
CloudRain,
Wind,
Sunrise,
// Food & Drink
Coffee,
UtensilsCrossed,
Pizza,
Apple,
Cake,
Wine,
Beer,
// Health & Medical
HeartPulse,
Stethoscope,
Pill,
Syringe,
Thermometer,
Activity,
Accessibility,
Brain,
// Home & Lifestyle
Home,
Bed,
Bath,
Sofa,
Lamp,
Tv2,
Refrigerator,
WashingMachine,
// Education
GraduationCap,
BookMarked,
Library,
PenTool,
Pencil,
Eraser,
Ruler,
Calculator,
// Sports & Fitness
Dumbbell,
Target,
Flag,
Timer as Stopwatch,
Footprints,
// Misc
Gift,
Package,
Box,
Archive,
Trash2,
RefreshCw,
RotateCcw,
Maximize,
Minimize,
Plus,
Minus,
Percent,
Hash,
AtSign,
Asterisk,
Command,
Terminal,
Code,
Braces,
GitBranch,
Rocket,
Anchor,
Compass as CompassIcon,
Puzzle,
Layers,
Layout,
Grid,
List,
Menu,
MoreHorizontal,
MoreVertical,
Grip,
} from 'lucide-react';
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
// Checkmarks & Status
check: Check,
'check-circle': CheckCircle,
'check-circle-2': CheckCircle2,
'check-square': CheckSquare,
x: X,
'x-circle': XCircle,
'alert-circle': AlertCircle,
'alert-triangle': AlertTriangle,
info: Info,
'help-circle': HelpCircle,
// Stars & Ratings
star: Star,
sparkles: Sparkles,
award: Award,
trophy: Trophy,
medal: Medal,
crown: Crown,
'thumbs-up': ThumbsUp,
'thumbs-down': ThumbsDown,
// Arrows & Navigation
'arrow-right': ArrowRight,
'arrow-left': ArrowLeft,
'arrow-up': ArrowUp,
'arrow-down': ArrowDown,
'chevron-right': ChevronRight,
'chevron-left': ChevronLeft,
'chevron-up': ChevronUp,
'chevron-down': ChevronDown,
'chevrons-right': ChevronsRight,
'move-right': MoveRight,
'external-link': ExternalLink,
// Hearts & Emotions
heart: Heart,
'heart-handshake': HeartHandshake,
smile: Smile,
frown: Frown,
meh: Meh,
laugh: Laugh,
// Security & Protection
shield: Shield,
'shield-check': ShieldCheck,
lock: Lock,
unlock: Unlock,
key: Key,
fingerprint: Fingerprint,
eye: Eye,
'eye-off': EyeOff,
// Energy & Power
zap: Zap,
battery: Battery,
'battery-charging': BatteryCharging,
flame: Flame,
lightbulb: Lightbulb,
sun: Sun,
moon: Moon,
// Communication
mail: Mail,
'mail-open': MailOpen,
'message-circle': MessageCircle,
'message-square': MessageSquare,
phone: Phone,
'phone-call': PhoneCall,
video: Video,
mic: Mic,
volume: Volume2,
bell: Bell,
'bell-ring': BellRing,
send: Send,
inbox: Inbox,
// Time & Calendar
clock: Clock,
timer: Timer,
calendar: Calendar,
'calendar-check': CalendarCheck,
'calendar-days': CalendarDays,
hourglass: Hourglass,
history: History,
'alarm-clock': AlarmClock,
// People & Users
user: User,
users: Users,
'user-plus': UserPlus,
'user-check': UserCheck,
'user-circle': UserCircle,
contact: Contact,
'person-standing': PersonStanding,
baby: Baby,
// Business & Commerce
briefcase: Briefcase,
building: Building,
'building-2': Building2,
store: Store,
'shopping-cart': ShoppingCart,
'shopping-bag': ShoppingBag,
'credit-card': CreditCard,
wallet: Wallet,
receipt: Receipt,
'badge-dollar': BadgeDollarSign,
dollar: DollarSign,
banknote: Banknote,
'piggy-bank': PiggyBank,
'trending-up': TrendingUp,
'trending-down': TrendingDown,
'bar-chart': BarChart,
'bar-chart-2': BarChart2,
'pie-chart': PieChart,
'line-chart': LineChart,
// Documents & Files
file: File,
'file-text': FileText,
'file-check': FileCheck,
files: Files,
folder: Folder,
'folder-open': FolderOpen,
clipboard: Clipboard,
'clipboard-check': ClipboardCheck,
'clipboard-list': ClipboardList,
'book-open': BookOpen,
book: Book,
notebook: Notebook,
// Tools & Settings
settings: Settings,
wrench: Wrench,
hammer: Hammer,
cog: Cog,
sliders: SlidersHorizontal,
palette: Palette,
paintbrush: Paintbrush,
scissors: Scissors,
// Technology
laptop: Laptop,
monitor: Monitor,
smartphone: Smartphone,
tablet: Tablet,
watch: Watch,
wifi: Wifi,
bluetooth: Bluetooth,
signal: Signal,
database: Database,
server: Server,
cloud: Cloud,
'cloud-download': CloudDownload,
'cloud-upload': CloudUpload,
download: Download,
upload: Upload,
link: Link,
'qr-code': QrCode,
// Media & Entertainment
play: Play,
pause: Pause,
music: Music,
headphones: Headphones,
camera: Camera,
image: Image,
film: Film,
tv: Tv,
radio: Radio,
gamepad: Gamepad2,
// Location & Travel
'map-pin': MapPin,
map: Map,
navigation: Navigation,
compass: Compass,
globe: Globe,
plane: Plane,
car: Car,
bus: Bus,
train: Train,
ship: Ship,
bike: Bike,
// Nature & Weather
leaf: Leaf,
tree: TreePine,
flower: Flower2,
mountain: Mountain,
waves: Waves,
droplet: Droplet,
snowflake: Snowflake,
rain: CloudRain,
wind: Wind,
sunrise: Sunrise,
// Food & Drink
coffee: Coffee,
utensils: UtensilsCrossed,
pizza: Pizza,
apple: Apple,
cake: Cake,
wine: Wine,
beer: Beer,
// Health & Medical
'heart-pulse': HeartPulse,
stethoscope: Stethoscope,
pill: Pill,
syringe: Syringe,
thermometer: Thermometer,
activity: Activity,
accessibility: Accessibility,
brain: Brain,
// Home & Lifestyle
home: Home,
bed: Bed,
bath: Bath,
sofa: Sofa,
lamp: Lamp,
'tv-2': Tv2,
refrigerator: Refrigerator,
'washing-machine': WashingMachine,
// Education
'graduation-cap': GraduationCap,
'book-marked': BookMarked,
library: Library,
'pen-tool': PenTool,
pencil: Pencil,
eraser: Eraser,
ruler: Ruler,
calculator: Calculator,
// Sports & Fitness
dumbbell: Dumbbell,
target: Target,
flag: Flag,
stopwatch: Stopwatch,
footprints: Footprints,
// Misc
gift: Gift,
package: Package,
box: Box,
archive: Archive,
trash: Trash2,
refresh: RefreshCw,
rotate: RotateCcw,
maximize: Maximize,
minimize: Minimize,
plus: Plus,
minus: Minus,
percent: Percent,
hash: Hash,
at: AtSign,
asterisk: Asterisk,
command: Command,
terminal: Terminal,
code: Code,
braces: Braces,
'git-branch': GitBranch,
rocket: Rocket,
anchor: Anchor,
puzzle: Puzzle,
layers: Layers,
layout: Layout,
grid: Grid,
list: List,
menu: Menu,
'more-horizontal': MoreHorizontal,
'more-vertical': MoreVertical,
grip: Grip,
};
// Organized options for the select dropdown
const ICON_OPTIONS = [
// Status & Feedback
{ label: '── Status & Feedback ──', value: '_status', disabled: true },
{ label: '✓ Checkmark', value: 'check' },
{ label: '✓ Check Circle', value: 'check-circle' },
{ label: '✓ Check Circle 2', value: 'check-circle-2' },
{ label: '✓ Check Square', value: 'check-square' },
{ label: '✗ X Mark', value: 'x' },
{ label: '✗ X Circle', value: 'x-circle' },
{ label: '⚠ Alert Circle', value: 'alert-circle' },
{ label: '⚠ Alert Triangle', value: 'alert-triangle' },
{ label: ' Info', value: 'info' },
{ label: '? Help Circle', value: 'help-circle' },
{ label: '👍 Thumbs Up', value: 'thumbs-up' },
{ label: '👎 Thumbs Down', value: 'thumbs-down' },
// Stars & Awards
{ label: '── Stars & Awards ──', value: '_stars', disabled: true },
{ label: '⭐ Star', value: 'star' },
{ label: '✨ Sparkles', value: 'sparkles' },
{ label: '🏆 Award', value: 'award' },
{ label: '🏆 Trophy', value: 'trophy' },
{ label: '🎖 Medal', value: 'medal' },
{ label: '👑 Crown', value: 'crown' },
// Arrows & Navigation
{ label: '── Arrows & Navigation ──', value: '_arrows', disabled: true },
{ label: '→ Arrow Right', value: 'arrow-right' },
{ label: '← Arrow Left', value: 'arrow-left' },
{ label: '↑ Arrow Up', value: 'arrow-up' },
{ label: '↓ Arrow Down', value: 'arrow-down' },
{ label: ' Chevron Right', value: 'chevron-right' },
{ label: ' Chevron Left', value: 'chevron-left' },
{ label: '» Chevrons Right', value: 'chevrons-right' },
{ label: '↗ External Link', value: 'external-link' },
// Hearts & Emotions
{ label: '── Hearts & Emotions ──', value: '_hearts', disabled: true },
{ label: '❤ Heart', value: 'heart' },
{ label: '🤝 Heart Handshake', value: 'heart-handshake' },
{ label: '😊 Smile', value: 'smile' },
{ label: '😄 Laugh', value: 'laugh' },
{ label: '😐 Meh', value: 'meh' },
{ label: '☹ Frown', value: 'frown' },
// Security & Protection
{ label: '── Security & Protection ──', value: '_security', disabled: true },
{ label: '🛡 Shield', value: 'shield' },
{ label: '🛡✓ Shield Check', value: 'shield-check' },
{ label: '🔒 Lock', value: 'lock' },
{ label: '🔓 Unlock', value: 'unlock' },
{ label: '🔑 Key', value: 'key' },
{ label: '👆 Fingerprint', value: 'fingerprint' },
{ label: '👁 Eye', value: 'eye' },
{ label: '👁‍🗨 Eye Off', value: 'eye-off' },
// Energy & Power
{ label: '── Energy & Power ──', value: '_energy', disabled: true },
{ label: '⚡ Lightning', value: 'zap' },
{ label: '🔋 Battery', value: 'battery' },
{ label: '🔌 Battery Charging', value: 'battery-charging' },
{ label: '🔥 Flame', value: 'flame' },
{ label: '💡 Lightbulb', value: 'lightbulb' },
{ label: '☀ Sun', value: 'sun' },
{ label: '🌙 Moon', value: 'moon' },
// Communication
{ label: '── Communication ──', value: '_communication', disabled: true },
{ label: '✉ Mail', value: 'mail' },
{ label: '📬 Mail Open', value: 'mail-open' },
{ label: '💬 Message Circle', value: 'message-circle' },
{ label: '💬 Message Square', value: 'message-square' },
{ label: '📞 Phone', value: 'phone' },
{ label: '📞 Phone Call', value: 'phone-call' },
{ label: '📹 Video', value: 'video' },
{ label: '🎤 Mic', value: 'mic' },
{ label: '🔔 Bell', value: 'bell' },
{ label: '🔔 Bell Ring', value: 'bell-ring' },
{ label: '📤 Send', value: 'send' },
{ label: '📥 Inbox', value: 'inbox' },
// Time & Calendar
{ label: '── Time & Calendar ──', value: '_time', disabled: true },
{ label: '🕐 Clock', value: 'clock' },
{ label: '⏱ Timer', value: 'timer' },
{ label: '📅 Calendar', value: 'calendar' },
{ label: '📅✓ Calendar Check', value: 'calendar-check' },
{ label: '📅 Calendar Days', value: 'calendar-days' },
{ label: '⏳ Hourglass', value: 'hourglass' },
{ label: '🕓 History', value: 'history' },
{ label: '⏰ Alarm Clock', value: 'alarm-clock' },
// People & Users
{ label: '── People & Users ──', value: '_people', disabled: true },
{ label: '👤 User', value: 'user' },
{ label: '👥 Users', value: 'users' },
{ label: '👤+ User Plus', value: 'user-plus' },
{ label: '👤✓ User Check', value: 'user-check' },
{ label: '👤 User Circle', value: 'user-circle' },
{ label: '📇 Contact', value: 'contact' },
{ label: '🧍 Person Standing', value: 'person-standing' },
{ label: '👶 Baby', value: 'baby' },
// Business & Commerce
{ label: '── Business & Commerce ──', value: '_business', disabled: true },
{ label: '💼 Briefcase', value: 'briefcase' },
{ label: '🏢 Building', value: 'building' },
{ label: '🏬 Building 2', value: 'building-2' },
{ label: '🏪 Store', value: 'store' },
{ label: '🛒 Shopping Cart', value: 'shopping-cart' },
{ label: '🛍 Shopping Bag', value: 'shopping-bag' },
{ label: '💳 Credit Card', value: 'credit-card' },
{ label: '👛 Wallet', value: 'wallet' },
{ label: '🧾 Receipt', value: 'receipt' },
{ label: '💲 Dollar', value: 'dollar' },
{ label: '💵 Banknote', value: 'banknote' },
{ label: '🐷 Piggy Bank', value: 'piggy-bank' },
{ label: '📈 Trending Up', value: 'trending-up' },
{ label: '📉 Trending Down', value: 'trending-down' },
{ label: '📊 Bar Chart', value: 'bar-chart' },
{ label: '📊 Pie Chart', value: 'pie-chart' },
{ label: '📈 Line Chart', value: 'line-chart' },
// Documents & Files
{ label: '── Documents & Files ──', value: '_documents', disabled: true },
{ label: '📄 File', value: 'file' },
{ label: '📝 File Text', value: 'file-text' },
{ label: '📄✓ File Check', value: 'file-check' },
{ label: '📁 Folder', value: 'folder' },
{ label: '📂 Folder Open', value: 'folder-open' },
{ label: '📋 Clipboard', value: 'clipboard' },
{ label: '📋✓ Clipboard Check', value: 'clipboard-check' },
{ label: '📋 Clipboard List', value: 'clipboard-list' },
{ label: '📖 Book Open', value: 'book-open' },
{ label: '📕 Book', value: 'book' },
{ label: '📓 Notebook', value: 'notebook' },
// Tools & Settings
{ label: '── Tools & Settings ──', value: '_tools', disabled: true },
{ label: '⚙ Settings', value: 'settings' },
{ label: '🔧 Wrench', value: 'wrench' },
{ label: '🔨 Hammer', value: 'hammer' },
{ label: '⚙ Cog', value: 'cog' },
{ label: '🎚 Sliders', value: 'sliders' },
{ label: '🎨 Palette', value: 'palette' },
{ label: '🖌 Paintbrush', value: 'paintbrush' },
{ label: '✂ Scissors', value: 'scissors' },
// Technology
{ label: '── Technology ──', value: '_technology', disabled: true },
{ label: '💻 Laptop', value: 'laptop' },
{ label: '🖥 Monitor', value: 'monitor' },
{ label: '📱 Smartphone', value: 'smartphone' },
{ label: '📱 Tablet', value: 'tablet' },
{ label: '⌚ Watch', value: 'watch' },
{ label: '📶 WiFi', value: 'wifi' },
{ label: '🔵 Bluetooth', value: 'bluetooth' },
{ label: '📶 Signal', value: 'signal' },
{ label: '🗄 Database', value: 'database' },
{ label: '🖥 Server', value: 'server' },
{ label: '☁ Cloud', value: 'cloud' },
{ label: '☁↓ Cloud Download', value: 'cloud-download' },
{ label: '☁↑ Cloud Upload', value: 'cloud-upload' },
{ label: '⬇ Download', value: 'download' },
{ label: '⬆ Upload', value: 'upload' },
{ label: '🔗 Link', value: 'link' },
{ label: '▣ QR Code', value: 'qr-code' },
// Media & Entertainment
{ label: '── Media & Entertainment ──', value: '_media', disabled: true },
{ label: '▶ Play', value: 'play' },
{ label: '⏸ Pause', value: 'pause' },
{ label: '🎵 Music', value: 'music' },
{ label: '🎧 Headphones', value: 'headphones' },
{ label: '📷 Camera', value: 'camera' },
{ label: '🖼 Image', value: 'image' },
{ label: '🎬 Film', value: 'film' },
{ label: '📺 TV', value: 'tv' },
{ label: '📻 Radio', value: 'radio' },
{ label: '🎮 Gamepad', value: 'gamepad' },
// Location & Travel
{ label: '── Location & Travel ──', value: '_location', disabled: true },
{ label: '📍 Map Pin', value: 'map-pin' },
{ label: '🗺 Map', value: 'map' },
{ label: '🧭 Navigation', value: 'navigation' },
{ label: '🧭 Compass', value: 'compass' },
{ label: '🌍 Globe', value: 'globe' },
{ label: '✈ Plane', value: 'plane' },
{ label: '🚗 Car', value: 'car' },
{ label: '🚌 Bus', value: 'bus' },
{ label: '🚆 Train', value: 'train' },
{ label: '🚢 Ship', value: 'ship' },
{ label: '🚲 Bike', value: 'bike' },
// Nature & Weather
{ label: '── Nature & Weather ──', value: '_nature', disabled: true },
{ label: '🍃 Leaf', value: 'leaf' },
{ label: '🌲 Tree', value: 'tree' },
{ label: '🌸 Flower', value: 'flower' },
{ label: '⛰ Mountain', value: 'mountain' },
{ label: '🌊 Waves', value: 'waves' },
{ label: '💧 Droplet', value: 'droplet' },
{ label: '❄ Snowflake', value: 'snowflake' },
{ label: '🌧 Rain', value: 'rain' },
{ label: '💨 Wind', value: 'wind' },
{ label: '🌅 Sunrise', value: 'sunrise' },
// Food & Drink
{ label: '── Food & Drink ──', value: '_food', disabled: true },
{ label: '☕ Coffee', value: 'coffee' },
{ label: '🍴 Utensils', value: 'utensils' },
{ label: '🍕 Pizza', value: 'pizza' },
{ label: '🍎 Apple', value: 'apple' },
{ label: '🎂 Cake', value: 'cake' },
{ label: '🍷 Wine', value: 'wine' },
{ label: '🍺 Beer', value: 'beer' },
// Health & Medical
{ label: '── Health & Medical ──', value: '_health', disabled: true },
{ label: '💓 Heart Pulse', value: 'heart-pulse' },
{ label: '🩺 Stethoscope', value: 'stethoscope' },
{ label: '💊 Pill', value: 'pill' },
{ label: '💉 Syringe', value: 'syringe' },
{ label: '🌡 Thermometer', value: 'thermometer' },
{ label: '📊 Activity', value: 'activity' },
{ label: '♿ Accessibility', value: 'accessibility' },
{ label: '🧠 Brain', value: 'brain' },
// Home & Lifestyle
{ label: '── Home & Lifestyle ──', value: '_home', disabled: true },
{ label: '🏠 Home', value: 'home' },
{ label: '🛏 Bed', value: 'bed' },
{ label: '🛁 Bath', value: 'bath' },
{ label: '🛋 Sofa', value: 'sofa' },
{ label: '💡 Lamp', value: 'lamp' },
{ label: '📺 TV 2', value: 'tv-2' },
{ label: '🧊 Refrigerator', value: 'refrigerator' },
{ label: '🧺 Washing Machine', value: 'washing-machine' },
// Education
{ label: '── Education ──', value: '_education', disabled: true },
{ label: '🎓 Graduation Cap', value: 'graduation-cap' },
{ label: '📑 Book Marked', value: 'book-marked' },
{ label: '📚 Library', value: 'library' },
{ label: '✒ Pen Tool', value: 'pen-tool' },
{ label: '✏ Pencil', value: 'pencil' },
{ label: '📏 Ruler', value: 'ruler' },
{ label: '🔢 Calculator', value: 'calculator' },
// Sports & Fitness
{ label: '── Sports & Fitness ──', value: '_sports', disabled: true },
{ label: '🏋 Dumbbell', value: 'dumbbell' },
{ label: '🎯 Target', value: 'target' },
{ label: '🚩 Flag', value: 'flag' },
{ label: '⏱ Stopwatch', value: 'stopwatch' },
{ label: '👣 Footprints', value: 'footprints' },
// Misc
{ label: '── Miscellaneous ──', value: '_misc', disabled: true },
{ label: '🎁 Gift', value: 'gift' },
{ label: '📦 Package', value: 'package' },
{ label: '📦 Box', value: 'box' },
{ label: '🗃 Archive', value: 'archive' },
{ label: '🗑 Trash', value: 'trash' },
{ label: '🔄 Refresh', value: 'refresh' },
{ label: '↺ Rotate', value: 'rotate' },
{ label: '⊕ Plus', value: 'plus' },
{ label: '⊖ Minus', value: 'minus' },
{ label: '% Percent', value: 'percent' },
{ label: '# Hash', value: 'hash' },
{ label: '@ At Sign', value: 'at' },
{ label: '🚀 Rocket', value: 'rocket' },
{ label: '⚓ Anchor', value: 'anchor' },
{ label: '🧩 Puzzle', value: 'puzzle' },
{ label: '📚 Layers', value: 'layers' },
{ label: '🖼 Layout', value: 'layout' },
{ label: '⊞ Grid', value: 'grid' },
{ label: '☰ List', value: 'list' },
{ label: '☰ Menu', value: 'menu' },
{ label: '⌨ Terminal', value: 'terminal' },
{ label: '</> Code', value: 'code' },
{ label: '{ } Braces', value: 'braces' },
{ label: '🔀 Git Branch', value: 'git-branch' },
];
const COLUMN_CLASSES = {
1: 'grid-cols-1',
2: 'grid-cols-1 sm:grid-cols-2',
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
};
export const IconList: ComponentConfig<IconListProps> = {
label: 'Icon List',
fields: {
items: {
type: 'array',
arrayFields: {
icon: {
type: 'select',
options: ICON_OPTIONS.filter(opt => !opt.disabled),
},
title: { type: 'text' },
description: { type: 'textarea' },
},
getItemSummary: (item) => item.title || 'Feature',
},
columns: {
type: 'select',
options: [
{ label: '1 Column', value: 1 },
{ label: '2 Columns', value: 2 },
{ label: '3 Columns', value: 3 },
{ label: '4 Columns', value: 4 },
],
},
},
defaultProps: {
items: [
{ icon: 'check', title: 'Feature 1', description: 'Description of feature 1' },
{ icon: 'check', title: 'Feature 2', description: 'Description of feature 2' },
{ icon: 'check', title: 'Feature 3', description: 'Description of feature 3' },
],
columns: 3,
},
render: ({ items, columns }) => {
const columnClass = COLUMN_CLASSES[columns] || COLUMN_CLASSES[3];
return (
<div className={`grid ${columnClass} gap-8`}>
{items.map((item, index) => {
const IconComponent = ICON_MAP[item.icon] || Check;
return (
<div key={index} className="flex flex-col items-center text-center sm:items-start sm:text-left">
<div className="flex-shrink-0 w-12 h-12 bg-indigo-100 dark:bg-indigo-900 rounded-lg flex items-center justify-center mb-4">
<IconComponent className="w-6 h-6 text-indigo-600 dark:text-indigo-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
{item.title}
</h3>
<p className="text-gray-600 dark:text-gray-400">
{item.description}
</p>
</div>
);
})}
</div>
);
},
};
export default IconList;

View File

@@ -0,0 +1,90 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { ImageProps } from '../../types';
const ASPECT_RATIO_CLASSES = {
'16:9': 'aspect-video',
'4:3': 'aspect-[4/3]',
'1:1': 'aspect-square',
'auto': '',
};
const RADIUS_CLASSES = {
none: 'rounded-none',
small: 'rounded-md',
medium: 'rounded-lg',
large: 'rounded-xl',
};
export const Image: ComponentConfig<ImageProps> = {
label: 'Image',
fields: {
src: {
type: 'text',
label: 'Image URL',
},
alt: {
type: 'text',
label: 'Alt Text',
},
caption: {
type: 'text',
label: 'Caption (optional)',
},
aspectRatio: {
type: 'select',
options: [
{ label: 'Auto', value: 'auto' },
{ label: '16:9', value: '16:9' },
{ label: '4:3', value: '4:3' },
{ label: '1:1', value: '1:1' },
],
},
borderRadius: {
type: 'select',
options: [
{ label: 'None', value: 'none' },
{ label: 'Small', value: 'small' },
{ label: 'Medium', value: 'medium' },
{ label: 'Large', value: 'large' },
],
},
},
defaultProps: {
src: '',
alt: '',
aspectRatio: 'auto',
borderRadius: 'medium',
},
render: ({ src, alt, caption, aspectRatio = 'auto', borderRadius = 'medium' }) => {
const aspectClass = ASPECT_RATIO_CLASSES[aspectRatio] || '';
const radiusClass = RADIUS_CLASSES[borderRadius] || RADIUS_CLASSES.medium;
if (!src) {
return (
<div className={`${aspectClass || 'aspect-video'} ${radiusClass} bg-gray-200 dark:bg-gray-700 flex items-center justify-center`}>
<span className="text-gray-500 dark:text-gray-400">No image selected</span>
</div>
);
}
return (
<figure>
<div className={`${aspectClass} ${radiusClass} overflow-hidden`}>
<img
src={src}
alt={alt}
className={`w-full h-full ${aspectClass ? 'object-cover' : ''}`}
/>
</div>
{caption && (
<figcaption className="mt-2 text-sm text-gray-600 dark:text-gray-400 text-center">
{caption}
</figcaption>
)}
</figure>
);
},
};
export default Image;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { RichTextProps } from '../../types';
export const RichText: ComponentConfig<RichTextProps> = {
label: 'Rich Text',
fields: {
content: {
type: 'textarea',
label: 'Content',
},
},
defaultProps: {
content: 'Enter your text here...',
},
render: ({ content }) => {
// Simple text rendering - content is stored as plain text
// For production, this would use a structured JSON format and a safe renderer
return (
<div className="prose prose-lg dark:prose-invert max-w-none">
<p className="text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">
{content}
</p>
</div>
);
},
};
export default RichText;

View File

@@ -0,0 +1,96 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { TestimonialProps } from '../../types';
import { Star, Quote } from 'lucide-react';
export const Testimonial: ComponentConfig<TestimonialProps> = {
label: 'Testimonial',
fields: {
quote: {
type: 'textarea',
label: 'Quote',
},
author: {
type: 'text',
label: 'Author Name',
},
title: {
type: 'text',
label: 'Author Title/Company',
},
avatar: {
type: 'text',
label: 'Avatar URL',
},
rating: {
type: 'select',
options: [
{ label: '5 Stars', value: 5 },
{ label: '4 Stars', value: 4 },
{ label: '3 Stars', value: 3 },
{ label: '2 Stars', value: 2 },
{ label: '1 Star', value: 1 },
],
},
},
defaultProps: {
quote: 'This service has been amazing. I highly recommend it to everyone!',
author: 'John Doe',
title: 'Happy Customer',
rating: 5,
},
render: ({ quote, author, title, avatar, rating }) => {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 sm:p-8">
{/* Quote Icon */}
<Quote className="w-10 h-10 text-indigo-200 dark:text-indigo-800 mb-4" />
{/* Stars */}
{rating && (
<div className="flex gap-1 mb-4">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
className={`w-5 h-5 ${
i < rating
? 'text-yellow-400 fill-yellow-400'
: 'text-gray-300 dark:text-gray-600'
}`}
/>
))}
</div>
)}
{/* Quote Text */}
<blockquote className="text-lg text-gray-700 dark:text-gray-300 mb-6 italic">
"{quote}"
</blockquote>
{/* Author */}
<div className="flex items-center gap-4">
{avatar ? (
<img
src={avatar}
alt={author}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-indigo-100 dark:bg-indigo-900 flex items-center justify-center">
<span className="text-indigo-600 dark:text-indigo-400 font-semibold text-lg">
{author.charAt(0).toUpperCase()}
</span>
</div>
)}
<div>
<p className="font-semibold text-gray-900 dark:text-white">{author}</p>
{title && (
<p className="text-sm text-gray-600 dark:text-gray-400">{title}</p>
)}
</div>
</div>
</div>
);
},
};
export default Testimonial;

View File

@@ -0,0 +1,7 @@
export { Heading } from './Heading';
export { RichText } from './RichText';
export { Image } from './Image';
export { Button } from './Button';
export { IconList } from './IconList';
export { Testimonial } from './Testimonial';
export { FAQ } from './FAQ';

View File

@@ -0,0 +1,84 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import { DropZone } from '@measured/puck';
import type { CardProps } from '../../types';
const RADIUS_CLASSES = {
none: 'rounded-none',
small: 'rounded-md',
medium: 'rounded-lg',
large: 'rounded-xl',
};
const SHADOW_CLASSES = {
none: '',
small: 'shadow-sm',
medium: 'shadow-md',
large: 'shadow-lg',
};
const PADDING_CLASSES = {
none: 'p-0',
small: 'p-4',
medium: 'p-6',
large: 'p-8',
};
export const Card: ComponentConfig<CardProps> = {
label: 'Card',
fields: {
background: {
type: 'text',
label: 'Background Color',
},
borderRadius: {
type: 'select',
options: [
{ label: 'None', value: 'none' },
{ label: 'Small', value: 'small' },
{ label: 'Medium', value: 'medium' },
{ label: 'Large', value: 'large' },
],
},
shadow: {
type: 'select',
options: [
{ label: 'None', value: 'none' },
{ label: 'Small', value: 'small' },
{ label: 'Medium', value: 'medium' },
{ label: 'Large', value: 'large' },
],
},
padding: {
type: 'select',
options: [
{ label: 'None', value: 'none' },
{ label: 'Small', value: 'small' },
{ label: 'Medium', value: 'medium' },
{ label: 'Large', value: 'large' },
],
},
},
defaultProps: {
background: '#ffffff',
borderRadius: 'medium',
shadow: 'medium',
padding: 'medium',
},
render: ({ background, borderRadius, shadow, padding }) => {
const radiusClass = RADIUS_CLASSES[borderRadius] || RADIUS_CLASSES.medium;
const shadowClass = SHADOW_CLASSES[shadow] || SHADOW_CLASSES.medium;
const paddingClass = PADDING_CLASSES[padding] || PADDING_CLASSES.medium;
return (
<div
className={`${radiusClass} ${shadowClass} ${paddingClass} border border-gray-200 dark:border-gray-700`}
style={{ backgroundColor: background }}
>
<DropZone zone="content" />
</div>
);
},
};
export default Card;

View File

@@ -0,0 +1,97 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import { DropZone } from '@measured/puck';
import type { ColumnsProps } from '../../types';
const COLUMN_CONFIGS = {
'2': { count: 2, classes: 'grid-cols-1 md:grid-cols-2' },
'3': { count: 3, classes: 'grid-cols-1 md:grid-cols-3' },
'4': { count: 4, classes: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4' },
'2-1': { count: 2, classes: 'grid-cols-1 md:grid-cols-3', colSpans: ['md:col-span-2', 'md:col-span-1'] },
'1-2': { count: 2, classes: 'grid-cols-1 md:grid-cols-3', colSpans: ['md:col-span-1', 'md:col-span-2'] },
};
const GAP_CLASSES = {
none: 'gap-0',
small: 'gap-4',
medium: 'gap-6',
large: 'gap-8',
};
const ALIGN_CLASSES = {
top: 'items-start',
center: 'items-center',
bottom: 'items-end',
stretch: 'items-stretch',
};
export const Columns: ComponentConfig<ColumnsProps> = {
label: 'Columns',
fields: {
columns: {
type: 'select',
options: [
{ label: '2 Columns', value: '2' },
{ label: '3 Columns', value: '3' },
{ label: '4 Columns', value: '4' },
{ label: '2:1 Ratio', value: '2-1' },
{ label: '1:2 Ratio', value: '1-2' },
],
},
gap: {
type: 'select',
options: [
{ label: 'None', value: 'none' },
{ label: 'Small', value: 'small' },
{ label: 'Medium', value: 'medium' },
{ label: 'Large', value: 'large' },
],
},
verticalAlign: {
type: 'select',
options: [
{ label: 'Top', value: 'top' },
{ label: 'Center', value: 'center' },
{ label: 'Bottom', value: 'bottom' },
{ label: 'Stretch', value: 'stretch' },
],
},
stackOnMobile: {
type: 'radio',
label: 'Stack on Mobile',
options: [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
],
},
},
defaultProps: {
columns: '2',
gap: 'medium',
verticalAlign: 'top',
stackOnMobile: true,
},
render: ({ columns, gap, verticalAlign, stackOnMobile }) => {
const config = COLUMN_CONFIGS[columns] || COLUMN_CONFIGS['2'];
const gapClass = GAP_CLASSES[gap] || GAP_CLASSES.medium;
const alignClass = ALIGN_CLASSES[verticalAlign] || ALIGN_CLASSES.top;
// Generate column elements
const columnElements = Array.from({ length: config.count }).map((_, index) => {
const colSpan = config.colSpans?.[index] || '';
return (
<div key={index} className={colSpan}>
<DropZone zone={`column-${index}`} />
</div>
);
});
return (
<div className={`grid ${config.classes} ${gapClass} ${alignClass}`}>
{columnElements}
</div>
);
},
};
export default Columns;

View File

@@ -0,0 +1,59 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { DividerProps } from '../../types';
const STYLE_CLASSES = {
solid: 'border-solid',
dashed: 'border-dashed',
dotted: 'border-dotted',
};
const THICKNESS_CLASSES = {
thin: 'border-t',
medium: 'border-t-2',
thick: 'border-t-4',
};
export const Divider: ComponentConfig<DividerProps> = {
label: 'Divider',
fields: {
style: {
type: 'select',
options: [
{ label: 'Solid', value: 'solid' },
{ label: 'Dashed', value: 'dashed' },
{ label: 'Dotted', value: 'dotted' },
],
},
color: {
type: 'text',
label: 'Color',
},
thickness: {
type: 'select',
options: [
{ label: 'Thin', value: 'thin' },
{ label: 'Medium', value: 'medium' },
{ label: 'Thick', value: 'thick' },
],
},
},
defaultProps: {
style: 'solid',
color: '',
thickness: 'thin',
},
render: ({ style, color, thickness }) => {
const styleClass = STYLE_CLASSES[style] || STYLE_CLASSES.solid;
const thicknessClass = THICKNESS_CLASSES[thickness] || THICKNESS_CLASSES.thin;
return (
<hr
className={`${styleClass} ${thicknessClass} my-4 ${!color ? 'border-gray-200 dark:border-gray-700' : ''}`}
style={color ? { borderColor: color } : undefined}
/>
);
},
};
export default Divider;

View File

@@ -0,0 +1,158 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import { DropZone } from '@measured/puck';
import type { SectionProps } from '../../types';
const PADDING_CLASSES = {
none: 'py-0',
small: 'py-8',
medium: 'py-16',
large: 'py-24',
xlarge: 'py-32',
};
const CONTAINER_CLASSES = {
narrow: 'max-w-3xl',
default: 'max-w-6xl',
wide: 'max-w-7xl',
full: 'max-w-full px-0',
};
export const Section: ComponentConfig<SectionProps> = {
label: 'Section',
fields: {
background: {
type: 'object',
objectFields: {
type: {
type: 'select',
options: [
{ label: 'None', value: 'none' },
{ label: 'Color', value: 'color' },
{ label: 'Image', value: 'image' },
{ label: 'Gradient', value: 'gradient' },
],
},
value: { type: 'text', label: 'Color / Gradient' },
imageUrl: { type: 'text', label: 'Image URL' },
},
},
overlay: {
type: 'object',
objectFields: {
color: { type: 'text', label: 'Overlay Color' },
opacity: { type: 'number', label: 'Overlay Opacity (0-1)' },
},
},
padding: {
type: 'select',
options: [
{ label: 'None', value: 'none' },
{ label: 'Small', value: 'small' },
{ label: 'Medium', value: 'medium' },
{ label: 'Large', value: 'large' },
{ label: 'Extra Large', value: 'xlarge' },
],
},
containerWidth: {
type: 'select',
options: [
{ label: 'Narrow', value: 'narrow' },
{ label: 'Default', value: 'default' },
{ label: 'Wide', value: 'wide' },
{ label: 'Full Width', value: 'full' },
],
},
anchorId: { type: 'text', label: 'Anchor ID (for navigation)' },
hideOnMobile: {
type: 'radio',
label: 'Hide on Mobile',
options: [
{ label: 'No', value: false },
{ label: 'Yes', value: true },
],
},
hideOnTablet: {
type: 'radio',
label: 'Hide on Tablet',
options: [
{ label: 'No', value: false },
{ label: 'Yes', value: true },
],
},
hideOnDesktop: {
type: 'radio',
label: 'Hide on Desktop',
options: [
{ label: 'No', value: false },
{ label: 'Yes', value: true },
],
},
},
defaultProps: {
background: { type: 'none' },
padding: 'large',
containerWidth: 'default',
hideOnMobile: false,
hideOnTablet: false,
hideOnDesktop: false,
},
render: ({
background,
overlay,
padding,
containerWidth,
anchorId,
hideOnMobile,
hideOnTablet,
hideOnDesktop,
}) => {
const paddingClass = PADDING_CLASSES[padding] || PADDING_CLASSES.large;
const containerClass = CONTAINER_CLASSES[containerWidth] || CONTAINER_CLASSES.default;
// Build background style
let backgroundStyle: React.CSSProperties = {};
if (background.type === 'color' && background.value) {
backgroundStyle.backgroundColor = background.value;
} else if (background.type === 'image' && background.imageUrl) {
backgroundStyle.backgroundImage = `url(${background.imageUrl})`;
backgroundStyle.backgroundSize = 'cover';
backgroundStyle.backgroundPosition = 'center';
} else if (background.type === 'gradient' && background.value) {
backgroundStyle.background = background.value;
}
// Build visibility classes
const visibilityClasses = [
hideOnMobile ? 'hidden sm:block' : '',
hideOnTablet ? 'sm:hidden md:block' : '',
hideOnDesktop ? 'md:hidden' : '',
].filter(Boolean).join(' ');
return (
<section
id={anchorId || undefined}
className={`relative ${paddingClass} ${visibilityClasses}`}
style={backgroundStyle}
>
{/* Overlay */}
{overlay && overlay.color && (
<div
className="absolute inset-0 pointer-events-none"
style={{
backgroundColor: overlay.color,
opacity: overlay.opacity || 0.5,
}}
/>
)}
{/* Content container */}
<div className={`relative ${containerClass} mx-auto px-4 sm:px-6 lg:px-8`}>
<DropZone zone="content" />
</div>
</section>
);
},
};
export default Section;

View File

@@ -0,0 +1,34 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { SpacerProps } from '../../types';
const SIZE_CLASSES = {
small: 'h-4',
medium: 'h-8',
large: 'h-16',
xlarge: 'h-24',
};
export const Spacer: ComponentConfig<SpacerProps> = {
label: 'Spacer',
fields: {
size: {
type: 'select',
options: [
{ label: 'Small', value: 'small' },
{ label: 'Medium', value: 'medium' },
{ label: 'Large', value: 'large' },
{ label: 'Extra Large', value: 'xlarge' },
],
},
},
defaultProps: {
size: 'medium',
},
render: ({ size }) => {
const sizeClass = SIZE_CLASSES[size] || SIZE_CLASSES.medium;
return <div className={sizeClass} aria-hidden="true" />;
},
};
export default Spacer;

View File

@@ -0,0 +1,5 @@
export { Section } from './Section';
export { Columns } from './Columns';
export { Card } from './Card';
export { Spacer } from './Spacer';
export { Divider } from './Divider';

210
frontend/src/puck/config.ts Normal file
View File

@@ -0,0 +1,210 @@
/**
* Main Puck configuration with all components categorized
*/
import type { Config } from '@measured/puck';
import type { ComponentProps } from './types';
// Layout components
import { Section } from './components/layout/Section';
import { Columns } from './components/layout/Columns';
import { Card } from './components/layout/Card';
import { Spacer } from './components/layout/Spacer';
import { Divider } from './components/layout/Divider';
// Content components
import { Heading } from './components/content/Heading';
import { RichText } from './components/content/RichText';
import { Image } from './components/content/Image';
import { Button } from './components/content/Button';
import { IconList } from './components/content/IconList';
import { Testimonial } from './components/content/Testimonial';
import { FAQ } from './components/content/FAQ';
// Booking components
import { BookingWidget } from './components/booking/BookingWidget';
import { ServiceCatalog } from './components/booking/ServiceCatalog';
import { Services } from './components/booking/Services';
// Contact components
import { ContactForm } from './components/contact/ContactForm';
import { BusinessHours } from './components/contact/BusinessHours';
import { Map } from './components/contact/Map';
// Legacy components (for backward compatibility)
import { config as legacyConfig } from '../puckConfig';
// Component categories for the editor palette
export const componentCategories = {
layout: {
title: 'Layout',
components: ['Section', 'Columns', 'Card', 'Spacer', 'Divider'],
},
content: {
title: 'Content',
components: ['Heading', 'RichText', 'Image', 'Button', 'IconList', 'Testimonial', 'FAQ'],
},
booking: {
title: 'Booking',
components: ['BookingWidget', 'ServiceCatalog', 'Services'],
},
contact: {
title: 'Contact',
components: ['ContactForm', 'BusinessHours', 'Map'],
},
legacy: {
title: 'Legacy',
components: ['Hero', 'TextSection', 'Booking'],
},
};
// Full config with all components
export const puckConfig: Config<ComponentProps> = {
categories: {
layout: { title: 'Layout' },
content: { title: 'Content' },
booking: { title: 'Booking' },
contact: { title: 'Contact' },
legacy: { title: 'Legacy', defaultExpanded: false },
},
components: {
// Layout components
Section: {
...Section,
// @ts-expect-error - category assignment
category: 'layout',
},
Columns: {
...Columns,
// @ts-expect-error - category assignment
category: 'layout',
},
Card: {
...Card,
// @ts-expect-error - category assignment
category: 'layout',
},
Spacer: {
...Spacer,
// @ts-expect-error - category assignment
category: 'layout',
},
Divider: {
...Divider,
// @ts-expect-error - category assignment
category: 'layout',
},
// Content components
Heading: {
...Heading,
// @ts-expect-error - category assignment
category: 'content',
},
RichText: {
...RichText,
// @ts-expect-error - category assignment
category: 'content',
},
Image: {
...Image,
// @ts-expect-error - category assignment
category: 'content',
},
Button: {
...Button,
// @ts-expect-error - category assignment
category: 'content',
},
IconList: {
...IconList,
// @ts-expect-error - category assignment
category: 'content',
},
Testimonial: {
...Testimonial,
// @ts-expect-error - category assignment
category: 'content',
},
FAQ: {
...FAQ,
// @ts-expect-error - category assignment
category: 'content',
},
// Booking components
BookingWidget: {
...BookingWidget,
// @ts-expect-error - category assignment
category: 'booking',
},
ServiceCatalog: {
...ServiceCatalog,
// @ts-expect-error - category assignment
category: 'booking',
},
Services: {
...Services,
// @ts-expect-error - category assignment
category: 'booking',
},
// Contact components
ContactForm: {
...ContactForm,
// @ts-expect-error - category assignment
category: 'contact',
},
BusinessHours: {
...BusinessHours,
// @ts-expect-error - category assignment
category: 'contact',
},
Map: {
...Map,
// @ts-expect-error - category assignment
category: 'contact',
},
// Legacy components (for backward compatibility)
Hero: {
...legacyConfig.components.Hero,
// @ts-expect-error - category assignment
category: 'legacy',
},
TextSection: {
...legacyConfig.components.TextSection,
// @ts-expect-error - category assignment
category: 'legacy',
},
Booking: {
...legacyConfig.components.Booking,
// @ts-expect-error - category assignment
category: 'legacy',
},
},
};
// Render-only config (includes all components, no gating)
export const renderConfig = puckConfig;
// Editor config factory (can exclude components based on features)
export function getEditorConfig(features?: {
can_use_contact_form?: boolean;
can_use_service_catalog?: boolean;
}): Config<ComponentProps> {
// Start with full config
const config = { ...puckConfig, components: { ...puckConfig.components } };
// Remove gated components if features not available
if (features?.can_use_contact_form === false) {
delete config.components.ContactForm;
}
if (features?.can_use_service_catalog === false) {
delete config.components.ServiceCatalog;
}
return config;
}
export default puckConfig;

View File

@@ -0,0 +1,52 @@
/**
* Puck Site Builder Module
*
* Exports all Puck-related functionality including:
* - Component configurations
* - Type definitions
* - Editor and render configs
*/
// Main config
export { puckConfig, renderConfig, getEditorConfig, componentCategories } from './config';
// Types
export type {
Theme,
ThemeColors,
ThemeTypography,
ThemeButtons,
ThemeSections,
HeaderConfig,
FooterConfig,
SiteConfig,
PageData,
PuckData,
ComponentProps,
} from './types';
// Layout components
export { Section } from './components/layout';
export { Columns } from './components/layout';
export { Card } from './components/layout';
export { Spacer } from './components/layout';
export { Divider } from './components/layout';
// Content components
export { Heading } from './components/content';
export { RichText } from './components/content';
export { Image } from './components/content';
export { Button } from './components/content';
export { IconList } from './components/content';
export { Testimonial } from './components/content';
export { FAQ } from './components/content';
// Booking components
export { BookingWidget } from './components/booking';
export { ServiceCatalog } from './components/booking';
export { Services } from './components/booking';
// Contact components
export { ContactForm } from './components/contact';
export { BusinessHours } from './components/contact';
export { Map } from './components/contact';

318
frontend/src/puck/types.ts Normal file
View File

@@ -0,0 +1,318 @@
/**
* Puck component and configuration types
*/
// Theme token types
export interface ThemeColors {
primary: string;
secondary: string;
accent: string;
background: string;
surface: string;
text: string;
textMuted: string;
}
export interface ThemeTypography {
fontFamily?: string;
headingFamily?: string;
baseFontSize?: string;
scale?: number;
}
export interface ThemeButtons {
borderRadius?: string;
paddingX?: string;
paddingY?: string;
primaryStyle?: 'solid' | 'outline' | 'ghost';
secondaryStyle?: 'solid' | 'outline' | 'ghost';
}
export interface ThemeSections {
maxWidth?: string;
defaultPadding?: string;
containerPadding?: string;
}
export interface Theme {
colors?: Partial<ThemeColors>;
typography?: ThemeTypography;
buttons?: ThemeButtons;
sections?: ThemeSections;
}
// Header/Footer chrome types
export interface NavigationItem {
label: string;
href: string;
style?: 'link' | 'button';
}
export interface HeaderConfig {
enabled?: boolean;
style?: 'default' | 'transparent' | 'minimal' | 'none';
logoUrl?: string;
businessName?: string;
showNavigation?: boolean;
navigation?: NavigationItem[];
sticky?: boolean;
ctaText?: string;
ctaLink?: string;
}
export interface FooterColumn {
title: string;
links: Array<{ label: string; href: string }>;
}
export interface SocialLinks {
facebook?: string;
instagram?: string;
twitter?: string;
linkedin?: string;
youtube?: string;
}
export interface FooterConfig {
enabled?: boolean;
style?: 'default' | 'minimal' | 'none';
columns?: FooterColumn[];
copyrightText?: string;
socialLinks?: SocialLinks;
}
export interface SiteConfig {
theme: Theme;
header: HeaderConfig;
footer: FooterConfig;
}
// Component prop types
export interface SectionProps {
background: {
type: 'none' | 'color' | 'image' | 'gradient';
value?: string;
imageUrl?: string;
gradientStops?: string[];
};
overlay?: {
color: string;
opacity: number;
};
padding: 'none' | 'small' | 'medium' | 'large' | 'xlarge';
containerWidth: 'narrow' | 'default' | 'wide' | 'full';
anchorId?: string;
hideOnMobile?: boolean;
hideOnTablet?: boolean;
hideOnDesktop?: boolean;
}
export interface ColumnsProps {
columns: '2' | '3' | '4' | '2-1' | '1-2';
gap: 'none' | 'small' | 'medium' | 'large';
verticalAlign: 'top' | 'center' | 'bottom' | 'stretch';
stackOnMobile: boolean;
}
export interface CardProps {
background: string;
borderRadius: 'none' | 'small' | 'medium' | 'large';
shadow: 'none' | 'small' | 'medium' | 'large';
padding: 'none' | 'small' | 'medium' | 'large';
}
export interface SpacerProps {
size: 'small' | 'medium' | 'large' | 'xlarge';
}
export interface DividerProps {
style: 'solid' | 'dashed' | 'dotted';
color?: string;
thickness: 'thin' | 'medium' | 'thick';
}
export interface HeadingProps {
text: string;
level: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
align: 'left' | 'center' | 'right';
}
export interface RichTextProps {
content: string; // Stored as structured JSON, rendered safely
}
export interface ImageProps {
src: string;
alt: string;
caption?: string;
aspectRatio?: '16:9' | '4:3' | '1:1' | 'auto';
borderRadius?: 'none' | 'small' | 'medium' | 'large';
}
export interface ButtonProps {
text: string;
href: string;
variant: 'primary' | 'secondary' | 'outline' | 'ghost';
size: 'small' | 'medium' | 'large';
fullWidth?: boolean;
}
export interface IconListItem {
icon: string;
title: string;
description: string;
}
export interface IconListProps {
items: IconListItem[];
columns: 1 | 2 | 3 | 4;
}
export interface TestimonialProps {
quote: string;
author: string;
title?: string;
avatar?: string;
rating?: 1 | 2 | 3 | 4 | 5;
}
export interface FaqItem {
question: string;
answer: string;
}
export interface FaqProps {
items: FaqItem[];
title?: string;
}
export interface BookingWidgetProps {
serviceMode: 'all' | 'category' | 'specific';
categoryId?: string;
serviceIds?: string[];
headline?: string;
subheading?: string;
showDuration: boolean;
showPrice: boolean;
showDeposits: boolean;
requireLogin: boolean;
ctaAfterBooking?: string;
}
export interface ServiceCatalogProps {
layout: 'cards' | 'list';
showCategoryFilter: boolean;
categoryId?: string;
bookButtonText: string;
}
export interface ServicesProps {
title: string;
subtitle: string;
layout: '1-column' | '2-columns' | '3-columns';
cardStyle: 'horizontal' | 'vertical';
padding: 'none' | 'small' | 'medium' | 'large' | 'xlarge';
showDuration: boolean;
showPrice: boolean;
showDescription: boolean;
showDeposit: boolean;
buttonText: string;
buttonStyle: 'primary' | 'secondary' | 'outline' | 'link';
categoryFilter: string;
maxServices: number;
}
export interface ContactFormProps {
fields: Array<{
name: string;
type: 'text' | 'email' | 'phone' | 'textarea';
label: string;
required: boolean;
}>;
submitButtonText: string;
successMessage: string;
includeConsent: boolean;
consentText?: string;
}
export interface BusinessHoursProps {
showCurrent: boolean;
title?: string;
}
export interface MapProps {
embedUrl: string;
height: number;
}
// Component definitions for Puck config
export type ComponentProps = {
Section: SectionProps;
Columns: ColumnsProps;
Card: CardProps;
Spacer: SpacerProps;
Divider: DividerProps;
Heading: HeadingProps;
RichText: RichTextProps;
Image: ImageProps;
Button: ButtonProps;
IconList: IconListProps;
Testimonial: TestimonialProps;
FAQ: FaqProps;
BookingWidget: BookingWidgetProps;
ServiceCatalog: ServiceCatalogProps;
Services: ServicesProps;
ContactForm: ContactFormProps;
BusinessHours: BusinessHoursProps;
Map: MapProps;
// Legacy components for backward compatibility
Hero: {
title: string;
subtitle: string;
align: 'left' | 'center' | 'right';
ctaText?: string;
ctaLink?: string;
};
TextSection: {
heading: string;
body: string;
};
Booking: {
headline: string;
subheading: string;
};
};
// Puck data structure
export interface PuckData {
content: Array<{
type: keyof ComponentProps;
props: Partial<ComponentProps[keyof ComponentProps]> & { id?: string };
}>;
root: Record<string, unknown>;
zones?: Record<string, Array<{
type: keyof ComponentProps;
props: Partial<ComponentProps[keyof ComponentProps]> & { id?: string };
}>>;
}
// Page data structure
export interface PageData {
id: string;
title: string;
slug: string;
path: string;
is_home: boolean;
is_published: boolean;
puck_data: PuckData;
version: number;
// SEO fields
meta_title?: string;
meta_description?: string;
og_image?: string;
canonical_url?: string;
noindex?: boolean;
// Chrome control
include_in_nav?: boolean;
hide_chrome?: boolean;
}