diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index a3c4d53dd..f8fde0e19 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -93,7 +93,9 @@ function safeImg(url) { // (it ends in …/bytes), so the extension check below would wrongly reject it — // which is why persisted place photos showed as category icons in the PDF. if (url.startsWith('/api/maps/place-photo/')) return absUrl(url) - return /\.(jpe?g|png|webp|bmp|tiff?)(\?.*)?$/i.test(url) ? absUrl(url) : null + // gif is in the list because place-image-upload.ts accepts it; without it an + // uploaded GIF hero silently fell back to the category icon in the PDF. + return /\.(jpe?g|png|gif|webp|bmp|tiff?)(\?.*)?$/i.test(url) ? absUrl(url) : null } // Generate SVG string from Lucide icon name (for category thumbnails) diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index 5efe20a39..2e6b3097f 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -44,6 +44,49 @@ function setSessionCache(key, value) { try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {} } +const creditCache = new Map() + +/** + * Names whoever made the picture shown in the avatar. + * + * Only cached provider photos carry a credit, and their proxy URL embeds the + * cache key. Anything else (an uploaded image, a legacy remote URL) renders + * nothing. Commons pictures are largely CC BY-SA, so this is an obligation + * rather than a nicety — the picker credits them while choosing, this keeps the + * credit visible afterwards. + */ +function PhotoCredit({ imageUrl }) { + const [credit, setCredit] = useState(null) + const key = useMemo(() => { + const match = /^\/api\/maps\/place-photo\/(.+)\/bytes$/.exec(imageUrl || '') + return match ? decodeURIComponent(match[1]) : null + }, [imageUrl]) + + useEffect(() => { + if (!key) { setCredit(null); return } + if (creditCache.has(key)) { setCredit(creditCache.get(key)); return } + let alive = true + mapsApi.placePhotoCredit(key).then(data => { + creditCache.set(key, data.credit) + if (alive) setCredit(data.credit) + }).catch(() => {}) + return () => { alive = false } + }, [key]) + + if (!credit) return null + return ( + {credit} + ) +} + function usePlaceDetails(googlePlaceId, osmId, language) { const [details, setDetails] = useState(null) const detailId = googlePlaceId || osmId @@ -684,6 +727,7 @@ function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameIn onRemove={() => onUpdatePlace(place.id, { image_url: null })} /> : } + {openNow === null && } {openNow !== null && ( p.id === (payload.place as Place | undefined)?.id)?.image_url + : undefined set(state => STATE_APPLIERS[type as TrekWsTripEventName]?.(payload, state) ?? {}) + // Accommodation cards read their thumbnail from a page-local copy of the + // place, not from this store, so a new hero image would only appear there + // after a reload. Narrow to an actual image change: every other place edit + // (renaming, recategorising) must not trigger a refetch. + if (type === 'place:updated') { + const nextImage = (payload.place as Place | undefined)?.image_url + if (nextImage !== prevPlaceImage) { + window.dispatchEvent(new CustomEvent('accommodations:refresh')) + } + } + // A reorder/insert re-pins dates and re-stamps booking times server-side, so // pull the authoritative days + reservations for collaborators. if (type === 'day:reordered') { diff --git a/client/src/store/tripStore.test.ts b/client/src/store/tripStore.test.ts index eb0121de6..d0e3909b1 100644 --- a/client/src/store/tripStore.test.ts +++ b/client/src/store/tripStore.test.ts @@ -475,5 +475,37 @@ describe('tripStore', () => { expect(useTripStore.getState().places[0].name).toBe('After'); }); + + it('FE-TSTORE-019b: asks the planner to reload accommodations when a place image changes', () => { + // Accommodation cards keep their own copy of the place, so a new hero + // image would otherwise only show up after a reload. + const place = buildPlace({ id: 500, trip_id: 1, image_url: null }); + seedStore(useTripStore, { places: [place] }); + const listener = vi.fn(); + window.addEventListener('accommodations:refresh', listener); + + useTripStore.getState().handleRemoteEvent({ + type: 'place:updated', + place: { ...place, image_url: '/api/maps/place-photo/way%3A1~p0/bytes' }, + }); + + expect(listener).toHaveBeenCalledTimes(1); + window.removeEventListener('accommodations:refresh', listener); + }); + + it('FE-TSTORE-019c: does not reload accommodations for an edit that leaves the image alone', () => { + const place = buildPlace({ id: 500, trip_id: 1, name: 'Before', image_url: '/uploads/places/a.jpg' }); + seedStore(useTripStore, { places: [place] }); + const listener = vi.fn(); + window.addEventListener('accommodations:refresh', listener); + + useTripStore.getState().handleRemoteEvent({ + type: 'place:updated', + place: { ...place, name: 'After' }, + }); + + expect(listener).not.toHaveBeenCalled(); + window.removeEventListener('accommodations:refresh', listener); + }); }); });