feat(events): duplicate-gallery action (#626)
Daniel asked for a way to re-use a good gallery configuration without re-entering every setting. Two of his three suggested workflows are covered by this PR; the third (per-event-type behaviour defaults) is partially shipped already via event_types.theme_preset + theme_config and is left as a follow-up if the duplicate workflow doesn't cover it. Backend — POST /admin/events/:id/duplicate. Validates a new event_name (required) + event_date (optional) + customer_name/email (optional); copies branding (color_theme, css_template_id, header/hero/divider/anchor), behaviour toggles (allow_downloads, watermark_*, allow_user_uploads, require_password, etc.), photo_cap, welcome_message, default_photo_sort, admin_email, and feedback settings + per-event photo categories. Mints a fresh slug + share_token + random-placeholder password_hash (admin sets the real one via the publish dialog shipped in #627). Recomputes expires_at = new_event_date + (source.expires_at - source.event_date) so the duplicate keeps the same active window; defaults to 30 days if either source field was null. is_draft is always true. Deliberately NOT carried over: photos, hero_photo_id, client_access secrets, og_image_share opt-in, customer_phone, sent_at flags, archive state, customer-account assignments. Frontend — new DuplicateEventDialog (matches the PublishGalleryDialog pattern), wired into the Actions card on EventDetailsPage. Visible in both draft and live mode since admins typically duplicate from a published gallery. On success the page navigates to the new draft so the admin can finish customising + publish. I18n: EN + DE entries for the dialog + button label. Backend logs an event_duplicated activity with the source event id/name so the trail is auditable. Frontend service: eventsService.duplicateEvent(eventId, data).
This commit is contained in:
@@ -1176,6 +1176,193 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require
|
||||
}
|
||||
});
|
||||
|
||||
// Duplicate an event (#626). Creates a new DRAFT gallery that inherits the
|
||||
// source event's branding, behaviour, hero/header, feedback, and category
|
||||
// configuration — admin then fills in customer + publishes via the publish
|
||||
// dialog (#627), where the password is set. Photos, hero photo selection,
|
||||
// client-access secrets, customer assignments, archive/sent state are NOT
|
||||
// carried over.
|
||||
router.post('/:id/duplicate', adminAuth, requirePermission('events.create'), requireEventOwnership, [
|
||||
body('event_name').trim().notEmpty().withMessage('Event name is required'),
|
||||
body('event_date').optional({ values: 'falsy' }).isDate(),
|
||||
body('customer_name').optional().trim(),
|
||||
body('customer_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const source = await db('events').where('id', id).first();
|
||||
if (!source) {
|
||||
return res.status(404).json({ error: 'Source event not found' });
|
||||
}
|
||||
|
||||
const { event_name, event_date, customer_name, customer_email } = req.body;
|
||||
|
||||
// Generate a fresh unique slug using the same shape as the create path.
|
||||
const slugify = require('../utils/slug').slugify;
|
||||
const processedEventName = slugify(event_name);
|
||||
const slugSuffix = event_date || crypto.randomBytes(3).toString('hex');
|
||||
const baseSlug = `${source.event_type}-${processedEventName}-${slugSuffix}`;
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
while (await db('events').where({ slug }).first()) {
|
||||
slug = `${baseSlug}-${counter}`;
|
||||
counter += 1;
|
||||
}
|
||||
|
||||
// Recompute expires_at: preserve the source's expiration window (delta
|
||||
// between source.expires_at and source.event_date) so the duplicate keeps
|
||||
// the same "active for N days" feel. Falls back to 30 days if source had
|
||||
// no expiration set.
|
||||
let newExpiresAt = null;
|
||||
if (event_date) {
|
||||
let expirationDays = 30;
|
||||
if (source.expires_at && source.event_date) {
|
||||
const days = Math.round(
|
||||
(new Date(source.expires_at).getTime() - new Date(source.event_date).getTime())
|
||||
/ (24 * 60 * 60 * 1000),
|
||||
);
|
||||
if (days > 0) expirationDays = days;
|
||||
}
|
||||
const [year, month, day] = event_date.split('-').map((s) => parseInt(s, 10));
|
||||
const baseDate = new Date(year, month - 1, day);
|
||||
baseDate.setDate(baseDate.getDate() + expirationDays);
|
||||
newExpiresAt = baseDate;
|
||||
}
|
||||
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const { shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
|
||||
// Random-placeholder password hash. When the admin publishes via the
|
||||
// PublishGalleryDialog (#627), the dialog re-hashes whatever they type and
|
||||
// overwrites this. Pattern matches the create path at line ~606.
|
||||
const password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
|
||||
// Create the storage folder structure (same as create path).
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const eventPath = path.join(storagePath, 'events/active', slug);
|
||||
await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true });
|
||||
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
|
||||
|
||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||
const calendarColumnsExist = await hasColumnCached('events', 'is_full_day');
|
||||
|
||||
// Build the insert row. Copy behaviour + branding fields from source;
|
||||
// leave per-gallery secrets / state / photos blank.
|
||||
const insertResult = await db('events').insert({
|
||||
slug,
|
||||
event_type: source.event_type,
|
||||
event_name,
|
||||
event_date: event_date || null,
|
||||
...(calendarColumnsExist ? {
|
||||
event_time_start: source.event_time_start,
|
||||
event_time_end: source.event_time_end,
|
||||
is_full_day: source.is_full_day,
|
||||
} : {}),
|
||||
...(customerColumnsAvailable ? {
|
||||
customer_name: customer_name || null,
|
||||
customer_email: customer_email || null,
|
||||
} : {}),
|
||||
host_name: customer_name || null,
|
||||
host_email: customer_email || null,
|
||||
admin_email: source.admin_email || null,
|
||||
password_hash,
|
||||
welcome_message: source.welcome_message || '',
|
||||
color_theme: source.color_theme,
|
||||
share_link: shareLinkToStore,
|
||||
share_token: shareToken,
|
||||
expires_at: newExpiresAt ? newExpiresAt.toISOString() : null,
|
||||
created_at: new Date().toISOString(),
|
||||
created_by: req.admin.id,
|
||||
allow_user_uploads: source.allow_user_uploads,
|
||||
upload_category_id: source.upload_category_id,
|
||||
allow_downloads: source.allow_downloads,
|
||||
disable_right_click: source.disable_right_click,
|
||||
enable_devtools_protection: source.enable_devtools_protection,
|
||||
watermark_downloads: source.watermark_downloads,
|
||||
watermark_text: source.watermark_text,
|
||||
allow_presigned_download: source.allow_presigned_download,
|
||||
require_password: source.require_password,
|
||||
css_template_id: source.css_template_id || null,
|
||||
hero_logo_visible: source.hero_logo_visible,
|
||||
hero_logo_size: source.hero_logo_size,
|
||||
hero_logo_position: source.hero_logo_position,
|
||||
header_style: source.header_style || 'standard',
|
||||
hero_divider_style: source.hero_divider_style || 'wave',
|
||||
hero_image_anchor: source.hero_image_anchor || 'center',
|
||||
photo_cap: source.photo_cap || null,
|
||||
is_draft: formatBoolean(true),
|
||||
default_photo_sort: source.default_photo_sort || 'upload_date_desc',
|
||||
// Client-access secrets and the OG-share opt-in deliberately do NOT
|
||||
// carry over — admin re-decides per gallery.
|
||||
client_access_enabled: formatBoolean(false),
|
||||
og_image_share_enabled: formatBoolean(false),
|
||||
}).returning('id');
|
||||
|
||||
const newEventId = insertResult[0]?.id || insertResult[0];
|
||||
|
||||
// Copy event_feedback_settings if the source had a row (only present when
|
||||
// feedback_enabled was true on the source event).
|
||||
const sourceFeedback = await db('event_feedback_settings').where({ event_id: id }).first();
|
||||
if (sourceFeedback) {
|
||||
await db('event_feedback_settings').insert({
|
||||
event_id: newEventId,
|
||||
feedback_enabled: sourceFeedback.feedback_enabled,
|
||||
allow_ratings: sourceFeedback.allow_ratings,
|
||||
allow_likes: sourceFeedback.allow_likes,
|
||||
allow_comments: sourceFeedback.allow_comments,
|
||||
allow_favorites: sourceFeedback.allow_favorites,
|
||||
require_name_email: sourceFeedback.require_name_email,
|
||||
moderate_comments: sourceFeedback.moderate_comments,
|
||||
show_feedback_to_guests: sourceFeedback.show_feedback_to_guests,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Copy per-event photo categories (global categories are not duplicated —
|
||||
// they apply to every event already). Mapping by name; photo_categories
|
||||
// has no foreign key into photos here so we just clone the rows.
|
||||
if (await db.schema.hasTable('photo_categories')) {
|
||||
const sourceCategories = await db('photo_categories')
|
||||
.where({ event_id: id })
|
||||
.where(function () { this.whereNull('is_global').orWhere('is_global', formatBoolean(false)); })
|
||||
.select('name', 'slug', 'is_global');
|
||||
if (sourceCategories.length > 0) {
|
||||
await db('photo_categories').insert(
|
||||
sourceCategories.map((c) => ({
|
||||
event_id: newEventId,
|
||||
name: c.name,
|
||||
slug: c.slug,
|
||||
is_global: formatBoolean(false),
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await logActivity('event_duplicated',
|
||||
{ source_event_id: parseInt(id, 10), source_event_name: source.event_name },
|
||||
newEventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username },
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Event duplicated successfully',
|
||||
id: newEventId,
|
||||
slug,
|
||||
is_draft: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error duplicating event:', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to duplicate event' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update event
|
||||
router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
|
||||
body('event_name').optional().trim().notEmpty(),
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Copy } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Card, Input, LocalizedDateInput } from '../common';
|
||||
|
||||
interface DuplicateEventDialogProps {
|
||||
sourceEventName: string;
|
||||
isDuplicating: boolean;
|
||||
onConfirm: (data: {
|
||||
event_name: string;
|
||||
event_date?: string;
|
||||
customer_name?: string;
|
||||
customer_email?: string;
|
||||
}) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Duplicate gallery" dialog (#626) — admin types a fresh event name + date
|
||||
* (and optionally a new customer) and the backend clones the source event's
|
||||
* branding / behaviour / feedback / categories into a new draft. Photos,
|
||||
* the password, the share token and client-access secrets do NOT carry over —
|
||||
* those are set fresh on the duplicate. The new event opens in draft mode so
|
||||
* the admin can finish customising before publishing via the publish dialog
|
||||
* (#627).
|
||||
*/
|
||||
export const DuplicateEventDialog: React.FC<DuplicateEventDialogProps> = ({
|
||||
sourceEventName,
|
||||
isDuplicating,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [eventName, setEventName] = useState('');
|
||||
const [eventDate, setEventDate] = useState('');
|
||||
const [customerName, setCustomerName] = useState('');
|
||||
const [customerEmail, setCustomerEmail] = useState('');
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!eventName.trim()) {
|
||||
setError(t('events.duplicateDialog.errorNameRequired', 'Event name is required.'));
|
||||
return;
|
||||
}
|
||||
setError(undefined);
|
||||
onConfirm({
|
||||
event_name: eventName.trim(),
|
||||
event_date: eventDate || undefined,
|
||||
customer_name: customerName.trim() || undefined,
|
||||
customer_email: customerEmail.trim() || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<Card className="max-w-md 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.duplicateDialog.title', 'Duplicate gallery')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{t('events.duplicateDialog.description', {
|
||||
sourceEventName,
|
||||
defaultValue:
|
||||
'Creates a new draft gallery that inherits the branding, behaviour, feedback, and category configuration from "{{sourceEventName}}". Photos, password, and share tokens are NOT carried over.',
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3 mb-4">
|
||||
<Input
|
||||
type="text"
|
||||
label={t('events.duplicateDialog.eventNameLabel', 'New event name *')}
|
||||
placeholder={t('events.duplicateDialog.eventNamePlaceholder', 'e.g. Müller Wedding 2026')}
|
||||
value={eventName}
|
||||
onChange={(e) => {
|
||||
setEventName(e.target.value);
|
||||
if (error) setError(undefined);
|
||||
}}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
<LocalizedDateInput
|
||||
label={t('events.duplicateDialog.eventDateLabel', 'Event date')}
|
||||
value={eventDate}
|
||||
onChange={setEventDate}
|
||||
helperText={t(
|
||||
'events.duplicateDialog.eventDateHelp',
|
||||
'Leave blank to use a random suffix in the gallery URL. Expiration is recomputed from this date plus the source gallery’s expiration window.',
|
||||
)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
label={t('events.duplicateDialog.customerNameLabel', 'Customer name')}
|
||||
placeholder={t('events.duplicateDialog.customerNamePlaceholder', 'Optional — fill in later if unknown')}
|
||||
value={customerName}
|
||||
onChange={(e) => setCustomerName(e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
label={t('events.duplicateDialog.customerEmailLabel', 'Customer email')}
|
||||
placeholder={t('events.duplicateDialog.customerEmailPlaceholder', 'Optional')}
|
||||
value={customerEmail}
|
||||
onChange={(e) => setCustomerEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isDuplicating}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={isDuplicating}
|
||||
isLoading={isDuplicating}
|
||||
leftIcon={<Copy className="w-4 h-4" />}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('events.duplicateDialog.confirm', 'Create duplicate')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -18,6 +18,7 @@ export { AdminPhotoViewer } from './AdminPhotoViewer';
|
||||
export { PhotoFilters } from './PhotoFilters';
|
||||
export { PasswordResetModal } from './PasswordResetModal';
|
||||
export { PublishGalleryDialog } from './PublishGalleryDialog';
|
||||
export { DuplicateEventDialog } from './DuplicateEventDialog';
|
||||
export { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||
export { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
|
||||
export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
|
||||
|
||||
@@ -1027,6 +1027,22 @@
|
||||
"passwordHelp": "Gib das bei der Erstellung gesetzte Passwort erneut ein (oder wähle ein neues). Die E-Mail enthält genau diesen Text; das Backend hasht es erneut, sodass die Galerie-Anmeldung weiterhin funktioniert.",
|
||||
"errorMinLength": "Das Passwort muss mindestens 6 Zeichen lang sein."
|
||||
},
|
||||
"duplicateEvent": "Galerie duplizieren",
|
||||
"duplicateDialog": {
|
||||
"title": "Galerie duplizieren",
|
||||
"description": "Erstellt eine neue Entwurfsgalerie, die Branding, Verhalten, Feedback und Kategorien aus \"{{sourceEventName}}\" übernimmt. Fotos, Passwort und Share-Tokens werden NICHT übernommen.",
|
||||
"eventNameLabel": "Neuer Veranstaltungsname *",
|
||||
"eventNamePlaceholder": "z. B. Hochzeit Müller 2026",
|
||||
"eventDateLabel": "Veranstaltungsdatum",
|
||||
"eventDateHelp": "Leer lassen, um eine zufällige Endung in der Galerie-URL zu verwenden. Das Ablaufdatum wird aus diesem Datum zuzüglich des Ablauffensters der Quellgalerie berechnet.",
|
||||
"customerNameLabel": "Kundenname",
|
||||
"customerNamePlaceholder": "Optional – kann später ausgefüllt werden",
|
||||
"customerEmailLabel": "Kunden-E-Mail",
|
||||
"customerEmailPlaceholder": "Optional",
|
||||
"confirm": "Duplikat erstellen",
|
||||
"errorNameRequired": "Veranstaltungsname ist erforderlich.",
|
||||
"successToast": "Galerie dupliziert."
|
||||
},
|
||||
"draftBanner": "Diese Galerie befindet sich im Entwurfsmodus. Laden Sie Ihre Fotos hoch und veröffentlichen Sie, wenn Sie bereit sind.",
|
||||
"subtitle": "Verwalten Sie Ihre Fotogalerien und Veranstaltungen",
|
||||
"failedToLoadEvents": "Veranstaltungen konnten nicht geladen werden",
|
||||
|
||||
@@ -574,6 +574,22 @@
|
||||
"passwordHelp": "Re-type the password set at creation (or pick a new one). The email includes this exact text; the backend re-hashes it so the gallery login still works.",
|
||||
"errorMinLength": "Password must be at least 6 characters long."
|
||||
},
|
||||
"duplicateEvent": "Duplicate gallery",
|
||||
"duplicateDialog": {
|
||||
"title": "Duplicate gallery",
|
||||
"description": "Creates a new draft gallery that inherits the branding, behaviour, feedback, and category configuration from \"{{sourceEventName}}\". Photos, password, and share tokens are NOT carried over.",
|
||||
"eventNameLabel": "New event name *",
|
||||
"eventNamePlaceholder": "e.g. Müller Wedding 2026",
|
||||
"eventDateLabel": "Event date",
|
||||
"eventDateHelp": "Leave blank to use a random suffix in the gallery URL. Expiration is recomputed from this date plus the source gallery's expiration window.",
|
||||
"customerNameLabel": "Customer name",
|
||||
"customerNamePlaceholder": "Optional — fill in later if unknown",
|
||||
"customerEmailLabel": "Customer email",
|
||||
"customerEmailPlaceholder": "Optional",
|
||||
"confirm": "Create duplicate",
|
||||
"errorNameRequired": "Event name is required.",
|
||||
"successToast": "Gallery duplicated."
|
||||
},
|
||||
"draftBanner": "This gallery is in draft mode. Upload your photos, then publish when ready.",
|
||||
"subtitle": "Manage your photo galleries and events",
|
||||
"failedToLoadEvents": "Failed to load events",
|
||||
|
||||
@@ -59,7 +59,7 @@ import { toast } from 'react-toastify';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
import { Button, Input, Card, Loading, MarkdownContent, LocalizedDateInput } from '../../components/common';
|
||||
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, PublishGalleryDialog, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin';
|
||||
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, PublishGalleryDialog, DuplicateEventDialog, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin';
|
||||
import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker';
|
||||
import { EventReminderOverrideCard } from '../../components/admin/EventReminderOverrideCard';
|
||||
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
|
||||
@@ -374,6 +374,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false);
|
||||
const [showPublishDialog, setShowPublishDialog] = useState(false);
|
||||
const [showDuplicateDialog, setShowDuplicateDialog] = useState(false);
|
||||
const [logoUploading, setLogoUploading] = useState(false);
|
||||
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
|
||||
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
|
||||
@@ -550,6 +551,28 @@ export const EventDetailsPage: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Duplicate mutation (#626). Backend creates a draft inheriting branding +
|
||||
// behaviour + categories from the source; we navigate to the new event so
|
||||
// the admin can finish configuring + publish.
|
||||
const duplicateMutation = useMutation({
|
||||
mutationFn: (data: {
|
||||
event_name: string;
|
||||
event_date?: string;
|
||||
customer_name?: string;
|
||||
customer_email?: string;
|
||||
}) => eventsService.duplicateEvent(parseInt(id!), data),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
|
||||
toast.success(t('events.duplicateDialog.successToast', 'Gallery duplicated.'));
|
||||
setShowDuplicateDialog(false);
|
||||
navigate(`/admin/events/${result.id}`);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
const msg = err?.response?.data?.errors?.[0]?.msg || err?.response?.data?.error;
|
||||
toast.error(msg || t('errors.somethingWentWrong'));
|
||||
},
|
||||
});
|
||||
|
||||
// Extend expiration mutation
|
||||
const extendMutation = useMutation({
|
||||
mutationFn: (days: number) => {
|
||||
@@ -2191,6 +2214,17 @@ export const EventDetailsPage: React.FC = () => {
|
||||
</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={duplicateMutation.isPending}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
{t('events.duplicateEvent', 'Duplicate gallery')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
@@ -2615,6 +2649,20 @@ export const EventDetailsPage: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Duplicate Event Dialog (#626) — admin types a new event name/date
|
||||
(+ optional customer); backend clones the source gallery's config
|
||||
and we navigate to the new draft. */}
|
||||
{showDuplicateDialog && (
|
||||
<DuplicateEventDialog
|
||||
sourceEventName={event.event_name}
|
||||
isDuplicating={duplicateMutation.isPending}
|
||||
onConfirm={(data) => duplicateMutation.mutate(data)}
|
||||
onClose={() => {
|
||||
if (!duplicateMutation.isPending) setShowDuplicateDialog(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -232,6 +232,22 @@ export const eventsService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Duplicate an event (#626). Creates a new draft gallery that inherits the
|
||||
// source event's branding + behaviour + feedback + categories. Photos are
|
||||
// NOT carried over. The returned id/slug are the new draft event.
|
||||
async duplicateEvent(
|
||||
eventId: number,
|
||||
data: {
|
||||
event_name: string;
|
||||
event_date?: string;
|
||||
customer_name?: string;
|
||||
customer_email?: string;
|
||||
},
|
||||
): Promise<{ message: string; id: number; slug: string; is_draft: boolean }> {
|
||||
const response = await api.post(`/admin/events/${eventId}/duplicate`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get admin preview token (uses existing admin session token)
|
||||
getPreviewToken(): string | null {
|
||||
const token = sessionStorage.getItem('admin_token') || localStorage.getItem('admin_token');
|
||||
|
||||
Reference in New Issue
Block a user