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:
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
113
frontend/src/puck/components/booking/BookingWidget.tsx
Normal file
113
frontend/src/puck/components/booking/BookingWidget.tsx
Normal 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;
|
||||
166
frontend/src/puck/components/booking/ServiceCatalog.tsx
Normal file
166
frontend/src/puck/components/booking/ServiceCatalog.tsx
Normal 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;
|
||||
486
frontend/src/puck/components/booking/Services.tsx
Normal file
486
frontend/src/puck/components/booking/Services.tsx
Normal 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;
|
||||
3
frontend/src/puck/components/booking/index.ts
Normal file
3
frontend/src/puck/components/booking/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { BookingWidget } from './BookingWidget';
|
||||
export { ServiceCatalog } from './ServiceCatalog';
|
||||
export { Services } from './Services';
|
||||
102
frontend/src/puck/components/contact/BusinessHours.tsx
Normal file
102
frontend/src/puck/components/contact/BusinessHours.tsx
Normal 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;
|
||||
253
frontend/src/puck/components/contact/ContactForm.tsx
Normal file
253
frontend/src/puck/components/contact/ContactForm.tsx
Normal 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;
|
||||
91
frontend/src/puck/components/contact/Map.tsx
Normal file
91
frontend/src/puck/components/contact/Map.tsx
Normal 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;
|
||||
3
frontend/src/puck/components/contact/index.ts
Normal file
3
frontend/src/puck/components/contact/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { ContactForm } from './ContactForm';
|
||||
export { BusinessHours } from './BusinessHours';
|
||||
export { Map } from './Map';
|
||||
78
frontend/src/puck/components/content/Button.tsx
Normal file
78
frontend/src/puck/components/content/Button.tsx
Normal 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;
|
||||
88
frontend/src/puck/components/content/FAQ.tsx
Normal file
88
frontend/src/puck/components/content/FAQ.tsx
Normal 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;
|
||||
65
frontend/src/puck/components/content/Heading.tsx
Normal file
65
frontend/src/puck/components/content/Heading.tsx
Normal 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;
|
||||
874
frontend/src/puck/components/content/IconList.tsx
Normal file
874
frontend/src/puck/components/content/IconList.tsx
Normal 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;
|
||||
90
frontend/src/puck/components/content/Image.tsx
Normal file
90
frontend/src/puck/components/content/Image.tsx
Normal 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;
|
||||
29
frontend/src/puck/components/content/RichText.tsx
Normal file
29
frontend/src/puck/components/content/RichText.tsx
Normal 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;
|
||||
96
frontend/src/puck/components/content/Testimonial.tsx
Normal file
96
frontend/src/puck/components/content/Testimonial.tsx
Normal 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;
|
||||
7
frontend/src/puck/components/content/index.ts
Normal file
7
frontend/src/puck/components/content/index.ts
Normal 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';
|
||||
84
frontend/src/puck/components/layout/Card.tsx
Normal file
84
frontend/src/puck/components/layout/Card.tsx
Normal 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;
|
||||
97
frontend/src/puck/components/layout/Columns.tsx
Normal file
97
frontend/src/puck/components/layout/Columns.tsx
Normal 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;
|
||||
59
frontend/src/puck/components/layout/Divider.tsx
Normal file
59
frontend/src/puck/components/layout/Divider.tsx
Normal 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;
|
||||
158
frontend/src/puck/components/layout/Section.tsx
Normal file
158
frontend/src/puck/components/layout/Section.tsx
Normal 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;
|
||||
34
frontend/src/puck/components/layout/Spacer.tsx
Normal file
34
frontend/src/puck/components/layout/Spacer.tsx
Normal 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;
|
||||
5
frontend/src/puck/components/layout/index.ts
Normal file
5
frontend/src/puck/components/layout/index.ts
Normal 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
210
frontend/src/puck/config.ts
Normal 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;
|
||||
52
frontend/src/puck/index.ts
Normal file
52
frontend/src/puck/index.ts
Normal 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
318
frontend/src/puck/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user