diff --git a/01_SCENT-PROFILE_Resized.mp4 b/01_SCENT-PROFILE_Resized.mp4 new file mode 100644 index 0000000..fb5a155 Binary files /dev/null and b/01_SCENT-PROFILE_Resized.mp4 differ diff --git a/apps/admin/src/components/MediaLibraryModal.tsx b/apps/admin/src/components/MediaLibraryModal.tsx index c21feab..cac56f9 100644 --- a/apps/admin/src/components/MediaLibraryModal.tsx +++ b/apps/admin/src/components/MediaLibraryModal.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { API_BASE } from '@homaroma/types'; -import { X, Upload, Loader2, Image as ImageIcon } from 'lucide-react'; +import { X, Upload, Loader2, Image as ImageIcon, Video } from 'lucide-react'; interface MediaLibraryModalProps { isOpen: boolean; @@ -12,6 +12,7 @@ interface MediaItem { id?: string; url: string; filename?: string; + mimeType?: string; } export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLibraryModalProps) { @@ -57,19 +58,28 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLi const data = await res.json(); await fetchMedia(); // Refresh list if (data.url) { - // Auto-select uploaded image (optional, or let user pick from list) onSelect(data.url); onClose(); } + } else { + const err = await res.json(); + alert(`Upload failed: ${err.error || 'Unknown error'}`); } } catch (error) { console.error('Failed to upload', error); + alert('Failed to upload media file.'); } finally { setUploading(false); e.target.value = ''; // Reset input } }; + const isVideoFile = (item: MediaItem) => { + if (item.mimeType?.startsWith('video/')) return true; + const fn = (item.filename || item.url).toLowerCase(); + return fn.endsWith('.mp4') || fn.endsWith('.webm') || fn.endsWith('.mov') || fn.endsWith('.ogg'); + }; + if (!isOpen) return null; return ( @@ -82,7 +92,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLi Media Library -

Select or upload images for your content.

+

Select or upload images and videos (.mp4, .webm) for your content.

+ + + ) + }, + // 27. ACCORDION + { + type: 'ACCORDION', + category: 'forms', + title: 'ACCORDION (FAQ Block)', + description: 'Question-answer accordions for FAQs, divided by custom categories.', + renderPreview: () => ( +
+
+ How long do Reed Diffusers last? + +
+

Evaporates over 4 to 6 weeks.

+
+ ) + }, + // 28. CONTACT_INFO + { + type: 'CONTACT_INFO', + category: 'forms', + title: 'CONTACT INFO', + description: 'Information box showing address, telephone, email, and social details.', + renderPreview: () => ( +
+
Contact Info
+
Sukhumvit Rd, Bangkok
+
hello@homaroma.com
+
+ ) + }, + // 29. CONTACT_FORM + { + type: 'CONTACT_FORM', + category: 'forms', + title: 'CONTACT FORM', + description: 'General support email inquiry contact form.', + renderPreview: () => ( +
+
Get In Touch
+
+ + +
+
+ ) + }, + // 30. CUSTOM + { + type: 'CUSTOM', + category: 'custom', + title: 'CUSTOM BLOCK', + description: 'Registered custom layout components (e.g., Bakery Categories).', + renderPreview: () => ( +
+ CUSTOM COMPONENT +

Dynamic registered widget

