From f0a084aab7d9079599f49c1e2a683cf15ea97f98 Mon Sep 17 00:00:00 2001 From: safina57 Date: Mon, 9 Nov 2026 14:21:15 +0100 Subject: [PATCH] refactor(frontend): untangle the workspace store, services and trial display --- frontend/public/favicon.svg | 14 +- frontend/src/App.tsx | 5 + frontend/src/assets/ontario.geojson.ts | 314 ------------------ frontend/src/components/Spinner/Spinner.tsx | 18 +- .../bookmarks/BookmarkRow/BookmarkRow.tsx | 4 +- .../BookmarksSheet/BookmarksSheet.test.tsx | 68 ++-- .../BookmarksSheet/BookmarksSheet.tsx | 44 +-- .../bookmarks/TrialBadges/TrialBadges.tsx | 21 +- .../chat/ChatHeader/ChatHeader.test.tsx | 14 - .../components/chat/ChatHeader/ChatHeader.tsx | 2 +- .../chat/ChatInput/ChatInput.test.tsx | 29 +- .../components/chat/ChatInput/ChatInput.tsx | 18 +- .../chat/ChatMessages/ChatMessages.test.tsx | 49 ++- .../chat/ChatMessages/ChatMessages.tsx | 40 +-- .../chat/ChatPanel/ChatPanel.test.tsx | 155 +++------ .../components/chat/ChatPanel/ChatPanel.tsx | 55 +-- .../MessageContextTrials.tsx | 14 +- .../MessageFeedback/MessageFeedback.test.tsx | 65 +++- .../chat/MessageFeedback/MessageFeedback.tsx | 35 +- .../SelectedTrials/SelectedTrials.test.tsx | 21 -- .../chat/SelectedTrials/SelectedTrials.tsx | 12 +- .../chat/TermDefinition/TermDefinition.tsx | 5 +- .../chat/TrialCitation/TrialCitation.test.tsx | 43 ++- .../chat/TrialCitation/TrialCitation.tsx | 40 +-- .../TrialContactLink/TrialContactLink.tsx | 12 +- .../components/consent/ConsentGate.test.tsx | 21 +- .../src/components/consent/ConsentGate.tsx | 18 +- .../contact/ContactRow/ContactRow.tsx | 7 +- .../ContactSiteStep/ContactSiteStep.tsx | 12 +- .../debug/TrialTable/TrialTable.tsx | 59 ++-- .../export/pdf/TrialPdfDocument.tsx | 68 ++-- frontend/src/components/export/pdf/fonts.ts | 3 +- frontend/src/components/export/pdf/theme.ts | 13 +- .../src/components/language/LanguageGate.tsx | 39 +-- .../LanguageOptions/LanguageOptions.tsx | 56 ++++ .../layout/AppHeader/AppHeader.test.tsx | 41 +-- .../components/layout/AppHeader/AppHeader.tsx | 40 +-- .../layout/BrandTitle/BrandTitle.tsx | 31 ++ .../layout/DesktopLayout/DesktopLayout.tsx | 103 ++++++ .../layout/LanguagePicker/LanguagePicker.tsx | 48 +-- .../src/components/map/CountPin/CountPin.tsx | 2 +- .../src/components/map/DualPin/DualPin.tsx | 2 +- .../components/map/MapLegend/MapLegend.tsx | 30 +- .../src/components/map/MapPanel/MapPanel.tsx | 121 ++++--- .../src/components/map/PinShape/PinShape.tsx | 3 +- .../map/TrialCluster/TrialCluster.tsx | 60 ++-- .../map/TrialMarker/TrialMarker.tsx | 34 -- .../mobile/MobileLayout/MobileLayout.tsx | 96 +----- .../components/mobile/SearchBar/SearchBar.tsx | 40 +-- .../mobile/TrialCarousel/TrialCarousel.tsx | 36 +- .../components/onboarding/tour/tourBlur.ts | 5 +- .../onboarding/tour/useOnboardingTour.ts | 116 +++---- .../src/components/onboarding/tourSteps.ts | 30 +- .../summary/TrialFacts/TrialFacts.tsx | 26 +- .../TrialSummaryHeader/TrialSummaryHeader.tsx | 144 ++++---- .../TrialSummaryPanel.test.tsx | 60 ++-- .../TrialSummaryPanel/TrialSummaryPanel.tsx | 54 ++- .../SourceBadge/SourceBadge.tsx | 0 .../components/trial/StatusDot/StatusDot.tsx | 18 + .../trial/StatusLabel/StatusLabel.tsx | 23 ++ .../components/turnstile/TurnstileContext.ts | 11 +- .../components/turnstile/TurnstileGate.tsx | 16 +- frontend/src/constants/bookmarks.ts | 13 - frontend/src/constants/contact.ts | 3 - frontend/src/constants/language.ts | 6 +- frontend/src/constants/layout.ts | 7 - frontend/src/constants/trialSources.ts | 5 +- frontend/src/constants/trialStatus.ts | 19 ++ frontend/src/hooks/useAppLanguage.ts | 6 +- frontend/src/hooks/useCachedTranslation.ts | 41 --- frontend/src/hooks/useChat.test.tsx | 30 +- frontend/src/hooks/useChat.ts | 71 ++-- frontend/src/hooks/useMapViewSync.ts | 32 +- frontend/src/hooks/useTrialPdfExport.test.tsx | 96 +++--- frontend/src/hooks/useTrialPdfExport.tsx | 20 +- frontend/src/hooks/useTrialPins.test.ts | 31 ++ frontend/src/hooks/useTrialPins.ts | 41 ++- .../src/hooks/useTrialTranslation.test.tsx | 63 ++-- frontend/src/hooks/useTrialTranslation.ts | 50 ++- frontend/src/hooks/useWorkspace.ts | 39 +++ frontend/src/i18n/locales/de.ts | 12 +- frontend/src/i18n/locales/en.ts | 12 +- frontend/src/i18n/locales/es.ts | 12 +- frontend/src/i18n/locales/fr-CA.ts | 12 +- frontend/src/i18n/locales/hi.ts | 12 +- frontend/src/i18n/locales/yue.ts | 12 +- frontend/src/i18n/locales/zh-CN.ts | 12 +- frontend/src/lib/citations.test.ts | 37 +-- frontend/src/lib/citations.ts | 25 +- frontend/src/lib/dataDate.ts | 7 +- frontend/src/lib/motion.ts | 17 + frontend/src/lib/textBlocks.ts | 5 +- frontend/src/lib/translateTrial.ts | 2 - frontend/src/lib/trial.test.ts | 17 +- frontend/src/lib/trial.ts | 49 ++- frontend/src/lib/trialStatus.test.ts | 25 -- frontend/src/lib/trialStatus.ts | 34 -- frontend/src/pages/DebugPage.tsx | 15 +- frontend/src/pages/HomePage.tsx | 232 +------------ frontend/src/services/api.ts | 59 ++++ frontend/src/services/chat.test.ts | 23 +- frontend/src/services/chat.ts | 42 +-- frontend/src/services/contacts.ts | 20 +- frontend/src/services/dataFreshness.ts | 18 +- frontend/src/services/debug.ts | 16 +- frontend/src/services/feedback.ts | 36 +- frontend/src/services/translation.ts | 44 +-- frontend/src/services/trials.ts | 19 +- frontend/src/store/appStore.test.ts | 87 +++++ frontend/src/store/appStore.ts | 137 +++++--- frontend/src/test/fixtures/trials.ts | 24 +- frontend/src/test/render.tsx | 9 +- frontend/src/test/setup.ts | 42 ++- frontend/src/types/contact.ts | 1 - frontend/src/types/map.ts | 3 +- frontend/src/types/trial.ts | 14 - 116 files changed, 1799 insertions(+), 2500 deletions(-) delete mode 100644 frontend/src/assets/ontario.geojson.ts delete mode 100644 frontend/src/components/chat/ChatHeader/ChatHeader.test.tsx delete mode 100644 frontend/src/components/chat/SelectedTrials/SelectedTrials.test.tsx create mode 100644 frontend/src/components/language/LanguageOptions/LanguageOptions.tsx create mode 100644 frontend/src/components/layout/BrandTitle/BrandTitle.tsx create mode 100644 frontend/src/components/layout/DesktopLayout/DesktopLayout.tsx delete mode 100644 frontend/src/components/map/TrialMarker/TrialMarker.tsx rename frontend/src/components/{summary => trial}/SourceBadge/SourceBadge.tsx (100%) create mode 100644 frontend/src/components/trial/StatusDot/StatusDot.tsx create mode 100644 frontend/src/components/trial/StatusLabel/StatusLabel.tsx delete mode 100644 frontend/src/constants/bookmarks.ts delete mode 100644 frontend/src/constants/contact.ts create mode 100644 frontend/src/constants/trialStatus.ts delete mode 100644 frontend/src/hooks/useCachedTranslation.ts create mode 100644 frontend/src/hooks/useTrialPins.test.ts create mode 100644 frontend/src/hooks/useWorkspace.ts create mode 100644 frontend/src/lib/motion.ts delete mode 100644 frontend/src/lib/trialStatus.test.ts delete mode 100644 frontend/src/lib/trialStatus.ts create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/src/store/appStore.test.ts diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 402fed8..fb5b0e8 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1 +1,13 @@ - + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4be3b6b..063e3e0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,4 @@ +import { useLayoutEffect } from 'react'; import { QueryClientProvider } from '@tanstack/react-query'; import { BrowserRouter, Route, Routes } from 'react-router'; import { Toaster } from '@/components/ui/sonner'; @@ -22,6 +23,10 @@ function App() { const theme = useAppStore((state) => state.theme); useAppLanguage(); + useLayoutEffect(() => { + document.documentElement.classList.toggle('dark', theme === 'dark'); + }, [theme]); + return ( diff --git a/frontend/src/assets/ontario.geojson.ts b/frontend/src/assets/ontario.geojson.ts deleted file mode 100644 index 5120990..0000000 --- a/frontend/src/assets/ontario.geojson.ts +++ /dev/null @@ -1,314 +0,0 @@ -import type { FeatureCollection, Polygon } from 'geojson'; - -// Simplified Ontario province boundary (Douglas-Peucker, ~287 pts) for a soft -// map region highlight. Source: click_that_hood public-domain Canada data, simplified. -export const ONTARIO_BOUNDARY: FeatureCollection = { - type: 'FeatureCollection', - features: [ - { - type: 'Feature', - properties: { name: 'Ontario' }, - geometry: { - type: 'Polygon', - coordinates: [ - [ - [-79.5376, 50.9586], - [-79.5113, 47.5656], - [-79.5764, 47.4419], - [-79.4394, 47.2653], - [-79.4439, 47.1106], - [-78.9506, 46.5117], - [-78.7036, 46.3372], - [-77.555, 46.1519], - [-77.0028, 45.7903], - [-76.9208, 45.8064], - [-76.9106, 45.89], - [-76.8047, 45.8647], - [-76.6664, 45.5905], - [-76.3378, 45.4558], - [-76.0767, 45.5139], - [-75.8472, 45.3725], - [-75.4533, 45.5236], - [-74.6461, 45.6356], - [-74.3967, 45.5642], - [-74.4792, 45.3167], - [-74.3247, 45.2011], - [-74.5064, 45.0717], - [-75.3292, 44.8106], - [-75.9769, 44.3836], - [-76.6883, 44.2169], - [-76.9842, 44.0714], - [-77.07, 44.0922], - [-76.8867, 44.1775], - [-77.0467, 44.1294], - [-77.0114, 44.2111], - [-77.5572, 44.1214], - [-77.5756, 44.0789], - [-77.3144, 44.1522], - [-77.3178, 44.0897], - [-77.0767, 44.1794], - [-77.1278, 44.0336], - [-76.8467, 44.1183], - [-77.0422, 43.9483], - [-76.8481, 43.9433], - [-77.1345, 43.8555], - [-77.2328, 43.88], - [-77.1703, 43.9542], - [-77.4856, 43.9422], - [-77.5808, 44.0028], - [-77.5389, 44.0194], - [-77.7036, 44.0411], - [-77.6783, 44.0008], - [-79.0925, 43.8264], - [-79.5878, 43.5647], - [-79.7758, 43.32], - [-79.7036, 43.2444], - [-79.3453, 43.188], - [-79.074, 43.2675], - [-79.0786, 43.0817], - [-78.9249, 42.9101], - [-79.0989, 42.8403], - [-79.4547, 42.8775], - [-80.2042, 42.7903], - [-80.4506, 42.6122], - [-80.1144, 42.5414], - [-80.925, 42.6655], - [-81.3331, 42.6514], - [-81.7853, 42.3978], - [-81.8564, 42.2564], - [-81.8575, 42.3208], - [-82.3881, 42.1236], - [-82.5058, 41.9133], - [-82.6278, 42.0353], - [-82.8906, 41.9822], - [-83.1239, 42.0697], - [-83.0305, 42.3132], - [-82.4272, 42.3539], - [-82.4189, 42.4983], - [-82.6384, 42.547], - [-82.5189, 42.6338], - [-82.472, 42.8987], - [-81.7455, 43.3458], - [-81.6978, 43.4878], - [-81.745, 44.0839], - [-81.5806, 44.3467], - [-81.4053, 44.4378], - [-81.2681, 44.6167], - [-81.3497, 44.973], - [-81.4339, 44.9825], - [-81.5761, 45.1928], - [-81.72, 45.2158], - [-81.6953, 45.2647], - [-81.2836, 45.2492], - [-81.3256, 45.1575], - [-81.2536, 45.0017], - [-81.1103, 44.9603], - [-81.1503, 44.9114], - [-80.9486, 44.9636], - [-81.1328, 44.758], - [-80.8928, 44.7911], - [-80.9422, 44.5953], - [-80.8056, 44.7025], - [-80.65, 44.7261], - [-80.5844, 44.6122], - [-80.0897, 44.468], - [-79.9975, 44.5569], - [-79.9875, 44.6828], - [-80.1133, 44.7525], - [-80.1039, 44.82], - [-79.9458, 44.8656], - [-79.8775, 44.7683], - [-79.6597, 44.7517], - [-79.7217, 44.7872], - [-79.6831, 44.8792], - [-79.7836, 44.8175], - [-79.8286, 44.9361], - [-79.9142, 44.9447], - [-80.1, 45.1233], - [-79.9886, 45.1483], - [-80.1294, 45.2375], - [-80.0375, 45.3142], - [-80.0594, 45.3828], - [-80.1278, 45.4117], - [-80.2333, 45.3411], - [-80.3358, 45.4356], - [-80.35, 45.3828], - [-80.4003, 45.6067], - [-80.5014, 45.5694], - [-80.7483, 45.9214], - [-81.1553, 45.9258], - [-81.1689, 46.0178], - [-81.2506, 45.9575], - [-81.4975, 45.975], - [-81.4978, 46.0203], - [-81.6703, 45.9464], - [-81.5761, 45.9969], - [-81.6447, 46.015], - [-81.5039, 46.0417], - [-81.7039, 46.0186], - [-81.5433, 46.06], - [-81.6214, 46.1231], - [-81.7611, 46.013], - [-81.7144, 46.1042], - [-82.3695, 46.1658], - [-82.3222, 46.1903], - [-82.6742, 46.1644], - [-82.6303, 46.2178], - [-82.9869, 46.1722], - [-84.0497, 46.3261], - [-84.1195, 46.3753], - [-84.075, 46.5036], - [-84.1731, 46.5525], - [-84.5172, 46.4822], - [-84.5903, 46.5658], - [-84.4219, 46.6961], - [-84.4658, 46.7583], - [-84.5483, 46.6994], - [-84.5442, 46.825], - [-84.3511, 46.8828], - [-84.7778, 46.9817], - [-84.5789, 47.2889], - [-85.0278, 47.6114], - [-84.8445, 47.9492], - [-85.5575, 47.9255], - [-85.8603, 47.9853], - [-86.1314, 48.2414], - [-86.2628, 48.5844], - [-86.4347, 48.7772], - [-86.5525, 48.7322], - [-86.7031, 48.8103], - [-86.8556, 48.7508], - [-86.9706, 48.8097], - [-87.2897, 48.7675], - [-87.9558, 48.9505], - [-88.0356, 49.0222], - [-88.2611, 48.993], - [-88.2539, 48.8722], - [-88.1053, 48.7875], - [-88.2045, 48.6067], - [-88.3325, 48.6572], - [-88.3125, 48.5817], - [-88.4303, 48.5736], - [-88.5597, 48.4303], - [-88.5564, 48.5461], - [-88.3139, 48.7606], - [-88.3608, 48.8364], - [-88.4803, 48.8544], - [-88.54, 48.6453], - [-88.6244, 48.6247], - [-88.7403, 48.3697], - [-88.9386, 48.3144], - [-88.7595, 48.5806], - [-89.1386, 48.4844], - [-89.2431, 48.3883], - [-89.1989, 48.3244], - [-89.3378, 48.1161], - [-89.5306, 48.0018], - [-89.9003, 47.9926], - [-90.1452, 48.1129], - [-90.7433, 48.0886], - [-90.8644, 48.2543], - [-91.2393, 48.0814], - [-91.5714, 48.0437], - [-91.5687, 48.1046], - [-91.7036, 48.1149], - [-91.7118, 48.1969], - [-91.9794, 48.2505], - [-92.035, 48.3556], - [-92.276, 48.3524], - [-92.2768, 48.2444], - [-92.37, 48.2209], - [-92.4974, 48.4402], - [-92.7065, 48.4605], - [-92.6417, 48.5404], - [-92.9468, 48.6284], - [-93.3041, 48.6372], - [-93.514, 48.5343], - [-93.7809, 48.5116], - [-93.8437, 48.6248], - [-94.5701, 48.7137], - [-94.6942, 48.7776], - [-94.8318, 49.3308], - [-95.1516, 49.3717], - [-95.1503, 52.8264], - [-93.6522, 53.7222], - [-90.8439, 55.6678], - [-89.3056, 56.6272], - [-89.1796, 56.8689], - [-88.8794, 56.8581], - [-88.3536, 56.5533], - [-87.9756, 56.4375], - [-87.5836, 56.0761], - [-87.6158, 56.0028], - [-87.5486, 56.05], - [-87.3519, 55.9928], - [-87.3625, 55.9467], - [-86.9794, 55.9317], - [-86.2772, 55.7289], - [-85.9444, 55.6978], - [-85.5778, 55.5628], - [-85.3933, 55.4089], - [-85.1289, 55.3464], - [-85.3981, 55.1008], - [-85.4239, 54.9906], - [-85.1942, 55.2442], - [-85.002, 55.2967], - [-84.5992, 55.2417], - [-83.9203, 55.3192], - [-83.73, 55.2594], - [-83.7369, 55.215], - [-83.6014, 55.2242], - [-83.5619, 55.1308], - [-83.5836, 55.2292], - [-83.6986, 55.2831], - [-83.1797, 55.1972], - [-82.9647, 55.2336], - [-82.8953, 55.1717], - [-82.9467, 55.1064], - [-82.5367, 55.1644], - [-82.3195, 55.0642], - [-82.3406, 55.1647], - [-82.2458, 55.1028], - [-82.2708, 54.9314], - [-82.1953, 54.8447], - [-82.4411, 54.3617], - [-82.4342, 54.2094], - [-82.3814, 54.1189], - [-82.2439, 54.068], - [-82.13, 53.7992], - [-82.2167, 53.6039], - [-82.1147, 53.2736], - [-82.2506, 53.2119], - [-82.3017, 52.9611], - [-82.1017, 52.88], - [-81.9214, 52.6822], - [-81.558, 52.4561], - [-81.553, 52.3525], - [-81.4781, 52.3022], - [-81.8225, 52.2544], - [-81.8836, 52.1875], - [-81.5017, 52.2333], - [-81.3528, 52.1011], - [-80.9883, 52.008], - [-80.5992, 51.7133], - [-80.4244, 51.3636], - [-81.015, 51.0283], - [-80.9328, 51.0497], - [-80.9439, 51.0075], - [-80.8622, 51.1161], - [-80.3922, 51.3386], - [-79.9964, 51.2547], - [-79.5376, 50.9586], - ], - ], - }, - }, - ], -}; - -// [[west, south], [east, north]] -export const ONTARIO_BOUNDS: [[number, number], [number, number]] = [ - [-95.1516, 41.9133], - [-74.3247, 56.8689], -]; -export const ONTARIO_CENTER: [number, number] = [-84.738, 49.391]; diff --git a/frontend/src/components/Spinner/Spinner.tsx b/frontend/src/components/Spinner/Spinner.tsx index d21f752..0dd6603 100644 --- a/frontend/src/components/Spinner/Spinner.tsx +++ b/frontend/src/components/Spinner/Spinner.tsx @@ -1,26 +1,20 @@ import { useEffect, useState } from 'react'; import { cn } from '@/lib/utils'; -const DOTS13_FRAMES = ['⣼', '⣹', '⢻', '⠿', '⡟', '⣏', '⣧', '⣶']; -const DOTS13_INTERVAL = 80; +const FRAMES = ['⣼', '⣹', '⢻', '⠿', '⡟', '⣏', '⣧', '⣶']; +const FRAME_MS = 80; -interface SpinnerProps { - frames?: string[]; - interval?: number; - className?: string; -} - -function Spinner({ frames = DOTS13_FRAMES, interval = DOTS13_INTERVAL, className }: SpinnerProps) { +function Spinner({ className }: { className?: string }) { const [index, setIndex] = useState(0); useEffect(() => { - const id = setInterval(() => setIndex((current) => (current + 1) % frames.length), interval); + const id = setInterval(() => setIndex((current) => (current + 1) % FRAMES.length), FRAME_MS); return () => clearInterval(id); - }, [frames.length, interval]); + }, []); return ( - {frames[index]} + {FRAMES[index]} ); } diff --git a/frontend/src/components/bookmarks/BookmarkRow/BookmarkRow.tsx b/frontend/src/components/bookmarks/BookmarkRow/BookmarkRow.tsx index b575cce..40505b4 100644 --- a/frontend/src/components/bookmarks/BookmarkRow/BookmarkRow.tsx +++ b/frontend/src/components/bookmarks/BookmarkRow/BookmarkRow.tsx @@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'; import { Item, ItemActions, ItemContent } from '@/components/ui/item'; import BookmarkRowSkeleton from '@/components/bookmarks/BookmarkRowSkeleton/BookmarkRowSkeleton'; import TrialBadges from '@/components/bookmarks/TrialBadges/TrialBadges'; -import { publicTrialId } from '@/lib/trial'; +import { publicTrialId, trialTitle } from '@/lib/trial'; import type { Trial } from '@/types/trial'; interface BookmarkRowProps { @@ -27,7 +27,7 @@ function BookmarkRow({ onExport, }: BookmarkRowProps) { const { t } = useTranslation(); - const title = trial?.shortTitleEn ?? trial?.officialTitleEn ?? publicTrialId(trial) ?? 'Trial'; + const title = trialTitle(trial); const isPending = Boolean(isLoading) && !trial; return ( diff --git a/frontend/src/components/bookmarks/BookmarksSheet/BookmarksSheet.test.tsx b/frontend/src/components/bookmarks/BookmarksSheet/BookmarksSheet.test.tsx index 49efb87..b970fcb 100644 --- a/frontend/src/components/bookmarks/BookmarksSheet/BookmarksSheet.test.tsx +++ b/frontend/src/components/bookmarks/BookmarksSheet/BookmarksSheet.test.tsx @@ -2,63 +2,51 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import BookmarksSheet from './BookmarksSheet'; +import { useTrialPdfExport } from '@/hooks/useTrialPdfExport'; +import { useAppStore } from '@/store/appStore'; import { renderWithClient } from '@/test/render'; import { mockTrials } from '@/test/fixtures/trials'; -const fetchTrial = async (trialRef: string) => { - const trial = mockTrials.find((candidate) => candidate.trialRef === trialRef); - if (!trial) throw new Error('not found'); - return trial; -}; +vi.mock('@/hooks/useTrialPdfExport', () => ({ + useTrialPdfExport: vi.fn(() => ({ exportTrials: vi.fn(), isExporting: false })), +})); + +function renderSheet(bookmarkedTrialRefs: string[]) { + useAppStore.setState({ bookmarkedTrialRefs }); + renderWithClient(, { trials: mockTrials }); +} describe('BookmarksSheet', () => { it('shows the empty state when nothing is saved', () => { - renderWithClient( - - ); - + renderSheet([]); expect(screen.getByText(/nothing saved yet/i)).toBeInTheDocument(); }); it('renders a row per bookmark and removes on click', async () => { - const onRemove = vi.fn(); - renderWithClient( - - ); + renderSheet(['CTC-4267848A']); expect(await screen.findByText(/Immunotherapy for Advanced/i)).toBeInTheDocument(); await userEvent.click(screen.getByRole('button', { name: /remove from saved trials/i })); - expect(onRemove).toHaveBeenCalledWith('CTC-4267848A'); + expect(useAppStore.getState().bookmarkedTrialRefs).toEqual([]); + }); + + it('opens a saved trial on the map and in the chat context', async () => { + renderSheet(['CTC-4267848A']); + + await userEvent.click(await screen.findByText(/Immunotherapy for Advanced/i)); + + const state = useAppStore.getState(); + expect(state.trials).toEqual([mockTrials[0]]); + expect(state.selectedTrialRef).toBe('CTC-4267848A'); + expect(state.contextTrialRefs).toEqual(['CTC-4267848A']); }); it('exports the loaded trials', async () => { - const onExport = vi.fn(); - renderWithClient( - - ); + const exportTrials = vi.fn(); + vi.mocked(useTrialPdfExport).mockReturnValue({ exportTrials, isExporting: false }); + renderSheet(['CTC-4267848A']); await userEvent.click(await screen.findByRole('button', { name: /export all as pdf/i })); - expect(onExport).toHaveBeenCalledWith([mockTrials[0]]); + expect(exportTrials).toHaveBeenCalledWith([mockTrials[0]]); }); }); diff --git a/frontend/src/components/bookmarks/BookmarksSheet/BookmarksSheet.tsx b/frontend/src/components/bookmarks/BookmarksSheet/BookmarksSheet.tsx index d37a352..5417fec 100644 --- a/frontend/src/components/bookmarks/BookmarksSheet/BookmarksSheet.tsx +++ b/frontend/src/components/bookmarks/BookmarksSheet/BookmarksSheet.tsx @@ -14,38 +14,25 @@ import { SheetTitle, } from '@/components/ui/sheet'; import BookmarkRow from '@/components/bookmarks/BookmarkRow/BookmarkRow'; -import { useCachedTrialTranslations } from '@/hooks/useCachedTranslation'; -import { getTrial } from '@/services/trials'; +import { useCachedTrialTranslations } from '@/hooks/useTrialTranslation'; +import { useTrialPdfExport } from '@/hooks/useTrialPdfExport'; +import { trialQuery } from '@/services/trials'; +import { useAppStore } from '@/store/appStore'; import type { Trial } from '@/types/trial'; interface BookmarksSheetProps { open: boolean; onOpenChange: (open: boolean) => void; - bookmarkedTrialRefs: string[]; - onRemove: (trialRef: string) => void; - onSelect?: (trial: Trial) => void; - onExport: (trials: Trial[]) => void; - isExporting?: boolean; - fetchTrial?: (trialRef: string, signal?: AbortSignal) => Promise; } -function BookmarksSheet({ - open, - onOpenChange, - bookmarkedTrialRefs, - onRemove, - onSelect, - onExport, - isExporting, - fetchTrial = getTrial, -}: BookmarksSheetProps) { +function BookmarksSheet({ open, onOpenChange }: BookmarksSheetProps) { const { t } = useTranslation(); + const bookmarkedTrialRefs = useAppStore((state) => state.bookmarkedTrialRefs); + const removeBookmark = useAppStore((state) => state.removeBookmark); + const openTrial = useAppStore((state) => state.openTrial); + const { exportTrials, isExporting } = useTrialPdfExport(); const results = useQueries({ - queries: bookmarkedTrialRefs.map((trialRef) => ({ - queryKey: ['trial', trialRef], - queryFn: ({ signal }: { signal: AbortSignal }) => fetchTrial(trialRef, signal), - enabled: open, - })), + queries: bookmarkedTrialRefs.map((trialRef) => ({ ...trialQuery(trialRef), enabled: open })), }); const loadedTrials = results @@ -53,13 +40,12 @@ function BookmarksSheet({ .filter((trial): trial is Trial => Boolean(trial)); const rowTrials = useCachedTrialTranslations(loadedTrials); - // Rows hand back the trial they render, which may be the translated copy; - // opening and exporting must both work from the stored English one. + // Rows hand back the possibly translated copy; opening and exporting need the stored English one. const english = (trial: Trial) => loadedTrials.find((loaded) => loaded.trialRef === trial.trialRef) ?? trial; const handleSelect = (trial: Trial) => { - onSelect?.(english(trial)); + openTrial(english(trial)); onOpenChange(false); }; @@ -102,8 +88,8 @@ function BookmarksSheet({ isLoading={results[index]?.isPending} isExporting={isExporting} onSelect={handleSelect} - onRemove={onRemove} - onExport={(trial) => onExport([english(trial)])} + onRemove={removeBookmark} + onExport={(trial) => exportTrials([english(trial)])} /> ))} @@ -114,7 +100,7 @@ function BookmarksSheet({ - ))} - - - + diff --git a/frontend/src/components/language/LanguageOptions/LanguageOptions.tsx b/frontend/src/components/language/LanguageOptions/LanguageOptions.tsx new file mode 100644 index 0000000..199d4f1 --- /dev/null +++ b/frontend/src/components/language/LanguageOptions/LanguageOptions.tsx @@ -0,0 +1,56 @@ +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { LANGUAGE_TARGETS, LanguageCode } from '@/constants/language'; +import { useAppLanguage } from '@/hooks/useAppLanguage'; +import { cn } from '@/lib/utils'; + +interface LanguageOptionsProps { + onSelect?: (language: LanguageCode) => void; +} + +/** The language grid with its English opt-out: the first-visit gate, the header picker and the mobile menu. */ +function LanguageOptions({ onSelect }: LanguageOptionsProps) { + const { t } = useTranslation(); + const { language, setLanguage } = useAppLanguage(); + + const select = (next: LanguageCode) => { + setLanguage(next); + onSelect?.(next); + }; + + return ( +
+
+ {LANGUAGE_TARGETS.map((option) => ( + + ))} +
+ +
+ ); +} + +export default LanguageOptions; diff --git a/frontend/src/components/layout/AppHeader/AppHeader.test.tsx b/frontend/src/components/layout/AppHeader/AppHeader.test.tsx index 1921712..fde4db8 100644 --- a/frontend/src/components/layout/AppHeader/AppHeader.test.tsx +++ b/frontend/src/components/layout/AppHeader/AppHeader.test.tsx @@ -1,55 +1,22 @@ -import { screen, waitFor } from '@testing-library/react'; +import { screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import AppHeader from './AppHeader'; import { renderWithClient } from '@/test/render'; -const props = { - dark: false, - bookmarkCount: 0, - onOpenBookmarks: vi.fn(), - onStartTour: vi.fn(), - onToggleTheme: vi.fn(), -}; - afterEach(() => { vi.unstubAllGlobals(); }); -function renderHeader() { - renderWithClient(); -} - describe('AppHeader data badge', () => { it('shows the published date once the server supplies one', async () => { vi.stubGlobal( 'fetch', - vi.fn(async () => Response.json({ published_at: '2026-09-02T13:04:22Z' })) - ); - - renderHeader(); - - await waitFor(() => expect(screen.getByText(/2 SEPT 2026/i)).toBeInTheDocument()); - }); - - it('shows no date while the request is still in flight', () => { - vi.stubGlobal( - 'fetch', - vi.fn(() => new Promise(() => {})) + vi.fn(async () => Response.json({ published_at: '2026-09-02T13:04:22Z', sources: [] })) ); - renderHeader(); + renderWithClient(); expect(screen.queryByText(/2026/)).not.toBeInTheDocument(); - }); - - it('shows no date when the request fails, rather than a stale one', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => new Response('nope', { status: 503 })) - ); - - renderHeader(); - - await waitFor(() => expect(screen.queryByText(/2026/)).not.toBeInTheDocument()); + expect(await screen.findByText(/2 SEPT 2026/i)).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/layout/AppHeader/AppHeader.tsx b/frontend/src/components/layout/AppHeader/AppHeader.tsx index 85c6c9a..47f11e9 100644 --- a/frontend/src/components/layout/AppHeader/AppHeader.tsx +++ b/frontend/src/components/layout/AppHeader/AppHeader.tsx @@ -3,29 +3,24 @@ import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import LanguagePicker from '@/components/layout/LanguagePicker/LanguagePicker'; +import BrandTitle from '@/components/layout/BrandTitle/BrandTitle'; import CamilleIcon from '@/components/layout/CamilleIcon/CamilleIcon'; +import LanguagePicker from '@/components/layout/LanguagePicker/LanguagePicker'; import OicrLogo from '@/components/layout/OicrLogo/OicrLogo'; -import { useDataFreshness } from '@/hooks/useDataFreshness'; -import { AGENT_NAME } from '@/constants/chat'; import { TRIAL_SOURCES } from '@/constants/trialSources'; +import { useDataFreshness } from '@/hooks/useDataFreshness'; +import { useAppStore } from '@/store/appStore'; interface AppHeaderProps { - dark: boolean; - bookmarkCount: number; onOpenBookmarks: () => void; onStartTour: () => void; - onToggleTheme: () => void; } -function AppHeader({ - dark, - bookmarkCount, - onOpenBookmarks, - onStartTour, - onToggleTheme, -}: AppHeaderProps) { +function AppHeader({ onOpenBookmarks, onStartTour }: AppHeaderProps) { const { t } = useTranslation(); + const dark = useAppStore((state) => state.theme === 'dark'); + const toggleTheme = useAppStore((state) => state.toggleTheme); + const bookmarkCount = useAppStore((state) => state.bookmarkedTrialRefs.length); const { updatedOn, sources } = useDataFreshness(); const lastUpdated = updatedOn ? t('data.lastUpdated', { date: updatedOn.toUpperCase() }) : null; const notice = updatedOn ? t('data.detailedNotice', { date: updatedOn }) : t('data.shortNotice'); @@ -37,19 +32,10 @@ function AppHeader({
-
-
- - {AGENT_NAME} - - - {t('app.preRelease')} - -
- - {t('app.tagline')} - -
+
@@ -130,7 +116,7 @@ function AppHeader({ - ))} -
- - - ); -} - function LanguagePicker() { const { t } = useTranslation(); const { language } = useAppLanguage(); diff --git a/frontend/src/components/map/CountPin/CountPin.tsx b/frontend/src/components/map/CountPin/CountPin.tsx index e64dd97..efddd5d 100644 --- a/frontend/src/components/map/CountPin/CountPin.tsx +++ b/frontend/src/components/map/CountPin/CountPin.tsx @@ -1,5 +1,5 @@ import PinShape from '@/components/map/PinShape/PinShape'; -import type { TrialStatus } from '@/types/trial'; +import type { TrialStatus } from '@/constants/trialStatus'; interface CountPinProps { count: number; diff --git a/frontend/src/components/map/DualPin/DualPin.tsx b/frontend/src/components/map/DualPin/DualPin.tsx index bd7a8de..6f94856 100644 --- a/frontend/src/components/map/DualPin/DualPin.tsx +++ b/frontend/src/components/map/DualPin/DualPin.tsx @@ -1,5 +1,5 @@ import CountPin from '@/components/map/CountPin/CountPin'; -import type { TrialStatus } from '@/types/trial'; +import type { TrialStatus } from '@/constants/trialStatus'; export interface StatusCount { status: TrialStatus; diff --git a/frontend/src/components/map/MapLegend/MapLegend.tsx b/frontend/src/components/map/MapLegend/MapLegend.tsx index a981660..25f8cd1 100644 --- a/frontend/src/components/map/MapLegend/MapLegend.tsx +++ b/frontend/src/components/map/MapLegend/MapLegend.tsx @@ -1,20 +1,30 @@ -import { useTranslation } from 'react-i18next'; +import type { CSSProperties } from 'react'; +import StatusLabel from '@/components/trial/StatusLabel/StatusLabel'; +import { TRIAL_STATUSES } from '@/constants/trialStatus'; import { cn } from '@/lib/utils'; -import { TRIAL_STATUS } from '@/lib/trialStatus'; -function MapLegend() { - const { t } = useTranslation(); +interface MapLegendProps { + className?: string; + style?: CSSProperties; +} +function MapLegend({ className, style }: MapLegendProps) { return (
    - {Object.values(TRIAL_STATUS).map((status) => ( -
  • - - {t(status.labelKey)} + {TRIAL_STATUSES.map((status) => ( +
  • +
  • ))}
diff --git a/frontend/src/components/map/MapPanel/MapPanel.tsx b/frontend/src/components/map/MapPanel/MapPanel.tsx index 80e3fca..dfae20a 100644 --- a/frontend/src/components/map/MapPanel/MapPanel.tsx +++ b/frontend/src/components/map/MapPanel/MapPanel.tsx @@ -6,53 +6,49 @@ import { useTranslation } from 'react-i18next'; import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'; import MapLegend from '@/components/map/MapLegend/MapLegend'; import TrialCluster from '@/components/map/TrialCluster/TrialCluster'; +import { CANADA_BOUNDARY } from '@/assets/canada.geojson'; +import { config } from '@/config'; import { useClusterDisclosure } from '@/hooks/useClusterDisclosure'; -import { useMapViewSync } from '@/hooks/useMapViewSync'; +import { INITIAL_VIEW, useMapViewSync } from '@/hooks/useMapViewSync'; import { useTrialPins } from '@/hooks/useTrialPins'; -import { CANADA_BOUNDARY, CANADA_CENTER } from '@/assets/canada.geojson'; -import { publicTrialId } from '@/lib/trial'; -import { config } from '@/config'; -import type { Trial } from '@/types/trial'; +import { useMapTrials } from '@/hooks/useWorkspace'; +import { useAppStore } from '@/store/appStore'; -const MAPBOX_TOKEN = config.mapboxToken; -const LIGHT_STYLE = config.mapboxStyleLight; -const DARK_STYLE = config.mapboxStyleDark; -const INITIAL_VIEW = { longitude: CANADA_CENTER[0], latitude: CANADA_CENTER[1], zoom: 2.5 }; const MIN_ZOOM = 2; const MAX_ZOOM = 16; // Mirrors the `--highlight` token (OICR logo green). const CANADA_GREEN = '#64bc46'; +// Mapbox's own margin around corner controls; the compact legend lines up with it. +const CONTROL_MARGIN_PX = 10; + interface MapPanelProps { - trials: Trial[]; - selectedTrialRef?: string | null; - selectedSiteKey?: string | null; - onSelectTrial?: (trialRef: string, siteKey?: string | null) => void; - dark?: boolean; - /** Opens a shared site's trial list when one of its trials is selected elsewhere. */ - autoOpenClusters?: boolean; + /** Phone layout: no coverage note, shared sites open only on tap, and the legend sits beside the zoom buttons. */ + compact?: boolean; + /** Pushes the top corner controls down, clear of anything layered over the map. */ + topInset?: number; } -function MapPanel({ - trials, - selectedTrialRef, - selectedSiteKey, - onSelectTrial, - dark, - autoOpenClusters = true, -}: MapPanelProps) { +function MapPanel({ compact = false, topInset = 0 }: MapPanelProps) { const { t } = useTranslation(); const mapRef = useRef(null); const containerRef = useRef(null); const [loaded, setLoaded] = useState(false); + const trials = useMapTrials(); + const selectedTrialRef = useAppStore((state) => state.selectedTrialRef); + const selectedSiteKey = useAppStore((state) => state.selectedSiteKey); + const selectTrial = useAppStore((state) => state.selectTrial); + const dark = useAppStore((state) => state.theme === 'dark'); + const { markers, units } = useTrialPins(trials); const { openKey, toggle, close } = useClusterDisclosure( units, selectedTrialRef, selectedSiteKey, - autoOpenClusters + !compact ); + const controlTop = topInset + CONTROL_MARGIN_PX; useMapViewSync({ mapRef, containerRef, @@ -61,10 +57,9 @@ function MapPanel({ units, selectedTrialRef, selectedSiteKey, - initialView: INITIAL_VIEW, }); - if (!MAPBOX_TOKEN) { + if (!config.mapboxToken) { return (
@@ -80,8 +75,8 @@ function MapPanel({
setLoaded(true)} > - + - {/* Every pin opens the popup, single-trial sites included, so the site is - named before a trial is picked. */} + {/* Every pin opens the popup, single-trial sites too, so the site is named before a pick. */} {units.map((unit) => ( toggle(unit.key)} onClose={close} - onSelectTrial={(nct) => onSelectTrial?.(nct, unit.key)} - items={unit.items.map((item) => ({ - trialRef: item.trial.trialRef, - title: - item.trial.shortTitleEn ?? - item.trial.officialTitleEn ?? - publicTrialId(item.trial) ?? - 'Trial', - status: item.status, - }))} + onSelectTrial={(trialRef) => selectTrial(trialRef, unit.key)} /> ))} -
- - - - - - {t('map.coverageNotice')} - - -
+ {!compact && ( +
+ + + + + + {t('map.coverageNotice')} + + +
+ )} {markers.length > 0 ? ( - + compact ? ( + // Mirrors the zoom buttons on the left: same inset, same 70px height. + + ) : ( + + ) ) : (

diff --git a/frontend/src/components/map/PinShape/PinShape.tsx b/frontend/src/components/map/PinShape/PinShape.tsx index 86fa39d..9652804 100644 --- a/frontend/src/components/map/PinShape/PinShape.tsx +++ b/frontend/src/components/map/PinShape/PinShape.tsx @@ -1,7 +1,6 @@ import { useId, type ReactNode } from 'react'; +import { TRIAL_STATUS, type TrialStatus } from '@/constants/trialStatus'; import { cn } from '@/lib/utils'; -import { TRIAL_STATUS } from '@/lib/trialStatus'; -import type { TrialStatus } from '@/types/trial'; const PIN_PATH = 'M16 3C10.477 3 6 7.477 6 13c0 7.25 10 18 10 18s10-10.75 10-18c0-5.523-4.477-10-10-10z'; diff --git a/frontend/src/components/map/TrialCluster/TrialCluster.tsx b/frontend/src/components/map/TrialCluster/TrialCluster.tsx index 6ca847e..39a1b3f 100644 --- a/frontend/src/components/map/TrialCluster/TrialCluster.tsx +++ b/frontend/src/components/map/TrialCluster/TrialCluster.tsx @@ -4,22 +4,13 @@ import { Button } from '@/components/ui/button'; import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'; import CountPin from '@/components/map/CountPin/CountPin'; import DualPin, { type StatusCount } from '@/components/map/DualPin/DualPin'; -import { TRIAL_STATUS } from '@/lib/trialStatus'; -import { cn } from '@/lib/utils'; -import type { TrialStatus } from '@/types/trial'; - -export interface ClusterItem { - trialRef: string | null; - title: string; - status: TrialStatus; -} +import StatusDot from '@/components/trial/StatusDot/StatusDot'; +import { trialTitle } from '@/lib/trial'; +import type { PinUnit } from '@/types/map'; interface TrialClusterProps { - longitude: number; - latitude: number; - locationName: string; - items: ClusterItem[]; - selectedTrialRef?: string | null; + unit: PinUnit; + selectedTrialRef: string | null; open: boolean; onToggle: () => void; onClose: () => void; @@ -27,10 +18,7 @@ interface TrialClusterProps { } function TrialCluster({ - longitude, - latitude, - locationName, - items, + unit, selectedTrialRef, open, onToggle, @@ -38,8 +26,9 @@ function TrialCluster({ onSelectTrial, }: TrialClusterProps) { const { t } = useTranslation(); - const selected = items.some((item) => item.trialRef === selectedTrialRef); - const selectedStatus = items.find((item) => item.trialRef === selectedTrialRef)?.status ?? null; + const { longitude, latitude, locationName, items } = unit; + const selectedStatus = + items.find((item) => item.trial.trialRef === selectedTrialRef)?.status ?? null; const groups = items.reduce((acc, item) => { const group = acc.find((entry) => entry.status === item.status); return group @@ -63,7 +52,11 @@ function TrialCluster({ {groups.length > 1 ? ( ) : ( - + )} @@ -90,27 +83,18 @@ function TrialCluster({ - {items.map((item, index) => ( + {items.map(({ trial, status }) => ( { - if (item.trialRef) { - onSelectTrial(item.trialRef); - onClose(); - } + onSelectTrial(trial.trialRef); + onClose(); }} > - - {item.title} + + {trialTitle(trial)} ))} diff --git a/frontend/src/components/map/TrialMarker/TrialMarker.tsx b/frontend/src/components/map/TrialMarker/TrialMarker.tsx deleted file mode 100644 index 4c180ed..0000000 --- a/frontend/src/components/map/TrialMarker/TrialMarker.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Marker } from 'react-map-gl/mapbox'; -import { Button } from '@/components/ui/button'; -import CountPin from '@/components/map/CountPin/CountPin'; -import type { TrialStatus } from '@/types/trial'; - -interface TrialMarkerProps { - longitude: number; - latitude: number; - status: TrialStatus; - selected: boolean; - label: string; - onSelect: () => void; -} - -function TrialMarker({ longitude, latitude, status, selected, label, onSelect }: TrialMarkerProps) { - return ( - - - - ); -} - -export default TrialMarker; diff --git a/frontend/src/components/mobile/MobileLayout/MobileLayout.tsx b/frontend/src/components/mobile/MobileLayout/MobileLayout.tsx index d18ee0f..faf5280 100644 --- a/frontend/src/components/mobile/MobileLayout/MobileLayout.tsx +++ b/frontend/src/components/mobile/MobileLayout/MobileLayout.tsx @@ -11,60 +11,28 @@ import { MOBILE_LAYOUT, sheetHeights } from '@/constants/layout'; import { useViewportHeight } from '@/hooks/useIsMobile'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/store/appStore'; -import type { Trial } from '@/types/trial'; const MAP_TUCK_PX = 16; const CAROUSEL_GAP_PX = 12; -// No coverage note on mobile; the legend mirrors the zoom buttons (10px inset, 70px tall) on the left. -const MAP_OVERLAYS = - '[&_[data-slot=map-info]]:hidden [&_[data-slot=map-legend]]:bottom-auto [&_[data-slot=map-legend]]:left-[10px] [&_[data-slot=map-legend]]:flex [&_[data-slot=map-legend]]:h-[70px] [&_[data-slot=map-legend]]:items-center [&_[data-slot=map-legend]]:py-0'; -const MAP_OVERLAYS_BESIDE_SHEET = '[&_[data-slot=map-legend]]:top-[10px]'; -// Behind the search bar both groups drop below it; `!` beats Mapbox's unlayered CSS. -const MAP_OVERLAYS_UNDER_SEARCH = - '[&_.mapboxgl-ctrl-top-right]:top-19! [&_[data-slot=map-legend]]:top-[calc(var(--spacing)*19+10px)]'; +// The search bar's footprint at the top of a full-height map (its h-14 plus the 0.75rem inset and a gap). +const SEARCH_BAR_CLEARANCE_PX = 76; interface MobileLayoutProps { - dark: boolean; - trials: Trial[]; - selectedTrial: Trial | null; - selectedSiteKey: string | null; - selectedSiteName: string | null; - selectedInContext: boolean; - selectedIsBookmarked: boolean; - contextTrials: Trial[]; - bookmarkCount: number; - onRemoveContext: (trialRef: string) => void; onOpenBookmarks: () => void; onStartTour: () => void; children: ReactNode; } -function MobileLayout({ - dark, - trials, - selectedTrial, - selectedSiteKey, - selectedSiteName, - selectedInContext, - selectedIsBookmarked, - contextTrials, - bookmarkCount, - onRemoveContext, - onOpenBookmarks, - onStartTour, - children, -}: MobileLayoutProps) { +function MobileLayout({ onOpenBookmarks, onStartTour, children }: MobileLayoutProps) { const { t } = useTranslation(); - const conversationTitle = useAppStore((state) => state.conversationTitle); - const { setTrials, selectTrial, reset, addToContext, toggleBookmark, toggleTheme } = - useAppStore.getState(); - + const hasTrials = useAppStore((state) => state.trials.length > 0); + const hasSelection = useAppStore((state) => state.selectedTrialRef !== null); + const selectTrial = useAppStore((state) => state.selectTrial); const snap = useAppStore((state) => state.sheetSnap); - const setSnap = useAppStore.getState().setSheetSnap; + const setSnap = useAppStore((state) => state.setSheetSnap); const detailOpen = useAppStore((state) => state.trialDrawerOpen); - const setDetailOpen = useAppStore.getState().setTrialDrawerOpen; + const setDetailOpen = useAppStore((state) => state.setTrialDrawerOpen); const heights = sheetHeights(useViewportHeight()); - const selectedTrialRef = selectedTrial?.trialRef ?? null; // A cited trial collapses the chat so its card is one tap from the details. const showTrial = (trialRef: string) => { @@ -72,8 +40,7 @@ function MobileLayout({ setSnap('peek'); }; - const askAbout = (trialRef: string) => { - addToContext(trialRef); + const returnToChat = () => { setDetailOpen(false); setSnap('full'); }; @@ -82,35 +49,20 @@ function MobileLayout({

{/* Beside a half sheet the map fits between the search bar and the sheet. */}
- +
setSnap('full')} onOpenBookmarks={onOpenBookmarks} onStartTour={onStartTour} - onToggleTheme={toggleTheme} />
- setDetailOpen(true)} - /> + setDetailOpen(true)} />
0 ? t('mobile.chatPeek') : t('mobile.searchPrompt')} + peekLabel={hasTrials ? t('mobile.chatPeek') : t('mobile.searchPrompt')} onSnapChange={setSnap} > - + - + {t('summary.trialDetails')}
setDetailOpen(false)} - onAddToContext={askAbout} - isInContext={selectedInContext} - onToggleBookmark={toggleBookmark} - isBookmarked={selectedIsBookmarked} - selectedSiteName={selectedSiteName} + onAddedToContext={returnToChat} actionBar />
diff --git a/frontend/src/components/mobile/SearchBar/SearchBar.tsx b/frontend/src/components/mobile/SearchBar/SearchBar.tsx index 15838f2..a21b8c0 100644 --- a/frontend/src/components/mobile/SearchBar/SearchBar.tsx +++ b/frontend/src/components/mobile/SearchBar/SearchBar.tsx @@ -19,41 +19,34 @@ import { SheetTitle, SheetTrigger, } from '@/components/ui/sheet'; +import LanguageOptions from '@/components/language/LanguageOptions/LanguageOptions'; +import BrandTitle from '@/components/layout/BrandTitle/BrandTitle'; import CamilleIcon from '@/components/layout/CamilleIcon/CamilleIcon'; import CornerMark from '@/components/layout/CornerMark/CornerMark'; -import { LanguageOptions } from '@/components/layout/LanguagePicker/LanguagePicker'; import OicrLogo from '@/components/layout/OicrLogo/OicrLogo'; import { AGENT_NAME } from '@/constants/chat'; import { LANGUAGE_TARGETS } from '@/constants/language'; import { useAppLanguage } from '@/hooks/useAppLanguage'; import { cn } from '@/lib/utils'; +import { useAppStore } from '@/store/appStore'; interface SearchBarProps { - /** The conversation title; null shows the placeholder prompt. */ - title: string | null; - dark: boolean; - bookmarkCount: number; onOpenChat: () => void; onOpenBookmarks: () => void; onStartTour: () => void; - onToggleTheme: () => void; } const MENU_ROW = 'h-11 justify-start gap-3 px-3 text-sm'; const HIGHLIGHT_EDGE = "after:bg-highlight relative overflow-hidden after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:content-['']"; -function SearchBar({ - title, - dark, - bookmarkCount, - onOpenChat, - onOpenBookmarks, - onStartTour, - onToggleTheme, -}: SearchBarProps) { +function SearchBar({ onOpenChat, onOpenBookmarks, onStartTour }: SearchBarProps) { const { t } = useTranslation(); const { language } = useAppLanguage(); + const title = useAppStore((state) => state.conversationTitle); + const dark = useAppStore((state) => state.theme === 'dark'); + const toggleTheme = useAppStore((state) => state.toggleTheme); + const bookmarkCount = useAppStore((state) => state.bookmarkedTrialRefs.length); const [languageOpen, setLanguageOpen] = useState(false); const current = LANGUAGE_TARGETS.find((option) => option.code === language); @@ -93,19 +86,8 @@ function SearchBar({
-
-
- - {AGENT_NAME} - - - {t('app.preRelease')} - -
- - {t('app.tagline')} - -
+ {AGENT_NAME} +