Add Puck site builder with preview and draft functionality

Frontend:
- Add comprehensive Puck component library (Layout, Content, Booking, Contact)
- Add Services component with usePublicServices hook integration
- Add 150+ icons to IconList component organized by category
- Add preview modal with viewport toggles (desktop/tablet/mobile)
- Add draft save/discard functionality with localStorage persistence
- Add draft status indicator in PageEditor toolbar
- Fix useSites hooks to use correct API URLs (/pages/{id}/)

Backend:
- Add SiteConfig model for theme, header, footer configuration
- Add Page SEO fields (meta_title, meta_description, og_image, etc.)
- Add puck_data validation for component structure
- Add create_missing_sites management command
- Fix PageViewSet to use EntitlementService for permissions
- Add comprehensive tests for site builder functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-13 01:32:11 -05:00
parent 41caccd31a
commit 29bcb27e76
40 changed files with 6626 additions and 45 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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