From fef12b0e8b5a9cd48d47766078f8d0209ace57f2 Mon Sep 17 00:00:00 2001 From: jubnl Date: Thu, 16 Apr 2026 22:49:20 +0200 Subject: [PATCH 1/5] fix(mobile): account for bottom navbar in overlays and improve system notices UX - Add paddingBottom: var(--bottom-nav-h) to all mobile overlays that were clipping content behind the bottom navbar: EntryEditor, SystemNoticeModal, JourneyPage create modal, TodoListPanel sheets, TripPlannerPage PlaceInspector, PackingListPanel bag modal, both PhotoLightboxes, FileManager viewer, and shared Modal primitive - Replace single-notice mobile bottom sheet with a 3-slot horizontal strip so adjacent notices are physically present during drag - Add live-follow swipe left/right to navigate between notices with spring-back when under threshold and flushSync to eliminate blink on commit - Add live-follow swipe down to dismiss all notices with spring-back; backdrop tap also triggers the slide-down animation - Normalize notice height with useLayoutEffect minHeight on strip and align-items: stretch so all slots are always the tallest notice height - Pin CTA button at consistent Y across notices via flex-1 + mt-auto; always render invisible Not now placeholder to equalise CTA section height - Move pager dots/counter below CTA buttons --- client/src/components/Files/FileManager.tsx | 2 +- .../src/components/Journey/PhotoLightbox.tsx | 1 + .../components/Packing/PackingListPanel.tsx | 2 +- .../src/components/Photos/PhotoLightbox.tsx | 1 + .../SystemNotices/SystemNoticeModal.tsx | 255 +++++++++++++++--- client/src/components/Todo/TodoListPanel.tsx | 4 +- client/src/components/shared/Modal.tsx | 2 +- client/src/pages/JourneyDetailPage.tsx | 2 +- client/src/pages/JourneyPage.tsx | 2 +- client/src/pages/TripPlannerPage.tsx | 2 +- 10 files changed, 223 insertions(+), 50 deletions(-) diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index 6092806a..b70a52e7 100644 --- a/client/src/components/Files/FileManager.tsx +++ b/client/src/components/Files/FileManager.tsx @@ -94,7 +94,7 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) { return (
setTouchStart(e.touches[0].clientX)} onTouchEnd={e => { diff --git a/client/src/components/Journey/PhotoLightbox.tsx b/client/src/components/Journey/PhotoLightbox.tsx index e3096ee1..f8e799a2 100644 --- a/client/src/components/Journey/PhotoLightbox.tsx +++ b/client/src/components/Journey/PhotoLightbox.tsx @@ -69,6 +69,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props position: 'fixed', inset: 0, zIndex: 500, background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)', display: 'flex', flexDirection: 'column', + paddingBottom: 'var(--bottom-nav-h)', }} onTouchStart={onTouchStart} onTouchEnd={onTouchEnd} diff --git a/client/src/components/Packing/PackingListPanel.tsx b/client/src/components/Packing/PackingListPanel.tsx index d5745476..14cbced9 100644 --- a/client/src/components/Packing/PackingListPanel.tsx +++ b/client/src/components/Packing/PackingListPanel.tsx @@ -1268,7 +1268,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp {/* ── Bag Modal (mobile + click) ── */} {showBagModal && bagTrackingEnabled && ( -
setShowBagModal(false)}>
e.stopPropagation()}> diff --git a/client/src/components/Photos/PhotoLightbox.tsx b/client/src/components/Photos/PhotoLightbox.tsx index ba6a5738..bb90170a 100644 --- a/client/src/components/Photos/PhotoLightbox.tsx +++ b/client/src/components/Photos/PhotoLightbox.tsx @@ -79,6 +79,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet return (
{/* Main area */} diff --git a/client/src/components/SystemNotices/SystemNoticeModal.tsx b/client/src/components/SystemNotices/SystemNoticeModal.tsx index 10d0a13f..6b9e23a2 100644 --- a/client/src/components/SystemNotices/SystemNoticeModal.tsx +++ b/client/src/components/SystemNotices/SystemNoticeModal.tsx @@ -1,4 +1,5 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useLayoutEffect, useRef } from 'react'; +import { flushSync } from 'react-dom'; import { useNavigate } from 'react-router-dom'; import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight } from 'lucide-react'; import * as LucideIcons from 'lucide-react'; @@ -69,7 +70,7 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark, : DefaultIcon; return ( -
+
{/* Dismiss X button */} {notice.dismissible && ( + ) : ( + + )} + +
+ {/* Pager — dots, arrows, counter (only when multiple notices) */} {total > 1 && ( -
+
)} - - {/* CTA + dismiss link */} -
- {ctaLabel ? ( - - ) : ( - - )} - {notice.dismissible && ctaLabel && ( - - )} -
); @@ -283,7 +282,10 @@ export function ModalRenderer({ notices }: Props) { // Non-dismissible notices lock the pager so users must act before advancing. const canPage = notice?.dismissible !== false; + const touchStartX = useRef(null); const touchStartY = useRef(null); + // 'h' once we classify the gesture as horizontal, 'v' for vertical, null = unclassified + const dragLockRef = useRef<'h' | 'v' | null>(null); // Keep a ref to the current notice id so dismiss/CTA handlers see the latest value const noticeIdRef = useRef(null); noticeIdRef.current = notice?.id ?? null; @@ -295,6 +297,13 @@ export function ModalRenderer({ notices }: Props) { // contentWrapperRef: the div wrapping NoticeContent — we animate its transform directly. const isPageNavRef = useRef(false); const slideDirRef = useRef<'left' | 'right'>('right'); + // Mobile drag strip — wraps all 3 slots and is translated to reveal prev/current/next + const stripRef = useRef(null); + // The sheet element itself — animated on vertical drag-to-dismiss + const sheetRef = useRef(null); + // Clip container ref + cached max height — used to pin sheet height to tallest notice + const clipRef = useRef(null); + const maxClipHeightRef = useRef(0); const contentWrapperRef = useRef(null); // Mobile breakpoint @@ -410,6 +419,19 @@ export function ModalRenderer({ notices }: Props) { return () => { document.body.style.overflow = ''; }; }, [visible, notice]); + // Pin the strip to the tallest notice height seen so far. + // Setting minHeight on the strip (not the clip) forces align-items:stretch to + // make every slot exactly that tall, so mt-auto always bottoms out at the same Y. + useLayoutEffect(() => { + if (!isMobile) return; + const el = stripRef.current; + if (!el) return; + el.style.minHeight = ''; + const h = el.scrollHeight; + if (h > maxClipHeightRef.current) maxClipHeightRef.current = h; + el.style.minHeight = `${maxClipHeightRef.current}px`; + }); + function announceIndex(newIdx: number, total: number) { setPageAnnouncement( t('system_notice.pager.position') @@ -453,6 +475,17 @@ export function ModalRenderer({ notices }: Props) { } } + function animatedDismissAll() { + const sheet = sheetRef.current; + if (!sheet || prefersReducedMotion) { handleDismissAll(); return; } + sheet.style.transition = 'transform 300ms ease-out'; + sheet.style.transform = 'translateY(110%)'; + sheet.addEventListener('transitionend', function onDone() { + sheet.removeEventListener('transitionend', onDone); + handleDismissAll(); + }, { once: true }); + } + // Sets up the content wrapper's start transform SYNCHRONOUSLY (before React // re-renders with the new notice), then flags the grace-delay effect to slide // rather than hide+show. @@ -531,6 +564,38 @@ export function ModalRenderer({ notices }: Props) { ? (visible ? 'opacity-100' : 'opacity-0') : (visible ? 'opacity-100 translate-y-0' : 'opacity-100 translate-y-full'); + // Build ContentProps for an adjacent slot so NoticeContent renders correctly + function buildSlotProps(n: SystemNoticeDTO, slotIdx: number): ContentProps { + const slotRawBody = t(n.bodyKey); + const slotBody = n.bodyParams + ? Object.entries(n.bodyParams).reduce( + (s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), v), + slotRawBody + ) + : slotRawBody; + return { + notice: n, + title: t(n.titleKey), + body: slotBody, + ctaLabel: n.cta ? t(n.cta.labelKey) : null, + titleId: `notice-title-${n.id}`, + bodyId: `notice-body-${n.id}`, + isDark, + onDismiss: handleDismiss, + onDismissAll: handleDismissAll, + onCTA: handleCTA, + total: notices.length, + currentPage: slotIdx, + canPage, + onPrev: handlePrev, + onNext: handleNext, + onGoto: handleGoto, + }; + } + + const prevNotice = notices[idx - 1] ?? null; + const nextNotice = notices[idx + 1] ?? null; + return (
{/* Screen-reader page announcements */} @@ -538,30 +603,136 @@ export function ModalRenderer({ notices }: Props) { {/* Backdrop */}
{/* Bottom sheet */}
{ touchStartY.current = e.touches[0].clientY; }} - onTouchEnd={e => { - if (touchStartY.current !== null && notice.dismissible) { - const delta = e.changedTouches[0].clientY - touchStartY.current; - if (delta > 80) handleDismiss(); + className={`absolute bottom-0 left-0 right-0 rounded-t-3xl overflow-hidden max-h-[85dvh] overflow-y-auto bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-[opacity,transform] ${dur} ${ease} ${mobileMotion}`} + style={{ paddingBottom: 'var(--bottom-nav-h)', touchAction: 'pan-y' }} + onTouchStart={e => { + touchStartX.current = e.touches[0].clientX; + touchStartY.current = e.touches[0].clientY; + dragLockRef.current = null; + }} + onTouchMove={e => { + if (prefersReducedMotion) return; + const startX = touchStartX.current; + const startY = touchStartY.current; + if (startX === null || startY === null) return; + const dx = e.touches[0].clientX - startX; + const dy = e.touches[0].clientY - startY; + // Classify gesture direction on first significant movement + if (!dragLockRef.current) { + if (Math.abs(dx) > 8 || Math.abs(dy) > 8) + dragLockRef.current = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v'; + return; } + if (dragLockRef.current === 'h') { + const strip = stripRef.current; + if (!strip) return; + strip.style.transition = 'none'; + // Strip base = -33.333% (center slot visible); dx offsets from there + strip.style.transform = `translateX(calc(-33.333% + ${dx}px))`; + } else if (dragLockRef.current === 'v' && notice.dismissible) { + const sheet = sheetRef.current; + if (!sheet || dy <= 0) return; + sheet.style.transition = 'none'; + sheet.style.transform = `translateY(${dy}px)`; + } + }} + onTouchEnd={e => { + const startX = touchStartX.current; + const startY = touchStartY.current; + touchStartX.current = null; touchStartY.current = null; + const lock = dragLockRef.current; + dragLockRef.current = null; + + if (lock === 'h') { + if (startX === null) return; + const deltaX = e.changedTouches[0].clientX - startX; + const strip = stripRef.current; + if (!strip) return; + + const goNext = isRtlLanguage(language) ? deltaX > 50 : deltaX < -50; + const goPrev = isRtlLanguage(language) ? deltaX < -50 : deltaX > 50; + const canGoNext = canPage && idx < notices.length - 1; + const canGoPrev = canPage && idx > 0; + + if ((goNext && canGoNext) || (goPrev && canGoPrev)) { + // Animate strip to the adjacent slot (-66.666% = next, 0% = prev) + strip.style.transition = 'transform 200ms ease-out'; + strip.style.transform = goNext ? 'translateX(-66.666%)' : 'translateX(0%)'; + strip.addEventListener('transitionend', function onDone() { + strip.removeEventListener('transitionend', onDone); + strip.style.transition = 'none'; + // Render new content into the center slot BEFORE moving the strip, + // so the browser never paints old content at the center position. + const newIdx = goNext ? idx + 1 : idx - 1; + flushSync(() => { + isPageNavRef.current = true; + setIdx(newIdx); + announceIndex(newIdx, notices.length); + }); + strip.style.transform = 'translateX(-33.333%)'; + }, { once: true }); + } else { + // Spring back to center + strip.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)'; + strip.style.transform = 'translateX(-33.333%)'; + strip.addEventListener('transitionend', function onSnap() { + strip.removeEventListener('transitionend', onSnap); + strip.style.transition = ''; + strip.style.transform = 'translateX(-33.333%)'; + }, { once: true }); + } + return; + } + + // Vertical drag — animated dismiss or spring back + if (lock === 'v' && startY !== null) { + const deltaY = e.changedTouches[0].clientY - startY; + const sheet = sheetRef.current; + if (deltaY > 80 && notice.dismissible) { + animatedDismissAll(); + } else if (sheet && deltaY > 0) { + sheet.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)'; + sheet.style.transform = 'translateY(0)'; + sheet.addEventListener('transitionend', function onSnap() { + sheet.removeEventListener('transitionend', onSnap); + sheet.style.transition = ''; + sheet.style.transform = ''; + }, { once: true }); + } + } }} > {/* Drag handle */}
-
- + {/* Clip container — hides the adjacent slots outside the sheet width */} +
+ {/* 3-slot strip: [prev][current][next] — starts at -33.333% to show current */} +
+
+ {prevNotice && } +
+
+ +
+
+ {nextNotice && } +
+
diff --git a/client/src/components/Todo/TodoListPanel.tsx b/client/src/components/Todo/TodoListPanel.tsx index 9f8a396f..2e7155ce 100644 --- a/client/src/components/Todo/TodoListPanel.tsx +++ b/client/src/components/Todo/TodoListPanel.tsx @@ -394,7 +394,7 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items )} {selectedItem && !isAddingNew && isMobile && (
{ if (e.target === e.currentTarget) setSelectedId(null) }} - style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}> + style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
{ if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}> { if (e.target === e.currentTarget) setIsAddingNew(false) }} - style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}> + style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
{ if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}> { mouseDownTarget.current = e.target }} onClick={e => { if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) onClose() diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index 1db6de3c..721bdd6b 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -2030,7 +2030,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa return (
-
+

{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}

diff --git a/client/src/pages/JourneyPage.tsx b/client/src/pages/JourneyPage.tsx index 564cdddd..1c094eb6 100644 --- a/client/src/pages/JourneyPage.tsx +++ b/client/src/pages/JourneyPage.tsx @@ -279,7 +279,7 @@ export default function JourneyPage() { {/* Create Modal */} {showCreate && (
-
+
{/* Header */}
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 0f152392..5c24201d 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -838,7 +838,7 @@ export default function TripPlannerPage(): React.ReactElement | null { )} {selectedPlace && isMobile && ReactDOM.createPortal( -
setSelectedPlaceId(null)}> +
setSelectedPlaceId(null)}>
e.stopPropagation()}> Date: Thu, 16 Apr 2026 23:36:33 +0200 Subject: [PATCH 2/5] fix: getAppVersion now getting 1st from environment, fallback to package.json, fallback to 0.0.0 if all failed --- server/src/systemNotices/service.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/server/src/systemNotices/service.ts b/server/src/systemNotices/service.ts index d962ed90..13c16b1b 100644 --- a/server/src/systemNotices/service.ts +++ b/server/src/systemNotices/service.ts @@ -1,10 +1,19 @@ +import { createRequire } from 'module'; +import semver from 'semver'; import { db } from '../db/database.js'; import { SYSTEM_NOTICES } from './registry.js'; import { evaluate } from './conditions.js'; import type { SystemNoticeDTO } from './types.js'; function getCurrentAppVersion(): string { - return process.env.APP_VERSION || '0.0.0'; + const fromEnv = semver.valid(process.env.APP_VERSION ?? ''); + if (fromEnv) return fromEnv; + try { + const pkg = require('../../package.json') as { version?: string }; + return semver.valid(pkg.version ?? '') ?? '0.0.0'; + } catch { + return '0.0.0'; + } } function severityWeight(s: string): number { From a1f3b4476e4b605da10c00bb565b65d16c2e8490 Mon Sep 17 00:00:00 2001 From: jubnl Date: Fri, 17 Apr 2026 15:06:23 +0200 Subject: [PATCH 3/5] fix(system-notices): overhaul mobile bottom sheet UX - Replace "Next Notice >" CTA with proper < > pager buttons - Fix shared scroll container: each slot now scrolls independently - Sheet uses fixed h-[85dvh] so height is consistent across all notices - Sticky footer (pager + CTA) always anchored at bottom of each slot - Content area vertically centered when shorter than available space - Dismiss-drag suppressed when slot is scrolled down (pan-up to scroll back) - Scroll position resets on navigation via per-slot refs - Adjacent slot scroll cleared on horizontal gesture classification - OK button navigates to next notice on non-last pages, dismisses on last - OK button only shown when dismissible or on last notice --- .../SystemNotices/SystemNoticeModal.tsx | 337 +++++++++--------- 1 file changed, 174 insertions(+), 163 deletions(-) diff --git a/client/src/components/SystemNotices/SystemNoticeModal.tsx b/client/src/components/SystemNotices/SystemNoticeModal.tsx index 837bef10..82ae2189 100644 --- a/client/src/components/SystemNotices/SystemNoticeModal.tsx +++ b/client/src/components/SystemNotices/SystemNoticeModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useLayoutEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { flushSync } from 'react-dom'; import { useNavigate } from 'react-router-dom'; import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight } from 'lucide-react'; @@ -71,159 +71,168 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark, : DefaultIcon; return ( -
+
{/* Dismiss X button — only on last page so users read all notices */} {notice.dismissible && isLastPage && ( )} - {/* Hero image (not inline) */} - {notice.media && notice.media.placement !== 'inline' && ( -
- {t(notice.media.altKey)} { (e.target as HTMLImageElement).style.display = 'none'; }} - /> -
- )} - - {/* Special warm header for Heart icon (thank-you notice) */} - {notice.icon === 'Heart' && !notice.media && ( -
-
-
-
- -
-
-

{title}

-

TREK 3.0

-
-
-
- )} - -
- {/* Severity icon (when no hero and not Heart) */} - {!notice.media && notice.icon !== 'Heart' && ( -
- -
- )} - - {/* Title (not for Heart — rendered in gradient header) */} - {(notice.icon !== 'Heart' || notice.media) && ( -

- {title} -

- )} - - {/* Body — markdown (long body text uses left-aligned layout) */} -
- {body}

}> - ( - - {children} - - ), - p: ({ children }) => { - // Signature line styling (e.g. "— Maurice") - const text = typeof children === 'string' ? children : Array.isArray(children) ? children.find(c => typeof c === 'string') : ''; - if (typeof text === 'string' && text.trim().startsWith('—') && text.trim().length < 30) { - return

{children}

; - } - return

{children}

; - }, - hr: () => ( -
-
- -
-
- ), - strong: ({ children }) => {children}, - ul: ({ children }) =>
    {children}
, - ol: ({ children }) =>
    {children}
, - }} - > - {body} - - -
- - {/* Inline image */} - {notice.media?.placement === 'inline' && ( + {/* Scrollable content — vertically centered when shorter than available space */} +
+ {/* Hero image (not inline) */} + {notice.media && notice.media.placement !== 'inline' && (
{t(notice.media.altKey)} { (e.target as HTMLImageElement).style.display = 'none'; }} />
)} - {/* Highlights */} - {notice.highlights && notice.highlights.length > 0 && ( -
    - {notice.highlights.map((h, i) => { - const HIcon: React.ElementType | null = h.iconName - ? ((LucideIcons as Record)[h.iconName] as React.ElementType) ?? null - : null; - return ( -
  • - {HIcon - ? - : - } - {t(h.labelKey)} -
  • - ); - })} -
+ {/* Special warm header for Heart icon (thank-you notice) */} + {notice.icon === 'Heart' && !notice.media && ( +
+
+
+
+ +
+
+

{title}

+

TREK 3.0

+
+
+
)} +
+ {/* Severity icon (when no hero and not Heart) */} + {!notice.media && notice.icon !== 'Heart' && ( +
+ +
+ )} + + {/* Title (not for Heart — rendered in gradient header) */} + {(notice.icon !== 'Heart' || notice.media) && ( +

+ {title} +

+ )} + + {/* Body — markdown */} +
+ {body}

}> + ( + + {children} + + ), + p: ({ children }) => { + // Signature line styling (e.g. "— Maurice") + const text = typeof children === 'string' ? children : Array.isArray(children) ? children.find(c => typeof c === 'string') : ''; + if (typeof text === 'string' && text.trim().startsWith('—') && text.trim().length < 30) { + return

{children}

; + } + return

{children}

; + }, + hr: () => ( +
+
+ +
+
+ ), + strong: ({ children }) => {children}, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + }} + > + {body} + + +
+ + {/* Inline image */} + {notice.media?.placement === 'inline' && ( +
+ {t(notice.media.altKey)} { (e.target as HTMLImageElement).style.display = 'none'; }} + /> +
+ )} + + {/* Highlights */} + {notice.highlights && notice.highlights.length > 0 && ( +
    + {notice.highlights.map((h, i) => { + const HIcon: React.ElementType | null = h.iconName + ? ((LucideIcons as Record)[h.iconName] as React.ElementType) ?? null + : null; + return ( +
  • + {HIcon + ? + : + } + {t(h.labelKey)} +
  • + ); + })} +
+ )} +
+
+ + {/* Sticky footer — pager + CTA, always anchored at the bottom of the slot */} +
{/* Pager — dots, arrows, counter (only when multiple notices) */} {total > 1 && ( -
+
@@ -247,7 +256,7 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark, onClick={onNext} disabled={!canPage || currentPage === total - 1} aria-label={t('system_notice.pager.next')} - className="p-1 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" + className="px-2 py-1 rounded border border-slate-200 dark:border-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" > @@ -262,17 +271,8 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark, )} {/* CTA + dismiss link */} -
- {!isLastPage && total > 1 ? ( - /* Non-last page: "Next" button to advance through all notices */ - - ) : ctaLabel ? ( +
+ {ctaLabel && isLastPage ? ( - ) : ( + ) : (notice.dismissible || isLastPage) && (