diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx
index 955a4110..1d4535f3 100644
--- a/client/src/pages/AtlasPage.tsx
+++ b/client/src/pages/AtlasPage.tsx
@@ -938,7 +938,7 @@ export default function AtlasPage(): React.ReactElement {
ref={panelRef}
onMouseMove={handlePanelMouseMove}
onMouseLeave={handlePanelMouseLeave}
- className="hidden md:flex flex-col absolute z-10 overflow-hidden transition-all duration-300"
+ className="hidden md:flex flex-col absolute z-10 overflow-hidden transition-[width,height,transform,box-shadow] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{
bottom: 16,
left: '50%',
diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx
index 47063034..321ba3ef 100644
--- a/client/src/pages/DashboardPage.tsx
+++ b/client/src/pages/DashboardPage.tsx
@@ -13,6 +13,7 @@ import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
import TripFormModal from '../components/Trips/TripFormModal'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import { useToast } from '../components/shared/Toast'
+import { useCountUp } from '../hooks/useCountUp'
import {
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft, Users,
@@ -152,6 +153,28 @@ interface TripCardProps {
dark?: boolean
}
+function SpotlightStats({ trip, totalDays, t }: { trip: DashboardTrip; totalDays: number; t: TripCardProps['t'] }): React.ReactElement {
+ const days = useCountUp(trip.day_count || totalDays)
+ const places = useCountUp(trip.place_count || 0)
+ const buddies = useCountUp(trip.shared_count || 0)
+ return (
+
+
+ {days}
+ {t('dashboard.mobile.days')}
+
+
+ {places}
+ {t('dashboard.mobile.places')}
+
+
+ {buddies}
+ {t('dashboard.mobile.buddies')}
+
+
+ )
+}
+
function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: TripCardProps): React.ReactElement {
const status = getTripStatus(trip)
const isLive = status === 'ongoing'
@@ -173,16 +196,16 @@ function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t,
return (
onClick(trip)}
- className="group relative rounded-3xl overflow-hidden cursor-pointer mb-8"
- style={{ minHeight: 340, boxShadow: '0 8px 40px rgba(0,0,0,0.13)' }}
+ className="group relative rounded-3xl overflow-hidden cursor-pointer mb-8 transition-[transform,box-shadow] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-1 hover:shadow-[0_16px_60px_rgba(0,0,0,0.22)] active:scale-[0.995]"
+ style={{ minHeight: 340, boxShadow: '0 8px 40px rgba(0,0,0,0.13)', isolation: 'isolate' }}
>
{/* Background */}
-
{trip.cover_image && (
<>
- 
+
>
)}
@@ -233,7 +256,14 @@ function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t,
{t('dashboard.mobile.daysLeft', { count: daysLeft })}
-
@@ -241,20 +271,7 @@ function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t,
)}
{/* Stats */}
-
-
- {trip.day_count || totalDays}
- {t('dashboard.mobile.days')}
-
-
- {trip.place_count || 0}
- {t('dashboard.mobile.places')}
-
-
- {trip.shared_count || 0}
- {t('dashboard.mobile.buddies')}
-
-
+
)
@@ -278,13 +295,13 @@ function MobileTripCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t,
return (
onClick?.(trip)}
- className="rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
- style={{ background: 'var(--bg-card)' }}
+ className="group rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden cursor-pointer transition-[transform,box-shadow,border-color] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-md"
+ style={{ background: 'var(--bg-card)', isolation: 'isolate' }}
>
{/* Cover */}
{trip.cover_image && (
- 
+ 
)}
@@ -370,13 +387,13 @@ function TripCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, local
return (
onClick(trip)}
- className="group rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-lg hover:border-zinc-300 dark:hover:border-zinc-600"
- style={{ background: 'var(--bg-card)' }}
+ className="group rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden cursor-pointer transition-[transform,box-shadow,border-color] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-lg hover:border-zinc-300 dark:hover:border-zinc-600"
+ style={{ background: 'var(--bg-card)', isolation: 'isolate' }}
>
{/* Cover */}
{trip.cover_image && (
- 
+ 
)}
@@ -658,11 +675,14 @@ function IconBtn({ onClick, title, danger, loading, children }: { onClick: () =>
// ── Skeleton ─────────────────────────────────────────────────────────────────
function SkeletonCard(): React.ReactElement {
return (
-
-
-
-
-
+
)
@@ -958,10 +978,8 @@ export default function DashboardPage(): React.ReactElement {
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
marginLeft: 2,
- transition: 'opacity 0.15s ease',
}}
- onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
- onMouseLeave={e => e.currentTarget.style.opacity = '1'}
+ className="hover:opacity-[0.88]"
>
{t('dashboard.newTrip')}
@@ -1004,7 +1022,7 @@ export default function DashboardPage(): React.ReactElement {
{/* Loading skeletons */}
{isLoading && (
<>
-
+
{[1, 2, 3].map(i => )}
@@ -1070,7 +1088,7 @@ export default function DashboardPage(): React.ReactElement {
{/* Trips — desktop grid or list */}
{!isLoading && (viewMode === 'grid' ? rest : trips).length > 0 && (
viewMode === 'grid' ? (
-
+
{rest.map(trip => (
) : (
-
+
{trips.map(trip => (
e.location_name).filter(Boolean))]
return (
-
+
@@ -1251,7 +1251,7 @@ function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: {
const hasProscons = prosArr.length > 0 || consArr.length > 0
return (
-
+
{/* Hero area: photos with title overlay */}
{photos.length > 0 ? (
@@ -1371,7 +1371,7 @@ function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick: () =>
return (
@@ -1395,7 +1395,7 @@ function CheckinCard({ entry, onClick }: { entry: JourneyEntry; onClick: () => v
return (
diff --git a/client/src/pages/JourneyPage.tsx b/client/src/pages/JourneyPage.tsx
index 31da4b9a..92a7d3ec 100644
--- a/client/src/pages/JourneyPage.tsx
+++ b/client/src/pages/JourneyPage.tsx
@@ -238,7 +238,7 @@ export default function JourneyPage() {
navigate(`/journey/${activeJourney.id}`)}
- className="relative rounded-3xl overflow-hidden cursor-pointer transition-all duration-300 hover:-translate-y-1 hover:shadow-xl h-[340px] md:h-[400px]"
+ className="relative rounded-3xl overflow-hidden cursor-pointer transition-[transform,box-shadow] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-1 hover:shadow-xl h-[340px] md:h-[400px]"
style={{ background: pickGradient(activeJourney.id) }}
>
{/* Cover image */}
@@ -333,9 +333,9 @@ export default function JourneyPage() {
{/* Create card */}
@@ -130,7 +130,7 @@ export default function RegisterPage(): React.ReactElement {
onChange={(e: React.ChangeEvent ) => setPassword(e.target.value)}
required
placeholder={t('register.minChars')}
- className="w-full pl-10 pr-12 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
+ className="w-full pl-10 pr-12 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-[border-color,box-shadow] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
/>
) => setConfirmPassword(e.target.value)}
required
placeholder={t('register.repeatPassword')}
- className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
+ className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-[border-color,box-shadow] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
/>
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index 0d3b3531..ca7a9088 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -12,12 +12,14 @@ import PlaceInspector from '../components/Planner/PlaceInspector'
import DayDetailPanel from '../components/Planner/DayDetailPanel'
import PlaceFormModal from '../components/Planner/PlaceFormModal'
import TripFormModal from '../components/Trips/TripFormModal'
+import SlidingTabs from '../components/shared/SlidingTabs'
import TripMembersModal from '../components/Trips/TripMembersModal'
import { ReservationModal } from '../components/Planner/ReservationModal'
import { TransportModal } from '../components/Planner/TransportModal'
// MemoriesPanel moved to Journey addon
import ReservationsPanel from '../components/Planner/ReservationsPanel'
import PackingListPanel from '../components/Packing/PackingListPanel'
+import ApplyTemplateButton from '../components/Packing/ApplyTemplateButton'
import TodoListPanel from '../components/Todo/TodoListPanel'
import FileManager from '../components/Files/FileManager'
import BudgetPanel from '../components/Budget/BudgetPanel'
@@ -37,7 +39,7 @@ import { useRouteCalculation } from '../hooks/useRouteCalculation'
import { usePlaceSelection } from '../hooks/usePlaceSelection'
import { usePlannerHistory } from '../hooks/usePlannerHistory'
import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, TodoItem } from '../types'
-import { ListTodo, Upload, Plus } from 'lucide-react'
+import { ListTodo, Upload, Plus, Trash2, FolderPlus } from 'lucide-react'
function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; packingItems: PackingItem[]; todoItems: TodoItem[] }) {
const [subTab, setSubTab] = useState<'packing' | 'todo'>(() => {
@@ -45,6 +47,8 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
})
const setSubTabPersist = (tab: 'packing' | 'todo') => { setSubTab(tab); sessionStorage.setItem(`trip-lists-subtab-${tripId}`, tab) }
const [importPackingSignal, setImportPackingSignal] = useState(0)
+ const [clearCheckedSignal, setClearCheckedSignal] = useState(0)
+ const [saveTemplateSignal, setSaveTemplateSignal] = useState(0)
const [addTodoSignal, setAddTodoSignal] = useState(0)
const { t } = useTranslation()
@@ -79,7 +83,7 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
color: active ? 'var(--text-primary)' : 'var(--text-muted)',
fontWeight: active ? 500 : 400,
boxShadow: active ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
- transition: 'all 0.15s ease',
+ transition: 'background 180ms cubic-bezier(0.23,1,0.32,1), color 180ms cubic-bezier(0.23,1,0.32,1), box-shadow 180ms cubic-bezier(0.23,1,0.32,1)',
}}
>
@@ -95,33 +99,58 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
})}
- {subTab === 'packing' && (
- setImportPackingSignal(s => s + 1)} style={{
+ {subTab === 'packing' && (() => {
+ const packingAbgehakt = packingItems.filter(i => i.checked).length
+ const sharedBtnClass = 'inline-flex items-center gap-1.5 px-2.5 sm:px-[14px] py-[7px] sm:py-[9px] hover:opacity-[0.88]'
+ const sharedBtnStyle: React.CSSProperties = {
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
- display: 'inline-flex', alignItems: 'center', gap: 6,
- padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
- background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
- marginLeft: 'auto',
- transition: 'opacity 0.15s ease',
- }}
- onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
- onMouseLeave={e => e.currentTarget.style.opacity = '1'}
- >
-
- {t('packing.import')}
-
- )}
+ borderRadius: 10, fontSize: 13, fontWeight: 500,
+ }
+ return (
+
+ {packingAbgehakt > 0 && (
+ setClearCheckedSignal(s => s + 1)}
+ className={`hidden sm:inline-flex items-center gap-1.5 px-[14px] py-[9px] hover:opacity-[0.88]`}
+ style={{ ...sharedBtnStyle, background: 'rgba(239,68,68,0.14)', color: '#ef4444' }}
+ >
+
+ {t('packing.clearChecked', { count: packingAbgehakt })}
+
+ )}
+
+ {packingItems.length > 0 && (
+ setSaveTemplateSignal(s => s + 1)}
+ className={sharedBtnClass}
+ style={{ ...sharedBtnStyle, background: 'var(--accent)', color: 'var(--accent-text)' }}
+ >
+
+ {t('packing.saveAsTemplate')}
+
+ )}
+ setImportPackingSignal(s => s + 1)}
+ className={sharedBtnClass}
+ style={{ ...sharedBtnStyle, background: 'var(--accent)', color: 'var(--accent-text)' }}
+ >
+
+ {t('packing.import')}
+
+
+ )
+ })()}
{subTab === 'todo' && (
- setAddTodoSignal(s => s + 1)} style={{
- appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
- display: 'inline-flex', alignItems: 'center', gap: 6,
- padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
- background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
- marginLeft: 'auto',
- transition: 'opacity 0.15s ease',
- }}
- onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
- onMouseLeave={e => e.currentTarget.style.opacity = '1'}
+ setAddTodoSignal(s => s + 1)}
+ className="hover:opacity-[0.88]"
+ style={{
+ appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
+ display: 'inline-flex', alignItems: 'center', gap: 6,
+ padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
+ background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
+ marginLeft: 'auto',
+ }}
>
{t('todo.addItem')}
@@ -130,7 +159,7 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
- {subTab === 'packing' && }
+ {subTab === 'packing' && }
{subTab === 'todo' && }
@@ -720,34 +749,17 @@ export default function TripPlannerPage(): React.ReactElement | null {
WebkitBackdropFilter: 'blur(16px)',
borderBottom: '1px solid var(--border-faint)',
height: 44,
- overflowX: 'auto', scrollbarWidth: 'none', msOverflowStyle: 'none',
- gap: 2,
}}>
- {TRIP_TABS.map(tab => {
- const isActive = activeTab === tab.id
- const TabIcon = tab.icon
- return (
- handleTabChange(tab.id)}
- title={tab.label}
- style={{
- flexShrink: 0,
- padding: '5px 14px', borderRadius: 20, border: 'none', cursor: 'pointer',
- fontSize: 13, fontWeight: isActive ? 600 : 400,
- background: isActive ? 'var(--accent)' : 'transparent',
- color: isActive ? 'var(--accent-text)' : 'var(--text-muted)',
- fontFamily: 'inherit', transition: 'all 0.15s',
- display: 'flex', alignItems: 'center', gap: 5,
- }}
- onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = isActive ? 'var(--accent-text)' : 'var(--text-primary)' }}
- onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = isActive ? 'var(--accent-text)' : 'var(--text-muted)' }}
- >
- {TabIcon && <>>}
- {tab.shortLabel || tab.label}
-
- )
- })}
+ ({
+ id: tab.id,
+ label: {tab.shortLabel || tab.label},
+ title: tab.label,
+ icon: tab.icon,
+ }))}
+ activeTab={activeTab}
+ onChange={handleTabChange}
+ />
{/* Offset by navbar + tab bar (44px) */}
diff --git a/client/src/pages/VacayPage.tsx b/client/src/pages/VacayPage.tsx
index d0d50689..39ba72d8 100644
--- a/client/src/pages/VacayPage.tsx
+++ b/client/src/pages/VacayPage.tsx
@@ -92,7 +92,7 @@ export default function VacayPage(): React.ReactElement {
{years.map(y => (
setSelectedYear(y)}
- className="group relative py-1.5 rounded-lg text-xs font-medium transition-all text-center cursor-pointer"
+ className="group relative py-1.5 rounded-lg text-xs font-medium transition-[background-color,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] text-center cursor-pointer"
style={{
background: y === selectedYear ? 'var(--text-primary)' : 'var(--bg-secondary)',
color: y === selectedYear ? 'var(--bg-card)' : 'var(--text-muted)',
@@ -262,8 +262,8 @@ export default function VacayPage(): React.ReactElement {
{incomingInvites.map(inv => (
-
+
diff --git a/client/tests/setup.ts b/client/tests/setup.ts
index d92bf394..b8b2bcd9 100644
--- a/client/tests/setup.ts
+++ b/client/tests/setup.ts
@@ -25,6 +25,15 @@ afterAll(() => server.close());
// ── jsdom stubs ────────────────────────────────────────────────────────────────
+// Force en-US locale for toLocaleDateString so tests are deterministic on
+// non-US dev machines (Windows-de-DE returns "Sonntag" instead of "Sunday").
+// Only affects calls without an explicit locale — callers that pass a locale
+// keep their behavior.
+const _origToLocaleDateString = Date.prototype.toLocaleDateString
+Date.prototype.toLocaleDateString = function (locales?: Intl.LocalesArgument, options?: Intl.DateTimeFormatOptions) {
+ return _origToLocaleDateString.call(this, locales ?? 'en-US', options)
+}
+
// window.matchMedia — used by dark mode / responsive components
Object.defineProperty(window, 'matchMedia', {
writable: true,
|