diff --git a/client/src/components/SystemNotices/SystemNoticeModal.test.tsx b/client/src/components/SystemNotices/SystemNoticeModal.test.tsx
index 9607f48e..f7b030c3 100644
--- a/client/src/components/SystemNotices/SystemNoticeModal.test.tsx
+++ b/client/src/components/SystemNotices/SystemNoticeModal.test.tsx
@@ -112,8 +112,9 @@ describe('ModalRenderer', () => {
});
it('FE-SN-MODAL-005: CTA nav button dismisses all notices (not just current)', async () => {
- const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A', cta: { kind: 'nav', labelKey: 'Go to trips', href: '/trips' } });
- const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
+ // CTA is only shown on the last page; navigate there first
+ const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
+ const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B', cta: { kind: 'nav', labelKey: 'Go to trips', href: '/trips' } });
useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true });
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
@@ -121,6 +122,12 @@ describe('ModalRenderer', () => {
await flushGraceDelay();
+ // Navigate to last page
+ await act(async () => {
+ fireEvent.click(screen.getByLabelText('Go to notice 2'));
+ });
+ await flushGraceDelay();
+
const ctaBtn = screen.getByRole('button', { name: 'Go to trips' });
await act(async () => {
fireEvent.click(ctaBtn);
@@ -299,17 +306,22 @@ describe('ModalRenderer', () => {
expect(screen.getByText('Notice A')).toBeTruthy();
expect(screen.getByText('1 / 3')).toBeTruthy();
- // Dismiss notice A — store shrinks, parent re-renders with [B, C]
+ // Navigate to last page where X button is available
await act(async () => {
- fireEvent.click(screen.getByLabelText('Dismiss'));
- useSystemNoticeStore.setState({ notices: [noticeB, noticeC], loaded: true });
- rerender();
+ fireEvent.click(screen.getByLabelText('Go to notice 3'));
});
await flushGraceDelay();
- // Must show B (idx=0), not C (idx=1 — the old buggy behavior)
- expect(screen.getByText('Notice B')).toBeTruthy();
- expect(screen.getByText('1 / 2')).toBeTruthy();
+ // Dismiss all from last page — store shrinks
+ await act(async () => {
+ fireEvent.click(screen.getByLabelText('Dismiss'));
+ useSystemNoticeStore.setState({ notices: [], loaded: true });
+ rerender();
+ });
+ await flushGraceDelay();
+
+ // All dismissed — modal should be gone
+ expect(screen.queryByRole('dialog')).toBeNull();
});
it('FE-SN-MODAL-017: X button dismisses all notices, not just the current one', async () => {
@@ -321,6 +333,12 @@ describe('ModalRenderer', () => {
render();
await flushGraceDelay();
+ // X button only appears on the last page — navigate there
+ await act(async () => {
+ fireEvent.click(screen.getByLabelText('Go to notice 2'));
+ });
+ await flushGraceDelay();
+
await act(async () => {
fireEvent.click(screen.getByLabelText('Dismiss'));
});
@@ -330,7 +348,7 @@ describe('ModalRenderer', () => {
expect(dismissSpy).toHaveBeenCalledTimes(2);
});
- it('FE-SN-MODAL-018: ESC key dismisses all notices when current is dismissible', async () => {
+ it('FE-SN-MODAL-018: ESC key dismisses all notices when on last page', async () => {
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true });
@@ -339,6 +357,12 @@ describe('ModalRenderer', () => {
render();
await flushGraceDelay();
+ // ESC only works on last page — navigate there first
+ await act(async () => {
+ fireEvent.click(screen.getByLabelText('Go to notice 2'));
+ });
+ await flushGraceDelay();
+
await act(async () => {
fireEvent.keyDown(document, { key: 'Escape' });
});
diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx
index 295373f6..648fb985 100644
--- a/client/src/pages/JourneyDetailPage.tsx
+++ b/client/src/pages/JourneyDetailPage.tsx
@@ -1,5 +1,4 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
-import { createPortal } from 'react-dom'
import { useParams, useNavigate } from 'react-router-dom'
import { useJourneyStore } from '../store/journeyStore'
import { useAuthStore } from '../store/authStore'
@@ -228,16 +227,15 @@ export default function JourneyDetailPage() {
/>
)}
- {/* Fullscreen entry view (mobile) — portal to body for iOS stacking */}
- {viewingEntry && createPortal(
+ {/* Fullscreen entry view (mobile) */}
+ {viewingEntry && (
setViewingEntry(null)}
onEdit={() => { setViewingEntry(null); setEditingEntry(viewingEntry); }}
onDelete={() => { setViewingEntry(null); setDeleteTarget(viewingEntry); }}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
- />,
- document.body
+ />
)}
{/* Floating tab toggle on mobile combined view */}
@@ -580,8 +578,8 @@ export default function JourneyDetailPage() {
- {/* Entry Editor — portal to body to escape stacking context on iOS */}
- {editingEntry && createPortal(
+ {/* Entry Editor */}
+ {editingEntry && (
,
- document.body
+ />
)}
{/* Journey Settings */}
@@ -2093,7 +2090,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
}
return (
-