From fbefccf436740e835ff380ba168b4f95e3b8bab0 Mon Sep 17 00:00:00 2001 From: poduck Date: Sat, 13 Dec 2025 19:59:31 -0500 Subject: [PATCH] Add media gallery with album organization and Puck integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/App.tsx | 49 +- frontend/src/api/media.ts | 260 ++++ frontend/src/components/DevQuickLogin.tsx | 24 +- .../src/components/FloatingHelpButton.tsx | 2 +- frontend/src/components/Sidebar.tsx | 7 + frontend/src/contexts/SandboxContext.tsx | 8 +- frontend/src/hooks/useAuth.ts | 26 +- frontend/src/i18n/locales/en.json | 4 + frontend/src/pages/LoginPage.tsx | 13 +- frontend/src/pages/MediaGalleryPage.tsx | 973 ++++++++++++++ frontend/src/pages/PageEditor.tsx | 152 ++- .../src/pages/platform/PlatformLoginPage.tsx | 178 +++ .../src/puck/__tests__/blockSchemas.test.ts | 471 +++++++ .../puck/__tests__/templateGenerator.test.ts | 255 ++++ .../src/puck/__tests__/themeTokens.test.ts | 230 ++++ .../__tests__/videoEmbedValidation.test.ts | 231 ++++ .../puck/components/contact/AddressBlock.tsx | 215 ++++ .../puck/components/contact/BusinessHours.tsx | 93 +- .../puck/components/contact/ContactForm.tsx | 524 +++++--- frontend/src/puck/components/contact/index.ts | 1 + frontend/src/puck/components/content/FAQ.tsx | 88 -- .../src/puck/components/content/Image.tsx | 5 +- .../puck/components/content/Testimonial.tsx | 5 +- .../puck/components/marketing/CTASection.tsx | 157 +++ .../components/marketing/ContentBlocks.tsx | 157 +++ .../components/marketing/FAQAccordion.tsx | 197 +++ .../src/puck/components/marketing/Footer.tsx | 248 ++++ .../components/marketing/FullBookingFlow.tsx | 878 +++++++++++++ .../puck/components/marketing/GalleryGrid.tsx | 183 +++ .../src/puck/components/marketing/Header.tsx | 215 ++++ .../src/puck/components/marketing/Hero.tsx | 266 ++++ .../puck/components/marketing/LogoCloud.tsx | 158 +++ .../components/marketing/PricingCards.tsx | 331 +++++ .../components/marketing/SplitContent.tsx | 281 ++++ .../puck/components/marketing/StatsStrip.tsx | 110 ++ .../components/marketing/Testimonials.tsx | 259 ++++ .../puck/components/marketing/VideoEmbed.tsx | 181 +++ .../src/puck/components/marketing/index.ts | 56 + frontend/src/puck/config.ts | 257 ++-- frontend/src/puck/fields/ImagePickerField.tsx | 372 ++++++ frontend/src/puck/fields/index.ts | 4 + frontend/src/puck/templates.ts | 704 ++++++++++ frontend/src/puck/theme.ts | 561 ++++++++ frontend/src/puck/types.ts | 241 +++- frontend/src/puck/utils/videoEmbed.ts | 247 ++++ .../migrations/0028_tenantstorageusage.py | 28 + .../smoothschedule/identity/core/models.py | 61 + .../smoothschedule/identity/core/services.py | 165 +++ .../smoothschedule/identity/core/signals.py | 41 + .../platform/tenant_sites/models.py | 40 +- .../platform/tenant_sites/urls.py | 6 +- .../platform/tenant_sites/views.py | 146 ++- .../schedule/PLUGIN_SCRIPTING_API.md | 1142 +++++++++++++++++ ...um_mediafile_album_cover_image_and_more.py | 59 + .../scheduling/schedule/models.py | 115 ++ .../scheduling/schedule/serializers.py | 191 ++- .../scheduling/schedule/urls.py | 6 +- .../scheduling/schedule/views.py | 190 ++- 58 files changed, 11590 insertions(+), 477 deletions(-) create mode 100644 frontend/src/api/media.ts create mode 100644 frontend/src/pages/MediaGalleryPage.tsx create mode 100644 frontend/src/pages/platform/PlatformLoginPage.tsx create mode 100644 frontend/src/puck/__tests__/blockSchemas.test.ts create mode 100644 frontend/src/puck/__tests__/templateGenerator.test.ts create mode 100644 frontend/src/puck/__tests__/themeTokens.test.ts create mode 100644 frontend/src/puck/__tests__/videoEmbedValidation.test.ts create mode 100644 frontend/src/puck/components/contact/AddressBlock.tsx delete mode 100644 frontend/src/puck/components/content/FAQ.tsx create mode 100644 frontend/src/puck/components/marketing/CTASection.tsx create mode 100644 frontend/src/puck/components/marketing/ContentBlocks.tsx create mode 100644 frontend/src/puck/components/marketing/FAQAccordion.tsx create mode 100644 frontend/src/puck/components/marketing/Footer.tsx create mode 100644 frontend/src/puck/components/marketing/FullBookingFlow.tsx create mode 100644 frontend/src/puck/components/marketing/GalleryGrid.tsx create mode 100644 frontend/src/puck/components/marketing/Header.tsx create mode 100644 frontend/src/puck/components/marketing/Hero.tsx create mode 100644 frontend/src/puck/components/marketing/LogoCloud.tsx create mode 100644 frontend/src/puck/components/marketing/PricingCards.tsx create mode 100644 frontend/src/puck/components/marketing/SplitContent.tsx create mode 100644 frontend/src/puck/components/marketing/StatsStrip.tsx create mode 100644 frontend/src/puck/components/marketing/Testimonials.tsx create mode 100644 frontend/src/puck/components/marketing/VideoEmbed.tsx create mode 100644 frontend/src/puck/components/marketing/index.ts create mode 100644 frontend/src/puck/fields/ImagePickerField.tsx create mode 100644 frontend/src/puck/fields/index.ts create mode 100644 frontend/src/puck/templates.ts create mode 100644 frontend/src/puck/theme.ts create mode 100644 frontend/src/puck/utils/videoEmbed.ts create mode 100644 smoothschedule/smoothschedule/identity/core/migrations/0028_tenantstorageusage.py create mode 100644 smoothschedule/smoothschedule/identity/core/services.py create mode 100644 smoothschedule/smoothschedule/scheduling/schedule/PLUGIN_SCRIPTING_API.md create mode 100644 smoothschedule/smoothschedule/scheduling/schedule/migrations/0038_album_mediafile_album_cover_image_and_more.py diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bf2b62a..b87bab8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( + }> + + } /> + } /> + } /> + + + ); + } + + // For root domain, show marketing site with business user login return ( }> @@ -660,6 +683,13 @@ const AppContent: React.FC = () => { return ( }> + {/* Public routes outside BusinessLayout */} + } /> + } /> + } /> + } /> + + {/* Dashboard routes inside BusinessLayout */} { /> } > - {/* Redirect root to dashboard */} - } /> - {/* Trial and Upgrade Routes */} } /> } /> @@ -902,6 +929,16 @@ const AppContent: React.FC = () => { ) } /> + + ) : ( + + ) + } + /> {/* Settings Routes with Nested Layout */} {hasAccess(['owner']) ? ( }> @@ -925,8 +962,10 @@ const AppContent: React.FC = () => { )} } /> } /> - } /> + + {/* Catch-all redirects to home */} + } /> ); diff --git a/frontend/src/api/media.ts b/frontend/src/api/media.ts new file mode 100644 index 0000000..2c87001 --- /dev/null +++ b/frontend/src/api/media.ts @@ -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 { + const response = await apiClient.get('/albums/'); + return response.data; +} + +/** + * Get a single album + */ +export async function getAlbum(id: number): Promise { + const response = await apiClient.get(`/albums/${id}/`); + return response.data; +} + +/** + * Create a new album + */ +export async function createAlbum(data: AlbumPayload): Promise { + const response = await apiClient.post('/albums/', data); + return response.data; +} + +/** + * Update an album + */ +export async function updateAlbum(id: number, data: Partial): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const response = await apiClient.patch(`/media/${id}/`, data); + return response.data; +} + +/** + * Delete a media file + */ +export async function deleteMediaFile(id: number): Promise { + 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 { + 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; +} diff --git a/frontend/src/components/DevQuickLogin.tsx b/frontend/src/components/DevQuickLogin.tsx index 5267720..4b61c3e 100644 --- a/frontend/src/components/DevQuickLogin.tsx +++ b/frontend/src/components/DevQuickLogin.tsx @@ -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(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) {
- {testUsers.map((user) => ( + {filteredUsers.map((user) => (
{/* Dev Quick Login */} - + diff --git a/frontend/src/pages/MediaGalleryPage.tsx b/frontend/src/pages/MediaGalleryPage.tsx new file mode 100644 index 0000000..264380c --- /dev/null +++ b/frontend/src/pages/MediaGalleryPage.tsx @@ -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 = ({ usage, isLoading }) => { + if (isLoading || !usage) { + return ( +
+
+
+
+ ); + } + + const percentUsed = usage.percent_used; + const barColor = + percentUsed >= 95 + ? 'bg-red-500' + : percentUsed >= 80 + ? 'bg-yellow-500' + : 'bg-primary-500'; + + return ( +
+
+ + Storage: {usage.used_display} / {usage.total_display} + + + {usage.file_count} files + +
+
+
+
+ {percentUsed >= 80 && ( +

+ {percentUsed >= 95 + ? 'Storage almost full! Delete files or upgrade your plan.' + : 'Storage usage is getting high.'} +

+ )} +
+ ); +}; + +// ============================================================================ +// Album Card Component +// ============================================================================ + +interface AlbumCardProps { + album: Album; + isSelected: boolean; + onSelect: () => void; + onEdit: () => void; + onDelete: () => void; +} + +const AlbumCard: React.FC = ({ + album, + isSelected, + onSelect, + onEdit, + onDelete, +}) => { + const [showMenu, setShowMenu] = useState(false); + + return ( +
+
+ {album.cover_url ? ( + {album.name} + ) : ( +
+ +
+ )} +
+
+

{album.name}

+

+ {album.file_count} {album.file_count === 1 ? 'file' : 'files'} +

+
+ + {/* Menu button */} +
+ + {showMenu && ( +
+ + +
+ )} +
+
+ ); +}; + +// ============================================================================ +// Media Thumbnail Component +// ============================================================================ + +interface MediaThumbnailProps { + file: MediaFile; + isSelected: boolean; + onSelect: () => void; + onPreview: () => void; +} + +const MediaThumbnail: React.FC = ({ + file, + isSelected, + onSelect, + onPreview, +}) => { + return ( +
+
+ {file.alt_text +
+ + {/* Checkbox for selection */} + + + {/* File info overlay */} +
+

{file.filename}

+

{formatFileSize(file.file_size)}

+
+
+ ); +}; + +// ============================================================================ +// 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 = ({ + file, + isOpen, + onClose, + onUpdate, + onDelete, + albums, +}) => { + const [altText, setAltText] = useState(file?.alt_text || ''); + const [selectedAlbum, setSelectedAlbum] = useState(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 ( + +
+ {/* Image preview */} +
+ {file.alt_text +
+ + {/* Details form */} +
+
+

+ Filename +

+

{file.filename}

+
+ +
+
+

Size

+

{formatFileSize(file.file_size)}

+
+
+

Dimensions

+

+ {file.width && file.height ? `${file.width} x ${file.height}` : 'Unknown'} +

+
+
+ + setAltText(e.target.value)} + placeholder="Describe this image for accessibility" + /> + +
+ + +
+ +
+ + + + +
+
+
+ +
+ +
+ + +
+
+
+ ); +}; + +// ============================================================================ +// Album Modal +// ============================================================================ + +interface AlbumModalProps { + album: Album | null; + isOpen: boolean; + onClose: () => void; + onSave: (data: { name: string; description: string }) => void; +} + +const AlbumModal: React.FC = ({ 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 ( + +
+ setName(e.target.value)} + placeholder="Enter album name" + required + /> + setDescription(e.target.value)} + placeholder="Optional description" + rows={3} + /> +
+ + +
+ +
+ ); +}; + +// ============================================================================ +// Main Gallery Page Component +// ============================================================================ + +type ViewMode = 'albums' | 'files'; + +const MediaGalleryPage: React.FC = () => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const fileInputRef = useRef(null); + + // State + const [viewMode, setViewMode] = useState('albums'); + const [currentAlbumId, setCurrentAlbumId] = useState(null); + const [selectedFiles, setSelectedFiles] = useState>(new Set()); + const [previewFile, setPreviewFile] = useState(null); + const [albumModalOpen, setAlbumModalOpen] = useState(false); + const [editingAlbum, setEditingAlbum] = useState(null); + const [error, setError] = useState(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 ( +
+ {/* Header */} +
+
+ {viewMode === 'files' && ( + + )} +
+

