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:
Maurice
2026-05-27 23:19:03 +02:00
committed by GitHub
parent 0d2657ee37
commit 6d2dd37414
34 changed files with 1692 additions and 1296 deletions
+67 -113
View File
@@ -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);
});
});
+104 -93
View File
@@ -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 && (
+11 -1
View File
@@ -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)
+11 -1
View File
@@ -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(() => {
+10 -1
View File
@@ -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)