/* * Copyright (c) 2026 by Christian Kellner. * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause */ import { useEffect, useRef, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useSelector, useActions } from '../../services/state/store.js'; import { Typography, Button, Space, Card, Row, Col, Image, Tag, Divider, Descriptions, Banner, Spin, Toast, } from '@douyinfe/semi-ui-19'; import { IconArrowLeft, IconMapPin, IconCart, IconClock, IconBriefcase, IconActivity, IconLink, IconStar, IconStarStroked, IconExpand, IconGridView, } from '@douyinfe/semi-icons'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import no_image from '../../assets/no_image.jpg'; import * as timeService from '../../services/time/timeService.js'; import { distanceMeters, getBoundsFromCoords } from './mapUtils.js'; import { xhrPost } from '../../services/xhr.js'; import './ListingDetail.less'; const { Title, Text } = Typography; const STYLES = { STANDARD: 'https://tiles.openfreemap.org/styles/bright', }; export default function ListingDetail() { const { listingId } = useParams(); const navigate = useNavigate(); const actions = useActions(); const listing = useSelector((state) => state.listingsData.currentListing); const homeAddress = useSelector((state) => state.userSettings.settings.home_address); const mapContainer = useRef(null); const map = useRef(null); const [loading, setLoading] = useState(true); useEffect(() => { async function fetchListing() { try { setLoading(true); await actions.listingsData.getListing(listingId); } catch (e) { console.error('Failed to load listing details:', e); Toast.error('Failed to load listing details'); navigate('/listings'); } finally { setLoading(false); } } fetchListing(); }, [listingId]); const hasGeo = listing?.latitude != null && listing?.longitude != null && listing?.latitude !== -1 && listing?.longitude !== -1; useEffect(() => { if (loading || !listing || !mapContainer.current || !hasGeo) return; if (map.current) { map.current.remove(); } map.current = new maplibregl.Map({ container: mapContainer.current, style: STYLES.STANDARD, center: [listing.longitude, listing.latitude], zoom: 14, cooperativeGestures: true, }); map.current.addControl(new maplibregl.NavigationControl(), 'top-right'); new maplibregl.Marker({ color: '#3FB1CE' }) .setLngLat([listing.longitude, listing.latitude]) .setPopup(new maplibregl.Popup({ offset: 25 }).setHTML(`

Listing Location

${listing.address}

`)) .addTo(map.current); if (homeAddress?.coords) { new maplibregl.Marker({ color: 'red' }) .setLngLat([homeAddress.coords.lng, homeAddress.coords.lat]) .setPopup(new maplibregl.Popup({ offset: 25 }).setHTML(`

Home Address

${homeAddress.address}

`)) .addTo(map.current); const bounds = getBoundsFromCoords([ [listing.longitude, listing.latitude], [homeAddress.coords.lng, homeAddress.coords.lat], ]); map.current.fitBounds(bounds, { padding: 50, maxZoom: 15, }); const drawLine = () => { if (!map.current || !map.current.isStyleLoaded()) return; const distance = distanceMeters( listing.latitude, listing.longitude, homeAddress.coords.lat, homeAddress.coords.lng, ); const midpoint = [ (listing.longitude + homeAddress.coords.lng) / 2, (listing.latitude + homeAddress.coords.lat) / 2, ]; if (map.current.getSource('route')) { map.current.getSource('route').setData({ type: 'FeatureCollection', features: [ { type: 'Feature', geometry: { type: 'LineString', coordinates: [ [listing.longitude, listing.latitude], [homeAddress.coords.lng, homeAddress.coords.lat], ], }, }, { type: 'Feature', geometry: { type: 'Point', coordinates: midpoint, }, properties: { distance: `${Math.round(distance)} m`, }, }, ], }); } else { map.current.addSource('route', { type: 'geojson', data: { type: 'FeatureCollection', features: [ { type: 'Feature', geometry: { type: 'LineString', coordinates: [ [listing.longitude, listing.latitude], [homeAddress.coords.lng, homeAddress.coords.lat], ], }, }, { type: 'Feature', geometry: { type: 'Point', coordinates: midpoint, }, properties: { distance: `${Math.round(distance)} m`, }, }, ], }, }); map.current.addLayer({ id: 'route', type: 'line', source: 'route', layout: { 'line-join': 'round', 'line-cap': 'round', }, paint: { 'line-color': '#3FB1CE', 'line-width': 4, 'line-dasharray': [2, 1], }, filter: ['==', '$type', 'LineString'], }); map.current.addLayer({ id: 'route-distance', type: 'symbol', source: 'route', layout: { 'text-field': ['get', 'distance'], 'text-size': 14, 'text-offset': [0, -1], 'text-allow-overlap': true, }, paint: { 'text-color': '#ffffff', 'text-halo-color': '#3FB1CE', 'text-halo-width': 2, }, filter: ['==', '$type', 'Point'], }); } }; if (map.current.isStyleLoaded()) { drawLine(); } else { map.current.on('load', drawLine); } } return () => { if (map.current) { map.current.remove(); map.current = null; } }; }, [listing, loading, homeAddress]); const handleWatch = async () => { try { await xhrPost('/api/listings/watch', { listingId: listing.id }); Toast.success(listing.isWatched === 1 ? 'Removed from Watchlist' : 'Added to Watchlist'); actions.listingsData.getListing(listingId); } catch (e) { console.error('Failed to operate Watchlist:', e); Toast.error('Failed to operate Watchlist'); } }; if (loading) { return (
); } if (!listing) return null; const data = [ { key: 'Price', value: `${listing.price} €`, Icon: }, { key: 'Size', value: listing.size ? `${listing.size} m²` : 'N/A', Icon: , }, { key: 'Rooms', value: listing.rooms ? `${listing.rooms} Rooms` : 'N/A', Icon: , }, { key: 'Job', value: listing.job_name, Icon: , }, { key: 'Provider', value: listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1), Icon: , }, { key: 'Added', value: timeService.format(listing.created_at), Icon: , }, ]; return (
{listing.title} {listing.address || 'No address provided'} } underline> Open listing
Details {data.map((item, index) => ( {item.Icon} {item.value} ))} Description {listing.description || 'No description available.'} {listing.distance_to_destination && ( <> Distance to home: {listing.distance_to_destination} m )}
Location {!hasGeo ? ( ) : (
)}
); }