Files
fredy/ui/src/views/listings/ListingDetail.jsx
Stephan 10c94eea0a Feature/spec filter (#276)
* feat(): create map component, add area filtering to the job config

* feat(): filter listings by area filter

* chore(): cleanup

* feat(): solve feedback

* feat(): solve most providers

* feat(): solve maybe other providers

* feat(): add specFilter config, also add rooms to listing

* feat(): change tests

* feat(): fix kleinanzeigen parser

* feat(): add spec filter switch for listing overviiews

* feat(): add rooms and size to the overview and detail of a listing

* feat(): rem label

* feat(): add types, update providers, they now return specs as numbers

* feat(): add jsonconfig to enable type checks

* feat: add type for prividerConfig, add fieldNames per provider

* feat: fix tests, provider, add formatListing

* chore: remov duplicates

* feat(): fix tests

* feat: fix immoscout

* chore: geojson typing

* feat: solve requested changes
2026-04-12 09:17:23 +02:00

395 lines
12 KiB
JavaScript

/*
* 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(`<h4>Listing Location</h4><p>${listing.address}</p>`))
.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(`<h4>Home Address</h4><p>${homeAddress.address}</p>`))
.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 (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Spin size="large" />
</div>
);
}
if (!listing) return null;
const data = [
{ key: 'Price', value: `${listing.price}`, Icon: <IconCart /> },
{
key: 'Size',
value: listing.size ? `${listing.size}` : 'N/A',
Icon: <IconExpand />,
},
{
key: 'Rooms',
value: listing.rooms ? `${listing.rooms} Rooms` : 'N/A',
Icon: <IconGridView />,
},
{
key: 'Job',
value: listing.job_name,
Icon: <IconBriefcase />,
},
{
key: 'Provider',
value: listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1),
Icon: <IconBriefcase />,
},
{
key: 'Added',
value: timeService.format(listing.created_at),
Icon: <IconClock />,
},
];
return (
<div className="listing-detail">
<div className="listing-detail__back">
<Button icon={<IconArrowLeft />} onClick={() => navigate(-1)} theme="borderless">
Back
</Button>
</div>
<Card className="listing-detail__card">
<div className="listing-detail__header">
<Space vertical align="start" spacing="tight">
<Title heading={2} className="listing-detail__title">
{listing.title}
</Title>
<Space align="center">
<IconMapPin style={{ fontSize: '18px', color: 'var(--semi-color-primary)' }} />
<Text type="secondary">{listing.address || 'No address provided'}</Text>
</Space>
</Space>
<Space wrap className="listing-detail__header-actions">
<Button
icon={
listing.isWatched === 1 ? (
<IconStar style={{ color: 'var(--semi-color-warning)' }} />
) : (
<IconStarStroked />
)
}
onClick={handleWatch}
theme="light"
>
{listing.isWatched === 1 ? 'Watched' : 'Watch'}
</Button>
<Text link={{ href: listing.link, target: '_blank' }} icon={<IconLink />} underline>
Open listing
</Text>
</Space>
</div>
<Row>
<Col span={24} lg={12}>
<div className="listing-detail__image-container">
<Image
src={listing.image_url}
fallback={no_image}
style={{ width: '100%', height: '100%' }}
preview={true}
/>
</div>
</Col>
<Col span={24} lg={12}>
<div className="listing-detail__info-section">
<Title heading={4} style={{ marginBottom: '1rem' }}>
Details
</Title>
<Descriptions column={1}>
{data.map((item, index) => (
<Descriptions.Item key={index}>
<Space>
{item.Icon}
{item.value}
</Space>
</Descriptions.Item>
))}
</Descriptions>
<Divider margin="1.5rem" />
<Title heading={4} style={{ marginBottom: '1rem' }}>
Description
</Title>
<Text type="secondary" style={{ whiteSpace: 'pre-wrap' }}>
{listing.description || 'No description available.'}
</Text>
{listing.distance_to_destination && (
<>
<Divider margin="1.5rem" />
<Space align="center">
<IconActivity style={{ fontSize: '18px', color: 'var(--semi-color-primary)' }} />
<Text strong>Distance to home:</Text>
<Tag color="blue">{listing.distance_to_destination} m</Tag>
</Space>
</>
)}
</div>
</Col>
</Row>
</Card>
<div className="listing-detail__map-wrapper">
<Title heading={3}>Location</Title>
{!hasGeo ? (
<Banner
type="warning"
bordered
description="This listing has no valid geocoordinates, so we cannot show it on the map."
style={{ marginTop: '1rem' }}
/>
) : (
<div ref={mapContainer} className="listing-detail__map-container" />
)}
</div>
</div>
);
}