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:
@@ -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
260
frontend/src/api/media.ts
Normal 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;
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
973
frontend/src/pages/MediaGalleryPage.tsx
Normal file
973
frontend/src/pages/MediaGalleryPage.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
178
frontend/src/pages/platform/PlatformLoginPage.tsx
Normal file
178
frontend/src/pages/platform/PlatformLoginPage.tsx
Normal 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;
|
||||
471
frontend/src/puck/__tests__/blockSchemas.test.ts
Normal file
471
frontend/src/puck/__tests__/blockSchemas.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
255
frontend/src/puck/__tests__/templateGenerator.test.ts
Normal file
255
frontend/src/puck/__tests__/templateGenerator.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
230
frontend/src/puck/__tests__/themeTokens.test.ts
Normal file
230
frontend/src/puck/__tests__/themeTokens.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
231
frontend/src/puck/__tests__/videoEmbedValidation.test.ts
Normal file
231
frontend/src/puck/__tests__/videoEmbedValidation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
215
frontend/src/puck/components/contact/AddressBlock.tsx
Normal file
215
frontend/src/puck/components/contact/AddressBlock.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { ContactForm } from './ContactForm';
|
||||
export { BusinessHours } from './BusinessHours';
|
||||
export { AddressBlock } from './AddressBlock';
|
||||
export { Map } from './Map';
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
157
frontend/src/puck/components/marketing/CTASection.tsx
Normal file
157
frontend/src/puck/components/marketing/CTASection.tsx
Normal 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;
|
||||
157
frontend/src/puck/components/marketing/ContentBlocks.tsx
Normal file
157
frontend/src/puck/components/marketing/ContentBlocks.tsx
Normal 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;
|
||||
197
frontend/src/puck/components/marketing/FAQAccordion.tsx
Normal file
197
frontend/src/puck/components/marketing/FAQAccordion.tsx
Normal 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;
|
||||
248
frontend/src/puck/components/marketing/Footer.tsx
Normal file
248
frontend/src/puck/components/marketing/Footer.tsx
Normal 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;
|
||||
878
frontend/src/puck/components/marketing/FullBookingFlow.tsx
Normal file
878
frontend/src/puck/components/marketing/FullBookingFlow.tsx
Normal 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;
|
||||
183
frontend/src/puck/components/marketing/GalleryGrid.tsx
Normal file
183
frontend/src/puck/components/marketing/GalleryGrid.tsx
Normal 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;
|
||||
215
frontend/src/puck/components/marketing/Header.tsx
Normal file
215
frontend/src/puck/components/marketing/Header.tsx
Normal 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;
|
||||
266
frontend/src/puck/components/marketing/Hero.tsx
Normal file
266
frontend/src/puck/components/marketing/Hero.tsx
Normal 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;
|
||||
158
frontend/src/puck/components/marketing/LogoCloud.tsx
Normal file
158
frontend/src/puck/components/marketing/LogoCloud.tsx
Normal 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;
|
||||
331
frontend/src/puck/components/marketing/PricingCards.tsx
Normal file
331
frontend/src/puck/components/marketing/PricingCards.tsx
Normal 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;
|
||||
281
frontend/src/puck/components/marketing/SplitContent.tsx
Normal file
281
frontend/src/puck/components/marketing/SplitContent.tsx
Normal 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;
|
||||
110
frontend/src/puck/components/marketing/StatsStrip.tsx
Normal file
110
frontend/src/puck/components/marketing/StatsStrip.tsx
Normal 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;
|
||||
259
frontend/src/puck/components/marketing/Testimonials.tsx
Normal file
259
frontend/src/puck/components/marketing/Testimonials.tsx
Normal 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;
|
||||
181
frontend/src/puck/components/marketing/VideoEmbed.tsx
Normal file
181
frontend/src/puck/components/marketing/VideoEmbed.tsx
Normal 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;
|
||||
56
frontend/src/puck/components/marketing/index.ts
Normal file
56
frontend/src/puck/components/marketing/index.ts
Normal 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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
372
frontend/src/puck/fields/ImagePickerField.tsx
Normal file
372
frontend/src/puck/fields/ImagePickerField.tsx
Normal 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;
|
||||
4
frontend/src/puck/fields/index.ts
Normal file
4
frontend/src/puck/fields/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Puck Custom Field Components
|
||||
*/
|
||||
export { ImagePickerField, imagePickerField } from './ImagePickerField';
|
||||
704
frontend/src/puck/templates.ts
Normal file
704
frontend/src/puck/templates.ts
Normal 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
561
frontend/src/puck/theme.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
247
frontend/src/puck/utils/videoEmbed.ts
Normal file
247
frontend/src/puck/utils/videoEmbed.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'])
|
||||
|
||||
|
||||
165
smoothschedule/smoothschedule/identity/core/services.py
Normal file
165
smoothschedule/smoothschedule/identity/core/services.py
Normal 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)
|
||||
@@ -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))
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user