Add media gallery with album organization and Puck integration

Backend:
- Add Album and MediaFile models for tenant-scoped media storage
- Add TenantStorageUsage model for per-tenant storage quota tracking
- Create StorageQuotaService with EntitlementService integration
- Add AlbumViewSet, MediaFileViewSet with bulk operations
- Add StorageUsageView for quota monitoring

Frontend:
- Create MediaGalleryPage with album management and file upload
- Add drag-and-drop upload with storage quota validation
- Create ImagePickerField custom Puck field for gallery integration
- Update Image, Testimonial components to use ImagePicker
- Add background image picker to Puck design controls
- Add gallery to sidebar navigation

Also includes:
- Puck marketing components (Hero, SplitContent, etc.)
- Enhanced ContactForm and BusinessHours components
- Platform login page improvements
- Site builder draft/preview enhancements

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-13 19:59:31 -05:00
parent e7733449dd
commit fbefccf436
58 changed files with 11590 additions and 477 deletions

View File

@@ -56,6 +56,7 @@ const TrialExpired = React.lazy(() => import('./pages/TrialExpired'));
const Upgrade = React.lazy(() => import('./pages/Upgrade'));
// Import platform pages
const PlatformLoginPage = React.lazy(() => import('./pages/platform/PlatformLoginPage'));
const PlatformDashboard = React.lazy(() => import('./pages/platform/PlatformDashboard'));
const PlatformBusinesses = React.lazy(() => import('./pages/platform/PlatformBusinesses'));
const PlatformSupportPage = React.lazy(() => import('./pages/platform/PlatformSupport'));
@@ -115,6 +116,7 @@ const PageEditor = React.lazy(() => import('./pages/PageEditor')); // Import Pag
const PublicPage = React.lazy(() => import('./pages/PublicPage')); // Import PublicPage
const BookingFlow = React.lazy(() => import('./pages/BookingFlow')); // Import Booking Flow
const Locations = React.lazy(() => import('./pages/Locations')); // Import Locations management page
const MediaGalleryPage = React.lazy(() => import('./pages/MediaGalleryPage')); // Import Media Gallery page
// Settings pages
const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout'));
@@ -368,7 +370,28 @@ const AppContent: React.FC = () => {
);
}
// For root domain or platform subdomain, show marketing site / login
// For platform subdomain, only /platform/login exists - everything else renders nothing
if (isPlatformSubdomain) {
const path = window.location.pathname;
const allowedPaths = ['/platform/login', '/mfa-verify', '/verify-email'];
// If not an allowed path, render nothing
if (!allowedPaths.includes(path)) {
return null;
}
return (
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route path="/platform/login" element={<PlatformLoginPage />} />
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
<Route path="/verify-email" element={<VerifyEmail />} />
</Routes>
</Suspense>
);
}
// For root domain, show marketing site with business user login
return (
<Suspense fallback={<LoadingScreen />}>
<Routes>
@@ -660,6 +683,13 @@ const AppContent: React.FC = () => {
return (
<Suspense fallback={<LoadingScreen />}>
<Routes>
{/* Public routes outside BusinessLayout */}
<Route path="/" element={<PublicPage />} />
<Route path="/book" element={<BookingFlow />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/sign/:token" element={<ContractSigning />} />
{/* Dashboard routes inside BusinessLayout */}
<Route
element={
<BusinessLayout
@@ -672,9 +702,6 @@ const AppContent: React.FC = () => {
/>
}
>
{/* Redirect root to dashboard */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
{/* Trial and Upgrade Routes */}
<Route path="/dashboard/trial-expired" element={<TrialExpired />} />
<Route path="/dashboard/upgrade" element={<Upgrade />} />
@@ -902,6 +929,16 @@ const AppContent: React.FC = () => {
)
}
/>
<Route
path="/dashboard/gallery"
element={
hasAccess(['owner', 'manager']) ? (
<MediaGalleryPage />
) : (
<Navigate to="/dashboard" />
)
}
/>
{/* Settings Routes with Nested Layout */}
{hasAccess(['owner']) ? (
<Route path="/dashboard/settings" element={<SettingsLayout />}>
@@ -925,8 +962,10 @@ const AppContent: React.FC = () => {
)}
<Route path="/dashboard/profile" element={<ProfileSettings />} />
<Route path="/dashboard/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/dashboard" />} />
</Route>
{/* Catch-all redirects to home */}
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Suspense>
);

260
frontend/src/api/media.ts Normal file
View File

@@ -0,0 +1,260 @@
/**
* Media Gallery API
*
* API client functions for managing media files and albums.
*/
import apiClient from './client';
// ============================================================================
// Types
// ============================================================================
/**
* Album for organizing media files
*/
export interface Album {
id: number;
name: string;
description: string;
cover_image: number | null;
file_count: number;
cover_url: string | null;
created_at: string;
updated_at: string;
}
/**
* Media file (uploaded image)
*/
export interface MediaFile {
id: number;
url: string;
filename: string;
alt_text: string;
file_size: number;
width: number | null;
height: number | null;
mime_type: string;
album: number | null;
album_name: string | null;
created_at: string;
}
/**
* Storage usage statistics
*/
export interface StorageUsage {
bytes_used: number;
bytes_total: number;
file_count: number;
percent_used: number;
used_display: string;
total_display: string;
}
/**
* Album creation/update payload
*/
export interface AlbumPayload {
name: string;
description?: string;
cover_image?: number | null;
}
/**
* Media file update payload (can't change the actual file)
*/
export interface MediaFileUpdatePayload {
alt_text?: string;
album?: number | null;
}
// ============================================================================
// Album API
// ============================================================================
/**
* List all albums
*/
export async function listAlbums(): Promise<Album[]> {
const response = await apiClient.get('/albums/');
return response.data;
}
/**
* Get a single album
*/
export async function getAlbum(id: number): Promise<Album> {
const response = await apiClient.get(`/albums/${id}/`);
return response.data;
}
/**
* Create a new album
*/
export async function createAlbum(data: AlbumPayload): Promise<Album> {
const response = await apiClient.post('/albums/', data);
return response.data;
}
/**
* Update an album
*/
export async function updateAlbum(id: number, data: Partial<AlbumPayload>): Promise<Album> {
const response = await apiClient.patch(`/albums/${id}/`, data);
return response.data;
}
/**
* Delete an album (files are moved to uncategorized)
*/
export async function deleteAlbum(id: number): Promise<void> {
await apiClient.delete(`/albums/${id}/`);
}
// ============================================================================
// Media File API
// ============================================================================
/**
* List media files
* @param albumId - Filter by album ID, 'null' for uncategorized, undefined for all
*/
export async function listMediaFiles(albumId?: number | 'null'): Promise<MediaFile[]> {
const params = albumId !== undefined ? { album: albumId } : {};
const response = await apiClient.get('/media/', { params });
return response.data;
}
/**
* Get a single media file
*/
export async function getMediaFile(id: number): Promise<MediaFile> {
const response = await apiClient.get(`/media/${id}/`);
return response.data;
}
/**
* Upload a new media file
*/
export async function uploadMediaFile(
file: File,
albumId?: number | null,
altText?: string
): Promise<MediaFile> {
const formData = new FormData();
formData.append('file', file);
if (albumId !== undefined && albumId !== null) {
formData.append('album', albumId.toString());
}
if (altText) {
formData.append('alt_text', altText);
}
const response = await apiClient.post('/media/', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
}
/**
* Update a media file (alt text, album assignment)
*/
export async function updateMediaFile(
id: number,
data: MediaFileUpdatePayload
): Promise<MediaFile> {
const response = await apiClient.patch(`/media/${id}/`, data);
return response.data;
}
/**
* Delete a media file
*/
export async function deleteMediaFile(id: number): Promise<void> {
await apiClient.delete(`/media/${id}/`);
}
/**
* Move multiple files to an album
*/
export async function bulkMoveFiles(
fileIds: number[],
albumId: number | null
): Promise<{ updated: number }> {
const response = await apiClient.post('/media/bulk_move/', {
file_ids: fileIds,
album_id: albumId,
});
return response.data;
}
/**
* Delete multiple files
*/
export async function bulkDeleteFiles(fileIds: number[]): Promise<{ deleted: number }> {
const response = await apiClient.post('/media/bulk_delete/', {
file_ids: fileIds,
});
return response.data;
}
// ============================================================================
// Storage Usage API
// ============================================================================
/**
* Get storage usage statistics
*/
export async function getStorageUsage(): Promise<StorageUsage> {
const response = await apiClient.get('/storage-usage/');
return response.data;
}
// ============================================================================
// Utility Functions
// ============================================================================
/**
* Format file size in human-readable format
*/
export function formatFileSize(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
} else if (bytes >= 1024 * 1024) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
} else if (bytes >= 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${bytes} B`;
}
/**
* Check if a file type is allowed
*/
export function isAllowedFileType(file: File): boolean {
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
return allowedTypes.includes(file.type);
}
/**
* Get allowed file types for input accept attribute
*/
export function getAllowedFileTypes(): string {
return 'image/jpeg,image/png,image/gif,image/webp';
}
/**
* Maximum file size in bytes (10 MB)
*/
export const MAX_FILE_SIZE = 10 * 1024 * 1024;
/**
* Check if file size is within limits
*/
export function isFileSizeAllowed(file: File): boolean {
return file.size <= MAX_FILE_SIZE;
}

View File

@@ -10,6 +10,7 @@ export interface TestUser {
role: string;
label: string;
color: string;
category: 'platform' | 'business' | 'customer';
}
const testUsers: TestUser[] = [
@@ -19,6 +20,7 @@ const testUsers: TestUser[] = [
role: 'SUPERUSER',
label: 'Platform Superuser',
color: 'bg-purple-600 hover:bg-purple-700',
category: 'platform',
},
{
email: 'manager@platform.com',
@@ -26,6 +28,7 @@ const testUsers: TestUser[] = [
role: 'PLATFORM_MANAGER',
label: 'Platform Manager',
color: 'bg-blue-600 hover:bg-blue-700',
category: 'platform',
},
{
email: 'sales@platform.com',
@@ -33,6 +36,7 @@ const testUsers: TestUser[] = [
role: 'PLATFORM_SALES',
label: 'Platform Sales',
color: 'bg-green-600 hover:bg-green-700',
category: 'platform',
},
{
email: 'support@platform.com',
@@ -40,6 +44,7 @@ const testUsers: TestUser[] = [
role: 'PLATFORM_SUPPORT',
label: 'Platform Support',
color: 'bg-yellow-600 hover:bg-yellow-700',
category: 'platform',
},
{
email: 'owner@demo.com',
@@ -47,6 +52,7 @@ const testUsers: TestUser[] = [
role: 'TENANT_OWNER',
label: 'Business Owner',
color: 'bg-indigo-600 hover:bg-indigo-700',
category: 'business',
},
{
email: 'manager@demo.com',
@@ -54,6 +60,7 @@ const testUsers: TestUser[] = [
role: 'TENANT_MANAGER',
label: 'Business Manager',
color: 'bg-pink-600 hover:bg-pink-700',
category: 'business',
},
{
email: 'staff@demo.com',
@@ -61,6 +68,7 @@ const testUsers: TestUser[] = [
role: 'TENANT_STAFF',
label: 'Staff Member',
color: 'bg-teal-600 hover:bg-teal-700',
category: 'business',
},
{
email: 'customer@demo.com',
@@ -68,14 +76,18 @@ const testUsers: TestUser[] = [
role: 'CUSTOMER',
label: 'Customer',
color: 'bg-orange-600 hover:bg-orange-700',
category: 'customer',
},
];
type UserFilter = 'all' | 'platform' | 'business';
interface DevQuickLoginProps {
embedded?: boolean;
filter?: UserFilter;
}
export function DevQuickLogin({ embedded = false }: DevQuickLoginProps) {
export function DevQuickLogin({ embedded = false, filter = 'all' }: DevQuickLoginProps) {
const queryClient = useQueryClient();
const [loading, setLoading] = useState<string | null>(null);
const [isMinimized, setIsMinimized] = useState(false);
@@ -85,6 +97,14 @@ export function DevQuickLogin({ embedded = false }: DevQuickLoginProps) {
return null;
}
// Filter users based on the filter prop
const filteredUsers = testUsers.filter((user) => {
if (filter === 'all') return true;
if (filter === 'platform') return user.category === 'platform';
if (filter === 'business') return user.category === 'business';
return true;
});
const handleQuickLogin = async (user: TestUser) => {
setLoading(user.email);
try {
@@ -174,7 +194,7 @@ export function DevQuickLogin({ embedded = false }: DevQuickLoginProps) {
</div>
<div className="grid grid-cols-2 gap-2">
{testUsers.map((user) => (
{filteredUsers.map((user) => (
<button
key={user.email}
onClick={() => handleQuickLogin(user)}

View File

@@ -30,7 +30,7 @@ const routeToHelpPath: Record<string, string> = {
'/plugins': '/help/plugins',
'/plugins/marketplace': '/help/plugins',
'/plugins/my-plugins': '/help/plugins',
'/plugins/create': '/help/plugins/create',
'/plugins/create': '/help/plugins/docs',
'/settings': '/help/settings/general',
'/settings/general': '/help/settings/general',
'/settings/resource-types': '/help/settings/resource-types',

View File

@@ -19,6 +19,7 @@ import {
CalendarOff,
LayoutTemplate,
MapPin,
Image,
} from 'lucide-react';
import { Business, User } from '../types';
import { useLogout } from '../hooks/useAuth';
@@ -161,6 +162,12 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
<SidebarItem
to="/dashboard/gallery"
icon={Image}
label={t('nav.gallery', 'Media Gallery')}
isCollapsed={isCollapsed}
/>
<SidebarItem
to="/dashboard/customers"
icon={Users}

View File

@@ -5,6 +5,7 @@
import React, { createContext, useContext, useEffect, ReactNode } from 'react';
import { useSandboxStatus, useToggleSandbox } from '../hooks/useSandbox';
import { useEntitlements } from '../hooks/useEntitlements';
interface SandboxContextType {
/** Whether the app is currently in sandbox/test mode */
@@ -28,6 +29,10 @@ interface SandboxProviderProps {
export const SandboxProvider: React.FC<SandboxProviderProps> = ({ children }) => {
const { data: status, isLoading } = useSandboxStatus();
const toggleMutation = useToggleSandbox();
const { hasFeature } = useEntitlements();
// Check if tenant has API access - sandbox toggle requires API access
const hasApiAccess = hasFeature('api_access');
const toggleSandbox = async (enableSandbox: boolean) => {
await toggleMutation.mutateAsync(enableSandbox);
@@ -42,7 +47,8 @@ export const SandboxProvider: React.FC<SandboxProviderProps> = ({ children }) =>
const value: SandboxContextType = {
isSandbox: status?.sandbox_mode ?? false,
sandboxEnabled: status?.sandbox_enabled ?? false,
// Only show sandbox toggle if both: sandbox is enabled for business AND tenant has API access
sandboxEnabled: (status?.sandbox_enabled ?? false) && hasApiAccess,
isLoading,
toggleSandbox,
isToggling: toggleMutation.isPending,

View File

@@ -119,6 +119,18 @@ export const useIsAuthenticated = (): boolean => {
return !isLoading && !!user;
};
/**
* Get the redirect path based on user role
* Tenant users go to /dashboard/, platform users go to /
*/
const getRedirectPathForRole = (role: string): string => {
const tenantRoles = ['tenant_owner', 'tenant_manager', 'tenant_staff'];
if (tenantRoles.includes(role)) {
return '/dashboard/';
}
return '/';
};
/**
* Hook to masquerade as another user
*/
@@ -154,6 +166,7 @@ export const useMasquerade = () => {
}
const needsRedirect = targetSubdomain && currentHostname !== `${targetSubdomain}.${baseDomain}`;
const redirectPath = getRedirectPathForRole(user.role);
if (needsRedirect) {
// CRITICAL: Clear the session cookie BEFORE redirect
@@ -170,17 +183,17 @@ export const useMasquerade = () => {
// Pass tokens AND masquerading stack in URL (for cross-domain transfer)
const stackEncoded = encodeURIComponent(JSON.stringify(data.masquerade_stack || []));
const redirectUrl = buildSubdomainUrl(targetSubdomain, `/?access_token=${data.access}&refresh_token=${data.refresh}&masquerade_stack=${stackEncoded}`);
const redirectUrl = buildSubdomainUrl(targetSubdomain, `${redirectPath}?access_token=${data.access}&refresh_token=${data.refresh}&masquerade_stack=${stackEncoded}`);
window.location.href = redirectUrl;
return;
}
// If no redirect needed (same subdomain), we can just set cookies and reload
// If no redirect needed (same subdomain), we can just set cookies and navigate
setCookie('access_token', data.access, 7);
setCookie('refresh_token', data.refresh, 7);
queryClient.setQueryData(['currentUser'], data.user);
window.location.reload();
window.location.href = redirectPath;
},
});
};
@@ -227,6 +240,7 @@ export const useStopMasquerade = () => {
}
const needsRedirect = targetSubdomain && currentHostname !== `${targetSubdomain}.${baseDomain}`;
const redirectPath = getRedirectPathForRole(user.role);
if (needsRedirect) {
// CRITICAL: Clear the session cookie BEFORE redirect
@@ -242,17 +256,17 @@ export const useStopMasquerade = () => {
// Pass tokens AND masquerading stack in URL (for cross-domain transfer)
const stackEncoded = encodeURIComponent(JSON.stringify(data.masquerade_stack || []));
const redirectUrl = buildSubdomainUrl(targetSubdomain, `/?access_token=${data.access}&refresh_token=${data.refresh}&masquerade_stack=${stackEncoded}`);
const redirectUrl = buildSubdomainUrl(targetSubdomain, `${redirectPath}?access_token=${data.access}&refresh_token=${data.refresh}&masquerade_stack=${stackEncoded}`);
window.location.href = redirectUrl;
return;
}
// If no redirect needed (same subdomain), we can just set cookies and reload
// If no redirect needed (same subdomain), we can just set cookies and navigate
setCookie('access_token', data.access, 7);
setCookie('refresh_token', data.refresh, 7);
queryClient.setQueryData(['currentUser'], data.user);
window.location.reload();
window.location.href = redirectPath;
},
});
};

View File

@@ -91,6 +91,10 @@
"subtitle": "Sign in to manage your appointments",
"staffAccess": "Staff Access",
"customerBooking": "Customer Booking"
},
"platformLogin": {
"title": "Platform Staff Login",
"subtitle": "Authorized personnel only"
}
},
"nav": {

View File

@@ -71,8 +71,8 @@ const LoginPage: React.FC = () => {
// Customer users
const isCustomer = user.role === 'customer';
// RULE 1: Platform users cannot login on business subdomains
if (isPlatformUser && isBusinessSubdomain) {
// RULE 1: Platform users cannot login from /login - they must use /platform/login
if (isPlatformUser) {
setError(t('auth.invalidCredentials'));
return;
}
@@ -104,11 +104,8 @@ const LoginPage: React.FC = () => {
// Determine target subdomain for redirect
let targetSubdomain: string | null = null;
// Platform users should be redirected to platform subdomain if not already there
if (isPlatformUser && !isPlatformDomain) {
targetSubdomain = 'platform';
} else if (isBusinessUser && user.business_subdomain && !isBusinessSubdomain) {
// Business users should be on their business subdomain
// Business users should be on their business subdomain
if (isBusinessUser && user.business_subdomain && !isBusinessSubdomain) {
targetSubdomain = user.business_subdomain;
}
@@ -294,7 +291,7 @@ const LoginPage: React.FC = () => {
</div>
{/* Dev Quick Login */}
<DevQuickLogin embedded />
<DevQuickLogin embedded filter="business" />
</div>
</div>
</div>

View File

@@ -0,0 +1,973 @@
/**
* Media Gallery Page
*
* Allows users to upload and manage images organized into albums.
* Includes storage quota tracking and bulk operations.
*/
import React, { useState, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
FolderPlus,
Upload,
Image as ImageIcon,
Trash2,
MoreVertical,
Edit,
FolderOpen,
X,
Check,
Copy,
ExternalLink,
ChevronLeft,
} from 'lucide-react';
import { Modal, FormInput, Button, Alert, FormTextarea } from '../components/ui';
import {
Album,
MediaFile,
StorageUsage,
listAlbums,
listMediaFiles,
getStorageUsage,
createAlbum,
updateAlbum,
deleteAlbum,
uploadMediaFile,
updateMediaFile,
deleteMediaFile,
bulkMoveFiles,
bulkDeleteFiles,
formatFileSize,
isAllowedFileType,
isFileSizeAllowed,
getAllowedFileTypes,
MAX_FILE_SIZE,
} from '../api/media';
// ============================================================================
// Storage Usage Bar Component
// ============================================================================
interface StorageUsageBarProps {
usage: StorageUsage | undefined;
isLoading: boolean;
}
const StorageUsageBar: React.FC<StorageUsageBarProps> = ({ usage, isLoading }) => {
if (isLoading || !usage) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 animate-pulse">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/3 mb-2" />
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
);
}
const percentUsed = usage.percent_used;
const barColor =
percentUsed >= 95
? 'bg-red-500'
: percentUsed >= 80
? 'bg-yellow-500'
: 'bg-primary-500';
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Storage: {usage.used_display} / {usage.total_display}
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">
{usage.file_count} files
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className={`${barColor} h-2 rounded-full transition-all duration-300`}
style={{ width: `${Math.min(percentUsed, 100)}%` }}
/>
</div>
{percentUsed >= 80 && (
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-2">
{percentUsed >= 95
? 'Storage almost full! Delete files or upgrade your plan.'
: 'Storage usage is getting high.'}
</p>
)}
</div>
);
};
// ============================================================================
// Album Card Component
// ============================================================================
interface AlbumCardProps {
album: Album;
isSelected: boolean;
onSelect: () => void;
onEdit: () => void;
onDelete: () => void;
}
const AlbumCard: React.FC<AlbumCardProps> = ({
album,
isSelected,
onSelect,
onEdit,
onDelete,
}) => {
const [showMenu, setShowMenu] = useState(false);
return (
<div
className={`relative group cursor-pointer rounded-lg border-2 transition-all ${
isSelected
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
onClick={onSelect}
>
<div className="aspect-square bg-gray-100 dark:bg-gray-800 rounded-t-lg overflow-hidden">
{album.cover_url ? (
<img
src={album.cover_url}
alt={album.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<FolderOpen className="w-16 h-16 text-gray-400 dark:text-gray-600" />
</div>
)}
</div>
<div className="p-3">
<h3 className="font-medium text-gray-900 dark:text-white truncate">{album.name}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{album.file_count} {album.file_count === 1 ? 'file' : 'files'}
</p>
</div>
{/* Menu button */}
<div className="absolute top-2 right-2">
<button
onClick={(e) => {
e.stopPropagation();
setShowMenu(!showMenu);
}}
className="p-1 rounded-full bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/70"
>
<MoreVertical className="w-4 h-4" />
</button>
{showMenu && (
<div className="absolute right-0 mt-1 w-36 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-10">
<button
onClick={(e) => {
e.stopPropagation();
onEdit();
setShowMenu(false);
}}
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
<Edit className="w-4 h-4" />
Edit
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
setShowMenu(false);
}}
className="w-full px-3 py-2 text-left text-sm text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</div>
)}
</div>
</div>
);
};
// ============================================================================
// Media Thumbnail Component
// ============================================================================
interface MediaThumbnailProps {
file: MediaFile;
isSelected: boolean;
onSelect: () => void;
onPreview: () => void;
}
const MediaThumbnail: React.FC<MediaThumbnailProps> = ({
file,
isSelected,
onSelect,
onPreview,
}) => {
return (
<div
className={`relative group cursor-pointer rounded-lg overflow-hidden border-2 transition-all ${
isSelected
? 'border-primary-500 ring-2 ring-primary-200'
: 'border-transparent hover:border-gray-300 dark:hover:border-gray-600'
}`}
onClick={onPreview}
>
<div className="aspect-square bg-gray-100 dark:bg-gray-800">
<img
src={file.url}
alt={file.alt_text || file.filename}
className="w-full h-full object-cover"
loading="lazy"
/>
</div>
{/* Checkbox for selection */}
<button
onClick={(e) => {
e.stopPropagation();
onSelect();
}}
className={`absolute top-2 left-2 w-6 h-6 rounded border-2 flex items-center justify-center transition-all ${
isSelected
? 'bg-primary-500 border-primary-500 text-white'
: 'bg-white/80 border-gray-300 opacity-0 group-hover:opacity-100'
}`}
>
{isSelected && <Check className="w-4 h-4" />}
</button>
{/* File info overlay */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2 opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-white text-xs truncate">{file.filename}</p>
<p className="text-white/70 text-xs">{formatFileSize(file.file_size)}</p>
</div>
</div>
);
};
// ============================================================================
// Image Preview Modal
// ============================================================================
interface ImagePreviewModalProps {
file: MediaFile | null;
isOpen: boolean;
onClose: () => void;
onUpdate: (id: number, data: { alt_text?: string; album?: number | null }) => void;
onDelete: (id: number) => void;
albums: Album[];
}
const ImagePreviewModal: React.FC<ImagePreviewModalProps> = ({
file,
isOpen,
onClose,
onUpdate,
onDelete,
albums,
}) => {
const [altText, setAltText] = useState(file?.alt_text || '');
const [selectedAlbum, setSelectedAlbum] = useState<number | null>(file?.album || null);
const [copied, setCopied] = useState(false);
React.useEffect(() => {
if (file) {
setAltText(file.alt_text || '');
setSelectedAlbum(file.album);
}
}, [file]);
if (!file) return null;
const handleSave = () => {
onUpdate(file.id, {
alt_text: altText,
album: selectedAlbum,
});
};
const handleCopyUrl = () => {
navigator.clipboard.writeText(file.url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="Image Details" size="lg">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Image preview */}
<div className="bg-gray-100 dark:bg-gray-800 rounded-lg overflow-hidden">
<img
src={file.url}
alt={file.alt_text || file.filename}
className="w-full h-auto max-h-96 object-contain"
/>
</div>
{/* Details form */}
<div className="space-y-4">
<div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Filename
</p>
<p className="text-gray-900 dark:text-white">{file.filename}</p>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-500 dark:text-gray-400">Size</p>
<p className="text-gray-900 dark:text-white">{formatFileSize(file.file_size)}</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400">Dimensions</p>
<p className="text-gray-900 dark:text-white">
{file.width && file.height ? `${file.width} x ${file.height}` : 'Unknown'}
</p>
</div>
</div>
<FormInput
label="Alt Text"
value={altText}
onChange={(e) => setAltText(e.target.value)}
placeholder="Describe this image for accessibility"
/>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Album
</label>
<select
value={selectedAlbum?.toString() || ''}
onChange={(e) => setSelectedAlbum(e.target.value ? Number(e.target.value) : null)}
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-gray-900 dark:text-white"
>
<option value="">No album (Uncategorized)</option>
{albums.map((album) => (
<option key={album.id} value={album.id}>
{album.name}
</option>
))}
</select>
</div>
<div className="flex gap-2">
<button
onClick={handleCopyUrl}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
{copied ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
{copied ? 'Copied!' : 'Copy URL'}
</button>
<a
href={file.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
<ExternalLink className="w-4 h-4" />
</a>
</div>
</div>
</div>
<div className="flex justify-between mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<Button
variant="outline"
onClick={() => {
if (confirm('Are you sure you want to delete this image?')) {
onDelete(file.id);
onClose();
}
}}
className="text-red-600 border-red-300 hover:bg-red-50"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave}>Save Changes</Button>
</div>
</div>
</Modal>
);
};
// ============================================================================
// Album Modal
// ============================================================================
interface AlbumModalProps {
album: Album | null;
isOpen: boolean;
onClose: () => void;
onSave: (data: { name: string; description: string }) => void;
}
const AlbumModal: React.FC<AlbumModalProps> = ({ album, isOpen, onClose, onSave }) => {
const [name, setName] = useState(album?.name || '');
const [description, setDescription] = useState(album?.description || '');
React.useEffect(() => {
if (album) {
setName(album.name);
setDescription(album.description);
} else {
setName('');
setDescription('');
}
}, [album]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (name.trim()) {
onSave({ name: name.trim(), description: description.trim() });
onClose();
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={album ? 'Edit Album' : 'Create Album'}>
<form onSubmit={handleSubmit} className="space-y-4">
<FormInput
label="Album Name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter album name"
required
/>
<FormTextarea
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description"
rows={3}
/>
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit">{album ? 'Save Changes' : 'Create Album'}</Button>
</div>
</form>
</Modal>
);
};
// ============================================================================
// Main Gallery Page Component
// ============================================================================
type ViewMode = 'albums' | 'files';
const MediaGalleryPage: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
// State
const [viewMode, setViewMode] = useState<ViewMode>('albums');
const [currentAlbumId, setCurrentAlbumId] = useState<number | 'null' | null>(null);
const [selectedFiles, setSelectedFiles] = useState<Set<number>>(new Set());
const [previewFile, setPreviewFile] = useState<MediaFile | null>(null);
const [albumModalOpen, setAlbumModalOpen] = useState(false);
const [editingAlbum, setEditingAlbum] = useState<Album | null>(null);
const [error, setError] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
// Queries
const { data: storageUsage, isLoading: usageLoading } = useQuery({
queryKey: ['storageUsage'],
queryFn: getStorageUsage,
});
const { data: albums = [], isLoading: albumsLoading } = useQuery({
queryKey: ['albums'],
queryFn: listAlbums,
});
const { data: files = [], isLoading: filesLoading } = useQuery({
queryKey: ['mediaFiles', currentAlbumId],
queryFn: () => listMediaFiles(currentAlbumId === null ? undefined : currentAlbumId),
enabled: viewMode === 'files',
});
// Mutations
const createAlbumMutation = useMutation({
mutationFn: createAlbum,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['albums'] });
},
onError: (err: Error) => setError(err.message),
});
const updateAlbumMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: { name: string; description: string } }) =>
updateAlbum(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['albums'] });
},
onError: (err: Error) => setError(err.message),
});
const deleteAlbumMutation = useMutation({
mutationFn: deleteAlbum,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['albums'] });
queryClient.invalidateQueries({ queryKey: ['mediaFiles'] });
},
onError: (err: Error) => setError(err.message),
});
const uploadMutation = useMutation({
mutationFn: ({
file,
albumId,
}: {
file: File;
albumId?: number | null;
}) => uploadMediaFile(file, albumId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mediaFiles'] });
queryClient.invalidateQueries({ queryKey: ['storageUsage'] });
queryClient.invalidateQueries({ queryKey: ['albums'] });
},
onError: (err: Error) => setError(err.message),
});
const updateFileMutation = useMutation({
mutationFn: ({
id,
data,
}: {
id: number;
data: { alt_text?: string; album?: number | null };
}) => updateMediaFile(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mediaFiles'] });
queryClient.invalidateQueries({ queryKey: ['albums'] });
},
onError: (err: Error) => setError(err.message),
});
const deleteFileMutation = useMutation({
mutationFn: deleteMediaFile,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mediaFiles'] });
queryClient.invalidateQueries({ queryKey: ['storageUsage'] });
queryClient.invalidateQueries({ queryKey: ['albums'] });
},
onError: (err: Error) => setError(err.message),
});
const bulkDeleteMutation = useMutation({
mutationFn: bulkDeleteFiles,
onSuccess: () => {
setSelectedFiles(new Set());
queryClient.invalidateQueries({ queryKey: ['mediaFiles'] });
queryClient.invalidateQueries({ queryKey: ['storageUsage'] });
queryClient.invalidateQueries({ queryKey: ['albums'] });
},
onError: (err: Error) => setError(err.message),
});
const bulkMoveMutation = useMutation({
mutationFn: ({ fileIds, albumId }: { fileIds: number[]; albumId: number | null }) =>
bulkMoveFiles(fileIds, albumId),
onSuccess: () => {
setSelectedFiles(new Set());
queryClient.invalidateQueries({ queryKey: ['mediaFiles'] });
queryClient.invalidateQueries({ queryKey: ['albums'] });
},
onError: (err: Error) => setError(err.message),
});
// Handlers
const handleFileUpload = useCallback(
async (fileList: FileList | null) => {
if (!fileList || fileList.length === 0) return;
setError(null);
setIsUploading(true);
const filesToUpload = Array.from(fileList);
const errors: string[] = [];
for (const file of filesToUpload) {
// Validate file
if (!isAllowedFileType(file)) {
errors.push(`${file.name}: Invalid file type. Use JPEG, PNG, GIF, or WebP.`);
continue;
}
if (!isFileSizeAllowed(file)) {
errors.push(
`${file.name}: File too large. Maximum size is ${formatFileSize(MAX_FILE_SIZE)}.`
);
continue;
}
try {
await uploadMutation.mutateAsync({
file,
albumId: typeof currentAlbumId === 'number' ? currentAlbumId : null,
});
} catch {
errors.push(`${file.name}: Upload failed.`);
}
}
setIsUploading(false);
if (errors.length > 0) {
setError(errors.join('\n'));
}
},
[currentAlbumId, uploadMutation]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
handleFileUpload(e.dataTransfer.files);
},
[handleFileUpload]
);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
const toggleFileSelection = (fileId: number) => {
setSelectedFiles((prev) => {
const next = new Set(prev);
if (next.has(fileId)) {
next.delete(fileId);
} else {
next.add(fileId);
}
return next;
});
};
const handleAlbumClick = (albumId: number | 'null') => {
setCurrentAlbumId(albumId);
setViewMode('files');
setSelectedFiles(new Set());
};
const handleBackToAlbums = () => {
setViewMode('albums');
setCurrentAlbumId(null);
setSelectedFiles(new Set());
};
const handleDeleteSelectedFiles = () => {
if (selectedFiles.size === 0) return;
if (confirm(`Delete ${selectedFiles.size} selected file(s)?`)) {
bulkDeleteMutation.mutate(Array.from(selectedFiles));
}
};
const handleAlbumSave = (data: { name: string; description: string }) => {
if (editingAlbum) {
updateAlbumMutation.mutate({ id: editingAlbum.id, data });
} else {
createAlbumMutation.mutate(data);
}
setEditingAlbum(null);
setAlbumModalOpen(false);
};
const handleDeleteAlbum = (album: Album) => {
if (
confirm(
`Delete album "${album.name}"? Files will be moved to Uncategorized.`
)
) {
deleteAlbumMutation.mutate(album.id);
}
};
const currentAlbum =
typeof currentAlbumId === 'number'
? albums.find((a) => a.id === currentAlbumId)
: null;
return (
<div className="p-6 max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
{viewMode === 'files' && (
<button
onClick={handleBackToAlbums}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg"
>
<ChevronLeft className="w-5 h-5" />
</button>
)}
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
{viewMode === 'albums'
? t('gallery.title', 'Media Gallery')
: currentAlbum?.name || t('gallery.uncategorized', 'Uncategorized')}
</h1>
{viewMode === 'files' && currentAlbum?.description && (
<p className="text-gray-500 dark:text-gray-400 mt-1">
{currentAlbum.description}
</p>
)}
</div>
</div>
<div className="flex gap-2">
{viewMode === 'albums' && (
<Button
onClick={() => {
setEditingAlbum(null);
setAlbumModalOpen(true);
}}
variant="outline"
>
<FolderPlus className="w-4 h-4 mr-2" />
{t('gallery.newAlbum', 'New Album')}
</Button>
)}
<Button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
<Upload className="w-4 h-4 mr-2" />
{isUploading
? t('gallery.uploading', 'Uploading...')
: t('gallery.upload', 'Upload')}
</Button>
<input
ref={fileInputRef}
type="file"
accept={getAllowedFileTypes()}
multiple
className="hidden"
onChange={(e) => handleFileUpload(e.target.files)}
/>
</div>
</div>
{/* Storage usage */}
<StorageUsageBar usage={storageUsage} isLoading={usageLoading} />
{/* Error message */}
{error && (
<Alert type="error" className="mt-4" onClose={() => setError(null)}>
<pre className="whitespace-pre-wrap text-sm">{error}</pre>
</Alert>
)}
{/* Bulk actions bar */}
{selectedFiles.size > 0 && (
<div className="mt-4 flex items-center gap-4 p-3 bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg">
<span className="text-sm font-medium text-primary-700 dark:text-primary-300">
{selectedFiles.size} selected
</span>
<div className="flex-1" />
<select
onChange={(e) => {
const albumId = e.target.value === '' ? null : Number(e.target.value);
bulkMoveMutation.mutate({
fileIds: Array.from(selectedFiles),
albumId,
});
e.target.value = '';
}}
className="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-2 py-1"
defaultValue=""
>
<option value="" disabled>
Move to album...
</option>
<option value="">Uncategorized</option>
{albums.map((album) => (
<option key={album.id} value={album.id}>
{album.name}
</option>
))}
</select>
<Button
size="sm"
variant="outline"
onClick={() => setSelectedFiles(new Set())}
>
<X className="w-4 h-4 mr-1" />
Clear
</Button>
<Button
size="sm"
variant="outline"
onClick={handleDeleteSelectedFiles}
className="text-red-600 border-red-300 hover:bg-red-50"
>
<Trash2 className="w-4 h-4 mr-1" />
Delete
</Button>
</div>
)}
{/* Content */}
<div
className="mt-6"
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{viewMode === 'albums' ? (
// Albums view
<div>
{/* Quick access buttons */}
<div className="flex gap-2 mb-6">
<button
onClick={() => handleAlbumClick(null as never)}
className="px-4 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg text-sm font-medium"
>
{t('gallery.allFiles', 'All Files')}
</button>
<button
onClick={() => handleAlbumClick('null')}
className="px-4 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg text-sm font-medium"
>
{t('gallery.uncategorized', 'Uncategorized')}
</button>
</div>
{/* Albums grid */}
{albumsLoading ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="aspect-square bg-gray-200 dark:bg-gray-700 rounded-t-lg" />
<div className="p-3">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-2/3 mb-2" />
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/3" />
</div>
</div>
))}
</div>
) : albums.length === 0 ? (
<div className="text-center py-12">
<FolderOpen className="w-16 h-16 mx-auto text-gray-400 dark:text-gray-600 mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{t('gallery.noAlbums', 'No albums yet')}
</h3>
<p className="text-gray-500 dark:text-gray-400 mb-4">
{t('gallery.noAlbumsDesc', 'Create an album to organize your images')}
</p>
<Button
onClick={() => {
setEditingAlbum(null);
setAlbumModalOpen(true);
}}
>
<FolderPlus className="w-4 h-4 mr-2" />
{t('gallery.createFirstAlbum', 'Create First Album')}
</Button>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{albums.map((album) => (
<AlbumCard
key={album.id}
album={album}
isSelected={currentAlbumId === album.id}
onSelect={() => handleAlbumClick(album.id)}
onEdit={() => {
setEditingAlbum(album);
setAlbumModalOpen(true);
}}
onDelete={() => handleDeleteAlbum(album)}
/>
))}
</div>
)}
</div>
) : (
// Files view
<div>
{filesLoading ? (
<div className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{[...Array(12)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="aspect-square bg-gray-200 dark:bg-gray-700 rounded-lg" />
</div>
))}
</div>
) : files.length === 0 ? (
<div
className="text-center py-16 border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-lg"
onDrop={handleDrop}
onDragOver={handleDragOver}
>
<ImageIcon className="w-16 h-16 mx-auto text-gray-400 dark:text-gray-600 mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{t('gallery.noFiles', 'No files here')}
</h3>
<p className="text-gray-500 dark:text-gray-400 mb-4">
{t('gallery.dropFiles', 'Drop files here or click Upload')}
</p>
<Button onClick={() => fileInputRef.current?.click()}>
<Upload className="w-4 h-4 mr-2" />
{t('gallery.upload', 'Upload')}
</Button>
</div>
) : (
<div className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{files.map((file) => (
<MediaThumbnail
key={file.id}
file={file}
isSelected={selectedFiles.has(file.id)}
onSelect={() => toggleFileSelection(file.id)}
onPreview={() => setPreviewFile(file)}
/>
))}
</div>
)}
</div>
)}
</div>
{/* Album modal */}
<AlbumModal
album={editingAlbum}
isOpen={albumModalOpen}
onClose={() => {
setAlbumModalOpen(false);
setEditingAlbum(null);
}}
onSave={handleAlbumSave}
/>
{/* Image preview modal */}
<ImagePreviewModal
file={previewFile}
isOpen={!!previewFile}
onClose={() => setPreviewFile(null)}
onUpdate={(id, data) => {
updateFileMutation.mutate({ id, data });
setPreviewFile(null);
}}
onDelete={(id) => {
deleteFileMutation.mutate(id);
setPreviewFile(null);
}}
albums={albums}
/>
</div>
);
};
export default MediaGalleryPage;

