Enhance email templates with clickable links, branding, and improved styling

- Add clickable gallery links in all email templates
- Include application logo in email header and footer (custom or PicPeak default)
- Redesign emails with professional styling matching gallery login page
  - Gray background with white content box
  - PicPeak green header with centered logo
  - Clean typography and proper spacing
  - Responsive design for mobile devices
  - Styled call-to-action buttons
  - Footer with branding and copyright
- Update email processor to fetch branding settings dynamically
- Use proper API URLs for logo images in emails

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-10 22:19:15 +02:00
parent 6438374258
commit 5328b4f73a
52 changed files with 3237 additions and 683 deletions
+62 -13
View File
@@ -1,23 +1,25 @@
import React, { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { Camera, Calendar, AlertCircle, Clock } from 'lucide-react';
import { Calendar, AlertCircle, Clock } from 'lucide-react';
import { differenceInDays, parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../hooks/useLocalizedDate';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, Input, Button, Loading, ReCaptcha } from '../components/common';
import { useGalleryAuth } from '../contexts';
import { useGalleryAuth, useTheme } from '../contexts';
import { useGalleryInfo } from '../hooks/useGallery';
import { GalleryView } from '../components/gallery';
import { analyticsService } from '../services/analytics.service';
import { api } from '../config/api';
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
export const GalleryPage: React.FC = () => {
const { slug, token } = useParams<{ slug: string; token?: string }>();
const { isAuthenticated, login, event } = useGalleryAuth();
const { t, i18n } = useTranslation();
const { format } = useLocalizedDate();
const { setTheme } = useTheme();
const [password, setPassword] = useState('');
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
@@ -43,6 +45,47 @@ export const GalleryPage: React.FC = () => {
}
}, [settingsData, isAuthenticated, i18n]);
// Apply theme for login page
React.useEffect(() => {
if (!isAuthenticated && galleryInfo && settingsData) {
let themeToApply = null;
if (galleryInfo.color_theme) {
try {
// Check if it's a valid JSON string
if (galleryInfo.color_theme.startsWith('{')) {
themeToApply = JSON.parse(galleryInfo.color_theme);
} else {
// Handle legacy theme names - check if it's a preset
const preset = GALLERY_THEME_PRESETS[galleryInfo.color_theme];
if (preset) {
themeToApply = preset.config;
} else {
// Unknown theme name, fall back to global theme
if (settingsData.theme_config) {
themeToApply = settingsData.theme_config;
}
}
}
} catch (e) {
console.error('Failed to parse event theme:', e);
// Fall back to global theme
if (settingsData.theme_config) {
themeToApply = settingsData.theme_config;
}
}
} else if (settingsData.theme_config) {
// No event theme, use global theme
themeToApply = settingsData.theme_config;
}
// Apply theme
if (themeToApply) {
setTheme(themeToApply);
}
}
}, [galleryInfo, settingsData, isAuthenticated, setTheme]);
// Calculate days until expiration
const daysUntilExpiration = galleryInfo
? differenceInDays(parseISO(galleryInfo.expires_at), new Date())
@@ -159,6 +202,9 @@ export const GalleryPage: React.FC = () => {
{t('legal.datenschutz')}
</Link>
</div>
<p className="text-xs mt-2 text-neutral-500">
Powered by <span className="font-semibold">PicPeak</span>
</p>
</div>
</div>
</div>
@@ -213,6 +259,9 @@ export const GalleryPage: React.FC = () => {
{t('legal.datenschutz')}
</Link>
</div>
<p className="text-xs mt-2 text-neutral-500">
Powered by <span className="font-semibold">PicPeak</span>
</p>
</div>
</div>
</div>
@@ -231,17 +280,14 @@ export const GalleryPage: React.FC = () => {
<div className="w-full max-w-lg">
{/* Logo/Header */}
<div className="text-center mb-4 sm:mb-6">
{settingsData?.branding_logo_url ? (
<img
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-12 sm:h-16 lg:h-20 w-auto object-contain mx-auto mb-3 sm:mb-4"
/>
) : (
<div className="inline-flex items-center justify-center w-12 h-12 sm:w-16 sm:h-16 lg:w-20 lg:h-20 rounded-2xl mb-3 sm:mb-4" style={{ backgroundColor: 'var(--color-primary, #5C8762)' }}>
<Camera className="w-6 h-6 sm:w-8 sm:h-8 lg:w-10 lg:h-10 text-white" />
</div>
)}
<img
src={settingsData?.branding_logo_url ?
`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}` :
'/picpeak-logo-transparent.png'
}
alt={settingsData?.branding_company_name || 'PicPeak'}
className="h-12 sm:h-16 lg:h-20 w-auto object-contain mx-auto mb-3 sm:mb-4"
/>
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold mb-2 px-2" style={{ color: 'var(--color-text, #171717)' }}>
{galleryInfo?.event_name}
</h1>
@@ -325,6 +371,9 @@ export const GalleryPage: React.FC = () => {
{t('legal.datenschutz')}
</Link>
</div>
<p className="text-xs mt-2 text-neutral-500">
Powered by <span className="font-semibold">PicPeak</span>
</p>
</div>
</div>
</div>
+23 -19
View File
@@ -120,17 +120,16 @@ export const AdminLoginPage: React.FC = () => {
<div className="w-full max-w-md">
{/* Logo/Header */}
<div className="text-center mb-8">
{settingsData?.branding_logo_url ? (
<div
className="w-[200px] h-[150px] mx-auto mb-6 rounded-2xl flex items-center justify-center"
style={{ backgroundColor: '#eee6d2' }}
>
<img
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto mb-4"
src="/picpeak-logo-transparent.png"
alt="PicPeak"
className="w-[180px] h-[130px] object-contain"
/>
) : (
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full mb-4" style={{ backgroundColor: 'var(--color-primary, #5C8762)' }}>
<Lock className="w-8 h-8 text-white" />
</div>
)}
</div>
<h1 className="text-3xl font-bold" style={{ color: 'var(--color-text, #171717)' }}>Admin Login</h1>
<p className="mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>Sign in to manage your photo galleries</p>
</div>
@@ -229,16 +228,21 @@ export const AdminLoginPage: React.FC = () => {
</Card>
{/* Footer */}
<p className="text-center text-sm mt-8" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
Need help? Contact{' '}
<a
href={`mailto:${settingsData?.branding_support_email || 'support@example.com'}`}
className="hover:underline"
style={{ color: 'var(--color-primary, #5C8762)' }}
>
{settingsData?.branding_support_email || 'support@example.com'}
</a>
</p>
<div className="text-center mt-8">
<p className="text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
Need help? Contact{' '}
<a
href={`mailto:${settingsData?.branding_support_email || 'support@example.com'}`}
className="hover:underline"
style={{ color: 'var(--color-primary, #5C8762)' }}
>
{settingsData?.branding_support_email || 'support@example.com'}
</a>
</p>
<p className="text-xs mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.5 }}>
Powered by <span className="font-semibold">PicPeak</span>
</p>
</div>
{/* Development Hint */}
{import.meta.env.DEV && (
+31 -18
View File
@@ -4,7 +4,7 @@ import { toast } from 'react-toastify';
import { Button, Card, Input, ErrorBoundary, Loading } from '../../components/common';
import { ThemeCustomizer } from '../../components/admin/ThemeCustomizer';
import { useTheme, type ThemeConfig, GALLERY_THEME_PRESETS } from '../../contexts/ThemeContext';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { settingsService, type BrandingSettings } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
@@ -42,10 +42,15 @@ export const BrandingPage: React.FC = () => {
});
// Update branding mutation
const queryClient = useQueryClient();
const brandingMutation = useMutation({
mutationFn: settingsService.updateBranding,
onSuccess: () => {
toast.success(t('toast.brandingUpdated'));
// Invalidate all settings queries to refresh data
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
queryClient.invalidateQueries({ queryKey: ['public-settings'] });
},
onError: () => {
toast.error(t('toast.saveError'));
@@ -67,21 +72,24 @@ export const BrandingPage: React.FC = () => {
useEffect(() => {
if (settings) {
const formatted = settingsService.formatBrandingSettings(settings);
setBrandingSettings(formatted);
// Don't set logo_url here - it will be synced from theme
const { logo_url, ...brandingWithoutLogo } = formatted;
setBrandingSettings(prev => ({ ...prev, ...brandingWithoutLogo }));
}
}, [settings]);
// Initialize theme from database
useEffect(() => {
if (themeSettings) {
const formatted = settingsService.formatThemeSettings(themeSettings);
const formatted = settingsService.formatThemeSettings(themeSettings) as ThemeConfig;
if (formatted && Object.keys(formatted).length > 0) {
// Merge logo URL from branding settings if available
const logoUrl = settings?.branding_logo_url || brandingSettings.logo_url;
const themeWithLogo = logoUrl ? { ...formatted, logoUrl } : formatted;
// Use the theme's logo URL as stored in the theme config
setCurrentTheme(formatted);
setTheme(formatted);
setCurrentTheme(themeWithLogo);
setTheme(themeWithLogo);
// Always sync the logo URL from theme to branding settings - theme is source of truth
setBrandingSettings(prev => ({ ...prev, logo_url: formatted.logoUrl || '' }));
// Try to identify which preset this matches
for (const [key, preset] of Object.entries(GALLERY_THEME_PRESETS)) {
@@ -92,7 +100,7 @@ export const BrandingPage: React.FC = () => {
}
}
}
}, [themeSettings, settings, brandingSettings.logo_url, setTheme]);
}, [themeSettings, setTheme]);
const handleBrandingChange = (key: string, value: any) => {
setBrandingSettings(prev => ({ ...prev, [key]: value }));
@@ -151,17 +159,23 @@ export const BrandingPage: React.FC = () => {
const handleSave = async () => {
try {
// Save branding settings to database
await brandingMutation.mutateAsync(brandingSettings);
// Sync logo URL from theme to branding settings
const updatedBrandingSettings = {
...brandingSettings,
logo_url: currentTheme.logoUrl || ''
};
// Save theme settings to database (including logo URL if present)
const themeToSave = brandingSettings.logo_url
? { ...currentTheme, logoUrl: brandingSettings.logo_url }
: currentTheme;
await themeMutation.mutateAsync(themeToSave);
// Save branding settings to database
await brandingMutation.mutateAsync(updatedBrandingSettings);
// Save theme settings to database
await themeMutation.mutateAsync(currentTheme);
// Apply theme globally
setTheme(themeToSave);
setTheme(currentTheme);
// Update local state to reflect saved values
setBrandingSettings(updatedBrandingSettings);
} catch (error) {
console.error('Failed to save settings:', error);
}
@@ -463,7 +477,6 @@ export const BrandingPage: React.FC = () => {
onChange={handleThemeChange}
presetName={currentThemeName}
onPresetChange={handlePresetChange}
isPreviewMode={isPreviewMode}
/>
</div>
@@ -11,6 +11,7 @@ import {
EyeOff
} from 'lucide-react';
import { format, addDays } from 'date-fns';
import { enUS, de } from 'date-fns/locale';
import { toast } from 'react-toastify';
import { Button, Input, Card } from '../../components/common';
@@ -18,6 +19,7 @@ import { ThemeCustomizerEnhanced } from '../../components/admin/ThemeCustomizerE
import { useMutation, useQuery } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { categoriesService } from '../../services/categories.service';
import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
@@ -25,6 +27,7 @@ interface FormData {
event_type: string;
event_name: string;
event_date: string;
host_name: string;
host_email: string;
admin_email: string;
password: string;
@@ -53,7 +56,7 @@ const EVENT_TYPES = [
export const CreateEventPageEnhanced: React.FC = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const isMountedRef = useRef(true);
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
// const [showPreview, setShowPreview] = useState(false);
@@ -68,6 +71,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
event_type: 'wedding',
event_name: '',
event_date: format(new Date(), 'yyyy-MM-dd'),
host_name: '',
host_email: '',
admin_email: '',
password: '',
@@ -89,6 +93,22 @@ export const CreateEventPageEnhanced: React.FC = () => {
queryFn: () => categoriesService.getGlobalCategories()
});
// Fetch default settings
const { data: settings } = useQuery({
queryKey: ['admin-settings'],
queryFn: () => settingsService.getAllSettings()
});
// Update default expiration days when settings are loaded
useEffect(() => {
if (settings?.general_default_expiration_days) {
setFormData(prev => ({
...prev,
expires_in_days: settings.general_default_expiration_days
}));
}
}, [settings]);
// Update theme when event type changes
useEffect(() => {
const recommendedPreset = EVENT_TYPE_PRESETS[formData.event_type];
@@ -111,8 +131,21 @@ export const CreateEventPageEnhanced: React.FC = () => {
},
onError: (error: any) => {
console.error('Create event error:', error);
console.error('Error response:', error.response?.data);
console.error('Error status:', error.response?.status);
console.error('Full error object:', JSON.stringify(error.response, null, 2));
const errorMessage = error.response?.data?.error || error.message || t('errors.eventCreationFailed');
toast.error(errorMessage);
// If validation errors exist, show them
if (error.response?.data?.errors) {
const validationErrors = error.response.data.errors;
console.error('Validation errors:', validationErrors);
validationErrors.forEach((err: any) => {
toast.error(`${err.param}: ${err.msg}`);
});
} else {
toast.error(errorMessage);
}
},
});
@@ -127,6 +160,10 @@ export const CreateEventPageEnhanced: React.FC = () => {
newErrors.event_date = t('validation.eventDateRequired');
}
if (!formData.host_name) {
newErrors.host_name = t('validation.hostNameRequired');
}
if (!formData.host_email) {
newErrors.host_email = t('validation.hostEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.host_email)) {
@@ -164,10 +201,11 @@ export const CreateEventPageEnhanced: React.FC = () => {
return;
}
createMutation.mutate({
const payload = {
event_type: formData.event_type,
event_name: formData.event_name,
event_date: formData.event_date,
host_name: formData.host_name,
host_email: formData.host_email,
admin_email: formData.admin_email,
password: formData.password,
@@ -176,7 +214,10 @@ export const CreateEventPageEnhanced: React.FC = () => {
expiration_days: formData.expires_in_days,
allow_user_uploads: formData.allow_user_uploads,
upload_category_id: formData.upload_category_id,
});
};
console.log('Submitting payload:', payload);
createMutation.mutate(payload);
};
const handleInputChange = (field: keyof FormData) => (
@@ -354,16 +395,27 @@ export const CreateEventPageEnhanced: React.FC = () => {
{t('events.accessAndSecurity')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
type="email"
label={t('events.hostEmail')}
placeholder={t('events.hostEmailPlaceholder')}
value={formData.host_email}
onChange={handleInputChange('host_email')}
error={errors.host_email}
leftIcon={<Mail className="w-5 h-5" />}
/>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={t('events.hostName')}
placeholder={t('events.hostNamePlaceholder')}
value={formData.host_name}
onChange={handleInputChange('host_name')}
error={errors.host_name}
leftIcon={<Calendar className="w-5 h-5" />}
/>
<Input
type="email"
label={t('events.hostEmail')}
placeholder={t('events.hostEmailPlaceholder')}
value={formData.host_email}
onChange={handleInputChange('host_email')}
error={errors.host_email}
leftIcon={<Mail className="w-5 h-5" />}
/>
</div>
<Input
type="email"
@@ -411,22 +463,23 @@ export const CreateEventPageEnhanced: React.FC = () => {
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('events.galleryExpiration')}
</label>
<div className="flex items-center gap-4">
<Input
type="number"
value={formData.expires_in_days}
onChange={handleInputChange('expires_in_days')}
error={errors.expires_in_days}
min={1}
max={365}
leftIcon={<Clock className="w-5 h-5" />}
className="w-32"
/>
<div className="flex items-center gap-2">
<div className="w-32">
<Input
type="number"
value={formData.expires_in_days}
onChange={handleInputChange('expires_in_days')}
error={errors.expires_in_days}
min={1}
max={365}
leftIcon={<Clock className="w-5 h-5" />}
/>
</div>
<span className="text-sm text-neutral-600">{t('events.daysAfterEvent')}</span>
</div>
{formData.event_date && (
<p className="mt-2 text-sm text-neutral-500">
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days), 'PPP')}
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days), 'PPP', { locale: i18n.language === 'de' ? de : enUS })}
</p>
)}
</div>
+335 -105
View File
@@ -5,8 +5,6 @@ import {
ArrowLeft,
ExternalLink,
Calendar,
Users,
Eye,
Download,
Archive,
Edit2,
@@ -17,24 +15,28 @@ import {
CheckCircle,
Upload,
Image,
Key
Key,
Palette,
Settings
} from 'lucide-react';
import { format, parseISO, differenceInDays } from 'date-fns';
import { parseISO, differenceInDays } from 'date-fns';
import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Input, Card, Loading } from '../../components/common';
import { PhotoUpload, EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal } from '../../components/admin';
import { PhotoUpload, EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeDisplay, ThemeCustomizerEnhanced, ThemeEditorModal, HeroPhotoSelector } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { galleryService } from '../../services/gallery.service';
import { archiveService } from '../../services/archive.service';
import { photosService, AdminPhoto } from '../../services/photos.service';
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
export const EventDetailsPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { t } = useTranslation();
const { format } = useLocalizedDate();
// Validate ID parameter
React.useEffect(() => {
@@ -50,12 +52,18 @@ export const EventDetailsPage: React.FC = () => {
expires_at: '',
allow_user_uploads: false,
upload_category_id: null as number | null,
hero_photo_id: null as number | null,
host_name: '',
});
const [copiedLink, setCopiedLink] = useState(false);
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories'>('overview');
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
const [showPasswordReset, setShowPasswordReset] = useState(false);
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
const [showThemeEditorModal, setShowThemeEditorModal] = useState(false);
// Photo filters state
const [photoFilters, setPhotoFilters] = useState({
@@ -72,19 +80,13 @@ export const EventDetailsPage: React.FC = () => {
enabled: !!id,
});
// Fetch event statistics (skip if event doesn't exist or from admin context)
const { data: stats } = useQuery({
queryKey: ['admin-event-stats', event?.slug],
queryFn: () => galleryService.getGalleryStats(event!.slug),
enabled: false, // Disable stats from admin panel as it requires gallery auth
retry: false,
});
// Statistics are now fetched with the event details from the admin API
// Fetch photos when on photos tab
// Fetch photos (needed for both photos tab and hero photo selector)
const { data: photos = [], isLoading: photosLoading, refetch: refetchPhotos } = useQuery({
queryKey: ['admin-event-photos', id, photoFilters],
queryFn: () => photosService.getEventPhotos(parseInt(id!), photoFilters),
enabled: !!id && activeTab === 'photos',
enabled: !!id && (activeTab === 'photos' || isEditing),
});
// Fetch categories for the event
@@ -105,8 +107,15 @@ export const EventDetailsPage: React.FC = () => {
toast.success(t('toast.eventUpdated'));
setIsEditing(false);
},
onError: () => {
toast.error(t('toast.saveError'));
onError: (error: any) => {
console.error('Update event error:', error.response?.data || error);
if (error.response?.data?.errors) {
console.error('Validation errors:', error.response.data.errors);
const errorMessage = error.response.data.errors[0].msg + ' (field: ' + error.response.data.errors[0].path + ')';
toast.error(errorMessage);
} else {
toast.error(error.response?.data?.error || t('toast.saveError'));
}
},
});
@@ -155,18 +164,85 @@ export const EventDetailsPage: React.FC = () => {
expires_at: format(parseISO(event.expires_at), 'yyyy-MM-dd'),
allow_user_uploads: event.allow_user_uploads || false,
upload_category_id: event.upload_category_id || null,
hero_photo_id: event.hero_photo_id || null,
host_name: event.host_name || '',
});
// Parse theme configuration
if (event.color_theme) {
try {
if (event.color_theme.startsWith('{')) {
const parsedTheme = JSON.parse(event.color_theme);
setCurrentTheme(parsedTheme);
// Try to find matching preset
const matchingPreset = Object.entries(GALLERY_THEME_PRESETS).find(
([_, preset]) => JSON.stringify(preset.config) === JSON.stringify(parsedTheme)
);
setCurrentPresetName(matchingPreset ? matchingPreset[0] : 'custom');
} else {
// Legacy theme name
const preset = GALLERY_THEME_PRESETS[event.color_theme];
if (preset) {
setCurrentTheme(preset.config);
setCurrentPresetName(event.color_theme);
}
}
} catch (e) {
console.error('Failed to parse theme:', e);
setCurrentTheme(GALLERY_THEME_PRESETS.default.config);
setCurrentPresetName('default');
}
} else {
setCurrentTheme(GALLERY_THEME_PRESETS.default.config);
setCurrentPresetName('default');
}
setIsEditing(true);
};
const handleSaveEdit = () => {
updateMutation.mutate({
welcome_message: editForm.welcome_message || undefined,
color_theme: editForm.color_theme || undefined,
// Prepare color_theme - if we have a custom theme, serialize it
let themeToSave = editForm.color_theme;
if (currentTheme && (currentPresetName === 'custom' || showThemeCustomizer)) {
themeToSave = JSON.stringify(currentTheme);
} else if (currentPresetName && currentPresetName !== 'custom') {
// Use preset name for non-custom themes
themeToSave = currentPresetName;
}
// Clean up the data - remove undefined values
const updateData: any = {
expires_at: editForm.expires_at,
allow_user_uploads: editForm.allow_user_uploads,
upload_category_id: editForm.upload_category_id,
};
// Only include fields that have defined values
if (editForm.welcome_message !== undefined && editForm.welcome_message !== null) {
updateData.welcome_message = editForm.welcome_message;
}
if (themeToSave) {
updateData.color_theme = themeToSave;
}
if (editForm.upload_category_id !== undefined) {
updateData.upload_category_id = editForm.upload_category_id;
}
if (editForm.hero_photo_id !== undefined) {
updateData.hero_photo_id = editForm.hero_photo_id;
}
if (editForm.host_name !== undefined && editForm.host_name !== null) {
updateData.host_name = editForm.host_name;
}
// Remove any keys with undefined values
Object.keys(updateData).forEach(key => {
if (updateData[key] === undefined) {
delete updateData[key];
}
});
console.log('Updating event with data:', updateData);
console.log('Theme length:', updateData.color_theme ? updateData.color_theme.length : 0);
updateMutation.mutate(updateData);
};
const handleCopyLink = async () => {
@@ -180,6 +256,35 @@ export const EventDetailsPage: React.FC = () => {
}
};
const handleThemeModalSave = async (theme: ThemeConfig, presetName: string) => {
// Prepare theme for saving
let themeToSave: string;
if (presetName !== 'custom' && GALLERY_THEME_PRESETS[presetName]) {
// Save preset name for standard presets
themeToSave = presetName;
} else {
// Save full theme config for custom themes
themeToSave = JSON.stringify(theme);
}
try {
// Update the event with new theme
await eventsService.updateEvent(parseInt(id!), {
color_theme: themeToSave
});
// Invalidate queries to refresh the data
await queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
// Close modal and show success
setShowThemeEditorModal(false);
toast.success(t('toast.themeUpdated'));
} catch (error) {
console.error('Failed to save theme:', error);
toast.error(t('toast.saveError'));
}
};
return (
<div>
{/* Page Header */}
@@ -200,15 +305,15 @@ export const EventDetailsPage: React.FC = () => {
<div className="flex items-center gap-4 mt-2 text-sm text-neutral-600">
<span className="flex items-center">
<Calendar className="w-4 h-4 mr-1" />
{format(parseISO(event.event_date), 'MMMM d, yyyy')}
{format(parseISO(event.event_date), 'PPP')}
</span>
<span className="capitalize">{event.event_type}</span>
{event.is_archived && (
{event.is_archived ? (
<span className="text-neutral-500 flex items-center">
<Archive className="w-4 h-4 mr-1" />
{t('events.archived')}
</span>
)}
) : null}
</div>
</div>
@@ -319,8 +424,8 @@ export const EventDetailsPage: React.FC = () => {
}`}
>
<Image className="w-4 h-4" />
{t('events.photos')}
{event.photo_count && event.photo_count > 0 && (
<span>{t('events.photos')}</span>
{event.photo_count !== undefined && event.photo_count > 0 && (
<span className="ml-1 px-2 py-0.5 text-xs font-medium bg-neutral-100 text-neutral-700 rounded-full">
{event.photo_count}
</span>
@@ -341,9 +446,9 @@ export const EventDetailsPage: React.FC = () => {
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Details */}
<div className="lg:col-span-2 space-y-6">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Left Column - Main Details */}
<div className="space-y-6">
{/* Event Information */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.eventInformation')}</h2>
@@ -363,6 +468,18 @@ export const EventDetailsPage: React.FC = () => {
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.hostName')}
</label>
<Input
type="text"
value={editForm.host_name}
onChange={(e) => setEditForm(prev => ({ ...prev, host_name: e.target.value }))}
placeholder={t('events.hostNamePlaceholder')}
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.expirationDate')}
@@ -375,6 +492,81 @@ export const EventDetailsPage: React.FC = () => {
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.galleryTheme')}
</label>
{!showThemeCustomizer ? (
<div className="space-y-2">
<select
value={currentPresetName}
onChange={(e) => {
const presetName = e.target.value;
setCurrentPresetName(presetName);
if (presetName !== 'custom') {
const preset = GALLERY_THEME_PRESETS[presetName];
if (preset) {
setCurrentTheme(preset.config);
setEditForm(prev => ({ ...prev, color_theme: presetName }));
}
}
}}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
{Object.entries(GALLERY_THEME_PRESETS).map(([key, preset]) => (
<option key={key} value={key}>
{preset.name}
</option>
))}
<option value="custom">{t('branding.customTheme')}</option>
</select>
<Button
variant="outline"
size="sm"
leftIcon={<Settings className="w-4 h-4" />}
onClick={() => setShowThemeCustomizer(true)}
className="w-full"
>
{t('branding.customizeTheme')}
</Button>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-600">{t('branding.customizingTheme')}</span>
<Button
variant="ghost"
size="sm"
onClick={() => setShowThemeCustomizer(false)}
>
{t('common.hide')}
</Button>
</div>
<ThemeCustomizerEnhanced
value={currentTheme || GALLERY_THEME_PRESETS.default.config}
onChange={setCurrentTheme}
presetName={currentPresetName}
onPresetChange={(presetName) => {
setCurrentPresetName(presetName);
if (presetName !== 'custom') {
setEditForm(prev => ({ ...prev, color_theme: presetName }));
}
}}
isPreviewMode={false}
showGalleryLayouts={true}
/>
</div>
)}
</div>
{/* Hero Photo Selection */}
<HeroPhotoSelector
photos={photos || []}
currentHeroPhotoId={editForm.hero_photo_id}
onSelect={(photoId) => setEditForm(prev => ({ ...prev, hero_photo_id: photoId }))}
isEditing={isEditing}
/>
<div>
<label className="flex items-center">
<input
@@ -419,12 +611,19 @@ export const EventDetailsPage: React.FC = () => {
) : (
<dl className="space-y-4">
<div>
<dt className="text-sm font-medium text-neutral-500">{t('events.welcomeMessageLabel')}</dt>
<dt className="text-sm font-medium text-neutral-500">{t('events.welcomeMessage')}</dt>
<dd className="mt-1 text-sm text-neutral-900">
{event.welcome_message || <span className="text-neutral-400">{t('events.noWelcomeMessageSet')}</span>}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-neutral-500">{t('events.hostName')}</dt>
<dd className="mt-1 text-sm text-neutral-900">
{event.host_name || <span className="text-neutral-400">{t('common.notSet')}</span>}
</dd>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-neutral-500">{t('events.hostEmail')}</dt>
@@ -439,25 +638,36 @@ export const EventDetailsPage: React.FC = () => {
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-neutral-500">{t('events.createdOn')}</dt>
<dt className="text-sm font-medium text-neutral-500">{t('events.created')}</dt>
<dd className="mt-1 text-sm text-neutral-900">
{format(parseISO(event.created_at), 'MMM d, yyyy')}
{format(parseISO(event.created_at), 'PP')}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-neutral-500">{t('events.expires')}</dt>
<dd className="mt-1 text-sm text-neutral-900">
{format(parseISO(event.expires_at), 'MMM d, yyyy')}
{format(parseISO(event.expires_at), 'PP')}
{!event.is_archived && daysUntilExpiration > 0 && (
<span className="text-neutral-500 ml-1">
{t('events.daysLeft', { days: daysUntilExpiration })}
{t('events.daysLeft', { count: daysUntilExpiration })}
</span>
)}
</dd>
</div>
</div>
<div>
<dt className="text-sm font-medium text-neutral-500">{t('events.heroPhoto')}</dt>
<dd className="mt-1 text-sm text-neutral-900">
{event.hero_photo_id ? (
<span className="text-primary-600">{t('events.heroPhotoSelected')}</span>
) : (
<span className="text-neutral-400">{t('events.noHeroPhotoSelected')}</span>
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-neutral-500">{t('events.userUploads')}</dt>
<dd className="mt-1 text-sm text-neutral-900">
@@ -523,41 +733,7 @@ export const EventDetailsPage: React.FC = () => {
)}
</Card>
{/* Photo Statistics */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.photoStatistics')}</h2>
<div className="space-y-3">
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">{t('events.totalPhotos')}</span>
<span className="text-sm font-medium">{event.photo_count || 0}</span>
</div>
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">{t('events.totalSize')}</span>
<span className="text-sm font-medium">
{event.total_size ? `${(event.total_size / (1024 * 1024)).toFixed(1)} MB` : '0 MB'}
</span>
</div>
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">{t('events.categories')}</span>
<span className="text-sm font-medium">{categories.length}</span>
</div>
</div>
<div className="mt-4">
<Button
variant="outline"
size="sm"
leftIcon={<Image className="w-4 h-4" />}
onClick={() => setActiveTab('photos')}
className="w-full justify-center"
>
{t('events.managePhotos')}
</Button>
</div>
</Card>
{/* Actions */}
{!event.is_archived && (
@@ -587,47 +763,90 @@ export const EventDetailsPage: React.FC = () => {
)}
</div>
{/* Right Column - Statistics */}
{/* Right Column - Statistics, Theme, and Actions */}
<div className="space-y-6">
{/* Photo Statistics */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.statistics')}</h2>
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.photoStatistics')}</h2>
{stats ? (
<div className="space-y-4">
<div className="text-center p-4 bg-neutral-50 rounded-lg">
<p className="text-3xl font-bold text-neutral-900">{stats.total_photos}</p>
<p className="text-sm text-neutral-500">{t('events.totalPhotos')}</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="text-center p-3 bg-blue-50 rounded-lg">
<Eye className="w-5 h-5 text-blue-600 mx-auto mb-1" />
<p className="text-xl font-semibold text-neutral-900">{stats.total_views}</p>
<p className="text-xs text-neutral-500">{t('events.views')}</p>
</div>
<div className="text-center p-3 bg-purple-50 rounded-lg">
<Download className="w-5 h-5 text-purple-600 mx-auto mb-1" />
<p className="text-xl font-semibold text-neutral-900">{stats.total_downloads}</p>
<p className="text-xs text-neutral-500">{t('events.downloads')}</p>
</div>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<Users className="w-5 h-5 text-green-600 mx-auto mb-1" />
<p className="text-xl font-semibold text-neutral-900">{stats.unique_visitors}</p>
<p className="text-xs text-neutral-500">{t('events.uniqueVisitors')}</p>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">{t('events.totalPhotos')}</span>
<span className="text-sm font-medium">{event.photo_count || 0}</span>
</div>
) : (
<div className="text-center py-8 text-neutral-500">
<p>{t('events.statisticsNotAvailable')}</p>
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">{t('events.totalSize')}</span>
<span className="text-sm font-medium">
{event.total_size ? `${(event.total_size / (1024 * 1024)).toFixed(1)} MB` : '0 MB'}
</span>
</div>
)}
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">{t('events.categories')}</span>
<span className="text-sm font-medium">{categories.length}</span>
</div>
{event.total_views !== undefined && (
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">{t('events.totalViews')}</span>
<span className="text-sm font-medium">{event.total_views || 0}</span>
</div>
)}
{event.total_downloads !== undefined && (
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">{t('events.totalDownloads')}</span>
<span className="text-sm font-medium">{event.total_downloads || 0}</span>
</div>
)}
{event.unique_visitors !== undefined && (
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">{t('events.uniqueVisitors')}</span>
<span className="text-sm font-medium">{event.unique_visitors || 0}</span>
</div>
)}
</div>
<div className="mt-4">
<Button
variant="outline"
size="sm"
leftIcon={<Image className="w-4 h-4" />}
onClick={() => setActiveTab('photos')}
className="w-full justify-center"
>
{t('events.managePhotos')}
</Button>
</div>
</Card>
{/* Gallery Theme */}
<Card padding="md">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-900">{t('events.galleryTheme')}</h2>
{!event.is_archived && (
<Button
variant="outline"
size="sm"
leftIcon={<Palette className="w-4 h-4" />}
onClick={() => setShowThemeEditorModal(true)}
>
{t('events.customizeTheme')}
</Button>
)}
</div>
<ThemeDisplay
theme={event.color_theme || GALLERY_THEME_PRESETS.default.config}
presetName={event.color_theme && !event.color_theme.startsWith('{') ? event.color_theme : undefined}
showDetails={true}
/>
</Card>
{/* Archive Status */}
{event.is_archived && (
{event.is_archived ? (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.archiveStatusTitle')}</h2>
@@ -635,7 +854,7 @@ export const EventDetailsPage: React.FC = () => {
<div>
<p className="text-sm font-medium text-neutral-500">{t('events.archivedOn')}</p>
<p className="text-sm text-neutral-900">
{event.archived_at && format(parseISO(event.archived_at), 'MMM d, yyyy h:mm a')}
{event.archived_at && format(parseISO(event.archived_at), 'PPp')}
</p>
</div>
@@ -660,7 +879,7 @@ export const EventDetailsPage: React.FC = () => {
)}
</div>
</Card>
)}
) : null}
</div>
</div>
)}
@@ -762,7 +981,7 @@ export const EventDetailsPage: React.FC = () => {
<div className="mb-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-2">{t('events.photoCategories')}</h2>
<p className="text-sm text-neutral-600">
{t('events.organizingPhotosInfo')}
{t('events.organizeCategoriesInfo')}
</p>
</div>
@@ -790,6 +1009,17 @@ export const EventDetailsPage: React.FC = () => {
onClose={() => setShowPasswordReset(false)}
/>
)}
{/* Theme Editor Modal */}
{showThemeEditorModal && (
<ThemeEditorModal
isOpen={showThemeEditorModal}
onClose={() => setShowThemeEditorModal(false)}
onSave={handleThemeModalSave}
currentTheme={event.color_theme || 'default'}
eventName={event.event_name}
/>
)}
</div>
);
};
+3 -1
View File
@@ -11,8 +11,9 @@ import {
Download,
Trash2
} from 'lucide-react';
import { format, parseISO, differenceInDays } from 'date-fns';
import { parseISO, differenceInDays } from 'date-fns';
import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../components/common';
import { BulkArchiveModal } from '../../components/admin';
@@ -23,6 +24,7 @@ import { useTranslation } from 'react-i18next';
export const EventsListPage: React.FC = () => {
const { t } = useTranslation();
const { format } = useLocalizedDate();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
+36 -2
View File
@@ -56,7 +56,8 @@ export const SettingsPage: React.FC = () => {
enable_analytics: true,
enable_registration: false,
maintenance_mode: false,
default_language: 'en'
default_language: 'en',
date_format: { format: 'DD/MM/YYYY', locale: 'en-GB' }
});
// Security settings state
@@ -88,7 +89,8 @@ export const SettingsPage: React.FC = () => {
enable_analytics: settings.general_enable_analytics || true,
enable_registration: settings.general_enable_registration || false,
maintenance_mode: settings.general_maintenance_mode || false,
default_language: settings.general_default_language || 'en'
default_language: settings.general_default_language || 'en',
date_format: settings.general_date_format || { format: 'DD/MM/YYYY', locale: 'en-GB' }
});
// Extract security settings
@@ -337,6 +339,38 @@ export const SettingsPage: React.FC = () => {
</p>
</div>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.dateTimeFormat')}</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('settings.general.dateFormat')}
</label>
<select
value={generalSettings.date_format?.format || 'DD/MM/YYYY'}
onChange={(e) => {
const format = e.target.value;
const locale = format === 'MM/DD/YYYY' ? 'en-US' : 'en-GB';
setGeneralSettings(prev => ({
...prev,
date_format: { format, locale }
}));
}}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="DD/MM/YYYY">DD/MM/YYYY (European)</option>
<option value="MM/DD/YYYY">MM/DD/YYYY (US)</option>
<option value="YYYY-MM-DD">YYYY-MM-DD (ISO)</option>
<option value="DD.MM.YYYY">DD.MM.YYYY (German)</option>
</select>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.dateFormatHelp')}
</p>
</div>
</div>
<div className="mt-6">
<Button