+
+ ) + } + ]; + + const filteredBlocks = blocks.filter((block) => { + const matchesCategory = activeCategory === 'all' || block.category === activeCategory; + const matchesSearch = searchQuery === '' || + block.type.toLowerCase().includes(searchQuery.toLowerCase()) || + block.title.toLowerCase().includes(searchQuery.toLowerCase()) || + block.description.toLowerCase().includes(searchQuery.toLowerCase()); + return matchesCategory && matchesSearch; + }); + + return ( +
+
+ {/* Modal Header */} +
+
+

+ + Add Block Section +

+

+ Select a visual block component to insert into your page layout. +

+
+ +
+ {/* Search input */} +
+ + setSearchQuery(e.target.value)} + className="w-full bg-slate-900/80 border border-white/10 text-white rounded-xl pl-9 pr-4 py-2 text-xs focus:outline-none focus:border-cyan-glow/50 transition-all" + /> + {searchQuery && ( + + )} +
+ + +
+
+ + {/* Category Filter Tabs */} +
+ {CATEGORIES.map((cat) => ( + + ))} +
+ + {/* Ultra-Wide Block Cards Grid */} +
+ {filteredBlocks.length === 0 ? ( +
+ +

No blocks found matching "{searchQuery}".

+
+ ) : ( + filteredBlocks.map((block) => ( +
{ + onSelectBlock(block.type); + onClose(); + }} + className="group relative bg-slate-900/50 hover:bg-slate-900 border border-white/5 hover:border-cyan-glow/40 rounded-2xl p-4 transition-all duration-200 cursor-pointer flex flex-col justify-between hover:shadow-xl hover:shadow-cyan-glow/5" + > +
+ {/* Card Top Info */} +
+
+

+ {block.title} +

+ + {block.type} + +
+ {block.badge && ( + + {block.badge} + + )} +
+ +

+ {block.description} +

+ + {/* Visual Preview Container */} +
+ {block.renderPreview()} +
+
+ + {/* Hover Selection Footer */} +
+ + Category: {block.category} + + +
+
+ )) + )} +
+
+
+ ); +} diff --git a/apps/admin/src/components/page-builder/FeaturedTilesForm.tsx b/apps/admin/src/components/page-builder/FeaturedTilesForm.tsx new file mode 100644 index 0000000..8affa23 --- /dev/null +++ b/apps/admin/src/components/page-builder/FeaturedTilesForm.tsx @@ -0,0 +1,234 @@ +import React from 'react'; +import { UploadCloud, Image as ImageIcon, Plus, Trash2 } from 'lucide-react'; +import { type PageSection } from '@homaroma/types'; + +interface FeaturedTilesFormProps { + editingSection: Partial; + setEditingSection: React.Dispatch | null>>; + editingLang: string; + getLocalizedValue: (payload: any, field: string) => string; + updateLocalizedValue: (field: string, value: string) => void; + updatePayloadValue: (field: string, value: any) => void; + setUploadTarget: (target: { type: 'main' | 'slide' | 'gallery' | 'tile'; index?: number } | null) => void; + setIsMediaModalOpen: (open: boolean) => void; +} + +export default function FeaturedTilesForm({ + editingSection, + setEditingSection: _setEditingSection, + editingLang, + getLocalizedValue, + updateLocalizedValue, + updatePayloadValue, + setUploadTarget, + setIsMediaModalOpen, +}: FeaturedTilesFormProps) { + const payload = editingSection.payload || {}; + const tiles = Array.isArray(payload.tiles) ? payload.tiles : []; + + const handleAddTile = () => { + const newTiles = [ + ...tiles, + { + title: { th: 'New Tile Title', en: 'New Tile Title' }, + description: { th: '', en: '' }, + imageUrl: 'https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?auto=format&fit=crop&w=600&q=80', + imageAspectRatio: 'square', + buttonText: { th: 'Shop Now', en: 'Shop Now' }, + buttonLink: '/shop' + } + ]; + updatePayloadValue('tiles', newTiles); + }; + + const handleRemoveTile = (index: number) => { + const newTiles = tiles.filter((_: any, i: number) => i !== index); + updatePayloadValue('tiles', newTiles); + }; + + const updateTileField = (index: number, field: string, value: any) => { + const newTiles = [...tiles]; + newTiles[index] = { ...(newTiles[index] || {}), [field]: value }; + updatePayloadValue('tiles', newTiles); + }; + + const updateTileLocalizedField = (index: number, field: string, value: string) => { + const newTiles = [...tiles]; + const tile = newTiles[index] || {}; + const currentVal = tile[field]; + let newVal; + if (typeof currentVal === 'string') { + newVal = { th: currentVal, [editingLang]: value }; + } else { + newVal = { ...(currentVal || {}), [editingLang]: value }; + } + newTiles[index] = { ...tile, [field]: newVal }; + updatePayloadValue('tiles', newTiles); + }; + + const getTileLocalizedField = (tile: any, field: string) => { + if (!tile || tile[field] === undefined) return ''; + const val = tile[field]; + if (typeof val === 'string') return editingLang === 'th' ? val : ''; + return val[editingLang] || ''; + }; + + return ( +
+ {/* Section Header Controls */} +
+
+ + updateLocalizedValue('title', e.target.value)} + className="admin-input w-full" + placeholder="e.g. Summer Campaign" + /> +
+
+ + +
+
+ + updatePayloadValue('backgroundColor', e.target.value)} + className="admin-input w-full h-10 p-1 cursor-pointer" + /> +
+
+ + {/* Tiles List */} +
+
+

