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);
+ });
});
});