mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 22:31:46 +00:00
51ab30f436
* fix: hotel day-range clamping in ReservationModal + stale assignment_id on accommodation clear (issues #929, #934)
* ReservationModal hotel start/end pickers now use findIndex-based
positional clamping instead of raw ID arithmetic, matching the fix
applied to DayDetailPanel in 8e05ba7. Prevents inverted
start_day_id/end_day_id on trips with non-monotonic day IDs.
* Clearing accommodation_id on a hotel reservation now forces
assignment_id to null in the save payload, removing the stale
day-assignment link that had no UI path to clear.
* Migration: swaps inverted start_day_id/end_day_id pairs in
day_accommodations where start.day_number > end.day_number,
recovering existing corrupt rows from the pre-fix picker bug.
* Tests FE-PLANNER-RESMODAL-050/051/052 cover both fixes.
* fix: preserve line breaks and wrap long URLs in notes fields (#930)
Add remark-breaks to all reservation/place notes markdown renderers so
single newlines render as <br>, and add wordBreak/overflowWrap styles
so long unbroken URLs (e.g. booking.com tracking links) wrap correctly.
* fix: delete linked budget item when accommodation or reservation is deleted (#933)
Deleting an accommodation or reservation now removes any budget item
linked via reservation_id, preventing orphan entries in the Budget page.
Also fixes a pre-existing payload-shape bug where budget:deleted was
broadcast with {id} instead of {itemId}, breaking live updates for
collaborators when a reservation price was cleared.
Tests added: ACCOM-006, RESV-009b, BUDGET-004b.
* fix: restore scroll position in mobile Plan and Places sidebars on reopen (issue #932)
Both DayPlanSidebar and PlacesSidebar have their own internal scroll
containers (overflowY: auto). Scroll events don't bubble, so previous
attempts that tracked scrollTop on the outer portal div never fired.
Each sidebar now accepts initialScrollTop and onScrollTopChange props.
The internal scroll container saves its scrollTop via onScrollTopChange
on every scroll event, and restores it via useLayoutEffect on mount
(before the browser paints, so no visible flash).
TripPlannerPage holds the saved values in refs (mobilePlanScrollTopRef,
mobilePlacesScrollTopRef) and passes them through on each portal mount.
* fix(map): prevent auto zoom-out when opening/closing place inspector (issue #921)
Both Leaflet and Mapbox GL renderers now gate fitBounds strictly on fitKey
increments from the parent. Selecting or dismissing a place inspector changes
paddingOpts (via hasInspector) but no longer triggers a re-fit that zoomed
the map out to the full trip extent when no day was selected.
Also removes the zoom-12 visibility gate on Leaflet route info pills so they
render at all zoom levels when a route is active.
* fix: translate mobile bottom-nav tab labels (issue #931)
Replaced hardcoded English labels in BottomNav with t() lookups using the same translation keys as the desktop navbar (nav.myTrips, admin.addons.catalog.*.name).
163 lines
6.5 KiB
TypeScript
163 lines
6.5 KiB
TypeScript
import { useState } from 'react'
|
|
import { NavLink, useNavigate } from 'react-router-dom'
|
|
import { useAddonStore } from '../../store/addonStore'
|
|
import { useAuthStore } from '../../store/authStore'
|
|
import { useSettingsStore } from '../../store/settingsStore'
|
|
import { useTranslation } from '../../i18n'
|
|
import { Plane, CalendarDays, Globe, Compass, User, Settings, Shield, LogOut, X } from 'lucide-react'
|
|
import type { LucideIcon } from 'lucide-react'
|
|
|
|
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
|
|
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
|
|
atlas: { icon: Globe, labelKey: 'admin.addons.catalog.atlas.name' },
|
|
journey: { icon: Compass, labelKey: 'admin.addons.catalog.journey.name' },
|
|
}
|
|
|
|
export default function BottomNav() {
|
|
const { t } = useTranslation()
|
|
const darkMode = useSettingsStore(s => s.settings.dark_mode)
|
|
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
|
const addons = useAddonStore(s => s.addons)
|
|
const globalAddons = addons.filter(a => a.type === 'global' && a.enabled)
|
|
const [showProfile, setShowProfile] = useState(false)
|
|
|
|
const items: { to: string; label: string; icon: LucideIcon }[] = [
|
|
{ to: '/trips', label: t('nav.myTrips'), icon: Plane },
|
|
...globalAddons.flatMap(addon => {
|
|
const nav = ADDON_NAV[addon.id]
|
|
return nav ? [{ to: `/${addon.id}`, label: t(nav.labelKey), icon: nav.icon }] : []
|
|
}),
|
|
]
|
|
|
|
return (
|
|
<>
|
|
<nav
|
|
className="md:hidden sticky bottom-0 border-t border-zinc-200 dark:border-zinc-800 flex justify-around items-start pt-3 z-50 mt-auto flex-shrink-0"
|
|
style={{
|
|
height: 'calc(84px + env(safe-area-inset-bottom, 0px))',
|
|
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
|
background: dark ? 'rgba(9,9,11,0.96)' : 'rgba(255,255,255,0.96)',
|
|
backdropFilter: 'blur(20px)',
|
|
WebkitBackdropFilter: 'blur(20px)',
|
|
}}
|
|
>
|
|
{items.map(({ to, label, icon: Icon }) => (
|
|
<NavLink
|
|
key={to}
|
|
to={to}
|
|
className={({ isActive }) =>
|
|
`flex flex-col items-center gap-1 px-3 py-1 min-w-[60px] ${
|
|
isActive ? 'text-zinc-900 dark:text-white' : 'text-zinc-400 dark:text-zinc-500'
|
|
}`
|
|
}
|
|
>
|
|
<Icon size={22} strokeWidth={2} />
|
|
<span className="text-[10px] font-medium">{label}</span>
|
|
</NavLink>
|
|
))}
|
|
<button
|
|
onClick={() => setShowProfile(true)}
|
|
className="flex flex-col items-center gap-1 px-3 py-1 min-w-[60px] text-zinc-400 dark:text-zinc-500"
|
|
>
|
|
<User size={22} strokeWidth={2} />
|
|
<span className="text-[10px] font-medium">{t("nav.profile")}</span>
|
|
</button>
|
|
</nav>
|
|
|
|
{showProfile && <ProfileSheet onClose={() => setShowProfile(false)} />}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function ProfileSheet({ onClose }: { onClose: () => void }) {
|
|
const { t } = useTranslation()
|
|
const { user, logout } = useAuthStore()
|
|
const navigate = useNavigate()
|
|
|
|
const handleNav = (path: string) => {
|
|
onClose()
|
|
navigate(path)
|
|
}
|
|
|
|
const handleLogout = () => {
|
|
onClose()
|
|
logout()
|
|
navigate('/login')
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[300] md:hidden" onClick={onClose}>
|
|
{/* Backdrop */}
|
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
|
|
|
{/* Sheet */}
|
|
<div
|
|
className="absolute bottom-0 left-0 right-0 bg-white dark:bg-zinc-900 rounded-t-2xl overflow-hidden"
|
|
style={{ animation: 'slideUp 0.25s ease-out', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
{/* Handle */}
|
|
<div className="flex justify-center pt-3 pb-2">
|
|
<div className="w-10 h-1 rounded-full bg-zinc-300 dark:bg-zinc-700" />
|
|
</div>
|
|
|
|
{/* User info */}
|
|
<div className="px-6 pb-4 pt-1">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-11 h-11 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[16px] font-bold">
|
|
{(user?.username || '?')[0].toUpperCase()}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-[15px] font-semibold text-zinc-900 dark:text-white">{user?.username}</p>
|
|
<p className="text-[12px] text-zinc-500 truncate">{user?.email}</p>
|
|
</div>
|
|
{user?.role === 'admin' && (
|
|
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-semibold text-zinc-600 dark:text-zinc-400 uppercase tracking-wide">
|
|
<Shield size={10} /> Admin
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="h-px bg-zinc-100 dark:bg-zinc-800 mx-4" />
|
|
|
|
{/* Links */}
|
|
<div className="py-2 px-2">
|
|
<button
|
|
onClick={() => handleNav('/settings')}
|
|
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
|
|
>
|
|
<Settings size={18} className="text-zinc-500" />
|
|
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t("nav.bottomSettings")}</span>
|
|
</button>
|
|
|
|
{user?.role === 'admin' && (
|
|
<button
|
|
onClick={() => handleNav('/admin')}
|
|
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
|
|
>
|
|
<Shield size={18} className="text-zinc-500" />
|
|
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t("nav.bottomAdmin")}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="h-px bg-zinc-100 dark:bg-zinc-800 mx-4" />
|
|
|
|
{/* Logout */}
|
|
<div className="py-2 px-2">
|
|
<button
|
|
onClick={handleLogout}
|
|
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-red-50 dark:hover:bg-red-900/20 active:bg-red-100 transition-colors"
|
|
>
|
|
<LogOut size={18} className="text-red-500" />
|
|
<span className="text-[14px] font-medium text-red-600 dark:text-red-400">{t("nav.bottomLogout")}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="h-4" />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|