Campaign Cards ({tiles.length})

+ +
+ + {tiles.length === 0 ? ( +
+ No campaign cards added yet. Click "Add Card" above to insert cards. +
+ ) : ( + tiles.map((tile: any, idx: number) => ( +
+
+ Card #{idx + 1} + +
+ +
+ {/* Image selector */} +
+ +
{ + setUploadTarget({ type: 'tile', index: idx }); + setIsMediaModalOpen(true); + }} + > + {tile.imageUrl ? ( + <> + Tile +
+ +
+ + ) : ( +
+ + Select Image +
+ )} +
+ updateTileField(idx, 'imageUrl', e.target.value)} + className="admin-input w-full text-xs" + placeholder="Or paste Image URL..." + /> +
+ +
+
+ + updateTileLocalizedField(idx, 'title', e.target.value)} + className="admin-input w-full text-xs" + placeholder="e.g. Here Comes The Sun" + /> +
+
+ + +
+
+ +
+ + updateTileLocalizedField(idx, 'buttonText', e.target.value)} + className="admin-input w-full text-xs" + placeholder="e.g. Shop Beach Blossom" + /> +
+ +
+ + updateTileField(idx, 'buttonLink', e.target.value)} + className="admin-input w-full text-xs" + placeholder="e.g. /shop/beach-blossom" + /> +
+
+
+ )) + )} +
+
+ ); +} diff --git a/apps/admin/src/components/page-builder/HeroVideoForm.tsx b/apps/admin/src/components/page-builder/HeroVideoForm.tsx new file mode 100644 index 0000000..85f4eb6 --- /dev/null +++ b/apps/admin/src/components/page-builder/HeroVideoForm.tsx @@ -0,0 +1,241 @@ +import React from 'react'; +import { UploadCloud, Image as ImageIcon, Video } from 'lucide-react'; +import { type PageSection } from '@homaroma/types'; + +interface HeroVideoFormProps { + editingSection: Partial; + setEditingSection: React.Dispatch | null>>; + editingLang: string; + getLocalizedValue: (payload: any, field: string) => string; + updateLocalizedValue: (field: string, value: string) => void; + updatePayloadValue: (field: string, value: any) => void; + setUploadTarget: (target: { type: 'main' | 'slide' | 'gallery' | 'tile'; index?: number } | null) => void; + setIsMediaModalOpen: (open: boolean) => void; +} + +export default function HeroVideoForm({ + editingSection, + setEditingSection: _setEditingSection, + editingLang: _editingLang, + getLocalizedValue, + updateLocalizedValue, + updatePayloadValue, + setUploadTarget, + setIsMediaModalOpen, +}: HeroVideoFormProps) { + const payload = editingSection.payload || {}; + + return ( +
+ {/* Video Source & Poster */} +
+

+

+ +
+ {/* Video URL Input */} +
+ +
+ updatePayloadValue('videoUrl', e.target.value)} + className="admin-input flex-1 text-xs font-mono" + placeholder="https://example.com/brand-hero-video.mp4" + /> + +
+

Direct MP4/WebM video URL. Recommended size under 15MB for fast loading.

+
+ + {/* Poster Image */} +
+ +
+ updatePayloadValue('posterImageUrl', e.target.value)} + className="admin-input flex-1 text-xs" + placeholder="https://example.com/poster.jpg" + /> + +
+
+
+ + {/* Video Settings: Opacity & Height */} +
+
+ + updatePayloadValue('overlayOpacity', parseInt(e.target.value))} + className="w-full h-2 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-cyan-glow" + /> +
+ +
+ + +
+ +
+ + +
+
+
+ + {/* Localized Content */} +
+

Hero Text Content

+ +
+
+ + updateLocalizedValue('tagline', e.target.value)} + className="admin-input w-full" + placeholder="e.g. Cinema of Scents" + /> +
+ +
+ + updateLocalizedValue('title', e.target.value)} + className="admin-input w-full text-base font-bold" + placeholder="e.g. Pure Botanical [Sanctuary]" + /> +

Use [brackets] for accent colored text (e.g. Pure Botanical [Sanctuary]).

+
+ +
+ +