View File

@@ -1,9 +1,9 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Puck, Render } from "@measured/puck";
import "@measured/puck/puck.css";
import { puckConfig, getEditorConfig, renderConfig } from "../puck/config";
import { usePages, useUpdatePage, useCreatePage, useDeletePage } from "../hooks/useSites";
import { Loader2, Plus, Trash2, FileText, Monitor, Tablet, Smartphone, Settings, Eye, X, ExternalLink, Save, RotateCcw } from "lucide-react";
import { Loader2, Plus, Trash2, FileText, Monitor, Tablet, Smartphone, Settings, Eye, X, ExternalLink, Save, RotateCcw, Maximize2, Minimize2 } from "lucide-react";
import toast from 'react-hot-toast';
import { useEntitlements, FEATURE_CODES } from '../hooks/useEntitlements';
@@ -26,6 +26,7 @@ export const PageEditor: React.FC = () => {
const deletePage = useDeletePage();
const [data, setData] = useState<any>(null);
const [currentPageId, setCurrentPageId] = useState<string | null>(null);
const [loadedPageId, setLoadedPageId] = useState<string | null>(null); // Track which page's data is loaded
const [showNewPageModal, setShowNewPageModal] = useState(false);
const [newPageTitle, setNewPageTitle] = useState('');
const [viewport, setViewport] = useState<ViewportSize>('desktop');
@@ -33,6 +34,7 @@ export const PageEditor: React.FC = () => {
const [showPreview, setShowPreview] = useState(false);
const [previewViewport, setPreviewViewport] = useState<ViewportSize>('desktop');
const [previewData, setPreviewData] = useState<any>(null);
const [previewFullWidth, setPreviewFullWidth] = useState(false);
const [hasDraft, setHasDraft] = useState(false);
const [publishedData, setPublishedData] = useState<any>(null);
@@ -56,46 +58,87 @@ export const PageEditor: React.FC = () => {
// Feature-gated components
const features = {
can_use_contact_form: hasFeature('can_use_contact_form'),
can_use_service_catalog: hasFeature('can_use_service_catalog'),
};
// Get editor config with feature gating
const editorConfig = getEditorConfig(features);
const currentPage = pages?.find((p: any) => p.id === currentPageId) || pages?.find((p: any) => p.is_home) || pages?.[0];
// Compute current page - prioritize explicit selection, then home page, then first page
const currentPage = useMemo(() => {
if (!pages?.length) return null;
if (currentPageId) {
const found = pages.find((p: any) => String(p.id) === String(currentPageId));
if (found) return found;
}
return pages.find((p: any) => p.is_home) || pages[0];
}, [pages, currentPageId]);
// Load page data when currentPageId changes (use ID, not object reference)
useEffect(() => {
if (currentPage) {
// Ensure data structure is valid for Puck
const puckData = currentPage.puck_data || { content: [], root: {} };
if (!puckData.content) puckData.content = [];
if (!puckData.root) puckData.root = {};
if (!pages?.length || !currentPageId) return;
// Store the published data for comparison
setPublishedData(puckData);
const page = pages.find((p: any) => String(p.id) === String(currentPageId));
if (!page) return;
// Check for saved draft
const draftKey = getDraftKey(currentPage.id);
const savedDraft = localStorage.getItem(draftKey);
// Clear data first to prevent stale data being used
// Only clear if we're loading a different page
if (loadedPageId !== String(page.id)) {
setData(null);
}
if (savedDraft) {
try {
const draftData = JSON.parse(savedDraft);
setData(draftData);
setHasDraft(true);
} catch (e) {
// Invalid draft data, use published
setData(puckData);
setHasDraft(false);
localStorage.removeItem(draftKey);
}
} else {
// Ensure data structure is valid for Puck
const puckData = page.puck_data || { content: [], root: {} };
if (!puckData.content) puckData.content = [];
if (!puckData.root) puckData.root = {};
// Store the published data for comparison
setPublishedData(puckData);
// Check for saved draft
const draftKey = getDraftKey(page.id);
const savedDraft = localStorage.getItem(draftKey);
if (savedDraft) {
try {
const draftData = JSON.parse(savedDraft);
setData(draftData);
setHasDraft(true);
} catch (e) {
// Invalid draft data, use published
setData(puckData);
setHasDraft(false);
localStorage.removeItem(draftKey);
}
} else {
setData(puckData);
setHasDraft(false);
}
// Mark which page's data is now loaded
setLoadedPageId(String(page.id));
}, [pages, currentPageId, getDraftKey]);
// Set initial page ID when pages load
useEffect(() => {
if (pages?.length && !currentPageId) {
const homePage = pages.find((p: any) => p.is_home);
const initialPage = homePage || pages[0];
if (initialPage) {
setCurrentPageId(String(initialPage.id));
}
}
}, [currentPage, getDraftKey]);
}, [pages, currentPageId]);
// Handle page change - clear data immediately to prevent stale state
const handlePageChange = useCallback((newPageId: string) => {
if (newPageId !== currentPageId) {
// Clear data immediately so Puck doesn't render with wrong data
setData(null);
setLoadedPageId(null);
setCurrentPageId(newPageId);
}
}, [currentPageId]);
const handlePublish = async (newData: any) => {
if (!currentPage) return;
@@ -164,7 +207,12 @@ export const PageEditor: React.FC = () => {
toast.success(`Page "${newPageTitle}" created!`);
setNewPageTitle('');
setShowNewPageModal(false);
setCurrentPageId(newPage.id);
// Set empty data immediately so the new page starts blank
const emptyData = { content: [], root: {} };
setData(emptyData);
setPublishedData(emptyData);
setHasDraft(false);
setCurrentPageId(String(newPage.id));
} catch (error: any) {
const errorMsg = error?.response?.data?.error || "Failed to create page";
toast.error(errorMsg);
@@ -215,7 +263,8 @@ export const PageEditor: React.FC = () => {
return <div>No page found. Please contact support.</div>;
}
if (!data) return null;
// Wait for data to be loaded for the current page
const isDataReady = data && loadedPageId === String(currentPage.id);
// Display max pages as string for UI (null = unlimited shown as ∞)
const maxPagesDisplay = maxPagesLimit === null ? '∞' : maxPagesLimit;
@@ -252,7 +301,7 @@ export const PageEditor: React.FC = () => {
<FileText size={20} className="text-indigo-600" />
<select
value={currentPageId || currentPage.id}
onChange={(e) => setCurrentPageId(e.target.value)}
onChange={(e) => handlePageChange(e.target.value)}
className="px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-indigo-500"
>
{pages?.map((page: any) => (
@@ -498,6 +547,18 @@ export const PageEditor: React.FC = () => {
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setPreviewFullWidth(!previewFullWidth)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm ${
previewFullWidth
? 'bg-indigo-600 text-white hover:bg-indigo-700'
: 'text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
title={previewFullWidth ? "Show framed preview" : "Show full-width preview"}
>
{previewFullWidth ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
{previewFullWidth ? "Framed" : "Full Width"}
</button>
<button
onClick={handlePreviewNewTab}
className="flex items-center gap-1.5 px-3 py-1.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 text-sm"
@@ -517,11 +578,14 @@ export const PageEditor: React.FC = () => {
</div>
{/* Preview Content */}
<div className="flex-1 overflow-auto bg-gray-100 dark:bg-gray-900 p-4">
<div className={`flex-1 overflow-auto ${previewFullWidth ? 'bg-white dark:bg-gray-800' : 'bg-gray-100 dark:bg-gray-900 p-4'}`}>
<div
className="mx-auto bg-white dark:bg-gray-800 min-h-full shadow-xl rounded-lg overflow-hidden"
className={previewFullWidth
? 'min-h-full'
: 'mx-auto bg-white dark:bg-gray-800 min-h-full shadow-xl rounded-lg overflow-hidden'
}
style={{
width: VIEWPORT_WIDTHS[previewViewport] || '100%',
width: previewFullWidth ? '100%' : (VIEWPORT_WIDTHS[previewViewport] || '100%'),
maxWidth: '100%',
transition: 'width 0.3s ease-in-out',
}}
@@ -534,12 +598,22 @@ export const PageEditor: React.FC = () => {
{/* Puck Editor with viewport width */}
<div className="flex-1 overflow-hidden" style={iframeStyle}>
<Puck
config={editorConfig}
data={data}
onPublish={handlePublish}
onChange={handleDataChange}
/>
{isDataReady ? (
<Puck
key={`puck-${loadedPageId}`}
config={editorConfig}
data={data}
onPublish={handlePublish}
onChange={handleDataChange}
/>
) : (
<div className="flex items-center justify-center h-full bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin text-indigo-600 mx-auto mb-2" />
<p className="text-gray-600 dark:text-gray-400">Loading page...</p>
</div>
</div>
)}
</div>
</div>
);

View File

@@ -0,0 +1,178 @@
/**
* Platform Login Page Component
* Secure login page exclusively for platform staff (superuser, platform_manager, platform_support)
* This page is intentionally not linked publicly - platform staff must know the URL
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLogin } from '../../hooks/useAuth';
import { useNavigate } from 'react-router-dom';
import { AlertCircle, Loader2, Mail, Lock, ArrowRight, Shield } from 'lucide-react';
import { DevQuickLogin } from '../../components/DevQuickLogin';
const PlatformLoginPage: React.FC = () => {
const { t } = useTranslation();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const navigate = useNavigate();
const loginMutation = useLogin();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
loginMutation.mutate(
{ email, password },
{
onSuccess: (data) => {
// Check if MFA is required
if (data.mfa_required) {
sessionStorage.setItem('mfa_challenge', JSON.stringify({
user_id: data.user_id,
mfa_methods: data.mfa_methods,
phone_last_4: data.phone_last_4,
}));
navigate('/mfa-verify');
return;
}
const user = data.user!;
// Platform login ONLY allows platform users
const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role);
if (!isPlatformUser) {
// Non-platform users cannot login here
setError(t('auth.invalidCredentials'));
return;
}
// Platform user logged in successfully - go to dashboard
navigate('/platform/dashboard');
},
onError: (err: any) => {
setError(err.response?.data?.error || t('auth.invalidCredentials'));
},
}
);
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900 px-4 py-12">
<div className="w-full max-w-md">
{/* Platform Badge */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-brand-600/10 mb-4">
<Shield className="w-8 h-8 text-brand-500" />
</div>
<h1 className="text-2xl font-bold text-white tracking-tight">
{t('auth.platformLogin.title')}
</h1>
<p className="mt-2 text-sm text-gray-400">
{t('auth.platformLogin.subtitle')}
</p>
</div>
{/* Login Card */}
<div className="bg-gray-800 rounded-xl shadow-xl border border-gray-700 p-8">
{error && (
<div className="mb-6 rounded-lg bg-red-900/30 p-4 border border-red-800/50 animate-in fade-in slide-in-from-top-2">
<div className="flex">
<div className="flex-shrink-0">
<AlertCircle className="h-5 w-5 text-red-400" aria-hidden="true" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-200">
{t('auth.authError')}
</h3>
<div className="mt-1 text-sm text-red-300">
{error}
</div>
</div>
</div>
</div>
)}
<form className="space-y-6" onSubmit={handleSubmit}>
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-1">
{t('auth.email')}
</label>
<div className="relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-gray-500" aria-hidden="true" />
</div>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="focus:ring-brand-500 focus:border-brand-500 block w-full pl-10 sm:text-sm border-gray-600 rounded-lg py-3 bg-gray-700 text-white placeholder-gray-400 transition-colors"
placeholder={t('auth.enterEmail')}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
</div>
{/* Password */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-1">
{t('auth.password')}
</label>
<div className="relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-500" aria-hidden="true" />
</div>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="focus:ring-brand-500 focus:border-brand-500 block w-full pl-10 sm:text-sm border-gray-600 rounded-lg py-3 bg-gray-700 text-white placeholder-gray-400 transition-colors"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</div>
<button
type="submit"
disabled={loginMutation.isPending}
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-brand-500 disabled:opacity-70 disabled:cursor-not-allowed transition-all duration-200 ease-in-out transform active:scale-[0.98]"
>
{loginMutation.isPending ? (
<span className="flex items-center gap-2">
<Loader2 className="animate-spin h-5 w-5" />
{t('auth.signingIn')}
</span>
) : (
<span className="flex items-center gap-2">
{t('auth.signIn')}
<ArrowRight className="h-4 w-4" />
</span>
)}
</button>
</form>
</div>
{/* Dev Quick Login - Platform Staff Only */}
<DevQuickLogin embedded filter="platform" />
{/* Footer */}
<div className="mt-8 text-center text-xs text-gray-500">
© {new Date().getFullYear()} SmoothSchedule. All rights reserved.
</div>
</div>
</div>
);
};
export default PlatformLoginPage;

View File

@@ -0,0 +1,471 @@
/**
* Tests for marketing block schema registration
*
* Verifies all blocks have the expected fields and defaults.
*/
import { describe, it, expect } from 'vitest';
import { puckConfig } from '../config';
describe('Marketing Block Schemas', () => {
describe('Header block', () => {
const block = puckConfig.components.Header;
it('should be registered in config', () => {
expect(block).toBeDefined();
});
it('should have brand fields', () => {
expect(block.fields.brandText).toBeDefined();
expect(block.fields.brandLogo).toBeDefined();
});
it('should have links field', () => {
expect(block.fields.links).toBeDefined();
expect(block.fields.links.type).toBe('array');
});
it('should have CTA button field', () => {
expect(block.fields.ctaButton).toBeDefined();
});
it('should have variant field', () => {
expect(block.fields.variant).toBeDefined();
const options = block.fields.variant.options;
expect(options.some((o: { value: string }) => o.value === 'transparent-on-dark')).toBe(true);
expect(options.some((o: { value: string }) => o.value === 'light')).toBe(true);
});
it('should have mobile menu toggle', () => {
expect(block.fields.showMobileMenu).toBeDefined();
});
it('should have sensible defaults', () => {
expect(block.defaultProps).toBeDefined();
expect(block.defaultProps.variant).toBe('light');
expect(block.defaultProps.showMobileMenu).toBe(true);
});
});
describe('Hero block', () => {
const block = puckConfig.components.Hero;
it('should be registered in config', () => {
expect(block).toBeDefined();
});
it('should have headline and subheadline', () => {
expect(block.fields.headline).toBeDefined();
expect(block.fields.subheadline).toBeDefined();
});
it('should have primary and secondary CTA', () => {
expect(block.fields.primaryCta).toBeDefined();
expect(block.fields.secondaryCta).toBeDefined();
});
it('should have background variant with gradient option', () => {
expect(block.fields.backgroundVariant).toBeDefined();
const options = block.fields.backgroundVariant.options;
expect(options.some((o: { value: string }) => o.value === 'gradient')).toBe(true);
});
it('should have gradient preset field', () => {
expect(block.fields.gradientPreset).toBeDefined();
});
it('should have media field', () => {
expect(block.fields.media).toBeDefined();
});
it('should have optional badge field', () => {
expect(block.fields.badge).toBeDefined();
});
it('should have variant field', () => {
expect(block.fields.variant).toBeDefined();
});
it('should have fullWidth field', () => {
expect(block.fields.fullWidth).toBeDefined();
});
});
describe('LogoCloud block', () => {
const block = puckConfig.components.LogoCloud;
it('should be registered in config', () => {
expect(block).toBeDefined();
});
it('should have optional heading', () => {
expect(block.fields.heading).toBeDefined();
});
it('should have logos array', () => {
expect(block.fields.logos).toBeDefined();
expect(block.fields.logos.type).toBe('array');
});
it('should have grayscale toggle', () => {
expect(block.fields.grayscale).toBeDefined();
});
it('should have spacing density', () => {
expect(block.fields.spacingDensity).toBeDefined();
});
});
describe('SplitContent block', () => {
const block = puckConfig.components.SplitContent;
it('should be registered in config', () => {
expect(block).toBeDefined();
});
it('should have eyebrow label', () => {
expect(block.fields.eyebrow).toBeDefined();
});
it('should have heading', () => {
expect(block.fields.heading).toBeDefined();
});
it('should have rich text content', () => {
expect(block.fields.content).toBeDefined();
});
it('should have bullets list', () => {
expect(block.fields.bullets).toBeDefined();
expect(block.fields.bullets.type).toBe('array');
});
it('should have optional CTA', () => {
expect(block.fields.cta).toBeDefined();
});
it('should have media field', () => {
expect(block.fields.media).toBeDefined();
});
it('should have media position (left/right)', () => {
expect(block.fields.mediaPosition).toBeDefined();
const options = block.fields.mediaPosition.options;
expect(options.some((o: { value: string }) => o.value === 'left')).toBe(true);
expect(options.some((o: { value: string }) => o.value === 'right')).toBe(true);
});
it('should have variant field', () => {
expect(block.fields.variant).toBeDefined();
});
});
describe('CTASection block', () => {
const block = puckConfig.components.CTASection;
it('should be registered in config', () => {
expect(block).toBeDefined();
});
it('should have heading', () => {
expect(block.fields.heading).toBeDefined();
});
it('should have supporting text', () => {
expect(block.fields.supportingText).toBeDefined();
});
it('should have buttons', () => {
expect(block.fields.buttons).toBeDefined();
});
it('should have variant field', () => {
expect(block.fields.variant).toBeDefined();
const options = block.fields.variant.options;
expect(options.some((o: { value: string }) => o.value === 'light')).toBe(true);
expect(options.some((o: { value: string }) => o.value === 'dark')).toBe(true);
expect(options.some((o: { value: string }) => o.value === 'gradient')).toBe(true);
});
});
describe('StatsStrip block', () => {
const block = puckConfig.components.StatsStrip;
it('should be registered in config', () => {
expect(block).toBeDefined();
});
it('should have stats array', () => {
expect(block.fields.stats).toBeDefined();
expect(block.fields.stats.type).toBe('array');
});
it('should have variant field', () => {
expect(block.fields.variant).toBeDefined();
});
it('should default to 4 stats', () => {
expect(block.defaultProps.stats.length).toBe(4);
});
});
describe('GalleryGrid block', () => {
const block = puckConfig.components.GalleryGrid;
it('should be registered in config', () => {
expect(block).toBeDefined();
});
it('should have items array', () => {
expect(block.fields.items).toBeDefined();
expect(block.fields.items.type).toBe('array');
});
it('should have columns per breakpoint', () => {
expect(block.fields.columnsMobile).toBeDefined();
expect(block.fields.columnsTablet).toBeDefined();
expect(block.fields.columnsDesktop).toBeDefined();
});
});
describe('Testimonials block', () => {
const block = puckConfig.components.Testimonials;
it('should be registered in config', () => {
expect(block).toBeDefined();
});
it('should have items array', () => {
expect(block.fields.items).toBeDefined();
expect(block.fields.items.type).toBe('array');
});
it('should have layout field', () => {
expect(block.fields.layout).toBeDefined();
const options = block.fields.layout.options;
expect(options.some((o: { value: string }) => o.value === 'carousel')).toBe(true);
expect(options.some((o: { value: string }) => o.value === 'stacked')).toBe(true);
});
it('should have rating stars toggle', () => {
expect(block.fields.showRating).toBeDefined();
});
});
describe('VideoEmbed block', () => {
const block = puckConfig.components.VideoEmbed;
it('should be registered in config', () => {
expect(block).toBeDefined();
});
it('should have heading and text', () => {
expect(block.fields.heading).toBeDefined();
expect(block.fields.text).toBeDefined();
});
it('should have thumbnail image', () => {
expect(block.fields.thumbnailImage).toBeDefined();
});
it('should have video provider field with allowlist', () => {
expect(block.fields.provider).toBeDefined();
const options = block.fields.provider.options;
expect(options.some((o: { value: string }) => o.value === 'youtube')).toBe(true);
expect(options.some((o: { value: string }) => o.value === 'vimeo')).toBe(true);
// Should NOT have arbitrary options
expect(options.length).toBe(2);
});
it('should have video ID field', () => {
expect(block.fields.videoId).toBeDefined();
});
it('should have variant field', () => {
expect(block.fields.variant).toBeDefined();
});
});
describe('ContentBlocks block', () => {
const block = puckConfig.components.ContentBlocks;
it('should be registered in config', () => {
expect(block).toBeDefined();
});
it('should have features array', () => {
expect(block.fields.features).toBeDefined();
expect(block.fields.features.type).toBe('array');
});
it('should have section heading', () => {
expect(block.fields.sectionHeading).toBeDefined();
});
});
describe('PricingCards block', () => {
const block = puckConfig.components.PricingCards;
it('should be registered in config', () => {
expect(block).toBeDefined();
});
it('should have currency field', () => {
expect(block.fields.currency).toBeDefined();
});
it('should have billing period label', () => {
expect(block.fields.billingPeriod).toBeDefined();
});
it('should have plans array', () => {
expect(block.fields.plans).toBeDefined();
expect(block.fields.plans.type).toBe('array');
});
it('should have highlight plan index', () => {
expect(block.fields.highlightIndex).toBeDefined();
});
it('should have recommended badge text', () => {
expect(block.fields.popularBadgeText).toBeDefined();
});
it('should have variant field', () => {
expect(block.fields.variant).toBeDefined();
});
it('should default to 3 plans with middle highlighted', () => {
expect(block.defaultProps.plans.length).toBe(3);
expect(block.defaultProps.highlightIndex).toBe(1);
});
});
describe('Footer block', () => {
const block = puckConfig.components.Footer;
it('should be registered in config', () => {
expect(block).toBeDefined();
});
it('should have brand logo/text', () => {
expect(block.fields.brandLogo).toBeDefined();
expect(block.fields.brandText).toBeDefined();
});
it('should have description', () => {
expect(block.fields.description).toBeDefined();
});
it('should have link columns', () => {
expect(block.fields.columns).toBeDefined();
expect(block.fields.columns.type).toBe('array');
});
it('should have social links', () => {
expect(block.fields.socialLinks).toBeDefined();
});
it('should have small print', () => {
expect(block.fields.smallPrint).toBeDefined();
});
it('should have optional mini CTA', () => {
expect(block.fields.miniCta).toBeDefined();
});
});
describe('FAQAccordion block', () => {
const block = puckConfig.components.FAQAccordion;
it('should be registered in config', () => {
expect(block).toBeDefined();
});
it('should have items array', () => {
expect(block.fields.items).toBeDefined();
expect(block.fields.items.type).toBe('array');
});
it('should have expand behavior', () => {
expect(block.fields.expandBehavior).toBeDefined();
const options = block.fields.expandBehavior.options;
expect(options.some((o: { value: string }) => o.value === 'single')).toBe(true);
expect(options.some((o: { value: string }) => o.value === 'multiple')).toBe(true);
});
it('should have variant field', () => {
expect(block.fields.variant).toBeDefined();
const options = block.fields.variant.options;
expect(options.some((o: { value: string }) => o.value === 'light')).toBe(true);
expect(options.some((o: { value: string }) => o.value === 'dark')).toBe(true);
});
});
});
describe('Block Categories', () => {
it('should have function-based categories', () => {
expect(puckConfig.categories.navigation).toBeDefined();
expect(puckConfig.categories.hero).toBeDefined();
expect(puckConfig.categories.content).toBeDefined();
expect(puckConfig.categories.trust).toBeDefined();
expect(puckConfig.categories.conversion).toBeDefined();
expect(puckConfig.categories.media).toBeDefined();
expect(puckConfig.categories.info).toBeDefined();
});
it('should have navigation blocks', () => {
expect(puckConfig.components.Header).toBeDefined();
expect(puckConfig.components.Footer).toBeDefined();
});
it('should have content blocks', () => {
expect(puckConfig.components.SplitContent).toBeDefined();
expect(puckConfig.components.ContentBlocks).toBeDefined();
expect(puckConfig.components.GalleryGrid).toBeDefined();
});
it('should have trust/social proof blocks', () => {
expect(puckConfig.components.LogoCloud).toBeDefined();
expect(puckConfig.components.Testimonials).toBeDefined();
expect(puckConfig.components.StatsStrip).toBeDefined();
});
it('should have conversion blocks', () => {
expect(puckConfig.components.CTASection).toBeDefined();
expect(puckConfig.components.PricingCards).toBeDefined();
});
});
describe('Shared Design Controls Integration', () => {
const blocksWithDesignControls = [
'Hero',
'SplitContent',
'CTASection',
'StatsStrip',
'GalleryGrid',
'Testimonials',
'VideoEmbed',
'ContentBlocks',
'PricingCards',
'FAQAccordion',
];
blocksWithDesignControls.forEach(blockName => {
describe(`${blockName} design controls`, () => {
const block = puckConfig.components[blockName];
it('should have padding field', () => {
expect(block.fields.padding).toBeDefined();
});
it('should have backgroundVariant field', () => {
expect(block.fields.backgroundVariant).toBeDefined();
});
it('should have contentMaxWidth field', () => {
expect(block.fields.contentMaxWidth).toBeDefined();
});
});
});
});

View File

@@ -0,0 +1,255 @@
/**
* Tests for landing page template generator
*/
import { describe, it, expect } from 'vitest';
import {
generateSaaSLandingPageTemplate,
LANDING_PAGE_TEMPLATES,
validatePuckData,
} from '../templates';
describe('Landing Page Templates', () => {
describe('LANDING_PAGE_TEMPLATES', () => {
it('should include SaaS Landing Page (Dark Hero) template', () => {
const template = LANDING_PAGE_TEMPLATES.find(t => t.id === 'saas-dark-hero');
expect(template).toBeDefined();
expect(template?.name).toBe('SaaS Landing Page (Dark Hero)');
expect(template?.description).toBeDefined();
expect(template?.thumbnail).toBeDefined();
});
it('should have generate function for each template', () => {
LANDING_PAGE_TEMPLATES.forEach(template => {
expect(typeof template.generate).toBe('function');
});
});
});
describe('generateSaaSLandingPageTemplate', () => {
it('should return valid Puck data structure', () => {
const data = generateSaaSLandingPageTemplate();
expect(data).toHaveProperty('content');
expect(data).toHaveProperty('root');
expect(Array.isArray(data.content)).toBe(true);
});
it('should include all required sections', () => {
const data = generateSaaSLandingPageTemplate();
const componentTypes = data.content.map((c: { type: string }) => c.type);
// Verify all required sections are present
expect(componentTypes).toContain('TopNav');
expect(componentTypes).toContain('HeroSaaS');
expect(componentTypes).toContain('LogoCloud');
expect(componentTypes).toContain('FeatureSplit');
expect(componentTypes).toContain('CTASection');
expect(componentTypes).toContain('StatsStrip');
expect(componentTypes).toContain('GalleryGrid');
expect(componentTypes).toContain('TestimonialCarousel');
expect(componentTypes).toContain('VideoFeature');
expect(componentTypes).toContain('AlternatingFeatures');
expect(componentTypes).toContain('PricingPlans');
expect(componentTypes).toContain('FooterMega');
expect(componentTypes).toContain('FAQAccordion');
});
it('should generate unique IDs for all components', () => {
const data = generateSaaSLandingPageTemplate();
const ids = data.content.map((c: { props: { id: string } }) => c.props.id);
// Check all IDs are unique
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
});
it('should include placeholder content in each section', () => {
const data = generateSaaSLandingPageTemplate();
data.content.forEach((component: { type: string; props: Record<string, unknown> }) => {
expect(component.props).toBeDefined();
// Each component should have some props set
expect(Object.keys(component.props).length).toBeGreaterThan(0);
});
});
describe('HeroSaaS section', () => {
it('should have headline and subheadline', () => {
const data = generateSaaSLandingPageTemplate();
const hero = data.content.find((c: { type: string }) => c.type === 'HeroSaaS');
expect(hero.props.headline).toBeDefined();
expect(hero.props.subheadline).toBeDefined();
expect(hero.props.headline.length).toBeGreaterThan(0);
});
it('should have primary CTA', () => {
const data = generateSaaSLandingPageTemplate();
const hero = data.content.find((c: { type: string }) => c.type === 'HeroSaaS');
expect(hero.props.primaryCta).toBeDefined();
expect(hero.props.primaryCta.text).toBeDefined();
expect(hero.props.primaryCta.href).toBeDefined();
});
it('should have dark gradient background', () => {
const data = generateSaaSLandingPageTemplate();
const hero = data.content.find((c: { type: string }) => c.type === 'HeroSaaS');
expect(hero.props.backgroundVariant).toBe('gradient');
expect(hero.props.gradientPreset).toBeDefined();
});
it('should have hero image/mockup', () => {
const data = generateSaaSLandingPageTemplate();
const hero = data.content.find((c: { type: string }) => c.type === 'HeroSaaS');
expect(hero.props.heroImage).toBeDefined();
expect(hero.props.heroImage.src).toBeDefined();
});
});
describe('LogoCloud section', () => {
it('should have multiple logos', () => {
const data = generateSaaSLandingPageTemplate();
const logoCloud = data.content.find((c: { type: string }) => c.type === 'LogoCloud');
expect(logoCloud.props.logos).toBeDefined();
expect(Array.isArray(logoCloud.props.logos)).toBe(true);
expect(logoCloud.props.logos.length).toBeGreaterThanOrEqual(4);
});
});
describe('PricingPlans section', () => {
it('should have 3 pricing plans', () => {
const data = generateSaaSLandingPageTemplate();
const pricing = data.content.find((c: { type: string }) => c.type === 'PricingPlans');
expect(pricing.props.plans).toBeDefined();
expect(pricing.props.plans.length).toBe(3);
});
it('should highlight the middle plan', () => {
const data = generateSaaSLandingPageTemplate();
const pricing = data.content.find((c: { type: string }) => c.type === 'PricingPlans');
expect(pricing.props.highlightIndex).toBe(1);
});
it('should have currency and billing period', () => {
const data = generateSaaSLandingPageTemplate();
const pricing = data.content.find((c: { type: string }) => c.type === 'PricingPlans');
expect(pricing.props.currency).toBeDefined();
expect(pricing.props.billingPeriod).toBeDefined();
});
});
describe('TestimonialCarousel section', () => {
it('should have multiple testimonials', () => {
const data = generateSaaSLandingPageTemplate();
const testimonials = data.content.find((c: { type: string }) => c.type === 'TestimonialCarousel');
expect(testimonials.props.items).toBeDefined();
expect(testimonials.props.items.length).toBeGreaterThanOrEqual(3);
});
});
describe('FAQAccordion section', () => {
it('should have multiple Q&A items', () => {
const data = generateSaaSLandingPageTemplate();
const faq = data.content.find((c: { type: string }) => c.type === 'FAQAccordion');
expect(faq.props.items).toBeDefined();
expect(faq.props.items.length).toBeGreaterThanOrEqual(4);
});
});
describe('VideoFeature section', () => {
it('should have a valid video configuration', () => {
const data = generateSaaSLandingPageTemplate();
const video = data.content.find((c: { type: string }) => c.type === 'VideoFeature');
expect(video.props.provider).toMatch(/^(youtube|vimeo)$/);
expect(video.props.videoId).toBeDefined();
});
});
});
describe('validatePuckData', () => {
it('should accept valid Puck data', () => {
const validData = {
content: [{ type: 'HeroSaaS', props: { id: 'hero-1', headline: 'Test' } }],
root: {},
};
const result = validatePuckData(validData);
expect(result.isValid).toBe(true);
});
it('should reject data without content array', () => {
const invalidData = { root: {} };
const result = validatePuckData(invalidData);
expect(result.isValid).toBe(false);
expect(result.error).toContain('content');
});
it('should reject data with non-array content', () => {
const invalidData = { content: 'not an array', root: {} };
const result = validatePuckData(invalidData);
expect(result.isValid).toBe(false);
});
it('should reject components without type', () => {
const invalidData = {
content: [{ props: { id: 'test' } }],
root: {},
};
const result = validatePuckData(invalidData);
expect(result.isValid).toBe(false);
expect(result.error).toContain('type');
});
it('should validate generated template', () => {
const data = generateSaaSLandingPageTemplate();
const result = validatePuckData(data);
expect(result.isValid).toBe(true);
});
});
});
describe('Block Presets', () => {
describe('HeroSaaS presets', () => {
it('should have dark gradient centered preset', () => {
const { BLOCK_PRESETS } = require('../templates');
const heroPresets = BLOCK_PRESETS.HeroSaaS;
expect(heroPresets).toBeDefined();
const darkCentered = heroPresets.find(
(p: { id: string }) => p.id === 'dark-gradient-centered'
);
expect(darkCentered).toBeDefined();
expect(darkCentered.props.backgroundVariant).toBe('gradient');
expect(darkCentered.props.alignment).toBe('center');
});
});
describe('PricingPlans presets', () => {
it('should have 3-card highlighted middle preset', () => {
const { BLOCK_PRESETS } = require('../templates');
const pricingPresets = BLOCK_PRESETS.PricingPlans;
expect(pricingPresets).toBeDefined();
const threeCard = pricingPresets.find(
(p: { id: string }) => p.id === 'three-card-highlighted'
);
expect(threeCard).toBeDefined();
expect(threeCard.props.highlightIndex).toBe(1);
});
});
});

View File

@@ -0,0 +1,230 @@
/**
* Tests for Puck theme tokens and design controls
*/
import { describe, it, expect } from 'vitest';
// These will be imported from the theme module once implemented
import {
defaultThemeTokens,
GRADIENT_PRESETS,
PADDING_PRESETS,
CONTAINER_WIDTH_PRESETS,
BUTTON_RADIUS_PRESETS,
SHADOW_PRESETS,
createDesignControlsFields,
applyDesignControls,
} from '../theme';
describe('Theme Tokens', () => {
describe('defaultThemeTokens', () => {
it('should have color tokens', () => {
expect(defaultThemeTokens.colors).toBeDefined();
expect(defaultThemeTokens.colors.primary).toBeDefined();
expect(defaultThemeTokens.colors.secondary).toBeDefined();
expect(defaultThemeTokens.colors.accent).toBeDefined();
expect(defaultThemeTokens.colors.neutral).toBeDefined();
});
it('should have typography tokens', () => {
expect(defaultThemeTokens.typography).toBeDefined();
expect(defaultThemeTokens.typography.fontScale).toBeDefined();
expect(defaultThemeTokens.typography.headingFamily).toBeDefined();
expect(defaultThemeTokens.typography.bodyFamily).toBeDefined();
});
it('should have button tokens', () => {
expect(defaultThemeTokens.buttons).toBeDefined();
expect(defaultThemeTokens.buttons.radius).toBeDefined();
expect(defaultThemeTokens.buttons.variants).toBeDefined();
expect(defaultThemeTokens.buttons.variants.primary).toBeDefined();
expect(defaultThemeTokens.buttons.variants.secondary).toBeDefined();
expect(defaultThemeTokens.buttons.variants.ghost).toBeDefined();
});
it('should have container width tokens', () => {
expect(defaultThemeTokens.containerWidths).toBeDefined();
expect(defaultThemeTokens.containerWidths.narrow).toBeDefined();
expect(defaultThemeTokens.containerWidths.normal).toBeDefined();
expect(defaultThemeTokens.containerWidths.wide).toBeDefined();
expect(defaultThemeTokens.containerWidths.full).toBeDefined();
});
});
describe('GRADIENT_PRESETS', () => {
it('should have dark purple preset', () => {
const darkPurple = GRADIENT_PRESETS.find(g => g.name === 'dark-purple');
expect(darkPurple).toBeDefined();
expect(darkPurple?.value).toContain('gradient');
});
it('should have dark blue preset', () => {
const darkBlue = GRADIENT_PRESETS.find(g => g.name === 'dark-blue');
expect(darkBlue).toBeDefined();
expect(darkBlue?.value).toContain('gradient');
});
it('should have at least 5 gradient presets', () => {
expect(GRADIENT_PRESETS.length).toBeGreaterThanOrEqual(5);
});
it('should have CSS-valid gradient values', () => {
GRADIENT_PRESETS.forEach(preset => {
expect(preset.value).toMatch(/^(linear-gradient|radial-gradient)/);
});
});
});
describe('PADDING_PRESETS', () => {
it('should have standard padding sizes', () => {
expect(PADDING_PRESETS).toHaveProperty('xs');
expect(PADDING_PRESETS).toHaveProperty('sm');
expect(PADDING_PRESETS).toHaveProperty('md');
expect(PADDING_PRESETS).toHaveProperty('lg');
expect(PADDING_PRESETS).toHaveProperty('xl');
});
it('should have increasing padding values', () => {
const sizes = ['xs', 'sm', 'md', 'lg', 'xl'] as const;
let prevValue = 0;
sizes.forEach(size => {
const match = PADDING_PRESETS[size].match(/\d+/);
const value = match ? parseInt(match[0], 10) : 0;
expect(value).toBeGreaterThanOrEqual(prevValue);
prevValue = value;
});
});
});
describe('CONTAINER_WIDTH_PRESETS', () => {
it('should have all width options', () => {
expect(CONTAINER_WIDTH_PRESETS).toHaveProperty('narrow');
expect(CONTAINER_WIDTH_PRESETS).toHaveProperty('normal');
expect(CONTAINER_WIDTH_PRESETS).toHaveProperty('wide');
expect(CONTAINER_WIDTH_PRESETS).toHaveProperty('full');
});
it('should have valid CSS classes or values', () => {
Object.values(CONTAINER_WIDTH_PRESETS).forEach(value => {
expect(value).toMatch(/^(max-w-|w-)/);
});
});
});
});
describe('Design Controls', () => {
describe('createDesignControlsFields', () => {
it('should return Puck field definitions', () => {
const fields = createDesignControlsFields();
expect(fields).toBeDefined();
expect(typeof fields).toBe('object');
});
it('should have padding field', () => {
const fields = createDesignControlsFields();
expect(fields.padding).toBeDefined();
expect(fields.padding.type).toBe('select');
});
it('should have background variant field', () => {
const fields = createDesignControlsFields();
expect(fields.backgroundVariant).toBeDefined();
expect(fields.backgroundVariant.type).toBe('select');
const options = fields.backgroundVariant.options;
expect(options.some((o: { value: string }) => o.value === 'none')).toBe(true);
expect(options.some((o: { value: string }) => o.value === 'light')).toBe(true);
expect(options.some((o: { value: string }) => o.value === 'dark')).toBe(true);
expect(options.some((o: { value: string }) => o.value === 'gradient')).toBe(true);
});
it('should have gradient preset field', () => {
const fields = createDesignControlsFields();
expect(fields.gradientPreset).toBeDefined();
expect(fields.gradientPreset.type).toBe('select');
});
it('should have content max width field', () => {
const fields = createDesignControlsFields();
expect(fields.contentMaxWidth).toBeDefined();
expect(fields.contentMaxWidth.type).toBe('select');
});
it('should have alignment field', () => {
const fields = createDesignControlsFields();
expect(fields.alignment).toBeDefined();
const options = fields.alignment.options;
expect(options.some((o: { value: string }) => o.value === 'left')).toBe(true);
expect(options.some((o: { value: string }) => o.value === 'center')).toBe(true);
expect(options.some((o: { value: string }) => o.value === 'right')).toBe(true);
});
it('should have visibility toggles', () => {
const fields = createDesignControlsFields();
expect(fields.hideOnMobile).toBeDefined();
expect(fields.hideOnTablet).toBeDefined();
expect(fields.hideOnDesktop).toBeDefined();
});
});
describe('applyDesignControls', () => {
it('should return empty object for no controls', () => {
const result = applyDesignControls({});
expect(result.className).toBe('');
expect(result.style).toEqual({});
});
it('should apply padding class', () => {
const result = applyDesignControls({ padding: 'lg' });
expect(result.className).toContain('py-');
});
it('should apply background variant styles', () => {
const result = applyDesignControls({ backgroundVariant: 'dark' });
expect(result.className).toContain('bg-');
});
it('should apply gradient preset', () => {
const result = applyDesignControls({
backgroundVariant: 'gradient',
gradientPreset: 'dark-purple',
});
expect(result.style.background).toBeDefined();
expect(result.style.background).toContain('gradient');
});
it('should apply content max width', () => {
const result = applyDesignControls({ contentMaxWidth: 'narrow' });
expect(result.containerClassName).toContain('max-w-');
});
it('should apply alignment', () => {
const result = applyDesignControls({ alignment: 'center' });
expect(result.className).toContain('text-center');
});
it('should apply visibility classes', () => {
const result = applyDesignControls({ hideOnMobile: true });
expect(result.className).toContain('hidden');
expect(result.className).toContain('sm:block');
});
});
});
describe('BUTTON_RADIUS_PRESETS', () => {
it('should have radius options', () => {
expect(BUTTON_RADIUS_PRESETS).toHaveProperty('none');
expect(BUTTON_RADIUS_PRESETS).toHaveProperty('sm');
expect(BUTTON_RADIUS_PRESETS).toHaveProperty('md');
expect(BUTTON_RADIUS_PRESETS).toHaveProperty('lg');
expect(BUTTON_RADIUS_PRESETS).toHaveProperty('full');
});
});
describe('SHADOW_PRESETS', () => {
it('should have shadow options', () => {
expect(SHADOW_PRESETS).toHaveProperty('none');
expect(SHADOW_PRESETS).toHaveProperty('sm');
expect(SHADOW_PRESETS).toHaveProperty('md');
expect(SHADOW_PRESETS).toHaveProperty('lg');
expect(SHADOW_PRESETS).toHaveProperty('xl');
});
});

View File

@@ -0,0 +1,231 @@
/**
* Tests for video embed URL allowlist validation
*
* Security: Only YouTube and Vimeo embeds are allowed.
* No arbitrary iframes or data URIs.
*/
import { describe, it, expect } from 'vitest';
import {
validateVideoEmbed,
parseVideoUrl,
buildSafeEmbedUrl,
VIDEO_PROVIDERS,
} from '../utils/videoEmbed';
describe('Video Embed Validation', () => {
describe('VIDEO_PROVIDERS', () => {
it('should define YouTube provider', () => {
expect(VIDEO_PROVIDERS.youtube).toBeDefined();
expect(VIDEO_PROVIDERS.youtube.name).toBe('YouTube');
expect(VIDEO_PROVIDERS.youtube.patterns).toBeDefined();
expect(VIDEO_PROVIDERS.youtube.embedTemplate).toBeDefined();
});
it('should define Vimeo provider', () => {
expect(VIDEO_PROVIDERS.vimeo).toBeDefined();
expect(VIDEO_PROVIDERS.vimeo.name).toBe('Vimeo');
expect(VIDEO_PROVIDERS.vimeo.patterns).toBeDefined();
expect(VIDEO_PROVIDERS.vimeo.embedTemplate).toBeDefined();
});
});
describe('validateVideoEmbed', () => {
describe('YouTube URLs', () => {
it('should accept standard YouTube watch URLs', () => {
const result = validateVideoEmbed('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
expect(result.isValid).toBe(true);
expect(result.provider).toBe('youtube');
expect(result.videoId).toBe('dQw4w9WgXcQ');
});
it('should accept YouTube short URLs', () => {
const result = validateVideoEmbed('https://youtu.be/dQw4w9WgXcQ');
expect(result.isValid).toBe(true);
expect(result.provider).toBe('youtube');
expect(result.videoId).toBe('dQw4w9WgXcQ');
});
it('should accept YouTube embed URLs', () => {
const result = validateVideoEmbed('https://www.youtube.com/embed/dQw4w9WgXcQ');
expect(result.isValid).toBe(true);
expect(result.provider).toBe('youtube');
expect(result.videoId).toBe('dQw4w9WgXcQ');
});
it('should accept YouTube nocookie embed URLs', () => {
const result = validateVideoEmbed('https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ');
expect(result.isValid).toBe(true);
expect(result.provider).toBe('youtube');
expect(result.videoId).toBe('dQw4w9WgXcQ');
});
it('should handle YouTube URLs with extra params', () => {
const result = validateVideoEmbed('https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=60s');
expect(result.isValid).toBe(true);
expect(result.videoId).toBe('dQw4w9WgXcQ');
});
it('should reject invalid YouTube video IDs', () => {
const result = validateVideoEmbed('https://www.youtube.com/watch?v=');
expect(result.isValid).toBe(false);
});
});
describe('Vimeo URLs', () => {
it('should accept standard Vimeo URLs', () => {
const result = validateVideoEmbed('https://vimeo.com/123456789');
expect(result.isValid).toBe(true);
expect(result.provider).toBe('vimeo');
expect(result.videoId).toBe('123456789');
});
it('should accept Vimeo player URLs', () => {
const result = validateVideoEmbed('https://player.vimeo.com/video/123456789');
expect(result.isValid).toBe(true);
expect(result.provider).toBe('vimeo');
expect(result.videoId).toBe('123456789');
});
it('should accept Vimeo URLs with hash for private videos', () => {
const result = validateVideoEmbed('https://vimeo.com/123456789/abc123def456');
expect(result.isValid).toBe(true);
expect(result.provider).toBe('vimeo');
expect(result.videoId).toBe('123456789');
expect(result.hash).toBe('abc123def456');
});
it('should reject non-numeric Vimeo IDs', () => {
const result = validateVideoEmbed('https://vimeo.com/not-a-video-id');
expect(result.isValid).toBe(false);
});
});
describe('Invalid URLs', () => {
it('should reject arbitrary URLs', () => {
const result = validateVideoEmbed('https://evil-site.com/embed');
expect(result.isValid).toBe(false);
expect(result.error).toBeDefined();
});
it('should reject data URIs', () => {
const result = validateVideoEmbed('data:text/html,<script>alert("xss")</script>');
expect(result.isValid).toBe(false);
});
it('should reject javascript URLs', () => {
const result = validateVideoEmbed('javascript:alert("xss")');
expect(result.isValid).toBe(false);
});
it('should reject HTTP (non-HTTPS) URLs', () => {
const result = validateVideoEmbed('http://www.youtube.com/watch?v=dQw4w9WgXcQ');
expect(result.isValid).toBe(false);
expect(result.error).toContain('HTTPS');
});
it('should reject empty URLs', () => {
const result = validateVideoEmbed('');
expect(result.isValid).toBe(false);
});
it('should reject null/undefined', () => {
expect(validateVideoEmbed(null as unknown as string).isValid).toBe(false);
expect(validateVideoEmbed(undefined as unknown as string).isValid).toBe(false);
});
it('should reject URLs with XSS payloads', () => {
const result = validateVideoEmbed('https://www.youtube.com/watch?v=<script>alert(1)</script>');
expect(result.isValid).toBe(false);
});
it('should reject Dailymotion (not in allowlist)', () => {
const result = validateVideoEmbed('https://www.dailymotion.com/video/x123456');
expect(result.isValid).toBe(false);
});
});
});
describe('parseVideoUrl', () => {
it('should extract video ID from YouTube watch URL', () => {
const parsed = parseVideoUrl('https://www.youtube.com/watch?v=abc123XYZ');
expect(parsed?.provider).toBe('youtube');
expect(parsed?.videoId).toBe('abc123XYZ');
});
it('should extract video ID from youtu.be URL', () => {
const parsed = parseVideoUrl('https://youtu.be/abc123XYZ');
expect(parsed?.provider).toBe('youtube');
expect(parsed?.videoId).toBe('abc123XYZ');
});
it('should extract video ID from Vimeo URL', () => {
const parsed = parseVideoUrl('https://vimeo.com/987654321');
expect(parsed?.provider).toBe('vimeo');
expect(parsed?.videoId).toBe('987654321');
});
it('should return null for invalid URLs', () => {
expect(parseVideoUrl('not a url')).toBeNull();
expect(parseVideoUrl('')).toBeNull();
});
});
describe('buildSafeEmbedUrl', () => {
it('should build YouTube embed URL', () => {
const url = buildSafeEmbedUrl('youtube', 'dQw4w9WgXcQ');
expect(url).toBe('https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ');
});
it('should build Vimeo embed URL', () => {
const url = buildSafeEmbedUrl('vimeo', '123456789');
expect(url).toBe('https://player.vimeo.com/video/123456789');
});
it('should include hash for private Vimeo videos', () => {
const url = buildSafeEmbedUrl('vimeo', '123456789', 'abc123');
expect(url).toBe('https://player.vimeo.com/video/123456789?h=abc123');
});
it('should sanitize video ID to prevent injection', () => {
const url = buildSafeEmbedUrl('youtube', 'abc"><script>');
// Should only include alphanumeric and safe characters
expect(url).not.toContain('<script>');
expect(url).not.toContain('"');
});
it('should throw for unknown provider', () => {
expect(() => buildSafeEmbedUrl('unknown' as never, '123')).toThrow();
});
});
describe('Security Edge Cases', () => {
it('should reject URLs with encoded XSS', () => {
const result = validateVideoEmbed('https://www.youtube.com/watch?v=%3Cscript%3Ealert(1)%3C/script%3E');
expect(result.isValid).toBe(false);
});
it('should reject URLs with unicode tricks', () => {
// Using homograph attack with unicode lookalikes
const result = validateVideoEmbed('https://www.youtube\u0430.com/watch?v=abc123'); // Cyrillic 'a'
expect(result.isValid).toBe(false);
});
it('should reject URLs with null bytes', () => {
const result = validateVideoEmbed('https://www.youtube.com/watch?v=abc\x00123');
expect(result.isValid).toBe(false);
});
it('should validate video ID format strictly', () => {
// YouTube IDs are 11 characters, alphanumeric + - and _
const validYouTube = validateVideoEmbed('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
expect(validYouTube.isValid).toBe(true);
const tooShort = validateVideoEmbed('https://www.youtube.com/watch?v=abc');
expect(tooShort.isValid).toBe(false);
const invalidChars = validateVideoEmbed('https://www.youtube.com/watch?v=abc!@#$%^&*()');
expect(invalidChars.isValid).toBe(false);
});
});
});

View File

@@ -0,0 +1,215 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import { MapPin, Phone, Mail, Building2 } from 'lucide-react';
export interface AddressBlockProps {
businessName?: string;
address?: string;
address2?: string;
city?: string;
state?: string;
zip?: string;
phone?: string;
email?: string;
showIcons: boolean;
layout: 'vertical' | 'horizontal';
alignment: 'left' | 'center' | 'right';
}
const formatPhone = (phone: string): string => {
// Remove all non-digits
const digits = phone.replace(/\D/g, '');
// Format as (XXX) XXX-XXXX if 10 digits
if (digits.length === 10) {
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
}
// Return original if not 10 digits
return phone;
};
const AddressBlockRender: React.FC<AddressBlockProps> = ({
businessName,
address,
address2,
city,
state,
zip,
phone,
email,
showIcons = true,
layout = 'vertical',
alignment = 'left',
}) => {
const alignmentClasses = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
};
const justifyClasses = {
left: 'justify-start',
center: 'justify-center',
right: 'justify-end',
};
// Build full address string
const addressParts = [];
if (address) addressParts.push(address);
if (address2) addressParts.push(address2);
const cityStateZip = [city, state].filter(Boolean).join(', ');
const fullCityLine = [cityStateZip, zip].filter(Boolean).join(' ');
if (fullCityLine) addressParts.push(fullCityLine);
const fullAddress = addressParts.join(', ');
const items = [
{ icon: Building2, value: businessName, href: null },
{ icon: MapPin, value: fullAddress, href: fullAddress ? `https://maps.google.com/?q=${encodeURIComponent(fullAddress)}` : null },
{ icon: Phone, value: phone ? formatPhone(phone) : null, href: phone ? `tel:${phone.replace(/\D/g, '')}` : null },
{ icon: Mail, value: email, href: email ? `mailto:${email}` : null },
].filter(item => item.value);
if (items.length === 0) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 text-center text-gray-500">
Add your business contact information
</div>
);
}
if (layout === 'horizontal') {
return (
<div className={`bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6`}>
<div className={`flex flex-wrap gap-6 ${justifyClasses[alignment]}`}>
{items.map(({ icon: Icon, value, href }, index) => (
<div key={index} className="flex items-center gap-2">
{showIcons && (
<Icon className="w-5 h-5 text-primary-600 dark:text-primary-400 flex-shrink-0" />
)}
{href ? (
<a
href={href}
target={href.startsWith('https') ? '_blank' : undefined}
rel={href.startsWith('https') ? 'noopener noreferrer' : undefined}
className="text-gray-700 dark:text-gray-300 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
>
{value}
</a>
) : (
<span className="text-gray-900 dark:text-white font-medium">{value}</span>
)}
</div>
))}
</div>
</div>
);
}
return (
<div className={`bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 ${alignmentClasses[alignment]}`}>
<div className={`space-y-4 ${alignment === 'center' ? 'flex flex-col items-center' : ''}`}>
{items.map(({ icon: Icon, value, href }, index) => (
<div key={index} className={`flex items-start gap-3 ${justifyClasses[alignment]}`}>
{showIcons && (
<Icon className="w-5 h-5 text-primary-600 dark:text-primary-400 flex-shrink-0 mt-0.5" />
)}
{href ? (
<a
href={href}
target={href.startsWith('https') ? '_blank' : undefined}
rel={href.startsWith('https') ? 'noopener noreferrer' : undefined}
className="text-gray-700 dark:text-gray-300 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
>
{value}
</a>
) : (
<span className="text-gray-900 dark:text-white font-semibold text-lg">{value}</span>
)}
</div>
))}
</div>
</div>
);
};
export const AddressBlock: ComponentConfig<AddressBlockProps> = {
label: 'Address Block',
fields: {
businessName: {
type: 'text',
label: 'Business Name',
},
address: {
type: 'text',
label: 'Street Address',
},
address2: {
type: 'text',
label: 'Address Line 2 (Suite, Unit, etc.)',
},
city: {
type: 'text',
label: 'City',
},
state: {
type: 'text',
label: 'State',
},
zip: {
type: 'text',
label: 'ZIP Code',
},
phone: {
type: 'text',
label: 'Phone Number',
},
email: {
type: 'text',
label: 'Email Address',
},
showIcons: {
type: 'radio',
label: 'Show Icons',
options: [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
],
},
layout: {
type: 'select',
label: 'Layout',
options: [
{ label: 'Vertical (Stacked)', value: 'vertical' },
{ label: 'Horizontal (Inline)', value: 'horizontal' },
],
},
alignment: {
type: 'select',
label: 'Alignment',
options: [
{ label: 'Left', value: 'left' },
{ label: 'Center', value: 'center' },
{ label: 'Right', value: 'right' },
],
},
},
defaultProps: {
businessName: '',
address: '',
address2: '',
city: '',
state: '',
zip: '',
phone: '',
email: '',
showIcons: true,
layout: 'vertical',
alignment: 'left',
},
render: AddressBlockRender,
};
export default AddressBlock;

View File

@@ -1,16 +1,37 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { BusinessHoursProps } from '../../types';
import { Clock, CheckCircle, XCircle } from 'lucide-react';
import { Clock, CheckCircle, XCircle, Loader2 } from 'lucide-react';
import api from '../../../api/client';
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 },
interface DayHours {
day: string;
is_open: boolean;
open: string | null;
close: string | null;
}
const formatTime = (time: string | null): string => {
if (!time) return '';
try {
const [hour, min] = time.split(':').map(Number);
if (isNaN(hour) || isNaN(min)) return time;
const period = hour >= 12 ? 'PM' : 'AM';
const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
return `${displayHour}:${min.toString().padStart(2, '0')} ${period}`;
} catch {
return time;
}
};
const DEFAULT_HOURS: DayHours[] = [
{ day: 'Monday', is_open: true, open: '09:00', close: '17:00' },
{ day: 'Tuesday', is_open: true, open: '09:00', close: '17:00' },
{ day: 'Wednesday', is_open: true, open: '09:00', close: '17:00' },
{ day: 'Thursday', is_open: true, open: '09:00', close: '17:00' },
{ day: 'Friday', is_open: true, open: '09:00', close: '17:00' },
{ day: 'Saturday', is_open: false, open: null, close: null },
{ day: 'Sunday', is_open: false, open: null, close: null },
];
export const BusinessHours: ComponentConfig<BusinessHoursProps> = {
@@ -34,8 +55,50 @@ export const BusinessHours: ComponentConfig<BusinessHoursProps> = {
title: 'Business Hours',
},
render: ({ showCurrent, title }) => {
const [hours, setHours] = useState<DayHours[]>(DEFAULT_HOURS);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const today = new Date().toLocaleDateString('en-US', { weekday: 'long' });
useEffect(() => {
const fetchHours = async () => {
try {
setIsLoading(true);
const response = await api.get('/public/weekly-hours/');
if (response.data?.hours) {
setHours(response.data.hours);
}
setError(null);
} catch (err: any) {
console.error('Failed to fetch business hours:', err);
// Use defaults on error
setError(null);
} finally {
setIsLoading(false);
}
};
fetchHours();
}, []);
if (isLoading) {
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="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
</div>
</div>
);
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
{title && (
@@ -48,7 +111,7 @@ export const BusinessHours: ComponentConfig<BusinessHoursProps> = {
)}
<div className="space-y-3">
{DEFAULT_HOURS.map(({ day, hours, isOpen }) => {
{hours.map(({ day, is_open, open, close }) => {
const isToday = showCurrent && day === today;
return (
@@ -61,7 +124,7 @@ export const BusinessHours: ComponentConfig<BusinessHoursProps> = {
}`}
>
<div className="flex items-center gap-2">
{isOpen ? (
{is_open ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : (
<XCircle className="w-4 h-4 text-red-400" />
@@ -83,12 +146,14 @@ export const BusinessHours: ComponentConfig<BusinessHoursProps> = {
</div>
<span
className={`${
isOpen
is_open
? 'text-gray-600 dark:text-gray-400'
: 'text-red-500 dark:text-red-400'
}`}
>
{hours}
{is_open && open && close
? `${formatTime(open)} - ${formatTime(close)}`
: 'Closed'}
</span>
</div>
);

View File

@@ -1,109 +1,70 @@
import React, { useState } from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { ContactFormProps } from '../../types';
import { Send, Loader2, CheckCircle } from 'lucide-react';
import { Send, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
import api from '../../../api/client';
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,
const ContactFormRender: React.FC<ContactFormProps> = ({
heading,
subheading,
submitButtonText,
successMessage,
includeConsent,
consentText,
}: ContactFormProps) {
const [formData, setFormData] = useState<Record<string, string>>({});
const [consent, setConsent] = useState(false);
showPhone,
showSubject,
subjectOptions,
padding = 'large',
backgroundVariant = 'white',
contentMaxWidth = '640px',
anchorId,
}) => {
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
subject: '',
message: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitError, setSubmitError] = useState<string | null>(null);
// Honeypot field for spam prevention
const [honeypot, setHoneypot] = useState('');
const paddingClasses: Record<string, string> = {
none: 'py-0',
small: 'py-8',
medium: 'py-12',
large: 'py-16 md:py-20',
xlarge: 'py-20 md:py-28',
};
const backgroundClasses: Record<string, string> = {
white: 'bg-white',
light: 'bg-neutral-50',
dark: 'bg-neutral-900 text-white',
'gradient-primary': 'bg-gradient-to-br from-primary-600 to-primary-800 text-white',
};
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 (!formData.name.trim()) {
newErrors.name = 'Name 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 (!formData.email.trim()) {
newErrors.email = 'Email is required';
} else {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.email)) {
newErrors.email = 'Please enter a valid email address';
}
});
}
if (includeConsent && !consent) {
newErrors.consent = 'You must agree to the terms';
if (!formData.message.trim()) {
newErrors.message = 'Message is required';
}
setErrors(newErrors);
@@ -112,6 +73,7 @@ function ContactFormDisplay({
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitError(null);
// Honeypot check
if (honeypot) {
@@ -125,129 +87,307 @@ function ContactFormDisplay({
setIsSubmitting(true);
try {
// TODO: Replace with actual API call
await new Promise((resolve) => setTimeout(resolve, 1000));
const payload = {
name: formData.name,
email: formData.email,
phone: formData.phone || undefined,
subject: formData.subject || 'Contact Form Submission',
message: formData.message,
};
await api.post('/public/contact/', payload);
setIsSubmitted(true);
} catch (error) {
} catch (error: any) {
console.error('Form submission error:', error);
const errorMessage = error?.response?.data?.error || 'Failed to send message. Please try again.';
setSubmitError(errorMessage);
} finally {
setIsSubmitting(false);
}
};
const isDark = backgroundVariant === 'dark' || backgroundVariant === 'gradient-primary';
const inputClasses = isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/50 focus:ring-white/50'
: 'bg-white border-neutral-300 text-neutral-900 focus:ring-primary-500';
const labelClasses = isDark ? 'text-white/90' : 'text-neutral-700';
const errorClasses = isDark ? 'text-red-300' : 'text-red-500';
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>
<section
id={anchorId}
className={`${paddingClasses[padding] || paddingClasses.large} ${backgroundClasses[backgroundVariant] || backgroundClasses.white}`}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div style={{ maxWidth: contentMaxWidth }} className="mx-auto">
<div className={`rounded-xl p-8 text-center ${isDark ? 'bg-white/10' : 'bg-green-50 border border-green-200'}`}>
<CheckCircle className={`w-12 h-12 mx-auto mb-4 ${isDark ? 'text-green-400' : 'text-green-500'}`} />
<p className={`text-lg ${isDark ? 'text-white' : 'text-green-700'}`}>
{successMessage}
</p>
</div>
</div>
</div>
</section>
);
}
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"
/>
<section
id={anchorId}
className={`${paddingClasses[padding] || paddingClasses.large} ${backgroundClasses[backgroundVariant] || backgroundClasses.white}`}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div style={{ maxWidth: contentMaxWidth }} className="mx-auto">
{/* Header */}
{(heading || subheading) && (
<div className="text-center mb-10">
{heading && (
<h2 className={`text-3xl md:text-4xl font-bold mb-4 ${isDark ? 'text-white' : 'text-neutral-900'}`}>
{heading}
</h2>
)}
{subheading && (
<p className={`text-lg ${isDark ? 'text-white/80' : 'text-neutral-600'}`}>
{subheading}
</p>
)}
</div>
)}
{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'
}`}
/>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Honeypot field - hidden from users */}
<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'
}`}
type="text"
name="website"
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
tabIndex={-1}
autoComplete="off"
style={{ position: 'absolute', left: '-9999px' }}
aria-hidden="true"
/>
)}
{errors[field.name] && (
<p className="mt-1 text-sm text-red-500">{errors[field.name]}</p>
)}
{/* Name */}
<div>
<label htmlFor="support-name" className={`block text-sm font-medium mb-2 ${labelClasses}`}>
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
id="support-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:outline-none transition-colors ${inputClasses} ${errors.name ? 'border-red-500' : ''}`}
placeholder="Your name"
/>
{errors.name && <p className={`mt-1 text-sm ${errorClasses}`}>{errors.name}</p>}
</div>
{/* Email */}
<div>
<label htmlFor="support-email" className={`block text-sm font-medium mb-2 ${labelClasses}`}>
Email <span className="text-red-500">*</span>
</label>
<input
type="email"
id="support-email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:outline-none transition-colors ${inputClasses} ${errors.email ? 'border-red-500' : ''}`}
placeholder="your@email.com"
/>
{errors.email && <p className={`mt-1 text-sm ${errorClasses}`}>{errors.email}</p>}
</div>
{/* Phone (optional) */}
{showPhone && (
<div>
<label htmlFor="support-phone" className={`block text-sm font-medium mb-2 ${labelClasses}`}>
Phone
</label>
<input
type="tel"
id="support-phone"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:outline-none transition-colors ${inputClasses}`}
placeholder="(555) 123-4567"
/>
</div>
)}
{/* Subject */}
{showSubject && (
<div>
<label htmlFor="support-subject" className={`block text-sm font-medium mb-2 ${labelClasses}`}>
Subject
</label>
{subjectOptions && subjectOptions.length > 0 ? (
<select
id="support-subject"
value={formData.subject}
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:outline-none transition-colors ${inputClasses}`}
>
<option value="">Select a topic...</option>
{subjectOptions.map((option, index) => (
<option key={index} value={option}>{option}</option>
))}
</select>
) : (
<input
type="text"
id="support-subject"
value={formData.subject}
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:outline-none transition-colors ${inputClasses}`}
placeholder="What is this about?"
/>
)}
</div>
)}
{/* Message */}
<div>
<label htmlFor="support-message" className={`block text-sm font-medium mb-2 ${labelClasses}`}>
Message <span className="text-red-500">*</span>
</label>
<textarea
id="support-message"
rows={5}
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:outline-none transition-colors resize-none ${inputClasses} ${errors.message ? 'border-red-500' : ''}`}
placeholder="How can we help you?"
/>
{errors.message && <p className={`mt-1 text-sm ${errorClasses}`}>{errors.message}</p>}
</div>
{/* Error Message */}
{submitError && (
<div className={`flex items-center gap-2 p-4 rounded-lg ${isDark ? 'bg-red-500/20 text-red-200' : 'bg-red-50 border border-red-200 text-red-700'}`}>
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<p className="text-sm">{submitError}</p>
</div>
)}
{/* Submit Button */}
<button
type="submit"
disabled={isSubmitting}
className={`w-full sm:w-auto px-8 py-3 rounded-lg font-medium flex items-center justify-center gap-2 transition-colors disabled:opacity-50 ${
isDark
? 'bg-white text-neutral-900 hover:bg-neutral-100'
: 'bg-primary-600 text-white hover:bg-primary-700'
}`}
>
{isSubmitting ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Sending...
</>
) : (
<>
<Send className="w-5 h-5" />
{submitButtonText}
</>
)}
</button>
</form>
</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>
</div>
</section>
);
}
};
export const ContactForm: ComponentConfig<ContactFormProps> = {
label: 'Contact Form',
fields: {
heading: {
type: 'text',
label: 'Heading',
},
subheading: {
type: 'text',
label: 'Subheading',
},
submitButtonText: {
type: 'text',
label: 'Submit Button Text',
},
successMessage: {
type: 'textarea',
label: 'Success Message',
},
showPhone: {
type: 'radio',
label: 'Show Phone Field',
options: [
{ label: 'No', value: false },
{ label: 'Yes', value: true },
],
},
showSubject: {
type: 'radio',
label: 'Show Subject Field',
options: [
{ label: 'No', value: false },
{ label: 'Yes', value: true },
],
},
subjectOptions: {
type: 'array',
label: 'Subject Options (leave empty for free text)',
arrayFields: {
value: { type: 'text' },
},
getItemSummary: (item: any) => item.value || 'Option',
},
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' },
],
},
backgroundVariant: {
type: 'select',
label: 'Background',
options: [
{ label: 'White', value: 'white' },
{ label: 'Light Gray', value: 'light' },
{ label: 'Dark', value: 'dark' },
{ label: 'Primary Gradient', value: 'gradient-primary' },
],
},
contentMaxWidth: {
type: 'text',
label: 'Max Width',
},
anchorId: {
type: 'text',
label: 'Anchor ID (for linking)',
},
},
defaultProps: {
heading: 'Get in Touch',
subheading: "Have a question or need help? We'd love to hear from you.",
submitButtonText: 'Send Message',
successMessage: "Thank you for your message! We'll get back to you as soon as possible.",
showPhone: true,
showSubject: true,
subjectOptions: [],
padding: 'large',
backgroundVariant: 'white',
contentMaxWidth: '640px',
},
render: ContactFormRender,
};
export default ContactForm;

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { ImageProps } from '../../types';
import { imagePickerField } from '../../fields/ImagePickerField';
const ASPECT_RATIO_CLASSES = {
'16:9': 'aspect-video',
@@ -20,8 +21,8 @@ export const Image: ComponentConfig<ImageProps> = {
label: 'Image',
fields: {
src: {
type: 'text',
label: 'Image URL',
...imagePickerField,
label: 'Image',
},
alt: {
type: 'text',

View File

@@ -2,6 +2,7 @@ import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import type { TestimonialProps } from '../../types';
import { Star, Quote } from 'lucide-react';
import { imagePickerField } from '../../fields/ImagePickerField';
export const Testimonial: ComponentConfig<TestimonialProps> = {
label: 'Testimonial',
@@ -19,8 +20,8 @@ export const Testimonial: ComponentConfig<TestimonialProps> = {
label: 'Author Title/Company',
},
avatar: {
type: 'text',
label: 'Avatar URL',
...imagePickerField,
label: 'Avatar Image',
},
rating: {
type: 'select',

View File

@@ -0,0 +1,157 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import {
createDesignControlsFields,
applyDesignControls,
type DesignControlsProps,
} from '../../theme';
export interface CTAButton {
text: string;
href: string;
variant?: 'primary' | 'secondary';
}
export interface CTASectionProps extends DesignControlsProps {
heading: string;
supportingText?: string;
buttons: CTAButton[];
variant: 'light' | 'dark' | 'gradient';
}
const CTASectionRender: React.FC<CTASectionProps> = (props) => {
const { heading, supportingText, buttons, variant, ...designControls } = props;
const applied = applyDesignControls(designControls);
const isDark = variant === 'dark' || variant === 'gradient';
const sectionClasses = {
light: 'bg-neutral-50',
dark: 'bg-neutral-900',
gradient: '', // Applied via applied.style
};
return (
<section
id={designControls.anchorId || undefined}
className={`${applied.className} ${sectionClasses[variant]}`}
style={applied.style}
>
<div
className={`${applied.containerClassName || 'max-w-4xl'} mx-auto px-4 sm:px-6 lg:px-8 text-center`}
>
<h2
className={`text-3xl sm:text-4xl font-bold ${
isDark ? 'text-white' : 'text-neutral-900'
}`}
>
{heading}
</h2>
{supportingText && (
<p
className={`mt-4 text-lg max-w-2xl mx-auto ${
isDark ? 'text-neutral-300' : 'text-neutral-600'
}`}
>
{supportingText}
</p>
)}
{buttons && buttons.length > 0 && (
<div className="mt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
{buttons.map((button, index) => {
const isPrimary = button.variant === 'primary' || (!button.variant && index === 0);
return (
<a
key={index}
href={button.href}
className={`w-full sm:w-auto px-8 py-3 rounded-lg font-semibold text-center transition-colors ${
isPrimary
? isDark
? 'bg-white text-neutral-900 hover:bg-neutral-100'
: 'bg-primary-600 text-white hover:bg-primary-700'
: isDark
? 'bg-transparent text-white border border-white/30 hover:bg-white/10'
: 'bg-transparent text-neutral-700 border border-neutral-300 hover:bg-neutral-100'
}`}
>
{button.text}
</a>
);
})}
</div>
)}
</div>
</section>
);
};
const designControlFields = createDesignControlsFields();
export const CTASection: ComponentConfig<CTASectionProps> = {
label: 'CTA Section',
fields: {
heading: {
type: 'text',
label: 'Heading',
},
supportingText: {
type: 'textarea',
label: 'Supporting Text',
},
buttons: {
type: 'array',
label: 'Buttons',
arrayFields: {
text: { type: 'text', label: 'Text' },
href: { type: 'text', label: 'URL' },
variant: {
type: 'select',
label: 'Variant',
options: [
{ label: 'Primary', value: 'primary' },
{ label: 'Secondary', value: 'secondary' },
],
},
},
defaultItemProps: {
text: 'Get Started',
href: '#',
variant: 'primary',
},
},
variant: {
type: 'select',
label: 'Variant',
options: [
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
{ label: 'Gradient', value: 'gradient' },
],
},
// Design Controls
padding: designControlFields.padding,
backgroundVariant: designControlFields.backgroundVariant,
gradientPreset: designControlFields.gradientPreset,
contentMaxWidth: designControlFields.contentMaxWidth,
alignment: designControlFields.alignment,
hideOnMobile: designControlFields.hideOnMobile,
hideOnTablet: designControlFields.hideOnTablet,
hideOnDesktop: designControlFields.hideOnDesktop,
anchorId: designControlFields.anchorId,
},
defaultProps: {
heading: 'Ready to take the next step?',
supportingText: 'Add a supporting message here.',
buttons: [
{ text: 'Get Started', href: '#', variant: 'primary' },
],
variant: 'gradient',
padding: 'lg',
contentMaxWidth: 'normal',
backgroundVariant: 'gradient',
gradientPreset: 'dark-purple',
},
render: CTASectionRender,
};
export default CTASection;

View File

@@ -0,0 +1,157 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import { Check } from 'lucide-react';
import {
createDesignControlsFields,
applyDesignControls,
type DesignControlsProps,
} from '../../theme';
export interface Feature {
heading: string;
text: string;
bullets?: string[];
image: string;
}
export interface ContentBlocksProps extends DesignControlsProps {
sectionHeading?: string;
features: Feature[];
}
const ContentBlocksRender: React.FC<ContentBlocksProps> = (props) => {
const { sectionHeading, features, ...designControls } = props;
const applied = applyDesignControls(designControls);
return (
<section
id={designControls.anchorId || undefined}
className={applied.className}
style={applied.style}
>
<div
className={`${applied.containerClassName || 'max-w-7xl'} mx-auto px-4 sm:px-6 lg:px-8`}
>
{sectionHeading && (
<h2
className={`text-3xl sm:text-4xl font-bold text-center mb-16 ${applied.textClassName || 'text-neutral-900'}`}
>
{sectionHeading}
</h2>
)}
{features && features.length > 0 && (
<div className="space-y-24">
{features.map((feature, index) => {
const isEven = index % 2 === 0;
return (
<div
key={index}
className={`grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-16 items-center`}
>
{/* Text content */}
<div className={isEven ? 'lg:order-1' : 'lg:order-2'}>
<h3 className="text-2xl sm:text-3xl font-bold text-neutral-900">
{feature.heading}
</h3>
<p className="mt-4 text-lg text-neutral-600">
{feature.text}
</p>
{feature.bullets && feature.bullets.length > 0 && (
<ul className="mt-8 space-y-3">
{feature.bullets.map((bullet, bulletIndex) => (
<li key={bulletIndex} className="flex items-start gap-3">
<div className="flex-shrink-0 w-5 h-5 rounded-full bg-primary-100 flex items-center justify-center mt-0.5">
<Check className="w-3 h-3 text-primary-600" />
</div>
<span className="text-neutral-700">{bullet}</span>
</li>
))}
</ul>
)}
</div>
{/* Image */}
<div className={isEven ? 'lg:order-2' : 'lg:order-1'}>
<img
src={feature.image}
alt={feature.heading}
className="rounded-xl shadow-lg"
/>
</div>
</div>
);
})}
</div>
)}
</div>
</section>
);
};
const designControlFields = createDesignControlsFields();
export const ContentBlocks: ComponentConfig<ContentBlocksProps> = {
label: 'Content Blocks',
fields: {
sectionHeading: {
type: 'text',
label: 'Section Heading (optional)',
},
features: {
type: 'array',
label: 'Features',
arrayFields: {
heading: { type: 'text', label: 'Heading' },
text: { type: 'textarea', label: 'Description' },
bullets: {
type: 'array',
label: 'Bullet Points',
arrayFields: {
// Using external field for string arrays
},
},
image: { type: 'text', label: 'Image URL' },
},
defaultItemProps: {
heading: 'Feature Heading',
text: 'Description of this feature and its benefits.',
bullets: ['Benefit one', 'Benefit two', 'Benefit three'],
image: 'https://placehold.co/500x350/f4f4f5/3f3f46?text=Feature',
},
},
// Design Controls
padding: designControlFields.padding,
backgroundVariant: designControlFields.backgroundVariant,
gradientPreset: designControlFields.gradientPreset,
contentMaxWidth: designControlFields.contentMaxWidth,
hideOnMobile: designControlFields.hideOnMobile,
hideOnTablet: designControlFields.hideOnTablet,
hideOnDesktop: designControlFields.hideOnDesktop,
anchorId: designControlFields.anchorId,
},
defaultProps: {
sectionHeading: '',
features: [
{
heading: 'First content block',
text: 'Add a description for this content block.',
bullets: ['First point', 'Second point', 'Third point'],
image: 'https://placehold.co/500x350/f4f4f5/3f3f46?text=Image',
},
{
heading: 'Second content block',
text: 'Add a description for this content block.',
bullets: ['First point', 'Second point', 'Third point'],
image: 'https://placehold.co/500x350/f4f4f5/3f3f46?text=Image',
},
],
padding: 'xl',
contentMaxWidth: 'wide',
},
render: ContentBlocksRender,
};
export default ContentBlocks;

View File

@@ -0,0 +1,197 @@
import React, { useState } from 'react';
import type { ComponentConfig } from '@measured/puck';
import { ChevronDown } from 'lucide-react';
import {
createDesignControlsFields,
applyDesignControls,
type DesignControlsProps,
} from '../../theme';
export interface FaqItem {
question: string;
answer: string;
}
export interface FAQAccordionProps extends DesignControlsProps {
heading?: string;
items: FaqItem[];
expandBehavior: 'single' | 'multiple';
variant: 'light' | 'dark';
}
function FaqAccordionInner({
heading,
items,
expandBehavior,
variant,
...designControls
}: FAQAccordionProps) {
const [openIndices, setOpenIndices] = useState<number[]>([]);
const applied = applyDesignControls(designControls);
const isDark = variant === 'dark';
const toggleItem = (index: number) => {
if (expandBehavior === 'single') {
setOpenIndices(openIndices.includes(index) ? [] : [index]);
} else {
setOpenIndices(
openIndices.includes(index)
? openIndices.filter((i) => i !== index)
: [...openIndices, index]
);
}
};
const isOpen = (index: number) => openIndices.includes(index);
return (
<section
id={designControls.anchorId || undefined}
className={`${applied.className} ${isDark ? 'bg-neutral-900' : ''}`}
style={applied.style}
>
<div
className={`${applied.containerClassName || 'max-w-3xl'} mx-auto px-4 sm:px-6 lg:px-8`}
>
{heading && (
<h2
className={`text-3xl font-bold text-center mb-12 ${
isDark ? 'text-white' : 'text-neutral-900'
}`}
>
{heading}
</h2>
)}
{items && items.length > 0 && (
<div className="space-y-4">
{items.map((item, index) => (
<div
key={index}
className={`rounded-lg overflow-hidden ${
isDark
? 'bg-neutral-800 border border-neutral-700'
: 'bg-white border border-neutral-200 shadow-sm'
}`}
>
<button
onClick={() => toggleItem(index)}
className={`w-full flex items-center justify-between p-5 text-left transition-colors ${
isDark
? 'hover:bg-neutral-750'
: 'hover:bg-neutral-50'
}`}
>
<span
className={`font-medium pr-4 ${
isDark ? 'text-white' : 'text-neutral-900'
}`}
>
{item.question}
</span>
<ChevronDown
className={`w-5 h-5 flex-shrink-0 transition-transform duration-200 ${
isDark ? 'text-neutral-400' : 'text-neutral-500'
} ${isOpen(index) ? 'rotate-180' : ''}`}
/>
</button>
<div
className={`overflow-hidden transition-all duration-200 ${
isOpen(index) ? 'max-h-96' : 'max-h-0'
}`}
>
<div className="px-5 pb-5">
<p
className={`whitespace-pre-wrap ${
isDark ? 'text-neutral-300' : 'text-neutral-600'
}`}
>
{item.answer}
</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
</section>
);
}
const designControlFields = createDesignControlsFields();
export const FAQAccordion: ComponentConfig<FAQAccordionProps> = {
label: 'FAQ Accordion',
fields: {
heading: {
type: 'text',
label: 'Heading (optional)',
},
items: {
type: 'array',
label: 'FAQ Items',
arrayFields: {
question: { type: 'text', label: 'Question' },
answer: { type: 'textarea', label: 'Answer' },
},
getItemSummary: (item) => item.question || 'Question',
defaultItemProps: {
question: 'How do I get started?',
answer: 'Getting started is easy! Simply sign up for a free account and follow the onboarding guide.',
},
},
expandBehavior: {
type: 'select',
label: 'Expand Behavior',
options: [
{ label: 'Single (accordion)', value: 'single' },
{ label: 'Multiple', value: 'multiple' },
],
},
variant: {
type: 'select',
label: 'Variant',
options: [
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
],
},
// Design Controls
padding: designControlFields.padding,
backgroundVariant: designControlFields.backgroundVariant,
gradientPreset: designControlFields.gradientPreset,
contentMaxWidth: designControlFields.contentMaxWidth,
hideOnMobile: designControlFields.hideOnMobile,
hideOnTablet: designControlFields.hideOnTablet,
hideOnDesktop: designControlFields.hideOnDesktop,
anchorId: designControlFields.anchorId,
},
defaultProps: {
heading: 'Frequently Asked Questions',
items: [
{
question: 'First question goes here?',
answer:
'Add your answer to this question here.',
},
{
question: 'Second question goes here?',
answer:
'Add your answer to this question here.',
},
{
question: 'Third question goes here?',
answer:
'Add your answer to this question here.',
},
],
expandBehavior: 'single',
variant: 'light',
padding: 'xl',
contentMaxWidth: 'normal',
},
render: FaqAccordionInner,
};
export default FAQAccordion;

View File

@@ -0,0 +1,248 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import { Twitter, Linkedin, Github, Facebook, Instagram, Youtube } from 'lucide-react';
export interface FooterLink {
label: string;
href: string;
}
export interface FooterColumn {
title: string;
links: FooterLink[];
}
export interface SocialLinks {
twitter?: string;
linkedin?: string;
github?: string;
facebook?: string;
instagram?: string;
youtube?: string;
}
export interface MiniCta {
text: string;
placeholder: string;
buttonText: string;
}
export interface FooterProps {
brandText: string;
brandLogo?: string;
description?: string;
columns: FooterColumn[];
socialLinks?: SocialLinks;
smallPrint?: string;
miniCta?: MiniCta;
}
const socialIcons = {
twitter: Twitter,
linkedin: Linkedin,
github: Github,
facebook: Facebook,
instagram: Instagram,
youtube: Youtube,
};
const FooterRender: React.FC<FooterProps> = ({
brandText,
brandLogo,
description,
columns,
socialLinks,
smallPrint,
miniCta,
}) => {
const hasSocialLinks = socialLinks && Object.values(socialLinks).some(Boolean);
return (
<footer className="bg-neutral-900 text-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<div className="grid grid-cols-1 lg:grid-cols-6 gap-12">
{/* Brand column */}
<div className="lg:col-span-2">
{brandLogo ? (
<img src={brandLogo} alt={brandText} className="h-8 w-auto" />
) : (
<span className="text-xl font-bold">{brandText}</span>
)}
{description && (
<p className="mt-4 text-neutral-400 text-sm leading-relaxed">
{description}
</p>
)}
{hasSocialLinks && (
<div className="mt-6 flex gap-4">
{Object.entries(socialLinks || {}).map(([key, url]) => {
if (!url) return null;
const Icon = socialIcons[key as keyof typeof socialIcons];
if (!Icon) return null;
return (
<a
key={key}
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-neutral-400 hover:text-white transition-colors"
aria-label={key}
>
<Icon className="w-5 h-5" />
</a>
);
})}
</div>
)}
</div>
{/* Link columns */}
{columns.map((column, index) => (
<div key={index}>
<h3 className="text-sm font-semibold text-white uppercase tracking-wider">
{column.title}
</h3>
<ul className="mt-4 space-y-3">
{column.links.map((link, linkIndex) => (
<li key={linkIndex}>
<a
href={link.href}
className="text-neutral-400 hover:text-white text-sm transition-colors"
>
{link.label}
</a>
</li>
))}
</ul>
</div>
))}
</div>
{/* Mini CTA (newsletter) */}
{miniCta && (
<div className="mt-12 pt-8 border-t border-neutral-800">
<div className="max-w-md">
<p className="text-sm font-semibold text-white">{miniCta.text}</p>
<div className="mt-4 flex gap-3">
<input
type="email"
placeholder={miniCta.placeholder}
className="flex-1 px-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-white placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
<button className="px-6 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 transition-colors">
{miniCta.buttonText}
</button>
</div>
</div>
</div>
)}
{/* Small print */}
{smallPrint && (
<div className="mt-12 pt-8 border-t border-neutral-800">
<p className="text-neutral-500 text-sm">{smallPrint}</p>
</div>
)}
</div>
</footer>
);
};
export const Footer: ComponentConfig<FooterProps> = {
label: 'Footer',
fields: {
brandText: {
type: 'text',
label: 'Brand Name',
},
brandLogo: {
type: 'text',
label: 'Brand Logo URL',
},
description: {
type: 'textarea',
label: 'Description',
},
columns: {
type: 'array',
label: 'Link Columns',
arrayFields: {
title: { type: 'text', label: 'Column Title' },
links: {
type: 'array',
label: 'Links',
arrayFields: {
label: { type: 'text', label: 'Label' },
href: { type: 'text', label: 'URL' },
},
},
},
defaultItemProps: {
title: 'Column',
links: [
{ label: 'Link 1', href: '#' },
{ label: 'Link 2', href: '#' },
],
},
},
socialLinks: {
type: 'object',
label: 'Social Links',
objectFields: {
twitter: { type: 'text', label: 'Twitter URL' },
linkedin: { type: 'text', label: 'LinkedIn URL' },
github: { type: 'text', label: 'GitHub URL' },
facebook: { type: 'text', label: 'Facebook URL' },
instagram: { type: 'text', label: 'Instagram URL' },
youtube: { type: 'text', label: 'YouTube URL' },
},
},
smallPrint: {
type: 'text',
label: 'Copyright/Small Print',
},
miniCta: {
type: 'object',
label: 'Newsletter CTA (optional)',
objectFields: {
text: { type: 'text', label: 'Heading' },
placeholder: { type: 'text', label: 'Placeholder' },
buttonText: { type: 'text', label: 'Button Text' },
},
},
},
defaultProps: {
brandText: 'Your Business',
description: 'Add a brief description of your business here.',
columns: [
{
title: 'Navigation',
links: [
{ label: 'Home', href: '#' },
{ label: 'About', href: '#' },
{ label: 'Services', href: '#' },
],
},
{
title: 'Contact',
links: [
{ label: 'Contact Us', href: '#' },
{ label: 'Support', href: '#' },
{ label: 'Location', href: '#' },
],
},
{
title: 'Legal',
links: [
{ label: 'Privacy Policy', href: '#' },
{ label: 'Terms of Service', href: '#' },
],
},
],
socialLinks: {},
smallPrint: '© 2024 Your Business. All rights reserved.',
},
render: FooterRender,
};
export default Footer;

View File

@@ -0,0 +1,878 @@
import React, { useState, useMemo } from 'react';
import type { ComponentConfig } from '@measured/puck';
import {
Clock,
DollarSign,
Loader2,
ChevronLeft,
ChevronRight,
Calendar as CalendarIcon,
Check,
User as UserIcon,
Mail,
Phone,
ArrowRight,
AlertCircle,
} from 'lucide-react';
import {
usePublicServices,
usePublicBusinessInfo,
usePublicAvailability,
usePublicBusinessHours,
useCreateBooking,
PublicService,
} from '../../../hooks/useBooking';
import { formatTimeForDisplay, getTimezoneAbbreviation, getUserTimezone } from '../../../utils/dateUtils';
import toast from 'react-hot-toast';
import {
createDesignControlsFields,
applyDesignControls,
type DesignControlsProps,
} from '../../theme';
export interface FullBookingFlowProps extends DesignControlsProps {
headline?: string;
subheadline?: string;
showPrices: boolean;
showDuration: boolean;
showDeposits: boolean;
allowGuestCheckout: boolean;
successMessage?: string;
successRedirectUrl?: string;
}
type BookingStep = 'service' | 'datetime' | 'details' | 'confirm' | 'success';
interface GuestInfo {
firstName: string;
lastName: string;
email: string;
phone: string;
notes: string;
}
const FullBookingFlowRender: React.FC<FullBookingFlowProps> = (props) => {
const {
headline,
subheadline,
showPrices,
showDuration,
showDeposits,
allowGuestCheckout,
successMessage,
successRedirectUrl,
...designControls
} = props;
const applied = applyDesignControls(designControls);
// State
const [step, setStep] = useState<BookingStep>('service');
const [selectedService, setSelectedService] = useState<PublicService | null>(null);
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [selectedTimeSlot, setSelectedTimeSlot] = useState<string | null>(null);
const [selectedTimeISO, setSelectedTimeISO] = useState<string | null>(null);
const [guestInfo, setGuestInfo] = useState<GuestInfo>({
firstName: '',
lastName: '',
email: '',
phone: '',
notes: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
// Hooks
const { data: services, isLoading: servicesLoading } = usePublicServices();
const { data: businessInfo } = usePublicBusinessInfo();
const createBooking = useCreateBooking();
// Calendar state
const today = new Date();
const [currentMonth, setCurrentMonth] = useState(today.getMonth());
const [currentYear, setCurrentYear] = useState(today.getFullYear());
// Date range for business hours
const { startDate, endDate } = useMemo(() => {
const start = new Date(currentYear, currentMonth, 1);
const end = new Date(currentYear, currentMonth + 1, 0);
return {
startDate: `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-01`,
endDate: `${end.getFullYear()}-${String(end.getMonth() + 1).padStart(2, '0')}-${String(end.getDate()).padStart(2, '0')}`,
};
}, [currentMonth, currentYear]);
const { data: businessHours, isLoading: businessHoursLoading } = usePublicBusinessHours(startDate, endDate);
const openDaysMap = useMemo(() => {
const map = new Map<string, boolean>();
if (businessHours?.dates) {
businessHours.dates.forEach((day) => {
map.set(day.date, day.is_open);
});
}
return map;
}, [businessHours]);
const dateString = selectedDate
? `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, '0')}-${String(selectedDate.getDate()).padStart(2, '0')}`
: undefined;
const { data: availability, isLoading: availabilityLoading } = usePublicAvailability(
selectedService?.id,
dateString
);
// Format price from cents
const formatPrice = (cents: number): string => `$${(cents / 100).toFixed(2)}`;
// Calendar helpers
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay();
const monthName = new Date(currentYear, currentMonth).toLocaleString('default', { month: 'long' });
const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
const isPast = (day: number) => {
const d = new Date(currentYear, currentMonth, day);
const now = new Date();
now.setHours(0, 0, 0, 0);
return d < now;
};
const isClosed = (day: number) => {
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
if (openDaysMap.size > 0) {
return openDaysMap.get(dateStr) === false;
}
return false;
};
const isSelected = (day: number) => {
return (
selectedDate?.getDate() === day &&
selectedDate?.getMonth() === currentMonth &&
selectedDate?.getFullYear() === currentYear
);
};
// Handlers
const handleSelectService = (service: PublicService) => {
setSelectedService(service);
setSelectedDate(null);
setSelectedTimeSlot(null);
setStep('datetime');
};
const handleSelectDate = (day: number) => {
const newDate = new Date(currentYear, currentMonth, day);
setSelectedDate(newDate);
setSelectedTimeSlot(null);
};
const handleSelectTime = (displayTime: string, isoTime: string) => {
setSelectedTimeSlot(displayTime);
setSelectedTimeISO(isoTime);
};
const handleContinueToDetails = () => {
if (selectedService && selectedDate && selectedTimeSlot) {
setStep('details');
}
};
const handleContinueToConfirm = () => {
if (guestInfo.firstName && guestInfo.lastName && guestInfo.email) {
setStep('confirm');
}
};
const handleSubmitBooking = async () => {
if (!selectedService || !selectedTimeISO) return;
setIsSubmitting(true);
try {
await createBooking.mutateAsync({
service_id: selectedService.id,
start_time: selectedTimeISO,
guest_first_name: guestInfo.firstName,
guest_last_name: guestInfo.lastName,
guest_email: guestInfo.email,
guest_phone: guestInfo.phone || undefined,
notes: guestInfo.notes || undefined,
});
setStep('success');
toast.success('Booking confirmed!');
} catch (error: any) {
toast.error(error?.response?.data?.detail || 'Failed to create booking');
} finally {
setIsSubmitting(false);
}
};
const handleReset = () => {
setStep('service');
setSelectedService(null);
setSelectedDate(null);
setSelectedTimeSlot(null);
setSelectedTimeISO(null);
setGuestInfo({ firstName: '', lastName: '', email: '', phone: '', notes: '' });
};
// Step indicator
const steps = [
{ key: 'service', label: 'Service' },
{ key: 'datetime', label: 'Date & Time' },
{ key: 'details', label: 'Your Info' },
{ key: 'confirm', label: 'Confirm' },
];
const currentStepIndex = steps.findIndex((s) => s.key === step);
// Render functions
const renderStepIndicator = () => {
if (step === 'success') return null;
return (
<div className="flex items-center justify-center mb-8">
{steps.map((s, idx) => (
<React.Fragment key={s.key}>
<div className="flex items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors ${
idx < currentStepIndex
? 'bg-green-500 text-white'
: idx === currentStepIndex
? 'bg-indigo-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
}`}
>
{idx < currentStepIndex ? <Check className="w-4 h-4" /> : idx + 1}
</div>
<span
className={`ml-2 text-sm hidden sm:inline ${
idx === currentStepIndex
? 'text-gray-900 dark:text-white font-medium'
: 'text-gray-500 dark:text-gray-400'
}`}
>
{s.label}
</span>
</div>
{idx < steps.length - 1 && (
<div
className={`w-8 sm:w-16 h-0.5 mx-2 ${
idx < currentStepIndex ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700'
}`}
/>
)}
</React.Fragment>
))}
</div>
);
};
const renderServiceStep = () => {
if (servicesLoading) {
return (
<div className="flex justify-center items-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-indigo-600" />
</div>
);
}
if (!services || services.length === 0) {
return (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p>No services available at this time.</p>
</div>
);
}
return (
<div className="space-y-4">
{services.map((service) => (
<button
key={service.id}
onClick={() => handleSelectService(service)}
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
selectedService?.id === service.id
? 'border-indigo-600 bg-indigo-50 dark:bg-indigo-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-indigo-300 dark:hover:border-indigo-600 bg-white dark:bg-gray-800'
}`}
>
<div className="flex justify-between items-start">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{service.name}
</h3>
{service.description && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{service.description}
</p>
)}
<div className="mt-3 flex items-center gap-4 text-sm">
{showDuration && (
<span className="flex items-center text-gray-600 dark:text-gray-400">
<Clock className="w-4 h-4 mr-1" />
{service.duration} min
</span>
)}
{showDeposits && service.deposit_amount_cents && service.deposit_amount_cents > 0 && (
<span className="text-indigo-600 dark:text-indigo-400 font-medium">
Deposit: {formatPrice(service.deposit_amount_cents)}
</span>
)}
</div>
</div>
{showPrices && (
<div className="text-lg font-bold text-gray-900 dark:text-white flex items-center">
<DollarSign className="w-4 h-4" />
{(service.price_cents / 100).toFixed(2)}
</div>
)}
</div>
</button>
))}
</div>
);
};
const renderDateTimeStep = () => {
const displayTimezone = availability?.timezone_display_mode === 'viewer'
? getUserTimezone()
: availability?.business_timezone || getUserTimezone();
const tzAbbrev = getTimezoneAbbreviation(displayTimezone);
return (
<div className="space-y-6">
{/* Back button */}
<button
onClick={() => setStep('service')}
className="flex items-center text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm"
>
<ChevronLeft className="w-4 h-4 mr-1" />
Back to services
</button>
{/* Selected service summary */}
{selectedService && (
<div className="p-4 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-200 dark:border-indigo-800">
<p className="text-sm text-indigo-600 dark:text-indigo-400 font-medium">Selected Service</p>
<p className="text-gray-900 dark:text-white font-semibold">{selectedService.name}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{selectedService.duration} min {formatPrice(selectedService.price_cents)}
</p>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Calendar */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<CalendarIcon className="w-5 h-5 mr-2 text-indigo-600" />
Select Date
</h3>
<div className="flex items-center space-x-1">
<button
onClick={() => {
if (currentMonth === 0) {
setCurrentMonth(11);
setCurrentYear(currentYear - 1);
} else {
setCurrentMonth(currentMonth - 1);
}
}}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm font-medium w-28 text-center">
{monthName} {currentYear}
</span>
<button
onClick={() => {
if (currentMonth === 11) {
setCurrentMonth(0);
setCurrentYear(currentYear + 1);
} else {
setCurrentMonth(currentMonth + 1);
}
}}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
<div className="grid grid-cols-7 gap-1 mb-2 text-center text-xs font-medium text-gray-500 dark:text-gray-400">
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d) => (
<div key={d}>{d}</div>
))}
</div>
{businessHoursLoading ? (
<div className="flex items-center justify-center h-48">
<Loader2 className="w-6 h-6 animate-spin text-indigo-600" />
</div>
) : (
<div className="grid grid-cols-7 gap-1">
{Array.from({ length: firstDayOfMonth }).map((_, i) => (
<div key={`empty-${i}`} />
))}
{days.map((day) => {
const past = isPast(day);
const closed = isClosed(day);
const disabled = past || closed;
const selected = isSelected(day);
return (
<button
key={day}
disabled={disabled}
onClick={() => handleSelectDate(day)}
className={`h-9 w-9 rounded-full flex items-center justify-center text-sm transition-all ${
selected
? 'bg-indigo-600 text-white'
: disabled
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
: 'hover:bg-indigo-50 dark:hover:bg-indigo-900/30 text-gray-700 dark:text-gray-200'
}`}
>
{day}
</button>
);
})}
</div>
)}
</div>
{/* Time slots */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Available Times
</h3>
{!selectedDate ? (
<div className="h-48 flex items-center justify-center text-gray-400 italic">
Please select a date
</div>
) : availabilityLoading ? (
<div className="h-48 flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-indigo-600" />
</div>
) : availability?.is_open === false ? (
<div className="h-48 flex items-center justify-center text-gray-400">
Business closed on this date
</div>
) : availability?.slots && availability.slots.length > 0 ? (
<>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Times shown in {tzAbbrev}
</p>
<div className="grid grid-cols-3 gap-2 max-h-64 overflow-y-auto">
{availability.slots.map((slot) => {
const displayTime = formatTimeForDisplay(
slot.time,
availability.timezone_display_mode === 'viewer' ? null : availability.business_timezone
);
return (
<button
key={slot.time}
disabled={!slot.available}
onClick={() => handleSelectTime(displayTime, slot.time)}
className={`py-2 px-3 rounded-lg text-sm font-medium transition-all ${
!slot.available
? 'bg-gray-100 dark:bg-gray-700 text-gray-400 cursor-not-allowed'
: selectedTimeSlot === displayTime
? 'bg-indigo-600 text-white'
: 'bg-gray-50 dark:bg-gray-700 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 text-gray-700 dark:text-gray-200'
}`}
>
{displayTime}
</button>
);
})}
</div>
</>
) : (
<div className="h-48 flex items-center justify-center text-gray-400">
No available times
</div>
)}
</div>
</div>
{/* Continue button */}
<div className="flex justify-end">
<button
onClick={handleContinueToDetails}
disabled={!selectedDate || !selectedTimeSlot}
className="px-6 py-3 bg-indigo-600 text-white rounded-lg font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-indigo-700 transition-colors flex items-center"
>
Continue
<ArrowRight className="w-4 h-4 ml-2" />
</button>
</div>
</div>
);
};
const renderDetailsStep = () => (
<div className="space-y-6 max-w-lg mx-auto">
<button
onClick={() => setStep('datetime')}
className="flex items-center text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm"
>
<ChevronLeft className="w-4 h-4 mr-1" />
Back to date & time
</button>
<div className="text-center">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">Your Information</h3>
<p className="text-gray-500 dark:text-gray-400 mt-1">
{allowGuestCheckout
? "Enter your details to complete the booking"
: "Create an account or sign in to book"}
</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
First Name *
</label>
<div className="relative">
<UserIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
required
value={guestInfo.firstName}
onChange={(e) => setGuestInfo({ ...guestInfo, firstName: e.target.value })}
className="w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="John"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Last Name *
</label>
<input
type="text"
required
value={guestInfo.lastName}
onChange={(e) => setGuestInfo({ ...guestInfo, lastName: e.target.value })}
className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="Doe"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email *
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="email"
required
value={guestInfo.email}
onChange={(e) => setGuestInfo({ ...guestInfo, email: e.target.value })}
className="w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="john@example.com"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Phone (optional)
</label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="tel"
value={guestInfo.phone}
onChange={(e) => setGuestInfo({ ...guestInfo, phone: e.target.value })}
className="w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="(555) 123-4567"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Notes (optional)
</label>
<textarea
value={guestInfo.notes}
onChange={(e) => setGuestInfo({ ...guestInfo, notes: e.target.value })}
rows={3}
className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white resize-none"
placeholder="Any special requests or notes..."
/>
</div>
</div>
</div>
<div className="flex justify-end">
<button
onClick={handleContinueToConfirm}
disabled={!guestInfo.firstName || !guestInfo.lastName || !guestInfo.email}
className="px-6 py-3 bg-indigo-600 text-white rounded-lg font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-indigo-700 transition-colors flex items-center"
>
Review Booking
<ArrowRight className="w-4 h-4 ml-2" />
</button>
</div>
</div>
);
const renderConfirmStep = () => (
<div className="space-y-6 max-w-lg mx-auto">
<button
onClick={() => setStep('details')}
className="flex items-center text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white text-sm"
>
<ChevronLeft className="w-4 h-4 mr-1" />
Back to your info
</button>
<div className="text-center">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">Confirm Your Booking</h3>
<p className="text-gray-500 dark:text-gray-400 mt-1">Please review the details below</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-4">
<div className="flex justify-between items-start pb-4 border-b border-gray-200 dark:border-gray-700">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Service</p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">{selectedService?.name}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{selectedService?.duration} minutes
</p>
</div>
{showPrices && selectedService && (
<p className="text-lg font-bold text-gray-900 dark:text-white">
{formatPrice(selectedService.price_cents)}
</p>
)}
</div>
<div className="pb-4 border-b border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-500 dark:text-gray-400">Date & Time</p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">
{selectedDate?.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</p>
<p className="text-gray-600 dark:text-gray-400">{selectedTimeSlot}</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Contact Information</p>
<p className="font-semibold text-gray-900 dark:text-white">
{guestInfo.firstName} {guestInfo.lastName}
</p>
<p className="text-gray-600 dark:text-gray-400">{guestInfo.email}</p>
{guestInfo.phone && <p className="text-gray-600 dark:text-gray-400">{guestInfo.phone}</p>}
</div>
{guestInfo.notes && (
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-500 dark:text-gray-400">Notes</p>
<p className="text-gray-700 dark:text-gray-300">{guestInfo.notes}</p>
</div>
)}
</div>
<button
onClick={handleSubmitBooking}
disabled={isSubmitting}
className="w-full py-3 bg-indigo-600 text-white rounded-lg font-semibold disabled:opacity-50 hover:bg-indigo-700 transition-colors flex items-center justify-center"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Booking...
</>
) : (
<>
<Check className="w-4 h-4 mr-2" />
Confirm Booking
</>
)}
</button>
</div>
);
const renderSuccessStep = () => (
<div className="text-center py-12 max-w-lg mx-auto">
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<Check className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
{successMessage || 'Booking Confirmed!'}
</h3>
<p className="text-gray-500 dark:text-gray-400 mb-6">
A confirmation email has been sent to {guestInfo.email}
</p>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 mb-6 text-left">
<p className="text-sm text-gray-600 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-white">{selectedService?.name}</span>
<br />
{selectedDate?.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
})}{' '}
at {selectedTimeSlot}
</p>
</div>
<div className="flex gap-4 justify-center">
<button
onClick={handleReset}
className="px-6 py-2 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 transition-colors"
>
Book Another
</button>
{successRedirectUrl && (
<a
href={successRedirectUrl}
className="px-6 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg font-medium hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Continue
</a>
)}
</div>
</div>
);
return (
<section
id={designControls.anchorId || undefined}
className={applied.className}
style={applied.style}
>
<div className={`${applied.containerClassName || 'max-w-4xl'} mx-auto px-4 sm:px-6 lg:px-8`}>
{/* Header */}
{(headline || subheadline) && step !== 'success' && (
<div className="text-center mb-8">
{headline && (
<h2
className={`text-3xl sm:text-4xl font-bold mb-4 ${
applied.textClassName || 'text-gray-900 dark:text-white'
}`}
>
{headline}
</h2>
)}
{subheadline && (
<p className="text-lg text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
{subheadline}
</p>
)}
</div>
)}
{/* Step indicator */}
{renderStepIndicator()}
{/* Step content */}
{step === 'service' && renderServiceStep()}
{step === 'datetime' && renderDateTimeStep()}
{step === 'details' && renderDetailsStep()}
{step === 'confirm' && renderConfirmStep()}
{step === 'success' && renderSuccessStep()}
</div>
</section>
);
};
const designControlFields = createDesignControlsFields();
export const FullBookingFlow: ComponentConfig<FullBookingFlowProps> = {
label: 'Full Booking Flow',
fields: {
headline: {
type: 'text',
label: 'Headline',
},
subheadline: {
type: 'textarea',
label: 'Subheadline',
},
showPrices: {
type: 'radio',
label: 'Show Prices',
options: [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
],
},
showDuration: {
type: 'radio',
label: 'Show Duration',
options: [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
],
},
showDeposits: {
type: 'radio',
label: 'Show Deposits',
options: [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
],
},
allowGuestCheckout: {
type: 'radio',
label: 'Allow Guest Checkout',
options: [
{ label: 'Yes (no account required)', value: true },
{ label: 'No (require account)', value: false },
],
},
successMessage: {
type: 'text',
label: 'Success Message',
},
successRedirectUrl: {
type: 'text',
label: 'Success Redirect URL (optional)',
},
// Design Controls
padding: designControlFields.padding,
backgroundVariant: designControlFields.backgroundVariant,
gradientPreset: designControlFields.gradientPreset,
contentMaxWidth: designControlFields.contentMaxWidth,
anchorId: designControlFields.anchorId,
},
defaultProps: {
headline: 'Book Your Appointment',
subheadline: 'Select a service, choose a time, and complete your booking in minutes.',
showPrices: true,
showDuration: true,
showDeposits: true,
allowGuestCheckout: true,
successMessage: 'Booking Confirmed!',
padding: 'xl',
backgroundVariant: 'light',
contentMaxWidth: 'normal',
},
render: FullBookingFlowRender,
};
export default FullBookingFlow;

View File

@@ -0,0 +1,183 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import {
createDesignControlsFields,
applyDesignControls,
type DesignControlsProps,
} from '../../theme';
export interface GalleryItem {
image: string;
title: string;
text?: string;
link?: string;
}
export interface GalleryGridProps extends DesignControlsProps {
heading?: string;
items: GalleryItem[];
columnsMobile: 1 | 2;
columnsTablet: 2 | 3;
columnsDesktop: 2 | 3 | 4;
}
const GalleryGridRender: React.FC<GalleryGridProps> = (props) => {
const {
heading,
items,
columnsMobile,
columnsTablet,
columnsDesktop,
...designControls
} = props;
const applied = applyDesignControls(designControls);
const gridCols = {
mobile: { 1: 'grid-cols-1', 2: 'grid-cols-2' },
tablet: { 2: 'sm:grid-cols-2', 3: 'sm:grid-cols-3' },
desktop: { 2: 'lg:grid-cols-2', 3: 'lg:grid-cols-3', 4: 'lg:grid-cols-4' },
};
return (
<section
id={designControls.anchorId || undefined}
className={applied.className}
style={applied.style}
>
<div
className={`${applied.containerClassName || 'max-w-7xl'} mx-auto px-4 sm:px-6 lg:px-8`}
>
{heading && (
<h2
className={`text-3xl font-bold text-center mb-12 ${applied.textClassName || 'text-neutral-900'}`}
>
{heading}
</h2>
)}
{items && items.length > 0 && (
<div
className={`grid ${gridCols.mobile[columnsMobile]} ${gridCols.tablet[columnsTablet]} ${gridCols.desktop[columnsDesktop]} gap-6 sm:gap-8`}
>
{items.map((item, index) => (
<div
key={index}
className="group bg-white rounded-xl overflow-hidden shadow-sm hover:shadow-lg transition-shadow"
>
<div className="aspect-[4/3] overflow-hidden">
<img
src={item.image}
alt={item.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>
<div className="p-6">
<h3 className="font-semibold text-neutral-900 group-hover:text-primary-600 transition-colors">
{item.link ? (
<a href={item.link}>{item.title}</a>
) : (
item.title
)}
</h3>
{item.text && (
<p className="mt-2 text-sm text-neutral-600">{item.text}</p>
)}
</div>
</div>
))}
</div>
)}
</div>
</section>
);
};
const designControlFields = createDesignControlsFields();
export const GalleryGrid: ComponentConfig<GalleryGridProps> = {
label: 'Gallery Grid',
fields: {
heading: {
type: 'text',
label: 'Heading (optional)',
},
items: {
type: 'array',
label: 'Items',
arrayFields: {
image: { type: 'text', label: 'Image URL' },
title: { type: 'text', label: 'Title' },
text: { type: 'textarea', label: 'Description' },
link: { type: 'text', label: 'Link URL (optional)' },
},
defaultItemProps: {
image: 'https://placehold.co/400x300/f4f4f5/3f3f46?text=Image',
title: 'Item Title',
text: 'Brief description of this item.',
},
},
columnsMobile: {
type: 'select',
label: 'Columns (Mobile)',
options: [
{ label: '1', value: 1 },
{ label: '2', value: 2 },
],
},
columnsTablet: {
type: 'select',
label: 'Columns (Tablet)',
options: [
{ label: '2', value: 2 },
{ label: '3', value: 3 },
],
},
columnsDesktop: {
type: 'select',
label: 'Columns (Desktop)',
options: [
{ label: '2', value: 2 },
{ label: '3', value: 3 },
{ label: '4', value: 4 },
],
},
// Design Controls
padding: designControlFields.padding,
backgroundVariant: designControlFields.backgroundVariant,
gradientPreset: designControlFields.gradientPreset,
contentMaxWidth: designControlFields.contentMaxWidth,
hideOnMobile: designControlFields.hideOnMobile,
hideOnTablet: designControlFields.hideOnTablet,
hideOnDesktop: designControlFields.hideOnDesktop,
anchorId: designControlFields.anchorId,
},
defaultProps: {
heading: '',
items: [
{
image: 'https://placehold.co/400x300/f4f4f5/3f3f46?text=Image',
title: 'Item title',
text: 'Add a description here.',
},
{
image: 'https://placehold.co/400x300/f4f4f5/3f3f46?text=Image',
title: 'Item title',
text: 'Add a description here.',
},
{
image: 'https://placehold.co/400x300/f4f4f5/3f3f46?text=Image',
title: 'Item title',
text: 'Add a description here.',
},
],
columnsMobile: 1,
columnsTablet: 2,
columnsDesktop: 3,
padding: 'xl',
backgroundVariant: 'light',
contentMaxWidth: 'wide',
},
render: GalleryGridRender,
};
export default GalleryGrid;

View File

@@ -0,0 +1,215 @@
import React, { useState } from 'react';
import type { ComponentConfig } from '@measured/puck';
import { Menu, X } from 'lucide-react';
export interface HeaderLink {
label: string;
href: string;
}
export interface HeaderCta {
text: string;
href: string;
}
export interface HeaderProps {
brandText: string;
brandLogo?: string;
links: HeaderLink[];
ctaButton?: HeaderCta;
variant: 'transparent-on-dark' | 'light' | 'dark';
showMobileMenu: boolean;
sticky?: boolean;
}
const HeaderRender: React.FC<HeaderProps> = ({
brandText,
brandLogo,
links,
ctaButton,
variant,
showMobileMenu,
sticky,
}) => {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const isTransparent = variant === 'transparent-on-dark';
const isDark = variant === 'dark';
const navClasses = isTransparent
? 'bg-transparent text-white'
: isDark
? 'bg-neutral-900 text-white shadow-sm'
: 'bg-white text-neutral-900 shadow-sm';
const linkClasses = isTransparent
? 'text-white/80 hover:text-white'
: isDark
? 'text-neutral-300 hover:text-white'
: 'text-neutral-600 hover:text-neutral-900';
const ctaClasses = isTransparent
? 'bg-white text-neutral-900 hover:bg-neutral-100'
: isDark
? 'bg-primary-600 text-white hover:bg-primary-700'
: 'bg-primary-600 text-white hover:bg-primary-700';
return (
<nav className={`relative z-50 ${navClasses} ${sticky ? 'sticky top-0' : ''}`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Brand */}
<div className="flex-shrink-0">
{brandLogo ? (
<img src={brandLogo} alt={brandText} className="h-8 w-auto" />
) : (
<span className="text-xl font-bold">{brandText}</span>
)}
</div>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center space-x-8">
{links.map((link, index) => (
<a
key={index}
href={link.href}
className={`text-sm font-medium transition-colors ${linkClasses}`}
>
{link.label}
</a>
))}
{ctaButton && (
<a
href={ctaButton.href}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${ctaClasses}`}
>
{ctaButton.text}
</a>
)}
</div>
{/* Mobile Menu Button */}
{showMobileMenu && (
<div className="md:hidden">
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className={`p-2 rounded-md ${linkClasses}`}
aria-label="Toggle menu"
>
{mobileMenuOpen ? (
<X className="h-6 w-6" />
) : (
<Menu className="h-6 w-6" />
)}
</button>
</div>
)}
</div>
</div>
{/* Mobile Menu */}
{showMobileMenu && mobileMenuOpen && (
<div className="md:hidden absolute top-16 left-0 right-0 bg-white shadow-lg">
<div className="px-4 py-4 space-y-3">
{links.map((link, index) => (
<a
key={index}
href={link.href}
className="block text-neutral-600 hover:text-neutral-900 text-sm font-medium py-2"
onClick={() => setMobileMenuOpen(false)}
>
{link.label}
</a>
))}
{ctaButton && (
<a
href={ctaButton.href}
className="block bg-primary-600 text-white text-center px-4 py-2 rounded-lg text-sm font-medium"
onClick={() => setMobileMenuOpen(false)}
>
{ctaButton.text}
</a>
)}
</div>
</div>
)}
</nav>
);
};
export const Header: ComponentConfig<HeaderProps> = {
label: 'Header',
fields: {
brandText: {
type: 'text',
label: 'Business Name',
},
brandLogo: {
type: 'text',
label: 'Logo URL',
},
links: {
type: 'array',
label: 'Navigation Links',
arrayFields: {
label: { type: 'text', label: 'Label' },
href: { type: 'text', label: 'URL' },
},
defaultItemProps: {
label: 'Link',
href: '#',
},
},
ctaButton: {
type: 'object',
label: 'Action Button (optional)',
objectFields: {
text: { type: 'text', label: 'Button Text' },
href: { type: 'text', label: 'Button URL' },
},
},
variant: {
type: 'select',
label: 'Style',
options: [
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
{ label: 'Transparent on Dark', value: 'transparent-on-dark' },
],
},
showMobileMenu: {
type: 'radio',
label: 'Mobile Menu',
options: [
{ label: 'Show', value: true },
{ label: 'Hide', value: false },
],
},
sticky: {
type: 'radio',
label: 'Sticky Header',
options: [
{ label: 'No', value: false },
{ label: 'Yes', value: true },
],
},
},
defaultProps: {
brandText: 'Your Business',
links: [
{ label: 'Services', href: '#services' },
{ label: 'About', href: '#about' },
{ label: 'Contact', href: '#contact' },
],
ctaButton: {
text: 'Book Now',
href: '#book',
},
variant: 'light',
showMobileMenu: true,
sticky: false,
},
render: HeaderRender,
};
export default Header;

View File

@@ -0,0 +1,266 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import {
createDesignControlsFields,
applyDesignControls,
type DesignControlsProps,
} from '../../theme';
export interface HeroCtaButton {
text: string;
href: string;
}
export interface HeroMedia {
type: 'image' | 'none';
src?: string;
alt?: string;
}
export interface HeroProps extends DesignControlsProps {
headline: string;
subheadline: string;
primaryCta: HeroCtaButton;
secondaryCta?: HeroCtaButton;
media?: HeroMedia;
badge?: string;
variant: 'centered' | 'split' | 'minimal';
fullWidth?: boolean;
}
const HeroRender: React.FC<HeroProps> = (props) => {
const {
headline,
subheadline,
primaryCta,
secondaryCta,
media,
badge,
variant,
fullWidth,
...designControls
} = props;
const applied = applyDesignControls(designControls);
const isGradient = designControls.backgroundVariant === 'gradient';
const isDark = isGradient || designControls.backgroundVariant === 'dark';
const isSplit = variant === 'split';
const isMinimal = variant === 'minimal';
const containerClass = fullWidth
? 'w-full px-4 sm:px-6 lg:px-8'
: `${applied.containerClassName || 'max-w-7xl'} mx-auto px-4 sm:px-6 lg:px-8`;
return (
<section
id={designControls.anchorId || undefined}
className={`relative overflow-hidden ${applied.className}`}
style={applied.style}
>
{/* Overlay for background images */}
{designControls.backgroundImage && designControls.overlayStrength && (
<div
className="absolute inset-0 bg-black pointer-events-none"
style={{ opacity: designControls.overlayStrength / 100 }}
/>
)}
<div className={`relative ${containerClass} py-16 sm:py-24 lg:py-32`}>
<div
className={
isSplit
? 'grid grid-cols-1 lg:grid-cols-2 gap-12 items-center'
: 'text-center max-w-4xl mx-auto'
}
>
<div>
{/* Badge */}
{badge && !isMinimal && (
<div className={`mb-6 ${isSplit ? '' : ''}`}>
<span
className={`inline-block px-4 py-1.5 rounded-full text-sm font-medium ${
isDark
? 'bg-white/10 text-white border border-white/20'
: 'bg-primary-100 text-primary-700'
}`}
>
{badge}
</span>
</div>
)}
{/* Headline */}
<h1
className={`text-4xl sm:text-5xl ${isMinimal ? '' : 'lg:text-6xl'} font-bold tracking-tight ${
isDark ? 'text-white' : 'text-neutral-900'
}`}
>
{headline}
</h1>
{/* Subheadline */}
<p
className={`mt-6 text-lg sm:text-xl ${isSplit ? '' : 'max-w-2xl mx-auto'} ${
isDark ? 'text-white/80' : 'text-neutral-600'
}`}
>
{subheadline}
</p>
{/* CTAs */}
{(primaryCta?.text || secondaryCta?.text) && (
<div
className={`mt-10 flex flex-col sm:flex-row gap-4 ${
isSplit ? '' : 'items-center justify-center'
}`}
>
{primaryCta?.text && (
<a
href={primaryCta.href || '#'}
className={`w-full sm:w-auto px-8 py-3 rounded-lg font-semibold text-center transition-colors ${
isDark
? 'bg-white text-neutral-900 hover:bg-neutral-100'
: 'bg-primary-600 text-white hover:bg-primary-700'
}`}
>
{primaryCta.text}
</a>
)}
{secondaryCta?.text && (
<a
href={secondaryCta.href || '#'}
className={`w-full sm:w-auto px-8 py-3 rounded-lg font-semibold text-center transition-colors ${
isDark
? 'bg-transparent text-white border border-white/30 hover:bg-white/10'
: 'bg-transparent text-neutral-700 border border-neutral-300 hover:bg-neutral-50'
}`}
>
{secondaryCta.text}
</a>
)}
</div>
)}
</div>
{/* Media */}
{media?.type === 'image' && media.src && (
<div className={isSplit ? '' : 'mt-16'}>
<div className={`relative ${isSplit ? '' : 'mx-auto max-w-5xl'}`}>
<div
className={`rounded-xl overflow-hidden shadow-2xl ${
isDark ? 'ring-1 ring-white/10' : 'ring-1 ring-neutral-200'
}`}
>
<img
src={media.src}
alt={media.alt || 'Hero image'}
className="w-full h-auto"
/>
</div>
</div>
</div>
)}
</div>
</div>
</section>
);
};
const designControlFields = createDesignControlsFields();
export const Hero: ComponentConfig<HeroProps> = {
label: 'Hero',
fields: {
headline: {
type: 'text',
label: 'Headline',
},
subheadline: {
type: 'textarea',
label: 'Subheadline',
},
primaryCta: {
type: 'object',
label: 'Primary Button',
objectFields: {
text: { type: 'text', label: 'Button Text' },
href: { type: 'text', label: 'Button URL' },
},
},
secondaryCta: {
type: 'object',
label: 'Secondary Button (optional)',
objectFields: {
text: { type: 'text', label: 'Button Text' },
href: { type: 'text', label: 'Button URL' },
},
},
media: {
type: 'object',
label: 'Media',
objectFields: {
type: {
type: 'select',
label: 'Type',
options: [
{ label: 'None', value: 'none' },
{ label: 'Image', value: 'image' },
],
},
src: { type: 'text', label: 'Image URL' },
alt: { type: 'text', label: 'Alt Text' },
},
},
badge: {
type: 'text',
label: 'Badge Text (optional)',
},
variant: {
type: 'select',
label: 'Layout Variant',
options: [
{ label: 'Centered', value: 'centered' },
{ label: 'Split (text + media)', value: 'split' },
{ label: 'Minimal', value: 'minimal' },
],
},
fullWidth: {
type: 'radio',
label: 'Full Width',
options: [
{ label: 'No', value: false },
{ label: 'Yes', value: true },
],
},
// Design Controls
padding: designControlFields.padding,
backgroundVariant: designControlFields.backgroundVariant,
gradientPreset: designControlFields.gradientPreset,
backgroundImage: designControlFields.backgroundImage,
overlayStrength: designControlFields.overlayStrength,
contentMaxWidth: designControlFields.contentMaxWidth,
alignment: designControlFields.alignment,
hideOnMobile: designControlFields.hideOnMobile,
hideOnTablet: designControlFields.hideOnTablet,
hideOnDesktop: designControlFields.hideOnDesktop,
anchorId: designControlFields.anchorId,
},
defaultProps: {
headline: 'Your headline goes here',
subheadline: 'Add a compelling subheadline that describes what you offer.',
primaryCta: {
text: 'Get Started',
href: '#',
},
variant: 'centered',
fullWidth: false,
backgroundVariant: 'none',
padding: 'xl',
contentMaxWidth: 'wide',
alignment: 'center',
},
render: HeroRender,
};
export default Hero;

View File

@@ -0,0 +1,158 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import {
createDesignControlsFields,
applyDesignControls,
type DesignControlsProps,
} from '../../theme';
export interface Logo {
src: string;
alt: string;
href?: string;
}
export interface LogoCloudProps extends DesignControlsProps {
heading?: string;
logos: Logo[];
grayscale: boolean;
spacingDensity: 'compact' | 'normal' | 'spacious';
}
const LogoCloudRender: React.FC<LogoCloudProps> = (props) => {
const { heading, logos, grayscale, spacingDensity, ...designControls } = props;
const applied = applyDesignControls(designControls);
const gapClasses = {
compact: 'gap-6',
normal: 'gap-8 sm:gap-12',
spacious: 'gap-12 sm:gap-16',
};
return (
<section
id={designControls.anchorId || undefined}
className={`${applied.className}`}
style={applied.style}
>
<div
className={`${applied.containerClassName || 'max-w-7xl'} mx-auto px-4 sm:px-6 lg:px-8`}
>
{heading && (
<p
className={`text-center text-sm font-medium uppercase tracking-wider mb-8 ${applied.textClassName || 'text-neutral-500'}`}
>
{heading}
</p>
)}
{logos && logos.length > 0 && (
<div
className={`flex flex-wrap items-center justify-center ${gapClasses[spacingDensity]}`}
>
{logos.map((logo, index) => {
const imgElement = (
<img
src={logo.src}
alt={logo.alt}
className={`h-8 sm:h-10 w-auto object-contain ${
grayscale ? 'grayscale opacity-60 hover:grayscale-0 hover:opacity-100' : ''
} transition-all duration-200`}
/>
);
if (logo.href) {
return (
<a
key={index}
href={logo.href}
className="flex-shrink-0"
target="_blank"
rel="noopener noreferrer"
>
{imgElement}
</a>
);
}
return (
<div key={index} className="flex-shrink-0">
{imgElement}
</div>
);
})}
</div>
)}
</div>
</section>
);
};
const designControlFields = createDesignControlsFields();
export const LogoCloud: ComponentConfig<LogoCloudProps> = {
label: 'Logo Cloud',
fields: {
heading: {
type: 'text',
label: 'Heading (optional)',
},
logos: {
type: 'array',
label: 'Logos',
arrayFields: {
src: { type: 'text', label: 'Image URL' },
alt: { type: 'text', label: 'Alt Text' },
href: { type: 'text', label: 'Link URL (optional)' },
},
defaultItemProps: {
src: 'https://placehold.co/160x48/e4e4e7/71717a?text=Logo',
alt: 'Company logo',
},
},
grayscale: {
type: 'radio',
label: 'Grayscale Logos',
options: [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
],
},
spacingDensity: {
type: 'select',
label: 'Spacing Density',
options: [
{ label: 'Compact', value: 'compact' },
{ label: 'Normal', value: 'normal' },
{ label: 'Spacious', value: 'spacious' },
],
},
// Design Controls
padding: designControlFields.padding,
backgroundVariant: designControlFields.backgroundVariant,
gradientPreset: designControlFields.gradientPreset,
contentMaxWidth: designControlFields.contentMaxWidth,
hideOnMobile: designControlFields.hideOnMobile,
hideOnTablet: designControlFields.hideOnTablet,
hideOnDesktop: designControlFields.hideOnDesktop,
anchorId: designControlFields.anchorId,
},
defaultProps: {
heading: '',
logos: [
{ src: 'https://placehold.co/160x48/e4e4e7/71717a?text=Logo', alt: 'Logo 1' },
{ src: 'https://placehold.co/160x48/e4e4e7/71717a?text=Logo', alt: 'Logo 2' },
{ src: 'https://placehold.co/160x48/e4e4e7/71717a?text=Logo', alt: 'Logo 3' },
{ src: 'https://placehold.co/160x48/e4e4e7/71717a?text=Logo', alt: 'Logo 4' },
],
grayscale: true,
spacingDensity: 'normal',
padding: 'lg',
backgroundVariant: 'light',
contentMaxWidth: 'wide',
},
render: LogoCloudRender,
};
export default LogoCloud;

View File

@@ -0,0 +1,331 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import { Check } from 'lucide-react';
import {
createDesignControlsFields,
applyDesignControls,
type DesignControlsProps,
} from '../../theme';
export interface PlanCta {
text: string;
href: string;
}
export interface Plan {
name: string;
price: string;
subtitle?: string;
features: string[];
cta: PlanCta;
}
export interface PricingCardsProps extends DesignControlsProps {
sectionHeading?: string;
sectionSubheading?: string;
currency: string;
billingPeriod: string;
plans: Plan[];
highlightIndex: number;
popularBadgeText?: string;
variant: 'light' | 'dark';
}
const PricingCardsRender: React.FC<PricingCardsProps> = (props) => {
const {
sectionHeading,
sectionSubheading,
currency,
billingPeriod,
plans,
highlightIndex,
popularBadgeText,
variant,
...designControls
} = props;
const applied = applyDesignControls(designControls);
const isDark = variant === 'dark';
return (
<section
id={designControls.anchorId || undefined}
className={`${applied.className} ${isDark ? 'bg-neutral-900' : ''}`}
style={applied.style}
>
<div
className={`${applied.containerClassName || 'max-w-7xl'} mx-auto px-4 sm:px-6 lg:px-8`}
>
{(sectionHeading || sectionSubheading) && (
<div className="text-center mb-12">
{sectionHeading && (
<h2
className={`text-3xl sm:text-4xl font-bold ${
isDark ? 'text-white' : 'text-neutral-900'
}`}
>
{sectionHeading}
</h2>
)}
{sectionSubheading && (
<p
className={`mt-4 text-lg ${
isDark ? 'text-neutral-400' : 'text-neutral-600'
}`}
>
{sectionSubheading}
</p>
)}
</div>
)}
{plans && plans.length > 0 && (
<div
className={`grid grid-cols-1 ${
plans.length === 2
? 'md:grid-cols-2 max-w-4xl'
: plans.length === 3
? 'lg:grid-cols-3 max-w-6xl'
: 'lg:grid-cols-4 max-w-7xl'
} mx-auto gap-8`}
>
{plans.map((plan, index) => {
const isHighlighted = index === highlightIndex;
return (
<div
key={index}
className={`relative rounded-2xl p-8 ${
isHighlighted
? 'bg-primary-600 text-white ring-4 ring-primary-600 scale-105 z-10'
: isDark
? 'bg-neutral-800 text-white'
: 'bg-white shadow-lg'
}`}
>
{isHighlighted && popularBadgeText && (
<div className="absolute -top-4 left-1/2 -translate-x-1/2">
<span className="bg-primary-800 text-white text-sm font-semibold px-4 py-1 rounded-full">
{popularBadgeText}
</span>
</div>
)}
<h3
className={`text-xl font-semibold ${
isHighlighted
? 'text-white'
: isDark
? 'text-white'
: 'text-neutral-900'
}`}
>
{plan.name}
</h3>
{plan.subtitle && (
<p
className={`mt-2 text-sm ${
isHighlighted
? 'text-primary-100'
: isDark
? 'text-neutral-400'
: 'text-neutral-600'
}`}
>
{plan.subtitle}
</p>
)}
<div className="mt-6">
<span
className={`text-4xl font-bold ${
isHighlighted
? 'text-white'
: isDark
? 'text-white'
: 'text-neutral-900'
}`}
>
{currency}
{plan.price}
</span>
<span
className={`text-sm ${
isHighlighted
? 'text-primary-100'
: isDark
? 'text-neutral-400'
: 'text-neutral-600'
}`}
>
{billingPeriod}
</span>
</div>
<ul className="mt-8 space-y-4">
{plan.features.map((feature, featureIndex) => (
<li key={featureIndex} className="flex items-start gap-3">
<Check
className={`w-5 h-5 flex-shrink-0 ${
isHighlighted
? 'text-primary-200'
: 'text-primary-600'
}`}
/>
<span
className={`text-sm ${
isHighlighted
? 'text-primary-100'
: isDark
? 'text-neutral-300'
: 'text-neutral-700'
}`}
>
{feature}
</span>
</li>
))}
</ul>
{plan.cta?.text && (
<a
href={plan.cta.href || '#'}
className={`mt-8 block w-full py-3 px-4 rounded-lg text-center font-semibold transition-colors ${
isHighlighted
? 'bg-white text-primary-600 hover:bg-neutral-100'
: isDark
? 'bg-primary-600 text-white hover:bg-primary-700'
: 'bg-primary-600 text-white hover:bg-primary-700'
}`}
>
{plan.cta.text}
</a>
)}
</div>
);
})}
</div>
)}
</div>
</section>
);
};
const designControlFields = createDesignControlsFields();
export const PricingCards: ComponentConfig<PricingCardsProps> = {
label: 'Pricing Cards',
fields: {
sectionHeading: {
type: 'text',
label: 'Section Heading',
},
sectionSubheading: {
type: 'text',
label: 'Section Subheading',
},
currency: {
type: 'text',
label: 'Currency Symbol',
},
billingPeriod: {
type: 'text',
label: 'Billing Period Label',
},
plans: {
type: 'array',
label: 'Plans',
arrayFields: {
name: { type: 'text', label: 'Plan Name' },
price: { type: 'text', label: 'Price' },
subtitle: { type: 'text', label: 'Subtitle' },
features: {
type: 'array',
label: 'Features',
arrayFields: {},
},
cta: {
type: 'object',
label: 'CTA Button',
objectFields: {
text: { type: 'text', label: 'Button Text' },
href: { type: 'text', label: 'Button URL' },
},
},
},
defaultItemProps: {
name: 'Plan',
price: '29',
subtitle: 'For small teams',
features: ['Feature 1', 'Feature 2', 'Feature 3'],
cta: { text: 'Get Started', href: '#' },
},
},
highlightIndex: {
type: 'number',
label: 'Highlighted Plan Index (0-based)',
},
popularBadgeText: {
type: 'text',
label: 'Popular Badge Text',
},
variant: {
type: 'select',
label: 'Variant',
options: [
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
],
},
// Design Controls
padding: designControlFields.padding,
backgroundVariant: designControlFields.backgroundVariant,
gradientPreset: designControlFields.gradientPreset,
contentMaxWidth: designControlFields.contentMaxWidth,
alignment: designControlFields.alignment,
hideOnMobile: designControlFields.hideOnMobile,
hideOnTablet: designControlFields.hideOnTablet,
hideOnDesktop: designControlFields.hideOnDesktop,
anchorId: designControlFields.anchorId,
},
defaultProps: {
sectionHeading: 'Choose your plan',
sectionSubheading: 'Select the option that works best for you.',
currency: '$',
billingPeriod: '/month',
plans: [
{
name: 'Basic',
price: '0',
subtitle: 'Get started',
features: ['Feature one', 'Feature two', 'Feature three'],
cta: { text: 'Get Started', href: '#' },
},
{
name: 'Standard',
price: '49',
subtitle: 'Most popular',
features: ['Everything in Basic', 'Feature four', 'Feature five', 'Priority support'],
cta: { text: 'Get Started', href: '#' },
},
{
name: 'Premium',
price: '99',
subtitle: 'For power users',
features: ['Everything in Standard', 'Feature six', 'Feature seven', 'Dedicated support'],
cta: { text: 'Contact Us', href: '#' },
},
],
highlightIndex: 1,
popularBadgeText: 'Recommended',
variant: 'light',
padding: 'xl',
backgroundVariant: 'light',
contentMaxWidth: 'wide',
alignment: 'center',
},
render: PricingCardsRender,
};
export default PricingCards;

View File

@@ -0,0 +1,281 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import { Check, BarChart2, FileText, Bell } from 'lucide-react';
import {
createDesignControlsFields,
applyDesignControls,
type DesignControlsProps,
} from '../../theme';
export interface Bullet {
text: string;
icon?: 'check' | 'chart' | 'file' | 'bell';
}
export interface FeatureCta {
text: string;
href: string;
}
export interface Media {
src: string;
alt: string;
}
export interface SplitContentProps extends DesignControlsProps {
eyebrow?: string;
heading: string;
content: string;
bullets?: Bullet[];
cta?: FeatureCta;
media: Media;
mediaPosition: 'left' | 'right';
variant: 'light' | 'dark';
}
const iconMap = {
check: Check,
chart: BarChart2,
file: FileText,
bell: Bell,
};
const SplitContentRender: React.FC<SplitContentProps> = (props) => {
const {
eyebrow,
heading,
content,
bullets,
cta,
media,
mediaPosition,
variant,
...designControls
} = props;
const applied = applyDesignControls(designControls);
const isDark = variant === 'dark';
const textContent = (
<div className="max-w-lg">
{eyebrow && (
<p
className={`text-sm font-semibold uppercase tracking-wider mb-4 ${
isDark ? 'text-primary-400' : 'text-primary-600'
}`}
>
{eyebrow}
</p>
)}
<h2
className={`text-3xl sm:text-4xl font-bold ${
isDark ? 'text-white' : 'text-neutral-900'
}`}
>
{heading}
</h2>
<p
className={`mt-4 text-lg ${
isDark ? 'text-neutral-300' : 'text-neutral-600'
}`}
>
{content}
</p>
{bullets && bullets.length > 0 && (
<ul className="mt-8 space-y-4">
{bullets.map((bullet, index) => {
const IconComponent = bullet.icon ? iconMap[bullet.icon] : Check;
return (
<li key={index} className="flex items-start gap-3">
<div
className={`flex-shrink-0 w-6 h-6 rounded-full flex items-center justify-center ${
isDark ? 'bg-primary-900 text-primary-400' : 'bg-primary-100 text-primary-600'
}`}
>
<IconComponent className="w-4 h-4" />
</div>
<span
className={isDark ? 'text-neutral-300' : 'text-neutral-700'}
>
{bullet.text}
</span>
</li>
);
})}
</ul>
)}
{cta && (
<div className="mt-8">
<a
href={cta.href}
className={`inline-flex items-center font-semibold ${
isDark ? 'text-primary-400 hover:text-primary-300' : 'text-primary-600 hover:text-primary-700'
}`}
>
{cta.text}
<svg
className="ml-2 w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</a>
</div>
)}
</div>
);
const mediaContent = media?.src ? (
<div className="relative">
<img
src={media.src}
alt={media.alt || ''}
className="rounded-xl shadow-xl"
/>
</div>
) : null;
return (
<section
id={designControls.anchorId || undefined}
className={`${applied.className} ${isDark ? 'bg-neutral-900' : ''}`}
style={applied.style}
>
<div
className={`${applied.containerClassName || 'max-w-7xl'} mx-auto px-4 sm:px-6 lg:px-8`}
>
<div
className={`grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-16 items-center ${
mediaPosition === 'left' ? 'lg:flex-row-reverse' : ''
}`}
>
{mediaPosition === 'left' ? (
<>
{mediaContent}
{textContent}
</>
) : (
<>
{textContent}
{mediaContent}
</>
)}
</div>
</div>
</section>
);
};
const designControlFields = createDesignControlsFields();
export const SplitContent: ComponentConfig<SplitContentProps> = {
label: 'Split Content',
fields: {
eyebrow: {
type: 'text',
label: 'Eyebrow Label',
},
heading: {
type: 'text',
label: 'Heading',
},
content: {
type: 'textarea',
label: 'Content',
},
bullets: {
type: 'array',
label: 'Bullet Points',
arrayFields: {
text: { type: 'text', label: 'Text' },
icon: {
type: 'select',
label: 'Icon',
options: [
{ label: 'Check', value: 'check' },
{ label: 'Chart', value: 'chart' },
{ label: 'File', value: 'file' },
{ label: 'Bell', value: 'bell' },
],
},
},
defaultItemProps: {
text: 'Feature benefit',
icon: 'check',
},
},
cta: {
type: 'object',
label: 'CTA (optional)',
objectFields: {
text: { type: 'text', label: 'Text' },
href: { type: 'text', label: 'URL' },
},
},
media: {
type: 'object',
label: 'Media',
objectFields: {
src: { type: 'text', label: 'Image URL' },
alt: { type: 'text', label: 'Alt Text' },
},
},
mediaPosition: {
type: 'select',
label: 'Media Position',
options: [
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
],
},
variant: {
type: 'select',
label: 'Variant',
options: [
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
],
},
// Design Controls
padding: designControlFields.padding,
backgroundVariant: designControlFields.backgroundVariant,
gradientPreset: designControlFields.gradientPreset,
contentMaxWidth: designControlFields.contentMaxWidth,
hideOnMobile: designControlFields.hideOnMobile,
hideOnTablet: designControlFields.hideOnTablet,
hideOnDesktop: designControlFields.hideOnDesktop,
anchorId: designControlFields.anchorId,
},
defaultProps: {
eyebrow: '',
heading: 'Your heading here',
content: 'Add your content description here. Explain the value or benefits.',
bullets: [
{ text: 'First key point', icon: 'check' },
{ text: 'Second key point', icon: 'check' },
{ text: 'Third key point', icon: 'check' },
],
cta: {
text: 'Learn more',
href: '#',
},
media: {
src: 'https://placehold.co/600x400/f4f4f5/3f3f46?text=Image',
alt: 'Image description',
},
mediaPosition: 'right',
variant: 'light',
padding: 'xl',
contentMaxWidth: 'wide',
},
render: SplitContentRender,
};
export default SplitContent;

View File

@@ -0,0 +1,110 @@
import React from 'react';
import type { ComponentConfig } from '@measured/puck';
import {
createDesignControlsFields,
applyDesignControls,
type DesignControlsProps,
} from '../../theme';
export interface Stat {
value: string;
label: string;
}
export interface StatsStripProps extends DesignControlsProps {
stats: Stat[];
variant: 'light' | 'dark';
}
const StatsStripRender: React.FC<StatsStripProps> = (props) => {
const { stats, variant, ...designControls } = props;
const applied = applyDesignControls(designControls);
const isDark = variant === 'dark';
return (
<section
id={designControls.anchorId || undefined}
className={`${applied.className} ${isDark ? 'bg-neutral-900' : 'bg-white'}`}
style={applied.style}
>
<div
className={`${applied.containerClassName || 'max-w-7xl'} mx-auto px-4 sm:px-6 lg:px-8`}
>
{stats && stats.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 sm:gap-12">
{stats.map((stat, index) => (
<div key={index} className="text-center">
<div
className={`text-4xl sm:text-5xl font-bold ${
isDark ? 'text-white' : 'text-primary-600'
}`}
>
{stat.value}
</div>
<div
className={`mt-2 text-sm font-medium ${
isDark ? 'text-neutral-400' : 'text-neutral-600'
}`}
>
{stat.label}
</div>
</div>
))}
</div>
)}
</div>
</section>
);
};
const designControlFields = createDesignControlsFields();
export const StatsStrip: ComponentConfig<StatsStripProps> = {
label: 'Stats Strip',
fields: {
stats: {
type: 'array',
label: 'Stats',
arrayFields: {
value: { type: 'text', label: 'Value' },
label: { type: 'text', label: 'Label' },
},
defaultItemProps: {
value: '100+',
label: 'Customers',
},
},
variant: {
type: 'select',
label: 'Variant',
options: [
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
],
},
// Design Controls
padding: designControlFields.padding,
backgroundVariant: designControlFields.backgroundVariant,
gradientPreset: designControlFields.gradientPreset,
contentMaxWidth: designControlFields.contentMaxWidth,
hideOnMobile: designControlFields.hideOnMobile,
hideOnTablet: designControlFields.hideOnTablet,
hideOnDesktop: designControlFields.hideOnDesktop,
anchorId: designControlFields.anchorId,
},
defaultProps: {
stats: [
{ value: '100+', label: 'Stat label' },
{ value: '50+', label: 'Stat label' },
{ value: '24/7', label: 'Stat label' },
{ value: '5.0', label: 'Stat label' },
],
variant: 'light',
padding: 'lg',
contentMaxWidth: 'wide',
},
render: StatsStripRender,
};
export default StatsStrip;

View File

@@ -0,0 +1,259 @@
import React, { useState } from 'react';
import type { ComponentConfig } from '@measured/puck';
import { ChevronLeft, ChevronRight, Star, Quote } from 'lucide-react';
import {
createDesignControlsFields,
applyDesignControls,
type DesignControlsProps,
} from '../../theme';
export interface TestimonialItem {
quote: string;
name: string;
title?: string;
avatar?: string;
rating?: 1 | 2 | 3 | 4 | 5;
}
export interface TestimonialsProps extends DesignControlsProps {
heading?: string;
items: TestimonialItem[];
layout: 'carousel' | 'stacked';
showRating: boolean;
}
const TestimonialsRender: React.FC<TestimonialsProps> = (props) => {
const { heading, items = [], layout, showRating, ...designControls } = props;
const [currentIndex, setCurrentIndex] = useState(0);
const applied = applyDesignControls(designControls);
const itemCount = items?.length || 0;
const nextSlide = () => {
if (itemCount > 0) {
setCurrentIndex((prev) => (prev + 1) % itemCount);
}
};
const prevSlide = () => {
if (itemCount > 0) {
setCurrentIndex((prev) => (prev - 1 + itemCount) % itemCount);
}
};
const renderTestimonial = (item: TestimonialItem, index: number) => (
<div
key={index}
className="bg-white rounded-xl shadow-md p-6 sm:p-8"
>
<Quote className="w-10 h-10 text-primary-200 mb-4" />
{showRating && item.rating && (
<div className="flex gap-1 mb-4">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
className={`w-5 h-5 ${
i < item.rating!
? 'text-yellow-400 fill-yellow-400'
: 'text-neutral-300'
}`}
/>
))}
</div>
)}
<blockquote className="text-lg text-neutral-700 mb-6 italic">
"{item.quote}"
</blockquote>
<div className="flex items-center gap-4">
{item.avatar ? (
<img
src={item.avatar}
alt={item.name}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-primary-100 flex items-center justify-center">
<span className="text-primary-600 font-semibold text-lg">
{item.name.charAt(0).toUpperCase()}
</span>
</div>
)}
<div>
<p className="font-semibold text-neutral-900">{item.name}</p>
{item.title && (
<p className="text-sm text-neutral-600">{item.title}</p>
)}
</div>
</div>
</div>
);
return (
<section
id={designControls.anchorId || undefined}
className={applied.className}
style={applied.style}
>
<div
className={`${applied.containerClassName || 'max-w-7xl'} mx-auto px-4 sm:px-6 lg:px-8`}
>
{heading && (
<h2
className={`text-3xl font-bold text-center mb-12 ${applied.textClassName || 'text-neutral-900'}`}
>
{heading}
</h2>
)}
{layout === 'carousel' ? (
<div className="relative">
<div className="overflow-hidden">
<div
className="flex transition-transform duration-300"
style={{ transform: `translateX(-${currentIndex * 100}%)` }}
>
{items.map((item, index) => (
<div key={index} className="w-full flex-shrink-0 px-4">
<div className="max-w-2xl mx-auto">
{renderTestimonial(item, index)}
</div>
</div>
))}
</div>
</div>
{itemCount > 1 && (
<>
<button
onClick={prevSlide}
className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-4 w-10 h-10 bg-white rounded-full shadow-lg flex items-center justify-center hover:bg-neutral-50 transition-colors"
aria-label="Previous testimonial"
>
<ChevronLeft className="w-5 h-5 text-neutral-600" />
</button>
<button
onClick={nextSlide}
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-4 w-10 h-10 bg-white rounded-full shadow-lg flex items-center justify-center hover:bg-neutral-50 transition-colors"
>
<ChevronRight className="w-5 h-5 text-neutral-600" />
</button>
</>
)}
{itemCount > 1 && (
<div className="flex justify-center gap-2 mt-6">
{items.map((_, index) => (
<button
key={index}
onClick={() => setCurrentIndex(index)}
className={`w-2 h-2 rounded-full transition-colors ${
index === currentIndex ? 'bg-primary-600' : 'bg-neutral-300'
}`}
aria-label={`Go to testimonial ${index + 1}`}
/>
))}
</div>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{items.map((item, index) => renderTestimonial(item, index))}
</div>
)}
</div>
</section>
);
};
const designControlFields = createDesignControlsFields();
export const Testimonials: ComponentConfig<TestimonialsProps> = {
label: 'Testimonials',
fields: {
heading: {
type: 'text',
label: 'Heading (optional)',
},
items: {
type: 'array',
label: 'Testimonials',
arrayFields: {
quote: { type: 'textarea', label: 'Quote' },
name: { type: 'text', label: 'Name' },
title: { type: 'text', label: 'Title/Company' },
avatar: { type: 'text', label: 'Avatar URL' },
rating: {
type: 'select',
label: 'Rating',
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 },
],
},
},
defaultItemProps: {
quote: 'This product has been amazing for our team!',
name: 'John Doe',
title: 'CEO, Company',
rating: 5,
},
},
layout: {
type: 'select',
label: 'Layout',
options: [
{ label: 'Carousel', value: 'carousel' },
{ label: 'Stacked Grid', value: 'stacked' },
],
},
showRating: {
type: 'radio',
label: 'Show Rating Stars',
options: [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
],
},
// Design Controls
padding: designControlFields.padding,
backgroundVariant: designControlFields.backgroundVariant,
gradientPreset: designControlFields.gradientPreset,
contentMaxWidth: designControlFields.contentMaxWidth,
hideOnMobile: designControlFields.hideOnMobile,
hideOnTablet: designControlFields.hideOnTablet,
hideOnDesktop: designControlFields.hideOnDesktop,
anchorId: designControlFields.anchorId,
},
defaultProps: {
heading: 'What people are saying',
items: [
{
quote: 'Add your first testimonial here.',
name: 'Customer Name',
title: 'Title or Company',
rating: 5,
},
{
quote: 'Add your second testimonial here.',
name: 'Customer Name',
title: 'Title or Company',
rating: 5,
},
{
quote: 'Add your third testimonial here.',
name: 'Customer Name',
title: 'Title or Company',
rating: 5,
},
],
layout: 'carousel',
showRating: true,
padding: 'xl',
contentMaxWidth: 'wide',
},
render: TestimonialsRender,
};
export default Testimonials;

View File

@@ -0,0 +1,181 @@
import React, { useState } from 'react';
import type { ComponentConfig } from '@measured/puck';
import { Play, X } from 'lucide-react';
import {
createDesignControlsFields,
applyDesignControls,
type DesignControlsProps,
} from '../../theme';
import { buildSafeEmbedUrl, validateVideoEmbed } from '../../utils/videoEmbed';
export interface VideoEmbedProps extends DesignControlsProps {
heading: string;
text?: string;
thumbnailImage: string;
provider: 'youtube' | 'vimeo';
videoId: string;
variant: 'light' | 'dark';
}
const VideoEmbedRender: React.FC<VideoEmbedProps> = (props) => {
const {
heading,
text,
thumbnailImage,
provider,
videoId,
variant,
...designControls
} = props;
const [isPlaying, setIsPlaying] = useState(false);
const applied = applyDesignControls(designControls);
const isDark = variant === 'dark';
// Build safe embed URL
let embedUrl = '';
try {
embedUrl = buildSafeEmbedUrl(provider, videoId);
} catch {
// Invalid video ID
}
return (
<section
id={designControls.anchorId || undefined}
className={`${applied.className} ${isDark ? 'bg-neutral-900' : ''}`}
style={applied.style}
>
<div
className={`${applied.containerClassName || 'max-w-4xl'} mx-auto px-4 sm:px-6 lg:px-8`}
>
<div className="text-center mb-12">
<h2
className={`text-3xl font-bold ${
isDark ? 'text-white' : 'text-neutral-900'
}`}
>
{heading}
</h2>
{text && (
<p
className={`mt-4 text-lg max-w-2xl mx-auto ${
isDark ? 'text-neutral-300' : 'text-neutral-600'
}`}
>
{text}
</p>
)}
</div>
<div className="relative aspect-video rounded-xl overflow-hidden shadow-2xl">
{isPlaying && embedUrl ? (
<div className="relative w-full h-full">
<iframe
src={`${embedUrl}?autoplay=1`}
className="absolute inset-0 w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
title="Video"
/>
<button
onClick={() => setIsPlaying(false)}
className="absolute top-4 right-4 w-10 h-10 bg-black/50 rounded-full flex items-center justify-center text-white hover:bg-black/70 transition-colors"
aria-label="Close video"
>
<X className="w-5 h-5" />
</button>
</div>
) : (
<button
onClick={() => setIsPlaying(true)}
className="relative w-full h-full group cursor-pointer"
disabled={!embedUrl}
>
<img
src={thumbnailImage}
alt="Video thumbnail"
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/30 group-hover:bg-black/40 transition-colors" />
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-20 h-20 bg-white/90 rounded-full flex items-center justify-center group-hover:scale-110 transition-transform shadow-lg">
<Play className="w-8 h-8 text-primary-600 ml-1" fill="currentColor" />
</div>
</div>
{!embedUrl && (
<div className="absolute bottom-4 left-4 right-4 bg-red-500/90 text-white text-sm px-4 py-2 rounded">
Invalid video configuration
</div>
)}
</button>
)}
</div>
</div>
</section>
);
};
const designControlFields = createDesignControlsFields();
export const VideoEmbed: ComponentConfig<VideoEmbedProps> = {
label: 'Video',
fields: {
heading: {
type: 'text',
label: 'Heading',
},
text: {
type: 'textarea',
label: 'Description',
},
thumbnailImage: {
type: 'text',
label: 'Thumbnail Image URL',
},
provider: {
type: 'select',
label: 'Video Provider',
options: [
{ label: 'YouTube', value: 'youtube' },
{ label: 'Vimeo', value: 'vimeo' },
],
},
videoId: {
type: 'text',
label: 'Video ID',
},
variant: {
type: 'select',
label: 'Variant',
options: [
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
],
},
// Design Controls
padding: designControlFields.padding,
backgroundVariant: designControlFields.backgroundVariant,
gradientPreset: designControlFields.gradientPreset,
contentMaxWidth: designControlFields.contentMaxWidth,
alignment: designControlFields.alignment,
hideOnMobile: designControlFields.hideOnMobile,
hideOnTablet: designControlFields.hideOnTablet,
hideOnDesktop: designControlFields.hideOnDesktop,
anchorId: designControlFields.anchorId,
},
defaultProps: {
heading: 'Watch the video',
text: 'Add a description for your video here.',
thumbnailImage: 'https://placehold.co/800x450/1a1a2e/ffffff?text=Video+Thumbnail',
provider: 'youtube',
videoId: '',
variant: 'light',
padding: 'xl',
contentMaxWidth: 'normal',
alignment: 'center',
},
render: VideoEmbedRender,
};
export default VideoEmbed;

View File

@@ -0,0 +1,56 @@
/**
* Marketing Components for Page Builder
* Generic, business-agnostic components for building landing pages
*/
// Navigation
export { Header } from './Header';
export type { HeaderProps, HeaderLink, HeaderCta } from './Header';
// Hero Sections
export { Hero } from './Hero';
export type { HeroProps, HeroCtaButton, HeroMedia } from './Hero';
// Trust & Social Proof
export { LogoCloud } from './LogoCloud';
export type { LogoCloudProps, Logo } from './LogoCloud';
export { Testimonials } from './Testimonials';
export type { TestimonialsProps, TestimonialItem } from './Testimonials';
export { StatsStrip } from './StatsStrip';
export type { StatsStripProps, Stat } from './StatsStrip';
// Content Blocks
export { SplitContent } from './SplitContent';
export type { SplitContentProps, Bullet, FeatureCta, Media } from './SplitContent';
export { ContentBlocks } from './ContentBlocks';
export type { ContentBlocksProps, Feature } from './ContentBlocks';
export { GalleryGrid } from './GalleryGrid';
export type { GalleryGridProps, GalleryItem } from './GalleryGrid';
// Media
export { VideoEmbed } from './VideoEmbed';
export type { VideoEmbedProps } from './VideoEmbed';
// Conversion
export { CTASection } from './CTASection';
export type { CTASectionProps, CTAButton } from './CTASection';
export { PricingCards } from './PricingCards';
export type { PricingCardsProps, Plan, PlanCta } from './PricingCards';
// Information
export { FAQAccordion } from './FAQAccordion';
export type { FAQAccordionProps, FaqItem } from './FAQAccordion';
// Footer
export { Footer } from './Footer';
export type { FooterProps, FooterColumn, FooterLink, SocialLinks, MiniCta } from './Footer';
// Full Booking Flow
export { FullBookingFlow } from './FullBookingFlow';
export type { FullBookingFlowProps } from './FullBookingFlow';

View File

@@ -18,7 +18,6 @@ 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';
@@ -28,8 +27,25 @@ import { Services } from './components/booking/Services';
// Contact components
import { ContactForm } from './components/contact/ContactForm';
import { BusinessHours } from './components/contact/BusinessHours';
import { AddressBlock } from './components/contact/AddressBlock';
import { Map } from './components/contact/Map';
// Marketing components (Page Builder)
import { Header } from './components/marketing/Header';
import { Hero } from './components/marketing/Hero';
import { LogoCloud } from './components/marketing/LogoCloud';
import { SplitContent } from './components/marketing/SplitContent';
import { CTASection } from './components/marketing/CTASection';
import { StatsStrip } from './components/marketing/StatsStrip';
import { GalleryGrid } from './components/marketing/GalleryGrid';
import { Testimonials } from './components/marketing/Testimonials';
import { VideoEmbed } from './components/marketing/VideoEmbed';
import { ContentBlocks } from './components/marketing/ContentBlocks';
import { PricingCards } from './components/marketing/PricingCards';
import { Footer } from './components/marketing/Footer';
import { FAQAccordion } from './components/marketing/FAQAccordion';
import { FullBookingFlow } from './components/marketing/FullBookingFlow';
// Legacy components (for backward compatibility)
import { config as legacyConfig } from '../puckConfig';
@@ -39,147 +55,229 @@ export const componentCategories = {
title: 'Layout',
components: ['Section', 'Columns', 'Card', 'Spacer', 'Divider'],
},
navigation: {
title: 'Navigation',
components: ['Header', 'Footer'],
},
hero: {
title: 'Hero',
components: ['Hero'],
},
content: {
title: 'Content',
components: ['Heading', 'RichText', 'Image', 'Button', 'IconList', 'Testimonial', 'FAQ'],
components: [
'SplitContent',
'ContentBlocks',
'GalleryGrid',
'Heading',
'RichText',
'Image',
'Button',
'IconList',
],
},
trust: {
title: 'Trust & Social Proof',
components: ['LogoCloud', 'Testimonials', 'StatsStrip', 'Testimonial'],
},
conversion: {
title: 'Conversion',
components: ['CTASection', 'PricingCards'],
},
media: {
title: 'Media',
components: ['VideoEmbed'],
},
info: {
title: 'Information',
components: ['FAQAccordion', 'FAQ'],
},
booking: {
title: 'Booking',
components: ['BookingWidget', 'ServiceCatalog', 'Services'],
components: ['FullBookingFlow', 'BookingWidget', 'ServiceCatalog', 'Services'],
},
contact: {
title: 'Contact',
components: ['ContactForm', 'BusinessHours', 'Map'],
components: ['ContactForm', 'BusinessHours', 'AddressBlock', 'Map'],
},
legacy: {
title: 'Legacy',
components: ['Hero', 'TextSection', 'Booking'],
components: ['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 },
layout: {
title: 'Layout',
components: ['Section', 'Columns', 'Card', 'Spacer', 'Divider'],
},
navigation: {
title: 'Navigation',
components: ['Header', 'Footer'],
},
hero: {
title: 'Hero',
components: ['Hero'],
},
content: {
title: 'Content',
components: ['SplitContent', 'ContentBlocks', 'GalleryGrid', 'Heading', 'RichText', 'Image', 'Button', 'IconList'],
},
trust: {
title: 'Trust & Social Proof',
components: ['LogoCloud', 'Testimonials', 'StatsStrip', 'Testimonial'],
},
conversion: {
title: 'Conversion',
components: ['CTASection', 'PricingCards'],
},
media: {
title: 'Media',
components: ['VideoEmbed'],
},
info: {
title: 'Information',
components: ['FAQAccordion'],
},
booking: {
title: 'Booking',
components: ['FullBookingFlow', 'BookingWidget', 'ServiceCatalog', 'Services'],
},
contact: {
title: 'Contact',
components: ['ContactForm', 'BusinessHours', 'AddressBlock', 'Map'],
},
legacy: {
title: 'Legacy',
components: ['TextSection', 'Booking'],
defaultExpanded: false,
},
},
components: {
// Layout components
Section: {
...Section,
// @ts-expect-error - category assignment
category: 'layout',
// Navigation components
Header: {
...Header,
},
Columns: {
...Columns,
// @ts-expect-error - category assignment
category: 'layout',
Footer: {
...Footer,
},
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',
// Hero components
Hero: {
...Hero,
},
// Content components
SplitContent: {
...SplitContent,
},
ContentBlocks: {
...ContentBlocks,
},
GalleryGrid: {
...GalleryGrid,
},
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',
},
// Trust & Social Proof components
LogoCloud: {
...LogoCloud,
},
Testimonials: {
...Testimonials,
},
StatsStrip: {
...StatsStrip,
},
Testimonial: {
...Testimonial,
// @ts-expect-error - category assignment
category: 'content',
},
FAQ: {
...FAQ,
// @ts-expect-error - category assignment
category: 'content',
// Conversion components
CTASection: {
...CTASection,
},
PricingCards: {
...PricingCards,
},
// Media components
VideoEmbed: {
...VideoEmbed,
},
// Information components
FAQAccordion: {
...FAQAccordion,
},
// Booking components
FullBookingFlow: {
...FullBookingFlow,
},
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',
},
AddressBlock: {
...AddressBlock,
},
Map: {
...Map,
// @ts-expect-error - category assignment
category: 'contact',
},
// Layout components
Section: {
...Section,
},
Columns: {
...Columns,
},
Card: {
...Card,
},
Spacer: {
...Spacer,
},
Divider: {
...Divider,
},
// 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',
},
},
};
@@ -189,19 +287,24 @@ 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 } };
// Deep clone the config to avoid mutating the original
const config = {
...puckConfig,
components: { ...puckConfig.components },
categories: puckConfig.categories ? JSON.parse(JSON.stringify(puckConfig.categories)) : undefined,
};
// 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;
// Also remove from categories
if (config.categories?.booking) {
config.categories.booking.components = config.categories.booking.components.filter(
(c: string) => c !== 'ServiceCatalog'
);
}
}
return config;

View File

@@ -0,0 +1,372 @@
/**
* Custom Puck field for picking images from the Media Gallery
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Image as ImageIcon, X, Upload, FolderOpen, Loader2 } from 'lucide-react';
import {
listAlbums,
listMediaFiles,
uploadMediaFile,
Album,
MediaFile,
formatFileSize,
isAllowedFileType,
isFileSizeAllowed,
getStorageUsage,
StorageUsage,
} from '../../api/media';
interface ImagePickerFieldProps {
value: string;
onChange: (value: string) => void;
readOnly?: boolean;
}
/**
* Image Picker Field Component
* Shows a preview of the selected image and opens a modal to browse/select from gallery
*/
export const ImagePickerField: React.FC<ImagePickerFieldProps> = ({
value,
onChange,
readOnly,
}) => {
const [isOpen, setIsOpen] = useState(false);
const handleSelect = (url: string) => {
onChange(url);
setIsOpen(false);
};
const handleClear = () => {
onChange('');
};
return (
<div className="space-y-2">
{/* Preview */}
{value ? (
<div className="relative group">
<img
src={value}
alt="Selected"
className="w-full h-32 object-cover rounded-lg border border-gray-200"
/>
{!readOnly && (
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center gap-2">
<button
type="button"
onClick={() => setIsOpen(true)}
className="px-3 py-1.5 bg-white text-gray-800 rounded text-sm font-medium hover:bg-gray-100"
>
Change
</button>
<button
type="button"
onClick={handleClear}
className="p-1.5 bg-red-500 text-white rounded hover:bg-red-600"
>
<X size={16} />
</button>
</div>
)}
</div>
) : (
<button
type="button"
onClick={() => setIsOpen(true)}
disabled={readOnly}
className="w-full h-32 border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center gap-2 text-gray-500 hover:border-indigo-400 hover:text-indigo-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<ImageIcon size={24} />
<span className="text-sm">Select Image</span>
</button>
)}
{/* URL input for manual entry */}
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Or paste image URL..."
disabled={readOnly}
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 disabled:opacity-50"
/>
{/* Gallery Modal */}
{isOpen && (
<ImagePickerModal
onSelect={handleSelect}
onClose={() => setIsOpen(false)}
currentValue={value}
/>
)}
</div>
);
};
/**
* Modal for browsing and selecting images from the gallery
*/
interface ImagePickerModalProps {
onSelect: (url: string) => void;
onClose: () => void;
currentValue?: string;
}
const ImagePickerModal: React.FC<ImagePickerModalProps> = ({
onSelect,
onClose,
currentValue,
}) => {
const [albums, setAlbums] = useState<Album[]>([]);
const [files, setFiles] = useState<MediaFile[]>([]);
const [selectedAlbumId, setSelectedAlbumId] = useState<number | 'all' | 'uncategorized'>('all');
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [storageUsage, setStorageUsage] = useState<StorageUsage | null>(null);
const [error, setError] = useState<string | null>(null);
// Load albums and files
const loadData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [albumsData, storageData] = await Promise.all([
listAlbums(),
getStorageUsage(),
]);
setAlbums(albumsData);
setStorageUsage(storageData);
// Load files based on selected album
let filesData: MediaFile[];
if (selectedAlbumId === 'all') {
filesData = await listMediaFiles();
} else if (selectedAlbumId === 'uncategorized') {
filesData = await listMediaFiles('null');
} else {
filesData = await listMediaFiles(selectedAlbumId);
}
setFiles(filesData);
} catch (err: any) {
setError(err?.response?.data?.detail || 'Failed to load gallery');
} finally {
setLoading(false);
}
}, [selectedAlbumId]);
useEffect(() => {
loadData();
}, [loadData]);
// Handle file upload
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!isAllowedFileType(file)) {
setError('Only JPEG, PNG, GIF, and WebP images are allowed');
return;
}
if (!isFileSizeAllowed(file)) {
setError('File size must be less than 10 MB');
return;
}
setUploading(true);
setError(null);
try {
const albumId = typeof selectedAlbumId === 'number' ? selectedAlbumId : undefined;
const uploaded = await uploadMediaFile(file, albumId);
// Select the newly uploaded file
onSelect(uploaded.url);
} catch (err: any) {
setError(err?.response?.data?.detail || err?.response?.data?.file?.[0] || 'Failed to upload file');
} finally {
setUploading(false);
// Reset file input
e.target.value = '';
}
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[9999] p-4">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">Select Image</h2>
<button
onClick={onClose}
className="p-1 hover:bg-gray-100 rounded-full transition-colors"
>
<X size={20} className="text-gray-500" />
</button>
</div>
{/* Storage Usage Bar */}
{storageUsage && (
<div className="px-6 py-3 bg-gray-50 border-b">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-600">Storage Used</span>
<span className="text-gray-900 font-medium">
{storageUsage.used_display} / {storageUsage.total_display}
</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
storageUsage.percent_used > 90
? 'bg-red-500'
: storageUsage.percent_used > 75
? 'bg-amber-500'
: 'bg-indigo-500'
}`}
style={{ width: `${Math.min(storageUsage.percent_used, 100)}%` }}
/>
</div>
</div>
)}
{/* Album Filter */}
<div className="px-6 py-3 border-b flex items-center gap-3 flex-wrap">
<button
onClick={() => setSelectedAlbumId('all')}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
selectedAlbumId === 'all'
? 'bg-indigo-100 text-indigo-700'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
All Files
</button>
<button
onClick={() => setSelectedAlbumId('uncategorized')}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
selectedAlbumId === 'uncategorized'
? 'bg-indigo-100 text-indigo-700'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
Uncategorized
</button>
{albums.map((album) => (
<button
key={album.id}
onClick={() => setSelectedAlbumId(album.id)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors flex items-center gap-1.5 ${
selectedAlbumId === album.id
? 'bg-indigo-100 text-indigo-700'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
<FolderOpen size={14} />
{album.name}
<span className="text-xs opacity-70">({album.file_count})</span>
</button>
))}
{/* Upload Button */}
<label className="ml-auto">
<input
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={handleUpload}
disabled={uploading}
className="hidden"
/>
<span className="inline-flex items-center gap-2 px-4 py-1.5 bg-indigo-600 text-white rounded-full text-sm font-medium cursor-pointer hover:bg-indigo-700 disabled:opacity-50">
{uploading ? (
<>
<Loader2 size={16} className="animate-spin" />
Uploading...
</>
) : (
<>
<Upload size={16} />
Upload
</>
)}
</span>
</label>
</div>
{/* Error Message */}
{error && (
<div className="px-6 py-3 bg-red-50 text-red-700 text-sm border-b">
{error}
</div>
)}
{/* Image Grid */}
<div className="flex-1 overflow-y-auto p-6">
{loading ? (
<div className="flex items-center justify-center h-48">
<Loader2 size={32} className="animate-spin text-indigo-600" />
</div>
) : files.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-gray-500">
<ImageIcon size={48} className="mb-3 opacity-50" />
<p className="text-lg font-medium">No images found</p>
<p className="text-sm">Upload an image to get started</p>
</div>
) : (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3">
{files.map((file) => (
<button
key={file.id}
onClick={() => onSelect(file.url)}
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all hover:shadow-lg ${
currentValue === file.url
? 'border-indigo-500 ring-2 ring-indigo-200'
: 'border-gray-200 hover:border-indigo-300'
}`}
>
<img
src={file.url}
alt={file.alt_text || file.filename}
className="w-full h-full object-cover"
/>
{currentValue === file.url && (
<div className="absolute inset-0 bg-indigo-500/20 flex items-center justify-center">
<div className="w-6 h-6 bg-indigo-600 rounded-full flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
)}
</button>
))}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t flex items-center justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 font-medium"
>
Cancel
</button>
</div>
</div>
</div>
);
};
/**
* Puck custom field configuration
* Use this in component field definitions
*/
export const imagePickerField = {
type: 'custom' as const,
render: ({ value, onChange, readOnly }: { value: string; onChange: (val: string) => void; readOnly?: boolean }) => (
<ImagePickerField value={value || ''} onChange={onChange} readOnly={readOnly} />
),
};
export default ImagePickerField;

View File

@@ -0,0 +1,4 @@
/**
* Puck Custom Field Components
*/
export { ImagePickerField, imagePickerField } from './ImagePickerField';

View File

@@ -0,0 +1,704 @@
/**
* Landing Page Templates and Block Presets
*
* Provides pre-built page templates and block presets for quick setup.
*/
import type { Data } from '@measured/puck';
// ============================================================================
// Template Types
// ============================================================================
export interface PageTemplate {
id: string;
name: string;
description: string;
thumbnail: string;
generate: () => Data;
}
export interface BlockPreset {
id: string;
name: string;
description?: string;
props: Record<string, unknown>;
}
// ============================================================================
// ID Generator
// ============================================================================
let idCounter = 0;
function generateId(prefix: string): string {
return `${prefix}-${Date.now()}-${++idCounter}`;
}
// ============================================================================
// SaaS Landing Page Template Generator
// ============================================================================
export function generateSaaSLandingPageTemplate(): Data {
idCounter = 0; // Reset counter for consistent IDs
return {
root: {
props: {},
},
content: [
// TopNav
{
type: 'TopNav',
props: {
id: generateId('topnav'),
brandText: 'YourBrand',
brandLogo: '',
links: [
{ label: 'Features', href: '#features' },
{ label: 'Pricing', href: '#pricing' },
{ label: 'About', href: '#about' },
{ label: 'Contact', href: '#contact' },
],
ctaButton: {
text: 'Get Started',
href: '#signup',
},
variant: 'transparent-on-dark',
showMobileMenu: true,
},
},
// HeroSaaS
{
type: 'HeroSaaS',
props: {
id: generateId('hero'),
headline: 'Transform Your Business with Our Platform',
subheadline:
'Streamline operations, boost productivity, and scale your business with our all-in-one solution trusted by thousands of companies worldwide.',
primaryCta: {
text: 'Start Free Trial',
href: '#signup',
},
secondaryCta: {
text: 'Watch Demo',
href: '#demo',
},
backgroundVariant: 'gradient',
gradientPreset: 'dark-purple',
heroImage: {
src: 'https://placehold.co/1200x800/1a1a2e/ffffff?text=Product+Screenshot',
alt: 'Product dashboard screenshot',
},
badge: 'New: AI-Powered Features',
padding: 'xl',
contentMaxWidth: 'wide',
alignment: 'center',
},
},
// LogoCloud
{
type: 'LogoCloud',
props: {
id: generateId('logos'),
heading: 'Trusted by industry leaders',
logos: [
{
src: 'https://placehold.co/160x48/e4e4e7/71717a?text=Logo+1',
alt: 'Company 1',
},
{
src: 'https://placehold.co/160x48/e4e4e7/71717a?text=Logo+2',
alt: 'Company 2',
},
{
src: 'https://placehold.co/160x48/e4e4e7/71717a?text=Logo+3',
alt: 'Company 3',
},
{
src: 'https://placehold.co/160x48/e4e4e7/71717a?text=Logo+4',
alt: 'Company 4',
},
{
src: 'https://placehold.co/160x48/e4e4e7/71717a?text=Logo+5',
alt: 'Company 5',
},
{
src: 'https://placehold.co/160x48/e4e4e7/71717a?text=Logo+6',
alt: 'Company 6',
},
],
grayscale: true,
spacingDensity: 'normal',
padding: 'lg',
backgroundVariant: 'light',
contentMaxWidth: 'wide',
},
},
// FeatureSplit
{
type: 'FeatureSplit',
props: {
id: generateId('feature-split'),
eyebrow: 'Powerful Analytics',
heading: 'Understand your data like never before',
content:
'Get deep insights into your business performance with our advanced analytics dashboard. Track key metrics, identify trends, and make data-driven decisions.',
bullets: [
{ text: 'Real-time data visualization', icon: 'chart' },
{ text: 'Custom report builder', icon: 'file' },
{ text: 'Automated insights & alerts', icon: 'bell' },
],
cta: {
text: 'Learn More',
href: '#analytics',
},
media: {
src: 'https://placehold.co/600x400/f4f4f5/3f3f46?text=Analytics+Dashboard',
alt: 'Analytics dashboard',
},
mediaPosition: 'right',
variant: 'light',
padding: 'xl',
contentMaxWidth: 'wide',
},
},
// CTASection (center CTA band)
{
type: 'CTASection',
props: {
id: generateId('cta-1'),
heading: 'Ready to get started?',
supportingText:
'Join thousands of businesses already using our platform to grow.',
buttons: [
{
text: 'Start Free Trial',
href: '#signup',
variant: 'primary',
},
],
variant: 'gradient',
gradientPreset: 'royal',
padding: 'lg',
contentMaxWidth: 'normal',
alignment: 'center',
},
},
// StatsStrip
{
type: 'StatsStrip',
props: {
id: generateId('stats'),
stats: [
{ value: '10K+', label: 'Active Users' },
{ value: '99.9%', label: 'Uptime' },
{ value: '50M+', label: 'Tasks Completed' },
{ value: '4.9/5', label: 'Customer Rating' },
],
variant: 'light',
padding: 'lg',
backgroundVariant: 'none',
contentMaxWidth: 'wide',
},
},
// GalleryGrid
{
type: 'GalleryGrid',
props: {
id: generateId('gallery'),
heading: 'Explore Our Features',
items: [
{
image: 'https://placehold.co/400x300/f4f4f5/3f3f46?text=Feature+1',
title: 'Team Collaboration',
text: 'Work together seamlessly with your team in real-time.',
},
{
image: 'https://placehold.co/400x300/f4f4f5/3f3f46?text=Feature+2',
title: 'Automation',
text: 'Automate repetitive tasks and save hours every week.',
},
{
image: 'https://placehold.co/400x300/f4f4f5/3f3f46?text=Feature+3',
title: 'Integrations',
text: 'Connect with 100+ tools you already use.',
},
{
image: 'https://placehold.co/400x300/f4f4f5/3f3f46?text=Feature+4',
title: 'Mobile App',
text: 'Stay productive on the go with our mobile app.',
},
{
image: 'https://placehold.co/400x300/f4f4f5/3f3f46?text=Feature+5',
title: 'Security',
text: 'Enterprise-grade security for your peace of mind.',
},
{
image: 'https://placehold.co/400x300/f4f4f5/3f3f46?text=Feature+6',
title: 'Support',
text: '24/7 customer support whenever you need help.',
},
],
columnsMobile: 1,
columnsTablet: 2,
columnsDesktop: 3,
padding: 'xl',
backgroundVariant: 'light',
contentMaxWidth: 'wide',
},
},
// CTASection (dark full-width)
{
type: 'CTASection',
props: {
id: generateId('cta-2'),
heading: 'See the platform in action',
supportingText:
'Watch a 2-minute demo to see how we can help your business.',
buttons: [
{
text: 'Watch Demo',
href: '#demo',
variant: 'primary',
},
{
text: 'Talk to Sales',
href: '#sales',
variant: 'secondary',
},
],
variant: 'dark',
padding: 'xl',
contentMaxWidth: 'normal',
alignment: 'center',
},
},
// TestimonialCarousel
{
type: 'TestimonialCarousel',
props: {
id: generateId('testimonials'),
heading: 'What our customers say',
items: [
{
quote:
'This platform has transformed how we work. We\'ve increased productivity by 40% since switching.',
name: 'Sarah Johnson',
title: 'CEO, TechCorp',
avatar: 'https://placehold.co/64x64/e4e4e7/3f3f46?text=SJ',
rating: 5,
},
{
quote:
'The best investment we\'ve made for our team. The automation features alone save us 20 hours per week.',
name: 'Michael Chen',
title: 'Operations Director, StartupXYZ',
avatar: 'https://placehold.co/64x64/e4e4e7/3f3f46?text=MC',
rating: 5,
},
{
quote:
'Outstanding customer support and a product that just works. Highly recommend to any growing business.',
name: 'Emily Rodriguez',
title: 'Founder, GrowthLabs',
avatar: 'https://placehold.co/64x64/e4e4e7/3f3f46?text=ER',
rating: 5,
},
],
layout: 'carousel',
showRating: true,
padding: 'xl',
backgroundVariant: 'none',
contentMaxWidth: 'wide',
},
},
// VideoFeature
{
type: 'VideoFeature',
props: {
id: generateId('video'),
heading: 'See how it works',
text: 'Watch a quick overview of our platform and discover how it can help your business grow.',
thumbnailImage:
'https://placehold.co/800x450/1a1a2e/ffffff?text=Video+Thumbnail',
provider: 'youtube',
videoId: 'dQw4w9WgXcQ',
variant: 'light',
padding: 'xl',
backgroundVariant: 'light',
contentMaxWidth: 'normal',
alignment: 'center',
},
},
// AlternatingFeatures
{
type: 'AlternatingFeatures',
props: {
id: generateId('alternating'),
sectionHeading: 'Everything you need to succeed',
features: [
{
heading: 'Streamlined Workflows',
text: 'Build custom workflows that automate your most repetitive tasks. Save time and reduce errors.',
bullets: [
'Drag-and-drop workflow builder',
'Pre-built templates',
'Conditional logic support',
],
image: 'https://placehold.co/500x350/f4f4f5/3f3f46?text=Workflows',
},
{
heading: 'Powerful Integrations',
text: 'Connect with the tools you already use. Our platform integrates with 100+ popular applications.',
bullets: [
'One-click connections',
'Real-time sync',
'Custom API access',
],
image: 'https://placehold.co/500x350/f4f4f5/3f3f46?text=Integrations',
},
{
heading: 'Advanced Reporting',
text: 'Get insights that matter with our comprehensive reporting and analytics tools.',
bullets: [
'Custom dashboards',
'Scheduled reports',
'Export to any format',
],
image: 'https://placehold.co/500x350/f4f4f5/3f3f46?text=Reporting',
},
],
padding: 'xl',
backgroundVariant: 'none',
contentMaxWidth: 'wide',
},
},
// PricingPlans
{
type: 'PricingPlans',
props: {
id: generateId('pricing'),
sectionHeading: 'Simple, transparent pricing',
sectionSubheading: 'No hidden fees. Cancel anytime.',
currency: '$',
billingPeriod: '/month',
plans: [
{
name: 'Starter',
price: '29',
subtitle: 'Perfect for small teams',
features: [
'Up to 5 team members',
'10 GB storage',
'Basic analytics',
'Email support',
],
cta: {
text: 'Start Free Trial',
href: '#signup',
},
},
{
name: 'Professional',
price: '79',
subtitle: 'For growing businesses',
features: [
'Up to 25 team members',
'100 GB storage',
'Advanced analytics',
'Priority support',
'Custom integrations',
'API access',
],
cta: {
text: 'Start Free Trial',
href: '#signup',
},
},
{
name: 'Enterprise',
price: '199',
subtitle: 'For large organizations',
features: [
'Unlimited team members',
'Unlimited storage',
'Custom analytics',
'24/7 dedicated support',
'Custom integrations',
'API access',
'SSO & SAML',
'SLA guarantee',
],
cta: {
text: 'Contact Sales',
href: '#sales',
},
},
],
highlightIndex: 1,
popularBadgeText: 'Most Popular',
variant: 'light',
padding: 'xl',
backgroundVariant: 'light',
contentMaxWidth: 'wide',
alignment: 'center',
},
},
// FAQAccordion
{
type: 'FAQAccordion',
props: {
id: generateId('faq'),
heading: 'Frequently Asked Questions',
items: [
{
question: 'How does the free trial work?',
answer:
'Start with a 14-day free trial of our Professional plan. No credit card required. You can downgrade or cancel anytime during the trial.',
},
{
question: 'Can I change plans later?',
answer:
'Yes! You can upgrade or downgrade your plan at any time. Changes take effect immediately, and we\'ll prorate any billing differences.',
},
{
question: 'What payment methods do you accept?',
answer:
'We accept all major credit cards (Visa, MasterCard, American Express) and can also invoice enterprise customers.',
},
{
question: 'Is my data secure?',
answer:
'Absolutely. We use enterprise-grade encryption, are SOC 2 Type II certified, and comply with GDPR. Your data is backed up daily.',
},
{
question: 'Do you offer refunds?',
answer:
'Yes, we offer a 30-day money-back guarantee. If you\'re not satisfied, contact support for a full refund.',
},
{
question: 'How do I get support?',
answer:
'All plans include email support. Professional plans get priority support, and Enterprise customers have a dedicated success manager.',
},
],
expandBehavior: 'single',
variant: 'light',
padding: 'xl',
backgroundVariant: 'none',
contentMaxWidth: 'normal',
},
},
// FooterMega
{
type: 'FooterMega',
props: {
id: generateId('footer'),
brandText: 'YourBrand',
brandLogo: '',
description:
'The all-in-one platform for modern businesses. Streamline your operations and grow faster.',
columns: [
{
title: 'Product',
links: [
{ label: 'Features', href: '#features' },
{ label: 'Pricing', href: '#pricing' },
{ label: 'Integrations', href: '#integrations' },
{ label: 'Changelog', href: '#changelog' },
],
},
{
title: 'Company',
links: [
{ label: 'About', href: '#about' },
{ label: 'Blog', href: '#blog' },
{ label: 'Careers', href: '#careers' },
{ label: 'Contact', href: '#contact' },
],
},
{
title: 'Resources',
links: [
{ label: 'Documentation', href: '#docs' },
{ label: 'Help Center', href: '#help' },
{ label: 'API Reference', href: '#api' },
{ label: 'Status', href: '#status' },
],
},
{
title: 'Legal',
links: [
{ label: 'Privacy Policy', href: '#privacy' },
{ label: 'Terms of Service', href: '#terms' },
{ label: 'Cookie Policy', href: '#cookies' },
],
},
],
socialLinks: {
twitter: 'https://twitter.com',
linkedin: 'https://linkedin.com',
github: 'https://github.com',
},
smallPrint: '2024 YourBrand. All rights reserved.',
miniCta: {
text: 'Subscribe to our newsletter',
placeholder: 'Enter your email',
buttonText: 'Subscribe',
},
},
},
],
};
}
// ============================================================================
// Landing Page Templates Registry
// ============================================================================
export const LANDING_PAGE_TEMPLATES: PageTemplate[] = [
{
id: 'saas-dark-hero',
name: 'SaaS Landing Page (Dark Hero)',
description:
'Modern SaaS landing page with dark gradient hero, product screenshot, logo cloud, feature sections, pricing, and more.',
thumbnail: 'https://placehold.co/400x300/1a1a2e/ffffff?text=SaaS+Dark+Hero',
generate: generateSaaSLandingPageTemplate,
},
];
// ============================================================================
// Block Presets
// ============================================================================
export const BLOCK_PRESETS: Record<string, BlockPreset[]> = {
HeroSaaS: [
{
id: 'dark-gradient-centered',
name: 'Dark Gradient (Centered)',
description: 'Dark gradient background with centered text',
props: {
backgroundVariant: 'gradient',
gradientPreset: 'dark-purple',
alignment: 'center',
padding: 'xl',
},
},
{
id: 'light-left-aligned',
name: 'Light (Left Aligned)',
description: 'Light background with left-aligned text',
props: {
backgroundVariant: 'light',
alignment: 'left',
padding: 'xl',
},
},
],
PricingPlans: [
{
id: 'three-card-highlighted',
name: '3 Cards (Middle Highlighted)',
description: 'Three pricing cards with middle plan highlighted',
props: {
highlightIndex: 1,
variant: 'light',
},
},
{
id: 'two-card',
name: '2 Cards',
description: 'Two pricing cards side by side',
props: {
highlightIndex: 0,
variant: 'light',
},
},
],
CTASection: [
{
id: 'dark-gradient',
name: 'Dark Gradient',
props: {
variant: 'gradient',
gradientPreset: 'dark-purple',
alignment: 'center',
},
},
{
id: 'light-centered',
name: 'Light Centered',
props: {
variant: 'light',
alignment: 'center',
},
},
],
FeatureSplit: [
{
id: 'image-right',
name: 'Image Right',
props: {
mediaPosition: 'right',
variant: 'light',
},
},
{
id: 'image-left',
name: 'Image Left',
props: {
mediaPosition: 'left',
variant: 'light',
},
},
],
};
// ============================================================================
// Puck Data Validation
// ============================================================================
export interface ValidationResult {
isValid: boolean;
error?: string;
}
export function validatePuckData(data: unknown): ValidationResult {
if (!data || typeof data !== 'object') {
return { isValid: false, error: 'Data must be an object' };
}
const puckData = data as Record<string, unknown>;
if (!('content' in puckData)) {
return { isValid: false, error: 'Data must have a content array' };
}
if (!Array.isArray(puckData.content)) {
return { isValid: false, error: 'content must be an array' };
}
for (let i = 0; i < puckData.content.length; i++) {
const component = puckData.content[i] as Record<string, unknown>;
if (!component || typeof component !== 'object') {
return { isValid: false, error: `content[${i}] must be an object` };
}
if (!('type' in component) || typeof component.type !== 'string') {
return { isValid: false, error: `content[${i}] must have a type string` };
}
}
return { isValid: true };
}

561
frontend/src/puck/theme.ts Normal file
View File

@@ -0,0 +1,561 @@
/**
* Puck Theme Tokens and Design Controls
*
* Provides centralized theme configuration and shared design controls
* for consistent styling across all marketing blocks.
*/
import type { Fields } from '@measured/puck';
import { imagePickerField } from './fields/ImagePickerField';
// ============================================================================
// Theme Token Types
// ============================================================================
export interface ColorScale {
50: string;
100: string;
200: string;
300: string;
400: string;
500: string;
600: string;
700: string;
800: string;
900: string;
950: string;
}
export interface ThemeTokens {
colors: {
primary: ColorScale;
secondary: ColorScale;
accent: ColorScale;
neutral: ColorScale;
};
typography: {
fontScale: {
xs: string;
sm: string;
base: string;
lg: string;
xl: string;
'2xl': string;
'3xl': string;
'4xl': string;
'5xl': string;
'6xl': string;
};
headingFamily: string;
bodyFamily: string;
};
buttons: {
radius: string;
variants: {
primary: {
bg: string;
text: string;
hoverBg: string;
};
secondary: {
bg: string;
text: string;
hoverBg: string;
};
ghost: {
bg: string;
text: string;
hoverBg: string;
};
};
};
containerWidths: {
narrow: string;
normal: string;
wide: string;
full: string;
};
}
// ============================================================================
// Default Theme Tokens
// ============================================================================
export const defaultThemeTokens: ThemeTokens = {
colors: {
primary: {
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
300: '#c4b5fd',
400: '#a78bfa',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
800: '#5b21b6',
900: '#4c1d95',
950: '#2e1065',
},
secondary: {
50: '#ecfdf5',
100: '#d1fae5',
200: '#a7f3d0',
300: '#6ee7b7',
400: '#34d399',
500: '#10b981',
600: '#059669',
700: '#047857',
800: '#065f46',
900: '#064e3b',
950: '#022c22',
},
accent: {
50: '#fff7ed',
100: '#ffedd5',
200: '#fed7aa',
300: '#fdba74',
400: '#fb923c',
500: '#f97316',
600: '#ea580c',
700: '#c2410c',
800: '#9a3412',
900: '#7c2d12',
950: '#431407',
},
neutral: {
50: '#fafafa',
100: '#f4f4f5',
200: '#e4e4e7',
300: '#d4d4d8',
400: '#a1a1aa',
500: '#71717a',
600: '#52525b',
700: '#3f3f46',
800: '#27272a',
900: '#18181b',
950: '#09090b',
},
},
typography: {
fontScale: {
xs: '0.75rem',
sm: '0.875rem',
base: '1rem',
lg: '1.125rem',
xl: '1.25rem',
'2xl': '1.5rem',
'3xl': '1.875rem',
'4xl': '2.25rem',
'5xl': '3rem',
'6xl': '3.75rem',
},
headingFamily: 'Inter, system-ui, sans-serif',
bodyFamily: 'Inter, system-ui, sans-serif',
},
buttons: {
radius: '0.5rem',
variants: {
primary: {
bg: 'bg-primary-600',
text: 'text-white',
hoverBg: 'hover:bg-primary-700',
},
secondary: {
bg: 'bg-neutral-100',
text: 'text-neutral-900',
hoverBg: 'hover:bg-neutral-200',
},
ghost: {
bg: 'bg-transparent',
text: 'text-neutral-600',
hoverBg: 'hover:bg-neutral-100',
},
},
},
containerWidths: {
narrow: 'max-w-3xl',
normal: 'max-w-6xl',
wide: 'max-w-7xl',
full: 'max-w-full',
},
};
// ============================================================================
// Gradient Presets
// ============================================================================
export interface GradientPreset {
name: string;
label: string;
value: string;
}
export const GRADIENT_PRESETS: GradientPreset[] = [
{
name: 'dark-purple',
label: 'Dark Purple',
value: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)',
},
{
name: 'dark-blue',
label: 'Dark Blue',
value: 'linear-gradient(135deg, #0c1445 0%, #1a237e 50%, #283593 100%)',
},
{
name: 'midnight',
label: 'Midnight',
value: 'linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%)',
},
{
name: 'ocean-dark',
label: 'Ocean Dark',
value: 'linear-gradient(135deg, #0d1b2a 0%, #1b263b 50%, #415a77 100%)',
},
{
name: 'emerald-dark',
label: 'Emerald Dark',
value: 'linear-gradient(135deg, #064e3b 0%, #065f46 50%, #047857 100%)',
},
{
name: 'royal',
label: 'Royal',
value: 'linear-gradient(135deg, #1e1b4b 0%, #312e81 50%, #4338ca 100%)',
},
{
name: 'sunset-dark',
label: 'Sunset Dark',
value: 'linear-gradient(135deg, #1f1c2c 0%, #431407 50%, #7c2d12 100%)',
},
{
name: 'slate-dark',
label: 'Slate Dark',
value: 'linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #334155 100%)',
},
];
// ============================================================================
// Padding Presets
// ============================================================================
export const PADDING_PRESETS: Record<string, string> = {
xs: 'py-4',
sm: 'py-8',
md: 'py-12',
lg: 'py-16',
xl: 'py-24',
};
// ============================================================================
// Container Width Presets
// ============================================================================
export const CONTAINER_WIDTH_PRESETS: Record<string, string> = {
narrow: 'max-w-3xl',
normal: 'max-w-6xl',
wide: 'max-w-7xl',
full: 'max-w-full w-full',
};
// ============================================================================
// Button Radius Presets
// ============================================================================
export const BUTTON_RADIUS_PRESETS: Record<string, string> = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
full: 'rounded-full',
};
// ============================================================================
// Shadow Presets
// ============================================================================
export const SHADOW_PRESETS: Record<string, string> = {
none: 'shadow-none',
sm: 'shadow-sm',
md: 'shadow-md',
lg: 'shadow-lg',
xl: 'shadow-xl',
};
// ============================================================================
// Background Variants
// ============================================================================
export const BACKGROUND_VARIANTS: Record<string, { className: string; textClass: string }> = {
none: { className: '', textClass: 'text-neutral-900' },
light: { className: 'bg-neutral-50', textClass: 'text-neutral-900' },
dark: { className: 'bg-neutral-900', textClass: 'text-white' },
gradient: { className: '', textClass: 'text-white' }, // gradient applied via style
};
// ============================================================================
// Design Controls Types
// ============================================================================
export interface DesignControlsProps {
padding?: keyof typeof PADDING_PRESETS;
backgroundVariant?: 'none' | 'light' | 'dark' | 'gradient';
gradientPreset?: string;
backgroundImage?: string;
overlayStrength?: number;
contentMaxWidth?: keyof typeof CONTAINER_WIDTH_PRESETS;
alignment?: 'left' | 'center' | 'right';
borderRadius?: keyof typeof BUTTON_RADIUS_PRESETS;
shadow?: keyof typeof SHADOW_PRESETS;
hideOnMobile?: boolean;
hideOnTablet?: boolean;
hideOnDesktop?: boolean;
anchorId?: string;
}
export interface AppliedDesignControls {
className: string;
containerClassName: string;
style: React.CSSProperties;
textClassName: string;
}
// ============================================================================
// Create Design Controls Fields
// ============================================================================
export function createDesignControlsFields(): Fields<DesignControlsProps> {
return {
padding: {
type: 'select',
label: 'Padding',
options: [
{ label: 'Extra Small', value: 'xs' },
{ label: 'Small', value: 'sm' },
{ label: 'Medium', value: 'md' },
{ label: 'Large', value: 'lg' },
{ label: 'Extra Large', value: 'xl' },
],
},
backgroundVariant: {
type: 'select',
label: 'Background',
options: [
{ label: 'None', value: 'none' },
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
{ label: 'Gradient', value: 'gradient' },
],
},
gradientPreset: {
type: 'select',
label: 'Gradient Preset',
options: GRADIENT_PRESETS.map(g => ({ label: g.label, value: g.name })),
},
backgroundImage: {
...imagePickerField,
label: 'Background Image',
},
overlayStrength: {
type: 'number',
label: 'Overlay Strength (0-100)',
min: 0,
max: 100,
},
contentMaxWidth: {
type: 'select',
label: 'Content Width',
options: [
{ label: 'Narrow', value: 'narrow' },
{ label: 'Normal', value: 'normal' },
{ label: 'Wide', value: 'wide' },
{ label: 'Full', value: 'full' },
],
},
alignment: {
type: 'select',
label: 'Alignment',
options: [
{ label: 'Left', value: 'left' },
{ label: 'Center', value: 'center' },
{ label: 'Right', value: 'right' },
],
},
borderRadius: {
type: 'select',
label: 'Border Radius',
options: [
{ label: 'None', value: 'none' },
{ label: 'Small', value: 'sm' },
{ label: 'Medium', value: 'md' },
{ label: 'Large', value: 'lg' },
{ label: 'Full', value: 'full' },
],
},
shadow: {
type: 'select',
label: 'Shadow',
options: [
{ label: 'None', value: 'none' },
{ label: 'Small', value: 'sm' },
{ label: 'Medium', value: 'md' },
{ label: 'Large', value: 'lg' },
{ label: 'Extra Large', value: 'xl' },
],
},
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 },
],
},
anchorId: {
type: 'text',
label: 'Anchor ID (for navigation)',
},
};
}
// ============================================================================
// Apply Design Controls
// ============================================================================
export function applyDesignControls(props: Partial<DesignControlsProps>): AppliedDesignControls {
const classes: string[] = [];
const containerClasses: string[] = [];
const style: React.CSSProperties = {};
let textClassName = '';
// Padding
if (props.padding) {
const paddingClass = PADDING_PRESETS[props.padding];
if (paddingClass) {
classes.push(paddingClass);
}
}
// Background variant
if (props.backgroundVariant) {
const variant = BACKGROUND_VARIANTS[props.backgroundVariant];
if (variant) {
if (variant.className) {
classes.push(variant.className);
}
textClassName = variant.textClass;
// Apply gradient if selected
if (props.backgroundVariant === 'gradient' && props.gradientPreset) {
const gradient = GRADIENT_PRESETS.find(g => g.name === props.gradientPreset);
if (gradient) {
style.background = gradient.value;
}
}
}
}
// Background image
if (props.backgroundImage) {
style.backgroundImage = `url(${props.backgroundImage})`;
style.backgroundSize = 'cover';
style.backgroundPosition = 'center';
}
// Overlay (applied via pseudo-element in render)
// Note: overlayStrength is handled in component render
// Content max width
if (props.contentMaxWidth) {
const widthClass = CONTAINER_WIDTH_PRESETS[props.contentMaxWidth];
if (widthClass) {
containerClasses.push(widthClass);
}
}
// Alignment
if (props.alignment) {
const alignmentClasses: Record<string, string> = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
};
classes.push(alignmentClasses[props.alignment] || '');
}
// Border radius
if (props.borderRadius) {
const radiusClass = BUTTON_RADIUS_PRESETS[props.borderRadius];
if (radiusClass) {
classes.push(radiusClass);
}
}
// Shadow
if (props.shadow) {
const shadowClass = SHADOW_PRESETS[props.shadow];
if (shadowClass) {
classes.push(shadowClass);
}
}
// Visibility
if (props.hideOnMobile) {
classes.push('hidden sm:block');
}
if (props.hideOnTablet) {
classes.push('sm:hidden md:block');
}
if (props.hideOnDesktop) {
classes.push('md:hidden');
}
return {
className: classes.filter(Boolean).join(' '),
containerClassName: containerClasses.filter(Boolean).join(' '),
style,
textClassName,
};
}
// ============================================================================
// Default Design Controls Props
// ============================================================================
export const defaultDesignControlsProps: DesignControlsProps = {
padding: 'lg',
backgroundVariant: 'none',
contentMaxWidth: 'normal',
alignment: 'left',
borderRadius: 'none',
shadow: 'none',
hideOnMobile: false,
hideOnTablet: false,
hideOnDesktop: false,
};
// ============================================================================
// Helper: Merge with defaults
// ============================================================================
export function mergeWithDesignDefaults(
props: Partial<DesignControlsProps>
): DesignControlsProps {
return {
...defaultDesignControlsProps,
...props,
};
}

View File

@@ -176,14 +176,20 @@ export interface TestimonialProps {
rating?: 1 | 2 | 3 | 4 | 5;
}
export interface FaqItem {
question: string;
answer: string;
}
export interface FaqProps {
items: FaqItem[];
title?: string;
export interface FullBookingFlowProps {
headline?: string;
subheadline?: string;
showPrices: boolean;
showDuration: boolean;
showDeposits: boolean;
allowGuestCheckout: boolean;
successMessage?: string;
successRedirectUrl?: string;
padding?: string;
backgroundVariant?: string;
contentMaxWidth?: string;
anchorId?: string;
}
export interface BookingWidgetProps {
@@ -223,16 +229,17 @@ export interface ServicesProps {
}
export interface ContactFormProps {
fields: Array<{
name: string;
type: 'text' | 'email' | 'phone' | 'textarea';
label: string;
required: boolean;
}>;
heading?: string;
subheading?: string;
submitButtonText: string;
successMessage: string;
includeConsent: boolean;
consentText?: string;
showPhone: boolean;
showSubject: boolean;
subjectOptions?: string[];
padding?: string;
backgroundVariant?: string;
contentMaxWidth?: string;
anchorId?: string;
}
export interface BusinessHoursProps {
@@ -240,39 +247,227 @@ export interface BusinessHoursProps {
title?: string;
}
export interface AddressBlockProps {
businessName?: string;
address?: string;
address2?: string;
city?: string;
state?: string;
zip?: string;
phone?: string;
email?: string;
showIcons: boolean;
layout: 'vertical' | 'horizontal';
alignment: 'left' | 'center' | 'right';
}
export interface MapProps {
embedUrl: string;
height: number;
}
// Marketing component prop types (generic, business-agnostic)
export interface HeaderMarketingProps {
brandText: string;
brandLogo?: string;
links: Array<{ label: string; href: string }>;
ctaButton?: { text: string; href: string };
variant: 'transparent-on-dark' | 'light' | 'dark';
showMobileMenu: boolean;
sticky?: boolean;
}
export interface HeroMarketingProps {
headline: string;
subheadline: string;
primaryCta: { text: string; href: string };
secondaryCta?: { text: string; href: string };
media?: { type: 'image' | 'none'; src?: string; alt?: string };
badge?: string;
variant: 'centered' | 'split' | 'minimal';
fullWidth?: boolean;
padding?: string;
backgroundVariant?: string;
gradientPreset?: string;
contentMaxWidth?: string;
alignment?: string;
anchorId?: string;
}
export interface LogoCloudProps {
heading?: string;
logos: Array<{ src: string; alt: string; href?: string }>;
grayscale: boolean;
spacingDensity: 'compact' | 'normal' | 'spacious';
padding?: string;
backgroundVariant?: string;
contentMaxWidth?: string;
anchorId?: string;
}
export interface SplitContentProps {
eyebrow?: string;
heading: string;
content: string;
bullets?: Array<{ text: string; icon?: string }>;
cta?: { text: string; href: string };
media: { src: string; alt: string };
mediaPosition: 'left' | 'right';
variant: 'light' | 'dark';
padding?: string;
backgroundVariant?: string;
contentMaxWidth?: string;
anchorId?: string;
}
export interface CTASectionProps {
heading: string;
supportingText?: string;
buttons: Array<{ text: string; href: string; variant?: 'primary' | 'secondary' }>;
variant: 'light' | 'dark' | 'gradient';
padding?: string;
backgroundVariant?: string;
contentMaxWidth?: string;
anchorId?: string;
}
export interface StatsStripProps {
stats: Array<{ value: string; label: string }>;
variant: 'light' | 'dark';
padding?: string;
backgroundVariant?: string;
contentMaxWidth?: string;
anchorId?: string;
}
export interface GalleryGridProps {
heading?: string;
items: Array<{ image: string; title: string; text?: string; link?: string }>;
columnsMobile: 1 | 2;
columnsTablet: 2 | 3;
columnsDesktop: 2 | 3 | 4;
padding?: string;
backgroundVariant?: string;
contentMaxWidth?: string;
anchorId?: string;
}
export interface TestimonialsMarketingProps {
heading?: string;
items: Array<{ quote: string; name: string; title?: string; avatar?: string; rating?: 1 | 2 | 3 | 4 | 5 }>;
layout: 'carousel' | 'stacked';
showRating: boolean;
padding?: string;
backgroundVariant?: string;
contentMaxWidth?: string;
anchorId?: string;
}
export interface VideoEmbedProps {
heading: string;
text?: string;
thumbnailImage: string;
provider: 'youtube' | 'vimeo';
videoId: string;
variant: 'light' | 'dark';
padding?: string;
backgroundVariant?: string;
contentMaxWidth?: string;
anchorId?: string;
}
export interface ContentBlocksProps {
sectionHeading?: string;
features: Array<{ heading: string; text: string; bullets?: string[]; image: string }>;
padding?: string;
backgroundVariant?: string;
contentMaxWidth?: string;
anchorId?: string;
}
export interface PricingCardsProps {
sectionHeading?: string;
sectionSubheading?: string;
currency: string;
billingPeriod: string;
plans: Array<{
name: string;
price: string;
subtitle?: string;
features: string[];
cta: { text: string; href: string };
}>;
highlightIndex: number;
popularBadgeText?: string;
variant: 'light' | 'dark';
padding?: string;
backgroundVariant?: string;
contentMaxWidth?: string;
anchorId?: string;
}
export interface FooterMarketingProps {
brandText: string;
brandLogo?: string;
description?: string;
columns: Array<{ title: string; links: Array<{ label: string; href: string }> }>;
socialLinks?: { twitter?: string; linkedin?: string; github?: string; facebook?: string; instagram?: string; youtube?: string };
smallPrint?: string;
miniCta?: { text: string; placeholder: string; buttonText: string };
}
export interface FAQAccordionProps {
heading?: string;
items: Array<{ question: string; answer: string }>;
expandBehavior: 'single' | 'multiple';
variant: 'light' | 'dark';
padding?: string;
backgroundVariant?: string;
contentMaxWidth?: string;
anchorId?: string;
}
// Component definitions for Puck config
export type ComponentProps = {
// Layout components
Section: SectionProps;
Columns: ColumnsProps;
Card: CardProps;
Spacer: SpacerProps;
Divider: DividerProps;
// Content components
Heading: HeadingProps;
RichText: RichTextProps;
Image: ImageProps;
Button: ButtonProps;
IconList: IconListProps;
Testimonial: TestimonialProps;
FAQ: FaqProps;
// Booking components
FullBookingFlow: FullBookingFlowProps;
BookingWidget: BookingWidgetProps;
ServiceCatalog: ServiceCatalogProps;
Services: ServicesProps;
// Contact components
ContactForm: ContactFormProps;
BusinessHours: BusinessHoursProps;
AddressBlock: AddressBlockProps;
Map: MapProps;
// Marketing components (generic, business-agnostic)
Header: HeaderMarketingProps;
Hero: HeroMarketingProps;
LogoCloud: LogoCloudProps;
SplitContent: SplitContentProps;
CTASection: CTASectionProps;
StatsStrip: StatsStripProps;
GalleryGrid: GalleryGridProps;
Testimonials: TestimonialsMarketingProps;
VideoEmbed: VideoEmbedProps;
ContentBlocks: ContentBlocksProps;
PricingCards: PricingCardsProps;
Footer: FooterMarketingProps;
FAQAccordion: FAQAccordionProps;
// Legacy components for backward compatibility
Hero: {
title: string;
subtitle: string;
align: 'left' | 'center' | 'right';
ctaText?: string;
ctaLink?: string;
};
TextSection: {
heading: string;
body: string;

View File

@@ -0,0 +1,247 @@
/**
* Video Embed Validation Utilities
*
* Security: Only YouTube and Vimeo embeds are allowed.
* This module validates video URLs and builds safe embed URLs.
*/
// ============================================================================
// Video Provider Definitions
// ============================================================================
export interface VideoProvider {
name: string;
patterns: RegExp[];
embedTemplate: (videoId: string, hash?: string) => string;
validateId: (id: string) => boolean;
}
export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
youtube: {
name: 'YouTube',
patterns: [
// Standard watch URL: https://www.youtube.com/watch?v=VIDEO_ID
/^https:\/\/(?:www\.)?youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/,
// Short URL: https://youtu.be/VIDEO_ID
/^https:\/\/youtu\.be\/([a-zA-Z0-9_-]{11})/,
// Embed URL: https://www.youtube.com/embed/VIDEO_ID
/^https:\/\/(?:www\.)?youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/,
// Nocookie embed: https://www.youtube-nocookie.com/embed/VIDEO_ID
/^https:\/\/(?:www\.)?youtube-nocookie\.com\/embed\/([a-zA-Z0-9_-]{11})/,
],
embedTemplate: (videoId: string) =>
`https://www.youtube-nocookie.com/embed/${videoId}`,
validateId: (id: string) => /^[a-zA-Z0-9_-]{11}$/.test(id),
},
vimeo: {
name: 'Vimeo',
patterns: [
// Standard URL: https://vimeo.com/VIDEO_ID
/^https:\/\/(?:www\.)?vimeo\.com\/(\d+)(?:\/([a-zA-Z0-9]+))?/,
// Player URL: https://player.vimeo.com/video/VIDEO_ID
/^https:\/\/player\.vimeo\.com\/video\/(\d+)/,
],
embedTemplate: (videoId: string, hash?: string) =>
hash
? `https://player.vimeo.com/video/${videoId}?h=${hash}`
: `https://player.vimeo.com/video/${videoId}`,
validateId: (id: string) => /^\d+$/.test(id),
},
};
// ============================================================================
// Validation Result Types
// ============================================================================
export interface VideoValidationResult {
isValid: boolean;
provider?: 'youtube' | 'vimeo';
videoId?: string;
hash?: string;
error?: string;
}
export interface ParsedVideo {
provider: 'youtube' | 'vimeo';
videoId: string;
hash?: string;
}
// ============================================================================
// URL Sanitization
// ============================================================================
/**
* Check if a string contains potentially dangerous content
*/
function containsDangerousContent(str: string): boolean {
if (!str) return false;
const dangerousPatterns = [
/<script/i,
/<\/script/i,
/javascript:/i,
/onerror=/i,
/onload=/i,
/onclick=/i,
/%00/, // null byte
/[\u0430-\u04FF]/, // Cyrillic characters (homograph attacks)
/[\u0600-\u06FF]/, // Arabic characters
];
return dangerousPatterns.some(pattern => pattern.test(str));
}
/**
* Sanitize a video ID to prevent injection
*/
function sanitizeVideoId(id: string, provider: 'youtube' | 'vimeo'): string {
if (provider === 'youtube') {
// YouTube IDs: exactly 11 alphanumeric chars, plus - and _
return id.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 11);
}
// Vimeo IDs: numeric only
return id.replace(/\D/g, '');
}
// ============================================================================
// Validation Functions
// ============================================================================
/**
* Validate a video embed URL
*
* @param url - The URL to validate
* @returns Validation result with provider and video ID if valid
*/
export function validateVideoEmbed(url: string): VideoValidationResult {
// Handle null/undefined
if (!url || typeof url !== 'string') {
return { isValid: false, error: 'URL is required' };
}
const trimmedUrl = url.trim();
// Check for dangerous content
if (containsDangerousContent(trimmedUrl)) {
return { isValid: false, error: 'URL contains invalid characters' };
}
// Must be HTTPS
if (!trimmedUrl.startsWith('https://')) {
return { isValid: false, error: 'URL must use HTTPS' };
}
// Check for data: and javascript: protocols
if (trimmedUrl.startsWith('data:') || trimmedUrl.includes('javascript:')) {
return { isValid: false, error: 'Invalid URL protocol' };
}
// Try to parse as YouTube
for (const pattern of VIDEO_PROVIDERS.youtube.patterns) {
const match = trimmedUrl.match(pattern);
if (match && match[1]) {
const videoId = match[1];
if (!VIDEO_PROVIDERS.youtube.validateId(videoId)) {
return { isValid: false, error: 'Invalid YouTube video ID' };
}
return {
isValid: true,
provider: 'youtube',
videoId,
};
}
}
// Try to parse as Vimeo
for (const pattern of VIDEO_PROVIDERS.vimeo.patterns) {
const match = trimmedUrl.match(pattern);
if (match && match[1]) {
const videoId = match[1];
const hash = match[2];
if (!VIDEO_PROVIDERS.vimeo.validateId(videoId)) {
return { isValid: false, error: 'Invalid Vimeo video ID' };
}
return {
isValid: true,
provider: 'vimeo',
videoId,
hash,
};
}
}
// No match found
return {
isValid: false,
error: 'URL must be from YouTube or Vimeo',
};
}
/**
* Parse a video URL to extract provider and ID
*
* @param url - The URL to parse
* @returns Parsed video info or null if invalid
*/
export function parseVideoUrl(url: string): ParsedVideo | null {
const result = validateVideoEmbed(url);
if (result.isValid && result.provider && result.videoId) {
return {
provider: result.provider,
videoId: result.videoId,
hash: result.hash,
};
}
return null;
}
/**
* Build a safe embed URL from provider and video ID
*
* @param provider - The video provider
* @param videoId - The video ID
* @param hash - Optional hash for private Vimeo videos
* @returns Safe embed URL
*/
export function buildSafeEmbedUrl(
provider: 'youtube' | 'vimeo',
videoId: string,
hash?: string
): string {
const providerConfig = VIDEO_PROVIDERS[provider];
if (!providerConfig) {
throw new Error(`Unknown video provider: ${provider}`);
}
// Sanitize the video ID
const sanitizedId = sanitizeVideoId(videoId, provider);
// Validate the sanitized ID
if (!providerConfig.validateId(sanitizedId)) {
throw new Error(`Invalid video ID for ${provider}`);
}
// Sanitize hash if present (for Vimeo)
const sanitizedHash = hash ? hash.replace(/[^a-zA-Z0-9]/g, '') : undefined;
return providerConfig.embedTemplate(sanitizedId, sanitizedHash);
}
/**
* Get the embed URL from a video URL
*
* @param url - The video URL
* @returns Safe embed URL or null if invalid
*/
export function getEmbedUrl(url: string): string | null {
const parsed = parseVideoUrl(url);
if (!parsed) return null;
try {
return buildSafeEmbedUrl(parsed.provider, parsed.videoId, parsed.hash);
} catch {
return null;
}
}