From 09431f725cd5742667972ead12fef9f01d850a93 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 21 Apr 2026 20:45:23 +0200 Subject: [PATCH] feat(dashboard): add pre-copy confirmation modal showing what will and won't be copied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces CopyTripDialog — a two-section modal that appears before the copy action and lists what is carried over (days, places, budget items, packing lists, TODOs, notes) and what is intentionally skipped (collaborators, collab data, files, share tokens). Addresses the UX gap raised in #786. --- .../src/components/shared/CopyTripDialog.tsx | 108 ++++++++++++++++++ client/src/i18n/translations/en.ts | 14 +++ client/src/pages/DashboardPage.test.tsx | 8 ++ client/src/pages/DashboardPage.tsx | 17 ++- 4 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 client/src/components/shared/CopyTripDialog.tsx diff --git a/client/src/components/shared/CopyTripDialog.tsx b/client/src/components/shared/CopyTripDialog.tsx new file mode 100644 index 00000000..efea7afb --- /dev/null +++ b/client/src/components/shared/CopyTripDialog.tsx @@ -0,0 +1,108 @@ +import React, { useEffect, useCallback } from 'react' +import { Check, X } from 'lucide-react' +import { useTranslation } from '../../i18n' + +interface CopyTripDialogProps { + isOpen: boolean + tripTitle: string + onClose: () => void + onConfirm: () => void +} + +const WILL_COPY_KEYS = [ + 'dashboard.confirm.copy.will1', + 'dashboard.confirm.copy.will2', + 'dashboard.confirm.copy.will3', + 'dashboard.confirm.copy.will4', + 'dashboard.confirm.copy.will5', + 'dashboard.confirm.copy.will6', +] + +const WONT_COPY_KEYS = [ + 'dashboard.confirm.copy.wont1', + 'dashboard.confirm.copy.wont2', + 'dashboard.confirm.copy.wont3', + 'dashboard.confirm.copy.wont4', +] + +export default function CopyTripDialog({ isOpen, tripTitle, onClose, onConfirm }: CopyTripDialogProps) { + const { t } = useTranslation() + + const handleEsc = useCallback((e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + }, [onClose]) + + useEffect(() => { + if (isOpen) document.addEventListener('keydown', handleEsc) + return () => document.removeEventListener('keydown', handleEsc) + }, [isOpen, handleEsc]) + + if (!isOpen) return null + + return ( +
+
e.stopPropagation()} + > +

+ {t('dashboard.confirm.copy.title')} +

+

+ {tripTitle} +

+ +
+
+

+ {t('dashboard.confirm.copy.willCopy')} +

+
    + {WILL_COPY_KEYS.map(key => ( +
  • + + {t(key)} +
  • + ))} +
+
+ +
+

+ {t('dashboard.confirm.copy.wontCopy')} +

+
    + {WONT_COPY_KEYS.map(key => ( +
  • + + {t(key)} +
  • + ))} +
+
+
+ +
+ + +
+
+
+ ) +} diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index e444d957..a50ffcc3 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -122,6 +122,20 @@ const en: Record = { 'dashboard.toast.copied': 'Trip copied!', 'dashboard.toast.copyError': 'Failed to copy trip', 'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.', + 'dashboard.confirm.copy.title': 'Copy this trip?', + 'dashboard.confirm.copy.willCopy': 'Will be copied', + 'dashboard.confirm.copy.will1': 'Days, places & day assignments', + 'dashboard.confirm.copy.will2': 'Accommodations & reservations', + 'dashboard.confirm.copy.will3': 'Budget items & category order', + 'dashboard.confirm.copy.will4': 'Packing lists (unchecked)', + 'dashboard.confirm.copy.will5': 'TODOs (unassigned & unchecked)', + 'dashboard.confirm.copy.will6': 'Day notes', + 'dashboard.confirm.copy.wontCopy': "Won't be copied", + 'dashboard.confirm.copy.wont1': 'Collaborators & member assignments', + 'dashboard.confirm.copy.wont2': 'Collab notes, polls & messages', + 'dashboard.confirm.copy.wont3': 'Files & photos', + 'dashboard.confirm.copy.wont4': 'Share tokens', + 'dashboard.confirm.copy.confirm': 'Copy trip', 'dashboard.editTrip': 'Edit Trip', 'dashboard.createTrip': 'Create New Trip', 'dashboard.tripTitle': 'Title', diff --git a/client/src/pages/DashboardPage.test.tsx b/client/src/pages/DashboardPage.test.tsx index 4aac3121..2a3530ee 100644 --- a/client/src/pages/DashboardPage.test.tsx +++ b/client/src/pages/DashboardPage.test.tsx @@ -401,6 +401,10 @@ describe('DashboardPage', () => { const copyButtons = screen.getAllByRole('button', { name: /copy/i }); await user.click(copyButtons[0]); + // Confirm the copy dialog + const confirmButton = await screen.findByRole('button', { name: /copy trip/i }); + await user.click(confirmButton); + await waitFor(() => { expect(screen.getAllByText('Paris Adventure (Copy)')[0]).toBeInTheDocument(); }); @@ -766,6 +770,10 @@ describe('DashboardPage', () => { expect(copyButtons.length).toBeGreaterThan(0); await user.click(copyButtons[0]); + // Confirm the copy dialog + const confirmButton = await screen.findByRole('button', { name: /copy trip/i }); + await user.click(confirmButton); + await waitFor(() => { expect(screen.getAllByText('Paris Adventure (Copy)').length).toBeGreaterThan(0); }); diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index 321ba3ef..c3e4fa85 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -12,6 +12,7 @@ import CurrencyWidget from '../components/Dashboard/CurrencyWidget' import TimezoneWidget from '../components/Dashboard/TimezoneWidget' import TripFormModal from '../components/Trips/TripFormModal' import ConfirmDialog from '../components/shared/ConfirmDialog' +import CopyTripDialog from '../components/shared/CopyTripDialog' import { useToast } from '../components/shared/Toast' import { useCountUp } from '../hooks/useCountUp' import { @@ -699,6 +700,7 @@ export default function DashboardPage(): React.ReactElement { const [showWidgetSettings, setShowWidgetSettings] = useState(false) const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid') const [deleteTrip, setDeleteTrip] = useState(null) + const [copyTrip, setCopyTrip] = useState(null) const toggleViewMode = () => { setViewMode(prev => { @@ -815,14 +817,18 @@ export default function DashboardPage(): React.ReactElement { setArchivedTrips(prev => prev.map(update)) } - const handleCopy = async (trip: DashboardTrip) => { + const handleCopy = (trip: DashboardTrip) => setCopyTrip(trip) + + const confirmCopy = async () => { + if (!copyTrip) return try { - const data = await tripsApi.copy(trip.id, { title: `${trip.title} (${t('dashboard.copySuffix')})` }) + const data = await tripsApi.copy(copyTrip.id, { title: `${copyTrip.title} (${t('dashboard.copySuffix')})` }) setTrips(prev => sortTrips([data.trip, ...prev])) toast.success(t('dashboard.toast.copied')) } catch { toast.error(t('dashboard.toast.copyError')) } + setCopyTrip(null) } const today = new Date().toISOString().split('T')[0] @@ -1205,6 +1211,13 @@ export default function DashboardPage(): React.ReactElement { message={t('dashboard.confirm.delete', { title: deleteTrip?.title || '' })} /> + setCopyTrip(null)} + onConfirm={confirmCopy} + /> +