/* * 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, TextArea, Tooltip, } from '@douyinfe/semi-ui-19'; import { IconArrowLeft, IconMapPin, IconCart, IconClock, IconBriefcase, IconActivity, IconLink, IconStar, IconStarStroked, IconDelete, 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.png'; import * as timeService from '../../services/time/timeService.js'; import { distanceMeters, getBoundsFromCoords } from './mapUtils.js'; import { xhrPost, xhrDelete } from '../../services/xhr.js'; import ListingDeletionModal from '../../components/ListingDeletionModal.jsx'; import Headline from '../../components/headline/Headline.jsx'; import StatusControl from '../../components/listings/StatusControl.jsx'; 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 userSettings = useSelector((state) => state.userSettings.settings); const homeAddress = userSettings?.home_address; const listingDeletionPref = userSettings?.listing_deletion_preference; const defaultDeleteType = listingDeletionPref?.hardDelete ? 'hard' : 'soft'; const mapContainer = useRef(null); const map = useRef(null); const [loading, setLoading] = useState(true); const [deleteModalVisible, setDeleteModalVisible] = useState(false); const [notesDraft, setNotesDraft] = useState(''); const [notesSaving, setNotesSaving] = useState(false); 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]); useEffect(() => { setNotesDraft(listing?.notes ?? ''); }, [listing?.id, listing?.notes]); 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 confirmDeletion = async (hardDelete, remember) => { try { if (remember) { await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete }); } await xhrDelete('/api/listings/', { ids: [listing.id], hardDelete }); Toast.success('Listing successfully removed'); navigate('/listings'); } catch (e) { Toast.error(e.message || 'Error deleting listing'); } finally { setDeleteModalVisible(false); } }; 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'); } }; const handleStatusChange = async (next) => { try { await actions.listingsData.setListingStatus(listing.id, next); await actions.listingsData.getListing(listingId); Toast.success(next ? `Marked as ${next}` : 'Status cleared'); } catch (e) { console.error('Failed to update status:', e); Toast.error('Failed to update status'); } }; const handleSaveNotes = async () => { if (!listing) return; setNotesSaving(true); try { await actions.listingsData.setListingNotes(listing.id, notesDraft); await actions.listingsData.getListing(listingId); Toast.success('Notes saved'); } catch (e) { console.error('Failed to save notes:', e); Toast.error('Failed to save notes'); } finally { setNotesSaving(false); } }; if (loading) { return (
); } if (!listing) return null; const statusLabel = listing.status?.status ? listing.status.status.charAt(0).toUpperCase() + listing.status.status.slice(1) : null; const data = [ { key: 'Price', value: `${listing.price} €`, Icon: , helpText: 'The asking price of this listing, as reported by the provider.', }, { key: 'Size', value: listing.size ? `${listing.size} m²` : 'N/A', Icon: , helpText: 'Living space of the listing in square meters.', }, { key: 'Rooms', value: listing.rooms ? `${listing.rooms} Rooms` : 'N/A', Icon: , helpText: 'Number of rooms in the listing.', }, { key: 'Job', value: listing.job_name, Icon: , helpText: 'The Fredy job that found this listing.', }, { key: 'Provider', value: listing.provider ? listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1) : 'Unknown', Icon: , helpText: 'The real estate portal where this listing was scraped from.', }, { key: 'Added', value: timeService.format(listing.created_at), Icon: , helpText: 'When Fredy first added this listing to your database.', }, ]; if (statusLabel) { data.push({ key: 'Status', value: listing.status?.setAt ? `${statusLabel} (set ${timeService.format(listing.status.setAt)})` : statusLabel, Icon: , helpText: 'The status you marked for this listing and when you set it.', }); } return (
} onClick={() => navigate(-1)} theme="borderless" style={{ color: '#909090' }}> Back } />
{listing.address ? ( {listing.address} ) : ( No address provided )} Open listing
No image available} style={{ width: '100%', height: '100%' }} preview={!!listing.image_url} />
Notes