From c988ac7f84b34b9276edb745038a272aaca5b150 Mon Sep 17 00:00:00 2001 From: chenkel-data Date: Fri, 5 Jun 2026 17:16:28 +0200 Subject: [PATCH 1/3] feat: add listing details --- client/src/App.jsx | 413 ++- client/src/api.js | 10 +- client/src/components/ListingCard.jsx | 309 +- client/src/components/ListingDetailDrawer.jsx | 330 ++ client/src/components/ListingsGrid.jsx | 138 +- client/src/index.css | 2662 ++++++++++++++--- package.json | 1 + src/db/database.js | 220 ++ src/providers/immoscout24/detail.js | 244 ++ src/providers/immoscout24/index.js | 2 +- src/providers/kleinanzeigen/detail.js | 347 +++ src/routes/listings.js | 82 + tests/immoscout24.test.js | 101 + tests/kleinanzeigen.test.js | 121 + 14 files changed, 4365 insertions(+), 615 deletions(-) create mode 100644 client/src/components/ListingDetailDrawer.jsx create mode 100644 src/providers/immoscout24/detail.js create mode 100644 src/providers/kleinanzeigen/detail.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 4a92697..e772d9d 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -3,6 +3,7 @@ import Header from './components/Header.jsx'; import AgentSidebar from './components/AgentSidebar.jsx'; import FilterBar from './components/FilterBar.jsx'; import ListingsGrid from './components/ListingsGrid.jsx'; +import ListingDetailDrawer from './components/ListingDetailDrawer.jsx'; import ScrapeLog from './components/ScrapeLog.jsx'; import Sidebar from './components/Sidebar.jsx'; import Toast from './components/Toast.jsx'; @@ -15,7 +16,14 @@ import { useToast } from './hooks/useToast.js'; import { useSearchConfigs } from './hooks/useSearchConfigs.js'; import { parseNum } from './utils/formatting.js'; import { api } from './api.js'; -import { TABS, ITEMS_PER_PAGE, LISTING_TYPE_LABELS, LISTING_TYPE_COLORS, PROVIDER_COLORS, PROVIDER_LABELS } from './constants.js'; +import { + TABS, + ITEMS_PER_PAGE, + LISTING_TYPE_LABELS, + LISTING_TYPE_COLORS, + PROVIDER_COLORS, + PROVIDER_LABELS, +} from './constants.js'; const FILTERS_STORAGE_KEY = 'immo.filters.v1'; @@ -50,19 +58,43 @@ export default function App() { try { const cfg = await api.scrape.getConfig(); setScrapeConfig(cfg ?? { blacklistKeywords: [] }); - } catch { /* ignore */ } + } catch { + /* ignore */ + } }, []); const { - listings, loading, stats, orphanStats, configStats, - loadListings, loadStats, loadConfigStats, - handleSeen, handleFavorite, handleBlacklist, handleUnblacklist, - handleMarkAllSeen, handleReset, handleResetConfig, - handleClearFavorites, handleClearFavoritesByConfig, - handleClearBlacklist, handleClearBlacklistByConfig, + listings, + loading, + stats, + orphanStats, + configStats, + loadListings, + loadStats, + loadConfigStats, + handleSeen, + handleFavorite, + handleBlacklist, + handleUnblacklist, + handleMarkAllSeen, + handleReset, + handleResetConfig, + handleClearFavorites, + handleClearFavoritesByConfig, + handleClearBlacklist, + handleClearBlacklistByConfig, } = useListings(showToast); const { runs, loadRuns } = useRuns(); - const { configs, providers, loadConfigs, loadProviders, addConfig, editConfig, removeConfig, toggleConfig } = useSearchConfigs(showToast); + const { + configs, + providers, + loadConfigs, + loadProviders, + addConfig, + editConfig, + removeConfig, + toggleConfig, + } = useSearchConfigs(showToast); const currentListingParamsRef = useRef({ include_blacklisted: true }); @@ -73,13 +105,17 @@ export default function App() { loadConfigStats(); loadRuns(); }, [loadListings, loadStats, loadConfigStats, loadRuns]); - const { scraping, scrapeProgress, startScraping, startScrapingConfig, stopScraping, cleanup } = useScraper(showToast, reloadAll); + const { scraping, scrapeProgress, startScraping, startScrapingConfig, stopScraping, cleanup } = + useScraper(showToast, reloadAll); /* confirm dialog state */ const [confirmDialog, setConfirmDialog] = useState({ open: false }); - const askConfirm = useCallback(({ title, message, danger = false, confirm = 'Bestätigen', onConfirm }) => { - setConfirmDialog({ open: true, title, message, danger, confirm, onConfirm }); - }, []); + const askConfirm = useCallback( + ({ title, message, danger = false, confirm = 'Bestätigen', onConfirm }) => { + setConfirmDialog({ open: true, title, message, danger, confirm, onConfirm }); + }, + [], + ); const closeConfirm = useCallback(() => setConfirmDialog({ open: false }), []); /* local UI state */ @@ -87,6 +123,7 @@ export default function App() { const [activeConfigId, setActiveConfigId] = useState(null); const [sidebarOpen, setSidebarOpen] = useState(false); const [agentSidebarCollapsed, setAgentSidebarCollapsed] = useState(false); + const [detailListing, setDetailListing] = useState(null); const [page, setPage] = useState(1); const [searchQuery, setSearchQuery] = useState(persistedFilters.searchQuery); const [listingTypeFilter, setListingTypeFilter] = useState(persistedFilters.listingTypeFilter); @@ -99,22 +136,42 @@ export default function App() { const [maxAvailableFrom, setMaxAvailableFrom] = useState(persistedFilters.maxAvailableFrom); /* initial load */ - useEffect(() => { loadStats(); loadConfigStats(); loadRuns(); loadConfigs(); loadProviders(); loadScrapeConfig(); }, []); + useEffect(() => { + loadStats(); + loadConfigStats(); + loadRuns(); + loadConfigs(); + loadProviders(); + loadScrapeConfig(); + }, []); useEffect(() => cleanup, [cleanup]); useEffect(() => { - localStorage.setItem(FILTERS_STORAGE_KEY, JSON.stringify({ - searchQuery, - listingTypeFilter, - providerFilter, - publisherFilter, - minPrice, - maxPrice, - minSize, - minRooms, - maxAvailableFrom, - })); - }, [searchQuery, listingTypeFilter, providerFilter, publisherFilter, minPrice, maxPrice, minSize, minRooms, maxAvailableFrom]); + localStorage.setItem( + FILTERS_STORAGE_KEY, + JSON.stringify({ + searchQuery, + listingTypeFilter, + providerFilter, + publisherFilter, + minPrice, + maxPrice, + minSize, + minRooms, + maxAvailableFrom, + }), + ); + }, [ + searchQuery, + listingTypeFilter, + providerFilter, + publisherFilter, + minPrice, + maxPrice, + minSize, + minRooms, + maxAvailableFrom, + ]); useEffect(() => { const params = { include_blacklisted: true }; @@ -122,18 +179,23 @@ export default function App() { loadListings(params); }, [loadListings]); - useEffect(() => { setPage(1); }, [activeTab]); + useEffect(() => { + setPage(1); + }, [activeTab]); /* config selection handler – always resets tab to ALL when switching */ - const handleSelectConfig = useCallback((configId) => { - setActiveConfigId(configId ?? null); - const params = { include_blacklisted: true }; - if (configId) params.search_config_id = configId; - currentListingParamsRef.current = params; - loadListings(params); - setActiveTab(TABS.ALL); - setPage(1); - }, [loadListings]); + const handleSelectConfig = useCallback( + (configId) => { + setActiveConfigId(configId ?? null); + const params = { include_blacklisted: true }; + if (configId) params.search_config_id = configId; + currentListingParamsRef.current = params; + loadListings(params); + setActiveTab(TABS.ALL); + setPage(1); + }, + [loadListings], + ); /* navigate home: deselect agent, reset to unseen tab */ const handleNavigateHome = useCallback(() => { @@ -143,7 +205,15 @@ export default function App() { loadListings(params); setActiveTab(TABS.UNSEEN); setPage(1); - setSearchQuery(''); setMinPrice(''); setMaxPrice(''); setMinSize(''); setMinRooms(''); setListingTypeFilter(''); setProviderFilter(''); setPublisherFilter(''); setMaxAvailableFrom(''); + setSearchQuery(''); + setMinPrice(''); + setMaxPrice(''); + setMinSize(''); + setMinRooms(''); + setListingTypeFilter(''); + setProviderFilter(''); + setPublisherFilter(''); + setMaxAvailableFrom(''); }, [loadListings]); const isProviderFilterActive = !activeConfigId && activeTab === TABS.ALL; @@ -153,72 +223,105 @@ export default function App() { const q = searchQuery.toLowerCase(); const keywords = scrapeConfig.blacklistKeywords ?? []; - if (listingTypeFilter) list = list.filter(l => l.listing_type === listingTypeFilter); - if (q) list = list.filter(l => (l.title || '').toLowerCase().includes(q) || (l.address || '').toLowerCase().includes(q) || (l.description || '').toLowerCase().includes(q)); - if (publisherFilter) list = list.filter(l => (l.publisher || '').toLowerCase().includes(publisherFilter.toLowerCase())); - if (minPrice) list = list.filter(l => parseNum(l.price) >= Number(minPrice)); - if (maxPrice) list = list.filter(l => parseNum(l.price) <= Number(maxPrice)); - if (minSize) list = list.filter(l => parseNum(l.size) >= Number(minSize)); - if (minRooms) list = list.filter(l => { const r = parseNum(l.rooms); return r && r >= Number(minRooms); }); - if (maxAvailableFrom) list = list.filter(l => !l.available_from || l.available_from === 'sofort' || l.available_from <= maxAvailableFrom); + if (listingTypeFilter) list = list.filter((l) => l.listing_type === listingTypeFilter); + if (q) + list = list.filter( + (l) => + (l.title || '').toLowerCase().includes(q) || + (l.address || '').toLowerCase().includes(q) || + (l.description || '').toLowerCase().includes(q), + ); + if (publisherFilter) + list = list.filter((l) => + (l.publisher || '').toLowerCase().includes(publisherFilter.toLowerCase()), + ); + if (minPrice) list = list.filter((l) => parseNum(l.price) >= Number(minPrice)); + if (maxPrice) list = list.filter((l) => parseNum(l.price) <= Number(maxPrice)); + if (minSize) list = list.filter((l) => parseNum(l.size) >= Number(minSize)); + if (minRooms) + list = list.filter((l) => { + const r = parseNum(l.rooms); + return r && r >= Number(minRooms); + }); + if (maxAvailableFrom) + list = list.filter( + (l) => + !l.available_from || + l.available_from === 'sofort' || + l.available_from <= maxAvailableFrom, + ); if (keywords.length > 0) { - list = list.filter(l => { - if (l.is_blacklisted || l.is_favorite) return true; + list = list.filter((l) => { + if (l.is_blacklisted || l.is_favorite) return true; const text = `${l.title || ''} ${l.description || ''} ${l.publisher || ''}`.toLowerCase(); - return !keywords.some(kw => text.includes(kw.toLowerCase())); + return !keywords.some((kw) => text.includes(kw.toLowerCase())); }); } return list; - }, [listings, listingTypeFilter, publisherFilter, searchQuery, minPrice, maxPrice, minSize, minRooms, maxAvailableFrom, scrapeConfig]); + }, [ + listings, + listingTypeFilter, + publisherFilter, + searchQuery, + minPrice, + maxPrice, + minSize, + minRooms, + maxAvailableFrom, + scrapeConfig, + ]); const filteredBase = useMemo(() => { - if (isProviderFilterActive && providerFilter) return uiFilteredListings.filter(l => l.provider === providerFilter); + if (isProviderFilterActive && providerFilter) + return uiFilteredListings.filter((l) => l.provider === providerFilter); return uiFilteredListings; }, [uiFilteredListings, isProviderFilterActive, providerFilter]); const filtered = useMemo(() => { let list = [...filteredBase]; - if (activeTab === TABS.UNSEEN) list = list.filter(l => !l.is_blacklisted && !l.is_seen); + if (activeTab === TABS.UNSEEN) list = list.filter((l) => !l.is_blacklisted && !l.is_seen); else if (activeTab === TABS.FAVORITES) { - list = list.filter(l => !l.is_blacklisted && l.is_favorite); + list = list.filter((l) => !l.is_blacklisted && l.is_favorite); list.sort((a, b) => { const ta = a.favorited_at ? new Date(a.favorited_at).getTime() : 0; const tb = b.favorited_at ? new Date(b.favorited_at).getTime() : 0; return tb - ta; }); - } - else if (activeTab === TABS.BLACKLISTED) { - list = list.filter(l => l.is_blacklisted); + } else if (activeTab === TABS.BLACKLISTED) { + list = list.filter((l) => l.is_blacklisted); list.sort((a, b) => { const ta = a.blacklisted_at ? new Date(a.blacklisted_at).getTime() : 0; const tb = b.blacklisted_at ? new Date(b.blacklisted_at).getTime() : 0; return tb - ta; }); - } - else list = list.filter(l => !l.is_blacklisted); + } else list = list.filter((l) => !l.is_blacklisted); return list; }, [filteredBase, activeTab]); const tabCounts = useMemo(() => { let base = listings; - if (listingTypeFilter) base = base.filter(l => l.listing_type === listingTypeFilter); - if (isProviderFilterActive && providerFilter) base = base.filter(l => l.provider === providerFilter); - const nonBlacklisted = base.filter(l => !l.is_blacklisted); + if (listingTypeFilter) base = base.filter((l) => l.listing_type === listingTypeFilter); + if (isProviderFilterActive && providerFilter) + base = base.filter((l) => l.provider === providerFilter); + const nonBlacklisted = base.filter((l) => !l.is_blacklisted); return { [TABS.ALL]: nonBlacklisted.length, - [TABS.UNSEEN]: nonBlacklisted.filter(l => !l.is_seen).length, - [TABS.FAVORITES]: nonBlacklisted.filter(l => l.is_favorite).length, - [TABS.BLACKLISTED]: base.filter(l => l.is_blacklisted).length, + [TABS.UNSEEN]: nonBlacklisted.filter((l) => !l.is_seen).length, + [TABS.FAVORITES]: nonBlacklisted.filter((l) => l.is_favorite).length, + [TABS.BLACKLISTED]: base.filter((l) => l.is_blacklisted).length, }; }, [listings, listingTypeFilter, isProviderFilterActive, providerFilter]); - const activeConfigStats = useMemo(() => ({ - total: tabCounts[TABS.ALL], - unseen: tabCounts[TABS.UNSEEN], - favorites: tabCounts[TABS.FAVORITES], - blacklisted: tabCounts[TABS.BLACKLISTED], - }), [tabCounts]); + const activeConfigStats = useMemo( + () => ({ + total: tabCounts[TABS.ALL], + unseen: tabCounts[TABS.UNSEEN], + favorites: tabCounts[TABS.FAVORITES], + blacklisted: tabCounts[TABS.BLACKLISTED], + }), + [tabCounts], + ); const pages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE)); const safePage = Math.min(page, pages); @@ -228,18 +331,31 @@ export default function App() { setPage(newPage); window.scrollTo({ top: 0, behavior: 'smooth' }); }, []); - const unseenCount = listings.filter(l => !l.is_seen && !l.is_blacklisted).length; - - const resetFilters = () => { setSearchQuery(''); setMinPrice(''); setMaxPrice(''); setMinSize(''); setMinRooms(''); setListingTypeFilter(''); setProviderFilter(''); setPublisherFilter(''); setMaxAvailableFrom(''); }; + const unseenCount = listings.filter((l) => !l.is_seen && !l.is_blacklisted).length; + + const resetFilters = () => { + setSearchQuery(''); + setMinPrice(''); + setMaxPrice(''); + setMinSize(''); + setMinRooms(''); + setListingTypeFilter(''); + setProviderFilter(''); + setPublisherFilter(''); + setMaxAvailableFrom(''); + }; - const activeAgent = configs.find(c => c.id === activeConfigId); + const activeAgent = configs.find((c) => c.id === activeConfigId); const activeAgentName = activeAgent ? activeAgent.name : null; - const enabledConfigs = useMemo(() => configs.filter(c => c.enabled), [configs]); + const enabledConfigs = useMemo(() => configs.filter((c) => c.enabled), [configs]); const canScrape = enabledConfigs.length > 0; const handleHeaderScrape = useCallback(() => { if (!canScrape) { - showToast?.('Kein aktiver Agent vorhanden. Bitte zuerst einen Agenten anlegen oder aktivieren.', 'info'); + showToast?.( + 'Kein aktiver Agent vorhanden. Bitte zuerst einen Agenten anlegen oder aktivieren.', + 'info', + ); return; } if (activeConfigId) { @@ -255,12 +371,15 @@ export default function App() {
setSidebarOpen(o => !o)} + onToggleSidebar={() => setSidebarOpen((o) => !o)} onNavigateHome={handleNavigateHome} activeConfigId={activeConfigId} activeAgentName={activeAgentName} @@ -278,11 +397,12 @@ export default function App() { onAdd={addConfig} onEdit={(id, d) => editConfig(id, d)} onDelete={(id) => { - const cfg = configs.find(c => c.id === id); + const cfg = configs.find((c) => c.id === id); const name = cfg?.name || 'Agent'; askConfirm({ title: `"${name}" löschen?`, - message: 'Favoriten & Blacklist-Einträge bleiben erhalten. Alle anderen Listings dieses Agenten werden entfernt.', + message: + 'Favoriten & Blacklist-Einträge bleiben erhalten. Alle anderen Listings dieses Agenten werden entfernt.', confirm: 'Agent löschen', danger: true, onConfirm: async () => { @@ -298,11 +418,12 @@ export default function App() { onToggle={toggleConfig} onScrapeConfig={startScrapingConfig} onResetConfig={(configId) => { - const cfg = configs.find(c => c.id === configId); + const cfg = configs.find((c) => c.id === configId); const name = cfg?.name || 'Agent'; askConfirm({ title: `Listings von "${name}" bereinigen?`, - message: 'Alle normalen Listings dieses Agenten werden gelöscht. Favoriten & Blacklist-Einträge bleiben erhalten.', + message: + 'Alle normalen Listings dieses Agenten werden gelöscht. Favoriten & Blacklist-Einträge bleiben erhalten.', confirm: 'Bereinigen', danger: false, onConfirm: async () => { @@ -312,11 +433,12 @@ export default function App() { }); }} onClearFavoritesForConfig={(configId) => { - const cfg = configs.find(c => c.id === configId); + const cfg = configs.find((c) => c.id === configId); const name = cfg?.name || 'Agent'; askConfirm({ title: `Favoriten von "${name}" löschen?`, - message: 'Alle als Favorit markierten Listings dieses Agenten werden zurückgesetzt.', + message: + 'Alle als Favorit markierten Listings dieses Agenten werden zurückgesetzt.', confirm: 'Favoriten löschen', danger: true, onConfirm: async () => { @@ -326,7 +448,7 @@ export default function App() { }); }} onClearBlacklistForConfig={(configId) => { - const cfg = configs.find(c => c.id === configId); + const cfg = configs.find((c) => c.id === configId); const name = cfg?.name || 'Agent'; askConfirm({ title: `Blacklist von "${name}" leeren?`, @@ -341,7 +463,7 @@ export default function App() { }} scraping={scraping} collapsed={agentSidebarCollapsed} - onToggleCollapse={() => setAgentSidebarCollapsed(c => !c)} + onToggleCollapse={() => setAgentSidebarCollapsed((c) => !c)} />
@@ -349,24 +471,50 @@ export default function App() {
- + + + + {activeAgent.name}
- - {providers.find(p => p.id === activeAgent.provider)?.name || activeAgent.provider} + }} + > + {providers.find((p) => p.id === activeAgent.provider)?.name || + activeAgent.provider} - + {LISTING_TYPE_LABELS[activeAgent.listing_type] || activeAgent.listing_type} {activeAgent.scrape_url && ( @@ -378,43 +526,74 @@ export default function App() { readOnly value={activeAgent.scrape_url} title="Klicken um gesamte URL zu lesen / kopieren" - onClick={e => e.target.select()} + onClick={(e) => e.target.select()} /> )}
)} @@ -427,14 +606,20 @@ export default function App() { onClose={() => setSidebarOpen(false)} onReset={() => handleReset(currentListingParamsRef.current)} showToast={showToast} - onSaved={() => { loadScrapeConfig(); reloadAll(); }} + onSaved={() => { + loadScrapeConfig(); + reloadAll(); + }} onClearFavorites={() => askConfirm({ title: 'Alle Favoriten löschen?', message: 'Sämtliche als Favorit markierten Listings werden zurückgesetzt.', confirm: 'Alle Favoriten löschen', danger: true, - onConfirm: async () => { closeConfirm(); await handleClearFavorites(); }, + onConfirm: async () => { + closeConfirm(); + await handleClearFavorites(); + }, }) } onClearBlacklist={() => @@ -443,7 +628,10 @@ export default function App() { message: 'Alle Blacklist-Einträge werden dauerhaft entfernt.', confirm: 'Blacklist leeren', danger: true, - onConfirm: async () => { closeConfirm(); await handleClearBlacklist(); }, + onConfirm: async () => { + closeConfirm(); + await handleClearBlacklist(); + }, }) } /> @@ -459,8 +647,17 @@ export default function App() { onCancel={closeConfirm} /> + setDetailListing(null)} + showToast={showToast} + /> +
- {toasts.map(t => )} + {toasts.map((t) => ( + + ))}
diff --git a/client/src/api.js b/client/src/api.js index 280615a..d4a1ea8 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -33,9 +33,13 @@ async function request(method, path, body) { } function buildQuery(params = {}) { - const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== ''); + const entries = Object.entries(params).filter( + ([, v]) => v !== undefined && v !== null && v !== '', + ); if (entries.length === 0) return ''; - return '?' + entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&'); + return ( + '?' + entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&') + ); } export const api = { @@ -54,6 +58,8 @@ export const api = { markAllSeen: () => api.patch('/api/listings/seen-all'), toggleFav: (id) => api.patch(`/api/listings/${id}/favorite`), getImages: (id) => api.get(`/api/listings/${id}/images`), + getDetails: (id) => api.get(`/api/listings/${id}/details`), + refreshDetails: (id) => api.post(`/api/listings/${id}/details/refresh`), batchImages: (ids) => api.post('/api/listings/batch-images', { ids }), getRuns: () => api.get('/api/listings/runs'), reset: () => api.delete('/api/listings/reset'), diff --git a/client/src/components/ListingCard.jsx b/client/src/components/ListingCard.jsx index afcbe99..9af8277 100644 --- a/client/src/components/ListingCard.jsx +++ b/client/src/components/ListingCard.jsx @@ -1,39 +1,102 @@ import { memo, useState, useCallback } from 'react'; import { formatAvailableFrom, formatListingDate, isValidImageUrl } from '../utils/formatting.js'; -import { LISTING_TYPE_LABELS, LISTING_TYPE_COLORS, PROVIDER_LABELS, PROVIDER_COLORS } from '../constants.js'; +import { + LISTING_TYPE_LABELS, + LISTING_TYPE_COLORS, + PROVIDER_LABELS, + PROVIDER_COLORS, +} from '../constants.js'; const HeartIcon = ({ filled }) => ( - + ); const BlacklistIcon = () => ( - + + ); -const EyeIcon = ({ seen }) => ( - seen - ? - : -); +const EyeIcon = ({ seen }) => + seen ? ( + + + + + + ) : ( + + + + + ); const ChevronLeft = () => ( - + + + ); const ChevronRight = () => ( - + + + ); -const ListingCard = memo(function ListingCard({ listing: l, onSeen, onFavorite, onBlacklist, onUnblacklist, prefetchedImages, isBlacklistView }) { +const ListingCard = memo(function ListingCard({ + listing: l, + onSeen, + onFavorite, + onBlacklist, + onUnblacklist, + onDetails, + prefetchedImages, + isBlacklistView, +}) { const isNew = !l.is_seen; - const upgradeUrl = (u) => u ? u.replace('/thumbs/images/', '/images/').replace(/s-l\d+\./, 's-l1600.') : u; + const upgradeUrl = (u) => + u ? u.replace('/thumbs/images/', '/images/').replace(/s-l\d+\./, 's-l1600.') : u; const resolveImages = () => { if (prefetchedImages?.length) return prefetchedImages; - try { const cached = l.images ? JSON.parse(l.images) : null; if (cached?.length) return cached; } catch {} + try { + const cached = l.images ? JSON.parse(l.images) : null; + if (cached?.length) return cached; + } catch {} const img = isValidImageUrl(l.image) ? upgradeUrl(l.image) : null; return img ? [img] : []; }; @@ -41,17 +104,56 @@ const ListingCard = memo(function ListingCard({ listing: l, onSeen, onFavorite, const resolvedImages = resolveImages(); const [imgIdx, setImgIdx] = useState(0); - const goNext = useCallback((e) => { e.stopPropagation(); e.preventDefault(); setImgIdx(i => (i + 1) % Math.max(resolvedImages.length, 1)); }, [resolvedImages.length]); - const goPrev = useCallback((e) => { e.stopPropagation(); e.preventDefault(); setImgIdx(i => (i - 1 + Math.max(resolvedImages.length, 1)) % Math.max(resolvedImages.length, 1)); }, [resolvedImages.length]); + const goNext = useCallback( + (e) => { + e.stopPropagation(); + e.preventDefault(); + setImgIdx((i) => (i + 1) % Math.max(resolvedImages.length, 1)); + }, + [resolvedImages.length], + ); + const goPrev = useCallback( + (e) => { + e.stopPropagation(); + e.preventDefault(); + setImgIdx( + (i) => (i - 1 + Math.max(resolvedImages.length, 1)) % Math.max(resolvedImages.length, 1), + ); + }, + [resolvedImages.length], + ); - const handleFav = (e) => { e.stopPropagation(); e.preventDefault(); onFavorite(l.id); }; - const handleBlacklist = (e) => { e.stopPropagation(); e.preventDefault(); isBlacklistView ? onUnblacklist?.(l.id) : onBlacklist?.(l.id); }; - const handleSeenToggle = (e) => { e.stopPropagation(); e.preventDefault(); onSeen(l.id); }; - const handleOpen = () => { if (!l.is_seen) onSeen(l.id); }; + const handleFav = (e) => { + e.stopPropagation(); + e.preventDefault(); + onFavorite(l.id); + }; + const handleBlacklist = (e) => { + e.stopPropagation(); + e.preventDefault(); + isBlacklistView ? onUnblacklist?.(l.id) : onBlacklist?.(l.id); + }; + const handleSeenToggle = (e) => { + e.stopPropagation(); + e.preventDefault(); + onSeen(l.id); + }; + const handleDetails = (e) => { + e.stopPropagation(); + e.preventDefault(); + onDetails?.(l); + }; + const handleOpen = () => { + if (!l.is_seen) onSeen(l.id); + }; const currentImg = resolvedImages[imgIdx] ?? null; const typeColors = LISTING_TYPE_COLORS[l.listing_type] || { bg: '#f3f4f6', text: '#374151' }; - const providerColors = PROVIDER_COLORS[l.provider] || { bg: 'rgba(255,255,255,.92)', text: '#1f2937', border: 'rgba(255,255,255,.75)' }; + const providerColors = PROVIDER_COLORS[l.provider] || { + bg: 'rgba(255,255,255,.92)', + text: '#1f2937', + border: 'rgba(255,255,255,.75)', + }; const providerLabel = PROVIDER_LABELS[l.provider] || l.provider; const publishedLabel = formatListingDate(l.listed_at); const availableFromLabel = formatAvailableFrom(l.available_from); @@ -60,21 +162,38 @@ const ListingCard = memo(function ListingCard({ listing: l, onSeen, onFavorite, const seenMultipleTimes = l.first_seen && l.last_seen && l.first_seen !== l.last_seen; return ( -
+
{currentImg ? ( - { e.currentTarget.style.display = 'none'; }} /> + { + e.currentTarget.style.display = 'none'; + }} + /> ) : (
🏠
)} {resolvedImages.length > 1 && currentImg && ( <> - - + + )} {resolvedImages.length > 1 && ( - {imgIdx + 1}/{resolvedImages.length} + + {imgIdx + 1}/{resolvedImages.length} + )}
@@ -87,10 +206,24 @@ const ListingCard = memo(function ListingCard({ listing: l, onSeen, onFavorite, )} - -
@@ -98,11 +231,21 @@ const ListingCard = memo(function ListingCard({ listing: l, onSeen, onFavorite, {isNew && !isBlacklistView && Neu}
{providerLabel && ( - + {providerLabel} )} - + {LISTING_TYPE_LABELS[l.listing_type] || l.listing_type}
@@ -122,7 +265,16 @@ const ListingCard = memo(function ListingCard({ listing: l, onSeen, onFavorite, )} {l.address && (

- + + + + {l.address}

)} @@ -132,43 +284,118 @@ const ListingCard = memo(function ListingCard({ listing: l, onSeen, onFavorite,
- + + + + + + Veröffentlicht am: {publishedLabel || 'unbekannt'} {l.available_from && ( - + + + + Einzug ab: {availableFromLabel} )} {l.publisher && ( - + + + + Inserent: {l.publisher} )} {l.first_seen && ( - - + + + + + Entdeckt: {firstSeenLabel} )} {seenMultipleTimes && ( - - + + + + + + Zuletzt gesehen: {lastSeenLabel} )}
- - Öffnen - - +
+ {onDetails && ( + + )} + + Öffnen + + + + +
); diff --git a/client/src/components/ListingDetailDrawer.jsx b/client/src/components/ListingDetailDrawer.jsx new file mode 100644 index 0000000..c34381b --- /dev/null +++ b/client/src/components/ListingDetailDrawer.jsx @@ -0,0 +1,330 @@ +import { useEffect, useMemo, useState } from 'react'; +import { api } from '../api.js'; +import { formatAvailableFrom } from '../utils/formatting.js'; + +function Field({ label, value }) { + if (value === null || value === undefined || value === '') return null; + return ( +
+ {label} + {value} +
+ ); +} + +function Feature({ label, value }) { + if (value === null || value === undefined) return null; + return {label}; +} + +function formatBool(value) { + if (value === 1 || value === true) return 'Ja'; + if (value === 0 || value === false) return 'Nein'; + return value; +} + +function findAttribute(detail, pattern) { + for (const group of detail?.attribute_groups ?? []) { + for (const attr of group.attributes ?? []) { + if (attr.label && pattern.test(attr.label)) { + return attr.value === true ? 'Ja' : attr.value; + } + } + } + return null; +} + +function visibleAttributeGroups(detail) { + const covered = [ + /warmmiete|kaltmiete|nebenkosten|kaution|preis\/m/i, + /wohnfläche|zimmer|verfügbar ab/i, + /etage|schlafzimmer|badezimmer|haustiere/i, + /art der unterkunft|mietart|rauchen|anzahl mitbewohner|online-besichtigung/i, + /einbauküche|küche|kühlschrank|backofen|herd|spülmaschine|keller|balkon|garten|aufzug|stufenlos/i, + ]; + + return (detail?.attribute_groups ?? []) + .map((group) => ({ + ...group, + attributes: (group.attributes ?? []).filter((attr) => { + if (!attr?.label || attr.value === null || attr.value === undefined || attr.value === '') { + return false; + } + return !covered.some((pattern) => pattern.test(attr.label)); + }), + })) + .filter((group) => group.attributes.length > 0); +} + +export default function ListingDetailDrawer({ listing, open, onClose, showToast }) { + const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [payload, setPayload] = useState(null); + const [error, setError] = useState(''); + + const detail = payload?.detail ?? null; + const currentListing = payload?.listing ?? listing; + const detailError = payload?.detail_error ?? null; + const detailSize = findAttribute(detail, /wohnfläche|wohnflaeche|gesamtfläche/i); + const detailRooms = findAttribute(detail, /^zimmer$/i); + const extraGroups = visibleAttributeGroups(detail); + + useEffect(() => { + if (!open || !listing?.id) return; + let cancelled = false; + setLoading(true); + setError(''); + setPayload(null); + + api.listings + .getDetails(listing.id) + .then((data) => { + if (!cancelled) setPayload(data); + }) + .catch((err) => { + if (!cancelled) setError(err.message); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [open, listing?.id]); + + const images = useMemo(() => { + if (Array.isArray(detail?.images) && detail.images.length > 0) return detail.images; + try { + const parsed = currentListing?.images ? JSON.parse(currentListing.images) : []; + if (Array.isArray(parsed)) return parsed; + } catch {} + return currentListing?.image ? [currentListing.image] : []; + }, [detail, currentListing]); + + if (!open || !listing) return null; + + const handleRefresh = async () => { + setRefreshing(true); + setError(''); + try { + const data = await api.listings.refreshDetails(listing.id); + setPayload(data); + showToast?.('Detaildaten aktualisiert', 'success'); + } catch (err) { + setError(err.message); + showToast?.(`Detailabruf fehlgeschlagen: ${err.message}`, 'error'); + } finally { + setRefreshing(false); + } + }; + + return ( + <> +
+ + + ); +} diff --git a/client/src/components/ListingsGrid.jsx b/client/src/components/ListingsGrid.jsx index 3c5ceef..2024299 100644 --- a/client/src/components/ListingsGrid.jsx +++ b/client/src/components/ListingsGrid.jsx @@ -4,71 +4,149 @@ import Pagination from './Pagination.jsx'; import { api } from '../api.js'; function preloadImages(urls) { - for (const url of urls) { const img = new Image(); img.src = url; } + for (const url of urls) { + const img = new Image(); + img.src = url; + } } export default function ListingsGrid({ - listings, allCount, loading, page, pages, onPageChange, - onSeen, onFavorite, onBlacklist, onUnblacklist, onScrape, - canScrape, allFiltered, itemsPerPage, isBlacklistView, + listings, + allCount, + loading, + page, + pages, + onPageChange, + onSeen, + onFavorite, + onBlacklist, + onUnblacklist, + onScrape, + onDetails, + canScrape, + allFiltered, + itemsPerPage, + isBlacklistView, }) { const [imageCache, setImageCache] = useState({}); const fetchedRef = useRef(new Set()); const batchFetchImages = useCallback(async (listingsToFetch) => { - const needFetch = listingsToFetch.filter(l => { + const needFetch = listingsToFetch.filter((l) => { if (fetchedRef.current.has(l.id)) return false; - try { const cached = l.images ? JSON.parse(l.images) : null; if (cached?.length) { setImageCache(prev => ({ ...prev, [l.id]: cached })); preloadImages([cached[0]]); fetchedRef.current.add(l.id); return false; } } catch {} + try { + const cached = l.images ? JSON.parse(l.images) : null; + if (cached?.length) { + setImageCache((prev) => ({ ...prev, [l.id]: cached })); + preloadImages([cached[0]]); + fetchedRef.current.add(l.id); + return false; + } + } catch {} return true; }); if (needFetch.length === 0) return; for (const l of needFetch) fetchedRef.current.add(l.id); try { - const { results } = await api.listings.batchImages(needFetch.map(l => l.id)); - if (results) { setImageCache(prev => ({ ...prev, ...results })); for (const imgs of Object.values(results)) { if (imgs?.[0]) preloadImages([imgs[0]]); } } + const { results } = await api.listings.batchImages(needFetch.map((l) => l.id)); + if (results) { + setImageCache((prev) => ({ ...prev, ...results })); + for (const imgs of Object.values(results)) { + if (imgs?.[0]) preloadImages([imgs[0]]); + } + } } catch {} }, []); - useEffect(() => { if (listings.length > 0) batchFetchImages(listings); }, [listings, batchFetchImages]); + useEffect(() => { + if (listings.length > 0) batchFetchImages(listings); + }, [listings, batchFetchImages]); useEffect(() => { if (!allFiltered || !itemsPerPage || page >= pages) return; const next = allFiltered.slice(page * itemsPerPage, (page + 1) * itemsPerPage); - if (next.length > 0) { const t = setTimeout(() => batchFetchImages(next), 300); return () => clearTimeout(t); } + if (next.length > 0) { + const t = setTimeout(() => batchFetchImages(next), 300); + return () => clearTimeout(t); + } }, [page, pages, allFiltered, itemsPerPage, batchFetchImages]); - if (loading) return

Lade Anzeigen…

; + if (loading) + return ( +
+
+

Lade Anzeigen…

+
+ ); - if (allCount === 0) return ( -
-
- - - - - - + if (allCount === 0) + return ( +
+
+ + + + + + +
+

Keine Anzeigen gefunden

+

Erstelle eine Suchkonfiguration und starte einen Scraping-Lauf.

+
-

Keine Anzeigen gefunden

-

Erstelle eine Suchkonfiguration und starte einen Scraping-Lauf.

- -
- ); + ); return (
-

{allCount} Ergebnis{allCount !== 1 ? 'se' : ''} · Seite {page}/{pages}

+

+ {allCount} Ergebnis{allCount !== 1 ? 'se' : ''}{' '} + · Seite {page}/{pages} +

{listings.map((l) => ( - + ))}
- {pages > 1 &&
} + {pages > 1 && ( +
+ +
+ )}
); } diff --git a/client/src/index.css b/client/src/index.css index 0371106..19e1648 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -4,7 +4,7 @@ /* ── Design Tokens ─────────────────────────────────────────── */ :root { - --primary-50: #eef2ff; + --primary-50: #eef2ff; --primary-100: #e0e7ff; --primary-200: #c7d2fe; --primary-300: #a5b4fc; @@ -12,7 +12,7 @@ --primary-500: #6366f1; --primary-600: #4f46e5; --primary-700: #4338ca; - --gray-50: #f8fafc; + --gray-50: #f8fafc; --gray-100: #f1f5f9; --gray-200: #e2e8f0; --gray-300: #cbd5e1; @@ -22,93 +22,295 @@ --gray-700: #334155; --gray-800: #1e293b; --gray-900: #0f172a; - --success-50: #ecfdf5; + --success-50: #ecfdf5; --success-500: #10b981; --success-600: #059669; - --danger-50: #fef2f2; - --danger-400: #f87171; - --danger-500: #ef4444; - --warning-50: #fffbeb; + --danger-50: #fef2f2; + --danger-400: #f87171; + --danger-500: #ef4444; + --warning-50: #fffbeb; --warning-500: #f59e0b; - --violet-50: #f5f3ff; - --violet-100: #ede9fe; - --violet-500: #8b5cf6; - --violet-600: #7c3aed; - --surface: #ffffff; - --bg: #f8fafc; - --border: #e2e8f0; - --text: #0f172a; + --violet-50: #f5f3ff; + --violet-100: #ede9fe; + --violet-500: #8b5cf6; + --violet-600: #7c3aed; + --surface: #ffffff; + --bg: #f8fafc; + --border: #e2e8f0; + --text: #0f172a; --text-secondary: #475569; --text-muted: #94a3b8; - --header-h: 60px; + --header-h: 60px; --sidebar-w: 400px; --agent-sidebar-w: 316px; --agent-sidebar-collapsed-w: 56px; - --max-w: 1400px; + --max-w: 1400px; --radius-xs: 6px; --radius-sm: 8px; --radius-md: 12px; --radius-lg: 16px; --radius-xl: 20px; --radius-full: 9999px; - --shadow-xs: 0 1px 2px 0 rgba(0,0,0,.05); - --shadow-sm: 0 1px 3px 0 rgba(0,0,0,.06), 0 1px 2px -1px rgba(0,0,0,.06); - --shadow-md: 0 4px 6px -1px rgba(0,0,0,.07), 0 2px 4px -2px rgba(0,0,0,.05); - --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.08), 0 4px 6px -4px rgba(0,0,0,.04); - --shadow-xl: 0 20px 25px -5px rgba(0,0,0,.1), 0 8px 10px -6px rgba(0,0,0,.06); - --shadow-card: 0 0 0 1px rgba(0,0,0,.03), 0 2px 4px rgba(0,0,0,.04); - --shadow-card-hover: 0 0 0 1px rgba(0,0,0,.03), 0 8px 24px rgba(0,0,0,.08); - --transition: 200ms cubic-bezier(.4,0,.2,1); + --shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.06); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.04); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.06); + --shadow-card: 0 0 0 1px rgba(0, 0, 0, 0.03), 0 2px 4px rgba(0, 0, 0, 0.04); + --shadow-card-hover: 0 0 0 1px rgba(0, 0, 0, 0.03), 0 8px 24px rgba(0, 0, 0, 0.08); + --transition: 200ms cubic-bezier(0.4, 0, 0.2, 1); } /* ── Reset ─────────────────────────────────────────────────── */ -*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } -html { font-size: 16px; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; scroll-behavior: smooth; } -body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; } -img { display: block; max-width: 100%; } -button, input, select, textarea { font: inherit; color: inherit; } -button { cursor: pointer; border: none; background: none; } -a { color: inherit; text-decoration: none; } +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + scroll-behavior: smooth; +} +body { + font-family: + 'Inter', + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; +} +img { + display: block; + max-width: 100%; +} +button, +input, +select, +textarea { + font: inherit; + color: inherit; +} +button { + cursor: pointer; + border: none; + background: none; +} +a { + color: inherit; + text-decoration: none; +} /* ── Layout ────────────────────────────────────────────────── */ -.app { display: flex; flex-direction: column; min-height: 100vh; } -.app-body { display: flex; flex: 1; margin-top: var(--header-h); } -.main { flex: 1; min-width: 0; padding: 20px 28px 64px; } +.app { + display: flex; + flex-direction: column; + min-height: 100vh; +} +.app-body { + display: flex; + flex: 1; + margin-top: var(--header-h); +} +.main { + flex: 1; + min-width: 0; + padding: 20px 28px 64px; +} /* ── Header ────────────────────────────────────────────────── */ -.header { position: fixed; top: 0; left: 0; right: 0; z-index: 100; background: var(--gray-900); border-bottom: 1px solid rgba(255,255,255,.06); height: var(--header-h); } -.header-inner { height: 100%; display: flex; align-items: center; justify-content: space-between; padding: 0 28px; } -.header-brand { display: flex; align-items: center; gap: 12px; transition: opacity var(--transition); } -.header-brand:hover { opacity: .85; } -.header-logo { width: 34px; height: 34px; display: grid; place-items: center; background: linear-gradient(135deg, var(--primary-500), var(--violet-500)); border-radius: var(--radius-sm); box-shadow: 0 2px 8px rgba(99,102,241,.35); } -.logo-icon { width: 18px; height: 18px; color: #fff; } -.header-title { font-size: 1.05rem; font-weight: 700; letter-spacing: -.02em; color: #fff; } -.header-subtitle { font-size: .65rem; color: var(--gray-400); font-weight: 500; letter-spacing: .01em; line-height: 1.3; } -.header-actions { display: flex; align-items: center; gap: 6px; } -.scrape-badge { display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px; border-radius: var(--radius-full); background: rgba(99,102,241,.15); color: var(--primary-300); font-size: .78rem; font-weight: 600; border: 1px solid rgba(99,102,241,.2); max-width: 380px; } -.scrape-info { display: flex; flex-direction: column; gap: 3px; min-width: 0; } -.scrape-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.scrape-progress-bar { height: 3px; background: rgba(255,255,255,.15); border-radius: 2px; overflow: hidden; min-width: 80px; } -.scrape-progress-fill { height: 100%; background: var(--primary-400); border-radius: 2px; transition: width 300ms ease; } -.scrape-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--primary-400); animation: pulse-dot 1.2s infinite; } -@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: .3; } } +.header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + background: var(--gray-900); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + height: var(--header-h); +} +.header-inner { + height: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 28px; +} +.header-brand { + display: flex; + align-items: center; + gap: 12px; + transition: opacity var(--transition); +} +.header-brand:hover { + opacity: 0.85; +} +.header-logo { + width: 34px; + height: 34px; + display: grid; + place-items: center; + background: linear-gradient(135deg, var(--primary-500), var(--violet-500)); + border-radius: var(--radius-sm); + box-shadow: 0 2px 8px rgba(99, 102, 241, 0.35); +} +.logo-icon { + width: 18px; + height: 18px; + color: #fff; +} +.header-title { + font-size: 1.05rem; + font-weight: 700; + letter-spacing: -0.02em; + color: #fff; +} +.header-subtitle { + font-size: 0.65rem; + color: var(--gray-400); + font-weight: 500; + letter-spacing: 0.01em; + line-height: 1.3; +} +.header-actions { + display: flex; + align-items: center; + gap: 6px; +} +.scrape-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + border-radius: var(--radius-full); + background: rgba(99, 102, 241, 0.15); + color: var(--primary-300); + font-size: 0.78rem; + font-weight: 600; + border: 1px solid rgba(99, 102, 241, 0.2); + max-width: 380px; +} +.scrape-info { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} +.scrape-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.scrape-progress-bar { + height: 3px; + background: rgba(255, 255, 255, 0.15); + border-radius: 2px; + overflow: hidden; + min-width: 80px; +} +.scrape-progress-fill { + height: 100%; + background: var(--primary-400); + border-radius: 2px; + transition: width 300ms ease; +} +.scrape-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--primary-400); + animation: pulse-dot 1.2s infinite; +} +@keyframes pulse-dot { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } +} /* ── Buttons ───────────────────────────────────────────────── */ -.btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: var(--radius-sm); font-size: .8125rem; font-weight: 600; transition: all var(--transition); white-space: nowrap; } -.btn-icon { width: 15px; height: 15px; flex-shrink: 0; } -.btn--primary { background: var(--primary-600); color: #fff; box-shadow: 0 1px 3px rgba(79,70,229,.3); } -.btn--primary:hover { background: var(--primary-700); box-shadow: 0 2px 6px rgba(79,70,229,.4); } -.btn--danger { background: var(--danger-500); color: #fff; } -.btn--danger:hover { background: var(--danger-400); } -.btn--ghost { background: transparent; color: var(--gray-500); border: 1px solid var(--border); } -.btn--ghost:hover { background: var(--gray-50); color: var(--gray-700); border-color: var(--gray-300); } -.btn--ghost-light { background: rgba(255,255,255,.08); color: var(--gray-300); border: 1px solid rgba(255,255,255,.1); } -.btn--ghost-light:hover { background: rgba(255,255,255,.12); color: #fff; } -.btn--icon { padding: 7px; border-radius: var(--radius-sm); color: var(--gray-400); } -.btn--icon:hover { background: rgba(255,255,255,.08); color: #fff; } -.btn--sm { padding: 5px 10px; font-size: .78rem; } -.btn--lg { padding: 11px 22px; font-size: .9rem; } -.btn--full { width: 100%; justify-content: center; } +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-radius: var(--radius-sm); + font-size: 0.8125rem; + font-weight: 600; + transition: all var(--transition); + white-space: nowrap; +} +.btn-icon { + width: 15px; + height: 15px; + flex-shrink: 0; +} +.btn--primary { + background: var(--primary-600); + color: #fff; + box-shadow: 0 1px 3px rgba(79, 70, 229, 0.3); +} +.btn--primary:hover { + background: var(--primary-700); + box-shadow: 0 2px 6px rgba(79, 70, 229, 0.4); +} +.btn--danger { + background: var(--danger-500); + color: #fff; +} +.btn--danger:hover { + background: var(--danger-400); +} +.btn--ghost { + background: transparent; + color: var(--gray-500); + border: 1px solid var(--border); +} +.btn--ghost:hover { + background: var(--gray-50); + color: var(--gray-700); + border-color: var(--gray-300); +} +.btn--ghost-light { + background: rgba(255, 255, 255, 0.08); + color: var(--gray-300); + border: 1px solid rgba(255, 255, 255, 0.1); +} +.btn--ghost-light:hover { + background: rgba(255, 255, 255, 0.12); + color: #fff; +} +.btn--icon { + padding: 7px; + border-radius: var(--radius-sm); + color: var(--gray-400); +} +.btn--icon:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; +} +.btn--sm { + padding: 5px 10px; + font-size: 0.78rem; +} +.btn--lg { + padding: 11px 22px; + font-size: 0.9rem; +} +.btn--full { + width: 100%; + justify-content: center; +} /* ── Active Agent Indicator ────────────────────────────────── */ .active-agent-indicator { @@ -121,9 +323,11 @@ a { color: inherit; text-decoration: none; } background: linear-gradient(180deg, #f3f4f6 0%, #ebedef 100%); border: 1.5px solid rgba(156, 163, 175, 0.35); border-radius: 18px; - font-size: .8125rem; + font-size: 0.8125rem; color: var(--gray-700); - box-shadow: 0 0 0 2px rgba(107, 114, 128, 0.1), 0 10px 22px -18px rgba(15,23,42,.14); + box-shadow: + 0 0 0 2px rgba(107, 114, 128, 0.1), + 0 10px 22px -18px rgba(15, 23, 42, 0.14); } .active-agent-indicator::before { content: ''; @@ -135,21 +339,42 @@ a { color: inherit; text-decoration: none; } border-radius: 999px; background: var(--active-agent-accent, var(--gray-400)); } -.active-agent-info { flex: 1; min-width: 0; } -.active-agent-name { display: flex; align-items: center; gap: 8px; font-weight: 800; font-size: .98rem; letter-spacing: -.01em; color: var(--gray-800); margin-bottom: 8px; } -.active-agent-details { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: .75rem; color: var(--text-secondary); row-gap: 8px; width: 100%; } +.active-agent-info { + flex: 1; + min-width: 0; +} +.active-agent-name { + display: flex; + align-items: center; + gap: 8px; + font-weight: 800; + font-size: 0.98rem; + letter-spacing: -0.01em; + color: var(--gray-800); + margin-bottom: 8px; +} +.active-agent-details { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + font-size: 0.75rem; + color: var(--text-secondary); + row-gap: 8px; + width: 100%; +} .active-agent-badge { display: inline-flex; align-items: center; min-height: 26px; - font-size: .64rem; + font-size: 0.64rem; padding: 3px 10px; border-radius: 999px; font-weight: 700; - letter-spacing: .02em; + letter-spacing: 0.02em; text-transform: uppercase; white-space: nowrap; - box-shadow: inset 0 1px 0 rgba(255,255,255,.35); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35); } .active-agent-detail { display: inline-flex; @@ -159,9 +384,9 @@ a { color: inherit; text-decoration: none; } border-radius: 999px; font-weight: 600; white-space: nowrap; - font-size: .63rem; - letter-spacing: .02em; - box-shadow: inset 0 1px 0 rgba(255,255,255,.35); + font-size: 0.63rem; + letter-spacing: 0.02em; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35); } .active-agent-url-wrap { display: flex; @@ -174,25 +399,25 @@ a { color: inherit; text-decoration: none; } margin-top: 2px; padding: 7px 10px; border-radius: 10px; - background: linear-gradient(180deg, rgba(249,250,251,.97), rgba(243,244,246,.97)); + background: linear-gradient(180deg, rgba(249, 250, 251, 0.97), rgba(243, 244, 246, 0.97)); border: 1px solid rgba(209, 213, 219, 0.65); } .active-agent-url-label { flex-shrink: 0; - font-size: .68rem; + font-size: 0.68rem; font-weight: 700; color: var(--gray-500); - letter-spacing: .02em; + letter-spacing: 0.02em; white-space: nowrap; } .active-agent-url-input { flex: 1 1 0; min-width: 0; - background: rgba(255,255,255,.82); - border: 1px solid rgba(209,213,219,.7); + background: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(209, 213, 219, 0.7); border-radius: 10px; color: var(--gray-600); - font-size: .68rem; + font-size: 0.68rem; font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace; cursor: text; padding: 4px 8px; @@ -200,8 +425,11 @@ a { color: inherit; text-decoration: none; } white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - transition: border-color .15s, background .15s, color .15s; - box-shadow: inset 0 1px 2px rgba(0,0,0,.03); + transition: + border-color 0.15s, + background 0.15s, + color 0.15s; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.03); } .active-agent-url-input:focus { border-color: var(--active-agent-accent, var(--primary-400)); @@ -209,7 +437,9 @@ a { color: inherit; text-decoration: none; } color: var(--text); white-space: normal; overflow-wrap: break-word; - box-shadow: 0 0 0 3px rgba(99,102,241,.08), inset 0 1px 2px rgba(0,0,0,.04); + box-shadow: + 0 0 0 3px rgba(99, 102, 241, 0.08), + inset 0 1px 2px rgba(0, 0, 0, 0.04); } .active-agent-close { margin-left: auto; @@ -220,48 +450,171 @@ a { color: inherit; text-decoration: none; } gap: 6px; padding: 8px 12px; border-radius: 12px; - background: rgba(255,255,255,.88); - border: 1px solid rgba(209,213,219,.7); + background: rgba(255, 255, 255, 0.88); + border: 1px solid rgba(209, 213, 219, 0.7); color: var(--gray-600); - font-size: .78rem; + font-size: 0.78rem; font-weight: 700; - box-shadow: 0 1px 2px rgba(15,23,42,.05); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); transition: all var(--transition); } .active-agent-close:hover { color: var(--active-agent-accent, var(--primary-600)); - border-color: rgba(99,102,241,.2); + border-color: rgba(99, 102, 241, 0.2); background: #fff; } /* ══════════════════════════════════════════════════════════════ Agent Sidebar (Left Panel) ══════════════════════════════════════════════════════════════ */ -.agent-sidebar { width: var(--agent-sidebar-w); flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; height: calc(100vh - var(--header-h)); position: sticky; top: var(--header-h); overflow: hidden; transition: width 250ms cubic-bezier(.4,0,.2,1); } -.agent-sidebar--collapsed { width: var(--agent-sidebar-collapsed-w); } +.agent-sidebar { + width: var(--agent-sidebar-w); + flex-shrink: 0; + background: var(--surface); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + height: calc(100vh - var(--header-h)); + position: sticky; + top: var(--header-h); + overflow: hidden; + transition: width 250ms cubic-bezier(0.4, 0, 0.2, 1); +} +.agent-sidebar--collapsed { + width: var(--agent-sidebar-collapsed-w); +} -.agent-sidebar-header { display: flex; align-items: center; gap: 8px; padding: 14px 16px; border-bottom: 1px solid var(--border); min-height: 50px; } -.agent-sidebar-title { display: flex; align-items: center; gap: 6px; font-size: .8125rem; font-weight: 600; color: var(--text-secondary); flex: 1; white-space: nowrap; } -.agent-sidebar-count { font-size: .65rem; padding: 0 6px; border-radius: var(--radius-full); background: var(--gray-200); font-weight: 700; color: var(--gray-600); min-width: 18px; text-align: center; line-height: 1.6; } -.agent-sidebar-collapse-btn { display: grid; place-items: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--gray-400); transition: all var(--transition); flex-shrink: 0; } -.agent-sidebar-collapse-btn:hover { background: var(--gray-100); color: var(--gray-600); } +.agent-sidebar-header { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); + min-height: 50px; +} +.agent-sidebar-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-secondary); + flex: 1; + white-space: nowrap; +} +.agent-sidebar-count { + font-size: 0.65rem; + padding: 0 6px; + border-radius: var(--radius-full); + background: var(--gray-200); + font-weight: 700; + color: var(--gray-600); + min-width: 18px; + text-align: center; + line-height: 1.6; +} +.agent-sidebar-collapse-btn { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + color: var(--gray-400); + transition: all var(--transition); + flex-shrink: 0; +} +.agent-sidebar-collapse-btn:hover { + background: var(--gray-100); + color: var(--gray-600); +} -.agent-sidebar-body { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 10px; } +.agent-sidebar-body { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} /* Collapsed mode icons */ -.agent-sidebar-collapsed-icons { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 12px 0; flex: 1; overflow-y: auto; } -.agent-collapsed-icon { width: 36px; height: 36px; border-radius: var(--radius-sm); border: 1.5px solid var(--border); display: grid; place-items: center; position: relative; transition: all var(--transition); cursor: pointer; } -.agent-collapsed-icon:hover { border-color: var(--gray-300); box-shadow: var(--shadow-sm); } -.agent-collapsed-icon--active { border-width: 2px; box-shadow: var(--shadow-sm); } -.agent-collapsed-icon--disabled { opacity: .4; } -.agent-collapsed-letter { font-size: .75rem; font-weight: 700; text-transform: uppercase; } -.agent-collapsed-badge { position: absolute; top: -4px; right: -4px; font-size: .55rem; font-weight: 700; padding: 0 4px; border-radius: var(--radius-full); color: #fff; min-width: 14px; text-align: center; line-height: 1.5; } -.agent-collapsed-add { width: 36px; height: 36px; border-radius: var(--radius-sm); border: 1.5px dashed var(--gray-300); display: grid; place-items: center; color: var(--gray-400); transition: all var(--transition); cursor: pointer; } -.agent-collapsed-add:hover { border-color: var(--primary-400); color: var(--primary-600); background: var(--primary-50); } +.agent-sidebar-collapsed-icons { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 12px 0; + flex: 1; + overflow-y: auto; +} +.agent-collapsed-icon { + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + border: 1.5px solid var(--border); + display: grid; + place-items: center; + position: relative; + transition: all var(--transition); + cursor: pointer; +} +.agent-collapsed-icon:hover { + border-color: var(--gray-300); + box-shadow: var(--shadow-sm); +} +.agent-collapsed-icon--active { + border-width: 2px; + box-shadow: var(--shadow-sm); +} +.agent-collapsed-icon--disabled { + opacity: 0.4; +} +.agent-collapsed-letter { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; +} +.agent-collapsed-badge { + position: absolute; + top: -4px; + right: -4px; + font-size: 0.55rem; + font-weight: 700; + padding: 0 4px; + border-radius: var(--radius-full); + color: #fff; + min-width: 14px; + text-align: center; + line-height: 1.5; +} +.agent-collapsed-add { + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + border: 1.5px dashed var(--gray-300); + display: grid; + place-items: center; + color: var(--gray-400); + transition: all var(--transition); + cursor: pointer; +} +.agent-collapsed-add:hover { + border-color: var(--primary-400); + color: var(--primary-600); + background: var(--primary-50); +} /* Agent Card */ -.agent-list { display: flex; flex-direction: column; gap: 6px; } -.agent-list { display: flex; flex-direction: column; gap: 10px; } +.agent-list { + display: flex; + flex-direction: column; + gap: 6px; +} +.agent-list { + display: flex; + flex-direction: column; + gap: 10px; +} .agent-card { position: relative; padding: 14px; @@ -270,7 +623,9 @@ a { color: inherit; text-decoration: none; } border-radius: 18px; cursor: pointer; transition: all var(--transition); - box-shadow: 0 10px 22px -18px rgba(15,23,42,.14), 0 1px 2px rgba(15,23,42,.04); + box-shadow: + 0 10px 22px -18px rgba(15, 23, 42, 0.14), + 0 1px 2px rgba(15, 23, 42, 0.04); } .agent-card::before { content: ''; @@ -285,21 +640,46 @@ a { color: inherit; text-decoration: none; } } .agent-card:hover { border-color: rgba(107, 114, 128, 0.35); - box-shadow: 0 18px 32px -22px rgba(15,23,42,.18), 0 8px 18px -14px rgba(107,114,128,.14); + box-shadow: + 0 18px 32px -22px rgba(15, 23, 42, 0.18), + 0 8px 18px -14px rgba(107, 114, 128, 0.14); transform: translateY(-1px); } .agent-card--active { background: linear-gradient(180deg, #f3f4f6 0%, #ebedef 100%); border-color: rgba(107, 114, 128, 0.4); - box-shadow: 0 0 0 2px rgba(107, 114, 128, 0.12), 0 20px 36px -20px rgba(15,23,42,.14); + box-shadow: + 0 0 0 2px rgba(107, 114, 128, 0.12), + 0 20px 36px -20px rgba(15, 23, 42, 0.14); +} +.agent-card--active::before { + background: var(--agent-accent, var(--gray-400)); + width: 4px; +} +.agent-card--active .agent-card-city { + color: var(--gray-900); +} +.agent-card--disabled { + opacity: 0.45; } -.agent-card--active::before { background: var(--agent-accent, var(--gray-400)); width: 4px; } -.agent-card--active .agent-card-city { color: var(--gray-900); } -.agent-card--disabled { opacity: .45; } -.agent-card-header { display: block; margin-bottom: 10px; } -.agent-card-main { min-width: 0; display: flex; flex-direction: column; gap: 8px; } -.agent-card-title-row { display: flex; align-items: flex-start; gap: 8px; flex: 1; min-width: 0; } +.agent-card-header { + display: block; + margin-bottom: 10px; +} +.agent-card-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 8px; +} +.agent-card-title-row { + display: flex; + align-items: flex-start; + gap: 8px; + flex: 1; + min-width: 0; +} .agent-toggle { width: 22px; height: 22px; @@ -307,16 +687,23 @@ a { color: inherit; text-decoration: none; } place-items: center; flex-shrink: 0; border-radius: 999px; - background: rgba(255,255,255,.88); - border: 1px solid rgba(148,163,184,.16); - box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 2px rgba(15,23,42,.04); + background: rgba(255, 255, 255, 0.88); + border: 1px solid rgba(148, 163, 184, 0.16); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.75), + 0 1px 2px rgba(15, 23, 42, 0.04); +} +.agent-toggle-dot { + width: 9px; + height: 9px; + border-radius: 50%; + transition: background var(--transition); } -.agent-toggle-dot { width: 9px; height: 9px; border-radius: 50%; transition: background var(--transition); } .agent-card-city { font-weight: 800; - font-size: .96rem; + font-size: 0.96rem; line-height: 1.3; - letter-spacing: -.01em; + letter-spacing: -0.01em; color: var(--gray-800); text-transform: none; flex: 1; @@ -333,15 +720,22 @@ a { color: inherit; text-decoration: none; } display: inline-flex; align-items: center; min-height: 24px; - font-size: .63rem; + font-size: 0.63rem; padding: 3px 9px; border-radius: 999px; - letter-spacing: .02em; + letter-spacing: 0.02em; backdrop-filter: blur(4px); - box-shadow: inset 0 1px 0 rgba(255,255,255,.35); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35); +} +.agent-card-type { + font-weight: 700; + text-transform: uppercase; + white-space: nowrap; +} +.agent-card-provider { + font-weight: 600; + white-space: nowrap; } -.agent-card-type { font-weight: 700; text-transform: uppercase; white-space: nowrap; } -.agent-card-provider { font-weight: 600; white-space: nowrap; } .agent-card-actions { display: inline-flex; align-items: center; @@ -350,9 +744,9 @@ a { color: inherit; text-decoration: none; } margin-top: 12px; padding: 6px; border: 1px solid rgba(209, 213, 219, 0.7); - background: linear-gradient(180deg, rgba(255,255,255,.9), rgba(249,250,251,.96)); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(249, 250, 251, 0.96)); border-radius: 14px; - box-shadow: inset 0 1px 0 rgba(255,255,255,.75); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75); } .agent-action { display: grid; @@ -362,24 +756,58 @@ a { color: inherit; text-decoration: none; } color: var(--gray-500); border-radius: 10px; transition: all var(--transition); - background: rgba(255,255,255,.92); + background: rgba(255, 255, 255, 0.92); border: 1px solid rgba(209, 213, 219, 0.55); - box-shadow: 0 1px 2px rgba(15,23,42,.05); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); } .agent-action:hover { color: var(--agent-accent, var(--primary-600)); background: #fff; transform: translateY(-1px); - box-shadow: 0 8px 16px -12px rgba(15,23,42,.18); + box-shadow: 0 8px 16px -12px rgba(15, 23, 42, 0.18); +} +.agent-action:disabled { + opacity: 0.3; + cursor: default; +} +.agent-action--danger:hover { + color: var(--danger-500); + background: var(--danger-50); + border-color: rgba(248, 113, 113, 0.18); } -.agent-action:disabled { opacity: .3; cursor: default; } -.agent-action--danger:hover { color: var(--danger-500); background: var(--danger-50); border-color: rgba(248,113,113,.18); } -.agent-card-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } -.agent-meta-item { display: flex; align-items: center; gap: 3px; font-size: .7rem; color: var(--text-muted); } +.agent-card-meta { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} +.agent-meta-item { + display: flex; + align-items: center; + gap: 3px; + font-size: 0.7rem; + color: var(--text-muted); +} -.agent-card-url { display: flex; align-items: center; gap: 4px; font-size: .6rem; color: var(--gray-400); margin-bottom: 6px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; padding: 3px 6px; background: var(--gray-100); border-radius: var(--radius-xs); } -.agent-card-url span { overflow: hidden; text-overflow: ellipsis; } +.agent-card-url { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.6rem; + color: var(--gray-400); + margin-bottom: 6px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + padding: 3px 6px; + background: var(--gray-100); + border-radius: var(--radius-xs); +} +.agent-card-url span { + overflow: hidden; + text-overflow: ellipsis; +} .agent-card-stats { display: flex; @@ -393,336 +821,1704 @@ a { color: inherit; text-decoration: none; } display: inline-flex; align-items: center; gap: 5px; - font-size: .72rem; + font-size: 0.72rem; color: var(--gray-600); font-weight: 700; padding: 4px 9px; border-radius: 999px; - background: rgba(243,244,246,.95); + background: rgba(243, 244, 246, 0.95); border: 1px solid rgba(209, 213, 219, 0.65); } -.agent-stat--new { font-weight: 700; } +.agent-stat--new { + font-weight: 700; +} /* Agent Form */ -.agent-form { padding: 14px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); } -.agent-form-title { font-size: .85rem; font-weight: 700; margin-bottom: 12px; } -.agent-form-mode { display: flex; gap: 4px; margin-bottom: 12px; padding: 3px; background: var(--gray-100); border-radius: var(--radius-sm); } -.mode-tab { flex: 1; display: flex; align-items: center; justify-content: center; gap: 4px; padding: 5px 8px; border-radius: var(--radius-xs); font-size: .73rem; font-weight: 500; color: var(--text-secondary); transition: all var(--transition); } -.mode-tab:hover { color: var(--text); } -.mode-tab--active { background: var(--surface); color: var(--text); font-weight: 600; box-shadow: var(--shadow-xs); } - -.agent-form-row { display: flex; gap: 8px; } -.agent-form-field { display: flex; flex-direction: column; gap: 3px; margin-bottom: 10px; flex: 1; } -.agent-form-label { font-size: .7rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .03em; } -.agent-form-hint { font-size: .65rem; color: var(--text-muted); line-height: 1.4; } -.agent-form-url-error { font-size: .65rem; color: #dc2626; line-height: 1.4; display: block; margin-top: 3px; } -.agent-form-resolved { font-size: .65rem; color: var(--success-600); font-weight: 500; } -.agent-input { padding: 7px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); font-size: .8rem; transition: border-color var(--transition); width: 100%; } -.agent-input:focus { outline: none; border-color: var(--primary-400); box-shadow: 0 0 0 3px var(--primary-100); } -.agent-input--sm { width: 72px; text-align: center; } -.agent-select { padding: 7px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); font-size: .8rem; transition: border-color var(--transition); width: 100%; } -.agent-select:focus { outline: none; border-color: var(--primary-400); box-shadow: 0 0 0 3px var(--primary-100); } -.agent-input-with-suffix { display: flex; align-items: center; gap: 4px; } -.agent-input-suffix { font-size: .73rem; color: var(--text-muted); } -.agent-form-actions { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; } -.agent-form-detected { display: flex; align-items: flex-start; padding: 5px 8px; border-radius: var(--radius-sm); background: var(--gray-50); border: 1px solid var(--border); font-size: .72rem; min-height: 28px; margin-bottom: 4px; } -.agent-form-detected-ok { display: flex; align-items: center; gap: 5px; color: var(--success-600, #16a34a); flex-wrap: wrap; } -.agent-form-detected-fallback { display: flex; align-items: center; gap: 5px; color: var(--text-secondary); flex-wrap: wrap; } -.agent-form-type-badge { display: inline-flex; align-items: center; padding: 1px 7px; border-radius: 99px; font-size: .68rem; font-weight: 600; } -.agent-form-provider-badge { display: inline-flex; align-items: center; padding: 1px 7px; border-radius: 99px; font-size: .68rem; font-weight: 600; } -.agent-select-inline { padding: 2px 6px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); font-size: .72rem; } - -/* City Autocomplete */ -.city-input-wrap { position: relative; width: 100%; } -.city-suggestions { position: absolute; top: 100%; left: 0; right: 0; z-index: 50; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); box-shadow: var(--shadow-lg); max-height: 200px; overflow-y: auto; margin-top: 2px; list-style: none; } -.city-suggestion { display: flex; align-items: center; justify-content: space-between; padding: 7px 10px; cursor: pointer; font-size: .8rem; transition: background var(--transition); } -.city-suggestion:hover, .city-suggestion--active { background: var(--primary-50); } -.city-suggestion-name { font-weight: 500; } -.city-suggestion-slug { font-size: .68rem; color: var(--text-muted); } - -/* Agent Add Button */ -.agent-add-btn { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 10px; border-radius: var(--radius-md); border: 1.5px dashed var(--gray-300); color: var(--text-muted); font-size: .8rem; font-weight: 500; transition: all var(--transition); margin-top: auto; } -.agent-add-btn:hover { border-color: var(--primary-400); color: var(--primary-600); background: var(--primary-50); border-style: solid; } - -/* ── Filter Bar ────────────────────────────────────────────── */ -.filter-bar { display: flex; flex-direction: column; gap: 12px; padding: 14px 16px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-xs); margin-bottom: 20px; } -.tabs { display: flex; gap: 2px; background: var(--gray-100); border-radius: var(--radius-sm); padding: 3px; } -.tab { display: flex; align-items: center; gap: 5px; padding: 6px 14px; border-radius: var(--radius-xs); font-size: .8125rem; font-weight: 500; color: var(--text-secondary); transition: all var(--transition); white-space: nowrap; } -.tab:hover { color: var(--text); background: rgba(255,255,255,.5); } -.tab--active { background: var(--surface); color: var(--text); font-weight: 600; box-shadow: var(--shadow-xs); } -.tab-icon { width: 14px; height: 14px; flex-shrink: 0; } -.tab-badge { font-size: .65rem; padding: 0 6px; border-radius: var(--radius-full); background: var(--gray-200); font-weight: 700; color: var(--gray-600); min-width: 18px; text-align: center; line-height: 1.6; } -.tab--active .tab-badge { background: var(--primary-100); color: var(--primary-700); } -.filter-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } -.filter-select { padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); font-size: .8rem; transition: border-color var(--transition); } -.filter-select:focus { outline: none; border-color: var(--primary-400); } -.search-wrap { position: relative; flex: 1; min-width: 160px; } -.search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); width: 15px; height: 15px; color: var(--gray-400); pointer-events: none; } -.input-search { width: 100%; padding: 6px 10px 6px 30px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); font-size: .8rem; transition: all var(--transition); } -.input-search:focus { outline: none; border-color: var(--primary-400); box-shadow: 0 0 0 3px var(--primary-100); } -.filter-group { display: flex; gap: 6px; align-items: center; } -.filter-label { display: flex; align-items: center; gap: 3px; font-size: .8rem; color: var(--text-secondary); } -.filter-label--range { display: flex; align-items: center; gap: 4px; } -.filter-prefix { font-weight: 600; font-size: .8rem; color: var(--text-secondary); } -.filter-suffix, .filter-suffix--sep { font-size: .73rem; color: var(--text-muted); } -.input-small { width: 64px; padding: 5px 6px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); font-size: .8rem; text-align: center; transition: border-color var(--transition); } -.input-small:focus { outline: none; border-color: var(--primary-400); } -.filter-label--date { gap: 6px; } -.filter-date-icon { width: 14px; height: 14px; color: var(--gray-400); flex-shrink: 0; } -.filter-date-label { font-size: .8rem; color: var(--text-secondary); white-space: nowrap; } -.input-date { padding: 5px 6px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); font-size: .8rem; transition: border-color var(--transition); color: var(--text-primary); } -.input-date:focus { outline: none; border-color: var(--primary-400); } -.input-date::-webkit-calendar-picker-indicator { opacity: 0.5; cursor: pointer; } -.filter-reset { margin-left: auto; } - -/* ── Grid ──────────────────────────────────────────────────── */ -.grid-info-bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } -.grid-info { font-size: .8125rem; color: var(--text-secondary); } -.grid-info strong { color: var(--text); font-weight: 700; } -.grid-info-sep { margin: 0 5px; color: var(--gray-300); } -.listings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; } -/* Compact grid/card sizing (desktop) */ -@media (min-width: 1100px) { - .listings-grid--compact { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } +.agent-form { + padding: 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); } - -/* Card compact tweaks for denser layout (~20% smaller visual footprint) */ -.listings-grid--compact .card { border-radius: var(--radius-md); } -.listings-grid--compact .card-body { padding: 12px 14px 8px; } -.listings-grid--compact .card-footer { padding: 8px 14px; } - -.listings-grid--compact .card-price { font-size: 1rem; } -.listings-grid--compact .card-title { font-size: .82rem; } -.listings-grid--compact .card-address, -.listings-grid--compact .card-desc { font-size: .72rem; } - -.listings-grid--compact .pill { font-size: .6rem; padding: 1px 6px; } - -.listings-grid--compact .card-fav-btn, -.listings-grid--compact .card-blacklist-btn { width: 26px; height: 26px; } -.listings-grid--compact .heart-svg { width: 12px; height: 12px; } - -.listings-grid--compact .carousel-btn { width: 24px; height: 24px; } -.listings-grid--compact .chev-icon { width: 11px; height: 11px; } - -.listings-grid--compact .card-badge { font-size: .58rem; padding: 1px 8px; } -.listings-grid--compact .card-badge--new { top: 7px; left: 7px; } -.listings-grid--compact .card-badge--type { bottom: 7px; left: 7px; } - -.listings-grid--compact .date-icon { width: 10px; height: 10px; } -.listings-grid--compact .addr-icon { width: 12px; height: 12px; } -.listings-grid--compact .card-open-btn { font-size: .75rem; padding: 4px 10px; } -.grid-pagination-bottom { margin-top: 28px; display: flex; justify-content: center; } -.grid-state { text-align: center; padding: 80px 24px; } -.spinner { width: 32px; height: 32px; margin: 0 auto 16px; border: 2.5px solid var(--gray-200); border-top-color: var(--primary-500); border-radius: 50%; animation: spin .7s linear infinite; } -@keyframes spin { to { transform: rotate(360deg); } } -.empty-illustration { margin-bottom: 20px; } -.empty-svg { width: 100px; height: 100px; margin: 0 auto; } -.empty-title { font-size: 1.15rem; font-weight: 700; margin-bottom: 6px; color: var(--gray-800); } -.empty-sub { color: var(--text-muted); margin-bottom: 20px; font-size: .9rem; } - -/* ── Card ──────────────────────────────────────────────────── */ -.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-card); transition: box-shadow var(--transition), transform var(--transition); } -.card:hover { box-shadow: var(--shadow-card-hover); transform: translateY(-3px); } -.card--new { border-left: 3px solid var(--primary-500); } -.card--seen { opacity: 1; } -.card--blacklisted { border-left: 3px solid var(--danger-400); opacity: .65; } -.card-img { position: relative; aspect-ratio: 4/3; overflow: hidden; background: linear-gradient(135deg, var(--gray-100) 0%, var(--gray-50) 100%); } -.card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform 500ms ease; } -.card:hover .card-img img { transform: scale(1.03); } -.card-img-placeholder { width: 100%; height: 100%; display: grid; place-items: center; font-size: 2.2rem; background: linear-gradient(135deg, var(--gray-100) 0%, var(--primary-50) 100%); } -.carousel-btn { position: absolute; top: 50%; transform: translateY(-50%); width: 28px; height: 28px; border-radius: 50%; background: rgba(255,255,255,.9); color: var(--gray-700); display: grid; place-items: center; box-shadow: var(--shadow-sm); opacity: 0; transition: opacity var(--transition); backdrop-filter: blur(4px); } -.card:hover .carousel-btn { opacity: 1; } -.carousel-btn:hover { background: #fff; } -.carousel-btn--prev { left: 8px; } -.carousel-btn--next { right: 8px; } -.chev-icon { width: 13px; height: 13px; } -.carousel-counter { position: absolute; bottom: 8px; right: 8px; padding: 2px 8px; border-radius: var(--radius-full); background: rgba(0,0,0,.55); color: #fff; font-size: .65rem; font-weight: 600; backdrop-filter: blur(4px); } -.card-actions-overlay { position: absolute; top: 8px; right: 8px; display: flex; flex-direction: column; gap: 4px; } -.card-seen-btn { width: 30px; height: 30px; border-radius: 50%; background: rgba(255,255,255,.9); display: grid; place-items: center; box-shadow: var(--shadow-sm); transition: all var(--transition); backdrop-filter: blur(4px); color: var(--gray-400); } -.card-seen-btn:hover { transform: scale(1.1); color: var(--primary-600); background: #fff; } -.card-seen-btn--active { color: var(--primary-600); background: #fff; } -.card-fav-btn, .card-blacklist-btn { width: 30px; height: 30px; border-radius: 50%; background: rgba(255,255,255,.9); display: grid; place-items: center; box-shadow: var(--shadow-sm); transition: all var(--transition); backdrop-filter: blur(4px); } -.card-fav-btn:hover, .card-blacklist-btn:hover { transform: scale(1.1); } -.heart-svg { width: 14px; height: 14px; } -.card-fav-btn { color: var(--gray-400); } -.card-fav-btn:hover { color: var(--danger-500); background: #fff; } -.card-fav-btn--active { color: var(--danger-500); background: #fff; } -.card-blacklist-btn { color: var(--gray-400); } -.card-blacklist-btn:hover { color: var(--gray-800); background: #fff; } -.card-blacklist-btn--active { color: var(--danger-500); } -.card-badge { position: absolute; font-size: .625rem; font-weight: 700; padding: 2px 9px; border-radius: var(--radius-full); letter-spacing: .02em; text-transform: uppercase; } -.card-badge--new { top: 8px; left: 8px; background: var(--primary-600); color: #fff; box-shadow: 0 2px 6px rgba(79,70,229,.3); } -.card-badge-stack { position: absolute; left: 8px; bottom: 8px; display: flex; flex-wrap: wrap; gap: 6px; max-width: calc(100% - 16px); } -.card-badge-stack .card-badge { position: static; } -.card-badge--provider { border: 1px solid rgba(255,255,255,.72); backdrop-filter: blur(6px); } -.card-body { padding: 14px 16px 10px; flex: 1; display: flex; flex-direction: column; } -.card-meta { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 6px; } -.card-price { font-size: 1.1rem; font-weight: 800; color: var(--gray-900); letter-spacing: -.02em; } -.card-pills { display: flex; gap: 4px; } -.pill { font-size: .65rem; padding: 2px 7px; border-radius: var(--radius-full); background: var(--gray-100); color: var(--gray-600); font-weight: 600; } -.card-title { font-size: .875rem; font-weight: 600; line-height: 1.4; display: -webkit-box; line-clamp: 2; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 4px; color: var(--gray-800); } -.card-address { display: flex; align-items: flex-start; gap: 4px; font-size: .78rem; color: var(--text-muted); margin-bottom: 6px; } -.card-expose-id { font-size: .72rem; color: var(--text-muted); opacity: .65; margin: -2px 0 6px; font-family: monospace; } -.addr-icon { width: 13px; height: 13px; flex-shrink: 0; margin-top: 1px; color: var(--gray-400); } -.card-desc { font-size: .78rem; color: var(--gray-400); line-height: 1.45; display: -webkit-box; line-clamp: 2; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; flex: 1; } -.card-footer { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-top: 1px solid var(--gray-100); } -.card-dates { font-size: .7rem; color: var(--text-muted); display: flex; flex-direction: column; gap: 3px; min-width: 0; } -.card-date { display: flex; align-items: center; gap: 4px; min-width: 0; } -.card-date-label { font-weight: 700; color: var(--gray-600); white-space: nowrap; } -.card-date--publisher { max-width: 280px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.date-icon { width: 11px; height: 11px; } -.card-open-btn { display: inline-flex; align-items: center; gap: 4px; padding: 5px 12px; border-radius: var(--radius-sm); background: var(--primary-50); color: var(--primary-600); font-size: .78rem; font-weight: 600; transition: all var(--transition); } -.card-open-btn:hover { background: var(--primary-100); } -.open-icon { width: 12px; height: 12px; } - -/* ── Pagination ────────────────────────────────────────────── */ -.pagination { display: flex; align-items: center; gap: 3px; } -.page-btn { min-width: 30px; height: 30px; display: grid; place-items: center; border-radius: var(--radius-sm); font-size: .8125rem; font-weight: 500; color: var(--text-secondary); transition: all var(--transition); } -.page-btn:hover:not(:disabled) { background: var(--gray-100); color: var(--text); } -.page-btn--active { background: var(--primary-600); color: #fff; box-shadow: 0 1px 4px rgba(79,70,229,.25); } -.page-btn--active:hover { background: var(--primary-700); color: #fff; } -.page-btn:disabled { opacity: .3; cursor: default; } -.page-ellipsis { padding: 0 3px; color: var(--gray-400); font-size: .8rem; } - -/* ── Settings Sidebar (Right) ──────────────────────────────── */ -.sidebar { position: fixed; top: 0; right: 0; z-index: 200; width: var(--sidebar-w); max-width: 92vw; height: 100vh; background: var(--surface); border-left: 1px solid var(--border); box-shadow: var(--shadow-xl); transform: translateX(100%); transition: transform 300ms cubic-bezier(.4,0,.2,1); display: flex; flex-direction: column; } -.sidebar--open { transform: translateX(0); } -.sidebar-overlay { position: fixed; inset: 0; z-index: 199; background: rgba(15,23,42,.18); } -.sidebar-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border); } -.sidebar-title { font-size: 1rem; font-weight: 700; } -.sidebar-close { width: 30px; height: 30px; display: grid; place-items: center; border-radius: var(--radius-sm); font-size: 1.15rem; color: var(--text-muted); transition: all var(--transition); } -.sidebar-close:hover { background: var(--gray-100); color: var(--text); } -.sidebar-body { flex: 1; overflow-y: auto; padding: 20px; } -.sidebar-footer { padding: 16px 20px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 8px; } -.s-section { margin-bottom: 24px; } -.s-heading { font-size: .875rem; font-weight: 600; margin-bottom: 4px; } -.s-hint { font-size: .78rem; color: var(--text-muted); margin-bottom: 10px; line-height: 1.5; } -.tag-input-wrap { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); min-height: 40px; cursor: text; transition: border-color var(--transition); } -.tag-input-wrap:focus-within { border-color: var(--primary-400); box-shadow: 0 0 0 3px var(--primary-100); } -.tag { display: inline-flex; align-items: center; gap: 4px; padding: 2px 10px; border-radius: var(--radius-full); background: var(--gray-100); font-size: .78rem; font-weight: 500; } -.tag-remove { display: inline-flex; align-items: center; margin-left: 2px; color: var(--gray-400); font-size: .73rem; } -.tag-remove:hover { color: var(--danger-500); } -.tag-input { flex: 1; min-width: 100px; border: none; outline: none; font-size: .78rem; background: transparent; } -.reset-confirm { text-align: center; } -.reset-confirm p { font-size: .8125rem; color: var(--danger-500); font-weight: 500; margin-bottom: 8px; } -.reset-confirm-actions { display: flex; gap: 8px; justify-content: center; } - -/* ── ScrapeLog ─────────────────────────────────────────────── */ -.log-section { margin-top: 36px; padding: 20px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-xs); } -.log-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } -.log-title { display: flex; align-items: center; gap: 8px; font-size: .9375rem; font-weight: 700; } -.log-title-icon { width: 16px; height: 16px; color: var(--primary-500); } -.log-count { font-size: .78rem; color: var(--text-muted); } -.log-table-wrap { overflow-x: auto; } -.log-table { width: 100%; border-collapse: collapse; font-size: .78rem; } -.log-table th { text-align: left; padding: 8px 12px; font-weight: 600; color: var(--text-muted); font-size: .7rem; text-transform: uppercase; letter-spacing: .04em; border-bottom: 1px solid var(--border); } -.log-table td { padding: 8px 12px; border-bottom: 1px solid var(--gray-100); } -.source-label { background: var(--gray-100); padding: 2px 8px; border-radius: var(--radius-full); font-size: .7rem; font-weight: 600; } -.duration-label { color: var(--text-muted); } -.status-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 10px; border-radius: var(--radius-full); font-size: .7rem; font-weight: 600; } -.status-icon { font-size: .65rem; } -.badge--success { background: var(--success-50); color: var(--success-600); } -.badge--error { background: var(--danger-50); color: var(--danger-500); } -.badge--running { background: var(--primary-50); color: var(--primary-700); } -.log-error { display: block; font-size: .7rem; color: var(--danger-500); margin-top: 3px; } -.log-toggle { margin-top: 10px; } - -/* ── Toast ─────────────────────────────────────────────────── */ -.toast-container { position: fixed; bottom: 20px; right: 20px; z-index: 1000; display: flex; flex-direction: column-reverse; gap: 8px; pointer-events: none; } -.toast { display: flex; align-items: center; gap: 10px; padding: 12px 18px; border-radius: var(--radius-md); background: var(--gray-900); color: #fff; font-size: .8125rem; font-weight: 500; box-shadow: var(--shadow-xl); opacity: 0; transform: translateY(8px) scale(.96); transition: all 250ms cubic-bezier(.4,0,.2,1); pointer-events: auto; backdrop-filter: blur(12px); } -.toast--visible { opacity: 1; transform: translateY(0) scale(1); } -.toast--success { background: #065f46; } -.toast--error { background: #991b1b; } -.toast--info { background: var(--gray-800); } -.toast-icon { width: 16px; height: 16px; flex-shrink: 0; } -.toast-msg { flex: 1; } -.toast-action { flex-shrink: 0; padding: 3px 10px; border-radius: var(--radius-sm); background: rgba(255,255,255,.15); color: #fff; font-size: .78rem; font-weight: 700; border: 1px solid rgba(255,255,255,.2); transition: background var(--transition); white-space: nowrap; } -.toast-action:hover { background: rgba(255,255,255,.25); } - -/* ── Error Boundary ────────────────────────────────────────── */ -.error-boundary { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 40px; } -.error-boundary-content { text-align: center; max-width: 480px; } -.error-icon { font-size: 3rem; margin-bottom: 16px; } -.error-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 8px; } -.error-message { color: var(--text-muted); margin-bottom: 20px; } -.error-details { text-align: left; margin: 16px 0; } -.error-details summary { cursor: pointer; font-size: .8125rem; color: var(--text-muted); } -.error-stack { margin-top: 8px; padding: 12px; background: var(--gray-100); border-radius: var(--radius-sm); font-family: monospace; font-size: .75rem; overflow-x: auto; white-space: pre-wrap; word-break: break-all; } - -/* ── Responsive ────────────────────────────────────────────── */ -@media (max-width: 1024px) { - .agent-sidebar { width: var(--agent-sidebar-collapsed-w); } - .agent-sidebar .agent-sidebar-body { display: none; } - .agent-sidebar .agent-sidebar-collapsed-icons { display: flex; } - .agent-sidebar .agent-sidebar-title { display: none; } +.agent-form-title { + font-size: 0.85rem; + font-weight: 700; + margin-bottom: 12px; } -@media (max-width: 768px) { - .main { padding: 14px 14px 48px; } - .listings-grid { grid-template-columns: 1fr; gap: 14px; } - .filter-controls { flex-direction: column; } - .filter-group { width: 100%; } - .search-wrap { min-width: 100%; } - .tabs { gap: 1px; } - .tab { padding: 5px 10px; font-size: .78rem; } - .btn-label-desktop { display: none; } - .header-subtitle { display: none; } - .header-inner { padding: 0 14px; } - .agent-sidebar { position: fixed; left: 0; top: var(--header-h); z-index: 150; width: var(--agent-sidebar-collapsed-w); box-shadow: var(--shadow-xl); } - .agent-sidebar:not(.agent-sidebar--collapsed) { width: min(300px, 82vw); } - .active-agent-indicator { font-size: .75rem; padding: 6px 10px; } +.agent-form-mode { + display: flex; + gap: 4px; + margin-bottom: 12px; + padding: 3px; + background: var(--gray-100); + border-radius: var(--radius-sm); } - -@media (max-width: 480px) { - .tab-text { display: none; } - .tab { padding: 6px 8px; } - .card-meta { flex-direction: column; gap: 4px; } +.mode-tab { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 5px 8px; + border-radius: var(--radius-xs); + font-size: 0.73rem; + font-weight: 500; + color: var(--text-secondary); + transition: all var(--transition); } - +.mode-tab:hover { + color: var(--text); +} +.mode-tab--active { + background: var(--surface); + color: var(--text); + font-weight: 600; + box-shadow: var(--shadow-xs); +} + +.agent-form-row { + display: flex; + gap: 8px; +} +.agent-form-field { + display: flex; + flex-direction: column; + gap: 3px; + margin-bottom: 10px; + flex: 1; +} +.agent-form-label { + font-size: 0.7rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.03em; +} +.agent-form-hint { + font-size: 0.65rem; + color: var(--text-muted); + line-height: 1.4; +} +.agent-form-url-error { + font-size: 0.65rem; + color: #dc2626; + line-height: 1.4; + display: block; + margin-top: 3px; +} +.agent-form-resolved { + font-size: 0.65rem; + color: var(--success-600); + font-weight: 500; +} +.agent-input { + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + font-size: 0.8rem; + transition: border-color var(--transition); + width: 100%; +} +.agent-input:focus { + outline: none; + border-color: var(--primary-400); + box-shadow: 0 0 0 3px var(--primary-100); +} +.agent-input--sm { + width: 72px; + text-align: center; +} +.agent-select { + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + font-size: 0.8rem; + transition: border-color var(--transition); + width: 100%; +} +.agent-select:focus { + outline: none; + border-color: var(--primary-400); + box-shadow: 0 0 0 3px var(--primary-100); +} +.agent-input-with-suffix { + display: flex; + align-items: center; + gap: 4px; +} +.agent-input-suffix { + font-size: 0.73rem; + color: var(--text-muted); +} +.agent-form-actions { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 4px; +} +.agent-form-detected { + display: flex; + align-items: flex-start; + padding: 5px 8px; + border-radius: var(--radius-sm); + background: var(--gray-50); + border: 1px solid var(--border); + font-size: 0.72rem; + min-height: 28px; + margin-bottom: 4px; +} +.agent-form-detected-ok { + display: flex; + align-items: center; + gap: 5px; + color: var(--success-600, #16a34a); + flex-wrap: wrap; +} +.agent-form-detected-fallback { + display: flex; + align-items: center; + gap: 5px; + color: var(--text-secondary); + flex-wrap: wrap; +} +.agent-form-type-badge { + display: inline-flex; + align-items: center; + padding: 1px 7px; + border-radius: 99px; + font-size: 0.68rem; + font-weight: 600; +} +.agent-form-provider-badge { + display: inline-flex; + align-items: center; + padding: 1px 7px; + border-radius: 99px; + font-size: 0.68rem; + font-weight: 600; +} +.agent-select-inline { + padding: 2px 6px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + font-size: 0.72rem; +} + +/* City Autocomplete */ +.city-input-wrap { + position: relative; + width: 100%; +} +.city-suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 50; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-lg); + max-height: 200px; + overflow-y: auto; + margin-top: 2px; + list-style: none; +} +.city-suggestion { + display: flex; + align-items: center; + justify-content: space-between; + padding: 7px 10px; + cursor: pointer; + font-size: 0.8rem; + transition: background var(--transition); +} +.city-suggestion:hover, +.city-suggestion--active { + background: var(--primary-50); +} +.city-suggestion-name { + font-weight: 500; +} +.city-suggestion-slug { + font-size: 0.68rem; + color: var(--text-muted); +} + +/* Agent Add Button */ +.agent-add-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px; + border-radius: var(--radius-md); + border: 1.5px dashed var(--gray-300); + color: var(--text-muted); + font-size: 0.8rem; + font-weight: 500; + transition: all var(--transition); + margin-top: auto; +} +.agent-add-btn:hover { + border-color: var(--primary-400); + color: var(--primary-600); + background: var(--primary-50); + border-style: solid; +} + +/* ── Filter Bar ────────────────────────────────────────────── */ +.filter-bar { + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xs); + margin-bottom: 20px; +} +.tabs { + display: flex; + gap: 2px; + background: var(--gray-100); + border-radius: var(--radius-sm); + padding: 3px; +} +.tab { + display: flex; + align-items: center; + gap: 5px; + padding: 6px 14px; + border-radius: var(--radius-xs); + font-size: 0.8125rem; + font-weight: 500; + color: var(--text-secondary); + transition: all var(--transition); + white-space: nowrap; +} +.tab:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.5); +} +.tab--active { + background: var(--surface); + color: var(--text); + font-weight: 600; + box-shadow: var(--shadow-xs); +} +.tab-icon { + width: 14px; + height: 14px; + flex-shrink: 0; +} +.tab-badge { + font-size: 0.65rem; + padding: 0 6px; + border-radius: var(--radius-full); + background: var(--gray-200); + font-weight: 700; + color: var(--gray-600); + min-width: 18px; + text-align: center; + line-height: 1.6; +} +.tab--active .tab-badge { + background: var(--primary-100); + color: var(--primary-700); +} +.filter-controls { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} +.filter-select { + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + font-size: 0.8rem; + transition: border-color var(--transition); +} +.filter-select:focus { + outline: none; + border-color: var(--primary-400); +} +.search-wrap { + position: relative; + flex: 1; + min-width: 160px; +} +.search-icon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + width: 15px; + height: 15px; + color: var(--gray-400); + pointer-events: none; +} +.input-search { + width: 100%; + padding: 6px 10px 6px 30px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + font-size: 0.8rem; + transition: all var(--transition); +} +.input-search:focus { + outline: none; + border-color: var(--primary-400); + box-shadow: 0 0 0 3px var(--primary-100); +} +.filter-group { + display: flex; + gap: 6px; + align-items: center; +} +.filter-label { + display: flex; + align-items: center; + gap: 3px; + font-size: 0.8rem; + color: var(--text-secondary); +} +.filter-label--range { + display: flex; + align-items: center; + gap: 4px; +} +.filter-prefix { + font-weight: 600; + font-size: 0.8rem; + color: var(--text-secondary); +} +.filter-suffix, +.filter-suffix--sep { + font-size: 0.73rem; + color: var(--text-muted); +} +.input-small { + width: 64px; + padding: 5px 6px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + font-size: 0.8rem; + text-align: center; + transition: border-color var(--transition); +} +.input-small:focus { + outline: none; + border-color: var(--primary-400); +} +.filter-label--date { + gap: 6px; +} +.filter-date-icon { + width: 14px; + height: 14px; + color: var(--gray-400); + flex-shrink: 0; +} +.filter-date-label { + font-size: 0.8rem; + color: var(--text-secondary); + white-space: nowrap; +} +.input-date { + padding: 5px 6px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + font-size: 0.8rem; + transition: border-color var(--transition); + color: var(--text-primary); +} +.input-date:focus { + outline: none; + border-color: var(--primary-400); +} +.input-date::-webkit-calendar-picker-indicator { + opacity: 0.5; + cursor: pointer; +} +.filter-reset { + margin-left: auto; +} + +/* ── Grid ──────────────────────────────────────────────────── */ +.grid-info-bar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; +} +.grid-info { + font-size: 0.8125rem; + color: var(--text-secondary); +} +.grid-info strong { + color: var(--text); + font-weight: 700; +} +.grid-info-sep { + margin: 0 5px; + color: var(--gray-300); +} +.listings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} +/* Compact grid/card sizing (desktop) */ +@media (min-width: 1100px) { + .listings-grid--compact { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + } +} + +/* Card compact tweaks for denser layout (~20% smaller visual footprint) */ +.listings-grid--compact .card { + border-radius: var(--radius-md); +} +.listings-grid--compact .card-body { + padding: 12px 14px 8px; +} +.listings-grid--compact .card-footer { + padding: 8px 14px; +} + +.listings-grid--compact .card-price { + font-size: 1rem; +} +.listings-grid--compact .card-title { + font-size: 0.82rem; +} +.listings-grid--compact .card-address, +.listings-grid--compact .card-desc { + font-size: 0.72rem; +} + +.listings-grid--compact .pill { + font-size: 0.6rem; + padding: 1px 6px; +} + +.listings-grid--compact .card-fav-btn, +.listings-grid--compact .card-blacklist-btn { + width: 26px; + height: 26px; +} +.listings-grid--compact .heart-svg { + width: 12px; + height: 12px; +} + +.listings-grid--compact .carousel-btn { + width: 24px; + height: 24px; +} +.listings-grid--compact .chev-icon { + width: 11px; + height: 11px; +} + +.listings-grid--compact .card-badge { + font-size: 0.58rem; + padding: 1px 8px; +} +.listings-grid--compact .card-badge--new { + top: 7px; + left: 7px; +} +.listings-grid--compact .card-badge--type { + bottom: 7px; + left: 7px; +} + +.listings-grid--compact .date-icon { + width: 10px; + height: 10px; +} +.listings-grid--compact .addr-icon { + width: 12px; + height: 12px; +} +.listings-grid--compact .card-open-btn { + font-size: 0.75rem; + padding: 4px 10px; +} +.grid-pagination-bottom { + margin-top: 28px; + display: flex; + justify-content: center; +} +.grid-state { + text-align: center; + padding: 80px 24px; +} +.spinner { + width: 32px; + height: 32px; + margin: 0 auto 16px; + border: 2.5px solid var(--gray-200); + border-top-color: var(--primary-500); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +.empty-illustration { + margin-bottom: 20px; +} +.empty-svg { + width: 100px; + height: 100px; + margin: 0 auto; +} +.empty-title { + font-size: 1.15rem; + font-weight: 700; + margin-bottom: 6px; + color: var(--gray-800); +} +.empty-sub { + color: var(--text-muted); + margin-bottom: 20px; + font-size: 0.9rem; +} + +/* ── Card ──────────────────────────────────────────────────── */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: var(--shadow-card); + transition: + box-shadow var(--transition), + transform var(--transition); +} +.card:hover { + box-shadow: var(--shadow-card-hover); + transform: translateY(-3px); +} +.card--new { + border-left: 3px solid var(--primary-500); +} +.card--seen { + opacity: 1; +} +.card--blacklisted { + border-left: 3px solid var(--danger-400); + opacity: 0.65; +} +.card-img { + position: relative; + aspect-ratio: 4/3; + overflow: hidden; + background: linear-gradient(135deg, var(--gray-100) 0%, var(--gray-50) 100%); +} +.card-img img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 500ms ease; +} +.card:hover .card-img img { + transform: scale(1.03); +} +.card-img-placeholder { + width: 100%; + height: 100%; + display: grid; + place-items: center; + font-size: 2.2rem; + background: linear-gradient(135deg, var(--gray-100) 0%, var(--primary-50) 100%); +} +.carousel-btn { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 28px; + height: 28px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.9); + color: var(--gray-700); + display: grid; + place-items: center; + box-shadow: var(--shadow-sm); + opacity: 0; + transition: opacity var(--transition); + backdrop-filter: blur(4px); +} +.card:hover .carousel-btn { + opacity: 1; +} +.carousel-btn:hover { + background: #fff; +} +.carousel-btn--prev { + left: 8px; +} +.carousel-btn--next { + right: 8px; +} +.chev-icon { + width: 13px; + height: 13px; +} +.carousel-counter { + position: absolute; + bottom: 8px; + right: 8px; + padding: 2px 8px; + border-radius: var(--radius-full); + background: rgba(0, 0, 0, 0.55); + color: #fff; + font-size: 0.65rem; + font-weight: 600; + backdrop-filter: blur(4px); +} +.card-actions-overlay { + position: absolute; + top: 8px; + right: 8px; + display: flex; + flex-direction: column; + gap: 4px; +} +.card-seen-btn { + width: 30px; + height: 30px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.9); + display: grid; + place-items: center; + box-shadow: var(--shadow-sm); + transition: all var(--transition); + backdrop-filter: blur(4px); + color: var(--gray-400); +} +.card-seen-btn:hover { + transform: scale(1.1); + color: var(--primary-600); + background: #fff; +} +.card-seen-btn--active { + color: var(--primary-600); + background: #fff; +} +.card-fav-btn, +.card-blacklist-btn { + width: 30px; + height: 30px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.9); + display: grid; + place-items: center; + box-shadow: var(--shadow-sm); + transition: all var(--transition); + backdrop-filter: blur(4px); +} +.card-fav-btn:hover, +.card-blacklist-btn:hover { + transform: scale(1.1); +} +.heart-svg { + width: 14px; + height: 14px; +} +.card-fav-btn { + color: var(--gray-400); +} +.card-fav-btn:hover { + color: var(--danger-500); + background: #fff; +} +.card-fav-btn--active { + color: var(--danger-500); + background: #fff; +} +.card-blacklist-btn { + color: var(--gray-400); +} +.card-blacklist-btn:hover { + color: var(--gray-800); + background: #fff; +} +.card-blacklist-btn--active { + color: var(--danger-500); +} +.card-badge { + position: absolute; + font-size: 0.625rem; + font-weight: 700; + padding: 2px 9px; + border-radius: var(--radius-full); + letter-spacing: 0.02em; + text-transform: uppercase; +} +.card-badge--new { + top: 8px; + left: 8px; + background: var(--primary-600); + color: #fff; + box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3); +} +.card-badge-stack { + position: absolute; + left: 8px; + bottom: 8px; + display: flex; + flex-wrap: wrap; + gap: 6px; + max-width: calc(100% - 16px); +} +.card-badge-stack .card-badge { + position: static; +} +.card-badge--provider { + border: 1px solid rgba(255, 255, 255, 0.72); + backdrop-filter: blur(6px); +} +.card-body { + padding: 14px 16px 10px; + flex: 1; + display: flex; + flex-direction: column; +} +.card-meta { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 6px; +} +.card-price { + font-size: 1.1rem; + font-weight: 800; + color: var(--gray-900); + letter-spacing: -0.02em; +} +.card-pills { + display: flex; + gap: 4px; +} +.pill { + font-size: 0.65rem; + padding: 2px 7px; + border-radius: var(--radius-full); + background: var(--gray-100); + color: var(--gray-600); + font-weight: 600; +} +.card-title { + font-size: 0.875rem; + font-weight: 600; + line-height: 1.4; + display: -webkit-box; + line-clamp: 2; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + margin-bottom: 4px; + color: var(--gray-800); +} +.card-address { + display: flex; + align-items: flex-start; + gap: 4px; + font-size: 0.78rem; + color: var(--text-muted); + margin-bottom: 6px; +} +.card-expose-id { + font-size: 0.72rem; + color: var(--text-muted); + opacity: 0.65; + margin: -2px 0 6px; + font-family: monospace; +} +.addr-icon { + width: 13px; + height: 13px; + flex-shrink: 0; + margin-top: 1px; + color: var(--gray-400); +} +.card-desc { + font-size: 0.78rem; + color: var(--gray-400); + line-height: 1.45; + display: -webkit-box; + line-clamp: 2; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + flex: 1; +} +.card-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + border-top: 1px solid var(--gray-100); +} +.card-dates { + font-size: 0.7rem; + color: var(--text-muted); + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} +.card-date { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} +.card-date-label { + font-weight: 700; + color: var(--gray-600); + white-space: nowrap; +} +.card-date--publisher { + max-width: 280px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.date-icon { + width: 11px; + height: 11px; +} +.card-open-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 5px 12px; + border-radius: var(--radius-sm); + background: var(--primary-50); + color: var(--primary-600); + font-size: 0.78rem; + font-weight: 600; + transition: all var(--transition); +} +.card-open-btn:hover { + background: var(--primary-100); +} +.card-footer-actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} +.card-detail-btn { + display: inline-flex; + align-items: center; + padding: 5px 10px; + border-radius: var(--radius-sm); + background: var(--gray-100); + color: var(--gray-600); + font-size: 0.76rem; + font-weight: 700; + transition: all var(--transition); +} +.card-detail-btn:hover { + background: var(--gray-200); + color: var(--gray-800); +} +.open-icon { + width: 12px; + height: 12px; +} + +/* ── Listing Detail Drawer ─────────────────────────────────── */ +.detail-backdrop { + position: fixed; + inset: 0; + z-index: 250; + background: rgba(15, 23, 42, 0.24); +} +.detail-drawer { + position: fixed; + top: 0; + right: 0; + z-index: 260; + width: min(620px, 96vw); + height: 100vh; + background: var(--surface); + border-left: 1px solid var(--border); + box-shadow: var(--shadow-xl); + display: flex; + flex-direction: column; +} +.detail-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 22px; + border-bottom: 1px solid var(--border); +} +.detail-header-main { + min-width: 0; +} +.detail-provider { + display: inline-flex; + margin-bottom: 7px; + font-size: 0.68rem; + font-weight: 800; + color: var(--primary-700); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.detail-title { + font-size: 1rem; + line-height: 1.35; + font-weight: 800; + color: var(--gray-900); +} +.detail-close { + flex-shrink: 0; + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: var(--radius-sm); + color: var(--gray-500); + font-size: 1.35rem; + transition: all var(--transition); +} +.detail-close:hover { + background: var(--gray-100); + color: var(--gray-900); +} +.detail-body { + flex: 1; + overflow-y: auto; + padding: 18px 22px 24px; +} +.detail-state { + flex: 1; + display: grid; + place-items: center; + align-content: center; + gap: 10px; + color: var(--text-muted); +} +.detail-error { + margin: 20px; + padding: 14px; + border-radius: var(--radius-md); + background: var(--danger-50); + color: var(--danger-500); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.detail-error--inline { + margin: 0 0 14px; +} +.detail-summary { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; + padding-bottom: 18px; + border-bottom: 1px solid var(--border); + margin-bottom: 18px; +} +.detail-section { + padding: 16px 0; + border-bottom: 1px solid var(--gray-100); +} +.detail-section h3 { + font-size: 0.78rem; + font-weight: 800; + color: var(--gray-700); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 10px; +} +.detail-fields-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 12px; +} +.detail-field { + min-width: 0; + padding: 8px 10px; + border-radius: var(--radius-sm); + background: var(--gray-50); + border: 1px solid var(--gray-100); +} +.detail-field-label { + display: block; + font-size: 0.64rem; + font-weight: 800; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 2px; +} +.detail-field-value { + display: block; + font-size: 0.82rem; + font-weight: 700; + color: var(--gray-800); + overflow-wrap: anywhere; +} +.detail-features { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} +.detail-feature { + display: inline-flex; + align-items: center; + padding: 4px 9px; + border-radius: var(--radius-full); + background: var(--gray-100); + color: var(--gray-400); + border: 1px solid var(--gray-200); + font-size: 0.72rem; + font-weight: 800; +} +.detail-feature--yes { + background: var(--success-50); + color: var(--success-600); + border-color: rgba(16, 185, 129, 0.22); +} +.detail-text { + font-size: 0.84rem; + line-height: 1.65; + color: var(--gray-700); + white-space: pre-wrap; + margin-bottom: 10px; +} +.detail-attribute-groups { + display: flex; + flex-direction: column; + gap: 14px; +} +.detail-attribute-group h4 { + margin-bottom: 8px; + font-size: 0.72rem; + font-weight: 800; + color: var(--gray-500); +} +.detail-phone-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 10px; +} +.detail-phone { + display: inline-flex; + align-items: center; + width: fit-content; + padding: 6px 10px; + border-radius: var(--radius-sm); + background: var(--primary-50); + color: var(--primary-700); + font-size: 0.82rem; + font-weight: 800; +} +.detail-image-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} +.detail-image-grid img { + width: 100%; + aspect-ratio: 4/3; + object-fit: cover; + border-radius: var(--radius-sm); + background: var(--gray-100); +} +.detail-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 16px; +} + +/* ── Pagination ────────────────────────────────────────────── */ +.pagination { + display: flex; + align-items: center; + gap: 3px; +} +.page-btn { + min-width: 30px; + height: 30px; + display: grid; + place-items: center; + border-radius: var(--radius-sm); + font-size: 0.8125rem; + font-weight: 500; + color: var(--text-secondary); + transition: all var(--transition); +} +.page-btn:hover:not(:disabled) { + background: var(--gray-100); + color: var(--text); +} +.page-btn--active { + background: var(--primary-600); + color: #fff; + box-shadow: 0 1px 4px rgba(79, 70, 229, 0.25); +} +.page-btn--active:hover { + background: var(--primary-700); + color: #fff; +} +.page-btn:disabled { + opacity: 0.3; + cursor: default; +} +.page-ellipsis { + padding: 0 3px; + color: var(--gray-400); + font-size: 0.8rem; +} + +/* ── Settings Sidebar (Right) ──────────────────────────────── */ +.sidebar { + position: fixed; + top: 0; + right: 0; + z-index: 200; + width: var(--sidebar-w); + max-width: 92vw; + height: 100vh; + background: var(--surface); + border-left: 1px solid var(--border); + box-shadow: var(--shadow-xl); + transform: translateX(100%); + transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + flex-direction: column; +} +.sidebar--open { + transform: translateX(0); +} +.sidebar-overlay { + position: fixed; + inset: 0; + z-index: 199; + background: rgba(15, 23, 42, 0.18); +} +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} +.sidebar-title { + font-size: 1rem; + font-weight: 700; +} +.sidebar-close { + width: 30px; + height: 30px; + display: grid; + place-items: center; + border-radius: var(--radius-sm); + font-size: 1.15rem; + color: var(--text-muted); + transition: all var(--transition); +} +.sidebar-close:hover { + background: var(--gray-100); + color: var(--text); +} +.sidebar-body { + flex: 1; + overflow-y: auto; + padding: 20px; +} +.sidebar-footer { + padding: 16px 20px; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 8px; +} +.s-section { + margin-bottom: 24px; +} +.s-heading { + font-size: 0.875rem; + font-weight: 600; + margin-bottom: 4px; +} +.s-hint { + font-size: 0.78rem; + color: var(--text-muted); + margin-bottom: 10px; + line-height: 1.5; +} +.tag-input-wrap { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + min-height: 40px; + cursor: text; + transition: border-color var(--transition); +} +.tag-input-wrap:focus-within { + border-color: var(--primary-400); + box-shadow: 0 0 0 3px var(--primary-100); +} +.tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 10px; + border-radius: var(--radius-full); + background: var(--gray-100); + font-size: 0.78rem; + font-weight: 500; +} +.tag-remove { + display: inline-flex; + align-items: center; + margin-left: 2px; + color: var(--gray-400); + font-size: 0.73rem; +} +.tag-remove:hover { + color: var(--danger-500); +} +.tag-input { + flex: 1; + min-width: 100px; + border: none; + outline: none; + font-size: 0.78rem; + background: transparent; +} +.reset-confirm { + text-align: center; +} +.reset-confirm p { + font-size: 0.8125rem; + color: var(--danger-500); + font-weight: 500; + margin-bottom: 8px; +} +.reset-confirm-actions { + display: flex; + gap: 8px; + justify-content: center; +} + +/* ── ScrapeLog ─────────────────────────────────────────────── */ +.log-section { + margin-top: 36px; + padding: 20px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xs); +} +.log-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; +} +.log-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9375rem; + font-weight: 700; +} +.log-title-icon { + width: 16px; + height: 16px; + color: var(--primary-500); +} +.log-count { + font-size: 0.78rem; + color: var(--text-muted); +} +.log-table-wrap { + overflow-x: auto; +} +.log-table { + width: 100%; + border-collapse: collapse; + font-size: 0.78rem; +} +.log-table th { + text-align: left; + padding: 8px 12px; + font-weight: 600; + color: var(--text-muted); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; + border-bottom: 1px solid var(--border); +} +.log-table td { + padding: 8px 12px; + border-bottom: 1px solid var(--gray-100); +} +.source-label { + background: var(--gray-100); + padding: 2px 8px; + border-radius: var(--radius-full); + font-size: 0.7rem; + font-weight: 600; +} +.duration-label { + color: var(--text-muted); +} +.status-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 10px; + border-radius: var(--radius-full); + font-size: 0.7rem; + font-weight: 600; +} +.status-icon { + font-size: 0.65rem; +} +.badge--success { + background: var(--success-50); + color: var(--success-600); +} +.badge--error { + background: var(--danger-50); + color: var(--danger-500); +} +.badge--running { + background: var(--primary-50); + color: var(--primary-700); +} +.log-error { + display: block; + font-size: 0.7rem; + color: var(--danger-500); + margin-top: 3px; +} +.log-toggle { + margin-top: 10px; +} + +/* ── Toast ─────────────────────────────────────────────────── */ +.toast-container { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 1000; + display: flex; + flex-direction: column-reverse; + gap: 8px; + pointer-events: none; +} +.toast { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 18px; + border-radius: var(--radius-md); + background: var(--gray-900); + color: #fff; + font-size: 0.8125rem; + font-weight: 500; + box-shadow: var(--shadow-xl); + opacity: 0; + transform: translateY(8px) scale(0.96); + transition: all 250ms cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: auto; + backdrop-filter: blur(12px); +} +.toast--visible { + opacity: 1; + transform: translateY(0) scale(1); +} +.toast--success { + background: #065f46; +} +.toast--error { + background: #991b1b; +} +.toast--info { + background: var(--gray-800); +} +.toast-icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} +.toast-msg { + flex: 1; +} +.toast-action { + flex-shrink: 0; + padding: 3px 10px; + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.15); + color: #fff; + font-size: 0.78rem; + font-weight: 700; + border: 1px solid rgba(255, 255, 255, 0.2); + transition: background var(--transition); + white-space: nowrap; +} +.toast-action:hover { + background: rgba(255, 255, 255, 0.25); +} + +/* ── Error Boundary ────────────────────────────────────────── */ +.error-boundary { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 40px; +} +.error-boundary-content { + text-align: center; + max-width: 480px; +} +.error-icon { + font-size: 3rem; + margin-bottom: 16px; +} +.error-title { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 8px; +} +.error-message { + color: var(--text-muted); + margin-bottom: 20px; +} +.error-details { + text-align: left; + margin: 16px 0; +} +.error-details summary { + cursor: pointer; + font-size: 0.8125rem; + color: var(--text-muted); +} +.error-stack { + margin-top: 8px; + padding: 12px; + background: var(--gray-100); + border-radius: var(--radius-sm); + font-family: monospace; + font-size: 0.75rem; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; +} + +/* ── Responsive ────────────────────────────────────────────── */ +@media (max-width: 1024px) { + .agent-sidebar { + width: var(--agent-sidebar-collapsed-w); + } + .agent-sidebar .agent-sidebar-body { + display: none; + } + .agent-sidebar .agent-sidebar-collapsed-icons { + display: flex; + } + .agent-sidebar .agent-sidebar-title { + display: none; + } +} +@media (max-width: 768px) { + .main { + padding: 14px 14px 48px; + } + .listings-grid { + grid-template-columns: 1fr; + gap: 14px; + } + .detail-drawer { + width: 100vw; + } + .detail-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .detail-fields-grid { + grid-template-columns: 1fr; + } + .detail-image-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .filter-controls { + flex-direction: column; + } + .filter-group { + width: 100%; + } + .search-wrap { + min-width: 100%; + } + .tabs { + gap: 1px; + } + .tab { + padding: 5px 10px; + font-size: 0.78rem; + } + .btn-label-desktop { + display: none; + } + .header-subtitle { + display: none; + } + .header-inner { + padding: 0 14px; + } + .agent-sidebar { + position: fixed; + left: 0; + top: var(--header-h); + z-index: 150; + width: var(--agent-sidebar-collapsed-w); + box-shadow: var(--shadow-xl); + } + .agent-sidebar:not(.agent-sidebar--collapsed) { + width: min(300px, 82vw); + } + .active-agent-indicator { + font-size: 0.75rem; + padding: 6px 10px; + } +} + +@media (max-width: 480px) { + .tab-text { + display: none; + } + .tab { + padding: 6px 8px; + } + .card-meta { + flex-direction: column; + gap: 4px; + } +} + /* ── Confirm Dialog ────────────────────────────────────────── */ .cdialog-backdrop { - position: fixed; inset: 0; z-index: 500; - background: rgba(15,23,42,.35); + position: fixed; + inset: 0; + z-index: 500; + background: rgba(15, 23, 42, 0.35); backdrop-filter: blur(4px); - display: grid; place-items: center; + display: grid; + place-items: center; padding: 16px; animation: cdialog-fade-in 150ms ease; } -@keyframes cdialog-fade-in { from { opacity: 0; } to { opacity: 1; } } +@keyframes cdialog-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} .cdialog { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); - box-shadow: 0 20px 60px rgba(15,23,42,.18), 0 4px 16px rgba(15,23,42,.1); + box-shadow: + 0 20px 60px rgba(15, 23, 42, 0.18), + 0 4px 16px rgba(15, 23, 42, 0.1); padding: 24px; - width: 100%; max-width: 380px; - animation: cdialog-slide-in 160ms cubic-bezier(.34,1.4,.64,1); + width: 100%; + max-width: 380px; + animation: cdialog-slide-in 160ms cubic-bezier(0.34, 1.4, 0.64, 1); } @keyframes cdialog-slide-in { - from { opacity: 0; transform: scale(.94) translateY(-6px); } - to { opacity: 1; transform: scale(1) translateY(0); } + from { + opacity: 0; + transform: scale(0.94) translateY(-6px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } } -.cdialog-header { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; } +.cdialog-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} .cdialog-icon-wrap { flex-shrink: 0; - width: 38px; height: 38px; + width: 38px; + height: 38px; border-radius: var(--radius-sm); - display: grid; place-items: center; + display: grid; + place-items: center; +} +.cdialog-icon-wrap--danger { + background: var(--danger-50); + color: var(--danger-500); +} +.cdialog-icon-wrap--warning { + background: var(--warning-50); + color: var(--warning-500); } -.cdialog-icon-wrap--danger { background: var(--danger-50); color: var(--danger-500); } -.cdialog-icon-wrap--warning { background: var(--warning-50); color: var(--warning-500); } -.cdialog-title { font-size: .9375rem; font-weight: 700; color: var(--text); } +.cdialog-title { + font-size: 0.9375rem; + font-weight: 700; + color: var(--text); +} .cdialog-message { - font-size: .8125rem; color: var(--text-secondary); - line-height: 1.6; margin-bottom: 20px; + font-size: 0.8125rem; + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: 20px; padding-left: 50px; /* align with title */ } .cdialog-actions { - display: flex; gap: 8px; justify-content: flex-end; + display: flex; + gap: 8px; + justify-content: flex-end; } diff --git a/package.json b/package.json index 985d7da..0749fa2 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dev:client": "cd client && npm run dev", "build:client": "cd client && npm run build", "db": "node scripts/query-db.js", + "inspect:is24-detail": "node scripts/inspect-is24-detail-api.js", "test": "vitest run", "test:watch": "vitest", "lint": "eslint src/ tests/", diff --git a/src/db/database.js b/src/db/database.js index 1932d25..5d1bf05 100644 --- a/src/db/database.js +++ b/src/db/database.js @@ -76,6 +76,52 @@ db.exec(` FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE, FOREIGN KEY (search_config_id) REFERENCES search_configs(id) ON DELETE CASCADE ); + + CREATE TABLE IF NOT EXISTS listing_details ( + listing_id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + expose_id TEXT, + fetched_at TEXT NOT NULL, + source_version TEXT, + status TEXT NOT NULL DEFAULT 'ok', + error TEXT, + available_from TEXT, + available_from_source TEXT, + cold_rent TEXT, + warm_rent TEXT, + service_charge TEXT, + deposit TEXT, + price_per_sqm TEXT, + floor TEXT, + bedrooms TEXT, + bathrooms TEXT, + pets TEXT, + has_kitchen INTEGER, + has_cellar INTEGER, + has_balcony INTEGER, + has_garden INTEGER, + has_lift INTEGER, + barrier_free INTEGER, + construction_year TEXT, + condition TEXT, + heating_type TEXT, + energy_carrier TEXT, + energy_class TEXT, + energy_value TEXT, + description TEXT, + location_description TEXT, + address_line1 TEXT, + address_line2 TEXT, + lat REAL, + lon REAL, + agent_name TEXT, + contact_phone_numbers TEXT, + contact_available INTEGER, + images TEXT, + attribute_groups TEXT, + raw_detail_json TEXT, + FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE + ); `); // ── Migrations for existing DBs ─────────────────────────────────────────── @@ -124,6 +170,9 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_listings_link ON listings(link)'); // Index for fast agent lookups on the junction table db.exec('CREATE INDEX IF NOT EXISTS idx_listing_agents_config ON listing_agents(search_config_id)'); +// Index for ordering or inspecting detail fetch timestamps +db.exec('CREATE INDEX IF NOT EXISTS idx_listing_details_fetched ON listing_details(fetched_at)'); + // Migration: populate listing_agents from existing search_config_id values { const result = db @@ -510,6 +559,177 @@ export function getListingById(id) { return { ...row, agent_ids: row.agent_ids ? row.agent_ids.split(',').map(Number) : [] }; } +function jsonString(value) { + return value == null ? null : JSON.stringify(value); +} + +function jsonValue(value, fallback) { + if (!value) return fallback; + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function normalizeDetailRow(row) { + if (!row) return null; + return { + ...row, + contact_phone_numbers: jsonValue(row.contact_phone_numbers, []), + images: jsonValue(row.images, []), + attribute_groups: jsonValue(row.attribute_groups, []), + raw_detail_json: jsonValue(row.raw_detail_json, null), + }; +} + +export function upsertListingDetail(detail) { + const fetchedAt = detail.fetched_at ?? new Date().toISOString(); + + db.prepare( + ` + INSERT INTO listing_details ( + listing_id, provider, expose_id, fetched_at, source_version, status, error, + available_from, available_from_source, + cold_rent, warm_rent, service_charge, deposit, price_per_sqm, + floor, bedrooms, bathrooms, pets, + has_kitchen, has_cellar, has_balcony, has_garden, has_lift, barrier_free, + construction_year, condition, heating_type, energy_carrier, energy_class, energy_value, + description, location_description, address_line1, address_line2, lat, lon, + agent_name, contact_phone_numbers, contact_available, images, attribute_groups, raw_detail_json + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(listing_id) DO UPDATE SET + provider = excluded.provider, + expose_id = excluded.expose_id, + fetched_at = excluded.fetched_at, + source_version = excluded.source_version, + status = excluded.status, + error = excluded.error, + available_from = excluded.available_from, + available_from_source = excluded.available_from_source, + cold_rent = excluded.cold_rent, + warm_rent = excluded.warm_rent, + service_charge = excluded.service_charge, + deposit = excluded.deposit, + price_per_sqm = excluded.price_per_sqm, + floor = excluded.floor, + bedrooms = excluded.bedrooms, + bathrooms = excluded.bathrooms, + pets = excluded.pets, + has_kitchen = excluded.has_kitchen, + has_cellar = excluded.has_cellar, + has_balcony = excluded.has_balcony, + has_garden = excluded.has_garden, + has_lift = excluded.has_lift, + barrier_free = excluded.barrier_free, + construction_year = excluded.construction_year, + condition = excluded.condition, + heating_type = excluded.heating_type, + energy_carrier = excluded.energy_carrier, + energy_class = excluded.energy_class, + energy_value = excluded.energy_value, + description = excluded.description, + location_description = excluded.location_description, + address_line1 = excluded.address_line1, + address_line2 = excluded.address_line2, + lat = excluded.lat, + lon = excluded.lon, + agent_name = excluded.agent_name, + contact_phone_numbers = excluded.contact_phone_numbers, + contact_available = excluded.contact_available, + images = excluded.images, + attribute_groups = excluded.attribute_groups, + raw_detail_json = excluded.raw_detail_json + `, + ).run( + detail.listing_id, + detail.provider ?? 'immoscout24', + detail.expose_id ?? null, + fetchedAt, + detail.source_version ?? null, + detail.status ?? 'ok', + detail.error ?? null, + detail.available_from ?? null, + detail.available_from_source ?? null, + detail.cold_rent ?? null, + detail.warm_rent ?? null, + detail.service_charge ?? null, + detail.deposit ?? null, + detail.price_per_sqm ?? null, + detail.floor ?? null, + detail.bedrooms ?? null, + detail.bathrooms ?? null, + detail.pets ?? null, + detail.has_kitchen ?? null, + detail.has_cellar ?? null, + detail.has_balcony ?? null, + detail.has_garden ?? null, + detail.has_lift ?? null, + detail.barrier_free ?? null, + detail.construction_year ?? null, + detail.condition ?? null, + detail.heating_type ?? null, + detail.energy_carrier ?? null, + detail.energy_class ?? null, + detail.energy_value ?? null, + detail.description ?? null, + detail.location_description ?? null, + detail.address_line1 ?? null, + detail.address_line2 ?? null, + detail.lat ?? null, + detail.lon ?? null, + detail.agent_name ?? null, + jsonString(detail.contact_phone_numbers ?? []), + detail.contact_available ?? null, + jsonString(detail.images ?? []), + jsonString(detail.attribute_groups ?? []), + jsonString(detail.raw_detail_json ?? null), + ); + + if ( + /^\d{4}-\d{2}-\d{2}$/.test(detail.available_from ?? '') || + detail.available_from === 'sofort' + ) { + db.prepare('UPDATE listings SET available_from = COALESCE(available_from, ?) WHERE id = ?').run( + detail.available_from, + detail.listing_id, + ); + } + + if (Array.isArray(detail.images) && detail.images.length > 0) { + db.prepare('UPDATE listings SET images = COALESCE(images, ?) WHERE id = ?').run( + jsonString(detail.images), + detail.listing_id, + ); + } +} + +export function markListingDetailError({ + listingId, + provider = 'immoscout24', + exposeId = null, + error, +}) { + db.prepare( + ` + INSERT INTO listing_details (listing_id, provider, expose_id, fetched_at, status, error) + VALUES (?, ?, ?, ?, 'error', ?) + ON CONFLICT(listing_id) DO UPDATE SET + provider = excluded.provider, + expose_id = excluded.expose_id, + fetched_at = excluded.fetched_at, + status = 'error', + error = excluded.error + `, + ).run(listingId, provider, exposeId, new Date().toISOString(), error ?? null); +} + +export function getListingDetailById(listingId) { + const row = db.prepare('SELECT * FROM listing_details WHERE listing_id = ?').get(listingId); + return normalizeDetailRow(row); +} + export function resetAll() { db.exec('DELETE FROM listing_agents'); db.exec('DELETE FROM listings'); diff --git a/src/providers/immoscout24/detail.js b/src/providers/immoscout24/detail.js new file mode 100644 index 0000000..7b5dc83 --- /dev/null +++ b/src/providers/immoscout24/detail.js @@ -0,0 +1,244 @@ +import { normalizeAvailableFrom } from '../../utils.js'; + +const REQUEST_HEADERS = { + 'User-Agent': 'ImmoScout_28.3_34.0_._', + Accept: 'application/json', +}; + +const AVAILABLE_ATTR_LABEL = /(bezugsfrei|bezug|einzug|frei\s*ab|verfügbar\s*ab|verfuegbar\s*ab)/i; + +export function getExposeIdFromUrl(url) { + return String(url ?? '').match(/\/expose\/(\d+)/)?.[1] ?? null; +} + +function cleanText(value) { + if (value == null) return null; + const text = String(value) + .replace(/\u00a0/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + return text || null; +} + +function cleanBlockText(value) { + if (value == null) return null; + const text = String(value) + .replace(/\u00a0/g, ' ') + .replace(/\r\n?/g, '\n') + .replace(/[ \t\f\v]+/g, ' ') + .replace(/[ \t]*\n[ \t]*/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); + return text || null; +} + +function cleanLabel(value) { + return cleanText(value)?.replace(/:$/, '') ?? null; +} + +export function normalizeDetailAvailableFrom(value, baseDate = new Date()) { + const normalized = normalizeAvailableFrom(value); + if (normalized !== value) return normalized; + + const raw = cleanText(value); + const match = raw?.match(/^(\d{1,2})\.(\d{1,2})\.$/); + if (!match) return normalized; + + let year = baseDate.getFullYear(); + const candidate = new Date(year, Number(match[2]) - 1, Number(match[1])); + const today = new Date(baseDate.getFullYear(), baseDate.getMonth(), baseDate.getDate()); + if (candidate < today) year++; + return `${year}-${match[2].padStart(2, '0')}-${match[1].padStart(2, '0')}`; +} + +export function extractAvailableFromText(text) { + const value = cleanText(text); + if (!value) return null; + + if ( + /(?:bezugsfrei|verfügbar|verfuegbar|frei|einzug|bezug|ab)\s*(?:ab\s*)?sofort/i.test(value) || + /sofort\s*(?:verfügbar|verfuegbar|frei|bezugsfrei)/i.test(value) + ) { + return 'sofort'; + } + + if ( + /(?:bezugsfrei|bezug|einzug|verfügbar|verfuegbar|frei)[^.]{0,60}nach vereinbarung/i.test(value) + ) { + return 'nach Vereinbarung'; + } + + const patterns = [ + /(?:bezugsfrei|bezug|einzug|verfügbar|verfuegbar|frei\s*ab|ab dem|ab|zum)\D{0,30}(\d{1,2}\.\d{1,2}\.(?:\d{2,4})?)/i, + /(\d{1,2}\.\d{1,2}\.(?:\d{2,4})?)\s*(?:verfügbar|verfuegbar|frei|bezugsfrei)/i, + /(?:bezugsfrei|bezug|einzug|verfügbar|verfuegbar|frei\s*ab|ab dem|ab|zum)\D{0,30}(\d{4}-\d{2}-\d{2})/i, + ]; + + for (const pattern of patterns) { + const match = value.match(pattern); + if (match) return match[1]; + } + + return null; +} + +export function extractAvailableFromExposeDetail(detail) { + for (const section of detail?.sections ?? []) { + for (const attr of section.attributes ?? []) { + const label = cleanLabel(attr.label); + const text = cleanText(attr.text); + if (text && AVAILABLE_ATTR_LABEL.test(label)) return { value: text, source: label }; + } + } + + for (const [sectionIndex, section] of (detail?.sections ?? []).entries()) { + for (const key of ['title', 'text', 'subText']) { + const found = extractAvailableFromText(section[key]); + if (found) return { value: found, source: `sections[${sectionIndex}].${key}` }; + } + } + + return { value: null, source: null }; +} + +function attributeGroups(detail) { + return (detail?.sections ?? []) + .filter((section) => Array.isArray(section.attributes) && section.attributes.length > 0) + .map((section) => ({ + type: section.type ?? null, + title: cleanText(section.title) || section.type || null, + attributes: section.attributes.map((attr) => ({ + label: cleanLabel(attr.label), + value: + cleanText(attr.text) ?? cleanText(attr.value) ?? (attr.type === 'CHECK' ? true : null), + type: attr.type ?? null, + })), + })); +} + +function flattenAttributes(groups) { + return groups.flatMap((group) => group.attributes); +} + +function attrValue(attrs, pattern) { + const found = attrs.find((attr) => attr.label && pattern.test(attr.label)); + return found?.value ?? null; +} + +function attrBool(attrs, pattern) { + const found = attrs.find((attr) => attr.label && pattern.test(attr.label)); + if (!found) return null; + if (found.value === true) return 1; + if (/^(ja|yes|true|vorhanden)$/i.test(String(found.value ?? ''))) return 1; + if (/^(nein|no|false)$/i.test(String(found.value ?? ''))) return 0; + return null; +} + +function yesNo(value) { + if (value == null || value === '') return null; + if (/^(y|yes|true|1|ja)$/i.test(String(value))) return 1; + if (/^(n|no|false|0|nein)$/i.test(String(value))) return 0; + return null; +} + +function textSection(detail, titlePattern) { + const section = (detail?.sections ?? []).find( + (s) => s.type === 'TEXT_AREA' && titlePattern.test(cleanText(s.title) ?? ''), + ); + return cleanBlockText(section?.text); +} + +function collectImages(detail) { + return (detail?.sections ?? []) + .flatMap((section) => section.media ?? []) + .filter((item) => item.type === 'PICTURE') + .map((item) => cleanText(item.fullImageUrl) ?? cleanText(item.previewImageUrl)) + .filter(Boolean); +} + +function collectPhoneNumbers(detail) { + return (detail?.contact?.phoneNumbers ?? []) + .map((phone) => ({ + type: cleanText(phone.label ?? phone.type), + text: cleanText(phone.text), + })) + .filter((phone) => phone.text); +} + +export function parseExposeDetail(detail, { listingId = null, exposeId = null } = {}) { + const groups = attributeGroups(detail); + const attrs = flattenAttributes(groups); + const targeting = detail?.adTargetingParameters ?? {}; + const tracking = detail?.tracking?.parameters ?? {}; + const mergedParams = { ...tracking, ...targeting }; + const availability = extractAvailableFromExposeDetail(detail); + const mapSection = (detail?.sections ?? []).find((section) => section.type === 'MAP'); + + return { + listing_id: listingId, + provider: 'immoscout24', + expose_id: exposeId ?? cleanText(detail?.header?.id), + source_version: 'is24-mobile-expose-v1', + status: 'ok', + error: null, + available_from: normalizeDetailAvailableFrom(availability.value), + available_from_source: availability.source, + cold_rent: attrValue(attrs, /kaltmiete/i) ?? mergedParams.obj_baseRent ?? null, + warm_rent: attrValue(attrs, /warmmiete|gesamtmiete/i) ?? mergedParams.obj_totalRent ?? null, + service_charge: attrValue(attrs, /nebenkosten/i) ?? mergedParams.obj_serviceCharge ?? null, + deposit: attrValue(attrs, /kaution|genossenschaft/i), + price_per_sqm: attrValue(attrs, /preis\/m²|preis\/m2/i), + floor: attrValue(attrs, /etage/i), + bedrooms: attrValue(attrs, /schlafzimmer/i), + bathrooms: attrValue(attrs, /badezimmer/i), + pets: attrValue(attrs, /haustiere/i) ?? mergedParams.obj_petsAllowed ?? null, + has_kitchen: attrBool(attrs, /einbauküche|küche/i) ?? yesNo(mergedParams.obj_hasKitchen), + has_cellar: attrBool(attrs, /keller/i) ?? yesNo(mergedParams.obj_cellar), + has_balcony: attrBool(attrs, /balkon/i) ?? yesNo(mergedParams.obj_balcony), + has_garden: attrBool(attrs, /garten/i) ?? yesNo(mergedParams.obj_garden), + has_lift: attrBool(attrs, /aufzug|lift/i) ?? yesNo(mergedParams.obj_lift), + barrier_free: attrBool(attrs, /stufenlos|barriere/i), + construction_year: attrValue(attrs, /baujahr/i), + condition: attrValue(attrs, /zustand/i) ?? mergedParams.obj_condition ?? null, + heating_type: attrValue(attrs, /heizungsart/i), + energy_carrier: attrValue(attrs, /energieträger|energietraeger/i), + energy_class: attrValue(attrs, /energieeffizienzklasse/i), + energy_value: attrValue(attrs, /endenergie/i), + description: textSection(detail, /objektbeschreibung|beschreibung/i), + location_description: textSection(detail, /lage/i), + address_line1: cleanText(mapSection?.addressLine1), + address_line2: cleanText(mapSection?.addressLine2), + lat: mapSection?.location?.lat ?? null, + lon: mapSection?.location?.lng ?? null, + agent_name: cleanText(detail?.contact?.contactData?.agent?.name), + contact_phone_numbers: collectPhoneNumbers(detail), + contact_available: + detail?.contact?.mailButtonState === 'active' || detail?.contact?.callButtonState === 'active' + ? 1 + : 0, + images: collectImages(detail), + attribute_groups: groups, + raw_detail_json: detail, + }; +} + +export async function fetchExposeDetail(exposeId, { signal } = {}) { + const response = await fetch(`https://api.mobile.immobilienscout24.de/expose/${exposeId}`, { + headers: REQUEST_HEADERS, + signal, + }); + + if (!response.ok) { + throw new Error(`IS24 detail API failed for expose ${exposeId}: HTTP ${response.status}`); + } + + return response.json(); +} + +export async function fetchAndParseExposeDetail(listing, opts = {}) { + const exposeId = getExposeIdFromUrl(listing?.link) ?? listing?.expose_id; + if (!exposeId) throw new Error('No IS24 expose id found'); + + const raw = await fetchExposeDetail(exposeId, opts); + return parseExposeDetail(raw, { listingId: listing.id, exposeId }); +} diff --git a/src/providers/immoscout24/index.js b/src/providers/immoscout24/index.js index 2a4bb46..a33d007 100644 --- a/src/providers/immoscout24/index.js +++ b/src/providers/immoscout24/index.js @@ -327,7 +327,7 @@ export async function scrape(inputUrl, maxPages = 10, opts = {}) { * * @param {string} inputUrl * @param {number} maxPages - * @param {{ signal?: AbortSignal, onProgress?: Function, log?: Function }} opts + * @param {{ signal?: AbortSignal, onProgress?: Function, knownIds?: Set, log?: Function }} opts * @returns {Promise<{ mobileUrl: string, hitCount: number|string, pageCount: number, targetPages: number, pages: Array<{ pageNum: number, listings: object[] }> }>} */ export async function scrapePages(inputUrl, maxPages = 10, opts = {}) { diff --git a/src/providers/kleinanzeigen/detail.js b/src/providers/kleinanzeigen/detail.js new file mode 100644 index 0000000..121093c --- /dev/null +++ b/src/providers/kleinanzeigen/detail.js @@ -0,0 +1,347 @@ +import { normalizeAvailableFrom } from '../../utils.js'; + +const REQUEST_HEADERS = { + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', + 'Accept-Language': 'de-DE,de;q=0.9', + Accept: 'text/html', +}; + +const MONTHS = { + januar: '01', + februar: '02', + maerz: '03', + märz: '03', + april: '04', + mai: '05', + juni: '06', + juli: '07', + august: '08', + september: '09', + oktober: '10', + november: '11', + dezember: '12', +}; + +export function getAdIdFromUrl(url) { + return String(url ?? '').match(/\/(\d+)-\d+-\d+(?:[/?#]|$)/)?.[1] ?? null; +} + +function cleanText(value) { + if (value == null) return null; + const text = decodeHtml(String(value)) + .replace(/\u00a0/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + return text || null; +} + +function cleanBlockText(value) { + if (value == null) return null; + const text = decodeHtml(String(value)) + .replace(/\u00a0/g, ' ') + .replace(/\r\n?/g, '\n') + .replace(/[ \t\f\v]+/g, ' ') + .replace(/[ \t]*\n[ \t]*/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); + return text || null; +} + +function stripTags(value) { + return cleanText( + String(value ?? '') + .replace(//gi, '\n') + .replace(/<\/p\s*>/gi, '\n') + .replace(/<[^>]+>/g, ' '), + ); +} + +function stripBlockTags(value) { + return cleanBlockText( + String(value ?? '') + .replace(//gi, '\n') + .replace(/<\/p\s*>/gi, '\n\n') + .replace(/<[^>]+>/g, ' '), + ); +} + +function decodeHtml(value) { + return String(value ?? '') + .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16))) + .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10))) + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>'); +} + +function extractFirst(html, pattern) { + const match = html.match(pattern); + return match ? stripTags(match[1]) : null; +} + +function extractFirstBlock(html, pattern) { + const match = html.match(pattern); + return match ? stripBlockTags(match[1]) : null; +} + +function metaPropertyContent(html, property) { + for (const match of html.matchAll(/]*>/gi)) { + const tag = match[0]; + const tagProperty = tag.match(/\bproperty=["']([^"']+)["']/i)?.[1]; + if (tagProperty !== property) continue; + const content = tag.match(/\bcontent=["']([^"']*)["']/i)?.[1]; + return cleanText(content); + } + return null; +} + +function numberValue(value) { + const text = cleanText(value); + if (!text) return null; + const number = Number(text.replace(',', '.')); + return Number.isFinite(number) ? number : null; +} + +function sectionHtml(html, id) { + const start = html.indexOf(`id="${id}"`); + if (start < 0) return ''; + const next = html.indexOf('id="viewad-', start + id.length); + return html.slice(start, next > start ? next : start + 12000); +} + +function extractAttributes(html) { + const details = sectionHtml(html, 'viewad-details'); + const attributes = []; + const pattern = + /]*class="[^"]*addetailslist--detail[^"]*"[^>]*>([\s\S]*?)]*class="[^"]*addetailslist--detail--value[^"]*"[^>]*>([\s\S]*?)<\/span>[\s\S]*?<\/li>/gi; + + for (const match of details.matchAll(pattern)) { + const label = stripTags(match[1])?.replace(/:$/, ''); + const value = stripTags(match[2]); + if (label && value) attributes.push({ label, value, type: 'TEXT' }); + } + + return attributes; +} + +function extractFeatures(html) { + const config = sectionHtml(html, 'viewad-configuration'); + return [...config.matchAll(/]*class="[^"]*checktag[^"]*"[^>]*>([\s\S]*?)<\/li>/gi)] + .map((match) => stripTags(match[1])) + .filter(Boolean); +} + +function extractImages(html) { + const urls = new Set(); + + for (const match of html.matchAll(/ + + + `; + + it('extracts the ad id from a Kleinanzeigen URL', () => { + expect( + getAdIdFromUrl( + 'https://www.kleinanzeigen.de/s-anzeige/schoene-wohnung/3364804426-199-4308', + ), + ).toBe('3364804426'); + }); + + it('parses detail attributes, features and normalized availability', () => { + const detail = parseKleinanzeigenDetailHtml(HTML, { + listingId: 'listing-1', + url: 'https://www.kleinanzeigen.de/s-anzeige/schoene-wohnung/3364804426-199-4308', + }); + + expect(detail.provider).toBe('kleinanzeigen'); + expect(detail.expose_id).toBe('3364804426'); + expect(detail.available_from).toBe('2026-04-01'); + expect(detail.available_from_source).toBe('attribute:Verfügbar ab'); + expect(detail.warm_rent).toBe('700 €'); + expect(detail.deposit).toBe('500€'); + expect(detail.description).toBe('Ab dem 01.04. frei.\n\n500€ Kaution.\nWeitere Zeile.'); + expect(detail.address_line1).toBe('60320 Frankfurt am Main - Westend'); + expect(detail.lat).toBe(50.1354734); + expect(detail.lon).toBe(8.6718962); + expect(detail.agent_name).toBe('Semih K'); + expect(detail.has_cellar).toBe(1); + expect(detail.has_kitchen).toBe(1); + expect(detail.barrier_free).toBe(1); + expect(detail.attribute_groups[0].attributes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: 'Wohnfläche', value: '18 m²' }), + expect.objectContaining({ label: 'Zimmer', value: '3' }), + ]), + ); + expect(detail.images).toHaveLength(1); + }); + + it('keeps coordinates null when the page does not expose map metadata', () => { + const html = HTML.replace('', '').replace( + '', + '', + ); + const detail = parseKleinanzeigenDetailHtml(html); + expect(detail.lat).toBeNull(); + expect(detail.lon).toBeNull(); + }); + + it('does not expose a hidden/login-required phone number', () => { + const detail = parseKleinanzeigenDetailHtml(HTML); + expect(detail.contact_phone_numbers).toEqual([]); + expect(detail.raw_detail_json.login_required_for_phone).toBe(true); + }); + + it('extracts a visible phone number when the page exposes one', () => { + const html = HTML.replace('hasVisiblePhoneNumber: false', 'hasVisiblePhoneNumber: true').replace( + "adPhoneNumber: ''", + "adPhoneNumber: '+49 151 12345678'", + ); + const detail = parseKleinanzeigenDetailHtml(html); + expect(detail.contact_phone_numbers).toEqual([{ type: 'Telefon', text: '+49 151 12345678' }]); + }); +}); From cd0ee71ffb4d6daae232c391925d30e0ddf1b601 Mon Sep 17 00:00:00 2001 From: chenkel-data Date: Sat, 6 Jun 2026 10:56:07 +0200 Subject: [PATCH 2/3] feat: add location map --- README.md | 38 ++- client/package-lock.json | 11 +- client/package.json | 7 +- client/src/App.jsx | 16 +- client/src/api.js | 1 + client/src/components/ListingDetailDrawer.jsx | 103 +++++++ client/src/components/ListingMap.jsx | 97 ++++++ client/src/hooks/useListings.js | 47 ++- client/src/index.css | 63 ++++ src/db/database.js | 81 ++++- src/routes/listings.js | 20 +- src/services/mapLocationService.js | 106 +++++++ src/services/scraperService.js | 2 + src/utils/mapLocation.js | 288 ++++++++++++++++++ tests/mapLocation.test.js | 160 ++++++++++ 15 files changed, 1013 insertions(+), 27 deletions(-) create mode 100644 client/src/components/ListingMap.jsx create mode 100644 src/services/mapLocationService.js create mode 100644 src/utils/mapLocation.js create mode 100644 tests/mapLocation.test.js diff --git a/README.md b/README.md index e806f5e..4f2bc96 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ Currently supported: **ImmobilienScout24** & **Kleinanzeigen** – more provider - 🔍 **Search agents** – multiple agents, each with its own search URL from the provider and a page limit - 🚫 **Blacklist** – per click or globally via keywords - ❤️ **Favorites** – persisted even if the agent is deleted +- 🧾 **Listing detail view** – open a listing to see provider details: rent breakdown, descriptions, amenities, energy data, address, contact info, image gallery, and original attribute groups +- 🗺️ **Location map** – shows the listing location with Leaflet/OpenStreetMap, and uses cached Nominatim lookups when exact provider coordinates are missing or listings only expose postcode, district, or city-level address data - 🔄 **Scraping** – manual, on startup, or via cron; with pagination and duplicate filtering - 🧩 **Provider system** – currently: **ImmobilienScout24** & **Kleinanzeigen**; more planned - 🗄️ **Local** – SQLite @@ -58,6 +60,7 @@ Everything is optional – works without a `.env` file. | `SCRAPE_ON_START` | `false` | Scrape on startup | | `SCRAPE_CRON_ENABLED` | `false` | Cron-based scraping | | `SCRAPE_CRON` | `*/30 * * * *` | Cron expression | +| `NOMINATIM_USER_AGENT` | `Immo-Pilot/1.0 local detail map resolver` | User-Agent for OpenStreetMap Nominatim geocoding requests | Keyword blacklist in `config/default.json`: @@ -76,11 +79,34 @@ One row per unique listing, deduplicated by provider ID. Stores all scraped cont ``` id · source · title · price · size · rooms · address · description · publisher -link · image · images · provider · listing_type +lat · lon · link · image · images · provider · listing_type is_seen · is_favorite · is_blacklisted · blacklisted_at · favorited_at first_seen · last_seen · listed_at · available_from · scrape_rank ``` +### Table `listing_details` +Cached provider detail data for the listing detail view. Details are fetched on demand for ImmobilienScout24 and Kleinanzeigen. Stores normalized values for availability, rent, amenities, energy data, descriptions, address, coordinates, contact data, gallery image URLs, grouped attributes, and the raw provider response. + +``` +listing_id · provider · expose_id · fetched_at · source_version · status · error +available_from · available_from_source +cold_rent · warm_rent · service_charge · deposit · price_per_sqm +floor · bedrooms · bathrooms · pets +has_kitchen · has_cellar · has_balcony · has_garden · has_lift · barrier_free +construction_year · condition · heating_type · energy_carrier · energy_class · energy_value +description · location_description · address_line1 · address_line2 · lat · lon +agent_name · contact_phone_numbers · contact_available +images · attribute_groups · raw_detail_json +``` + +### Table `map_location_cache` +Cache for map lookups used by the listing detail view. Provider coordinates are used directly when they are exact; listings without exact coordinates, or with only postcode, district, or city-level address data, are resolved through Nominatim and cached by query, including misses. + +``` +query · fetched_at · status · source · label · precision +lat · lon · bbox_json · geometry_geojson +``` + ### Table `search_configs` One row per search agent. Defines provider, listing type, page limit, search URL, and enabled state. @@ -116,7 +142,7 @@ id · listing_id · url · created_at ``` client/ React frontend (Vite) - src/components/ UI components (cards, filter, sidebar, …) + src/components/ UI components (cards, filter, sidebar, detail panel, map, …) src/hooks/ Data fetching & state (useListings, useScraper, …) src/ Express backend @@ -124,7 +150,8 @@ src/ Express backend routes/ listings, scraper, configs scrapers/engine.js Playwright runner + CSS selector config providers/ Adapter registry + provider implementations - services/ Scrape orchestration per agent + services/ Scrape orchestration per agent, map-location resolution + utils/ Shared parsing and map-location helpers db/database.js node:sqlite – schema, migrations, upserts config/default.json Global blacklist keyword config @@ -154,7 +181,10 @@ DELETE /api/listings/clear-favorites/:configId DELETE /api/listings/clear-blacklist Clear blacklist flags DELETE /api/listings/clear-blacklist/:configId Clear blacklist flags for one agent -GET /api/listings/:id/images Fetch or return cached gallery images +GET /api/listings/:id/images Fetch or return cached gallery image URLs +GET /api/listings/:id/details Fetch or return cached listing details +POST /api/listings/:id/details/refresh Refresh provider detail data +GET /api/listings/:id/map-location Resolve a normalized map location POST /api/listings/batch-images Batch image fetch for listing cards GET /api/configs Get agents diff --git a/client/package-lock.json b/client/package-lock.json index 0aa7173..f2aa7ae 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,13 +1,14 @@ { - "name": "immo-app-client", + "name": "immo-pilot-client", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "immo-app-client", + "name": "immo-pilot-client", "version": "0.0.0", "dependencies": { + "leaflet": "^1.9.4", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -1377,6 +1378,12 @@ "node": ">=6" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", diff --git a/client/package.json b/client/package.json index 5ce230e..e533f1c 100644 --- a/client/package.json +++ b/client/package.json @@ -4,12 +4,13 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", - "build": "vite build", + "dev": "vite", + "build": "vite build", "preview": "vite preview" }, "dependencies": { - "react": "^18.2.0", + "leaflet": "^1.9.4", + "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { diff --git a/client/src/App.jsx b/client/src/App.jsx index e772d9d..bad1565 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -73,6 +73,7 @@ export default function App() { loadStats, loadConfigStats, handleSeen, + handleMarkSeen, handleFavorite, handleBlacklist, handleUnblacklist, @@ -216,6 +217,19 @@ export default function App() { setMaxAvailableFrom(''); }, [loadListings]); + const handleOpenDetails = useCallback( + (listing) => { + if (!listing) return; + setDetailListing({ ...listing, is_seen: 1 }); + if (!listing.is_seen) { + handleMarkSeen(listing.id).catch((err) => { + showToast?.(`Konnte nicht als gesehen markieren: ${err.message}`, 'error'); + }); + } + }, + [handleMarkSeen, showToast], + ); + const isProviderFilterActive = !activeConfigId && activeTab === TABS.ALL; const uiFilteredListings = useMemo(() => { @@ -589,7 +603,7 @@ export default function App() { onFavorite={handleFavorite} onBlacklist={handleBlacklist} onUnblacklist={handleUnblacklist} - onDetails={setDetailListing} + onDetails={handleOpenDetails} onScrape={handleHeaderScrape} canScrape={canScrape} allFiltered={filtered} diff --git a/client/src/api.js b/client/src/api.js index d4a1ea8..de7cd74 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -59,6 +59,7 @@ export const api = { toggleFav: (id) => api.patch(`/api/listings/${id}/favorite`), getImages: (id) => api.get(`/api/listings/${id}/images`), getDetails: (id) => api.get(`/api/listings/${id}/details`), + getMapLocation: (id) => api.get(`/api/listings/${id}/map-location`), refreshDetails: (id) => api.post(`/api/listings/${id}/details/refresh`), batchImages: (ids) => api.post('/api/listings/batch-images', { ids }), getRuns: () => api.get('/api/listings/runs'), diff --git a/client/src/components/ListingDetailDrawer.jsx b/client/src/components/ListingDetailDrawer.jsx index c34381b..567b883 100644 --- a/client/src/components/ListingDetailDrawer.jsx +++ b/client/src/components/ListingDetailDrawer.jsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { api } from '../api.js'; import { formatAvailableFrom } from '../utils/formatting.js'; +import ListingMap from './ListingMap.jsx'; function Field({ label, value }) { if (value === null || value === undefined || value === '') return null; @@ -56,14 +57,49 @@ function visibleAttributeGroups(detail) { .filter((group) => group.attributes.length > 0); } +function mapLinks(location) { + if (!location) return null; + const lat = Number(location.lat); + const lon = Number(location.lon); + if (Number.isFinite(lat) && Number.isFinite(lon)) { + const marker = `${lat.toFixed(6)},${lon.toFixed(6)}`; + return { + osmUrl: `https://www.openstreetmap.org/?mlat=${lat.toFixed(6)}&mlon=${lon.toFixed(6)}#map=15/${lat.toFixed(6)}/${lon.toFixed(6)}`, + googleUrl: `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(marker)}`, + }; + } + + const query = location.query || location.label; + if (!query) return null; + const encodedQuery = encodeURIComponent(query); + return { + osmUrl: `https://www.openstreetmap.org/search?query=${encodedQuery}`, + googleUrl: `https://www.google.com/maps/search/?api=1&query=${encodedQuery}`, + }; +} + +function mapPrecisionLabel(precision) { + return { + exact: 'Exakte Lage', + street: 'Ungefähre Lage', + postcode: 'PLZ-Gebiet', + district: 'Stadtteil', + city: 'Stadtgebiet', + }[precision] ?? 'Kartenlage'; +} + export default function ListingDetailDrawer({ listing, open, onClose, showToast }) { const [loading, setLoading] = useState(false); const [refreshing, setRefreshing] = useState(false); const [payload, setPayload] = useState(null); + const [mapLocation, setMapLocation] = useState(null); + const [mapLoading, setMapLoading] = useState(false); + const [mapError, setMapError] = useState(''); const [error, setError] = useState(''); const detail = payload?.detail ?? null; const currentListing = payload?.listing ?? listing; + const links = mapLinks(mapLocation); const detailError = payload?.detail_error ?? null; const detailSize = findAttribute(detail, /wohnfläche|wohnflaeche|gesamtfläche/i); const detailRooms = findAttribute(detail, /^zimmer$/i); @@ -75,6 +111,9 @@ export default function ListingDetailDrawer({ listing, open, onClose, showToast setLoading(true); setError(''); setPayload(null); + setMapLocation(null); + setMapError(''); + setMapLoading(false); api.listings .getDetails(listing.id) @@ -93,6 +132,30 @@ export default function ListingDetailDrawer({ listing, open, onClose, showToast }; }, [open, listing?.id]); + useEffect(() => { + if (!open || !listing?.id || !payload) return; + let cancelled = false; + setMapLoading(true); + setMapError(''); + setMapLocation(null); + + api.listings + .getMapLocation(listing.id) + .then((data) => { + if (!cancelled) setMapLocation(data.map_location ?? null); + }) + .catch((err) => { + if (!cancelled) setMapError(err.message); + }) + .finally(() => { + if (!cancelled) setMapLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [open, listing?.id, payload]); + const images = useMemo(() => { if (Array.isArray(detail?.images) && detail.images.length > 0) return detail.images; try { @@ -107,6 +170,8 @@ export default function ListingDetailDrawer({ listing, open, onClose, showToast const handleRefresh = async () => { setRefreshing(true); setError(''); + setMapLocation(null); + setMapError(''); try { const data = await api.listings.refreshDetails(listing.id); setPayload(data); @@ -233,6 +298,44 @@ export default function ListingDetailDrawer({ listing, open, onClose, showToast

+ {(mapLoading || mapError || mapLocation) && ( +
+

Karte

+ {mapLoading &&

Karte wird geladen…

} + {mapError &&

{mapError}

} + {mapLocation && ( +
+ +
+ + {mapPrecisionLabel(mapLocation.precision)}: {mapLocation.label} + + {links && ( + + )} +
+
+ )} +
+ )} +

Kontakt

diff --git a/client/src/components/ListingMap.jsx b/client/src/components/ListingMap.jsx new file mode 100644 index 0000000..cf186b1 --- /dev/null +++ b/client/src/components/ListingMap.jsx @@ -0,0 +1,97 @@ +import { useEffect, useRef } from 'react'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; + +function boundsFromBbox(bbox) { + if (!bbox) return null; + const { south, north, west, east } = bbox; + if (![south, north, west, east].every(Number.isFinite)) return null; + return [ + [south, west], + [north, east], + ]; +} + +function radiusForPrecision(precision) { + return { + exact: 40, + street: 250, + postcode: 700, + district: 1000, + city: 2500, + }[precision] ?? 600; +} + +export default function ListingMap({ location }) { + const containerRef = useRef(null); + + useEffect(() => { + if (!containerRef.current || !location) return undefined; + + const map = L.map(containerRef.current, { + attributionControl: true, + scrollWheelZoom: false, + zoomControl: true, + }); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: '© OpenStreetMap', + }).addTo(map); + + const shapeStyle = { + color: '#2563eb', + weight: 2, + opacity: 0.9, + fillColor: '#3b82f6', + fillOpacity: 0.16, + }; + + const lat = Number(location.lat); + const lon = Number(location.lon); + const hasPoint = Number.isFinite(lat) && Number.isFinite(lon); + const canRenderArea = ['postcode', 'district', 'city'].includes(location.precision); + let fitted = false; + + if (canRenderArea && location.geometry_geojson) { + const layer = L.geoJSON(location.geometry_geojson, { style: shapeStyle }).addTo(map); + const bounds = layer.getBounds(); + if (bounds.isValid()) { + map.fitBounds(bounds, { padding: [14, 14], maxZoom: 15 }); + fitted = true; + } + } else { + const bboxBounds = canRenderArea ? boundsFromBbox(location.bbox) : null; + if (bboxBounds) { + const rectangle = L.rectangle(bboxBounds, shapeStyle).addTo(map); + map.fitBounds(rectangle.getBounds(), { padding: [14, 14], maxZoom: 15 }); + fitted = true; + } + } + + if (hasPoint) { + if (location.precision === 'exact') { + L.marker([lat, lon], { + icon: L.divIcon({ + className: 'detail-map-pin', + iconSize: [18, 18], + }), + }).addTo(map); + } else if (!fitted) { + const circle = L.circle([lat, lon], { + ...shapeStyle, + radius: radiusForPrecision(location.precision), + }).addTo(map); + map.fitBounds(circle.getBounds(), { padding: [14, 14], maxZoom: 15 }); + fitted = true; + } + } + + if (!fitted && hasPoint) map.setView([lat, lon], location.precision === 'exact' ? 17 : 14); + setTimeout(() => map.invalidateSize(), 0); + + return () => map.remove(); + }, [location]); + + return
; +} diff --git a/client/src/hooks/useListings.js b/client/src/hooks/useListings.js index 70b8502..c2bf2ba 100644 --- a/client/src/hooks/useListings.js +++ b/client/src/hooks/useListings.js @@ -68,19 +68,11 @@ export function useListings(showToast) { await Promise.all([loadStats(), loadConfigStats()]); }, [loadStats, loadConfigStats]); - const handleSeen = useCallback(async (id) => { - const current = listings.find((l) => l.id === id); - if (!current) return; + const applySeenState = useCallback((current, isSeen) => { + if (!current || Boolean(current.is_seen) === Boolean(isSeen)) return; - const willBeSeen = !current.is_seen; - if (willBeSeen) { - await api.listings.markSeen(id); - } else { - await api.listings.markUnseen(id); - } - - setListings((prev) => prev.map((l) => (l.id === id ? { ...l, is_seen: willBeSeen ? 1 : 0 } : l))); - const delta = willBeSeen ? -1 : 1; + setListings((prev) => prev.map((l) => (l.id === current.id ? { ...l, is_seen: isSeen ? 1 : 0 } : l))); + const delta = isSeen ? -1 : 1; setStats((prev) => ({ ...prev, unseen: Math.max(0, (prev.unseen ?? 0) + delta) })); const agentIds = current.agent_ids || []; if (agentIds.length > 0) { @@ -94,7 +86,34 @@ export function useListings(showToast) { return next; }); } - }, [listings]); + }, []); + + const handleSeen = useCallback(async (id) => { + const current = listings.find((l) => l.id === id); + if (!current) return; + + const willBeSeen = !current.is_seen; + if (willBeSeen) { + await api.listings.markSeen(id); + } else { + await api.listings.markUnseen(id); + } + + applySeenState(current, willBeSeen); + }, [applySeenState, listings]); + + const handleMarkSeen = useCallback(async (id) => { + const current = listings.find((l) => l.id === id); + if (!current || current.is_seen) return; + + applySeenState(current, true); + try { + await api.listings.markSeen(id); + } catch (err) { + applySeenState({ ...current, is_seen: 1 }, false); + throw err; + } + }, [applySeenState, listings]); const handleFavorite = useCallback(async (id) => { const current = listings.find((l) => l.id === id); @@ -207,7 +226,7 @@ export function useListings(showToast) { return { listings, setListings, loading, stats, configStats, orphanStats, loadListings, loadStats, loadConfigStats, - handleSeen, handleFavorite, handleBlacklist, handleUnblacklist, + handleSeen, handleMarkSeen, handleFavorite, handleBlacklist, handleUnblacklist, handleMarkAllSeen, handleReset, handleResetConfig, handleClearFavorites, handleClearFavoritesByConfig, handleClearBlacklist, handleClearBlacklistByConfig, diff --git a/client/src/index.css b/client/src/index.css index 19e1648..58d9886 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1942,6 +1942,59 @@ a { font-size: 0.82rem; font-weight: 800; } +.detail-map { + display: flex; + flex-direction: column; + gap: 9px; +} +.detail-map-status { + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--gray-50); + color: var(--text-muted); + font-size: 0.82rem; + font-weight: 700; +} +.detail-map-status--error { + background: var(--danger-50); + color: var(--danger-500); +} +.detail-map-leaflet { + width: 100%; + height: 240px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--gray-100); + overflow: hidden; +} +.detail-map-pin { + width: 18px; + height: 18px; + border: 3px solid #fff; + border-radius: 50%; + background: var(--primary-600); + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.45), 0 6px 14px rgba(15, 23, 42, 0.22); +} +.detail-map-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.detail-map-label { + min-width: 0; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 700; + overflow-wrap: anywhere; +} +.detail-map-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} .detail-image-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -2377,6 +2430,16 @@ a { .detail-fields-grid { grid-template-columns: 1fr; } + .detail-map-leaflet { + height: 220px; + } + .detail-map-footer { + align-items: flex-start; + flex-direction: column; + } + .detail-map-actions { + justify-content: flex-start; + } .detail-image-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/src/db/database.js b/src/db/database.js index 5d1bf05..9fd7b1c 100644 --- a/src/db/database.js +++ b/src/db/database.js @@ -30,6 +30,8 @@ db.exec(` size TEXT, rooms TEXT, address TEXT, + lat REAL, + lon REAL, description TEXT, publisher TEXT, link TEXT NOT NULL, @@ -122,6 +124,19 @@ db.exec(` raw_detail_json TEXT, FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE ); + + CREATE TABLE IF NOT EXISTS map_location_cache ( + query TEXT PRIMARY KEY, + fetched_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'ok', + source TEXT, + label TEXT, + precision TEXT, + lat REAL, + lon REAL, + bbox_json TEXT, + geometry_geojson TEXT + ); `); // ── Migrations for existing DBs ─────────────────────────────────────────── @@ -137,6 +152,8 @@ safeAlter('ALTER TABLE listings ADD COLUMN rooms TEXT'); safeAlter('ALTER TABLE listings ADD COLUMN publisher TEXT'); safeAlter('ALTER TABLE listings ADD COLUMN listed_at TEXT'); safeAlter('ALTER TABLE listings ADD COLUMN images TEXT'); +safeAlter('ALTER TABLE listings ADD COLUMN lat REAL'); +safeAlter('ALTER TABLE listings ADD COLUMN lon REAL'); safeAlter("ALTER TABLE listings ADD COLUMN provider TEXT DEFAULT 'kleinanzeigen'"); safeAlter("ALTER TABLE listings ADD COLUMN listing_type TEXT DEFAULT 'miete'"); safeAlter('ALTER TABLE listings ADD COLUMN search_config_id INTEGER'); @@ -173,6 +190,9 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_listing_agents_config ON listing_agents( // Index for ordering or inspecting detail fetch timestamps db.exec('CREATE INDEX IF NOT EXISTS idx_listing_details_fetched ON listing_details(fetched_at)'); +// Cache lookup for address/district geocoding +db.exec('CREATE INDEX IF NOT EXISTS idx_map_location_cache_status ON map_location_cache(status)'); + // Migration: populate listing_agents from existing search_config_id values { const result = db @@ -398,13 +418,15 @@ export function upsertListing(l) { db.prepare( ` - INSERT INTO listings (id, source, provider, listing_type, title, price, size, rooms, address, description, publisher, link, image, is_blacklisted, listed_at, available_from, first_seen, last_seen, scrape_rank) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO listings (id, source, provider, listing_type, title, price, size, rooms, address, lat, lon, description, publisher, link, image, is_blacklisted, listed_at, available_from, first_seen, last_seen, scrape_rank) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET price = excluded.price, size = excluded.size, rooms = COALESCE(excluded.rooms, rooms), address = excluded.address, + lat = COALESCE(excluded.lat, lat), + lon = COALESCE(excluded.lon, lon), description = excluded.description, publisher = COALESCE(excluded.publisher, publisher), image = excluded.image, @@ -423,6 +445,8 @@ export function upsertListing(l) { l.size ?? null, l.rooms ?? null, l.address ?? null, + l.lat ?? null, + l.lon ?? null, l.description ?? null, l.publisher ?? null, l.link, @@ -730,6 +754,59 @@ export function getListingDetailById(listingId) { return normalizeDetailRow(row); } +function normalizeMapLocationRow(row) { + if (!row) return null; + return { + status: row.status, + source: row.source, + query: row.query, + label: row.label, + precision: row.precision, + lat: row.lat, + lon: row.lon, + bbox: jsonValue(row.bbox_json, null), + geometry_geojson: jsonValue(row.geometry_geojson, null), + fetched_at: row.fetched_at, + }; +} + +export function getCachedMapLocation(query) { + const row = db.prepare('SELECT * FROM map_location_cache WHERE query = ?').get(query); + return normalizeMapLocationRow(row); +} + +export function upsertMapLocationCache(location) { + db.prepare( + ` + INSERT INTO map_location_cache ( + query, fetched_at, status, source, label, precision, lat, lon, bbox_json, geometry_geojson + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(query) DO UPDATE SET + fetched_at = excluded.fetched_at, + status = excluded.status, + source = excluded.source, + label = excluded.label, + precision = excluded.precision, + lat = excluded.lat, + lon = excluded.lon, + bbox_json = excluded.bbox_json, + geometry_geojson = excluded.geometry_geojson + `, + ).run( + location.query, + location.fetched_at ?? new Date().toISOString(), + location.status ?? 'ok', + location.source ?? null, + location.label ?? null, + location.precision ?? null, + location.lat ?? null, + location.lon ?? null, + jsonString(location.bbox ?? null), + jsonString(location.geometry_geojson ?? null), + ); +} + export function resetAll() { db.exec('DELETE FROM listing_agents'); db.exec('DELETE FROM listings'); diff --git a/src/routes/listings.js b/src/routes/listings.js index 26e88b2..7f46fb2 100644 --- a/src/routes/listings.js +++ b/src/routes/listings.js @@ -35,6 +35,7 @@ import { fetchAndParseKleinanzeigenDetail, getAdIdFromUrl, } from '../providers/kleinanzeigen/detail.js'; +import { resolveListingMapLocation } from '../services/mapLocationService.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CONFIG_PATH = path.join(__dirname, '..', '..', 'config', 'default.json'); @@ -388,6 +389,19 @@ router.get( }), ); +// GET /api/listings/:id/map-location +router.get( + '/:id/map-location', + asyncHandler(async (req, res) => { + const listing = getListingById(req.params.id); + if (!listing) return res.status(404).json({ error: 'Listing not found' }); + + const detail = getListingDetailById(listing.id); + const mapLocation = await resolveListingMapLocation(listing, detail); + res.json({ map_location: mapLocation }); + }), +); + // POST /api/listings/:id/details/refresh router.post( '/:id/details/refresh', @@ -395,7 +409,11 @@ router.post( const listing = getListingById(req.params.id); if (!listing) return res.status(404).json({ error: 'Listing not found' }); const detail = await refreshListingDetail(listing); - res.json({ listing: getListingById(listing.id), detail: serializeDetail(detail) }); + const updatedListing = getListingById(listing.id); + res.json({ + listing: updatedListing, + detail: serializeDetail(detail), + }); }), ); diff --git a/src/services/mapLocationService.js b/src/services/mapLocationService.js new file mode 100644 index 0000000..22958da --- /dev/null +++ b/src/services/mapLocationService.js @@ -0,0 +1,106 @@ +import { getCachedMapLocation, upsertMapLocationCache } from '../db/database.js'; +import { + addressLooksRegional, + cleanMapAddress, + mapAddressCandidates, + mapLocationFromCoordinates, + selectBestNominatimLocation, +} from '../utils/mapLocation.js'; + +const NOMINATIM_URL = 'https://nominatim.openstreetmap.org/search'; +const NOMINATIM_USER_AGENT = + process.env.NOMINATIM_USER_AGENT ?? 'Immo-Pilot/1.0 local detail map resolver'; +const MISS_CACHE_SOURCE = 'nominatim-v3'; + +let lastNominatimRequestAt = 0; +let nominatimQueue = Promise.resolve(); + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function detailAddress(detail, listing) { + const parts = [detail?.address_line1, detail?.address_line2].map(cleanMapAddress).filter(Boolean); + if (parts.length > 0) return parts.join(', '); + return cleanMapAddress(listing?.address); +} + +function buildNominatimUrl(query) { + const url = new URL(NOMINATIM_URL); + url.search = new URLSearchParams({ + format: 'jsonv2', + q: query, + polygon_geojson: addressLooksRegional(query) ? '1' : '0', + addressdetails: '1', + limit: '5', + countrycodes: 'de', + }); + return url; +} + +async function queuedNominatimSearch(query) { + const run = nominatimQueue.then(async () => { + const waitMs = 1100 - (Date.now() - lastNominatimRequestAt); + if (waitMs > 0) await sleep(waitMs); + lastNominatimRequestAt = Date.now(); + + const response = await fetch(buildNominatimUrl(query), { + headers: { + 'User-Agent': NOMINATIM_USER_AGENT, + 'Accept-Language': 'de-DE,de;q=0.9', + Accept: 'application/json', + }, + signal: AbortSignal.timeout(6000), + }); + + if (!response.ok) throw new Error(`Nominatim failed: HTTP ${response.status}`); + return response.json(); + }); + + nominatimQueue = run.catch(() => {}); + return run; +} + +async function resolveAddressLocation(address) { + for (const query of mapAddressCandidates(address)) { + const cached = getCachedMapLocation(query); + if (cached?.status === 'ok') return cached; + if (cached?.status === 'miss' && (cached.source === MISS_CACHE_SOURCE || addressLooksRegional(query))) { + continue; + } + + const rows = await queuedNominatimSearch(query); + const location = selectBestNominatimLocation(rows, query); + if (location) { + upsertMapLocationCache(location); + return location; + } + + upsertMapLocationCache({ query, status: 'miss', source: MISS_CACHE_SOURCE }); + } + + return null; +} + +export async function resolveListingMapLocation(listing, detail) { + const address = detailAddress(detail, listing); + const coordinateLocation = mapLocationFromCoordinates({ + lat: detail?.lat ?? listing?.lat, + lon: detail?.lon ?? listing?.lon, + address, + source: detail?.lat != null && detail?.lon != null ? 'detail' : 'listing', + }); + if (coordinateLocation?.precision === 'exact') return coordinateLocation; + + if (!address) return coordinateLocation; + try { + if (!coordinateLocation || addressLooksRegional(address)) { + const addressLocation = await resolveAddressLocation(address); + if (addressLocation) return addressLocation; + } + return coordinateLocation ?? (await resolveAddressLocation(address)); + } catch (err) { + console.warn(`[map] Geocoding failed for "${address}": ${err.message}`); + return coordinateLocation ?? null; + } +} diff --git a/src/services/scraperService.js b/src/services/scraperService.js index 8923589..56d0967 100644 --- a/src/services/scraperService.js +++ b/src/services/scraperService.js @@ -99,6 +99,8 @@ export async function runScrapeForConfig(searchConfig, hooks = {}) { size: listing.size ?? null, rooms: listing.rooms ?? null, address: listing.address ?? null, + lat: listing.lat ?? null, + lon: listing.lon ?? null, description: listing.description ?? null, publisher: listing.publisher ?? null, link: listing.link, diff --git a/src/utils/mapLocation.js b/src/utils/mapLocation.js new file mode 100644 index 0000000..27b2471 --- /dev/null +++ b/src/utils/mapLocation.js @@ -0,0 +1,288 @@ +const HIDDEN_ADDRESS_PATTERNS = [ + /die vollst(?:ä|ae)ndige adresse der immobilie erh(?:ä|ae)ltst du vom anbieter\.?/gi, + /die genaue adresse (?:der immobilie )?erh(?:ä|ae)ltst du vom anbieter\.?/gi, + /\((?:unvollst(?:ä|ae)ndige adresse|adresse nicht vollständig)\)/gi, +]; + +const DISTANCE_PATTERN = /\(\s*\d+(?:[,.]\d+)?\s*km\s*\)/gi; +const POSTCODE_PATTERN = /\b\d{5}\b/; +const LETTER_PATTERN = /[A-Za-zÄÖÜäöüß]/; +const HOUSE_NUMBER_PATTERN = /(?:^|[\s/-])[1-9]\d{0,4}[a-z]?\b/i; +const HOUSE_NUMBER_TOKEN_PATTERN = /^[1-9]\d{0,4}[a-z]?$/i; + +const REGIONAL_TYPES = new Set([ + 'postcode', + 'postal_code', + 'city_district', + 'district', + 'suburb', + 'neighbourhood', + 'neighborhood', + 'quarter', + 'borough', +]); + +const CITY_TYPES = new Set(['city', 'town', 'village', 'municipality']); +const EXACT_TYPES = new Set(['house', 'building', 'residential', 'apartments']); +const STREET_TYPES = new Set(['road', 'street']); +const ROAD_ADDRESS_KEYS = ['road', 'pedestrian', 'footway', 'path', 'cycleway']; +const COMPONENT_STOPWORDS = new Set(['am', 'an', 'der', 'die', 'das', 'den', 'dem', 'des', 'im', 'in']); + +function unique(values) { + return [...new Set(values.filter(Boolean))]; +} + +export function cleanMapAddress(value) { + let text = String(value ?? ''); + for (const pattern of HIDDEN_ADDRESS_PATTERNS) text = text.replace(pattern, ' '); + return text + .replace(DISTANCE_PATTERN, ' ') + .replace(/\s*\/\s*/g, '/') + .replace(/\s+/g, ' ') + .replace(/\s+,/g, ',') + .replace(/,\s*,+/g, ',') + .replace(/^[,\s]+|[,\s]+$/g, '') + .trim(); +} + +function splitAddressParts(address) { + return cleanMapAddress(address) + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + .filter((part) => !/^(deutschland|germany|baden-württemberg|baden-wuerttemberg)$/i.test(part)); +} + +function withoutPostcode(value) { + return cleanMapAddress(value).replace(POSTCODE_PATTERN, '').trim(); +} + +function firstAddressPart(address) { + return cleanMapAddress(address).split(',')[0]?.trim() ?? ''; +} + +function firstPartHasHouseNumber(address) { + const firstPart = firstAddressPart(address); + return ( + LETTER_PATTERN.test(firstPart) && + !POSTCODE_PATTERN.test(firstPart) && + HOUSE_NUMBER_PATTERN.test(firstPart) + ); +} + +function normalizeForMatch(value) { + return cleanMapAddress(value) + .toLowerCase() + .replace(/ß/g, 'ss') + .replace(/\bstr\./g, 'strasse') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + +function normalizedTokens(value) { + const normalized = normalizeForMatch(value); + return normalized ? normalized.split(' ').filter(Boolean) : []; +} + +function componentTokens(value) { + return normalizedTokens(value).filter((token) => !COMPONENT_STOPWORDS.has(token)); +} + +function queryContainsComponent(query, component) { + const normalizedQuery = normalizeForMatch(query); + const normalizedComponent = normalizeForMatch(component); + if (!normalizedQuery || !normalizedComponent) return false; + if (normalizedQuery.includes(normalizedComponent)) return true; + + const queryTokens = new Set(normalizedTokens(query)); + const tokens = componentTokens(component); + return tokens.length > 0 && tokens.every((token) => queryTokens.has(token)); +} + +function queryContainsHouseNumber(query, houseNumber) { + const houseNumberTokens = normalizedTokens(houseNumber); + if (houseNumberTokens.length === 0) return false; + + const firstPartTokens = new Set(normalizedTokens(firstAddressPart(query))); + return houseNumberTokens.some( + (token) => HOUSE_NUMBER_TOKEN_PATTERN.test(token) && firstPartTokens.has(token), + ); +} + +export function mapAddressCandidates(address) { + const cleaned = cleanMapAddress(address); + if (!cleaned) return []; + + const parts = splitAddressParts(cleaned); + const firstPart = parts[0] ?? cleaned; + const city = parts.length > 1 ? parts[parts.length - 1] : null; + const intent = queryIntent(cleaned); + + if (intent.hasHouseNumber) { + return unique([cleaned, firstPart && city ? `${firstPart}, ${city}` : null]); + } + + const postcode = cleaned.match(POSTCODE_PATTERN)?.[0] ?? null; + const district = withoutPostcode(firstPart); + const slashDistricts = district.includes('/') + ? district.split('/').map((part) => cleanMapAddress(part)) + : []; + + return unique([ + cleaned, + district && city ? `${district}, ${city}` : null, + ...slashDistricts.map((part) => (city ? `${part}, ${city}` : part)), + postcode && city ? `${postcode} ${city}` : null, + city, + ]); +} + +export function validCoordinate(value) { + if (value === null || value === undefined || value === '') return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +function queryIntent(query) { + const cleaned = cleanMapAddress(query); + const postcode = cleaned.match(POSTCODE_PATTERN)?.[0] ?? null; + const hasHouseNumberInFirstPart = firstPartHasHouseNumber(cleaned); + return { + cleaned, + postcode, + hasHouseNumber: hasHouseNumberInFirstPart, + hasRealHouseNumber: hasHouseNumberInFirstPart, + isRegional: + !hasHouseNumberInFirstPart && (Boolean(postcode) || cleaned.includes('/') || cleaned.includes(',')), + }; +} + +export function addressLooksRegional(address) { + return queryIntent(address).isRegional; +} + +function precisionForResult(row, intent) { + const type = String(row?.addresstype ?? row?.type ?? '').toLowerCase(); + if (!type) return null; + + const road = firstAddressValue(row, ROAD_ADDRESS_KEYS); + const houseNumber = row?.address?.house_number; + const matchesRoad = road ? queryContainsComponent(intent.cleaned, road) : false; + const matchesHouseNumber = houseNumber + ? queryContainsHouseNumber(intent.cleaned, houseNumber) + : false; + + if (matchesRoad && matchesHouseNumber && EXACT_TYPES.has(type)) return 'exact'; + if (matchesRoad && (STREET_TYPES.has(type) || EXACT_TYPES.has(type))) return 'street'; + if (intent.hasHouseNumber) return null; + if (REGIONAL_TYPES.has(type)) return type === 'postcode' || type === 'postal_code' ? 'postcode' : 'district'; + if (CITY_TYPES.has(type)) return 'city'; + return null; +} + +function firstAddressValue(row, keys) { + for (const key of keys) { + const value = row?.address?.[key]; + if (value) return String(value); + } + return null; +} + +function parseBbox(value) { + if (!Array.isArray(value) || value.length !== 4) return null; + const [south, north, west, east] = value.map(Number); + if (![south, north, west, east].every(Number.isFinite)) return null; + return { south, north, west, east }; +} + +function regionalGeometry(row) { + const type = row?.geojson?.type; + if (type === 'Polygon' || type === 'MultiPolygon') return row.geojson; + return null; +} + +function precisionUsesArea(precision) { + return precision === 'postcode' || precision === 'district' || precision === 'city'; +} + +function precisionRank(precision) { + return { + exact: 50, + street: 40, + district: 30, + postcode: 25, + city: 10, + }[precision] ?? 0; +} + +export function mapLocationFromCoordinates({ lat, lon, address = null, label = null, source = 'provider' }) { + const parsedLat = validCoordinate(lat); + const parsedLon = validCoordinate(lon); + if ( + parsedLat === null || + parsedLon === null || + parsedLat < -90 || + parsedLat > 90 || + parsedLon < -180 || + parsedLon > 180 + ) { + return null; + } + + const cleanedAddress = cleanMapAddress(address); + const approximate = + !queryIntent(cleanedAddress).hasRealHouseNumber || /(?:^|[\s,])0(?:[\s,]|$)/.test(cleanedAddress); + + return { + status: 'ok', + source, + query: cleanedAddress || null, + label: label || cleanedAddress || `${parsedLat.toFixed(5)}, ${parsedLon.toFixed(5)}`, + precision: approximate ? 'street' : 'exact', + lat: parsedLat, + lon: parsedLon, + bbox: null, + geometry_geojson: null, + }; +} + +export function mapLocationFromNominatimResult(row, query) { + const intent = queryIntent(query); + const precision = precisionForResult(row, intent); + if (!precision) return null; + + const lat = validCoordinate(row?.lat); + const lon = validCoordinate(row?.lon); + if (lat === null || lon === null) return null; + const useArea = precisionUsesArea(precision); + + return { + status: 'ok', + source: 'nominatim', + query: intent.cleaned, + label: row.display_name ?? intent.cleaned, + precision, + lat, + lon, + bbox: useArea ? parseBbox(row.boundingbox) : null, + geometry_geojson: useArea ? regionalGeometry(row) : null, + }; +} + +export function selectBestNominatimLocation(rows, query) { + const candidates = (rows ?? []) + .map((row) => mapLocationFromNominatimResult(row, query)) + .filter(Boolean) + .map((location) => ({ + location, + score: + precisionRank(location.precision) + + (location.geometry_geojson ? 8 : 0) + + (location.bbox ? 3 : 0), + })) + .sort((a, b) => b.score - a.score); + + return candidates[0]?.location ?? null; +} diff --git a/tests/mapLocation.test.js b/tests/mapLocation.test.js new file mode 100644 index 0000000..ab1284f --- /dev/null +++ b/tests/mapLocation.test.js @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest'; +import { + addressLooksRegional, + cleanMapAddress, + mapAddressCandidates, + mapLocationFromCoordinates, + selectBestNominatimLocation, +} from '../src/utils/mapLocation.js'; + +describe('map location helpers', () => { + it('removes provider placeholder text from incomplete addresses', () => { + expect( + cleanMapAddress( + 'Die vollständige Adresse der Immobilie erhältst du vom Anbieter., 68165 Schwetzingerstadt/Oststadt, Mannheim', + ), + ).toBe('68165 Schwetzingerstadt/Oststadt, Mannheim'); + }); + + it('generates useful candidates for postcode and slash districts', () => { + expect(mapAddressCandidates('68165 Schwetzingerstadt/Oststadt, Mannheim')).toEqual([ + '68165 Schwetzingerstadt/Oststadt, Mannheim', + 'Schwetzingerstadt/Oststadt, Mannheim', + 'Schwetzingerstadt, Mannheim', + 'Oststadt, Mannheim', + '68165 Mannheim', + 'Mannheim', + ]); + }); + + it('recognizes postcode/district strings as regional addresses', () => { + expect(addressLooksRegional('68165 Schwetzingerstadt/Oststadt, Mannheim')).toBe(true); + expect(addressLooksRegional('Käfertaler Straße 12, Mannheim')).toBe(false); + expect(addressLooksRegional('Mönchwörthstraße 19, 68199 Neckarau, Mannheim')).toBe(false); + expect(addressLooksRegional('Am Hungerberg 10, 69434 Hirschhorn, Hirschhorn (Neckar)')).toBe( + false, + ); + }); + + it('does not fall back from exact-looking addresses to city polygons', () => { + expect(mapAddressCandidates('Am Hungerberg 10, 69434 Hirschhorn, Hirschhorn (Neckar)')).toEqual([ + 'Am Hungerberg 10, 69434 Hirschhorn, Hirschhorn (Neckar)', + 'Am Hungerberg 10, Hirschhorn (Neckar)', + ]); + }); + + it('selects a regional polygon instead of a random building for regional queries', () => { + const location = selectBestNominatimLocation( + [ + { + display_name: 'Nationaltheater Mannheim, 9, Mozartstraße, Oststadt, Mannheim', + type: 'construction', + addresstype: 'building', + lat: '49.4883241', + lon: '8.4777193', + boundingbox: ['49.4881370', '49.4886271', '8.4767593', '8.4786758'], + address: { house_number: '9', road: 'Mozartstraße' }, + geojson: { type: 'Polygon', coordinates: [] }, + }, + { + display_name: 'Schwetzingerstadt/Oststadt, Mannheim, Baden-Württemberg, 68165, Deutschland', + type: 'administrative', + addresstype: 'city_district', + lat: '49.4815281', + lon: '8.4894105', + boundingbox: ['49.4690924', '49.4940040', '8.4678293', '8.5084720'], + geojson: { type: 'Polygon', coordinates: [] }, + }, + ], + '68165 Schwetzingerstadt/Oststadt, Mannheim', + ); + + expect(location).toEqual( + expect.objectContaining({ + precision: 'district', + label: 'Schwetzingerstadt/Oststadt, Mannheim, Baden-Württemberg, 68165, Deutschland', + }), + ); + }); + + it('does not expose building polygons for exact address queries', () => { + const location = selectBestNominatimLocation( + [ + { + display_name: 'Mönchwörthstraße 19, Neckarau, Mannheim, Baden-Württemberg, 68199, Deutschland', + type: 'apartments', + addresstype: 'building', + lat: '49.4494600', + lon: '8.4865000', + boundingbox: ['49.4493000', '49.4496200', '8.4863000', '8.4867000'], + address: { house_number: '19', road: 'Mönchwörthstraße' }, + geojson: { type: 'Polygon', coordinates: [] }, + }, + ], + 'Mönchwörthstraße 19, 68199 Neckarau, Mannheim', + ); + + expect(location).toEqual( + expect.objectContaining({ + precision: 'exact', + bbox: null, + geometry_geojson: null, + }), + ); + }); + + it('maps exact addresses without a street suffix to exact points', () => { + const location = selectBestNominatimLocation( + [ + { + display_name: '10, Am Hungerberg, Ersheim, Hirschhorn, Hessen, 69434, Deutschland', + type: 'yes', + addresstype: 'building', + lat: '49.4505322', + lon: '8.9070176', + boundingbox: ['49.4503939', '49.4505792', '8.9068359', '8.9071497'], + address: { house_number: '10', road: 'Am Hungerberg' }, + geojson: { type: 'Polygon', coordinates: [] }, + }, + ], + 'Am Hungerberg 10, 69434 Hirschhorn, Hirschhorn (Neckar)', + ); + + expect(location).toEqual( + expect.objectContaining({ + precision: 'exact', + bbox: null, + geometry_geojson: null, + }), + ); + }); + + it('uses Nominatim road fields for street-level results', () => { + const location = selectBestNominatimLocation( + [ + { + display_name: 'Am Hungerberg, Hirschhorn, Hessen, 69434, Deutschland', + type: 'residential', + addresstype: 'road', + lat: '49.4502000', + lon: '8.9069000', + boundingbox: ['49.4499000', '49.4506000', '8.9065000', '8.9073000'], + address: { road: 'Am Hungerberg' }, + }, + ], + 'Am Hungerberg, Hirschhorn', + ); + + expect(location).toEqual( + expect.objectContaining({ + precision: 'street', + bbox: null, + geometry_geojson: null, + }), + ); + }); + + it('does not convert missing coordinates into a 0/0 map point', () => { + expect(mapLocationFromCoordinates({ lat: null, lon: null })).toBeNull(); + }); +}); From eae2ecc5f85aefe3743a8f6d9d61601053e2e3ca Mon Sep 17 00:00:00 2001 From: chenkel-data Date: Sat, 6 Jun 2026 11:07:07 +0200 Subject: [PATCH 3/3] fix formatting --- src/providers/kleinanzeigen/detail.js | 21 +++++++++--- src/services/mapLocationService.js | 5 ++- src/utils/mapLocation.js | 46 ++++++++++++++++++++------- tests/kleinanzeigen.test.js | 12 +++---- tests/mapLocation.test.js | 16 ++++++---- 5 files changed, 69 insertions(+), 31 deletions(-) diff --git a/src/providers/kleinanzeigen/detail.js b/src/providers/kleinanzeigen/detail.js index 121093c..5ede56c 100644 --- a/src/providers/kleinanzeigen/detail.js +++ b/src/providers/kleinanzeigen/detail.js @@ -138,7 +138,9 @@ function extractFeatures(html) { function extractImages(html) { const urls = new Set(); - for (const match of html.matchAll(/