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:
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