From 708a756a075b051918f78e2b403b6c45fb4cf3f9 Mon Sep 17 00:00:00 2001 From: Babayaga Date: Sun, 29 Mar 2026 14:43:15 +0200 Subject: [PATCH] map | list : speed & nav --- .../modules/places/CompetitorsGridView.tsx | 218 ++++++++- .../src/modules/places/CompetitorsMapView.tsx | 233 ++++++--- .../ui/src/modules/places/LocationDetail.tsx | 32 +- .../src/modules/places/client-gridsearch.ts | 3 + .../modules/places/components/MapFooter.tsx | 4 +- .../places/components/MapPosterOverlay.tsx | 12 +- .../components/map-layers/RegionLayers.tsx | 4 + .../components/map-layers/SimulatorLayers.tsx | 454 ++++++++++++------ .../modules/places/components/map-styles.ts | 33 +- .../modules/places/gridsearch/GridSearch.tsx | 13 +- .../places/gridsearch/GridSearchResults.tsx | 88 ++-- .../modules/places/gridsearch/JobViewer.tsx | 8 +- .../places/gridsearch/OngoingSearches.tsx | 49 +- .../simulator/hooks/useGridSimulatorState.ts | 13 +- .../places/hooks/useGridSearchState.ts | 2 +- .../modules/places/hooks/useMapControls.ts | 19 +- .../src/modules/places/utils/poster-themes.ts | 7 +- 17 files changed, 892 insertions(+), 300 deletions(-) diff --git a/packages/ui/src/modules/places/CompetitorsGridView.tsx b/packages/ui/src/modules/places/CompetitorsGridView.tsx index c24f88d1..6928189a 100644 --- a/packages/ui/src/modules/places/CompetitorsGridView.tsx +++ b/packages/ui/src/modules/places/CompetitorsGridView.tsx @@ -1,6 +1,19 @@ -import React, { useState } from 'react'; +import React, { useState, useRef, useEffect, useCallback } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { DataGrid, type GridColDef, type GridFilterModel, type GridPaginationModel, type GridSortModel, type GridColumnVisibilityModel } from '@mui/x-data-grid'; +import { + DataGrid, + useGridApiRef, + GridToolbarContainer, + GridToolbarColumnsButton, + GridToolbarFilterButton, + GridToolbarExport, + GridToolbarQuickFilter, + type GridColDef, + type GridFilterModel, + type GridPaginationModel, + type GridSortModel, + type GridColumnVisibilityModel +} from '@mui/x-data-grid'; import { type CompetitorFull } from '@polymech/shared'; import { ThemeProvider as MuiThemeProvider } from '@mui/material/styles'; import { useMuiTheme } from '@/hooks/useMuiTheme'; @@ -17,6 +30,8 @@ import { paramsToColumnOrder } from './gridUtils'; import { useGridColumns } from './useGridColumns'; +import { GripVertical } from 'lucide-react'; +import { LocationDetailView } from './LocationDetail'; interface CompetitorsGridViewProps { competitors: CompetitorFull[]; @@ -25,6 +40,27 @@ interface CompetitorsGridViewProps { updateExcludedTypes: (types: string[]) => Promise; } +const CustomToolbar = ({ selectedCount }: { selectedCount: number }) => { + return ( + +
+ + + + +
+ +
+ {selectedCount > 0 && ( + + {selectedCount} selected + + )} +
+
+ ); +}; + export const CompetitorsGridView: React.FC = ({ competitors, loading, settings, updateExcludedTypes }) => { const muiTheme = useMuiTheme(); const [searchParams, setSearchParams] = useSearchParams(); @@ -82,6 +118,32 @@ export const CompetitorsGridView: React.FC = ({ compet // Selection state const [selectedRows, setSelectedRows] = useState([]); + + const apiRef = useGridApiRef(); + const containerRef = useRef(null); + const [highlightedRowId, setHighlightedRowId] = useState(null); + const [anchorRowId, setAnchorRowId] = useState(null); + + // Sidebar panel state + const [showLocationDetail, setShowLocationDetail] = useState(false); + const [sidebarWidth, setSidebarWidth] = useState(400); + + const isResizingRef = useRef(false); + const startResizing = useCallback(() => { + isResizingRef.current = true; + const onMove = (e: MouseEvent) => { + if (!isResizingRef.current) return; + const newWidth = window.innerWidth - e.clientX; + if (newWidth > 300 && newWidth < 800) setSidebarWidth(newWidth); + }; + const onUp = () => { + isResizingRef.current = false; + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + }, []); // Get Columns Definition const columns = useGridColumns({ @@ -237,11 +299,117 @@ export const CompetitorsGridView: React.FC = ({ compet return ordered; }, [searchParams, columns, columnWidths]); + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + if (!apiRef.current) return; + + // Get row IDs in currently sorted/filtered visual order + const allSortedIds = apiRef.current.getSortedRowIds?.() || []; + // Retrieve MUI's internal lookup of which rows are hidden by filters + const filteredRowsLookup = apiRef.current.state?.filter?.filteredRowsLookup || {}; + + // Keep only rows that are NOT explicitly filtered out + const rowIds = allSortedIds.filter(id => filteredRowsLookup[id] !== false); + + if (rowIds.length === 0) return; + + const maxIdx = rowIds.length - 1; + let currentIdx = highlightedRowId ? rowIds.indexOf(highlightedRowId) : -1; + if (currentIdx < 0) currentIdx = 0; + + let nextIdx = currentIdx; + let handled = false; + + if (e.key === 'ArrowDown') { + nextIdx = Math.min(maxIdx, currentIdx + 1); + handled = true; + } else if (e.key === 'ArrowUp') { + nextIdx = Math.max(0, currentIdx - 1); + handled = true; + } else if (e.key === 'Home') { + nextIdx = 0; + handled = true; + } else if (e.key === 'End') { + nextIdx = maxIdx; + handled = true; + } else if (e.key === 'Enter') { + handled = true; + setShowLocationDetail(prev => !prev); + + } else if (e.key === ' ') { + handled = true; + const currentId = String(rowIds[currentIdx]); + setSelectedRows(prev => { + const isSelected = prev.includes(currentId); + return isSelected ? prev.filter(x => x !== currentId) : [...prev, currentId]; + }); + setAnchorRowId(currentId); + } else if (e.key === 'a' && (e.ctrlKey || e.metaKey)) { + handled = true; + setSelectedRows(rowIds.map(id => String(id))); + } else if (e.key === 'Escape') { + handled = true; + setSelectedRows([]); + } + + if (handled) { + e.preventDefault(); + e.stopImmediatePropagation(); // Crucial: Stop MUI from interpreting it + e.stopPropagation(); + + if (nextIdx !== currentIdx || !highlightedRowId) { + const nextId = String(rowIds[nextIdx]); + setHighlightedRowId(nextId); + + if (e.shiftKey) { + const anchorIdx = anchorRowId ? rowIds.indexOf(anchorRowId) : currentIdx; + const effectiveAnchor = anchorIdx >= 0 ? anchorIdx : currentIdx; + const start = Math.min(effectiveAnchor, nextIdx); + const end = Math.max(effectiveAnchor, nextIdx); + const newRange = rowIds.slice(start, end + 1).map(id => String(id)); + + if (e.ctrlKey || e.metaKey) { + setSelectedRows(prev => Array.from(new Set([...prev, ...newRange]))); + } else { + setSelectedRows(newRange); + } + } else if (e.key !== ' ' && e.key !== 'Enter' && e.key !== 'a' && e.key !== 'Escape') { + setAnchorRowId(nextId); + } + + requestAnimationFrame(() => { + try { + apiRef.current?.scrollToIndexes({ rowIndex: nextIdx }); + } catch (err) { } + }); + } + } + }; + + // Use capture phase to intercept before DataGrid native handlers + el.addEventListener('keydown', handleKeyDown, true); + return () => el.removeEventListener('keydown', handleKeyDown, true); + }, [highlightedRowId, anchorRowId, apiRef]); + + const activeDetailCompetitor = React.useMemo(() => { + if (!showLocationDetail || !highlightedRowId) return null; + return competitors.find(c => String(c.place_id || (c as any).placeId || (c as any).id) === highlightedRowId); + }, [showLocationDetail, highlightedRowId, competitors]); + return ( -
-
+
+
row.place_id || row.placeId || row.id} @@ -260,7 +428,22 @@ export const CompetitorsGridView: React.FC = ({ compet checkboxSelection showToolbar disableRowSelectionOnClick + slots={{ + toolbar: () => + }} + slotProps={{ + toolbar: { + showQuickFilter: true, + } as any + }} rowSelectionModel={{ type: 'include', ids: new Set(selectedRows) } as any} + getRowClassName={(params) => { + return String(params.id) === highlightedRowId ? 'row-custom-highlighted' : ''; + }} + onRowClick={(params) => { + setHighlightedRowId(String(params.id)); + setAnchorRowId(String(params.id)); + }} onRowSelectionModelChange={(newSelection) => { // Handle Array, Object with ids, Set, or generic Iterable let ids: any[] = []; @@ -304,6 +487,13 @@ export const CompetitorsGridView: React.FC = ({ compet backgroundColor: 'hsl(var(--primary) / 0.1)', }, }, + '& .MuiDataGrid-row.row-custom-highlighted': { + backgroundColor: 'hsl(var(--primary) / 0.15) !important', + '& .MuiDataGrid-cell': { + borderTop: '1px solid hsl(var(--primary)) !important', + borderBottom: '1px solid hsl(var(--primary)) !important', + } + }, '& .MuiDataGrid-footerContainer': { borderColor: 'hsl(var(--border))', }, @@ -338,6 +528,26 @@ export const CompetitorsGridView: React.FC = ({ compet />
+ + {showLocationDetail && activeDetailCompetitor && ( + <> + {/* Drag Handle */} +
+ +
+ + {/* Property Pane */} +
+ setShowLocationDetail(false)} + /> +
+ + )}
); }; diff --git a/packages/ui/src/modules/places/CompetitorsMapView.tsx b/packages/ui/src/modules/places/CompetitorsMapView.tsx index 8e8dc039..e43a9cd6 100644 --- a/packages/ui/src/modules/places/CompetitorsMapView.tsx +++ b/packages/ui/src/modules/places/CompetitorsMapView.tsx @@ -226,7 +226,7 @@ export const CompetitorsMapView: React.FC = ({ competit // Layer Toggles const [showDensity, setShowDensity] = useState(false); - const [showCenters, setShowCenters] = useState(false); + const [showCenters, setShowCenters] = useState(true); const [sidebarWidth, setSidebarWidth] = useState(400); const [isResizing, setIsResizing] = useState(false); @@ -334,6 +334,13 @@ export const CompetitorsMapView: React.FC = ({ competit return validLocations.map(l => l.place_id).sort().join(','); }, [validLocations]); + // Memoize the polygons FeatureCollection to avoid creating a new object reference on every render. + // Without this, RegionLayers re-pushes GeoJSON data to the map source on every single render. + const polygonsFeatureCollection = useMemo(() => { + if (pickerPolygons.length === 0) return undefined; + return { type: 'FeatureCollection' as const, features: pickerPolygons.flatMap(fc => fc?.features || []) }; + }, [pickerPolygons]); + const markersRef = useRef([]); const onMapMoveRef = useRef(onMapMove); @@ -359,20 +366,22 @@ export const CompetitorsMapView: React.FC = ({ competit map.current.addControl(new maplibregl.NavigationControl({ visualizePitch: true, showZoom: true, showCompass: true }), 'top-right'); // If container is null, it falls back to map canvas perfectly. map.current.addControl(new maplibregl.FullscreenControl({ container: mapWrapper.current || undefined }), 'top-right'); - // Removed broken GeolocateControl as it requires HTTPS/permission which might fail - // We implemented a custom IP-based locate button instead. - map.current.addControl(new maplibregl.TerrainControl({ - source: 'terrainSource', - exaggeration: 1 - }), 'top-right'); + // Only add TerrainControl when terrain source is available (3D style). + // The old code injected the terrain source into every style on style.load, + // causing expensive uncacheable DEM tile fetches even on 2D styles. + let terrainCtrlAdded = false; map.current.on('style.load', () => { const m = map.current; if (!m) return; - // Re-inject terrain for styles that don't include it (e.g. dark URL) - if (!m.getSource('terrainSource')) { - const terrainDef = { type: 'raster-dem' as const, url: 'https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=aQ5CJxgn3brYPrfV3ws9', tileSize: 256 }; - m.addSource('terrainSource', terrainDef); + if (m.getSource('terrainSource') && !terrainCtrlAdded) { + terrainCtrlAdded = true; + try { + m.addControl(new maplibregl.TerrainControl({ + source: 'terrainSource', + exaggeration: 1 + }), 'top-right'); + } catch (e) { /* already added */ } } }); @@ -401,10 +410,26 @@ export const CompetitorsMapView: React.FC = ({ competit // Create custom marker element const el = document.createElement('div'); el.className = 'custom-marker'; + // Set explicit low z-index so Pacman (9999) easily over-renders it + el.style.zIndex = '10'; + + const isDark = mapStyleKey === 'dark'; + const isSelected = selectedLocation && loc.place_id === selectedLocation.place_id; // Inner div for the pin to handle hover transform isolation const pin = document.createElement('div'); - pin.className = 'w-8 h-8 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold shadow-lg border-2 border-white cursor-pointer hover:bg-indigo-700 hover:scale-110 transition-transform'; + + // Theme-aware, more elegant pin styling with highlighting for the active one + pin.className = `w-7 h-7 rounded-full flex items-center justify-center font-bold text-xs shadow-md border-[1.5px] cursor-pointer transition-all duration-300 backdrop-blur-sm ` + + (isSelected + ? (isDark + ? 'bg-amber-500 border-amber-300 text-amber-900 scale-[1.35] z-50 ring-4 ring-amber-500/30 shadow-[0_0_15px_rgba(245,158,11,0.5)]' + : 'bg-amber-400 border-white text-amber-900 scale-[1.35] z-50 ring-4 ring-amber-400/40 shadow-[0_0_15px_rgba(251,191,36,0.6)]') + : (isDark + ? 'bg-indigo-900/80 border-indigo-400/60 text-indigo-100 hover:bg-indigo-800/90 hover:border-indigo-300 hover:scale-125' + : 'bg-indigo-600 border-white text-white shadow-lg hover:bg-indigo-700 hover:scale-125') + ); + pin.innerHTML = loc.label; el.appendChild(pin); @@ -430,6 +455,10 @@ export const CompetitorsMapView: React.FC = ({ competit .setPopup(popup) .addTo(map.current!); + // Ensure the maplibre container honors the z-index + const wrapper = marker.getElement(); + if (wrapper) wrapper.style.zIndex = '10'; + markersRef.current.push(marker); }); @@ -439,7 +468,7 @@ export const CompetitorsMapView: React.FC = ({ competit validLocations.forEach(loc => bounds.extend([loc.lon, loc.lat])); map.current.fitBounds(bounds, { padding: 50, maxZoom: 15 }); } - }, [locationIds, sidebarWidth, initialCenter]); + }, [locationIds, validLocations, sidebarWidth, initialCenter, mapStyleKey, selectedLocation]); // Sync Theme/Style @@ -457,53 +486,149 @@ export const CompetitorsMapView: React.FC = ({ competit - // Handle Layout Resize + // Handle Layout Resize (debounced to avoid firing easeTo per pixel of drag) + const resizeTimerRef = useRef | null>(null); useEffect(() => { - if (map.current) { - setTimeout(() => map.current?.resize(), 50); // Accommodate flexbox reflow - if (selectedLocation) { - // Adjust padding to account for sidebar - map.current.easeTo({ - padding: { right: 0, top: 0, bottom: 0, left: 0 }, - duration: 300 - }); + if (resizeTimerRef.current) clearTimeout(resizeTimerRef.current); + resizeTimerRef.current = setTimeout(() => { + if (map.current) { + map.current.resize(); + if (selectedLocation) { + map.current.easeTo({ + padding: { right: 0, top: 0, bottom: 0, left: 0 }, + duration: 300 + }); + } } - } + }, 100); + return () => { if (resizeTimerRef.current) clearTimeout(resizeTimerRef.current); }; }, [selectedLocation, sidebarWidth, leftSidebarWidth, gadmPickerActive, simulatorActive]); - // Resizing Handlers - const startResizing = useCallback(() => setIsResizing(true), []); - const stopResizing = useCallback(() => setIsResizing(false), []); - const resize = useCallback((e: MouseEvent) => { - if (isResizing) { + // Resizing Handlers — use refs so global listeners are only attached during active drag + const isResizingRef = useRef(false); + const isLeftResizingRef = useRef(false); + + const startResizing = useCallback(() => { + isResizingRef.current = true; + setIsResizing(true); + const onMove = (e: MouseEvent) => { + if (!isResizingRef.current) return; const newWidth = window.innerWidth - e.clientX; - if (newWidth > 300 && newWidth < 800) { - setSidebarWidth(newWidth); - } - } - }, [isResizing]); - - const startLeftResizing = useCallback(() => setIsLeftResizing(true), []); - const stopLeftResizing = useCallback(() => setIsLeftResizing(false), []); - const resizeLeft = useCallback((e: MouseEvent) => { - if (isLeftResizing) { - setLeftSidebarWidth(Math.max(100, e.clientX)); - } - }, [isLeftResizing]); - - useEffect(() => { - window.addEventListener('mousemove', resize); - window.addEventListener('mouseup', stopResizing); - window.addEventListener('mousemove', resizeLeft); - window.addEventListener('mouseup', stopLeftResizing); - return () => { - window.removeEventListener('mousemove', resize); - window.removeEventListener('mouseup', stopResizing); - window.removeEventListener('mousemove', resizeLeft); - window.removeEventListener('mouseup', stopLeftResizing); + if (newWidth > 300 && newWidth < 800) setSidebarWidth(newWidth); }; - }, [resize, stopResizing, resizeLeft, stopLeftResizing]); + const onUp = () => { + isResizingRef.current = false; + setIsResizing(false); + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + }, []); + const startLeftResizing = useCallback(() => { + isLeftResizingRef.current = true; + setIsLeftResizing(true); + const onMove = (e: MouseEvent) => { + if (!isLeftResizingRef.current) return; + setLeftSidebarWidth(Math.max(100, e.clientX)); + }; + const onUp = () => { + isLeftResizingRef.current = false; + setIsLeftResizing(false); + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + }, []); + + // Keyboard Navigation for Detail View + useEffect(() => { + if (!features.enableLocationDetails || !selectedLocation) return; + + const handleKeyDown = (e: KeyboardEvent) => { + // Ignore if in inputs + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + + // Only intercept keys we care about to prevent interfering with other global shortcuts + if (!['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', 'Escape'].includes(e.key)) return; + + let handled = false; + let nextLoc: typeof validLocations[0] | null = null; + const current = selectedLocation; + + if (e.key === 'Escape') { + setSelectedLocation(null); + handled = true; + } else { + handled = true; + + // Spatial Navigation logic + const currentLoc = validLocations.find(l => l.place_id === current.place_id); + if (currentLoc) { + const candidates = validLocations.filter(loc => loc.place_id !== current.place_id); + let bestDistance = Infinity; + + candidates.forEach(loc => { + const dx = loc.lon - currentLoc.lon; + const dy = loc.lat - currentLoc.lat; + + // Check if it's in the correct direction hemisphere + let isValidDirection = false; + if (e.key === 'ArrowRight' && dx > 0) isValidDirection = true; + if (e.key === 'ArrowLeft' && dx < 0) isValidDirection = true; + if (e.key === 'ArrowUp' && dy > 0) isValidDirection = true; + if (e.key === 'ArrowDown' && dy < 0) isValidDirection = true; + + if (isValidDirection) { + // We add a slight penalty to perpendicular movement to encourage strict directional lines, + // but simple Euclidean distance generally works best for human expectation on maps. + const distance = Math.hypot(dx, dy); + if (distance < bestDistance) { + bestDistance = distance; + nextLoc = loc; + } + } + }); + + // Rollover if we've hit the extent + if (!nextLoc && candidates.length > 0) { + let wrapTarget = candidates[0]; + candidates.forEach(loc => { + if (e.key === 'ArrowRight' && loc.lon < wrapTarget.lon) wrapTarget = loc; + if (e.key === 'ArrowLeft' && loc.lon > wrapTarget.lon) wrapTarget = loc; + if (e.key === 'ArrowUp' && loc.lat < wrapTarget.lat) wrapTarget = loc; + if (e.key === 'ArrowDown' && loc.lat > wrapTarget.lat) wrapTarget = loc; + }); + nextLoc = wrapTarget; + } + } + } + + if (handled) { + e.preventDefault(); + e.stopImmediatePropagation(); + e.stopPropagation(); + + if (e.key !== 'Escape' && nextLoc) { + setSelectedLocation(nextLoc); + if (map.current) { + map.current.flyTo({ + center: [nextLoc.lon, nextLoc.lat], + zoom: 15, + padding: { right: sidebarWidth }, + duration: 500 + }); + } + } + } + }; + + // Use capture phase to intercept before maplibregl gets it + window.addEventListener('keydown', handleKeyDown, true); + return () => window.removeEventListener('keydown', handleKeyDown, true); + }, [selectedLocation, validLocations, sidebarWidth, features.enableLocationDetails]); // Group locations by city const locationsByCity = useMemo(() => { @@ -642,7 +767,7 @@ export const CompetitorsMapView: React.FC = ({ competit map={map.current} isDarkStyle={mapStyleKey === 'dark'} pickerPolygons={pickerPolygons} - polygonsFeatureCollection={pickerPolygons.length > 0 ? { type: 'FeatureCollection', features: pickerPolygons.flatMap(fc => fc?.features || []) } : undefined} + polygonsFeatureCollection={polygonsFeatureCollection} showDensity={showDensity} showCenters={showCenters} /> diff --git a/packages/ui/src/modules/places/LocationDetail.tsx b/packages/ui/src/modules/places/LocationDetail.tsx index e719db0d..8cf46390 100644 --- a/packages/ui/src/modules/places/LocationDetail.tsx +++ b/packages/ui/src/modules/places/LocationDetail.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { useParams, Link } from 'react-router-dom'; -import { ArrowLeft, MapPin, Globe, Phone, Clock, Calendar, Image as ImageIcon, Instagram, Facebook, Linkedin, Youtube, Twitter, Star, Fingerprint, ListOrdered } from 'lucide-react'; +import { ArrowLeft, MapPin, Globe, Phone, Clock, Calendar, Image as ImageIcon, Instagram, Facebook, Linkedin, Youtube, Twitter, Star, Fingerprint, ListOrdered, Loader2 } from 'lucide-react'; import Lightbox from "yet-another-react-lightbox"; import "yet-another-react-lightbox/styles.css"; import { API_URL, THUMBNAIL_WIDTH } from '../../constants'; @@ -14,12 +14,27 @@ export const LocationDetailView: React.FC<{ competitor: CompetitorFull; onClose? const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(0); const [activeTab, setActiveTab] = useState<'overview' | 'homepage' | 'debug'>('overview'); + const [fetchedPhotos, setFetchedPhotos] = useState(null); + const [isFetchingPhotos, setIsFetchingPhotos] = useState(false); const showDebug = import.meta.env.VITE_LOCATION_DETAIL_DEBUG === 'true'; - console.log(competitor); + useEffect(() => { + // Reset local fetched state when competitor changes + setFetchedPhotos(null); + setIsFetchingPhotos(false); + + // Fetch photos on-the-fly (async, non-blocking) if we don't already have them + if (!livePhotos && !competitor.raw_data?.google_media?.photos?.length) { + setIsFetchingPhotos(true); + fetchPlacePhotos(competitor.place_id) + .then(photos => { if (photos?.photos?.length) setFetchedPhotos(photos); }) + .catch(err => console.warn('Photos fetch skipped:', err.message)) + .finally(() => setIsFetchingPhotos(false)); + } + }, [competitor.place_id, livePhotos, competitor.raw_data?.google_media?.photos?.length]); - // Prefer live-fetched photos, fall back to DB-cached - const photoSource = livePhotos || competitor.raw_data?.google_media; + // Prefer prop-injected, then dynamically fetched, then DB-cached + const photoSource = livePhotos || fetchedPhotos || competitor.raw_data?.google_media; const photos = photoSource?.photos?.map((photo: any) => ({ src: photo.image, alt: competitor.title, @@ -162,7 +177,8 @@ export const LocationDetailView: React.FC<{ competitor: CompetitorFull; onClose?

- Photos ({photos.length}) + Photos {photos.length > 0 ? `(${photos.length})` : ''} + {isFetchingPhotos && }

@@ -381,9 +397,9 @@ export const LocationDetailView: React.FC<{ competitor: CompetitorFull; onClose? Open in new tab
-