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;
}
}

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.8 on 2025-12-14 00:42
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0027_remove_legacy_feature_fields'),
]
operations = [
migrations.CreateModel(
name='TenantStorageUsage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('bytes_used', models.BigIntegerField(default=0, help_text='Total storage used in bytes')),
('file_count', models.PositiveIntegerField(default=0, help_text='Total number of media files')),
('last_calculated', models.DateTimeField(auto_now=True, help_text='When usage was last updated')),
('tenant', models.OneToOneField(help_text='The tenant this usage record belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='storage_usage', to='core.tenant')),
],
options={
'verbose_name': 'Tenant Storage Usage',
'verbose_name_plural': 'Tenant Storage Usage',
},
),
]

View File

@@ -874,3 +874,64 @@ class QuotaOverage(models.Model):
self.archived_resource_ids = archived_ids
self.save()
class TenantStorageUsage(models.Model):
"""
Track storage usage for each tenant.
Lives in public schema since it needs to track usage across all tenants.
Updated atomically when files are uploaded or deleted.
"""
tenant = models.OneToOneField(
Tenant,
on_delete=models.CASCADE,
related_name='storage_usage',
help_text="The tenant this usage record belongs to"
)
bytes_used = models.BigIntegerField(
default=0,
help_text="Total storage used in bytes"
)
file_count = models.PositiveIntegerField(
default=0,
help_text="Total number of media files"
)
last_calculated = models.DateTimeField(
auto_now=True,
help_text="When usage was last updated"
)
class Meta:
app_label = 'core'
verbose_name = 'Tenant Storage Usage'
verbose_name_plural = 'Tenant Storage Usage'
def __str__(self):
mb_used = self.bytes_used / (1024 * 1024)
return f"{self.tenant.name}: {mb_used:.2f} MB ({self.file_count} files)"
def add_file(self, file_size_bytes):
"""Add a file to the usage counter."""
from django.db.models import F
TenantStorageUsage.objects.filter(pk=self.pk).update(
bytes_used=F('bytes_used') + file_size_bytes,
file_count=F('file_count') + 1
)
self.refresh_from_db()
def remove_file(self, file_size_bytes):
"""Remove a file from the usage counter."""
from django.db.models import F
TenantStorageUsage.objects.filter(pk=self.pk).update(
bytes_used=F('bytes_used') - file_size_bytes,
file_count=F('file_count') - 1
)
self.refresh_from_db()
# Ensure we don't go negative
if self.bytes_used < 0:
self.bytes_used = 0
self.save(update_fields=['bytes_used'])
if self.file_count < 0:
self.file_count = 0
self.save(update_fields=['file_count'])

View File

@@ -0,0 +1,165 @@
"""
Core services for identity domain.
"""
from typing import Tuple
class StorageQuotaService:
"""
Service for managing tenant storage quotas.
Handles:
- Checking storage quota from billing features
- Getting current usage
- Validating uploads against quota
- Updating usage after upload/delete
"""
# Default storage in GB for tenants without a subscription
DEFAULT_STORAGE_GB = 1
@staticmethod
def get_quota_bytes(tenant) -> int:
"""
Get storage quota in bytes from billing feature.
Args:
tenant: Tenant instance
Returns:
int: Storage quota in bytes
"""
from smoothschedule.billing.services.entitlements import EntitlementService
# Get storage_gb feature value from billing
if hasattr(tenant, 'billing_subscription') and tenant.billing_subscription:
storage_gb = EntitlementService.get_feature_value(
tenant,
'storage_gb',
default=StorageQuotaService.DEFAULT_STORAGE_GB
)
else:
storage_gb = StorageQuotaService.DEFAULT_STORAGE_GB
# Convert GB to bytes (1 GB = 1024^3 bytes)
return storage_gb * 1024 * 1024 * 1024
@staticmethod
def get_usage(tenant) -> dict:
"""
Get current storage usage for a tenant.
Args:
tenant: Tenant instance
Returns:
dict: {
'bytes_used': int,
'bytes_total': int,
'file_count': int,
'percent_used': float
}
"""
from .models import TenantStorageUsage
# Get or create usage record
usage, _ = TenantStorageUsage.objects.get_or_create(tenant=tenant)
quota = StorageQuotaService.get_quota_bytes(tenant)
percent_used = (usage.bytes_used / quota * 100) if quota > 0 else 0
return {
'bytes_used': usage.bytes_used,
'bytes_total': quota,
'file_count': usage.file_count,
'percent_used': round(percent_used, 2)
}
@staticmethod
def can_upload(tenant, file_size: int) -> Tuple[bool, str]:
"""
Check if tenant can upload a file of given size.
Args:
tenant: Tenant instance
file_size: Size of file to upload in bytes
Returns:
Tuple of (bool, str): (can_upload, error_message)
"""
usage = StorageQuotaService.get_usage(tenant)
if usage['bytes_used'] + file_size > usage['bytes_total']:
remaining = usage['bytes_total'] - usage['bytes_used']
remaining_mb = remaining / (1024 * 1024)
file_mb = file_size / (1024 * 1024)
return False, (
f'Storage quota exceeded. You have {remaining_mb:.1f} MB remaining '
f'but the file is {file_mb:.1f} MB. Please delete some files or upgrade your plan.'
)
return True, ''
@staticmethod
def update_usage(tenant, bytes_delta: int, count_delta: int = 0):
"""
Update storage usage after upload or delete.
Args:
tenant: Tenant instance
bytes_delta: Change in bytes (positive for upload, negative for delete)
count_delta: Change in file count (typically +1 or -1)
"""
from .models import TenantStorageUsage
usage, _ = TenantStorageUsage.objects.get_or_create(tenant=tenant)
if bytes_delta > 0:
usage.add_file(bytes_delta)
elif bytes_delta < 0:
usage.remove_file(abs(bytes_delta))
elif count_delta != 0:
# Edge case: only count changed (shouldn't happen normally)
from django.db.models import F
TenantStorageUsage.objects.filter(pk=usage.pk).update(
file_count=F('file_count') + count_delta
)
@staticmethod
def recalculate_usage(tenant):
"""
Recalculate storage usage from actual files.
Use this if usage tracking gets out of sync.
Args:
tenant: Tenant instance
Returns:
dict: Updated usage stats
"""
from django.db import connection
from smoothschedule.scheduling.schedule.models import MediaFile
from .models import TenantStorageUsage
# Switch to tenant schema to query files
with connection.cursor() as cursor:
cursor.execute(f"SET search_path TO {tenant.schema_name}")
# Calculate actual usage from MediaFile table
from django.db.models import Sum, Count
stats = MediaFile.objects.aggregate(
total_bytes=Sum('file_size'),
total_files=Count('id')
)
bytes_used = stats['total_bytes'] or 0
file_count = stats['total_files'] or 0
# Update the usage record
usage, _ = TenantStorageUsage.objects.get_or_create(tenant=tenant)
usage.bytes_used = bytes_used
usage.file_count = file_count
usage.save()
return StorageQuotaService.get_usage(tenant)

View File

@@ -11,6 +11,27 @@ from django.dispatch import receiver
logger = logging.getLogger(__name__)
def _create_site_for_tenant(tenant_id):
"""
Create a Site and default home page for a tenant.
Called after transaction commits to ensure tenant is fully saved.
"""
from smoothschedule.identity.core.models import Tenant
from smoothschedule.platform.tenant_sites.models import Site
try:
tenant = Tenant.objects.get(id=tenant_id)
# Check if site already exists
if not Site.objects.filter(tenant=tenant).exists():
site = Site.objects.create(tenant=tenant, is_enabled=True)
logger.info(f"Created Site for tenant: {tenant.schema_name}")
# Default page is auto-created by Site's post_save signal
except Tenant.DoesNotExist:
logger.error(f"Tenant {tenant_id} not found when creating Site")
except Exception as e:
logger.error(f"Failed to create Site for tenant {tenant_id}: {e}")
def _seed_plugins_for_tenant(tenant_schema_name):
"""
Internal function to seed platform plugins for a tenant.
@@ -77,3 +98,23 @@ def seed_platform_plugins_on_tenant_create(sender, instance, created, **kwargs):
# This ensures the schema and all tables exist before we try to use them
schema_name = instance.schema_name
transaction.on_commit(lambda: _seed_plugins_for_tenant(schema_name))
@receiver(post_save, sender='core.Tenant')
def create_site_on_tenant_create(sender, instance, created, **kwargs):
"""
Create a Site with default home page when a new tenant is created.
This ensures every tenant has a booking site ready to use immediately.
Uses transaction.on_commit() to defer creation until after the tenant
is fully saved.
"""
if not created:
return
# Skip public schema
if instance.schema_name == 'public':
return
tenant_id = instance.id
transaction.on_commit(lambda: _create_site_for_tenant(tenant_id))

View File

@@ -21,22 +21,41 @@ class Site(models.Model):
default_content = {
"content": [
{
"type": "Hero",
"type": "Header",
"props": {
"id": "Hero-default",
"title": f"Welcome to {self.tenant.name}",
"subtitle": "Book your appointment online with ease",
"align": "center",
"ctaText": "Book Now",
"ctaLink": "/book"
"id": "Header-default",
"brandText": self.tenant.name,
"links": [
{"label": "Login", "href": "/login"}
],
"variant": "light",
"showMobileMenu": True,
"sticky": False
}
},
{
"type": "Booking",
"type": "Hero",
"props": {
"id": "Hero-default",
"headline": f"Welcome to {self.tenant.name}",
"subheadline": "Book your appointment online with ease",
"primaryCta": {"text": "Book Now", "href": "#booking"},
"variant": "centered",
"padding": "large",
"backgroundVariant": "gradient-primary"
}
},
{
"type": "FullBookingFlow",
"props": {
"id": "Booking-default",
"headline": "Schedule Your Appointment",
"subheading": "Choose a service and time that works for you"
"subheadline": "Choose a service and time that works for you",
"showPrices": True,
"showDuration": True,
"showDeposits": True,
"allowGuestCheckout": True,
"anchorId": "booking"
}
}
],
@@ -75,7 +94,8 @@ class SiteConfig(models.Model):
def __str__(self):
return f"Config for {self.site}"
def get_default_theme(self):
@staticmethod
def get_default_theme():
"""Return default theme structure."""
return {
'colors': {

View File

@@ -2,8 +2,8 @@ from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import (
SiteViewSet, SiteConfigViewSet, PageViewSet, DomainViewSet, PublicPageView, PublicServiceViewSet,
PublicAvailabilityView, PublicBusinessHoursView, PublicBookingView,
PublicPaymentIntentView, PublicBusinessInfoView, PublicSiteConfigView
PublicAvailabilityView, PublicBusinessHoursView, PublicWeeklyHoursView, PublicBookingView,
PublicPaymentIntentView, PublicBusinessInfoView, PublicSiteConfigView, PublicContactFormView
)
router = DefaultRouter()
@@ -21,6 +21,8 @@ urlpatterns = [
path('public/business/', PublicBusinessInfoView.as_view(), name='public-business'),
path('public/availability/', PublicAvailabilityView.as_view(), name='public-availability'),
path('public/business-hours/', PublicBusinessHoursView.as_view(), name='public-business-hours'),
path('public/weekly-hours/', PublicWeeklyHoursView.as_view(), name='public-weekly-hours'),
path('public/bookings/', PublicBookingView.as_view(), name='public-booking'),
path('public/payments/intent/', PublicPaymentIntentView.as_view(), name='public-payment-intent'),
path('public/contact/', PublicContactFormView.as_view(), name='public-contact-form'),
]

View File

@@ -384,6 +384,76 @@ class PublicAvailabilityView(APIView):
return None
class PublicWeeklyHoursView(APIView):
"""
Return weekly business hours for display on public pages.
Returns the open/close times for each day of the week.
"""
permission_classes = [AllowAny]
def get(self, request):
from smoothschedule.scheduling.schedule.models import TimeBlock
if request.tenant.schema_name == 'public':
return Response({"error": "Invalid tenant"}, status=400)
# Day names and indices
DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
# Get business hours blocks (the "before" and "after" blocks)
business_hours_blocks = TimeBlock.objects.filter(
resource__isnull=True,
purpose=TimeBlock.Purpose.BUSINESS_HOURS,
is_active=True,
recurrence_type='WEEKLY'
)
# Initialize all days as closed
weekly_hours = {i: {'is_open': False, 'open': None, 'close': None} for i in range(7)}
# Parse the blocks to determine open hours
# Business hours are stored as "before hours" (00:00 to open) and "after hours" (close to 23:59)
for block in business_hours_blocks:
if block.recurrence_pattern and 'days_of_week' in block.recurrence_pattern:
days_of_week = block.recurrence_pattern['days_of_week']
for day_index in days_of_week:
if day_index < 0 or day_index > 6:
continue
weekly_hours[day_index]['is_open'] = True
# Before hours block: 00:00 to open time
if block.start_time and str(block.start_time)[:5] == '00:00':
weekly_hours[day_index]['open'] = str(block.end_time)[:5] if block.end_time else '09:00'
# After hours block: close time to 23:59
elif block.end_time and str(block.end_time)[:5] in ['23:59', '00:00']:
weekly_hours[day_index]['close'] = str(block.start_time)[:5] if block.start_time else '17:00'
# Build response
result = []
for i, day_name in enumerate(DAY_NAMES):
day_data = weekly_hours[i]
result.append({
'day': day_name,
'is_open': day_data['is_open'],
'open': day_data['open'] if day_data['is_open'] else None,
'close': day_data['close'] if day_data['is_open'] else None,
})
# If no business hours are configured, return default 9-5 Mon-Fri
has_any_hours = any(d['is_open'] for d in result)
if not has_any_hours:
for i, day in enumerate(result):
if i < 5: # Mon-Fri
day['is_open'] = True
day['open'] = '09:00'
day['close'] = '17:00'
return Response({'hours': result})
class PublicBusinessHoursView(APIView):
"""
Return business hours for a date range.
@@ -529,4 +599,78 @@ class PublicSiteConfigView(APIView):
"header": {},
"footer": {},
"merged_theme": SiteConfig.get_default_theme()
})
})
class PublicContactFormView(APIView):
"""
Public endpoint for contact form submissions.
Creates a CUSTOMER ticket in the ticketing system.
"""
permission_classes = [AllowAny]
def post(self, request):
from smoothschedule.commerce.tickets.models import Ticket
tenant = request.tenant
# Handle 'public' schema case (central API)
if tenant.schema_name == 'public':
subdomain = request.headers.get('x-business-subdomain')
if subdomain:
try:
tenant = Tenant.objects.get(schema_name=subdomain)
except Tenant.DoesNotExist:
return Response({"error": "Tenant not found"}, status=404)
else:
return Response({"error": "Business subdomain required"}, status=400)
# Validate required fields
name = request.data.get('name', '').strip()
email = request.data.get('email', '').strip()
message = request.data.get('message', '').strip()
subject = request.data.get('subject', '').strip() or 'Contact Form Submission'
phone = request.data.get('phone', '').strip()
if not name:
return Response({"error": "Name is required"}, status=400)
if not email:
return Response({"error": "Email is required"}, status=400)
if not message:
return Response({"error": "Message is required"}, status=400)
# Basic email validation
import re
if not re.match(r'^[^@]+@[^@]+\.[^@]+$', email):
return Response({"error": "Invalid email address"}, status=400)
# Build description with contact info
description_parts = [message]
if phone:
description_parts.append(f"\n\n---\nPhone: {phone}")
# Create the ticket
try:
ticket = Ticket.objects.create(
tenant=tenant,
ticket_type=Ticket.TicketType.CUSTOMER,
category=Ticket.Category.GENERAL_INQUIRY,
priority=Ticket.Priority.MEDIUM,
status=Ticket.Status.OPEN,
subject=subject,
description='\n'.join(description_parts),
external_name=name,
external_email=email,
creator=None, # No authenticated user
)
return Response({
"success": True,
"message": "Thank you for your message. We'll get back to you soon!",
"ticket_id": ticket.id
}, status=201)
except Exception as e:
return Response({
"error": "Failed to submit contact form. Please try again."
}, status=500)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,59 @@
# Generated by Django 5.2.8 on 2025-12-14 00:42
import django.db.models.deletion
import smoothschedule.scheduling.schedule.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('schedule', '0037_add_location_model'),
]
operations = [
migrations.CreateModel(
name='Album',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('description', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='MediaFile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.ImageField(help_text='The uploaded image file', upload_to=smoothschedule.scheduling.schedule.models.MediaFile.upload_to)),
('filename', models.CharField(help_text='Original filename', max_length=255)),
('alt_text', models.CharField(blank=True, help_text='Alt text for accessibility', max_length=255)),
('file_size', models.PositiveIntegerField(help_text='File size in bytes')),
('width', models.PositiveIntegerField(blank=True, help_text='Image width in pixels', null=True)),
('height', models.PositiveIntegerField(blank=True, help_text='Image height in pixels', null=True)),
('mime_type', models.CharField(help_text='MIME type (e.g., image/jpeg)', max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
('album', models.ForeignKey(blank=True, help_text='Album this file belongs to', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='files', to='schedule.album')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.AddField(
model_name='album',
name='cover_image',
field=models.ForeignKey(blank=True, help_text='Cover image for this album', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cover_for_albums', to='schedule.mediafile'),
),
migrations.AddIndex(
model_name='mediafile',
index=models.Index(fields=['album', '-created_at'], name='schedule_me_album_i_0e8d85_idx'),
),
migrations.AddIndex(
model_name='mediafile',
index=models.Index(fields=['mime_type'], name='schedule_me_mime_ty_05ad5b_idx'),
),
]

View File

@@ -1670,6 +1670,121 @@ class EmailTemplate(models.Model):
return text + footer
class Album(models.Model):
"""
Album for organizing media files.
"""
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
cover_image = models.ForeignKey(
'MediaFile',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='cover_for_albums',
help_text="Cover image for this album"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'schedule'
ordering = ['-created_at']
def __str__(self):
return self.name
@property
def file_count(self):
"""Return the number of files in this album."""
return self.files.count()
class MediaFile(models.Model):
"""
Media file (image) uploaded by a tenant.
Storage is handled by django-storages with DigitalOcean Spaces in production.
File size is tracked for quota enforcement.
"""
def upload_to(instance, filename):
"""Generate upload path: media/{tenant_schema}/{filename}"""
from django.db import connection
schema = connection.schema_name
return f'media/{schema}/{filename}'
file = models.ImageField(
upload_to=upload_to,
help_text="The uploaded image file"
)
filename = models.CharField(
max_length=255,
help_text="Original filename"
)
alt_text = models.CharField(
max_length=255,
blank=True,
help_text="Alt text for accessibility"
)
file_size = models.PositiveIntegerField(
help_text="File size in bytes"
)
width = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Image width in pixels"
)
height = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Image height in pixels"
)
mime_type = models.CharField(
max_length=100,
help_text="MIME type (e.g., image/jpeg)"
)
album = models.ForeignKey(
Album,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='files',
help_text="Album this file belongs to"
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
app_label = 'schedule'
ordering = ['-created_at']
indexes = [
models.Index(fields=['album', '-created_at']),
models.Index(fields=['mime_type']),
]
def __str__(self):
return self.filename
@property
def url(self):
"""Return the URL to access this file."""
if self.file:
return self.file.url
return None
def delete(self, *args, **kwargs):
"""Delete the file from storage when the model is deleted."""
# Store file reference before deleting model
file_to_delete = self.file
file_size = self.file_size
# Delete the model first
super().delete(*args, **kwargs)
# Delete the actual file from storage
if file_to_delete:
file_to_delete.delete(save=False)
class Holiday(models.Model):
"""
Predefined holiday definitions for the holiday picker.

View File

@@ -4,7 +4,7 @@ DRF Serializers for Schedule App with Availability Validation
from rest_framework import serializers
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError as DjangoValidationError
from .models import Resource, Event, Participant, Service, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin, EmailTemplate, Holiday, TimeBlock, Location
from .models import Resource, Event, Participant, Service, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin, EmailTemplate, Holiday, TimeBlock, Location, Album, MediaFile
from .services import AvailabilityService
from smoothschedule.identity.users.models import User
from smoothschedule.identity.core.mixins import TimezoneSerializerMixin
@@ -1732,4 +1732,191 @@ class CheckConflictsSerializer(serializers.Serializer):
resource_id = serializers.IntegerField(required=False, allow_null=True)
all_day = serializers.BooleanField(default=True)
start_time = serializers.TimeField(required=False, allow_null=True)
end_time = serializers.TimeField(required=False, allow_null=True)
end_time = serializers.TimeField(required=False, allow_null=True)
# ============================================================================
# Media Gallery Serializers
# ============================================================================
class AlbumSerializer(serializers.ModelSerializer):
"""
Serializer for Album model.
Provides read-only computed fields for file_count and cover_url.
"""
file_count = serializers.IntegerField(read_only=True)
cover_url = serializers.SerializerMethodField()
class Meta:
model = Album
fields = [
'id', 'name', 'description', 'cover_image',
'file_count', 'cover_url', 'created_at', 'updated_at'
]
read_only_fields = ['id', 'created_at', 'updated_at']
def get_cover_url(self, obj):
"""Get the URL of the cover image."""
if obj.cover_image and obj.cover_image.file:
return obj.cover_image.file.url
# If no explicit cover, use first file in album
first_file = obj.files.first()
if first_file and first_file.file:
return first_file.file.url
return None
def to_representation(self, instance):
"""Add file_count if not already annotated."""
data = super().to_representation(instance)
# If file_count wasn't annotated in the queryset, compute it
if data.get('file_count') is None:
data['file_count'] = instance.files.count()
return data
class MediaFileSerializer(serializers.ModelSerializer):
"""
Serializer for MediaFile model.
Handles file uploads with validation for:
- File type (images only)
- File size (max 10MB)
- Storage quota checking
"""
url = serializers.SerializerMethodField()
album_name = serializers.CharField(source='album.name', read_only=True, allow_null=True)
# For uploads - write only
file = serializers.ImageField(write_only=True, required=False)
# Allowed MIME types
ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
class Meta:
model = MediaFile
fields = [
'id', 'file', 'url', 'filename', 'alt_text', 'file_size',
'width', 'height', 'mime_type', 'album', 'album_name', 'created_at'
]
read_only_fields = ['id', 'filename', 'file_size', 'width', 'height', 'mime_type', 'created_at']
def get_url(self, obj):
"""Get the URL to access the file."""
if obj.file:
return obj.file.url
return None
def validate_file(self, value):
"""Validate uploaded file."""
if not value:
return value
# Check file size
if value.size > self.MAX_FILE_SIZE:
max_mb = self.MAX_FILE_SIZE / (1024 * 1024)
file_mb = value.size / (1024 * 1024)
raise serializers.ValidationError(
f'File size ({file_mb:.1f} MB) exceeds maximum allowed ({max_mb:.0f} MB).'
)
# Check content type
content_type = value.content_type
if content_type not in self.ALLOWED_TYPES:
raise serializers.ValidationError(
f'File type "{content_type}" is not allowed. '
f'Allowed types: {", ".join(self.ALLOWED_TYPES)}'
)
return value
def validate(self, attrs):
"""Validate storage quota for new uploads."""
file = attrs.get('file')
if file:
# Check storage quota
request = self.context.get('request')
tenant = getattr(request, 'tenant', None) if request else None
if tenant:
from smoothschedule.identity.core.services import StorageQuotaService
can_upload, error = StorageQuotaService.can_upload(tenant, file.size)
if not can_upload:
raise serializers.ValidationError({'file': error})
return attrs
def create(self, validated_data):
"""Create MediaFile from uploaded file."""
from PIL import Image
from smoothschedule.identity.core.services import StorageQuotaService
file = validated_data.pop('file')
# Extract file metadata
validated_data['filename'] = file.name
validated_data['file_size'] = file.size
validated_data['mime_type'] = file.content_type
# Get image dimensions
try:
img = Image.open(file)
validated_data['width'] = img.width
validated_data['height'] = img.height
file.seek(0) # Reset file pointer after reading
except Exception:
# If we can't read dimensions, that's okay
pass
# Create the media file
validated_data['file'] = file
instance = super().create(validated_data)
# Update storage usage
request = self.context.get('request')
tenant = getattr(request, 'tenant', None) if request else None
if tenant:
StorageQuotaService.update_usage(tenant, file.size, 1)
return instance
class MediaFileUpdateSerializer(serializers.ModelSerializer):
"""
Serializer for updating MediaFile (alt_text, album).
Does not allow changing the actual file - must delete and re-upload.
"""
class Meta:
model = MediaFile
fields = ['alt_text', 'album']
class StorageUsageSerializer(serializers.Serializer):
"""Serializer for storage usage response."""
bytes_used = serializers.IntegerField()
bytes_total = serializers.IntegerField()
file_count = serializers.IntegerField()
percent_used = serializers.FloatField()
# Human-readable versions
used_display = serializers.SerializerMethodField()
total_display = serializers.SerializerMethodField()
def get_used_display(self, obj):
"""Format bytes_used as human-readable string."""
return self._format_bytes(obj.get('bytes_used', 0))
def get_total_display(self, obj):
"""Format bytes_total as human-readable string."""
return self._format_bytes(obj.get('bytes_total', 0))
def _format_bytes(self, bytes_val):
"""Convert bytes to human-readable format."""
if bytes_val >= 1024 * 1024 * 1024:
return f"{bytes_val / (1024 * 1024 * 1024):.1f} GB"
elif bytes_val >= 1024 * 1024:
return f"{bytes_val / (1024 * 1024):.1f} MB"
elif bytes_val >= 1024:
return f"{bytes_val / 1024:.1f} KB"
return f"{bytes_val} B"

View File

@@ -12,7 +12,8 @@ from .views import (
ScheduledTaskViewSet, TaskExecutionLogViewSet, PluginViewSet,
PluginTemplateViewSet, PluginInstallationViewSet, EventPluginViewSet,
GlobalEventPluginViewSet, EmailTemplateViewSet,
HolidayViewSet, TimeBlockViewSet, LocationViewSet
HolidayViewSet, TimeBlockViewSet, LocationViewSet,
AlbumViewSet, MediaFileViewSet, StorageUsageView,
)
from .export_views import ExportViewSet
@@ -38,8 +39,11 @@ router.register(r'export', ExportViewSet, basename='export')
router.register(r'holidays', HolidayViewSet, basename='holiday')
router.register(r'time-blocks', TimeBlockViewSet, basename='timeblock')
router.register(r'locations', LocationViewSet, basename='location')
router.register(r'albums', AlbumViewSet, basename='album')
router.register(r'media', MediaFileViewSet, basename='media')
# URL patterns
urlpatterns = [
path('', include(router.urls)),
path('storage-usage/', StorageUsageView.as_view(), name='storage-usage'),
]

View File

@@ -2747,4 +2747,192 @@ class LocationViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
if instance.is_primary:
LocationService.promote_next_primary(tenant, instance.pk)
instance.delete()
instance.delete()
# ============================================================================
# Media Gallery ViewSets
# ============================================================================
from .models import Album, MediaFile
from .serializers import (
AlbumSerializer,
MediaFileSerializer,
MediaFileUpdateSerializer,
StorageUsageSerializer,
)
from rest_framework.views import APIView
from django.db.models import Count
class AlbumViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
"""
API endpoint for managing Albums.
Endpoints:
- GET /api/albums/ - List all albums
- POST /api/albums/ - Create album
- GET /api/albums/{id}/ - Get album details
- PATCH /api/albums/{id}/ - Update album
- DELETE /api/albums/{id}/ - Delete album (files moved to uncategorized)
"""
queryset = Album.objects.all()
serializer_class = AlbumSerializer
permission_classes = [IsAuthenticated]
ordering = ['-created_at']
def get_queryset(self):
"""Annotate with file count for list view."""
queryset = super().get_queryset()
return queryset.annotate(file_count=Count('files'))
def perform_destroy(self, instance):
"""When deleting an album, move files to uncategorized (null album)."""
# Move all files in this album to no album
instance.files.update(album=None)
instance.delete()
class MediaFileViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
"""
API endpoint for managing Media Files.
Endpoints:
- GET /api/media/ - List all files (filterable by album)
- POST /api/media/ - Upload file
- GET /api/media/{id}/ - Get file details
- PATCH /api/media/{id}/ - Update alt text, album
- DELETE /api/media/{id}/ - Delete file
Query parameters:
- album: Filter by album ID (use 'null' for uncategorized)
"""
queryset = MediaFile.objects.all()
serializer_class = MediaFileSerializer
permission_classes = [IsAuthenticated]
ordering = ['-created_at']
def get_serializer_class(self):
"""Use different serializer for updates."""
if self.action in ['update', 'partial_update']:
return MediaFileUpdateSerializer
return MediaFileSerializer
def get_queryset(self):
"""Filter by album if requested."""
queryset = super().get_queryset()
album = self.request.query_params.get('album')
if album is not None:
if album.lower() == 'null' or album == '':
# Get files with no album
queryset = queryset.filter(album__isnull=True)
else:
try:
queryset = queryset.filter(album_id=int(album))
except ValueError:
pass
return queryset.select_related('album')
def perform_destroy(self, instance):
"""Delete file and update storage usage."""
from smoothschedule.identity.core.services import StorageQuotaService
file_size = instance.file_size
tenant = self.request.tenant
# Delete the instance (which also deletes the file from storage)
instance.delete()
# Update storage usage
if tenant:
StorageQuotaService.update_usage(tenant, -file_size, -1)
@action(detail=False, methods=['post'])
def bulk_move(self, request):
"""
Move multiple files to an album.
POST /api/media/bulk_move/
Body: {"file_ids": [1, 2, 3], "album_id": 5} # null to uncategorize
"""
file_ids = request.data.get('file_ids', [])
album_id = request.data.get('album_id')
if not file_ids:
return Response(
{'error': 'file_ids is required'},
status=status.HTTP_400_BAD_REQUEST
)
# Validate album exists if specified
album = None
if album_id is not None:
try:
album = Album.objects.get(id=album_id)
except Album.DoesNotExist:
return Response(
{'error': 'Album not found'},
status=status.HTTP_404_NOT_FOUND
)
# Update files
updated = MediaFile.objects.filter(id__in=file_ids).update(album=album)
return Response({'updated': updated})
@action(detail=False, methods=['post'])
def bulk_delete(self, request):
"""
Delete multiple files.
POST /api/media/bulk_delete/
Body: {"file_ids": [1, 2, 3]}
"""
from smoothschedule.identity.core.services import StorageQuotaService
file_ids = request.data.get('file_ids', [])
if not file_ids:
return Response(
{'error': 'file_ids is required'},
status=status.HTTP_400_BAD_REQUEST
)
# Get files and calculate total size
files = MediaFile.objects.filter(id__in=file_ids)
total_size = sum(f.file_size for f in files)
count = files.count()
# Delete files
for f in files:
f.delete() # This also deletes from storage
# Update storage usage
tenant = request.tenant
if tenant and total_size > 0:
StorageQuotaService.update_usage(tenant, -total_size, -count)
return Response({'deleted': count})
class StorageUsageView(APIView):
"""
API endpoint for getting storage usage.
GET /api/storage-usage/
"""
permission_classes = [IsAuthenticated]
def get(self, request):
from smoothschedule.identity.core.services import StorageQuotaService
tenant = request.tenant
if not tenant:
return Response(
{'error': 'Tenant not found'},
status=status.HTTP_400_BAD_REQUEST
)
usage = StorageQuotaService.get_usage(tenant)
serializer = StorageUsageSerializer(usage)
return Response(serializer.data)