mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
feat(dashboard): mobile layout, glass UI, context bottom nav + OIDC PKCE (#1079)
* feat(dashboard): mobile layout, glass tiles, plain-text countdown, place photos - Rework the mobile dashboard: cover hero, separate boarding-pass card, trimmed atlas (trips + days only), stacked widgets - New floating bottom tab bar with a centred context-aware + button (new trip / place / journey / entry depending on the page) - Move profile + notifications into a small top strip on the dashboard - Desktop: glassmorphic tiles (light + dark), neutral dark palette, plain-text countdown module, real place photos in the boarding pass * i18n(dashboard): translate new dashboard keys across all locales Fill the dashboard-rework keys (hero, atlas, fx, tz, upcoming, copy dialog, aria labels, countdown) that were left as English placeholders, plus the new startsIn/aria keys, for all 19 languages. * feat(oidc): send PKCE (S256) in the OIDC login flow The OIDC client now generates a code_verifier per login, sends the S256 code_challenge on the authorize request and the code_verifier on the token exchange. Works whether the provider has PKCE optional or required (fixes login against providers that require PKCE, e.g. Pocket ID).
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-BOTTOMNAV-001 to FE-COMP-BOTTOMNAV-009
|
||||
// FE-COMP-BOTTOMNAV-001 to FE-COMP-BOTTOMNAV-006
|
||||
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
@@ -16,7 +16,7 @@ vi.mock('react-router-dom', async () => {
|
||||
return { ...actual, useNavigate: () => mockNavigate };
|
||||
});
|
||||
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
@@ -39,82 +39,25 @@ describe('BottomNav', () => {
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-002: shows Trips nav link', () => {
|
||||
it('FE-COMP-BOTTOMNAV-002: shows the dashboard nav item', () => {
|
||||
render(<BottomNav />);
|
||||
expect(screen.getByText('My 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 () => {
|
||||
it('FE-COMP-BOTTOMNAV-003: centre create button creates a new trip by default', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BottomNav />);
|
||||
await user.click(screen.getByText('Profile'));
|
||||
// Profile sheet shows username
|
||||
expect(screen.getByText('testuser')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'New Trip' }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/dashboard?create=1');
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-010: Trips label translates when language is fr', async () => {
|
||||
it('FE-COMP-BOTTOMNAV-004: dashboard label translates when language is fr', async () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
|
||||
render(<BottomNav />);
|
||||
expect(await screen.findByText('Mes voyages')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-011: Profile label translates when language is fr', async () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
|
||||
render(<BottomNav />);
|
||||
expect(await screen.findByText('Profil')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-012: addon labels translate when language is fr', async () => {
|
||||
it('FE-COMP-BOTTOMNAV-005: addon labels translate when language is fr', async () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
|
||||
seedStore(useAddonStore, {
|
||||
addons: [
|
||||
@@ -129,7 +72,7 @@ describe('BottomNav', () => {
|
||||
expect(await screen.findByText('Journal de voyage')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-013: unknown addon id is not rendered', () => {
|
||||
it('FE-COMP-BOTTOMNAV-006: unknown addon id is not rendered', () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'foo', name: 'Foo Addon', type: 'global', icon: 'star', enabled: true }],
|
||||
});
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import { useNavigate, useLocation, useMatch } 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 { LayoutGrid, CalendarDays, Globe, Compass, Plus } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
|
||||
@@ -13,150 +11,106 @@ const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
|
||||
journey: { icon: Compass, labelKey: 'admin.addons.catalog.journey.name' },
|
||||
}
|
||||
|
||||
interface NavItem { to: string; label: string; icon: LucideIcon }
|
||||
|
||||
// The centre "+" means something different per context: inside a trip it adds a
|
||||
// place, on the journey list it starts a journey, inside a journey it adds an
|
||||
// entry — everywhere else it creates a new trip. Pages pick the intent up from
|
||||
// the ?create= query param.
|
||||
function useCreateAction(): { label: string; run: () => void } {
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
const inTrip = useMatch('/trips/:id')
|
||||
const inJourney = useMatch('/journey/:id')
|
||||
const onJourneyList = useMatch('/journey')
|
||||
|
||||
if (inTrip) {
|
||||
return { label: t('places.addPlace'), run: () => navigate(`/trips/${inTrip.params.id}?create=place`) }
|
||||
}
|
||||
if (inJourney) {
|
||||
return { label: t('journey.detail.addEntry'), run: () => navigate(`/journey/${inJourney.params.id}?create=entry`) }
|
||||
}
|
||||
if (onJourneyList) {
|
||||
return { label: t('journey.new'), run: () => navigate('/journey?create=1') }
|
||||
}
|
||||
return { label: t('dashboard.newTrip'), run: () => navigate('/dashboard?create=1') }
|
||||
}
|
||||
|
||||
export default function BottomNav() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
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 location = useLocation()
|
||||
const create = useCreateAction()
|
||||
|
||||
const items: { to: string; label: string; icon: LucideIcon }[] = [
|
||||
{ to: '/trips', label: t('nav.myTrips'), icon: Plane },
|
||||
const items: NavItem[] = [
|
||||
{ to: '/dashboard', label: t('nav.myTrips'), icon: LayoutGrid },
|
||||
...globalAddons.flatMap(addon => {
|
||||
const nav = ADDON_NAV[addon.id]
|
||||
return nav ? [{ to: `/${addon.id}`, label: t(nav.labelKey), icon: nav.icon }] : []
|
||||
}),
|
||||
]
|
||||
// Split the items so the raised "+" sits dead centre.
|
||||
const splitAt = Math.ceil(items.length / 2)
|
||||
const left = items.slice(0, splitAt)
|
||||
const right = items.slice(splitAt)
|
||||
|
||||
const isActive = (to: string) =>
|
||||
to === '/dashboard' ? location.pathname === '/dashboard' : location.pathname.startsWith(to)
|
||||
|
||||
const renderItem = ({ to, label, icon: Icon }: NavItem) => {
|
||||
const active = isActive(to)
|
||||
return (
|
||||
<button
|
||||
key={to}
|
||||
onClick={() => navigate(to)}
|
||||
className="flex flex-col items-center gap-1 py-1 px-1 min-w-0"
|
||||
style={{ color: active ? (dark ? '#fff' : 'oklch(0.22 0 0)') : (dark ? 'oklch(0.6 0 0)' : 'oklch(0.62 0.01 65)') }}
|
||||
>
|
||||
<Icon size={21} strokeWidth={active ? 2.4 : 1.9} />
|
||||
<span className="text-[10px] font-semibold tracking-tight truncate max-w-full">{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
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"
|
||||
<nav
|
||||
className="md:hidden fixed z-[60] flex items-center"
|
||||
style={{
|
||||
left: 12, right: 12,
|
||||
bottom: 'calc(12px + env(safe-area-inset-bottom, 0px))',
|
||||
padding: '8px 8px',
|
||||
borderRadius: 24,
|
||||
background: dark ? 'oklch(0.2 0 0 / 0.72)' : 'rgba(255,255,255,0.78)',
|
||||
backdropFilter: 'saturate(1.7) blur(22px)',
|
||||
WebkitBackdropFilter: 'saturate(1.7) blur(22px)',
|
||||
border: dark ? '1px solid oklch(1 0 0 / .1)' : '1px solid oklch(0.92 0.008 70 / .6)',
|
||||
boxShadow: dark
|
||||
? '0 12px 40px -8px oklch(0 0 0 / .6), inset 0 1px 0 oklch(1 0 0 / .08)'
|
||||
: '0 12px 40px -8px oklch(0 0 0 / .22), inset 0 1px 0 oklch(1 0 0 / .8)',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-1 items-center justify-around min-w-0">{left.map(renderItem)}</div>
|
||||
|
||||
<button
|
||||
onClick={create.run}
|
||||
aria-label={create.label}
|
||||
className="flex items-center justify-center flex-shrink-0 active:scale-95 transition-transform"
|
||||
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)',
|
||||
width: 46, height: 46, marginInline: 8,
|
||||
borderRadius: '50%',
|
||||
background: dark ? '#fff' : 'oklch(0.22 0 0)',
|
||||
color: dark ? 'oklch(0.22 0 0)' : '#fff',
|
||||
boxShadow: '0 4px 12px oklch(0 0 0 / .22)',
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
<Plus size={24} strokeWidth={2.6} />
|
||||
</button>
|
||||
|
||||
{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>
|
||||
<div className="flex flex-1 items-center justify-around min-w-0">{right.map(renderItem)}</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// FE-COMP-MOBILETOPBAR-001 to FE-COMP-MOBILETOPBAR-007
|
||||
|
||||
vi.mock('./InAppNotificationBell', () => ({ default: () => null }));
|
||||
|
||||
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 { useSettingsStore } from '../../store/settingsStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildSettings } from '../../../tests/helpers/factories';
|
||||
import MobileTopBar from './MobileTopBar';
|
||||
|
||||
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
mockNavigate.mockClear();
|
||||
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
|
||||
});
|
||||
|
||||
describe('MobileTopBar', () => {
|
||||
it('FE-COMP-MOBILETOPBAR-001: renders the profile avatar (no brand logo)', () => {
|
||||
render(<MobileTopBar />, { initialEntries: ['/dashboard'] });
|
||||
expect(screen.getByRole('button', { name: 'Profile' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('trek')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOBILETOPBAR-002: avatar opens the profile sheet', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MobileTopBar />, { initialEntries: ['/dashboard'] });
|
||||
await user.click(screen.getByRole('button', { name: 'Profile' }));
|
||||
expect(screen.getByText('testuser')).toBeInTheDocument();
|
||||
expect(screen.getByText('test@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOBILETOPBAR-003: profile sheet shows Settings', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MobileTopBar />, { initialEntries: ['/dashboard'] });
|
||||
await user.click(screen.getByRole('button', { name: 'Profile' }));
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOBILETOPBAR-004: profile sheet shows Logout', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MobileTopBar />, { initialEntries: ['/dashboard'] });
|
||||
await user.click(screen.getByRole('button', { name: 'Profile' }));
|
||||
expect(screen.getByText('Logout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOBILETOPBAR-005: admin badge shown for admin users', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 2, username: 'adminuser', role: 'admin' }), isAuthenticated: true });
|
||||
const user = userEvent.setup();
|
||||
render(<MobileTopBar />, { initialEntries: ['/dashboard'] });
|
||||
await user.click(screen.getByRole('button', { name: 'Profile' }));
|
||||
expect(screen.getByText('Admin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOBILETOPBAR-006: backdrop click closes the profile sheet', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MobileTopBar />, { initialEntries: ['/dashboard'] });
|
||||
await user.click(screen.getByRole('button', { name: 'Profile' }));
|
||||
expect(screen.getByText('testuser')).toBeInTheDocument();
|
||||
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement;
|
||||
expect(backdrop).toBeTruthy();
|
||||
fireEvent.click(backdrop);
|
||||
expect(screen.queryByText('testuser')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOBILETOPBAR-007: profile label translates when language is fr', async () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
|
||||
render(<MobileTopBar />, { initialEntries: ['/dashboard'] });
|
||||
expect(await screen.findByRole('button', { name: 'Profil' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useInAppNotificationStore } from '../../store/inAppNotificationStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Bell, Settings, Shield, LogOut } from 'lucide-react'
|
||||
|
||||
// Mobile-only: a slim strip at the very top of the dashboard with the
|
||||
// notification + profile icons (right-aligned). Scrolls with the page.
|
||||
export default function MobileTopBar() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { user, isAuthenticated } = useAuthStore()
|
||||
const unread = useInAppNotificationStore(s => s.unreadCount)
|
||||
const fetchUnreadCount = useInAppNotificationStore(s => s.fetchUnreadCount)
|
||||
const [showProfile, setShowProfile] = useState(false)
|
||||
|
||||
useEffect(() => { if (isAuthenticated) fetchUnreadCount() }, [isAuthenticated])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="md:hidden flex items-center justify-end gap-2 px-4"
|
||||
style={{ paddingTop: 'calc(10px + env(safe-area-inset-top, 0px))', paddingBottom: 10 }}
|
||||
>
|
||||
<button
|
||||
onClick={() => navigate('/notifications')}
|
||||
aria-label={t('notifications.title')}
|
||||
className="relative grid place-items-center rounded-full active:scale-95 transition-transform"
|
||||
style={{ width: 36, height: 36, color: 'var(--ink-2, #52525b)' }}
|
||||
>
|
||||
<Bell size={20} strokeWidth={1.9} />
|
||||
{unread > 0 && (
|
||||
<span style={{ position: 'absolute', top: 7, right: 7, width: 8, height: 8, borderRadius: '50%', background: 'oklch(0.7 0.17 38)', boxShadow: '0 0 0 2px var(--bg, #fff)' }} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowProfile(true)}
|
||||
aria-label={t('nav.profile')}
|
||||
className="grid place-items-center rounded-full text-white font-semibold text-[12px] active:scale-95 transition-transform"
|
||||
style={{ width: 34, height: 34, background: 'linear-gradient(135deg, oklch(0.7 0.14 38), oklch(0.55 0.13 25))' }}
|
||||
>
|
||||
{(user?.username || '?')[0].toUpperCase()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showProfile && createPortal(<ProfileSheet onClose={() => setShowProfile(false)} />, document.body)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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}>
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
||||
<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()}
|
||||
>
|
||||
<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>
|
||||
|
||||
<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" />
|
||||
|
||||
<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" />
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -65,7 +65,7 @@ describe('DashboardPage', () => {
|
||||
});
|
||||
|
||||
describe('FE-PAGE-DASH-004: Empty state when no trips', () => {
|
||||
it('shows empty state message when API returns no trips', async () => {
|
||||
it('shows the add-trip card when API returns no trips', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips', () => {
|
||||
return HttpResponse.json({ trips: [] });
|
||||
@@ -74,8 +74,9 @@ describe('DashboardPage', () => {
|
||||
|
||||
render(<DashboardPage />);
|
||||
|
||||
// With no trips the planned filter falls back to the "add trip" card
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no trips yet/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/plan a new trip from scratch/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -206,17 +207,11 @@ describe('DashboardPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-DASH-011: Archive trip moves it to archived section', () => {
|
||||
it('archiving a trip removes it from active and shows it in archived section', async () => {
|
||||
describe('FE-PAGE-DASH-011: Archive trip moves it to the archive filter', () => {
|
||||
it('archiving a trip removes it from active and shows it under the archive filter', async () => {
|
||||
const archivedTrip = buildTrip({ title: 'Paris Adventure', start_date: '2026-07-01', end_date: '2026-07-10', is_archived: true });
|
||||
server.use(
|
||||
http.put('/api/trips/:id', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
if (body.is_archived === true) {
|
||||
return HttpResponse.json({ trip: archivedTrip });
|
||||
}
|
||||
return HttpResponse.json({ trip: archivedTrip });
|
||||
}),
|
||||
http.put('/api/trips/:id', () => HttpResponse.json({ trip: archivedTrip })),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
@@ -226,17 +221,12 @@ describe('DashboardPage', () => {
|
||||
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click archive button
|
||||
const archiveButtons = screen.getAllByRole('button', { name: /archive/i });
|
||||
// The spotlight hero exposes an icon-only archive action
|
||||
const archiveButtons = screen.getAllByRole('button', { name: /archive/i }).filter(b => !b.textContent?.trim());
|
||||
await user.click(archiveButtons[0]);
|
||||
|
||||
// Wait for archived section toggle to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /archived/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click "Archived" toggle to show archived trips
|
||||
await user.click(screen.getByRole('button', { name: /archived/i }));
|
||||
// Switch to the archive filter segment
|
||||
await user.click(screen.getByText('Archive'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
|
||||
@@ -272,8 +262,8 @@ describe('DashboardPage', () => {
|
||||
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the view mode toggle button (shows List icon when in grid mode, title "List view")
|
||||
const viewToggle = screen.getByTitle(/list view/i);
|
||||
// The view-mode toggle flips grid ↔ list and persists the choice
|
||||
const viewToggle = screen.getByRole('button', { name: /toggle view/i });
|
||||
await user.click(viewToggle);
|
||||
|
||||
// localStorage should be updated to 'list'
|
||||
@@ -281,8 +271,8 @@ describe('DashboardPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-DASH-014: Archived trips section toggles visibility', () => {
|
||||
it('shows archived trips when the archived section toggle is clicked', async () => {
|
||||
describe('FE-PAGE-DASH-014: Archive filter reveals archived trips', () => {
|
||||
it('shows archived trips when the archive filter is selected', async () => {
|
||||
const oldTrip = buildTrip({ title: 'Old Rome Trip', start_date: '2024-01-01', end_date: '2024-01-07', is_archived: true });
|
||||
server.use(
|
||||
http.get('/api/trips', ({ request }) => {
|
||||
@@ -302,13 +292,8 @@ describe('DashboardPage', () => {
|
||||
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Archived section toggle should be present
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /archived/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click to expand
|
||||
await user.click(screen.getByRole('button', { name: /archived/i }));
|
||||
// Switch to the archive filter
|
||||
await user.click(screen.getByText('Archive'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
|
||||
@@ -343,7 +328,7 @@ describe('DashboardPage', () => {
|
||||
});
|
||||
|
||||
// Switch to list view
|
||||
const viewToggle = screen.getByTitle(/list view/i);
|
||||
const viewToggle = screen.getByRole('button', { name: /toggle view/i });
|
||||
await user.click(viewToggle);
|
||||
|
||||
// Non-spotlight trips should be visible in list view
|
||||
@@ -367,7 +352,7 @@ describe('DashboardPage', () => {
|
||||
});
|
||||
|
||||
// Switch to list view
|
||||
const viewToggle = screen.getByTitle(/list view/i);
|
||||
const viewToggle = screen.getByRole('button', { name: /toggle view/i });
|
||||
await user.click(viewToggle);
|
||||
|
||||
// Non-spotlight trips render in list view
|
||||
@@ -397,8 +382,8 @@ describe('DashboardPage', () => {
|
||||
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find copy buttons
|
||||
const copyButtons = screen.getAllByRole('button', { name: /copy/i });
|
||||
// Find duplicate buttons (the copy action is labelled "Duplicate")
|
||||
const copyButtons = screen.getAllByRole('button', { name: /duplicate/i });
|
||||
await user.click(copyButtons[0]);
|
||||
|
||||
// Confirm the copy dialog
|
||||
@@ -411,28 +396,18 @@ describe('DashboardPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-DASH-019: Widget settings dropdown opens and closes', () => {
|
||||
it('clicking the settings button shows the widget toggles', async () => {
|
||||
const user = userEvent.setup();
|
||||
describe('FE-PAGE-DASH-019: Currency converter widget renders in the sidebar', () => {
|
||||
it('shows the currency widget with from/to fields', async () => {
|
||||
render(<DashboardPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Paris Adventure').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Find settings button — the gear icon button (icon-only, no visible label)
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const settingsButton = allBtns.find(btn =>
|
||||
btn.querySelector('.lucide-settings') && !btn.textContent?.trim()
|
||||
);
|
||||
|
||||
expect(settingsButton).toBeDefined();
|
||||
if (settingsButton) {
|
||||
await user.click(settingsButton);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Widgets:')).toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
// The sidebar currency tool exposes its title and the From/To converter fields
|
||||
expect(screen.getByText('Currency')).toBeInTheDocument();
|
||||
expect(screen.getByText('From')).toBeInTheDocument();
|
||||
expect(screen.getByText('To')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -463,23 +438,23 @@ describe('DashboardPage', () => {
|
||||
render(<DashboardPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /archived/i })).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Expand archived section
|
||||
await user.click(screen.getByRole('button', { name: /archived/i }));
|
||||
// Switch to the archive filter
|
||||
await user.click(screen.getByText('Archive'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click restore button
|
||||
const restoreBtn = screen.getByRole('button', { name: /restore/i });
|
||||
await user.click(restoreBtn);
|
||||
// An archived card's archive action is labelled "Restore" and toggles the trip back to active
|
||||
const card = screen.getByText('Old Rome Trip').closest('.trip-card') as HTMLElement;
|
||||
await user.click(card.querySelector('[aria-label="Restore"]') as HTMLElement);
|
||||
|
||||
// After restore, archived section should disappear (no more archived trips)
|
||||
// Once restored there are no archived trips left to show
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: /archived/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Old Rome Trip')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -572,15 +547,12 @@ describe('DashboardPage', () => {
|
||||
expect(screen.getAllByText('Current Voyage').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Live badge text appears (mobile + desktop spotlight)
|
||||
// Live badge appears on the boarding-pass hero for an ongoing trip
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/live now/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Progress bar label "Trip progress" appears
|
||||
expect(screen.getAllByText(/trip progress/i).length).toBeGreaterThan(0);
|
||||
|
||||
// "days left" label appears inside the progress section
|
||||
// The countdown ring labels the remaining days
|
||||
expect(screen.getAllByText(/days left/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -612,11 +584,10 @@ describe('DashboardPage', () => {
|
||||
expect(screen.getAllByText('Upcoming Safari').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Badge should show "X days left" countdown (not "Live now")
|
||||
// An upcoming trip is not "live", and the countdown cell counts down to the start
|
||||
expect(screen.queryByText(/live now/i)).not.toBeInTheDocument();
|
||||
// The SpotlightCard renders a badge with the countdown text containing "days"
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/days/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/trip starts in/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -636,39 +607,22 @@ describe('DashboardPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-DASH-026: Widget settings toggles currency and timezone', () => {
|
||||
it('toggling currency widget off hides it from settings', async () => {
|
||||
const user = userEvent.setup();
|
||||
describe('FE-PAGE-DASH-026: Timezone widget renders in the sidebar', () => {
|
||||
it('shows the timezone widget with an add-zone control', async () => {
|
||||
render(<DashboardPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Paris Adventure').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Open widget settings — gear icon button (icon-only, no visible label)
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const settingsButton = allBtns.find(btn =>
|
||||
btn.querySelector('.lucide-settings') && !btn.textContent?.trim()
|
||||
);
|
||||
|
||||
expect(settingsButton).toBeDefined();
|
||||
if (settingsButton) {
|
||||
await user.click(settingsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Widgets:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Both currency and timezone toggle labels should be visible
|
||||
// Use getAllByText because labels may appear in both widget settings and quick actions
|
||||
expect(screen.getAllByText(/currency/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/timezone/i).length).toBeGreaterThan(0);
|
||||
}
|
||||
// The timezone tool title and its add-zone button are present
|
||||
expect(screen.getByText('Timezones')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /add timezone/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-DASH-027: Archived section expand and collapse', () => {
|
||||
it('expands and then collapses the archived trips section', async () => {
|
||||
describe('FE-PAGE-DASH-027: Archive filter toggles archived trips in and out of view', () => {
|
||||
it('shows archived trips under the archive filter and hides them under planned', async () => {
|
||||
const activeTrip = buildTrip({ title: 'Active Trip', start_date: '2026-08-01', end_date: '2026-08-10' });
|
||||
const archivedTrip = buildTrip({ title: 'Old Archived Trip', start_date: '2024-03-01', end_date: '2024-03-07', is_archived: true });
|
||||
|
||||
@@ -686,17 +640,17 @@ describe('DashboardPage', () => {
|
||||
render(<DashboardPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /archived/i })).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Active Trip')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Expand
|
||||
await user.click(screen.getByRole('button', { name: /archived/i }));
|
||||
// Archive filter reveals the archived trip
|
||||
await user.click(screen.getByText('Archive'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Old Archived Trip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Collapse
|
||||
await user.click(screen.getByRole('button', { name: /archived/i }));
|
||||
// Switching back to the planned filter hides it again
|
||||
await user.click(screen.getByText('Planned'));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Old Archived Trip')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -730,21 +684,22 @@ describe('DashboardPage', () => {
|
||||
render(<DashboardPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /archived/i })).toBeInTheDocument();
|
||||
expect(screen.getAllByText('My Active Trip')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /archived/i }));
|
||||
await user.click(screen.getByText('Archive'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Restored Trip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const restoreBtn = screen.getByRole('button', { name: /restore/i });
|
||||
await user.click(restoreBtn);
|
||||
// An archived card's archive action is labelled "Restore" and restores the trip
|
||||
const card = screen.getByText('Restored Trip').closest('.trip-card') as HTMLElement;
|
||||
await user.click(card.querySelector('[aria-label="Restore"]') as HTMLElement);
|
||||
|
||||
// After restore, the archived section should disappear (no archived trips left)
|
||||
// After restore the archive filter has nothing left to show
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: /archived/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Restored Trip')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -765,8 +720,8 @@ describe('DashboardPage', () => {
|
||||
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find copy buttons (may appear in mobile + desktop)
|
||||
const copyButtons = screen.getAllByRole('button', { name: /copy/i });
|
||||
// Find duplicate buttons (the copy action is labelled "Duplicate")
|
||||
const copyButtons = screen.getAllByRole('button', { name: /duplicate/i });
|
||||
expect(copyButtons.length).toBeGreaterThan(0);
|
||||
await user.click(copyButtons[0]);
|
||||
|
||||
@@ -791,10 +746,10 @@ describe('DashboardPage', () => {
|
||||
render(<DashboardPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no trips yet/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/plan a new trip from scratch/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Empty state should show a descriptive text and a create button
|
||||
// The add-trip card and the floating action button both offer a way to create a trip
|
||||
const createButtons = screen.getAllByRole('button');
|
||||
const createBtn = createButtons.find(btn => btn.textContent?.toLowerCase().includes('trip'));
|
||||
expect(createBtn).toBeDefined();
|
||||
@@ -828,16 +783,15 @@ describe('DashboardPage', () => {
|
||||
expect(screen.getAllByText('Live Adventure').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Stats section: places count "5" and buddies count "2" appear
|
||||
// Boarding pass summarises destinations and travelers for the spotlight trip
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('5').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('2').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/5 destinations/i)).toBeInTheDocument();
|
||||
// shared_count (2) + the owner = 3 travelers
|
||||
expect(screen.getByText(/3 travelers/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Days stat label
|
||||
expect(screen.getAllByText(/days/i).length).toBeGreaterThan(0);
|
||||
// Places stat label
|
||||
expect(screen.getAllByText(/places/i).length).toBeGreaterThan(0);
|
||||
// The countdown ring labels the remaining days of the ongoing trip
|
||||
expect(screen.getAllByText(/days left/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import TripFormModal from '../components/Trips/TripFormModal'
|
||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||
import CopyTripDialog from '../components/shared/CopyTripDialog'
|
||||
import CustomSelect from '../components/shared/CustomSelect'
|
||||
import PlaceAvatar from '../components/shared/PlaceAvatar'
|
||||
import MobileTopBar from '../components/Layout/MobileTopBar'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import {
|
||||
Plus, Edit2, Trash2, Archive, Copy, ArrowRight, MapPin,
|
||||
@@ -110,7 +112,11 @@ function initials(name: string | null | undefined): string {
|
||||
}
|
||||
|
||||
interface Member { id: number; username: string; avatar_url?: string | null }
|
||||
interface Place { id: number; name: string; image_url?: string | null }
|
||||
interface Place {
|
||||
id: number; name: string; image_url: string | null; lat: number | null; lng: number | null
|
||||
google_place_id: string | null; osm_id: string | null
|
||||
category_color?: string | null; category_icon?: string | null
|
||||
}
|
||||
interface HeroBundle { members: Member[]; places: Place[] }
|
||||
interface TravelStats { totalTrips?: number; totalDays?: number; totalPlaces?: number; totalDistanceKm?: number; countries?: string[] }
|
||||
interface UpcomingReservation {
|
||||
@@ -124,6 +130,18 @@ const RES_ICON: Record<string, React.ReactElement> = {
|
||||
}
|
||||
const RES_TYPE_CLASS: Record<string, string> = { flight: 'flight', hotel: 'hotel', restaurant: 'food' }
|
||||
|
||||
// Mobile gets a different boarding-pass treatment (separate card under the hero).
|
||||
function useIsMobile(): boolean {
|
||||
const [mobile, setMobile] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 720px)').matches)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 720px)')
|
||||
const onChange = () => setMobile(mq.matches)
|
||||
mq.addEventListener('change', onChange)
|
||||
return () => mq.removeEventListener('change', onChange)
|
||||
}, [])
|
||||
return mobile
|
||||
}
|
||||
|
||||
export default function DashboardPage(): React.ReactElement {
|
||||
const [trips, setTrips] = useState<DashboardTrip[]>([])
|
||||
const [archivedTrips, setArchivedTrips] = useState<DashboardTrip[]>([])
|
||||
@@ -274,10 +292,11 @@ export default function DashboardPage(): React.ReactElement {
|
||||
: rest.filter(t => getTripStatus(t) !== 'past')
|
||||
|
||||
return (
|
||||
<div className="trek-dash" style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="trek-dash trek-dash-shell">
|
||||
<Navbar />
|
||||
{demoMode && <DemoBanner />}
|
||||
<div style={{ flex: 1, overflow: 'auto', overscrollBehavior: 'contain', marginTop: 'var(--nav-h)' }}>
|
||||
<div className="trek-dash-scroll">
|
||||
<MobileTopBar />
|
||||
<main className="page">
|
||||
<div className="page-main">
|
||||
{spotlight && (
|
||||
@@ -304,10 +323,10 @@ export default function DashboardPage(): React.ReactElement {
|
||||
<button className={tripFilter === 'archive' ? 'on' : ''} onClick={() => setTripFilter('archive')}>{t('dashboard.archive')}</button>
|
||||
<button className={tripFilter === 'completed' ? 'on' : ''} onClick={() => setTripFilter('completed')}>{t('dashboard.mobile.completed')}</button>
|
||||
</div>
|
||||
<button className="tool-action" aria-label="Toggle view" onClick={toggleViewMode} style={{ width: 38, height: 38, borderRadius: 11 }}>
|
||||
<button className="tool-action" aria-label={t('dashboard.aria.toggleView')} onClick={toggleViewMode} style={{ width: 38, height: 38, borderRadius: 11 }}>
|
||||
{viewMode === 'grid' ? <List size={17} /> : <LayoutGrid size={17} />}
|
||||
</button>
|
||||
<button className="tool-action" aria-label="Filter" style={{ width: 38, height: 38, borderRadius: 11 }}>
|
||||
<button className="tool-action" aria-label={t('dashboard.aria.filter')} style={{ width: 38, height: 38, borderRadius: 11 }}>
|
||||
<SlidersHorizontal size={17} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -395,45 +414,36 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
|
||||
onEdit: () => void; onCopy: () => void; onArchive: () => void; onDelete: () => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const mobile = useIsMobile()
|
||||
const stop = (e: React.MouseEvent, fn: () => void) => { e.stopPropagation(); fn() }
|
||||
const status = getTripStatus(trip)
|
||||
const start = splitDate(trip.start_date, locale)
|
||||
const end = splitDate(trip.end_date, locale)
|
||||
const dayCount = trip.day_count || (trip.start_date && trip.end_date
|
||||
? Math.round((new Date(trip.end_date).getTime() - new Date(trip.start_date).getTime()) / MS_PER_DAY) + 1
|
||||
: null)
|
||||
|
||||
// Countdown cell — plain text in the same style as the trip-dates cell:
|
||||
// days remaining while the trip runs, days until departure before it starts.
|
||||
const until = daysUntil(trip.start_date)
|
||||
const ongoing = status === 'ongoing'
|
||||
let ringFraction = 0
|
||||
let countdownTop = ''
|
||||
let countdownNumber = ''
|
||||
let countdownLabel = ''
|
||||
if (ongoing && trip.end_date) {
|
||||
const todayMid = new Date(); todayMid.setHours(0, 0, 0, 0)
|
||||
const endMid = new Date(trip.end_date + 'T00:00:00')
|
||||
const daysLeft = Math.max(0, Math.round((endMid.getTime() - todayMid.getTime()) / MS_PER_DAY))
|
||||
// Ring tracks progress through the trip; the number shows what's left.
|
||||
if (trip.start_date && dayCount) {
|
||||
const elapsed = Math.round((Date.now() - new Date(trip.start_date + 'T00:00:00').getTime()) / MS_PER_DAY) + 1
|
||||
ringFraction = Math.min(1, Math.max(0.04, elapsed / dayCount))
|
||||
} else {
|
||||
ringFraction = 0.5
|
||||
}
|
||||
countdownTop = t('dashboard.status.ongoing')
|
||||
countdownNumber = String(daysLeft)
|
||||
countdownLabel = daysLeft === 0 ? t('dashboard.hero.lastDay') : daysLeft === 1 ? t('dashboard.hero.dayLeft') : t('dashboard.hero.daysLeft')
|
||||
} else if (until !== null && until >= 0) {
|
||||
// Closer trips fill more of the ring (1-year horizon).
|
||||
ringFraction = Math.min(1, Math.max(0.04, 1 - until / 365))
|
||||
countdownTop = t('dashboard.hero.startsIn')
|
||||
countdownNumber = String(until)
|
||||
countdownLabel = until === 1 ? t('dashboard.hero.dayLeft') : t('dashboard.hero.daysLeft')
|
||||
countdownLabel = until === 1 ? t('dashboard.hero.dayUnitOne') : t('dashboard.hero.dayUnitMany')
|
||||
}
|
||||
const RING_LEN = 170
|
||||
const dashOffset = RING_LEN * (1 - ringFraction)
|
||||
|
||||
const members = bundle?.members || []
|
||||
const places = (bundle?.places || []).filter(p => p.image_url)
|
||||
const places = bundle?.places || []
|
||||
const buddyCount = trip.shared_count != null ? trip.shared_count + 1 : members.length
|
||||
const placeCount = trip.place_count || (bundle?.places.length ?? 0)
|
||||
const placeCount = trip.place_count || places.length
|
||||
|
||||
const badge = status === 'ongoing' ? t('dashboard.hero.badgeLive')
|
||||
: status === 'today' ? t('dashboard.hero.badgeToday')
|
||||
@@ -441,7 +451,61 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
|
||||
: status === 'future' ? t('dashboard.hero.badgeNext')
|
||||
: t('dashboard.hero.badgeRecent')
|
||||
|
||||
const passCells = (
|
||||
<>
|
||||
<div className="pass-cell buddies">
|
||||
<div className="pass-label">{t('dashboard.members')}</div>
|
||||
<div className="buddies-avatars">
|
||||
{members.slice(0, 4).map((m, i) => (
|
||||
m.avatar_url
|
||||
? <img key={m.id} className="buddy-avatar" src={m.avatar_url} alt={m.username} style={{ objectFit: 'cover' }} />
|
||||
: <div key={m.id} className="buddy-avatar" style={{ background: buddyColor(i) }}>{initials(m.username)}</div>
|
||||
))}
|
||||
{members.length > 4 && <div className="buddy-more">+{members.length - 4}</div>}
|
||||
{members.length === 0 && <div className="buddy-avatar" style={{ background: buddyColor(0) }}>{initials(trip.owner_username)}</div>}
|
||||
</div>
|
||||
<div className="date-month">{buddyCount === 1 ? t('dashboard.hero.travelerOne', { count: buddyCount }) : t('dashboard.hero.travelerMany', { count: buddyCount })}</div>
|
||||
</div>
|
||||
|
||||
<div className="pass-cell dates-combined">
|
||||
<div className="pass-label">{t('dashboard.hero.tripDates')}</div>
|
||||
<div className="dates-row">
|
||||
{start ? <div className="date-block"><div className="date-num mono">{start.d}</div><div className="date-month">{start.m}</div></div>
|
||||
: <div className="date-block"><div className="date-num">—</div></div>}
|
||||
<div className="date-arrow"><ArrowRight /></div>
|
||||
{end ? <div className="date-block"><div className="date-num mono">{end.d}</div><div className="date-month">{end.m}</div></div>
|
||||
: <div className="date-block"><div className="date-num">—</div></div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pass-cell countdown">
|
||||
{countdownNumber && (
|
||||
<>
|
||||
<div className="pass-label">{countdownTop}</div>
|
||||
<div className="date-num mono">{countdownNumber}</div>
|
||||
<div className="date-month">{countdownLabel}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pass-cell places">
|
||||
<div className="pass-label">{t('dashboard.places')}</div>
|
||||
<div className="places-preview">
|
||||
{places.slice(0, 3).map(p => (
|
||||
<div key={p.id} className="place-av">
|
||||
<PlaceAvatar place={p} size={mobile ? 24 : 32} category={{ color: p.category_color ?? undefined, icon: p.category_icon ?? undefined }} />
|
||||
</div>
|
||||
))}
|
||||
{places.length === 0 && <div className="place-more"><MapPin size={15} /></div>}
|
||||
{places.length > 3 && <div className="place-more">+{places.length - 3}</div>}
|
||||
</div>
|
||||
<div className="date-month">{placeCount === 1 ? t('dashboard.hero.destinationOne', { count: placeCount }) : t('dashboard.hero.destinationMany', { count: placeCount })}</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="hero-trip" onClick={onOpen}>
|
||||
{trip.cover_image
|
||||
? <img className="bg" src={trip.cover_image} alt={trip.title} />
|
||||
@@ -454,10 +518,10 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
|
||||
{badge}
|
||||
</div>
|
||||
<div className="hero-tools">
|
||||
<button className="hero-tool" aria-label="Edit" onClick={(e) => stop(e, onEdit)}><Edit2 size={16} /></button>
|
||||
<button className="hero-tool" aria-label="Duplicate" onClick={(e) => stop(e, onCopy)}><Copy size={16} /></button>
|
||||
<button className="hero-tool" aria-label="Archive" onClick={(e) => stop(e, onArchive)}><Archive size={16} /></button>
|
||||
<button className="hero-tool" aria-label="Delete" onClick={(e) => stop(e, onDelete)}><Trash2 size={16} /></button>
|
||||
<button className="hero-tool" aria-label={t('common.edit')} onClick={(e) => stop(e, onEdit)}><Edit2 size={16} /></button>
|
||||
<button className="hero-tool" aria-label={t('dashboard.aria.duplicate')} onClick={(e) => stop(e, onCopy)}><Copy size={16} /></button>
|
||||
<button className="hero-tool" aria-label={trip.is_archived ? t('dashboard.restore') : t('dashboard.archive')} onClick={(e) => stop(e, onArchive)}><Archive size={16} /></button>
|
||||
<button className="hero-tool" aria-label={t('common.delete')} onClick={(e) => stop(e, onDelete)}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -465,66 +529,13 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
|
||||
<h2 className="hero-title">{trip.title}</h2>
|
||||
</div>
|
||||
|
||||
<div className="hero-pass" onClick={(e) => { e.stopPropagation(); onOpen() }}>
|
||||
<div className="pass-cell buddies">
|
||||
<div className="pass-label">{t('dashboard.members')}</div>
|
||||
<div className="buddies-avatars">
|
||||
{members.slice(0, 4).map((m, i) => (
|
||||
m.avatar_url
|
||||
? <img key={m.id} className="buddy-avatar" src={m.avatar_url} alt={m.username} style={{ objectFit: 'cover' }} />
|
||||
: <div key={m.id} className="buddy-avatar" style={{ background: buddyColor(i) }}>{initials(m.username)}</div>
|
||||
))}
|
||||
{members.length > 4 && <div className="buddy-more">+{members.length - 4}</div>}
|
||||
{members.length === 0 && <div className="buddy-avatar" style={{ background: buddyColor(0) }}>{initials(trip.owner_username)}</div>}
|
||||
</div>
|
||||
<div className="pass-sub">{buddyCount === 1 ? t('dashboard.hero.travelerOne', { count: buddyCount }) : t('dashboard.hero.travelerMany', { count: buddyCount })}</div>
|
||||
</div>
|
||||
|
||||
<div className="pass-cell dates-combined">
|
||||
<div className="pass-label">{t('dashboard.hero.tripDates')}</div>
|
||||
<div className="dates-row">
|
||||
{start ? <div className="date-block"><div className="date-num mono">{start.d}</div><div className="date-month">{start.m}</div></div>
|
||||
: <div className="date-block"><div className="date-num">—</div></div>}
|
||||
<div className="date-arrow"><ArrowRight /></div>
|
||||
{end ? <div className="date-block"><div className="date-num mono">{end.d}</div><div className="date-month">{end.m}</div></div>
|
||||
: <div className="date-block"><div className="date-num">—</div></div>}
|
||||
</div>
|
||||
<div className="pass-sub">{dayCount ? `${dayCount} ${dayCount === 1 ? t('dashboard.hero.dayUnitOne') : t('dashboard.hero.dayUnitMany')}` : t('dashboard.hero.noDates')}</div>
|
||||
</div>
|
||||
|
||||
<div className="pass-cell countdown">
|
||||
{countdownNumber && (
|
||||
<>
|
||||
<div className="countdown-ring">
|
||||
<svg viewBox="0 0 64 64">
|
||||
<circle className="track" cx="32" cy="32" r="27" fill="none" />
|
||||
<circle className="glow" cx="32" cy="32" r="27" fill="none" strokeDasharray={RING_LEN} strokeDashoffset={dashOffset} />
|
||||
<circle className="fill" cx="32" cy="32" r="27" fill="none" strokeDasharray={RING_LEN} strokeDashoffset={dashOffset} />
|
||||
</svg>
|
||||
<div className="pct mono">{Math.round(ringFraction * 100)}%</div>
|
||||
</div>
|
||||
<div className="countdown-info">
|
||||
<div className="countdown-days mono">{countdownNumber}</div>
|
||||
<div className="countdown-label">{countdownLabel}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pass-cell places">
|
||||
<div className="pass-label">{t('dashboard.places')}</div>
|
||||
<div className="places-preview">
|
||||
{places.slice(0, 4).map(p => (
|
||||
<img key={p.id} className="place-thumb" src={p.image_url as string} alt={p.name} />
|
||||
))}
|
||||
{places.length === 0 && <div className="place-more"><MapPin size={15} /></div>}
|
||||
{placeCount > 4 && <div className="place-more">+{placeCount - 4}</div>}
|
||||
</div>
|
||||
<div className="pass-sub">{placeCount === 1 ? t('dashboard.hero.destinationOne', { count: placeCount }) : t('dashboard.hero.destinationMany', { count: placeCount })}</div>
|
||||
</div>
|
||||
</div>
|
||||
{!mobile && (
|
||||
<div className="hero-pass" onClick={(e) => { e.stopPropagation(); onOpen() }}>{passCells}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{mobile && <section className="pass-card" onClick={onOpen}>{passCells}</section>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -612,10 +623,10 @@ function TripCard({ trip, locale, onOpen, onEdit, onCopy, onArchive, onDelete }:
|
||||
: <div style={{ width: '100%', height: '100%', background: tripGradient(trip.id) }} />}
|
||||
<div className={`trip-status ${statusClass}`}><span className="indicator" /> {statusLabel}</div>
|
||||
<div className="trip-actions">
|
||||
<button className="trip-action-btn" aria-label="Edit" onClick={(e) => stop(e, onEdit)}><Edit2 size={16} /></button>
|
||||
<button className="trip-action-btn" aria-label="Duplicate" onClick={(e) => stop(e, onCopy)}><Copy size={16} /></button>
|
||||
<button className="trip-action-btn" aria-label="Archive" onClick={(e) => stop(e, onArchive)}><Archive size={16} /></button>
|
||||
<button className="trip-action-btn" aria-label="Delete" onClick={(e) => stop(e, onDelete)}><Trash2 size={16} /></button>
|
||||
<button className="trip-action-btn" aria-label={t('common.edit')} onClick={(e) => stop(e, onEdit)}><Edit2 size={16} /></button>
|
||||
<button className="trip-action-btn" aria-label={t('dashboard.aria.duplicate')} onClick={(e) => stop(e, onCopy)}><Copy size={16} /></button>
|
||||
<button className="trip-action-btn" aria-label={trip.is_archived ? t('dashboard.restore') : t('dashboard.archive')} onClick={(e) => stop(e, onArchive)}><Archive size={16} /></button>
|
||||
<button className="trip-action-btn" aria-label={t('common.delete')} onClick={(e) => stop(e, onDelete)}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
<div className="trip-cover-content">
|
||||
<h3 className="trip-name">{trip.title}</h3>
|
||||
@@ -672,7 +683,7 @@ function CurrencyTool(): React.ReactElement {
|
||||
<div className="tool">
|
||||
<div className="tool-head">
|
||||
<div className="tool-title"><RefreshCw size={14} /> {t('dashboard.currency')}</div>
|
||||
<button className="tool-action" aria-label="Refresh" onClick={fetchRate}><RefreshCw size={14} /></button>
|
||||
<button className="tool-action" aria-label={t('dashboard.aria.refreshRates')} onClick={fetchRate}><RefreshCw size={14} /></button>
|
||||
</div>
|
||||
<div className="fx-input">
|
||||
<div className="fx-field">
|
||||
@@ -680,7 +691,7 @@ function CurrencyTool(): React.ReactElement {
|
||||
<input className="amt mono" value={amount} onChange={e => setAmount(e.target.value)} inputMode="decimal" />
|
||||
<CustomSelect value={from} onChange={setFrom} options={ccyOptions} searchable size="sm" style={{ marginTop: 6 }} />
|
||||
</div>
|
||||
<button className="fx-swap" aria-label="Swap" onClick={swap}><ArrowRightLeft size={14} /></button>
|
||||
<button className="fx-swap" aria-label={t('dashboard.aria.swapCurrencies')} onClick={swap}><ArrowRightLeft size={14} /></button>
|
||||
<div className="fx-field">
|
||||
<div className="lbl">{t('dashboard.fx.to')}</div>
|
||||
<input className="amt mono" value={converted != null ? converted.toFixed(2) : '—'} readOnly />
|
||||
@@ -753,7 +764,7 @@ function TimezoneTool({ locale }: { locale: string }): React.ReactElement {
|
||||
<div className="tool">
|
||||
<div className="tool-head">
|
||||
<div className="tool-title"><Clock size={14} /> {t('dashboard.timezone')}</div>
|
||||
<button className="tool-action" aria-label="Add timezone" onClick={() => setAdding(a => !a)}>
|
||||
<button className="tool-action" aria-label={t('dashboard.aria.addTimezone')} onClick={() => setAdding(a => !a)}>
|
||||
{adding ? <X size={14} /> : <Plus size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
@@ -771,7 +782,7 @@ function TimezoneTool({ locale }: { locale: string }): React.ReactElement {
|
||||
<div className="tz-sub">{offsetLabel(tz)}</div>
|
||||
</div>
|
||||
<div className="tz-time mono">{timeIn(tz)}</div>
|
||||
<button className="tz-del" aria-label={`Remove ${shortZone(tz)}`} onClick={() => removeZone(tz)}><X size={13} /></button>
|
||||
<button className="tz-del" aria-label={t('dashboard.aria.removeTimezone', { city: shortZone(tz) })} onClick={() => removeZone(tz)}><X size={13} /></button>
|
||||
</div>
|
||||
))}
|
||||
{zones.length === 0 && (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { formatLocationName } from '../utils/formatters'
|
||||
import { normalizeImageFiles } from '../utils/convertHeic'
|
||||
import { type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useJourneyStore } from '../store/journeyStore'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
@@ -113,6 +113,16 @@ export default function JourneyDetailPage() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<JourneyEntry | null>(null)
|
||||
const [showInvite, setShowInvite] = useState(false)
|
||||
const [showAddTrip, setShowAddTrip] = useState(false)
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
// The bottom-nav "+" starts a new entry via ?create=entry.
|
||||
useEffect(() => {
|
||||
if (searchParams.get('create') === 'entry' && current && canEditEntries) {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
setEditingEntry({ id: 0, journey_id: current.id, author_id: 0, type: 'entry', entry_date: today, visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0 } as JourneyEntry)
|
||||
setSearchParams(p => { p.delete('create'); return p }, { replace: true })
|
||||
}
|
||||
}, [searchParams, current, canEditEntries])
|
||||
const [unlinkTrip, setUnlinkTrip] = useState<{ trip_id: number; title: string } | null>(null)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [hideSkeletons, setHideSkeletons] = useState(false)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useMemo, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useJourneyStore } from '../store/journeyStore'
|
||||
import { journeyApi } from '../api/client'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
@@ -52,11 +52,21 @@ export default function JourneyPage() {
|
||||
const [suggestions, setSuggestions] = useState<any[]>([])
|
||||
const [dismissedSuggestions, setDismissedSuggestions] = useState<Set<number>>(new Set())
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
loadJourneys()
|
||||
journeyApi.suggestions().then(d => setSuggestions(d.trips || [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// The bottom-nav "+" opens the new-journey modal via ?create=1.
|
||||
useEffect(() => {
|
||||
if (searchParams.get('create') === '1') {
|
||||
openCreateModal()
|
||||
setSearchParams(p => { p.delete('create'); return p }, { replace: true })
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const activeSuggestion = suggestions.find(s => !dismissedSuggestions.has(s.id))
|
||||
|
||||
const activeJourney = useMemo(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { useCanDo } from '../store/permissionsStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
@@ -261,6 +261,15 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
const [editingPlace, setEditingPlace] = useState<Place | null>(null)
|
||||
const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string } | null>(null)
|
||||
const [editingAssignmentId, setEditingAssignmentId] = useState<number | null>(null)
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
// The bottom-nav "+" opens the new-place form via ?create=place.
|
||||
useEffect(() => {
|
||||
if (searchParams.get('create') === 'place') {
|
||||
setEditingPlace(null); setEditingAssignmentId(null); setShowPlaceForm(true)
|
||||
setSearchParams(p => { p.delete('create'); return p }, { replace: true })
|
||||
}
|
||||
}, [searchParams])
|
||||
const [showTripForm, setShowTripForm] = useState<boolean>(false)
|
||||
const [showMembersModal, setShowMembersModal] = useState<boolean>(false)
|
||||
const [showReservationModal, setShowReservationModal] = useState<boolean>(false)
|
||||
|
||||
+150
-46
@@ -42,19 +42,27 @@
|
||||
font-feature-settings: "ss01", "cv11";
|
||||
letter-spacing: -0.005em;
|
||||
min-height: 100%;
|
||||
|
||||
/* liquid-glass surface tokens (shared by all tiles) */
|
||||
--glass-bg: linear-gradient(135deg, oklch(1 0 0 / .72) 0%, oklch(0.99 0.006 75 / .5) 100%);
|
||||
--glass-border: oklch(0.88 0.008 70 / .7);
|
||||
--glass-shadow: 0 1px 2px oklch(0.4 0.02 60 / .05), 0 12px 32px -14px oklch(0.3 0.02 60 / .2);
|
||||
--glass-shadow-hover: 0 2px 6px oklch(0.4 0.02 60 / .07), 0 26px 56px -20px oklch(0.25 0.04 60 / .32);
|
||||
--glass-highlight: inset 0 1px 0 oklch(1 0 0 / .8);
|
||||
--glass-blur: blur(22px) saturate(1.7);
|
||||
}
|
||||
|
||||
/* dark variant — same geometry, dark surfaces, accent kept */
|
||||
.dark .trek-dash {
|
||||
--bg: oklch(0.17 0.012 65);
|
||||
--bg-2: oklch(0.21 0.012 65);
|
||||
--surface: oklch(0.225 0.012 65);
|
||||
--surface-2: oklch(0.255 0.012 65);
|
||||
--ink: oklch(0.96 0.006 70);
|
||||
--ink-2: oklch(0.78 0.008 70);
|
||||
--ink-3: oklch(0.6 0.008 70);
|
||||
--line: oklch(0.32 0.01 65);
|
||||
--line-2: oklch(0.38 0.012 65);
|
||||
--bg: oklch(0.17 0 0);
|
||||
--bg-2: oklch(0.21 0 0);
|
||||
--surface: oklch(0.225 0 0);
|
||||
--surface-2: oklch(0.255 0 0);
|
||||
--ink: oklch(0.96 0 0);
|
||||
--ink-2: oklch(0.78 0 0);
|
||||
--ink-3: oklch(0.6 0 0);
|
||||
--line: oklch(0.32 0 0);
|
||||
--line-2: oklch(0.38 0 0);
|
||||
--accent: oklch(0.7 0.16 40);
|
||||
--accent-ink: oklch(0.82 0.13 50);
|
||||
--accent-soft: oklch(0.32 0.07 45);
|
||||
@@ -65,8 +73,20 @@
|
||||
--sh-sm: 0 1px 2px oklch(0 0 0 / .3), 0 2px 6px oklch(0 0 0 / .35);
|
||||
--sh-md: 0 1px 2px oklch(0 0 0 / .35), 0 8px 24px -8px oklch(0 0 0 / .5);
|
||||
--sh-lg: 0 2px 4px oklch(0 0 0 / .4), 0 20px 50px -16px oklch(0 0 0 / .7);
|
||||
|
||||
/* liquid-glass surface tokens — dark */
|
||||
--glass-bg: linear-gradient(135deg, oklch(0.31 0 0 / .58) 0%, oklch(0.25 0 0 / .42) 100%);
|
||||
--glass-border: oklch(1 0 0 / .1);
|
||||
--glass-shadow: 0 1px 2px oklch(0 0 0 / .3), 0 12px 32px -14px oklch(0 0 0 / .55);
|
||||
--glass-shadow-hover: 0 2px 6px oklch(0 0 0 / .4), 0 26px 56px -20px oklch(0 0 0 / .72);
|
||||
--glass-highlight: inset 0 1px 0 oklch(1 0 0 / .09);
|
||||
}
|
||||
|
||||
/* App shell: desktop is a fixed full-height column with its own scroll area.
|
||||
On mobile (see media query) it flows normally inside the global chrome. */
|
||||
.trek-dash-shell { position: fixed; inset: 0; display: flex; flex-direction: column; }
|
||||
.trek-dash-scroll { flex: 1; overflow: auto; overscroll-behavior: contain; margin-top: var(--nav-h); }
|
||||
|
||||
.trek-dash * { box-sizing: border-box; }
|
||||
.trek-dash .mono { font-family: "Poppins", -apple-system, BlinkMacSystemFont, system-ui, sans-serif; font-feature-settings: "tnum"; }
|
||||
.trek-dash button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; padding: 0; }
|
||||
@@ -224,7 +244,7 @@
|
||||
transform: translateZ(0);
|
||||
}
|
||||
.dark .trek-dash .hero-pass {
|
||||
background: linear-gradient(135deg, oklch(0.28 0.012 65 / .8) 0%, oklch(0.25 0.012 65 / .85) 50%, oklch(0.23 0.012 65 / .8) 100%);
|
||||
background: linear-gradient(135deg, oklch(0.28 0 0 / .8) 0%, oklch(0.25 0 0 / .85) 50%, oklch(0.23 0 0 / .8) 100%);
|
||||
border: 1px solid oklch(1 0 0 / .1);
|
||||
box-shadow:
|
||||
0 2px 8px -2px oklch(0 0 0 / .3), 0 8px 24px -6px oklch(0 0 0 / .4),
|
||||
@@ -280,33 +300,39 @@
|
||||
.dark .trek-dash .buddy-avatar,
|
||||
.dark .trek-dash .place-thumb,
|
||||
.dark .trek-dash .buddy-more,
|
||||
.dark .trek-dash .place-more { border-color: oklch(0.25 0.012 65 / .95); }
|
||||
.dark .trek-dash .place-more { border-color: oklch(0.25 0 0 / .95); }
|
||||
.dark .trek-dash .buddy-more,
|
||||
.dark .trek-dash .place-more { background: oklch(0.32 0.01 70); }
|
||||
.dark .trek-dash .place-more { background: oklch(0.32 0 0); }
|
||||
.trek-dash .place-thumb {
|
||||
width: 36px; height: 36px; border-radius: 50%; object-fit: cover;
|
||||
border: 2px solid oklch(0.985 0.008 75 / .95); box-shadow: 0 2px 6px rgba(0,0,0,0.1); margin-left: -8px;
|
||||
}
|
||||
.trek-dash .place-thumb:first-child { margin-left: 0; }
|
||||
.trek-dash .places-preview .place-av {
|
||||
border-radius: 50%; line-height: 0; margin-left: -8px;
|
||||
border: 2px solid oklch(0.985 0.008 75 / .95); box-shadow: 0 2px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
.trek-dash .places-preview .place-av:first-child { margin-left: 0; }
|
||||
.dark .trek-dash .places-preview .place-av { border-color: oklch(0.25 0 0 / .95); }
|
||||
|
||||
.trek-dash .pass-cell.countdown { flex-direction: row; align-items: center; text-align: left; gap: 12px; }
|
||||
.trek-dash .countdown-ring { position: relative; width: 64px; height: 64px; flex-shrink: 0; }
|
||||
.trek-dash .countdown-ring svg { width: 100%; height: 100%; transform: rotate(-90deg); }
|
||||
.trek-dash .countdown-ring .track { stroke: oklch(0.92 0.01 70); stroke-width: 5; }
|
||||
.dark .trek-dash .countdown-ring .track { stroke: oklch(0.4 0.01 70); }
|
||||
.trek-dash .countdown-ring .fill { stroke: var(--ink); stroke-linecap: round; stroke-width: 5; }
|
||||
.trek-dash .countdown-ring .glow { stroke: var(--ink); stroke-width: 5; stroke-linecap: round; opacity: 0.15; filter: blur(3px); }
|
||||
.trek-dash .countdown-ring .pct { position: absolute; inset: 0; display: grid; place-items: center; font-size: 14px; font-weight: 700; color: var(--ink); letter-spacing: -0.02em; }
|
||||
.trek-dash .countdown-info { display: flex; flex-direction: column; gap: 2px; align-items: flex-start; }
|
||||
.trek-dash .countdown-days { font-size: 28px; font-weight: 700; letter-spacing: -0.03em; color: var(--ink); line-height: 1; }
|
||||
.trek-dash .countdown-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); font-weight: 500; }
|
||||
.trek-dash .pass-cell.countdown { gap: 6px; }
|
||||
|
||||
/* ----------------- atlas / stats ----------------- */
|
||||
.trek-dash .atlas { display: grid; grid-template-columns: 1.5fr 1fr 1fr 1fr; gap: 16px; margin-bottom: 56px; }
|
||||
.trek-dash .atlas-card {
|
||||
background: var(--surface); border-radius: var(--r-lg); padding: 24px 26px;
|
||||
box-shadow: var(--sh-sm); position: relative; overflow: hidden;
|
||||
background: var(--glass-bg); border-radius: var(--r-lg); padding: 24px 26px;
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow), var(--glass-highlight);
|
||||
backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur);
|
||||
position: relative; overflow: hidden;
|
||||
transition: transform .3s cubic-bezier(.2,.7,.2,1), box-shadow .3s, border-color .3s;
|
||||
}
|
||||
.trek-dash .atlas-card:not(.passport):hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--glass-shadow-hover), var(--glass-highlight);
|
||||
border-color: oklch(0.8 0.01 70 / .8);
|
||||
}
|
||||
.dark .trek-dash .atlas-card:not(.passport):hover { border-color: oklch(1 0 0 / .2); }
|
||||
.trek-dash .atlas-card .label { font-size: 12px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-3); font-weight: 500; }
|
||||
.trek-dash .atlas-card .value { font-size: 44px; font-weight: 600; letter-spacing: -0.035em; line-height: 1; margin-top: 16px; display: flex; align-items: baseline; gap: 8px; }
|
||||
.trek-dash .atlas-card .value .unit { font-size: 17px; color: var(--ink-3); font-weight: 500; letter-spacing: -0.01em; }
|
||||
@@ -315,7 +341,7 @@
|
||||
.trek-dash .atlas-card.passport {
|
||||
background:
|
||||
linear-gradient(135deg, oklch(0.95 0.01 70 / .15) 0%, oklch(0.98 0.005 70 / .08) 50%, oklch(1 0 0 / .12) 100%),
|
||||
linear-gradient(180deg, oklch(0.15 0.02 65), oklch(0.08 0.01 70));
|
||||
linear-gradient(180deg, oklch(0.16 0 0), oklch(0.09 0 0));
|
||||
color: #fff; border: 1px solid oklch(1 0 0 / .12);
|
||||
box-shadow: 0 8px 32px oklch(0 0 0 / .15), inset 0 1px 0 oklch(1 0 0 / .15), inset 0 -1px 0 oklch(0 0 0 / .3);
|
||||
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
|
||||
@@ -355,11 +381,18 @@
|
||||
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: space-between; padding: 20px 32px; gap: 48px; }
|
||||
.trek-dash .trips.list-view .trip-meta { display: flex; gap: 32px; padding: 0; border: none; }
|
||||
.trek-dash .trip-card {
|
||||
position: relative; border-radius: var(--r-xl); overflow: hidden; background: var(--surface);
|
||||
box-shadow: var(--sh-md); transition: transform .25s cubic-bezier(.2,.7,.2,1), box-shadow .25s;
|
||||
position: relative; border-radius: var(--r-xl); overflow: hidden; background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow), var(--glass-highlight);
|
||||
transition: transform .25s cubic-bezier(.2,.7,.2,1), box-shadow .25s, border-color .25s;
|
||||
cursor: pointer; isolation: isolate;
|
||||
}
|
||||
.trek-dash .trip-card:hover { transform: translateY(-4px); box-shadow: var(--sh-lg); }
|
||||
.trek-dash .trip-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--glass-shadow-hover), var(--glass-highlight);
|
||||
border-color: oklch(0.8 0.01 70 / .8);
|
||||
}
|
||||
.dark .trek-dash .trip-card:hover { border-color: oklch(1 0 0 / .2); }
|
||||
.trek-dash .trip-cover { position: relative; aspect-ratio: 4 / 3; overflow: hidden; }
|
||||
.trek-dash .trip-cover img { width: 100%; height: 100%; object-fit: cover; transition: transform .6s cubic-bezier(.2,.7,.2,1); }
|
||||
.trek-dash .trip-card:hover .trip-cover img { transform: scale(1.04); }
|
||||
@@ -399,11 +432,16 @@
|
||||
.trek-dash .trip-meta .k { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); font-weight: 500; }
|
||||
.trek-dash .add-trip-card {
|
||||
border-radius: var(--r-xl); border: 1.5px dashed var(--line-2);
|
||||
background: oklch(0.97 0.008 75 / .5); display: grid; place-items: center;
|
||||
text-align: center; padding: 32px; transition: background .15s, border-color .15s; cursor: pointer; min-height: 240px;
|
||||
background: var(--glass-bg); display: grid; place-items: center;
|
||||
backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur);
|
||||
box-shadow: var(--glass-highlight);
|
||||
text-align: center; padding: 32px; cursor: pointer; min-height: 240px;
|
||||
transition: transform .3s cubic-bezier(.2,.7,.2,1), box-shadow .3s, border-color .3s, color .15s;
|
||||
}
|
||||
.trek-dash .add-trip-card:hover {
|
||||
transform: translateY(-3px); border-color: var(--ink); color: var(--ink);
|
||||
box-shadow: var(--glass-shadow-hover), var(--glass-highlight);
|
||||
}
|
||||
.dark .trek-dash .add-trip-card { background: oklch(0.22 0.01 65 / .5); }
|
||||
.trek-dash .add-trip-card:hover { background: var(--surface-2); border-color: var(--ink); color: var(--ink); }
|
||||
.trek-dash .add-trip-card .circ {
|
||||
width: 48px; height: 48px; border-radius: 50%; background: #111827; color: #fff;
|
||||
display: grid; place-items: center; margin: 0 auto 14px; box-shadow: var(--sh-sm);
|
||||
@@ -415,7 +453,19 @@
|
||||
.trek-dash .add-trip-card .sub { font-size: 13px; color: var(--ink-3); }
|
||||
|
||||
/* ----------------- tools sidebar ----------------- */
|
||||
.trek-dash .tool { background: var(--surface); border-radius: var(--r-xl); padding: 24px 26px; box-shadow: var(--sh-sm); }
|
||||
.trek-dash .tool {
|
||||
background: var(--glass-bg); border-radius: var(--r-xl); padding: 24px 26px;
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow), var(--glass-highlight);
|
||||
backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur);
|
||||
transition: transform .3s cubic-bezier(.2,.7,.2,1), box-shadow .3s, border-color .3s;
|
||||
}
|
||||
.trek-dash .tool:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--glass-shadow-hover), var(--glass-highlight);
|
||||
border-color: oklch(0.8 0.01 70 / .8);
|
||||
}
|
||||
.dark .trek-dash .tool:hover { border-color: oklch(1 0 0 / .2); }
|
||||
.trek-dash .tool-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; }
|
||||
.trek-dash .tool-title { font-size: 13px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-3); font-weight: 500; display: flex; align-items: center; gap: 8px; }
|
||||
.trek-dash .tool-title svg { width: 14px; height: 14px; }
|
||||
@@ -467,14 +517,72 @@
|
||||
.trek-dash .atlas { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.trek-dash .page { padding: 24px 16px 96px; }
|
||||
/* Flow inside the global chrome (top bar + floating tab bar) instead of fixed */
|
||||
.trek-dash-shell { position: static; inset: auto; display: block; min-height: 100%; }
|
||||
.trek-dash-scroll { overflow: visible; margin-top: 0; }
|
||||
.trek-dash .page { padding: 16px 16px 120px; gap: 0; }
|
||||
.trek-dash .greeting { grid-template-columns: 1fr; }
|
||||
.trek-dash .hello { font-size: 40px; }
|
||||
.trek-dash .hero-trip { height: 420px; }
|
||||
.trek-dash .hero-title { font-size: 52px; }
|
||||
.trek-dash .hero-pass { grid-template-columns: 1fr 1fr; gap: 16px 0; }
|
||||
.trek-dash .trips { grid-template-columns: 1fr; }
|
||||
.trek-dash .atlas { grid-template-columns: 1fr 1fr; }
|
||||
|
||||
/* Hero — immersive cover, title only (the pass is its own card below) */
|
||||
.trek-dash .hero-trip { height: 340px; margin-bottom: 16px; border-radius: var(--r-xl); }
|
||||
.trek-dash .hero-content { padding: 18px; }
|
||||
/* the page already opens with the notification/profile strip, trim its top gap */
|
||||
.trek-dash .page { padding-top: 4px; }
|
||||
.trek-dash .hero-title { font-size: 48px; }
|
||||
|
||||
/* Boarding pass — separate 2×2 glass card under the hero (mockup) */
|
||||
.trek-dash .pass-card {
|
||||
display: grid; grid-template-columns: 1fr 1fr;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur);
|
||||
border: 1px solid var(--glass-border); border-radius: 20px; overflow: hidden;
|
||||
box-shadow: var(--glass-shadow), var(--glass-highlight);
|
||||
margin-bottom: 22px; cursor: pointer;
|
||||
}
|
||||
.trek-dash .pass-card .pass-cell {
|
||||
padding: 14px 12px; gap: 8px; flex: none;
|
||||
border-right: 1px dashed var(--line-2); border-bottom: 1px dashed var(--line-2);
|
||||
}
|
||||
.trek-dash .pass-card .pass-cell:nth-child(2n) { border-right: 0; }
|
||||
.trek-dash .pass-card .pass-cell:nth-last-child(-n+2) { border-bottom: 0; }
|
||||
.trek-dash .pass-card .pass-cell + .pass-cell::before { display: none; }
|
||||
.trek-dash .pass-card .date-num { font-size: 22px; }
|
||||
/* Buddies + places circles: identical size, ring and overlap */
|
||||
.trek-dash .pass-card .buddies-avatars,
|
||||
.trek-dash .pass-card .places-preview { display: flex; justify-content: center; align-items: center; }
|
||||
.trek-dash .pass-card .buddy-avatar,
|
||||
.trek-dash .pass-card .buddy-more,
|
||||
.trek-dash .pass-card .place-av,
|
||||
.trek-dash .pass-card .place-more {
|
||||
width: 28px; height: 28px; border-radius: 50%; flex: none;
|
||||
border: 2px solid var(--surface); margin-left: -8px;
|
||||
box-shadow: 0 1px 3px oklch(0 0 0 / .12);
|
||||
font-size: 10px; font-weight: 700; line-height: 0;
|
||||
}
|
||||
.trek-dash .pass-card .buddy-avatar:first-child,
|
||||
.trek-dash .pass-card .place-av:first-child { margin-left: 0; }
|
||||
|
||||
/* Atlas → single row of stat cards. Passport (countries) and distance are
|
||||
hidden on mobile; only Trips total + Days traveled remain. */
|
||||
.trek-dash .atlas { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 0 0 26px; }
|
||||
.trek-dash .atlas-card.passport,
|
||||
.trek-dash .atlas-card:last-child { display: none; }
|
||||
.trek-dash .atlas .spark { display: none; }
|
||||
.trek-dash .atlas-card .value { font-size: 30px; margin-top: 10px; }
|
||||
|
||||
/* Trips — stacked header + full-width cards */
|
||||
.trek-dash .sec-head { flex-direction: column; align-items: stretch; gap: 14px; margin-bottom: 16px; }
|
||||
.trek-dash .sec-title { font-size: 24px; }
|
||||
.trek-dash .sec-tools { gap: 8px; }
|
||||
.trek-dash .seg { flex: 1; }
|
||||
.trek-dash .seg button { flex: 1; text-align: center; padding: 9px 8px; }
|
||||
.trek-dash .trips { grid-template-columns: 1fr; gap: 16px; margin-bottom: 28px; }
|
||||
.trek-dash .add-trip-card { min-height: 180px; }
|
||||
|
||||
/* Tools — stacked full-width cards (mockup) */
|
||||
.trek-dash .page-sidebar { flex-direction: column; flex-wrap: nowrap; gap: 14px; margin: 0; padding: 0; }
|
||||
.trek-dash .page-sidebar .tool { flex: none; width: auto; }
|
||||
}
|
||||
|
||||
/* Floating action button — Neuer Trip */
|
||||
@@ -496,10 +604,6 @@
|
||||
.trek-dash .fab-new-trip svg { flex-shrink: 0; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* collapse to a round button and lift above the bottom nav */
|
||||
.trek-dash .fab-new-trip {
|
||||
right: 18px; bottom: calc(84px + env(safe-area-inset-bottom, 0px) + 16px);
|
||||
width: 56px; padding: 0; justify-content: center; gap: 0;
|
||||
}
|
||||
.trek-dash .fab-new-trip .fab-label { display: none; }
|
||||
/* The bottom tab bar's centre "+" replaces the floating FAB on mobile */
|
||||
.trek-dash .fab-new-trip { display: none; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user