+ {viewMode === 'albums' + ? t('gallery.title', 'Media Gallery') + : currentAlbum?.name || t('gallery.uncategorized', 'Uncategorized')} +

+ {viewMode === 'files' && currentAlbum?.description && ( +

+ {currentAlbum.description} +

+ )} +
+
+ +
+ {viewMode === 'albums' && ( + + )} + + handleFileUpload(e.target.files)} + /> +
+
+ + {/* Storage usage */} + + + {/* Error message */} + {error && ( + setError(null)}> +
{error}
+
+ )} + + {/* Bulk actions bar */} + {selectedFiles.size > 0 && ( +
+ + {selectedFiles.size} selected + +
+ + + +
+ )} + + {/* Content */} +
+ {viewMode === 'albums' ? ( + // Albums view +
+ {/* Quick access buttons */} +
+ + +
+ + {/* Albums grid */} + {albumsLoading ? ( +
+ {[...Array(5)].map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : albums.length === 0 ? ( +
+ +

+ {t('gallery.noAlbums', 'No albums yet')} +

+

+ {t('gallery.noAlbumsDesc', 'Create an album to organize your images')} +

+ +
+ ) : ( +
+ {albums.map((album) => ( + handleAlbumClick(album.id)} + onEdit={() => { + setEditingAlbum(album); + setAlbumModalOpen(true); + }} + onDelete={() => handleDeleteAlbum(album)} + /> + ))} +
+ )} +
+ ) : ( + // Files view +
+ {filesLoading ? ( +
+ {[...Array(12)].map((_, i) => ( +
+
+
+ ))} +
+ ) : files.length === 0 ? ( +
+ +

+ {t('gallery.noFiles', 'No files here')} +

+

+ {t('gallery.dropFiles', 'Drop files here or click Upload')} +

+ +
+ ) : ( +
+ {files.map((file) => ( + toggleFileSelection(file.id)} + onPreview={() => setPreviewFile(file)} + /> + ))} +
+ )} +
+ )} +
+ + {/* Album modal */} + { + setAlbumModalOpen(false); + setEditingAlbum(null); + }} + onSave={handleAlbumSave} + /> + + {/* Image preview modal */} + setPreviewFile(null)} + onUpdate={(id, data) => { + updateFileMutation.mutate({ id, data }); + setPreviewFile(null); + }} + onDelete={(id) => { + deleteFileMutation.mutate(id); + setPreviewFile(null); + }} + albums={albums} + /> +
+ ); +}; + +export default MediaGalleryPage; diff --git a/frontend/src/pages/PageEditor.tsx b/frontend/src/pages/PageEditor.tsx index 933e3a5..e39abb0 100644 --- a/frontend/src/pages/PageEditor.tsx +++ b/frontend/src/pages/PageEditor.tsx @@ -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(null); const [currentPageId, setCurrentPageId] = useState(null); + const [loadedPageId, setLoadedPageId] = useState(null); // Track which page's data is loaded const [showNewPageModal, setShowNewPageModal] = useState(false); const [newPageTitle, setNewPageTitle] = useState(''); const [viewport, setViewport] = useState('desktop'); @@ -33,6 +34,7 @@ export const PageEditor: React.FC = () => { const [showPreview, setShowPreview] = useState(false); const [previewViewport, setPreviewViewport] = useState('desktop'); const [previewData, setPreviewData] = useState(null); + const [previewFullWidth, setPreviewFullWidth] = useState(false); const [hasDraft, setHasDraft] = useState(false); const [publishedData, setPublishedData] = useState(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
No page found. Please contact support.
; } - 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 = () => { setEmail(e.target.value)} + /> +
+
+ + {/* Password */} +
+ +
+
+
+ setPassword(e.target.value)} + /> +
+
+ + + +
+ + {/* Dev Quick Login - Platform Staff Only */} + + + {/* Footer */} +
+ © {new Date().getFullYear()} SmoothSchedule. All rights reserved. +
+
+
+ ); +}; + +export default PlatformLoginPage; diff --git a/frontend/src/puck/__tests__/blockSchemas.test.ts b/frontend/src/puck/__tests__/blockSchemas.test.ts new file mode 100644 index 0000000..c5554dc --- /dev/null +++ b/frontend/src/puck/__tests__/blockSchemas.test.ts @@ -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(); + }); + }); + }); +}); diff --git a/frontend/src/puck/__tests__/templateGenerator.test.ts b/frontend/src/puck/__tests__/templateGenerator.test.ts new file mode 100644 index 0000000..d2ee661 --- /dev/null +++ b/frontend/src/puck/__tests__/templateGenerator.test.ts @@ -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 }) => { + 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); + }); + }); +}); diff --git a/frontend/src/puck/__tests__/themeTokens.test.ts b/frontend/src/puck/__tests__/themeTokens.test.ts new file mode 100644 index 0000000..6bc000e --- /dev/null +++ b/frontend/src/puck/__tests__/themeTokens.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/puck/__tests__/videoEmbedValidation.test.ts b/frontend/src/puck/__tests__/videoEmbedValidation.test.ts new file mode 100644 index 0000000..b93fd60 --- /dev/null +++ b/frontend/src/puck/__tests__/videoEmbedValidation.test.ts @@ -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,'); + 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='); + 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">