refactor(frontend): decompose EventDetailsPage and ThemeCustomizerEnhanced
Move-code split, entry paths/exports unchanged: - EventDetailsPage.tsx (2,697 -> 679) + pages/admin/event-details/* (16 files) - ThemeCustomizerEnhanced.tsx (1,541 -> 349) + admin/theme-customizer/* (11) Known ephemeral-UI delta: widget-local state (copied-link flags, unsaved PIN input, modal selection) now resets when a tab unmounts.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Download } from 'lucide-react';
|
||||
import type { Event } from '../../../types';
|
||||
import { Button, Card } from '../../../components/common';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
import { archiveService } from '../../../services/archive.service';
|
||||
import { safeParseDate } from './utils';
|
||||
|
||||
interface ArchiveStatusCardProps {
|
||||
event: Event;
|
||||
id: string | undefined;
|
||||
}
|
||||
|
||||
export const ArchiveStatusCard: React.FC<ArchiveStatusCardProps> = ({ event, id }) => {
|
||||
const { t } = useTranslation();
|
||||
const { formatDateTime: fmtDateTime } = useLocalizedDate();
|
||||
|
||||
return (
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('events.archiveStatusTitle')}</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.archivedOn')}</p>
|
||||
<p className="text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{event.archived_at && fmtDateTime(safeParseDate(event.archived_at)!)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{event.archive_path && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
toast.info(t('events.downloadingArchive', { name: event.event_name }));
|
||||
await archiveService.downloadArchive(Number(id), `${event.slug}-archive.zip`);
|
||||
toast.success(t('events.downloadStarted'));
|
||||
} catch {
|
||||
toast.error(t('events.failedToDownloadArchive'));
|
||||
}
|
||||
}}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
{t('events.downloadArchive')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '../../../components/common';
|
||||
import { EventCategoryManager } from '../../../components/admin';
|
||||
|
||||
interface CategoriesTabProps {
|
||||
id: string | undefined;
|
||||
}
|
||||
|
||||
export const CategoriesTab: React.FC<CategoriesTabProps> = ({ id }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card padding="md">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('events.photoCategories')}</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('events.organizeCategoriesInfo')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<EventCategoryManager
|
||||
eventId={parseInt(id!)}
|
||||
/>
|
||||
|
||||
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-900/30 rounded-lg">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300">
|
||||
{t('events.categoriesTip')}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Shield, Key, Copy, CheckCircle } from 'lucide-react';
|
||||
import type { Event } from '../../../types';
|
||||
import { Button, Card } from '../../../components/common';
|
||||
import { eventsService } from '../../../services/events.service';
|
||||
|
||||
interface ClientAccessCardProps {
|
||||
event: Event;
|
||||
refetchEvent: () => void;
|
||||
}
|
||||
|
||||
export const ClientAccessCard: React.FC<ClientAccessCardProps> = ({ event, refetchEvent }) => {
|
||||
const { t } = useTranslation();
|
||||
const [copiedClientLink, setCopiedClientLink] = useState(false);
|
||||
const [clientPin, setClientPin] = useState('');
|
||||
|
||||
return (
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
{t('clientAccess.adminTitle')}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||
checked={!!event?.client_access_enabled}
|
||||
onChange={async (e) => {
|
||||
try {
|
||||
await eventsService.updateEvent(event.id, { client_access_enabled: e.target.checked });
|
||||
refetchEvent();
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
}
|
||||
}}
|
||||
disabled={event?.is_archived}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
{t('clientAccess.enableToggle')}
|
||||
</span>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('clientAccess.enableDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{event?.client_access_enabled && (
|
||||
<>
|
||||
{/* Set/Change PIN */}
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('clientAccess.pinLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={clientPin}
|
||||
onChange={(e) => setClientPin(e.target.value)}
|
||||
placeholder={t('clientAccess.pinPlaceholder')}
|
||||
className="w-full px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
leftIcon={<Key className="w-4 h-4" />}
|
||||
onClick={async () => {
|
||||
if (!clientPin.trim()) return;
|
||||
try {
|
||||
await eventsService.updateEvent(event.id, { client_password: clientPin });
|
||||
setClientPin('');
|
||||
toast.success(t('clientAccess.pinUpdated'));
|
||||
refetchEvent();
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
}
|
||||
}}
|
||||
disabled={!clientPin.trim()}
|
||||
>
|
||||
{t('clientAccess.setPin')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Client access link */}
|
||||
{event?.client_share_token && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('clientAccess.linkLabel')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={`${window.location.origin}/gallery/${event.slug}/client-access?token=${event.client_share_token}`}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
leftIcon={copiedClientLink ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
onClick={async () => {
|
||||
const link = `${window.location.origin}/gallery/${event.slug}/client-access?token=${event.client_share_token}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
} catch {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = link;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
setCopiedClientLink(true);
|
||||
setTimeout(() => setCopiedClientLink(false), 2000);
|
||||
}}
|
||||
>
|
||||
{copiedClientLink ? t('events.copied') : t('events.copy')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2 text-xs"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await eventsService.updateEvent(event.id, { regenerate_client_token: true });
|
||||
toast.success(t('clientAccess.tokenRegenerated'));
|
||||
refetchEvent();
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('clientAccess.regenerateToken')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Archive, Send, Copy } from 'lucide-react';
|
||||
import type { Event } from '../../../types';
|
||||
import { Button, Card } from '../../../components/common';
|
||||
|
||||
interface EventActionsCardProps {
|
||||
event: Event;
|
||||
onArchive: () => void;
|
||||
isArchiving: boolean;
|
||||
setShowPublishDialog: (show: boolean) => void;
|
||||
isPublishing: boolean;
|
||||
setShowDuplicateDialog: (show: boolean) => void;
|
||||
isDuplicating: boolean;
|
||||
}
|
||||
|
||||
export const EventActionsCard: React.FC<EventActionsCardProps> = ({
|
||||
event,
|
||||
onArchive,
|
||||
isArchiving,
|
||||
setShowPublishDialog,
|
||||
isPublishing,
|
||||
setShowDuplicateDialog,
|
||||
isDuplicating
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('events.actions')}</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{event.is_draft ? (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<Send className="w-4 h-4" />}
|
||||
onClick={() => setShowPublishDialog(true)}
|
||||
isLoading={isPublishing}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
{t('events.publishAndNotify')}
|
||||
</Button>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 text-center">
|
||||
{t('events.draftBanner')}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<Archive className="w-4 h-4" />}
|
||||
onClick={() => {
|
||||
if (confirm(t('events.archiveConfirm'))) {
|
||||
onArchive();
|
||||
}
|
||||
}}
|
||||
isLoading={isArchiving}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
{t('events.archiveEvent')}
|
||||
</Button>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 text-center">
|
||||
{t('events.archivingInfo')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{/* Duplicate (#626) — visible in both draft and live mode.
|
||||
Creates a new draft inheriting this gallery's config. */}
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<Copy className="w-4 h-4" />}
|
||||
onClick={() => setShowDuplicateDialog(true)}
|
||||
isLoading={isDuplicating}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
{t('events.duplicateEvent', 'Duplicate gallery')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,271 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ExternalLink,
|
||||
Calendar,
|
||||
Archive,
|
||||
Edit2,
|
||||
Save,
|
||||
X,
|
||||
AlertTriangle,
|
||||
MessageSquare,
|
||||
Receipt,
|
||||
Type,
|
||||
Send
|
||||
} from 'lucide-react';
|
||||
import type { Event } from '../../../types';
|
||||
import { Button, Card } from '../../../components/common';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
|
||||
import { eventsService } from '../../../services/events.service';
|
||||
import { buildShareLinkUrl } from '../../../utils/url';
|
||||
import { isGalleryPublic } from '../../../utils/accessControl';
|
||||
import type { FeedbackSettings as FeedbackSettingsType } from '../../../services/feedback.service';
|
||||
import { safeParseDate } from './utils';
|
||||
|
||||
interface EventDetailsHeaderProps {
|
||||
event: Event;
|
||||
id: string | undefined;
|
||||
isEditing: boolean;
|
||||
setIsEditing: (editing: boolean) => void;
|
||||
handleStartEdit: () => void;
|
||||
handleSaveEdit: () => void;
|
||||
isSaving: boolean;
|
||||
feedbackSettings: FeedbackSettingsType;
|
||||
setShowRenameDialog: (show: boolean) => void;
|
||||
setShowPublishDialog: (show: boolean) => void;
|
||||
isPublishing: boolean;
|
||||
onExtendExpiration: (days: number) => void;
|
||||
daysUntilExpiration: number | null;
|
||||
isExpired: boolean;
|
||||
isExpiring: boolean;
|
||||
}
|
||||
|
||||
export const EventDetailsHeader: React.FC<EventDetailsHeaderProps> = ({
|
||||
event,
|
||||
id,
|
||||
isEditing,
|
||||
setIsEditing,
|
||||
handleStartEdit,
|
||||
handleSaveEdit,
|
||||
isSaving,
|
||||
feedbackSettings,
|
||||
setShowRenameDialog,
|
||||
setShowPublishDialog,
|
||||
isPublishing,
|
||||
onExtendExpiration,
|
||||
daysUntilExpiration,
|
||||
isExpired,
|
||||
isExpiring
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const { flags } = useFeatureFlags();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<ArrowLeft className="w-4 h-4" />}
|
||||
onClick={() => navigate('/admin/events')}
|
||||
className="mb-4"
|
||||
>
|
||||
{t('events.backToEvents')}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{event.event_name}</h1>
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{event.event_date && (
|
||||
<span className="flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
{format(safeParseDate(event.event_date)!, 'PPP')}
|
||||
</span>
|
||||
)}
|
||||
<span className="capitalize">{event.event_type}</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
isGalleryPublic(event.require_password)
|
||||
? 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300'
|
||||
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{isGalleryPublic(event.require_password) ? t('events.publicAccess', 'Public access') : t('events.passwordProtected', 'Password protected')}
|
||||
</span>
|
||||
{event.is_draft ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-300">
|
||||
{t('events.draft')}
|
||||
</span>
|
||||
) : null}
|
||||
{event.is_archived ? (
|
||||
<span className="text-neutral-500 dark:text-neutral-400 flex items-center">
|
||||
<Archive className="w-4 h-4 mr-1" />
|
||||
{t('events.archived')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
{!event.is_archived && (
|
||||
<>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<X className="w-4 h-4" />}
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
onClick={handleSaveEdit}
|
||||
isLoading={isSaving}
|
||||
>
|
||||
{t('events.saveChanges')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Edit2 className="w-4 h-4" />}
|
||||
onClick={handleStartEdit}
|
||||
>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Type className="w-4 h-4" />}
|
||||
onClick={() => setShowRenameDialog(true)}
|
||||
>
|
||||
{t('events.rename.button', 'Rename')}
|
||||
</Button>
|
||||
{feedbackSettings?.feedback_enabled && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<MessageSquare className="w-4 h-4" />}
|
||||
onClick={() => navigate(`/admin/events/${id}/feedback`)}
|
||||
>
|
||||
{t('feedback.manage', 'Manage Feedback')}
|
||||
</Button>
|
||||
)}
|
||||
{/* Create a draft invoice for this event — pre-fills the
|
||||
bill editor with the event snapshot + (when exactly
|
||||
one is linked) the customer. Gated on the bills flag. */}
|
||||
{flags.bills && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Receipt className="w-4 h-4" />}
|
||||
onClick={() => {
|
||||
const accts = ((event as { customer_accounts?: Array<{ id: number }> }).customer_accounts) || [];
|
||||
const params = new URLSearchParams({ eventId: String(event.id) });
|
||||
if (event.event_name) params.set('eventName', event.event_name);
|
||||
if (event.event_date) params.set('eventDate', String(event.event_date).slice(0, 10));
|
||||
if (accts.length === 1) params.set('customerAccountId', String(accts[0].id));
|
||||
navigate(`/admin/clients/bills/new?${params.toString()}`);
|
||||
}}
|
||||
>
|
||||
{t('events.createInvoice', 'Create invoice')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{event.share_link && !isEditing && (
|
||||
<a
|
||||
href={event.is_draft
|
||||
? `${buildShareLinkUrl(event.share_link)}${buildShareLinkUrl(event.share_link).includes('?') ? '&' : '?'}preview=${eventsService.getPreviewToken() || ''}`
|
||||
: buildShareLinkUrl(event.share_link)
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-accent hover:opacity-80 border border-accent-dark rounded-lg hover:bg-accent-dark/15 transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t('events.viewGallery')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Draft Banner */}
|
||||
{event.is_draft && !event.is_archived && (
|
||||
<Card className="p-4 mb-6 border-2 border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 flex-shrink-0 text-yellow-600 dark:text-yellow-400" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-yellow-900 dark:text-yellow-200">
|
||||
{t('events.draft')}
|
||||
</p>
|
||||
<p className="text-sm mt-1 text-yellow-700 dark:text-yellow-300">
|
||||
{t('events.draftBanner')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Send className="w-4 h-4" />}
|
||||
onClick={() => setShowPublishDialog(true)}
|
||||
isLoading={isPublishing}
|
||||
>
|
||||
{t('events.publishAndNotify')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Expiration Warning */}
|
||||
{!event.is_archived && (isExpired || isExpiring) && (
|
||||
<Card className={`p-4 mb-6 border-2 ${isExpired ? 'border-red-500 bg-red-50' : 'border-orange-500 bg-orange-50'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className={`w-5 h-5 flex-shrink-0 ${isExpired ? 'text-red-600' : 'text-orange-600'}`} />
|
||||
<div className="flex-1">
|
||||
<p className={`font-medium ${isExpired ? 'text-red-900' : 'text-orange-900'}`}>
|
||||
{isExpired
|
||||
? t('events.eventExpiredMessage')
|
||||
: t('events.eventExpiresIn', { days: daysUntilExpiration })
|
||||
}
|
||||
</p>
|
||||
<p className={`text-sm mt-1 ${isExpired ? 'text-red-700' : 'text-orange-700'}`}>
|
||||
{isExpired
|
||||
? t('events.guestsCannotAccessGallery')
|
||||
: t('events.warningEmailsHaveBeenSent')}
|
||||
</p>
|
||||
</div>
|
||||
{!isExpired && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm(t('events.extendExpiration', { days: 7 }) + '?')) {
|
||||
onExtendExpiration(7);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('events.extendSevenDays')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,900 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
Download,
|
||||
Upload,
|
||||
Image,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Shield,
|
||||
Monitor,
|
||||
Droplets,
|
||||
MousePointer,
|
||||
Layout,
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
import type { Event } from '../../../types';
|
||||
import { Input, Card, Loading, MarkdownContent, LocalizedDateInput } from '../../../components/common';
|
||||
import { HeroPhotoSelector, FocalPointPicker, FeedbackSettings } from '../../../components/admin';
|
||||
import { CustomerAccountPicker } from '../../../components/admin/CustomerAccountPicker';
|
||||
import { api } from '../../../config/api';
|
||||
import { buildResourceUrl } from '../../../utils/url';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
import type { AdminPhoto } from '../../../services/photos.service';
|
||||
import type { FeedbackSettings as FeedbackSettingsType } from '../../../services/feedback.service';
|
||||
import { ExternalFolderPicker } from './ExternalFolderPicker';
|
||||
import { safeParseDate } from './utils';
|
||||
import type { EditFormState } from './types';
|
||||
|
||||
interface EventInformationCardProps {
|
||||
event: Event;
|
||||
id: string | undefined;
|
||||
isEditing: boolean;
|
||||
editForm: EditFormState;
|
||||
setEditForm: React.Dispatch<React.SetStateAction<EditFormState>>;
|
||||
showNewPassword: boolean;
|
||||
setShowNewPassword: (show: boolean) => void;
|
||||
feedbackSettings: FeedbackSettingsType;
|
||||
setFeedbackSettings: React.Dispatch<React.SetStateAction<FeedbackSettingsType>>;
|
||||
categories: Array<{ id: number; name: string; slug: string }>;
|
||||
photos: AdminPhoto[];
|
||||
phoneFieldEnabled: boolean;
|
||||
daysUntilExpiration: number | null;
|
||||
}
|
||||
|
||||
export const EventInformationCard: React.FC<EventInformationCardProps> = ({
|
||||
event,
|
||||
id,
|
||||
isEditing,
|
||||
editForm,
|
||||
setEditForm,
|
||||
showNewPassword,
|
||||
setShowNewPassword,
|
||||
feedbackSettings,
|
||||
setFeedbackSettings,
|
||||
categories,
|
||||
photos,
|
||||
phoneFieldEnabled,
|
||||
daysUntilExpiration
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const queryClient = useQueryClient();
|
||||
const [logoUploading, setLogoUploading] = useState(false);
|
||||
|
||||
const handleEventLogoUpload = async (file: File) => {
|
||||
if (!id) return;
|
||||
setLogoUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('logo', file);
|
||||
await api.post(`/admin/events/${id}/logo`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
toast.success(t('events.eventLogoUploaded', 'Event logo uploaded successfully'));
|
||||
queryClient.invalidateQueries({ queryKey: ['event', id] });
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.error || t('events.eventLogoUploadFailed', 'Failed to upload event logo'));
|
||||
} finally {
|
||||
setLogoUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEventLogoRemove = async () => {
|
||||
if (!id) return;
|
||||
setLogoUploading(true);
|
||||
try {
|
||||
await api.delete(`/admin/events/${id}/logo`);
|
||||
toast.success(t('events.eventLogoRemoved', 'Event logo removed successfully'));
|
||||
queryClient.invalidateQueries({ queryKey: ['event', id] });
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.error || t('events.eventLogoRemoveFailed', 'Failed to remove event logo'));
|
||||
} finally {
|
||||
setLogoUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('events.eventInformation')}</h2>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('events.welcomeMessageLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
value={editForm.welcome_message}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, welcome_message: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
|
||||
rows={3}
|
||||
placeholder={t('events.welcomeMessage')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('events.hostName')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={editForm.customer_name}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, customer_name: e.target.value }))}
|
||||
placeholder={t('events.hostNamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('events.hostEmail')}
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={editForm.customer_email}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, customer_email: e.target.value }))}
|
||||
placeholder={t('events.hostEmailPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{phoneFieldEnabled && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('events.customerPhone', 'Customer Phone')} ({t('common.optional')})
|
||||
</label>
|
||||
<Input
|
||||
type="tel"
|
||||
value={editForm.customer_phone}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, customer_phone: e.target.value }))}
|
||||
placeholder={t('events.customerPhonePlaceholder', '+1 555 555 1234')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Customer accounts (#354). Picker self-hides when the
|
||||
customerPortal feature flag is off. */}
|
||||
<CustomerAccountPicker
|
||||
value={editForm.customer_accounts}
|
||||
onChange={(next) => setEditForm((prev) => ({ ...prev, customer_accounts: next }))}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('events.expirationDate')}
|
||||
</label>
|
||||
<LocalizedDateInput
|
||||
value={editForm.expires_at}
|
||||
onChange={(iso) => setEditForm(prev => ({ ...prev, expires_at: iso }))}
|
||||
min={format(new Date(), 'yyyy-MM-dd')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hero Photo Selection */}
|
||||
<HeroPhotoSelector
|
||||
photos={photos || []}
|
||||
currentHeroPhotoId={editForm.hero_photo_id}
|
||||
onSelect={(photoId) => setEditForm(prev => ({ ...prev, hero_photo_id: photoId }))}
|
||||
isEditing={isEditing}
|
||||
/>
|
||||
|
||||
{/* Per-event social-share opt-in (#474). Toggle is
|
||||
disabled when no hero photo is picked — there's
|
||||
nothing to surface as the cover. The help text
|
||||
deliberately spells out the public-by-design
|
||||
consequence so an admin doesn't flip this on for
|
||||
a sensitive gallery without realising what they're
|
||||
sharing with link-preview crawlers. */}
|
||||
<div className="ml-6 mt-3">
|
||||
<label className={`flex items-start gap-2 cursor-pointer ${editForm.hero_photo_id ? '' : 'opacity-60 cursor-not-allowed'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
|
||||
checked={editForm.og_image_share_enabled === true}
|
||||
disabled={!editForm.hero_photo_id}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, og_image_share_enabled: e.target.checked }))}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('events.ogShare.title', 'Use hero photo as social-share preview')}
|
||||
</span>
|
||||
<span className="block text-xs text-neutral-600 dark:text-neutral-400 mt-0.5">
|
||||
{editForm.hero_photo_id
|
||||
? t('events.ogShare.help', 'When this gallery URL is shared on WhatsApp, Facebook, Slack, etc., the link preview will show the hero photo above. The thumbnail is fetched unauthenticated by link-preview crawlers — anyone with the URL effectively makes this image public. Off by default; pick a hero you are comfortable surfacing publicly before enabling.')
|
||||
: t('events.ogShare.heroRequired', 'Pick a hero photo above first — this option uses it as the WhatsApp / Facebook / Slack preview image.')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Hero Image Focal Point Picker (#162) */}
|
||||
{editForm.hero_photo_id && (() => {
|
||||
const heroPhoto = (photos || []).find((p) => p.id === editForm.hero_photo_id);
|
||||
const heroImageUrl = heroPhoto?.thumbnail_url || heroPhoto?.url;
|
||||
if (!heroImageUrl) return null;
|
||||
return (
|
||||
<div className="ml-6 mt-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('events.heroImageAnchor', 'Hero Image Crop Position')}
|
||||
</label>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">
|
||||
{t('events.heroImageAnchorDescription', 'Click on the image to set the focal point for cropping.')}
|
||||
</p>
|
||||
<FocalPointPicker
|
||||
imageUrl={heroImageUrl}
|
||||
currentValue={editForm.hero_image_anchor}
|
||||
onChange={(value) => setEditForm(prev => ({ ...prev, hero_image_anchor: value }))}
|
||||
slug={event.slug}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div>
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||
checked={editForm.require_password}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
setEditForm(prev => ({
|
||||
...prev,
|
||||
require_password: checked,
|
||||
new_password: checked ? prev.new_password : '',
|
||||
confirm_new_password: checked ? prev.confirm_new_password : '',
|
||||
}));
|
||||
if (!checked) {
|
||||
setShowNewPassword(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('events.requirePasswordToggle')}</span>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('events.requirePasswordToggleHelp', 'Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{!editForm.require_password && (
|
||||
<div className="mt-2 rounded-md border border-orange-200 dark:border-orange-800 bg-orange-50 dark:bg-orange-900/30 p-3 text-xs text-orange-800 dark:text-orange-300">
|
||||
{t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editForm.require_password && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('events.newPasswordLabel', 'New gallery password')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
value={editForm.new_password}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, new_password: e.target.value }))}
|
||||
placeholder={t('events.enterPassword')}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
{showNewPassword ? (
|
||||
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('events.confirmPassword')}
|
||||
</label>
|
||||
<Input
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
value={editForm.confirm_new_password}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, confirm_new_password: e.target.value }))}
|
||||
placeholder={t('events.confirmPasswordPlaceholder')}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('events.sourceMode', 'Source Mode')}
|
||||
</label>
|
||||
<select
|
||||
value={editForm.source_mode}
|
||||
onChange={(e) => {
|
||||
const mode = e.target.value as 'managed' | 'reference';
|
||||
setEditForm(prev => ({
|
||||
...prev,
|
||||
source_mode: mode,
|
||||
external_path: mode === 'reference'
|
||||
? (prev.external_path || event.external_path || '')
|
||||
: ''
|
||||
}));
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
|
||||
>
|
||||
<option value="managed">{t('events.sourceModeManaged', 'Managed (upload to PicPeak)')}</option>
|
||||
<option value="reference">{t('events.sourceModeReference', 'Reference external folder')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('events.sourceModeHelp', 'Use managed mode for direct uploads or reference an external folder that is mounted at /external-media in Docker.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{editForm.source_mode === 'reference' && (
|
||||
<div className="mt-3">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('events.externalFolder', 'External Folder')}
|
||||
</label>
|
||||
<ExternalFolderPicker
|
||||
value={editForm.external_path || ''}
|
||||
onChange={(folder) => setEditForm(prev => ({ ...prev, external_path: folder }))}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('events.externalFolderHint', 'These folders come from the /external-media mount inside the container. Ensure it is accessible to the backend process.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Photo Cap */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('events.photoCap', 'Photo Limit')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.photo_cap}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, photo_cap: parseInt(e.target.value) || 0 }))}
|
||||
min={0}
|
||||
className="w-24 px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
|
||||
/>
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('events.photoCapHelp', 'Maximum number of photos allowed. 0 = unlimited')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default Photo Sort */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('photoSort.defaultSort', 'Default Photo Sort')}
|
||||
</label>
|
||||
<select
|
||||
value={editForm.default_photo_sort}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, default_photo_sort: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
|
||||
>
|
||||
<option value="upload_date_desc">{t('photoSort.uploadDateNewest', 'Upload Date (Newest First)')}</option>
|
||||
<option value="upload_date_asc">{t('photoSort.uploadDateOldest', 'Upload Date (Oldest First)')}</option>
|
||||
<option value="capture_date_desc">{t('photoSort.captureDateNewest', 'Date Taken (Newest First)')}</option>
|
||||
<option value="capture_date_asc">{t('photoSort.captureDateOldest', 'Date Taken (Oldest First)')}</option>
|
||||
<option value="filename_asc">{t('photoSort.filenameAZ', 'Filename (A-Z)')}</option>
|
||||
<option value="filename_desc">{t('photoSort.filenameZA', 'Filename (Z-A)')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.allow_user_uploads}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, allow_user_uploads: e.target.checked }))}
|
||||
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">{t('events.allowUserUploads')}</span>
|
||||
</label>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1 ml-6">
|
||||
{t('events.allowUserUploadsHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{editForm.allow_user_uploads && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('events.uploadCategory')}
|
||||
</label>
|
||||
<select
|
||||
value={editForm.upload_category_id || ''}
|
||||
onChange={(e) => setEditForm(prev => ({
|
||||
...prev,
|
||||
upload_category_id: e.target.value ? parseInt(e.target.value) : null
|
||||
}))}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
|
||||
>
|
||||
<option value="">{t('events.selectCategory')}</option>
|
||||
{categories?.map(category => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('events.uploadCategoryHelp')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feedback Settings */}
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('feedback.settings.title', 'Guest Feedback Settings')}</h3>
|
||||
<FeedbackSettings
|
||||
settings={feedbackSettings}
|
||||
onChange={setFeedbackSettings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Promotional Banner Override (#440) — three-way: inherit / custom / off */}
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3">
|
||||
{t('events.promoBanner.title', 'Promotional Banner')}
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
|
||||
{t('events.promoBanner.help', 'Choose how this gallery handles the promotional banner. "Inherit" uses your global default; "Custom" overrides it for this event; "Off" hides it entirely.')}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{(['inherit', 'custom', 'off'] as const).map((mode) => (
|
||||
<label key={mode} className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="promo_mode"
|
||||
value={mode}
|
||||
checked={editForm.promo_mode === mode}
|
||||
onChange={() => setEditForm(prev => ({ ...prev, promo_mode: mode }))}
|
||||
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{t(`events.promoBanner.mode_${mode}`, mode === 'inherit' ? 'Inherit global default' : mode === 'custom' ? 'Custom override for this event' : 'Off (hide for this event)')}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{editForm.promo_mode === 'custom' && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<textarea
|
||||
value={editForm.promo_markdown}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, promo_markdown: e.target.value }))}
|
||||
rows={5}
|
||||
placeholder={t('events.promoBanner.placeholder', 'Markdown content (e.g. **Special offer:** [book your next session](https://example.com))')}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark font-mono text-sm"
|
||||
/>
|
||||
{editForm.promo_markdown.trim() && (
|
||||
<div className="border border-neutral-200 dark:border-neutral-700 rounded-lg p-3 bg-neutral-50 dark:bg-neutral-900">
|
||||
<p className="text-xs uppercase tracking-wide text-neutral-500 dark:text-neutral-400 mb-2">
|
||||
{t('events.promoBanner.preview', 'Preview')}
|
||||
</p>
|
||||
<MarkdownContent source={editForm.promo_markdown} className="text-sm text-neutral-800 dark:text-neutral-200 prose-sm prose-a:text-primary-600 dark:prose-a:text-primary-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Download Protection Settings */}
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3 flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-accent" />
|
||||
{t('events.downloadProtection', 'Download Protection')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.allow_downloads}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, allow_downloads: e.target.checked }))}
|
||||
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Download className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.allowDownloads', 'Allow photo downloads')}</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.disable_right_click}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, disable_right_click: e.target.checked }))}
|
||||
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<MousePointer className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.disableRightClick', 'Block right-click menu')}</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.watermark_downloads}
|
||||
onChange={(e) => setEditForm(prev => ({
|
||||
...prev,
|
||||
watermark_downloads: e.target.checked,
|
||||
// Watermarking and presigned URLs are mutually
|
||||
// exclusive — presigned URLs serve raw bytes from
|
||||
// S3 without going through the watermark pipeline.
|
||||
allow_presigned_download: e.target.checked ? false : prev.allow_presigned_download,
|
||||
}))}
|
||||
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Droplets className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.watermarkDownloads', 'Add watermark to downloads')}</span>
|
||||
</label>
|
||||
|
||||
<label
|
||||
className={`flex items-center ${editForm.watermark_downloads ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
title={editForm.watermark_downloads
|
||||
? 'Disabled while watermarks are on — presigned URLs bypass the watermark pipeline.'
|
||||
: 'When the backend uses STORAGE_BACKEND=s3, "Download All" returns a 5-minute presigned S3 URL instead of streaming through the backend. Saves bandwidth on huge galleries; bypasses watermarking.'
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!editForm.allow_presigned_download}
|
||||
disabled={editForm.watermark_downloads}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, allow_presigned_download: e.target.checked }))}
|
||||
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Download className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{t('events.allowPresignedDownload', 'Allow direct S3 download (no watermark, S3 mode only)')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.enable_devtools_protection}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, enable_devtools_protection: e.target.checked }))}
|
||||
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Monitor className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.enableDevtoolsProtection', 'Detect developer tools')}</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.use_canvas_rendering}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, use_canvas_rendering: e.target.checked }))}
|
||||
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Image className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.useCanvasRendering', 'Canvas rendering (advanced protection)')}</span>
|
||||
</label>
|
||||
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-2">
|
||||
{t('events.protectionInfo', 'Protection features help prevent unauthorized downloads but cannot block all methods.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero Logo Settings */}
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3 flex items-center gap-2">
|
||||
<Layout className="w-4 h-4 text-accent" />
|
||||
{t('events.heroLogoSettings', 'Hero Logo Settings')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.hero_logo_visible}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_visible: e.target.checked }))}
|
||||
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Image className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.heroLogoVisible', 'Display logo in hero section')}</span>
|
||||
</label>
|
||||
|
||||
{editForm.hero_logo_visible && (
|
||||
<>
|
||||
<div className="ml-6">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('events.heroLogoSize', 'Logo Size')}
|
||||
</label>
|
||||
<select
|
||||
value={editForm.hero_logo_size}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_size: e.target.value as 'small' | 'medium' | 'large' | 'xlarge' }))}
|
||||
className="w-full sm:w-48 px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md shadow-sm focus:ring-primary-500 focus:border-accent-dark text-sm"
|
||||
>
|
||||
<option value="small">{t('events.heroLogoSizeSmall', 'Small')}</option>
|
||||
<option value="medium">{t('events.heroLogoSizeMedium', 'Medium')}</option>
|
||||
<option value="large">{t('events.heroLogoSizeLarge', 'Large')}</option>
|
||||
<option value="xlarge">{t('events.heroLogoSizeXLarge', 'Extra Large')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="ml-6">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('events.heroLogoPosition', 'Logo Position')}
|
||||
</label>
|
||||
<select
|
||||
value={editForm.hero_logo_position}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_position: e.target.value as 'top' | 'center' | 'bottom' }))}
|
||||
className="w-full sm:w-48 px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md shadow-sm focus:ring-primary-500 focus:border-accent-dark text-sm"
|
||||
>
|
||||
<option value="top">{t('events.heroLogoPositionTop', 'Top (above title)')}</option>
|
||||
<option value="center">{t('events.heroLogoPositionCenter', 'Center (between title and dates)')}</option>
|
||||
<option value="bottom">{t('events.heroLogoPositionBottom', 'Bottom (below dates)')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Custom Event Logo Upload */}
|
||||
<div className="ml-6 mt-3 pt-3 border-t border-neutral-100 dark:border-neutral-700">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('events.eventCustomLogo', 'Custom Event Logo')}
|
||||
</label>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">
|
||||
{t('events.eventCustomLogoDescription', 'Upload a custom logo for this event. This overrides the global branding logo for this gallery only.')}
|
||||
</p>
|
||||
|
||||
{event.hero_logo_url ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-16 h-16 border border-neutral-200 dark:border-neutral-600 rounded-md flex items-center justify-center bg-neutral-50 dark:bg-neutral-700 overflow-hidden">
|
||||
<img
|
||||
src={buildResourceUrl(event.hero_logo_url)}
|
||||
alt={t('events.eventCustomLogo', 'Custom Event Logo')}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="cursor-pointer inline-flex items-center gap-1 text-xs text-accent hover:opacity-80">
|
||||
<Upload className="w-3 h-3" />
|
||||
{t('events.replaceLogo', 'Replace')}
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/png,image/jpeg,image/gif,image/svg+xml"
|
||||
disabled={logoUploading}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleEventLogoUpload(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEventLogoRemove}
|
||||
disabled={logoUploading}
|
||||
className="inline-flex items-center gap-1 text-xs text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{t('events.removeLogo', 'Remove')}
|
||||
</button>
|
||||
</div>
|
||||
{logoUploading && <Loading size="sm" />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className={`cursor-pointer inline-flex items-center gap-2 px-3 py-1.5 text-xs font-medium border border-neutral-300 dark:border-neutral-600 text-neutral-700 dark:text-neutral-300 rounded-md hover:bg-neutral-50 dark:hover:bg-neutral-700 ${logoUploading ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
{t('events.uploadEventLogo', 'Upload Logo')}
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/png,image/jpeg,image/gif,image/svg+xml"
|
||||
disabled={logoUploading}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleEventLogoUpload(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{logoUploading && <Loading size="sm" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-2">
|
||||
{t('events.heroLogoInfo', 'These settings apply when the gallery uses the Hero layout. You can hide the logo or customize its size and position.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<dl className="space-y-4">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.sourceMode', 'Source Mode')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{event.source_mode === 'reference' ? t('events.sourceModeReference', 'Reference external folder') : t('events.sourceModeManaged', 'Managed (upload to PicPeak)')}
|
||||
{event.source_mode === 'reference' && event.external_path ? (
|
||||
<span className="text-neutral-500 dark:text-neutral-400 ml-2">/external-media/{event.external_path}</span>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.welcomeMessage')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{event.welcome_message || <span className="text-neutral-400">{t('events.noWelcomeMessageSet')}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.hostName')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{event.customer_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 dark:text-neutral-400">{t('events.hostEmail')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">{event.customer_email}</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.adminEmail')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">{event.admin_email}</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{phoneFieldEnabled && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">
|
||||
{t('events.customerPhone', 'Customer Phone')}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{event.customer_phone || (
|
||||
<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 dark:text-neutral-400">{t('events.created')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{event.created_at && format(safeParseDate(event.created_at)!, 'PP')}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.expires')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{event.expires_at ? (
|
||||
<>
|
||||
{format(safeParseDate(event.expires_at)!, 'PP')}
|
||||
{!event.is_archived && daysUntilExpiration !== null && daysUntilExpiration > 0 && (
|
||||
<span className="text-neutral-500 dark:text-neutral-400 ml-1">
|
||||
{t('events.daysLeft', { count: daysUntilExpiration })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-neutral-500 dark:text-neutral-400">{t('events.neverExpires', 'Never')}</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.heroPhoto')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{event.hero_photo_id ? (
|
||||
<span className="text-accent">{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 dark:text-neutral-400">{t('events.userUploads')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{event.allow_user_uploads ? (
|
||||
<div className="space-y-1">
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300 bg-green-100 dark:bg-green-900/40 rounded">
|
||||
{t('common.yes')}
|
||||
</span>
|
||||
{event.upload_category_id && (
|
||||
<p className="text-xs text-neutral-600 dark:text-neutral-400">
|
||||
{t('events.uploadCategory')}: {categories.find(c => c.id === event.upload_category_id)?.name || 'N/A'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-700 rounded">
|
||||
{t('common.no')}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{/* Download Protection Display */}
|
||||
<div className="pt-3 mt-3 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400 flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
{t('events.downloadProtection', 'Download Protection')}
|
||||
</dt>
|
||||
<dd className="mt-2 text-sm text-neutral-900 dark:text-neutral-100">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className={`inline-flex items-center px-2 py-1 text-xs font-medium rounded ${
|
||||
event.protection_level === 'maximum' ? 'bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300' :
|
||||
event.protection_level === 'enhanced' ? 'bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300' :
|
||||
event.protection_level === 'standard' ? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300' :
|
||||
'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300'
|
||||
}`}>
|
||||
{event.protection_level || 'standard'}
|
||||
</span>
|
||||
{event.disable_right_click && (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded">
|
||||
<MousePointer className="w-3 h-3 mr-1" />
|
||||
{t('events.rightClickBlocked', 'Right-click blocked')}
|
||||
</span>
|
||||
)}
|
||||
{event.enable_devtools_protection && (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded">
|
||||
<Monitor className="w-3 h-3 mr-1" />
|
||||
{t('events.devtoolsDetection', 'DevTools detection')}
|
||||
</span>
|
||||
)}
|
||||
{!event.allow_downloads && (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 rounded">
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
{t('events.downloadsDisabled', 'Downloads disabled')}
|
||||
</span>
|
||||
)}
|
||||
{event.watermark_downloads && (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded">
|
||||
<Droplets className="w-3 h-3 mr-1" />
|
||||
{t('events.watermarked', 'Watermarked')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{/* Hero Logo Settings Display */}
|
||||
<div className="pt-3 mt-3 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400 flex items-center gap-2">
|
||||
<Layout className="w-4 h-4" />
|
||||
{t('events.heroLogoSettings', 'Hero Logo Settings')}
|
||||
</dt>
|
||||
<dd className="mt-2 text-sm text-neutral-900 dark:text-neutral-100">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{event.hero_logo_visible !== false ? (
|
||||
<>
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300 rounded">
|
||||
<Image className="w-3 h-3 mr-1" />
|
||||
{t('events.heroLogoVisibleLabel', 'Logo visible')}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded">
|
||||
{t('events.heroLogoSizeLabel', 'Size')}: {event.hero_logo_size || 'medium'}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded">
|
||||
{t('events.heroLogoPositionLabel', 'Position')}: {event.hero_logo_position || 'top'}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded">
|
||||
<Image className="w-3 h-3 mr-1" />
|
||||
{t('events.heroLogoHidden', 'Logo hidden')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Image } from 'lucide-react';
|
||||
import type { Event } from '../../../types';
|
||||
import type { FeedbackSettings as FeedbackSettingsType } from '../../../services/feedback.service';
|
||||
import type { EventDetailsTab } from './types';
|
||||
|
||||
interface EventTabsProps {
|
||||
event: Event;
|
||||
eventFeedbackSettings: FeedbackSettingsType | undefined;
|
||||
activeTab: EventDetailsTab;
|
||||
setActiveTab: (tab: EventDetailsTab) => void;
|
||||
}
|
||||
|
||||
export const EventTabs: React.FC<EventTabsProps> = ({
|
||||
event,
|
||||
eventFeedbackSettings,
|
||||
activeTab,
|
||||
setActiveTab
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="mb-6 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'overview'
|
||||
? 'border-accent text-accent'
|
||||
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||
}`}
|
||||
>
|
||||
{t('events.overview')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('photos')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
|
||||
activeTab === 'photos'
|
||||
? 'border-accent text-accent'
|
||||
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||
}`}
|
||||
>
|
||||
<Image className="w-4 h-4" />
|
||||
<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 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-full">
|
||||
{event.photo_count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('categories')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'categories'
|
||||
? 'border-accent text-accent'
|
||||
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||
}`}
|
||||
>
|
||||
{t('events.categories')}
|
||||
</button>
|
||||
{eventFeedbackSettings?.identity_mode === 'guest' && (
|
||||
<button
|
||||
onClick={() => setActiveTab('guests')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'guests'
|
||||
? 'border-accent text-accent'
|
||||
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||
}`}
|
||||
>
|
||||
{t('admin.events.tabs.guests', 'Guests')}
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Event } from '../../../types';
|
||||
import { Card } from '../../../components/common';
|
||||
import { ThemeCustomizerEnhanced, ThemeDisplay } from '../../../components/admin';
|
||||
import { usePublicSettings } from '../../../hooks/usePublicSettings';
|
||||
import type { EnabledTemplate } from '../../../services/cssTemplates.service';
|
||||
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../../types/theme.types';
|
||||
import type { EditFormState } from './types';
|
||||
|
||||
interface EventThemeSectionProps {
|
||||
event: Event;
|
||||
isEditing: boolean;
|
||||
editForm: EditFormState;
|
||||
setEditForm: React.Dispatch<React.SetStateAction<EditFormState>>;
|
||||
currentTheme: ThemeConfig | null;
|
||||
setCurrentTheme: (theme: ThemeConfig | null) => void;
|
||||
currentPresetName: string;
|
||||
setCurrentPresetName: (name: string) => void;
|
||||
setThemeChanged: (changed: boolean) => void;
|
||||
cssTemplates: EnabledTemplate[];
|
||||
}
|
||||
|
||||
export const EventThemeSection: React.FC<EventThemeSectionProps> = ({
|
||||
event,
|
||||
isEditing,
|
||||
editForm,
|
||||
setEditForm,
|
||||
currentTheme,
|
||||
setCurrentTheme,
|
||||
currentPresetName,
|
||||
setCurrentPresetName,
|
||||
setThemeChanged,
|
||||
cssTemplates
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: publicSettings } = usePublicSettings();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Theme & Style */}
|
||||
{isEditing && !event.is_archived && (
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('branding.themeAndStyle')}</h2>
|
||||
<ThemeCustomizerEnhanced
|
||||
value={currentTheme || GALLERY_THEME_PRESETS.default.config}
|
||||
forceColorMode={publicSettings?.branding_force_color_mode ?? null}
|
||||
onChange={(theme) => {
|
||||
setCurrentTheme(theme);
|
||||
setEditForm(prev => ({ ...prev, color_theme: JSON.stringify(theme) }));
|
||||
setThemeChanged(true);
|
||||
}}
|
||||
presetName={currentPresetName}
|
||||
onPresetChange={(presetName) => {
|
||||
setCurrentPresetName(presetName);
|
||||
setThemeChanged(true);
|
||||
if (presetName !== 'custom') {
|
||||
const preset = GALLERY_THEME_PRESETS[presetName];
|
||||
if (preset) {
|
||||
setCurrentTheme(preset.config);
|
||||
setEditForm(prev => ({ ...prev, color_theme: presetName }));
|
||||
}
|
||||
}
|
||||
}}
|
||||
onSyncFromBranding={() => {
|
||||
// Reset only the 8 colour tokens to the site Branding —
|
||||
// layout, header, typography all stay so the admin doesn't
|
||||
// lose tweaks made for this specific event.
|
||||
const branding = publicSettings?.theme_config as ThemeConfig | undefined;
|
||||
if (!branding) {
|
||||
toast.error(t('toast.brandingThemeMissing', 'No branding theme has been saved yet.'));
|
||||
return;
|
||||
}
|
||||
const base = currentTheme || GALLERY_THEME_PRESETS.default.config;
|
||||
const merged: ThemeConfig = {
|
||||
...base,
|
||||
primaryColor: branding.primaryColor,
|
||||
accentColor: branding.accentColor,
|
||||
accentDarkColor: branding.accentDarkColor,
|
||||
backgroundColor: branding.backgroundColor,
|
||||
surfaceColor: branding.surfaceColor,
|
||||
elevatedColor: branding.elevatedColor,
|
||||
surfaceBorderColor: branding.surfaceBorderColor,
|
||||
textColor: branding.textColor,
|
||||
mutedTextColor: branding.mutedTextColor,
|
||||
colorMode: branding.colorMode ?? base.colorMode,
|
||||
};
|
||||
setCurrentTheme(merged);
|
||||
setCurrentPresetName('custom');
|
||||
setEditForm(prev => ({ ...prev, color_theme: JSON.stringify(merged) }));
|
||||
setThemeChanged(true);
|
||||
toast.success(t('toast.brandingPaletteSynced', 'Palette synced from Branding.'));
|
||||
}}
|
||||
isPreviewMode={true}
|
||||
showGalleryLayouts={true}
|
||||
hideActions={true}
|
||||
cssTemplates={cssTemplates}
|
||||
cssTemplateId={editForm.css_template_id}
|
||||
onCssTemplateChange={(templateId) => setEditForm(prev => ({ ...prev, css_template_id: templateId }))}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Theme Display (when not editing) */}
|
||||
{!isEditing && !event.is_archived && (
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('events.galleryTheme')}</h2>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FolderTreeNode } from './FolderTreeNode';
|
||||
|
||||
export const ExternalFolderPicker: React.FC<{ value: string; onChange: (p: string) => void }> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Seed expanded paths so the current selection (and the synthetic root) is visible on mount.
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => {
|
||||
const set = new Set<string>(['']);
|
||||
if (value) {
|
||||
const parts = value.split('/').filter(Boolean);
|
||||
let acc = '';
|
||||
for (const seg of parts) {
|
||||
acc = acc ? `${acc}/${seg}` : seg;
|
||||
set.add(acc);
|
||||
}
|
||||
}
|
||||
return set;
|
||||
});
|
||||
|
||||
const toggleExpand = (p: string) => {
|
||||
setExpandedPaths(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(p)) next.delete(p);
|
||||
else next.add(p);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2 border border-neutral-200 dark:border-neutral-700 rounded-lg p-2">
|
||||
<div className="flex items-center justify-between gap-2 mb-2 px-1">
|
||||
<div className="text-xs text-neutral-600 dark:text-neutral-400 truncate">
|
||||
{t('common.selected', 'Selected')}: /external-media/{value}
|
||||
</div>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs underline text-neutral-700 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-neutral-100 flex-shrink-0"
|
||||
onClick={() => onChange('')}
|
||||
>
|
||||
{t('events.clearSelection', 'Clear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-80 overflow-auto [color-scheme:light] dark:[color-scheme:dark]">
|
||||
<FolderTreeNode
|
||||
path=""
|
||||
name="/external-media"
|
||||
depth={0}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
expandedPaths={expandedPaths}
|
||||
toggleExpand={toggleExpand}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
import { ChevronRight, ChevronDown, Folder, FolderOpen, Loader2 } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { externalMediaService } from '../../../services/externalMedia.service';
|
||||
|
||||
export const FolderTreeNode: React.FC<{
|
||||
path: string;
|
||||
name: string;
|
||||
depth: number;
|
||||
value: string;
|
||||
onChange: (p: string) => void;
|
||||
expandedPaths: Set<string>;
|
||||
toggleExpand: (p: string) => void;
|
||||
}> = ({ path, name, depth, value, onChange, expandedPaths, toggleExpand }) => {
|
||||
const { t } = useTranslation();
|
||||
const isExpanded = expandedPaths.has(path);
|
||||
const isSelected = value === path;
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['external-folder-children', path],
|
||||
queryFn: () => externalMediaService.list(path),
|
||||
enabled: isExpanded,
|
||||
staleTime: 30_000
|
||||
});
|
||||
|
||||
const dirs = (data?.entries || []).filter(e => e.type === 'dir');
|
||||
const showEmpty = isExpanded && !isLoading && !isError && dirs.length === 0;
|
||||
const indentStyle = { paddingLeft: depth * 16 + 4 };
|
||||
const childIndentStyle = { paddingLeft: (depth + 1) * 16 + 4 };
|
||||
const rowClass =
|
||||
'flex items-center gap-1 py-1 pr-1 rounded ' +
|
||||
(isSelected
|
||||
? 'bg-accent-dark/15'
|
||||
: 'hover:bg-neutral-50 dark:hover:bg-neutral-700');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={rowClass} style={indentStyle}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(path)}
|
||||
className="p-0.5 text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200"
|
||||
aria-label={isExpanded ? t('common.collapse', 'Collapse') : t('common.expand', 'Expand')}
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(path)}
|
||||
className={
|
||||
'flex items-center gap-1.5 flex-1 min-w-0 text-left text-sm ' +
|
||||
(isSelected
|
||||
? 'text-accent-dark font-medium'
|
||||
: 'text-neutral-900 dark:text-neutral-100')
|
||||
}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<FolderOpen className="w-4 h-4 flex-shrink-0 text-accent" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 flex-shrink-0 text-neutral-500" />
|
||||
)}
|
||||
<span className="truncate">{name}</span>
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{isLoading && (
|
||||
<div
|
||||
className="flex items-center gap-2 py-1 text-xs text-neutral-500 dark:text-neutral-400"
|
||||
style={childIndentStyle}
|
||||
>
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<span>{t('common.loading', 'Loading...')}</span>
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<div
|
||||
className="py-1 text-xs text-red-600 dark:text-red-400"
|
||||
style={childIndentStyle}
|
||||
>
|
||||
{t('errors.somethingWentWrong', 'Something went wrong')}
|
||||
</div>
|
||||
)}
|
||||
{showEmpty && (
|
||||
<div
|
||||
className="py-1 text-xs italic text-neutral-500 dark:text-neutral-400"
|
||||
style={childIndentStyle}
|
||||
>
|
||||
{t('events.externalFolderEmpty', 'No subfolders')}
|
||||
</div>
|
||||
)}
|
||||
{dirs.map(d => {
|
||||
const childPath = path ? `${path}/${d.name}` : d.name;
|
||||
return (
|
||||
<FolderTreeNode
|
||||
key={childPath}
|
||||
path={childPath}
|
||||
name={d.name}
|
||||
depth={depth + 1}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
expandedPaths={expandedPaths}
|
||||
toggleExpand={toggleExpand}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
import React from 'react';
|
||||
import type { Event } from '../../../types';
|
||||
import { FeedbackModerationPanel } from '../../../components/admin';
|
||||
import { EventReminderOverrideCard } from '../../../components/admin/EventReminderOverrideCard';
|
||||
import { SlideshowSettingsCard } from '../../../components/admin/SlideshowSettingsCard';
|
||||
import { ShortUrlsCard } from '../../../components/admin/ShortUrlsCard';
|
||||
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
|
||||
import type { AdminPhoto } from '../../../services/photos.service';
|
||||
import type { FeedbackSettings as FeedbackSettingsType } from '../../../services/feedback.service';
|
||||
import type { EnabledTemplate } from '../../../services/cssTemplates.service';
|
||||
import { ThemeConfig } from '../../../types/theme.types';
|
||||
import type { EditFormState, EventDetailsTab } from './types';
|
||||
import { EventInformationCard } from './EventInformationCard';
|
||||
import { ShareLinkCard } from './ShareLinkCard';
|
||||
import { ClientAccessCard } from './ClientAccessCard';
|
||||
import { EventActionsCard } from './EventActionsCard';
|
||||
import { PhotoStatisticsCard } from './PhotoStatisticsCard';
|
||||
import { EventThemeSection } from './EventThemeSection';
|
||||
import { ArchiveStatusCard } from './ArchiveStatusCard';
|
||||
|
||||
interface OverviewTabProps {
|
||||
event: Event;
|
||||
id: string | undefined;
|
||||
isEditing: boolean;
|
||||
editForm: EditFormState;
|
||||
setEditForm: React.Dispatch<React.SetStateAction<EditFormState>>;
|
||||
showNewPassword: boolean;
|
||||
setShowNewPassword: (show: boolean) => void;
|
||||
feedbackSettings: FeedbackSettingsType;
|
||||
setFeedbackSettings: React.Dispatch<React.SetStateAction<FeedbackSettingsType>>;
|
||||
categories: Array<{ id: number; name: string; slug: string }>;
|
||||
photos: AdminPhoto[];
|
||||
phoneFieldEnabled: boolean;
|
||||
daysUntilExpiration: number | null;
|
||||
refetchEvent: () => void;
|
||||
setActiveTab: (tab: EventDetailsTab) => void;
|
||||
setShowPasswordReset: (show: boolean) => void;
|
||||
setShowPublishDialog: (show: boolean) => void;
|
||||
setShowDuplicateDialog: (show: boolean) => void;
|
||||
onArchive: () => void;
|
||||
isArchiving: boolean;
|
||||
isPublishing: boolean;
|
||||
isDuplicating: boolean;
|
||||
currentTheme: ThemeConfig | null;
|
||||
setCurrentTheme: (theme: ThemeConfig | null) => void;
|
||||
currentPresetName: string;
|
||||
setCurrentPresetName: (name: string) => void;
|
||||
setThemeChanged: (changed: boolean) => void;
|
||||
cssTemplates: EnabledTemplate[];
|
||||
}
|
||||
|
||||
export const OverviewTab: React.FC<OverviewTabProps> = ({
|
||||
event,
|
||||
id,
|
||||
isEditing,
|
||||
editForm,
|
||||
setEditForm,
|
||||
showNewPassword,
|
||||
setShowNewPassword,
|
||||
feedbackSettings,
|
||||
setFeedbackSettings,
|
||||
categories,
|
||||
photos,
|
||||
phoneFieldEnabled,
|
||||
daysUntilExpiration,
|
||||
refetchEvent,
|
||||
setActiveTab,
|
||||
setShowPasswordReset,
|
||||
setShowPublishDialog,
|
||||
setShowDuplicateDialog,
|
||||
onArchive,
|
||||
isArchiving,
|
||||
isPublishing,
|
||||
isDuplicating,
|
||||
currentTheme,
|
||||
setCurrentTheme,
|
||||
currentPresetName,
|
||||
setCurrentPresetName,
|
||||
setThemeChanged,
|
||||
cssTemplates
|
||||
}) => {
|
||||
const { flags } = useFeatureFlags();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
{/* Left Column - Main Details */}
|
||||
<div className="space-y-6">
|
||||
{/* Event Information */}
|
||||
<EventInformationCard
|
||||
event={event}
|
||||
id={id}
|
||||
isEditing={isEditing}
|
||||
editForm={editForm}
|
||||
setEditForm={setEditForm}
|
||||
showNewPassword={showNewPassword}
|
||||
setShowNewPassword={setShowNewPassword}
|
||||
feedbackSettings={feedbackSettings}
|
||||
setFeedbackSettings={setFeedbackSettings}
|
||||
categories={categories}
|
||||
photos={photos}
|
||||
phoneFieldEnabled={phoneFieldEnabled}
|
||||
daysUntilExpiration={daysUntilExpiration}
|
||||
/>
|
||||
|
||||
{/* Share Link */}
|
||||
<ShareLinkCard event={event} setShowPasswordReset={setShowPasswordReset} />
|
||||
|
||||
{/* Branded short URLs (#699). Sits between the canonical share-link
|
||||
card and the Client Access card — same "things you share with
|
||||
the customer" cluster. */}
|
||||
<ShortUrlsCard eventId={event.id} />
|
||||
|
||||
{/* Client Access (#172) */}
|
||||
<ClientAccessCard event={event} refetchEvent={refetchEvent} />
|
||||
|
||||
{/* Live Slideshow ("Diashow") link + live display settings (migrations 138/139).
|
||||
Gated behind the `slideshow` feature flag. */}
|
||||
{flags.slideshow && (
|
||||
<SlideshowSettingsCard
|
||||
eventId={event.id}
|
||||
slug={event.slug}
|
||||
isArchived={event.is_archived}
|
||||
initial={{
|
||||
show_share_token: event.show_share_token,
|
||||
show_interval_ms: event.show_interval_ms,
|
||||
show_transition: event.show_transition,
|
||||
show_transition_ms: event.show_transition_ms,
|
||||
show_watermark: event.show_watermark,
|
||||
show_colorfilter: event.show_colorfilter,
|
||||
}}
|
||||
onChanged={() => refetchEvent()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pre-event reminder override (migration 143). Hidden when
|
||||
the reminderEmails master flag is off — the override here
|
||||
would never fire since the cron itself no-ops. */}
|
||||
{flags.reminderEmails && (
|
||||
<EventReminderOverrideCard
|
||||
eventId={event.id}
|
||||
initial={{
|
||||
event_reminder_disabled: event.event_reminder_disabled,
|
||||
event_reminder_offset_days: event.event_reminder_offset_days,
|
||||
event_reminder_body_override: event.event_reminder_body_override,
|
||||
}}
|
||||
onSaved={() => refetchEvent()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{!event.is_archived && (
|
||||
<EventActionsCard
|
||||
event={event}
|
||||
onArchive={onArchive}
|
||||
isArchiving={isArchiving}
|
||||
setShowPublishDialog={setShowPublishDialog}
|
||||
isPublishing={isPublishing}
|
||||
setShowDuplicateDialog={setShowDuplicateDialog}
|
||||
isDuplicating={isDuplicating}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column - Statistics, Theme, and Actions */}
|
||||
<div className="space-y-6">
|
||||
{/* Photo Statistics */}
|
||||
<PhotoStatisticsCard event={event} categories={categories} setActiveTab={setActiveTab} />
|
||||
|
||||
{/* Theme & Style / Theme Display */}
|
||||
<EventThemeSection
|
||||
event={event}
|
||||
isEditing={isEditing}
|
||||
editForm={editForm}
|
||||
setEditForm={setEditForm}
|
||||
currentTheme={currentTheme}
|
||||
setCurrentTheme={setCurrentTheme}
|
||||
currentPresetName={currentPresetName}
|
||||
setCurrentPresetName={setCurrentPresetName}
|
||||
setThemeChanged={setThemeChanged}
|
||||
cssTemplates={cssTemplates}
|
||||
/>
|
||||
|
||||
{/* Feedback Moderation Panel */}
|
||||
{!event.is_archived && feedbackSettings?.feedback_enabled && (
|
||||
<FeedbackModerationPanel
|
||||
eventId={parseInt(id!)}
|
||||
compact={true}
|
||||
maxItems={3}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Archive Status */}
|
||||
{event.is_archived ? (
|
||||
<ArchiveStatusCard event={event} id={id} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Image } from 'lucide-react';
|
||||
import type { Event } from '../../../types';
|
||||
import { Button, Card } from '../../../components/common';
|
||||
import type { EventDetailsTab } from './types';
|
||||
|
||||
interface PhotoStatisticsCardProps {
|
||||
event: Event;
|
||||
categories: Array<{ id: number; name: string; slug: string }>;
|
||||
setActiveTab: (tab: EventDetailsTab) => void;
|
||||
}
|
||||
|
||||
export const PhotoStatisticsCard: React.FC<PhotoStatisticsCardProps> = ({
|
||||
event,
|
||||
categories,
|
||||
setActiveTab
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 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 dark:bg-neutral-700 rounded-lg">
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.totalPhotos')}</span>
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{event.photo_count || 0}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 dark:bg-neutral-700 rounded-lg">
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.totalSize')}</span>
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{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 dark:bg-neutral-700 rounded-lg">
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.categories')}</span>
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{categories.length}</span>
|
||||
</div>
|
||||
|
||||
{event.total_views !== undefined && (
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 dark:bg-neutral-700 rounded-lg">
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.totalViews')}</span>
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{event.total_views || 0}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.total_downloads !== undefined && (
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 dark:bg-neutral-700 rounded-lg">
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.totalDownloads')}</span>
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{event.total_downloads || 0}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.unique_visitors !== undefined && (
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 dark:bg-neutral-700 rounded-lg">
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.uniqueVisitors')}</span>
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Upload, X } from 'lucide-react';
|
||||
import type { Event } from '../../../types';
|
||||
import { Button, Card, Loading } from '../../../components/common';
|
||||
import { AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PhotoUploadModal, PhotoFilterPanel, PhotoExportMenu } from '../../../components/admin';
|
||||
import { externalMediaService } from '../../../services/externalMedia.service';
|
||||
import { AdminPhoto, type PhotoFilters as PhotoFilterParams, type FeedbackFilters, type FilterSummary } from '../../../services/photos.service';
|
||||
import { ExternalFolderPicker } from './ExternalFolderPicker';
|
||||
|
||||
interface PhotosTabProps {
|
||||
event: Event;
|
||||
id: string | undefined;
|
||||
photos: AdminPhoto[];
|
||||
photosLoading: boolean;
|
||||
refetchPhotos: () => void;
|
||||
categories: Array<{ id: number; name: string; slug: string }>;
|
||||
photoFilters: PhotoFilterParams;
|
||||
setPhotoFilters: React.Dispatch<React.SetStateAction<PhotoFilterParams>>;
|
||||
feedbackFilters: FeedbackFilters;
|
||||
setFeedbackFilters: React.Dispatch<React.SetStateAction<FeedbackFilters>>;
|
||||
filterSummary: FilterSummary | undefined;
|
||||
showMediaFilter: boolean;
|
||||
}
|
||||
|
||||
export const PhotosTab: React.FC<PhotosTabProps> = ({
|
||||
event,
|
||||
id,
|
||||
photos,
|
||||
photosLoading,
|
||||
refetchPhotos,
|
||||
categories,
|
||||
photoFilters,
|
||||
setPhotoFilters,
|
||||
feedbackFilters,
|
||||
setFeedbackFilters,
|
||||
filterSummary,
|
||||
showMediaFilter
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
|
||||
const [showExternalImport, setShowExternalImport] = useState(false);
|
||||
const [externalPath, setExternalPath] = useState<string>('');
|
||||
const [importing, setImporting] = useState<boolean>(false);
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
|
||||
const [selectedPhotoIds, setSelectedPhotoIds] = useState<number[]>([]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Photo Upload Modal */}
|
||||
<PhotoUploadModal
|
||||
isOpen={showPhotoUpload}
|
||||
onClose={() => setShowPhotoUpload(false)}
|
||||
eventId={parseInt(id!)}
|
||||
onUploadComplete={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event-photos', id] });
|
||||
toast.success(t('toast.uploadSuccess'));
|
||||
refetchPhotos();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Photo Filters */}
|
||||
<PhotoFilters
|
||||
categories={categories}
|
||||
selectedCategory={photoFilters.category_id}
|
||||
searchTerm={photoFilters.search ?? ''}
|
||||
sortBy={photoFilters.sort ?? 'date'}
|
||||
sortOrder={photoFilters.order ?? 'desc'}
|
||||
onCategoryChange={(categoryId) => setPhotoFilters(prev => ({ ...prev, category_id: categoryId }))}
|
||||
onSearchChange={(search) => setPhotoFilters(prev => ({ ...prev, search }))}
|
||||
onSortChange={(sort, order) => setPhotoFilters(prev => ({ ...prev, sort, order }))}
|
||||
mediaType={photoFilters.media_type || 'all'}
|
||||
onMediaTypeChange={(mediaType) => setPhotoFilters(prev => ({
|
||||
...prev,
|
||||
media_type: mediaType === 'all' ? undefined : mediaType
|
||||
}))}
|
||||
showMediaFilter={showMediaFilter}
|
||||
/>
|
||||
|
||||
{/* Feedback Filter Panel for Export */}
|
||||
<PhotoFilterPanel
|
||||
filters={feedbackFilters}
|
||||
onChange={setFeedbackFilters}
|
||||
summary={filterSummary || null}
|
||||
isLoading={photosLoading}
|
||||
/>
|
||||
|
||||
{/* Actions Bar */}
|
||||
<div className="mb-4 flex flex-wrap justify-between items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Upload className="w-4 h-4" />}
|
||||
onClick={() => setShowPhotoUpload(true)}
|
||||
>
|
||||
{t('events.uploadPhotos')}
|
||||
</Button>
|
||||
{event.source_mode === 'reference' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowExternalImport(true)}
|
||||
>
|
||||
{t('events.importExternal', 'Import from External Folder')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<PhotoExportMenu
|
||||
eventId={parseInt(id!)}
|
||||
selectedPhotoIds={selectedPhotoIds}
|
||||
filters={feedbackFilters}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Photo Grid */}
|
||||
{photosLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loading size="lg" text={t('events.loadingPhotos')} />
|
||||
</div>
|
||||
) : (
|
||||
<AdminPhotoGrid
|
||||
photos={photos}
|
||||
eventId={parseInt(id!)}
|
||||
onPhotoClick={(photo, index) => setSelectedPhoto({ photo, index })}
|
||||
onPhotosDeleted={() => {
|
||||
refetchPhotos();
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
|
||||
}}
|
||||
onSelectionChange={setSelectedPhotoIds}
|
||||
categories={categories}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Photo Viewer */}
|
||||
{selectedPhoto && (
|
||||
<AdminPhotoViewer
|
||||
photos={photos}
|
||||
initialIndex={selectedPhoto.index}
|
||||
eventId={parseInt(id!)}
|
||||
onClose={() => setSelectedPhoto(null)}
|
||||
onPhotoDeleted={() => {
|
||||
refetchPhotos();
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
|
||||
setSelectedPhoto(null);
|
||||
}}
|
||||
categories={categories}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* External Import Modal */}
|
||||
{showExternalImport && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<Card className="max-w-2xl w-full">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">{t('events.importExternal', 'Import from External Folder')}</h2>
|
||||
<button onClick={() => setShowExternalImport(false)} className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{t('events.externalImportInfo', 'All pictures from the selected folder will be imported.')}
|
||||
</div>
|
||||
<div className="mb-2 text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{t('events.selectExternalFolder', 'Select external folder under /external-media')}
|
||||
</div>
|
||||
<ExternalFolderPicker value={externalPath || event.external_path || ''} onChange={setExternalPath} />
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setShowExternalImport(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
isLoading={importing}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setImporting(true);
|
||||
const selected = externalPath || event.external_path || '';
|
||||
if (!selected) {
|
||||
toast.error(t('errors.somethingWentWrong', 'Something went wrong'));
|
||||
return;
|
||||
}
|
||||
await externalMediaService.importEvent(parseInt(id!), selected, { recursive: true });
|
||||
toast.success(t('toast.saveSuccess'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event-photos', id] });
|
||||
setShowExternalImport(false);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.error || 'Import failed');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('events.importFromSelectedFolder', 'Import from selected folder')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Copy, CheckCircle, Key, Mail } from 'lucide-react';
|
||||
import type { Event } from '../../../types';
|
||||
import { Button, Card } from '../../../components/common';
|
||||
import { eventsService } from '../../../services/events.service';
|
||||
import { buildShareLinkUrl } from '../../../utils/url';
|
||||
import { isGalleryPublic } from '../../../utils/accessControl';
|
||||
|
||||
interface ShareLinkCardProps {
|
||||
event: Event;
|
||||
setShowPasswordReset: (show: boolean) => void;
|
||||
}
|
||||
|
||||
export const ShareLinkCard: React.FC<ShareLinkCardProps> = ({ event, setShowPasswordReset }) => {
|
||||
const { t } = useTranslation();
|
||||
const [copiedLink, setCopiedLink] = useState(false);
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
// Check if share_link exists
|
||||
if (!event.share_link) {
|
||||
toast.error(t('errors.noShareLink', 'No share link available'));
|
||||
return;
|
||||
}
|
||||
|
||||
const shareUrl = buildShareLinkUrl(event.share_link);
|
||||
|
||||
// Try modern clipboard API first
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
} else {
|
||||
// Fallback for non-HTTPS contexts or older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = shareUrl;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
const successful = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
if (!successful) {
|
||||
throw new Error('Copy failed');
|
||||
}
|
||||
}
|
||||
|
||||
setCopiedLink(true);
|
||||
setTimeout(() => setCopiedLink(false), 2000);
|
||||
toast.success(t('toast.linkCopied'));
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err);
|
||||
toast.error(t('errors.copyFailed', 'Failed to copy link. Please copy manually.'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('events.shareLink')}</h2>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={buildShareLinkUrl(event.share_link)}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
leftIcon={copiedLink ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
{copiedLink ? t('events.copied') : t('events.copy')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-2">
|
||||
{isGalleryPublic(event.require_password)
|
||||
? t('events.shareWithGuestsPublic', 'Anyone with this link can view the gallery. No password is required.')
|
||||
: t('events.shareWithGuests')}
|
||||
</p>
|
||||
|
||||
{!event.is_archived && (
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700 space-y-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Key className="w-4 h-4" />}
|
||||
onClick={() => setShowPasswordReset(true)}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
{t('events.resetGalleryPassword')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Mail className="w-4 h-4" />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await eventsService.resendCreationEmail(event.id);
|
||||
toast.success(t('events.creationEmailResent'));
|
||||
} catch {
|
||||
toast.error(t('events.failedToResendEmail'));
|
||||
}
|
||||
}}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
{t('events.resendCreationEmail')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
export type EventDetailsTab = 'overview' | 'photos' | 'categories' | 'guests';
|
||||
|
||||
export type EditFormState = {
|
||||
welcome_message: string;
|
||||
color_theme: string;
|
||||
css_template_id: number | null;
|
||||
expires_at: string;
|
||||
allow_user_uploads: boolean;
|
||||
upload_category_id: number | null;
|
||||
hero_photo_id: number | null;
|
||||
customer_name: string;
|
||||
customer_email: string;
|
||||
customer_phone: string;
|
||||
source_mode: 'managed' | 'reference';
|
||||
external_path: string;
|
||||
require_password: boolean;
|
||||
new_password: string;
|
||||
confirm_new_password: string;
|
||||
// Download protection settings
|
||||
protection_level: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
disable_right_click: boolean;
|
||||
allow_downloads: boolean;
|
||||
watermark_downloads: boolean;
|
||||
allow_presigned_download: boolean;
|
||||
enable_devtools_protection: boolean;
|
||||
use_canvas_rendering: boolean;
|
||||
// Hero logo settings
|
||||
hero_logo_visible: boolean;
|
||||
hero_logo_size: 'small' | 'medium' | 'large' | 'xlarge';
|
||||
hero_logo_position: 'top' | 'center' | 'bottom';
|
||||
// Hero image anchor position (#162) – keyword or "X% Y%" focal point
|
||||
hero_image_anchor: string;
|
||||
// Photo cap
|
||||
photo_cap: number;
|
||||
// Default photo sort
|
||||
default_photo_sort: string;
|
||||
// Per-event promotional override (#440). Three-way mode:
|
||||
// inherit → use the global branding_promo_markdown
|
||||
// custom → render this event's promo_markdown
|
||||
// off → no promo for this event regardless of global
|
||||
promo_mode: 'inherit' | 'custom' | 'off';
|
||||
promo_markdown: string;
|
||||
// Customer accounts assigned to this event (#354). Hydrated from
|
||||
// the GET /admin/events/:id response and sent back as a flat id
|
||||
// array on save.
|
||||
customer_accounts: Array<{ id: number; email: string; displayName: string | null }>;
|
||||
// Per-event opt-in for hero photo as social-share preview (#474).
|
||||
og_image_share_enabled: boolean;
|
||||
};
|
||||
|
||||
export const INITIAL_EDIT_FORM: EditFormState = {
|
||||
welcome_message: '',
|
||||
color_theme: '',
|
||||
css_template_id: null,
|
||||
expires_at: '',
|
||||
allow_user_uploads: false,
|
||||
upload_category_id: null,
|
||||
hero_photo_id: null,
|
||||
customer_name: '',
|
||||
customer_email: '',
|
||||
customer_phone: '',
|
||||
source_mode: 'managed',
|
||||
external_path: '',
|
||||
require_password: true,
|
||||
new_password: '',
|
||||
confirm_new_password: '',
|
||||
// Download protection settings
|
||||
protection_level: 'standard',
|
||||
disable_right_click: true,
|
||||
allow_downloads: true,
|
||||
watermark_downloads: false,
|
||||
allow_presigned_download: false,
|
||||
enable_devtools_protection: true,
|
||||
use_canvas_rendering: false,
|
||||
// Hero logo settings
|
||||
hero_logo_visible: true,
|
||||
hero_logo_size: 'medium',
|
||||
hero_logo_position: 'top',
|
||||
// Hero image anchor position (#162)
|
||||
hero_image_anchor: 'center',
|
||||
// Photo cap
|
||||
photo_cap: 0,
|
||||
// Default photo sort
|
||||
default_photo_sort: 'upload_date_desc',
|
||||
// Per-event promotional override (#440)
|
||||
promo_mode: 'inherit',
|
||||
promo_markdown: '',
|
||||
// Customer accounts (#354) — hydrated from event response.
|
||||
customer_accounts: [],
|
||||
// Per-event social-share opt-in (#474). Default false everywhere
|
||||
// so a freshly opened editor never displays "on" against the saved
|
||||
// (off) state.
|
||||
og_image_share_enabled: false,
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { parseISO, isValid } from 'date-fns';
|
||||
|
||||
// Helper to safely parse dates that might be strings, Date objects, or timestamps
|
||||
export const safeParseDate = (dateValue: unknown): Date | null => {
|
||||
if (!dateValue) {
|
||||
return null;
|
||||
}
|
||||
if (dateValue instanceof Date) {
|
||||
return dateValue;
|
||||
}
|
||||
if (typeof dateValue === 'number') {
|
||||
return new Date(dateValue);
|
||||
}
|
||||
if (typeof dateValue === 'string') {
|
||||
const parsed = parseISO(dateValue);
|
||||
return isValid(parsed) ? parsed : new Date(dateValue);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
Reference in New Issue
Block a user