Merge remote-tracking branch 'origin/dev' into feat/indonesian-translation

This commit is contained in:
jubnl
2026-04-15 06:28:35 +02:00
320 changed files with 44012 additions and 4351 deletions
@@ -0,0 +1,102 @@
// FE-COMP-BOTTOMNAV-001 to FE-COMP-BOTTOMNAV-009
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
disconnect: vi.fn(),
getSocketId: vi.fn(() => null),
setRefetchCallback: vi.fn(),
setPreReconnectHook: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
}));
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return { ...actual, useNavigate: () => mockNavigate };
});
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { useAuthStore } from '../../store/authStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories';
import BottomNav from './BottomNav';
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
beforeEach(() => {
resetAllStores();
mockNavigate.mockClear();
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
});
describe('BottomNav', () => {
it('FE-COMP-BOTTOMNAV-001: renders without crashing', () => {
render(<BottomNav />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-002: shows Trips nav link', () => {
render(<BottomNav />);
expect(screen.getByText('Trips')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => {
render(<BottomNav />);
expect(screen.getByText('Profile')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-004: profile sheet opens on click', async () => {
const user = userEvent.setup();
render(<BottomNav />);
await user.click(screen.getByText('Profile'));
// Profile sheet shows username
expect(screen.getByText('testuser')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-005: profile sheet shows username', async () => {
const user = userEvent.setup();
render(<BottomNav />);
await user.click(screen.getByText('Profile'));
expect(screen.getByText('testuser')).toBeInTheDocument();
expect(screen.getByText('test@example.com')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-006: profile sheet shows Settings link', async () => {
const user = userEvent.setup();
render(<BottomNav />);
await user.click(screen.getByText('Profile'));
expect(screen.getByText('Settings')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-007: profile sheet shows Logout button', async () => {
const user = userEvent.setup();
render(<BottomNav />);
await user.click(screen.getByText('Profile'));
expect(screen.getByText('Logout')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-008: admin badge shown for admin users', async () => {
const adminUser = buildUser({ id: 2, username: 'adminuser', role: 'admin' });
seedStore(useAuthStore, { user: adminUser, isAuthenticated: true });
const user = userEvent.setup();
render(<BottomNav />);
await user.click(screen.getByText('Profile'));
expect(screen.getByText('Admin')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-009: backdrop click closes profile sheet', async () => {
const user = userEvent.setup();
render(<BottomNav />);
await user.click(screen.getByText('Profile'));
// Sheet is open — username visible
expect(screen.getByText('testuser')).toBeInTheDocument();
// The outermost fixed div is the backdrop wrapper, clicking it triggers onClose
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement;
expect(backdrop).toBeTruthy();
fireEvent.click(backdrop);
// Sheet should be closed — username no longer visible (only the nav Profile text remains)
expect(screen.queryByText('testuser')).not.toBeInTheDocument();
});
});
+164
View File
@@ -0,0 +1,164 @@
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 BASE_ITEMS: { to: string; label: string; icon: LucideIcon; addonId?: string }[] = [
{ to: '/trips', label: 'Trips', icon: Plane },
]
const ADDON_NAV: Record<string, { to: string; label: string; icon: LucideIcon }> = {
vacay: { to: '/vacay', label: 'Vacay', icon: CalendarDays },
atlas: { to: '/atlas', label: 'Atlas', icon: Globe },
journey: { to: '/journey', label: 'Journey', icon: Compass },
}
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 = [...BASE_ITEMS]
for (const addon of globalAddons) {
const nav = ADDON_NAV[addon.id]
if (nav) items.push(nav)
}
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>
)
}
@@ -0,0 +1,32 @@
// FE-COMP-MOBILETOPHEADER-001 to FE-COMP-MOBILETOPHEADER-004
import { describe, it, expect } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import MobileTopHeader from './MobileTopHeader';
describe('MobileTopHeader', () => {
it('FE-COMP-MOBILETOPHEADER-001: renders title as h1', () => {
render(<MobileTopHeader title="Journeys" />);
const heading = screen.getByRole('heading', { level: 1 });
expect(heading).toBeInTheDocument();
expect(heading.textContent).toBe('Journeys');
});
it('FE-COMP-MOBILETOPHEADER-002: renders subtitle when provided', () => {
render(<MobileTopHeader title="Journeys" subtitle="3 trips" />);
expect(screen.getByText('3 trips')).toBeInTheDocument();
});
it('FE-COMP-MOBILETOPHEADER-003: does not render subtitle when omitted', () => {
const { container } = render(<MobileTopHeader title="Journeys" />);
const subtitleEl = container.querySelector('.text-xs.text-zinc-500');
expect(subtitleEl).not.toBeInTheDocument();
});
it('FE-COMP-MOBILETOPHEADER-004: renders action children when provided', () => {
render(
<MobileTopHeader title="Trips" actions={<button>Add</button>} />,
);
expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument();
});
});
@@ -0,0 +1,17 @@
interface Props {
title: string
subtitle?: string
actions?: React.ReactNode
}
export default function MobileTopHeader({ title, subtitle, actions }: Props) {
return (
<div className="px-5 pt-4 pb-3 flex justify-between items-center bg-zinc-50 dark:bg-zinc-950 flex-shrink-0 md:hidden">
<div className="flex-1 min-w-0">
<h1 className="text-[28px] font-extrabold text-zinc-900 dark:text-white tracking-tight leading-none">{title}</h1>
{subtitle && <div className="text-xs text-zinc-500 mt-1">{subtitle}</div>}
</div>
{actions && <div className="flex gap-2 items-center flex-shrink-0">{actions}</div>}
</div>
)
}
+1 -1
View File
@@ -16,7 +16,7 @@ beforeEach(() => {
http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10' })),
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
);
seedStore(useAuthStore, { user: buildUser({ username: 'testuser', role: 'user' }), isAuthenticated: true });
seedStore(useAuthStore, { user: buildUser({ username: 'testuser', role: 'user' }), isAuthenticated: true, appVersion: '2.9.10' });
seedStore(useSettingsStore, { settings: buildSettings() });
});
+15 -11
View File
@@ -5,11 +5,11 @@ import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useTranslation } from '../../i18n'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe } from 'lucide-react'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx'
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe }
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass }
interface NavbarProps {
tripTitle?: string
@@ -27,14 +27,13 @@ interface Addon {
}
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
const { user, logout } = useAuthStore()
const { user, logout, isPrerelease, appVersion } = useAuthStore()
const { settings, updateSetting } = useSettingsStore()
const { addons: allAddons, loadAddons } = useAddonStore()
const { t, locale } = useTranslation()
const navigate = useNavigate()
const location = useLocation()
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
const [appVersion, setAppVersion] = useState<string | null>(null)
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
@@ -45,12 +44,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
if (user) loadAddons()
}, [user, location.pathname])
useEffect(() => {
import('../../api/client').then(({ authApi }) => {
authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {})
})
}, [])
const handleLogout = () => {
logout()
navigate('/login', { state: { noRedirect: true } })
@@ -75,7 +68,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
touchAction: 'manipulation',
paddingTop: 'env(safe-area-inset-top, 0px)',
height: 'var(--nav-h)',
}} className="flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
{/* Left side */}
<div className="flex items-center gap-3 min-w-0">
{showBack && (
@@ -155,6 +148,17 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
</button>
)}
{/* Prerelease badge */}
{isPrerelease && appVersion && (
<span
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold flex-shrink-0"
style={{ background: 'rgba(245,158,11,0.15)', color: '#d97706', border: '1px solid rgba(245,158,11,0.3)' }}
>
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ background: '#f59e0b' }} />
{appVersion}
</span>
)}
{/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */}
<button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex"
@@ -0,0 +1,86 @@
/**
* OfflineBanner — persistent top bar indicating connectivity + sync state.
*
* States:
* offline + N queued → amber bar "Offline — N changes queued"
* offline + 0 queued → amber bar "Offline"
* online + N pending → blue bar "Syncing N changes…"
* online + 0 pending → hidden
*/
import React, { useState, useEffect } from 'react'
import { WifiOff, RefreshCw } from 'lucide-react'
import { mutationQueue } from '../../sync/mutationQueue'
const POLL_MS = 3_000
export default function OfflineBanner(): React.ReactElement | null {
const [isOnline, setIsOnline] = useState(navigator.onLine)
const [pendingCount, setPendingCount] = useState(0)
useEffect(() => {
const onOnline = () => setIsOnline(true)
const onOffline = () => setIsOnline(false)
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
return () => {
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
}
}, [])
useEffect(() => {
let cancelled = false
async function poll() {
const n = await mutationQueue.pendingCount()
if (!cancelled) setPendingCount(n)
}
poll()
const id = setInterval(poll, POLL_MS)
return () => { cancelled = true; clearInterval(id) }
}, [])
const hidden = isOnline && pendingCount === 0
if (hidden) return null
const offline = !isOnline
const bg = offline ? '#92400e' : '#1e40af'
const text = '#fff'
const label = offline
? pendingCount > 0
? `Offline — ${pendingCount} change${pendingCount !== 1 ? 's' : ''} queued`
: 'Offline'
: `Syncing ${pendingCount} change${pendingCount !== 1 ? 's' : ''}`
return (
<div
role="status"
aria-live="polite"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 9999,
background: bg,
color: text,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 6px)',
paddingBottom: '6px',
paddingLeft: '16px',
paddingRight: '16px',
fontSize: 13,
fontWeight: 500,
}}
>
{offline
? <WifiOff size={14} />
: <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
}
{label}
</div>
)
}