feat: Add photo galleries to services, resource types management, and UI improvements

Major features:
- Add drag-and-drop photo gallery to Service create/edit modals
- Add Resource Types management section to Settings (CRUD for custom types)
- Add edit icon consistency to Resources table (pencil icon in actions)
- Improve Services page with drag-to-reorder and customer preview mockup

Backend changes:
- Add photos JSONField to Service model with migration
- Add ResourceType model with category (STAFF/OTHER), description fields
- Add ResourceTypeViewSet with CRUD operations
- Add service reorder endpoint for display order

Frontend changes:
- Services page: two-column layout, drag-reorder, photo upload
- Settings page: Resource Types tab with full CRUD modal
- Resources page: Edit icon in actions column instead of row click
- Sidebar: Payments link visibility based on role and paymentsEnabled
- Update types.ts with Service.photos and ResourceTypeDefinition

Note: Removed photos from ResourceType (kept only for Service)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-11-28 01:11:53 -05:00
parent a7c756a8ec
commit b10426fbdb
52 changed files with 4259 additions and 356 deletions

View File

@@ -2,11 +2,12 @@ import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import { Business, User, CustomDomain } from '../types';
import { Save, Globe, Palette, BookKey, Check, Sparkles, CheckCircle, Link2, AlertCircle, ExternalLink, Copy, Crown, ShieldCheck, Trash2, RefreshCw, Star, Eye, EyeOff, Key, ShoppingCart, Building2, Users, Lock, Wallet } from 'lucide-react';
import { Save, Globe, Palette, BookKey, Check, Sparkles, CheckCircle, Link2, AlertCircle, ExternalLink, Copy, Crown, ShieldCheck, Trash2, RefreshCw, Star, Eye, EyeOff, Key, ShoppingCart, Building2, Users, Lock, Wallet, X, Plus, Layers, Pencil } from 'lucide-react';
import DomainPurchase from '../components/DomainPurchase';
import { useBusinessOAuthSettings, useUpdateBusinessOAuthSettings } from '../hooks/useBusinessOAuth';
import { useCustomDomains, useAddCustomDomain, useDeleteCustomDomain, useVerifyCustomDomain, useSetPrimaryDomain } from '../hooks/useCustomDomains';
import { useBusinessOAuthCredentials, useUpdateBusinessOAuthCredentials } from '../hooks/useBusinessOAuthCredentials';
import { useResourceTypes, useCreateResourceType, useUpdateResourceType, useDeleteResourceType } from '../hooks/useResourceTypes';
import OnboardingWizard from '../components/OnboardingWizard';
// Curated color palettes with complementary primary and secondary colors
@@ -18,6 +19,55 @@ const colorPalettes = [
secondary: '#0ea5e9',
preview: 'bg-gradient-to-br from-blue-600 to-sky-500',
},
{
name: 'Sky Blue',
description: 'Light & airy',
primary: '#0ea5e9',
secondary: '#38bdf8',
preview: 'bg-gradient-to-br from-sky-500 to-sky-400',
},
{
name: 'Cyan Splash',
description: 'Modern & vibrant',
primary: '#06b6d4',
secondary: '#22d3ee',
preview: 'bg-gradient-to-br from-cyan-500 to-cyan-400',
},
{
name: 'Aqua Fresh',
description: 'Clean & refreshing',
primary: '#14b8a6',
secondary: '#2dd4bf',
preview: 'bg-gradient-to-br from-teal-500 to-teal-400',
},
{
name: 'Mint Green',
description: 'Soft & welcoming',
primary: '#10b981',
secondary: '#34d399',
preview: 'bg-gradient-to-br from-emerald-500 to-emerald-400',
},
{
name: 'Coral Reef',
description: 'Warm & inviting',
primary: '#f97316',
secondary: '#fb923c',
preview: 'bg-gradient-to-br from-orange-500 to-orange-400',
},
{
name: 'Lavender Dream',
description: 'Gentle & elegant',
primary: '#a78bfa',
secondary: '#c4b5fd',
preview: 'bg-gradient-to-br from-violet-400 to-violet-300',
},
{
name: 'Rose Pink',
description: 'Friendly & modern',
primary: '#ec4899',
secondary: '#f472b6',
preview: 'bg-gradient-to-br from-pink-500 to-pink-400',
},
{
name: 'Forest Green',
description: 'Natural & calming',
@@ -32,20 +82,6 @@ const colorPalettes = [
secondary: '#a78bfa',
preview: 'bg-gradient-to-br from-violet-600 to-purple-400',
},
{
name: 'Sunset Orange',
description: 'Energetic & warm',
primary: '#ea580c',
secondary: '#f97316',
preview: 'bg-gradient-to-br from-orange-600 to-amber-500',
},
{
name: 'Rose Pink',
description: 'Friendly & modern',
primary: '#db2777',
secondary: '#f472b6',
preview: 'bg-gradient-to-br from-pink-600 to-pink-400',
},
{
name: 'Slate Gray',
description: 'Minimal & sophisticated',
@@ -53,13 +89,6 @@ const colorPalettes = [
secondary: '#64748b',
preview: 'bg-gradient-to-br from-slate-600 to-slate-400',
},
{
name: 'Teal Wave',
description: 'Fresh & balanced',
primary: '#0d9488',
secondary: '#14b8a6',
preview: 'bg-gradient-to-br from-teal-600 to-teal-400',
},
{
name: 'Crimson Red',
description: 'Bold & dynamic',
@@ -69,7 +98,256 @@ const colorPalettes = [
},
];
type SettingsTab = 'general' | 'domains' | 'authentication';
type SettingsTab = 'general' | 'domains' | 'authentication' | 'resources';
// Resource Types Management Section Component
const ResourceTypesSection: React.FC = () => {
const { t } = useTranslation();
const { data: resourceTypes = [], isLoading } = useResourceTypes();
const createResourceType = useCreateResourceType();
const updateResourceType = useUpdateResourceType();
const deleteResourceType = useDeleteResourceType();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingType, setEditingType] = useState<any>(null);
const [formData, setFormData] = useState({
name: '',
description: '',
category: 'OTHER' as 'STAFF' | 'OTHER',
iconName: '',
});
const openCreateModal = () => {
setEditingType(null);
setFormData({ name: '', description: '', category: 'OTHER', iconName: '' });
setIsModalOpen(true);
};
const openEditModal = (type: any) => {
setEditingType(type);
setFormData({
name: type.name,
description: type.description || '',
category: type.category,
iconName: type.icon_name || type.iconName || '',
});
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
setEditingType(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingType) {
await updateResourceType.mutateAsync({
id: editingType.id,
updates: formData,
});
} else {
await createResourceType.mutateAsync(formData);
}
closeModal();
} catch (error) {
console.error('Failed to save resource type:', error);
}
};
const handleDelete = async (id: string, name: string) => {
if (window.confirm(`Are you sure you want to delete the "${name}" resource type?`)) {
try {
await deleteResourceType.mutateAsync(id);
} catch (error: any) {
alert(error.response?.data?.error || 'Failed to delete resource type');
}
}
};
return (
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Layers size={20} className="text-indigo-500" />
{t('settings.resourceTypes', 'Resource Types')}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{t('settings.resourceTypesDescription', 'Define custom types for your resources (e.g., Stylist, Treatment Room, Equipment)')}
</p>
</div>
<button
onClick={openCreateModal}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium text-sm"
>
<Plus size={16} />
{t('settings.addResourceType', 'Add Type')}
</button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
</div>
) : resourceTypes.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<Layers size={40} className="mx-auto mb-2 opacity-30" />
<p>{t('settings.noResourceTypes', 'No custom resource types yet.')}</p>
<p className="text-sm mt-1">{t('settings.addFirstResourceType', 'Add your first resource type to categorize your resources.')}</p>
</div>
) : (
<div className="space-y-3">
{resourceTypes.map((type: any) => {
const isDefault = type.is_default || type.isDefault;
return (
<div
key={type.id}
className="p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700"
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${
type.category === 'STAFF' ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400' : 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
}`}>
{type.category === 'STAFF' ? <Users size={20} /> : <Layers size={20} />}
</div>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-gray-900 dark:text-white flex items-center gap-2">
{type.name}
{isDefault && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
Default
</span>
)}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
{type.category === 'STAFF' ? 'Requires staff assignment' : 'General resource'}
</p>
{type.description && (
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1 line-clamp-2">
{type.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 ml-2">
<button
onClick={() => openEditModal(type)}
className="p-2 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
title={t('common.edit', 'Edit')}
>
<Pencil size={16} />
</button>
{!isDefault && (
<button
onClick={() => handleDelete(type.id, type.name)}
disabled={deleteResourceType.isPending}
className="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50"
title={t('common.delete', 'Delete')}
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{/* Modal for Create/Edit */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-700 shrink-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{editingType
? t('settings.editResourceType', 'Edit Resource Type')
: t('settings.addResourceType', 'Add Resource Type')}
</h3>
<button
onClick={closeModal}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('settings.resourceTypeName', 'Name')} *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder={t('settings.resourceTypeNamePlaceholder', 'e.g., Stylist, Treatment Room, Camera')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('settings.resourceTypeDescription', 'Description')}
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 resize-none"
placeholder={t('settings.resourceTypeDescriptionPlaceholder', 'Describe this type of resource...')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('settings.resourceTypeCategory', 'Category')} *
</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value as 'STAFF' | 'OTHER' })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
<option value="STAFF">{t('settings.categoryStaff', 'Staff (requires staff assignment)')}</option>
<option value="OTHER">{t('settings.categoryOther', 'Other (general resource)')}</option>
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{formData.category === 'STAFF'
? t('settings.staffCategoryHint', 'Staff resources must be assigned to a team member')
: t('settings.otherCategoryHint', 'General resources like rooms, equipment, or vehicles')}
</p>
</div>
</div>
<div className="flex justify-end gap-3 p-6 border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 shrink-0">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
{t('common.cancel', 'Cancel')}
</button>
<button
type="submit"
disabled={createResourceType.isPending || updateResourceType.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{editingType ? t('common.save', 'Save') : t('common.create', 'Create')}
</button>
</div>
</form>
</div>
</div>
)}
</section>
);
};
const SettingsPage: React.FC = () => {
const { t } = useTranslation();
@@ -115,6 +393,16 @@ const SettingsPage: React.FC = () => {
const [showSecrets, setShowSecrets] = useState<{ [key: string]: boolean }>({});
const [showOnboarding, setShowOnboarding] = useState(false);
// Drag and drop state for logo uploads
const [isDraggingLogo, setIsDraggingLogo] = useState(false);
const [isDraggingEmailLogo, setIsDraggingEmailLogo] = useState(false);
// Lightbox state for viewing logos
const [lightboxImage, setLightboxImage] = useState<{ url: string; title: string } | null>(null);
// Email preview modal state
const [showEmailPreview, setShowEmailPreview] = useState(false);
// Update OAuth settings when data loads
useEffect(() => {
if (oauthData?.businessSettings) {
@@ -159,11 +447,77 @@ const SettingsPage: React.FC = () => {
}
};
// Drag and drop handlers for logo upload
const handleLogoDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingLogo(true);
};
const handleLogoDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingLogo(false);
};
const handleLogoDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingLogo(false);
const files = e.dataTransfer.files;
if (files && files.length > 0) {
const file = files[0];
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onloadend = () => {
setFormState(prev => ({ ...prev, logoUrl: reader.result as string }));
};
reader.readAsDataURL(file);
}
}
};
// Drag and drop handlers for email logo upload
const handleEmailLogoDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingEmailLogo(true);
};
const handleEmailLogoDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingEmailLogo(false);
};
const handleEmailLogoDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingEmailLogo(false);
const files = e.dataTransfer.files;
if (files && files.length > 0) {
const file = files[0];
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onloadend = () => {
setFormState(prev => ({ ...prev, emailLogoUrl: reader.result as string }));
};
reader.readAsDataURL(file);
}
}
};
const handleSave = () => {
updateBusiness(formState);
setShowToast(true);
};
const handleCancel = () => {
setFormState(business);
};
const handleOAuthSave = () => {
updateOAuthMutation.mutate(oauthSettings, {
onSuccess: () => {
@@ -289,25 +643,17 @@ const SettingsPage: React.FC = () => {
// Tab configuration
const tabs = [
{ id: 'general' as const, label: 'General', icon: Building2 },
{ id: 'resources' as const, label: 'Resource Types', icon: Layers },
{ id: 'domains' as const, label: 'Domains', icon: Globe },
{ id: 'authentication' as const, label: 'Authentication', icon: Lock },
];
return (
<div className="p-8 max-w-4xl mx-auto">
<div className="p-8 max-w-4xl mx-auto pb-24">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('settings.businessSettings')}</h2>
<p className="text-gray-500 dark:text-gray-400">{t('settings.businessSettingsDescription')}</p>
</div>
<button
onClick={handleSave}
className="flex items-center gap-2 px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors shadow-sm font-medium"
>
<Save size={18} />
{t('common.saveChanges')}
</button>
<div className="mb-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('settings.businessSettings')}</h2>
<p className="text-gray-500 dark:text-gray-400">{t('settings.businessSettingsDescription')}</p>
</div>
{/* Tab Navigation */}
@@ -367,6 +713,270 @@ const SettingsPage: React.FC = () => {
<Palette size={20} className="text-purple-500"/> {t('settings.branding')}
</h3>
{/* Logo Upload */}
<div className="mb-6 pb-6 border-b border-gray-200 dark:border-gray-700">
<h4 className="font-medium text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Image size={16} className="text-blue-500" />
Brand Logos
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
Upload your logos for different purposes. PNG with transparent background recommended.
</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Website Logo Upload/Display */}
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h5 className="font-medium text-gray-900 dark:text-white mb-3">Website Logo</h5>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Used in sidebar and customer-facing pages
</p>
<div
onDragOver={handleLogoDragOver}
onDragLeave={handleLogoDragLeave}
onDrop={handleLogoDrop}
className={`transition-all ${isDraggingLogo ? 'scale-105' : ''}`}
>
{formState.logoUrl ? (
<div className="space-y-3">
<div className="relative inline-block group">
<img
src={formState.logoUrl}
alt="Business logo"
onClick={() => setLightboxImage({ url: formState.logoUrl!, title: 'Website Logo' })}
className="w-32 h-32 object-contain border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 p-2 cursor-pointer hover:border-blue-400 transition-colors"
/>
<button
type="button"
onClick={() => {
setFormState(prev => ({
...prev,
logoUrl: undefined,
logoDisplayMode: 'logo-and-text' // Reset to show icon with text
}));
}}
className="absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full p-1.5 shadow-lg transition-colors z-10"
title="Remove logo"
>
<X size={14} />
</button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Click to view full size Click × to remove Drag and drop to replace
</p>
</div>
) : (
<div className={`w-32 h-32 border-2 border-dashed rounded-lg flex items-center justify-center transition-colors ${
isDraggingLogo
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-500'
: 'border-gray-300 dark:border-gray-600 text-gray-400'
}`}>
<div className="text-center">
<Image size={32} className="mx-auto mb-2" />
<p className="text-xs">Drop image here</p>
</div>
</div>
)}
</div>
<div className="mt-3">
<input
type="file"
id="logo-upload"
className="hidden"
accept="image/png,image/jpeg,image/svg+xml"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
// TODO: Upload to backend
const reader = new FileReader();
reader.onloadend = () => {
setFormState(prev => ({ ...prev, logoUrl: reader.result as string }));
};
reader.readAsDataURL(file);
}
}}
/>
<label
htmlFor="logo-upload"
className="inline-flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 cursor-pointer transition-colors text-sm font-medium"
>
<Upload size={16} />
{formState.logoUrl ? 'Change Logo' : 'Upload Logo'}
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
PNG, JPG, or SVG. Recommended: 500x500px
</p>
</div>
{/* Logo Display Mode */}
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Display Mode
</label>
<select
name="logoDisplayMode"
value={formState.logoDisplayMode || 'text-only'}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg text-sm"
>
<option value="text-only">Text Only</option>
<option value="logo-only">Logo Only</option>
<option value="logo-and-text">Logo and Text</option>
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
How your branding appears in the sidebar
</p>
</div>
</div>
{/* Email Logo Upload/Display */}
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h5 className="font-medium text-gray-900 dark:text-white mb-3">Email Logo</h5>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Used in email notifications and receipts
</p>
<div
onDragOver={handleEmailLogoDragOver}
onDragLeave={handleEmailLogoDragLeave}
onDrop={handleEmailLogoDrop}
className={`transition-all ${isDraggingEmailLogo ? 'scale-105' : ''}`}
>
{formState.emailLogoUrl ? (
<div className="space-y-3">
<div className="relative inline-block group">
<img
src={formState.emailLogoUrl}
alt="Email logo"
onClick={() => setLightboxImage({ url: formState.emailLogoUrl!, title: 'Email Logo' })}
className="w-48 h-16 object-contain border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 p-2 cursor-pointer hover:border-blue-400 transition-colors"
/>
<button
type="button"
onClick={() => {
setFormState(prev => ({ ...prev, emailLogoUrl: undefined }));
}}
className="absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full p-1.5 shadow-lg transition-colors z-10"
title="Remove email logo"
>
<X size={14} />
</button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Click to view full size Click × to remove Drag and drop to replace
</p>
</div>
) : (
<div className={`w-48 h-16 border-2 border-dashed rounded-lg flex items-center justify-center transition-colors ${
isDraggingEmailLogo
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-500'
: 'border-gray-300 dark:border-gray-600 text-gray-400'
}`}>
<div className="text-center">
<Image size={24} className="mx-auto mb-1" />
<p className="text-xs">Drop image here</p>
</div>
</div>
)}
</div>
<div className="mt-3">
<input
type="file"
id="email-logo-upload"
className="hidden"
accept="image/png,image/jpeg,image/svg+xml"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
// TODO: Upload to backend
const reader = new FileReader();
reader.onloadend = () => {
setFormState(prev => ({ ...prev, emailLogoUrl: reader.result as string }));
};
reader.readAsDataURL(file);
}
}}
/>
<label
htmlFor="email-logo-upload"
className="inline-flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 cursor-pointer transition-colors text-sm font-medium"
>
<Upload size={16} />
{formState.emailLogoUrl ? 'Change Email Logo' : 'Upload Email Logo'}
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
PNG with transparent background. Recommended: 600x200px
</p>
<button
type="button"
onClick={() => setShowEmailPreview(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium mt-3"
>
<Eye size={16} />
Preview Email
</button>
</div>
</div>
</div>
{/* Sidebar Preview */}
<div className="mt-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Sidebar Preview
</label>
<div
className="w-full max-w-xs p-6 rounded-xl"
style={{ backgroundColor: formState.primaryColor }}
>
<div className="flex items-center gap-3">
{/* Logo-only mode: full width */}
{formState.logoDisplayMode === 'logo-only' && formState.logoUrl ? (
<div className="flex items-center justify-center w-full">
<img
src={formState.logoUrl}
alt={formState.name}
className="max-w-full max-h-16 object-contain"
/>
</div>
) : (
<>
{/* Logo/Icon display - only show if NOT text-only mode */}
{formState.logoDisplayMode !== 'text-only' && (
formState.logoUrl ? (
<div className="flex items-center justify-center w-10 h-10 shrink-0">
<img
src={formState.logoUrl}
alt={formState.name}
className="w-full h-full object-contain"
/>
</div>
) : (
<div
className="flex items-center justify-center w-10 h-10 bg-white rounded-lg text-brand-600 font-bold text-xl shrink-0"
style={{ color: formState.primaryColor }}
>
{formState.name.substring(0, 2).toUpperCase()}
</div>
)
)}
{/* Text display - only show if NOT logo-only mode */}
{formState.logoDisplayMode !== 'logo-only' && (
<div className="overflow-hidden">
<h1 className="font-bold leading-tight truncate text-white">{formState.name}</h1>
<p className="text-xs text-white/60 truncate">{business.subdomain}.smoothschedule.com</p>
</div>
)}
</>
)}
</div>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
This is how your branding will appear in the navigation sidebar
</p>
</div>
</div>
{/* Color Palette Selection */}
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
@@ -593,6 +1203,11 @@ const SettingsPage: React.FC = () => {
</>
)}
{/* RESOURCES TAB */}
{activeTab === 'resources' && isOwner && (
<ResourceTypesSection />
)}
{/* DOMAINS TAB */}
{activeTab === 'domains' && (
<>
@@ -1103,6 +1718,185 @@ const SettingsPage: React.FC = () => {
}}
/>
)}
{/* Lightbox Modal for Logo Preview */}
{lightboxImage && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
onClick={() => setLightboxImage(null)}
>
<div className="relative max-w-4xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between mb-4 text-white">
<h3 className="text-lg font-semibold">{lightboxImage.title}</h3>
<button
onClick={() => setLightboxImage(null)}
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
title="Close"
>
<X size={24} />
</button>
</div>
<div
className="bg-white dark:bg-gray-800 rounded-lg p-8 overflow-auto"
onClick={(e) => e.stopPropagation()}
>
<img
src={lightboxImage.url}
alt={lightboxImage.title}
className="max-w-full max-h-[70vh] object-contain mx-auto"
/>
</div>
<p className="text-white text-sm mt-4 text-center">
Click anywhere outside to close
</p>
</div>
</div>
)}
{/* Email Preview Modal */}
{showEmailPreview && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
onClick={() => setShowEmailPreview(false)}
>
<div
className="relative max-w-2xl w-full bg-white dark:bg-gray-800 rounded-lg shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">Email Preview</h3>
<button
onClick={() => setShowEmailPreview(false)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Close"
>
<X size={24} />
</button>
</div>
<div className="p-6 overflow-auto max-h-[70vh]">
{/* Email Template Preview */}
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-8" style={{ fontFamily: 'Arial, sans-serif' }}>
{/* Email Header with Logo */}
<div className="bg-white dark:bg-gray-800 rounded-t-lg p-6 text-center border-b-4" style={{ borderBottomColor: formState.primaryColor }}>
{formState.emailLogoUrl ? (
<img
src={formState.emailLogoUrl}
alt={formState.name}
className="mx-auto max-h-20 object-contain"
/>
) : (
<div className="flex items-center justify-center">
<div
className="inline-flex items-center justify-center w-16 h-16 rounded-full text-white font-bold text-2xl"
style={{ backgroundColor: formState.primaryColor }}
>
{formState.name.substring(0, 2).toUpperCase()}
</div>
</div>
)}
</div>
{/* Email Body */}
<div className="bg-white dark:bg-gray-800 p-8">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Appointment Confirmation
</h2>
<p className="text-gray-700 dark:text-gray-300 mb-4">
Hi John Doe,
</p>
<p className="text-gray-700 dark:text-gray-300 mb-6">
Your appointment has been confirmed. Here are the details:
</p>
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-6 mb-6">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-500 dark:text-gray-400 font-medium">Service</p>
<p className="text-gray-900 dark:text-white">Haircut & Style</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400 font-medium">Date & Time</p>
<p className="text-gray-900 dark:text-white">Dec 15, 2025 at 2:00 PM</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400 font-medium">Duration</p>
<p className="text-gray-900 dark:text-white">60 minutes</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400 font-medium">Price</p>
<p className="text-gray-900 dark:text-white">$45.00</p>
</div>
</div>
</div>
<button
className="w-full text-white font-semibold py-3 px-6 rounded-lg transition-colors"
style={{ backgroundColor: formState.primaryColor }}
>
View Appointment Details
</button>
<p className="text-gray-600 dark:text-gray-400 text-sm mt-6">
Need to make changes? You can reschedule or cancel up to 24 hours before your appointment.
</p>
</div>
{/* Email Footer */}
<div className="bg-gray-100 dark:bg-gray-900 rounded-b-lg p-6 text-center">
<p className="text-gray-600 dark:text-gray-400 text-sm mb-2">
{formState.name}
</p>
<p className="text-gray-500 dark:text-gray-500 text-xs">
{business.subdomain}.smoothschedule.com
</p>
<p className="text-gray-400 dark:text-gray-600 text-xs mt-4">
© 2025 {formState.name}. All rights reserved.
</p>
</div>
</div>
</div>
<div className="p-6 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 rounded-b-lg">
<p className="text-sm text-gray-600 dark:text-gray-400 text-center">
This is a preview of how your appointment confirmation emails will appear to customers.
</p>
</div>
</div>
</div>
)}
{/* Floating Action Buttons */}
<div className="fixed bottom-0 left-64 right-0 p-4 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 shadow-lg z-40 md:left-64">
<div className="max-w-4xl mx-auto flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
{showToast && (
<span className="flex items-center gap-2 text-green-600 dark:text-green-400">
<CheckCircle size={16} />
Changes saved successfully
</span>
)}
</div>
<div className="flex items-center gap-3">
<button
onClick={handleCancel}
className="flex items-center gap-2 px-6 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors font-medium"
>
<X size={18} />
Cancel Changes
</button>
<button
onClick={handleSave}
className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-md font-medium"
>
<Save size={18} />
Save Changes
</button>
</div>
</div>
</div>
</div>
);
};