/* * Copyright (c) 2026 by Christian Kellner. * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause */ import { useState, useEffect, useMemo } from 'react'; import { useSearchParamState, parseNumber, parseString, parseNullableBoolean, } from '../../../hooks/useSearchParamState.js'; import { Card, Col, Row, Image, Button, Typography, Pagination, Toast, Divider, Input, Select, Empty, Radio, RadioGroup, } from '@douyinfe/semi-ui-19'; import { IconBriefcase, IconCart, IconClock, IconDelete, IconLink, IconMapPin, IconStar, IconStarStroked, IconSearch, IconActivity, IconEyeOpened, IconArrowUp, IconArrowDown, } from '@douyinfe/semi-icons'; import { useNavigate, useSearchParams } from 'react-router-dom'; import ListingDeletionModal from '../../ListingDeletionModal.jsx'; import no_image from '../../../assets/no_image.jpg'; import * as timeService from '../../../services/time/timeService.js'; import { xhrDelete, xhrPost } from '../../../services/xhr.js'; import { useActions, useSelector } from '../../../services/state/store.js'; import { debounce } from '../../../utils'; import './ListingsGrid.less'; import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; const { Text } = Typography; const ListingsGrid = () => { const listingsData = useSelector((state) => state.listingsData); const providers = useSelector((state) => state.provider); const jobs = useSelector((state) => state.jobsData.jobs); const actions = useActions(); const navigate = useNavigate(); const sp = useSearchParams(); const [page, setPage] = useSearchParamState(sp, 'page', 1, parseNumber); const pageSize = 40; const [sortField, setSortField] = useSearchParamState(sp, 'sort', 'created_at', parseString); const [sortDir, setSortDir] = useSearchParamState(sp, 'dir', 'desc', parseString); const [freeTextFilter, setFreeTextFilter] = useSearchParamState(sp, 'q', null, parseString); const [watchListFilter, setWatchListFilter] = useSearchParamState(sp, 'watch', null, parseNullableBoolean); const [jobNameFilter, setJobNameFilter] = useSearchParamState(sp, 'job', null, parseString); const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean); const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString); const [deleteModalVisible, setDeleteModalVisible] = useState(false); const [listingToDelete, setListingToDelete] = useState(null); const loadData = () => { actions.listingsData.getListingsData({ page, pageSize, sortfield: sortField, sortdir: sortDir, freeTextFilter, filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter }, }); }; useEffect(() => { loadData(); }, [page, sortField, sortDir, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]); const handleFilterChange = useMemo( () => debounce((value) => { setFreeTextFilter(value || null); setPage(1); }, 500), [], ); useEffect(() => { return () => { // cleanup debounced handler to avoid memory leaks handleFilterChange.cancel && handleFilterChange.cancel(); }; }, [handleFilterChange]); const handleWatch = async (e, item) => { e.preventDefault(); e.stopPropagation(); try { await xhrPost('/api/listings/watch', { listingId: item.id }); Toast.success(item.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist'); loadData(); } catch (e) { console.error(e); Toast.error('Failed to operate Watchlist'); } }; const handlePageChange = (_page) => { setPage(_page); }; const confirmDeletion = async (hardDelete) => { try { await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete }); Toast.success('Listing successfully removed'); loadData(); } catch (error) { Toast.error(error.message || 'Error deleting listing'); } finally { setDeleteModalVisible(false); setListingToDelete(null); } }; const cap = (val) => { return String(val).charAt(0).toUpperCase() + String(val).slice(1); }; return (
} showClear placeholder="Search" defaultValue={freeTextFilter ?? ''} onChange={handleFilterChange} /> { const v = e.target.value; setActivityFilter(v === 'all' ? null : v === 'true'); setPage(1); }} > All Active Inactive { const v = e.target.value; setWatchListFilter(v === 'all' ? null : v === 'true'); setPage(1); }} > All Watched Unwatched
{(listingsData?.result || []).length === 0 && ( } darkModeImage={} description="No listings available yet..." /> )} {(listingsData?.result || []).map((item) => ( navigate(`/listings/listing/${item.id}`)} cover={
{!item.is_active &&
Inactive
}
} bodyStyle={{ padding: '12px' }} >
{cap(item.title)}
{item.price} €
} size="small" ellipsis={{ showTooltip: true }} style={{ width: '100%' }} > {item.address || 'No address provided'} }> {timeService.format(item.created_at, false)} }> {item.provider.charAt(0).toUpperCase() + item.provider.slice(1)} {item.distance_to_destination ? ( }> {item.distance_to_destination} m to chosen address ) : ( }> Distance cannot be calculated )}
e.stopPropagation()}>
))}
{(listingsData?.result || []).length > 0 && (
)} { setDeleteModalVisible(false); setListingToDelete(null); }} />
); }; export default ListingsGrid;