// screens-immo.jsx — Immobilien dashboard, search builder wizard, search results
// ─── DASHBOARD ──────────────────────────────────────────────────────────
function DashboardScreen({ ctx }) {
const { listings, searches, navigate, user, taskLimit } = ctx;
const purchaseSearches = searches.filter(s => s.category === 'kauf');
const bestDeals = listings
.filter(l => l.type === 'kauf' && l.aiScore)
.sort((a, b) => b.aiScore - a.aiScore)
.slice(0, 4);
const scoredListings = listings.filter(l => l.aiScore);
const stats = {
active: purchaseSearches.filter(s => s.active).length,
matches: purchaseSearches.reduce((sum, s) => sum + s.matches, 0),
newToday: purchaseSearches.reduce((sum, s) => sum + s.newToday, 0),
avgScore: scoredListings.length ? Math.round(scoredListings.reduce((s, l) => s + l.aiScore, 0) / scoredListings.length) : 0,
};
return (
{/* Header */}
{greeting()}, {firstName(user)}
Kaufobjekte
navigate('searchBuilder')}>
{/* Stats grid */}
{/* Active searches */}
{purchaseSearches.length === 0 ? (
}
title="Noch keine Kaufsuche"
subtitle="Legen Sie eine Suche an, damit ImmoBot neue Treffer sammelt."
cta={}
/>
) : purchaseSearches.map(s => (
navigate('results', { searchId: s.id })} />
))}
{/* Best deals */}
navigate('results', { searchId: purchaseSearches[0]?.id })}>
{bestDeals.map((l, i) => (
navigate('listing', { id: l.id })} />
))}
{user?.isAdmin && (
{taskLimit?.current_tasks ?? purchaseSearches.filter(s => s.active).length} aktive Suchen
{taskLimit?.remaining_evaluations == null ? 'KI unbegrenzt' : `${taskLimit.remaining_evaluations} KI heute übrig`}
)}
);
}
function firstName(user) {
const value = (user?.name || user?.email || 'Chris').trim();
return value.split(/\s+/)[0] || 'Chris';
}
function greeting(date = new Date()) {
const hour = date.getHours();
if (hour < 11) return 'Guten Morgen';
if (hour < 17) return 'Guten Tag';
return 'Guten Abend';
}
function slugify(value) {
return String(value || '')
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.toLowerCase().replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
function IconBtn({ children, onClick, badge }) {
return (
);
}
function Section({ title, trailing, onTrailing, children }) {
return (
{title}
{trailing && (
)}
{children}
);
}
function SearchCard({ search, onTap }) {
return (
{search.city} +{search.radius}km · {fmtPriceShort(search.priceMin)}–{fmtPriceShort(search.priceMax)} · {search.roomsMin}–{search.roomsMax} Zi.
{search.matches} Treffer
{search.newToday > 0 && (
+{search.newToday} neu
)}
{search.aiEnabled && (
KI
)}
{fmtRelative(search.lastRun)}
);
}
// ─── SEARCH BUILDER WIZARD (4 steps) ───────────────────────────────────
function SearchBuilderScreen({ ctx, onDone, type = 'kauf' }) {
const { navigate, back, addSearch } = ctx;
const [step, setStep] = React.useState(1);
const [saving, setSaving] = React.useState(false);
const [saveError, setSaveError] = React.useState(null);
const [data, setData] = React.useState({
name: '',
type,
city: '',
locationId: '',
locationSlug: '',
locationDisplay: '',
radius: 10,
priceMin: type === 'kauf' ? 250000 : 800,
priceMax: type === 'kauf' ? 550000 : 1800,
roomsMin: 2, roomsMax: 4,
areaMin: 60, areaMax: 120,
keywords: [],
excludeKeywords: [],
platforms: ['immoscout24', 'kleinanzeigen'],
aiEnabled: type === 'kauf',
});
const total = 4;
const update = (k, v) => setData(d => ({ ...d, [k]: v }));
const canContinue = step !== 1 || !!data.locationId;
const next = () => {
if (!canContinue) {
setSaveError('Bitte Stadt, Stadtteil oder PLZ aus der Standortsuche auswählen.');
return;
}
setSaveError(null);
return step < total ? setStep(step + 1) : finish();
};
const prev = () => step > 1 ? setStep(step - 1) : back();
const finish = async () => {
setSaving(true);
setSaveError(null);
try {
const created = await addSearch({
type: data.type,
property_type: 'wohnung',
city: data.locationDisplay || data.city,
location_id: data.locationId || null,
location_slug: data.locationSlug || null,
radius: data.radius,
price_min: data.priceMin,
price_max: data.priceMax,
rooms_min: data.roomsMin,
rooms_max: data.roomsMax,
area_min: data.areaMin,
area_max: data.areaMax,
require_keywords: data.keywords,
exclude_keywords: data.excludeKeywords,
active: true,
run_now: true,
});
const id = created?.id;
if (data.type === 'miete') {
navigate('mietenResults', { searchId: id }, true);
} else {
navigate('results', { searchId: id }, true);
}
} catch (error) {
setSaveError(error.payload?.message || error.message || 'Suche konnte nicht gespeichert werden.');
} finally {
setSaving(false);
}
};
return (
Abbrechen}
/>
{/* Progress */}
{[1,2,3,4].map(i => (
))}
{step === 1 &&
}
{step === 2 &&
}
{step === 3 &&
}
{step === 4 &&
}
{saveError && (
{saveError}
)}
{/* Footer */}
);
}
function StepHeader({ overline, title, subtitle }) {
return (
{overline}
{title}
{subtitle && (
{subtitle}
)}
);
}
function BuilderStep1({ data, update }) {
const selectLocation = (location) => {
const display = location.display_name || location.name || '';
update('locationId', location.id || '');
update('locationSlug', location.slug || slugify(display));
update('locationDisplay', display);
update('city', display);
if (!data.name) update('name', `${data.type === 'kauf' ? 'Kaufsuche' : 'Mietsuche'} ${display}`);
};
return (
);
}
function LocationSuggestInput({ value, selected, onInput, onSelect }) {
const [suggestions, setSuggestions] = React.useState([]);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState(null);
const abortRef = React.useRef(null);
React.useEffect(() => {
const query = String(value || '').trim();
if (selected && query === selected) return;
if (query.length < 2) {
setSuggestions([]);
setError(null);
return undefined;
}
const handle = setTimeout(async () => {
abortRef.current?.abort?.();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
setError(null);
try {
const response = await fetch(`/revamp-api/locations?q=${encodeURIComponent(query)}`, {
headers: { Accept: 'application/json' },
signal: controller.signal,
});
if (!response.ok) throw new Error('Standortsuche derzeit nicht verfügbar');
const payload = await response.json();
setSuggestions(Array.isArray(payload.locations) ? payload.locations : []);
if (payload.available === false) setError(payload.message || 'Standortsuche derzeit nicht verfügbar');
} catch (err) {
if (err.name !== 'AbortError') {
setSuggestions([]);
setError(err.message || 'Standortsuche derzeit nicht verfügbar');
}
} finally {
setLoading(false);
}
}, 250);
return () => clearTimeout(handle);
}, [value, selected]);
return (
{selected && (
{selected}
)}
{loading &&
Suche nach Standorten…
}
{error &&
{error}
}
{!selected && !loading && String(value || '').trim().length >= 2 && suggestions.length === 0 && !error && (
Keine Treffer gefunden.
)}
{!selected && suggestions.length > 0 && (
{suggestions.map(location => {
const display = location.display_name || location.name || '';
return (
);
})}
)}
Wie in der alten App wird die Kleinanzeigen-Ortssuche verwendet. Bitte einen Vorschlag übernehmen, damit PLZ/Stadtteil und Location-ID korrekt sind.
);
}
function BuilderStep2({ data, update }) {
const isKauf = data.type === 'kauf';
return (
update('priceMin', v)} onMax={v => update('priceMax', v)}
fmt={isKauf ? fmtPriceShort : v => v + ' €'}
absMin={isKauf ? 50000 : 300} absMax={isKauf ? 2000000 : 4000}
step={isKauf ? 25000 : 50}
/>
update('roomsMin', v)} onMax={v => update('roomsMax', v)}
fmt={v => v + ' Zi.'}
absMin={1} absMax={8}
step={0.5}
/>
update('areaMin', v)} onMax={v => update('areaMax', v)}
fmt={v => v + ' m²'}
absMin={20} absMax={300}
step={5}
/>
);
}
function BuilderStep3({ data, update }) {
const toggle = (kw, list, listKey) => {
const set = new Set(list);
if (set.has(kw)) set.delete(kw); else set.add(kw);
update(listKey, [...set]);
};
const presets = ['altbau', 'balkon', 'stuck', 'dielen', 'aufzug', 'garten', 'einbauküche', 'saniert', 'erstbezug', 'denkmal'];
const exPresets = ['erbpacht', 'sanierungsbedürftig', 'wbs', 'möbliert', 'tausch'];
const [kwInput, setKwInput] = React.useState('');
const [exInput, setExInput] = React.useState('');
return (
{data.keywords.map(k => (
{k}
))}
{
if (e.key === 'Enter' && kwInput.trim()) {
update('keywords', [...data.keywords, kwInput.trim().toLowerCase()]);
setKwInput('');
}
}}
/>
{presets.filter(p => !data.keywords.includes(p)).map(p => (
))}
{data.excludeKeywords.map(k => (
– {k}
))}
{
if (e.key === 'Enter' && exInput.trim()) {
update('excludeKeywords', [...data.excludeKeywords, exInput.trim().toLowerCase()]);
setExInput('');
}
}}
/>
{exPresets.filter(p => !data.excludeKeywords.includes(p)).map(p => (
))}
);
}
function BuilderStep4({ data, update }) {
const togglePlatform = (p) => {
const set = new Set(data.platforms);
if (set.has(p)) set.delete(p); else set.add(p);
update('platforms', [...set]);
};
return (
togglePlatform('immoscout24')}
color="#1F6B3A"
/>
togglePlatform('kleinanzeigen')}
color="#C28428"
/>
{data.type === 'kauf' && (
<>
update('aiEnabled', !data.aiEnabled)}
className="card" style={{ padding: 16, cursor: 'pointer' }}
>
Automatische KI-Analyse
Jedes neue Inserat erhält einen Profitabilitäts-Score (0–100), inkl. Stärken, Risiken & Renditeschätzung.
{data.aiEnabled && (
Verbrauch: ca. 1 KI-Token pro Inserat. Bewertungen ≥ 80 erhalten Sie als Push-Mitteilung.
)}
>
)}
Zusammenfassung
{data.type === 'kauf' ? 'Kauf' : 'Miete'} in {data.locationDisplay || data.city || '—'} +{data.radius}km
{fmtPriceShort(data.priceMin)}–{fmtPriceShort(data.priceMax)}, {data.roomsMin}–{data.roomsMax} Zi., {data.areaMin}–{data.areaMax} m²
{data.keywords.length > 0 && <>Stichwörter: {data.keywords.join(', ')}
>}
Plattformen: {data.platforms.length}
);
}
function PlatformOption({ name, desc, active, onToggle, color }) {
return (
);
}
function Toggle({ on }) {
return (
);
}
// ─── Form primitives ───────────────────────────────────────────────────
function Label({ children, style }) {
return {children}
;
}
function Input({ value, onChange, placeholder, onKeyDown, type = 'text' }) {
return (
onChange(e.target.value)} placeholder={placeholder}
type={type} onKeyDown={onKeyDown}
style={{
width: '100%', padding: '13px 14px', fontSize: 15, borderRadius: 12,
background: 'var(--surface)', border: '1px solid var(--border)',
fontFamily: 'var(--font-sans)', color: 'var(--ink)', outline: 'none',
boxSizing: 'border-box',
}}
/>
);
}
function Pill({ children, active, onClick }) {
return (
);
}
function RangeRow({ min, max, onMin, onMax, fmt, absMin, absMax, step }) {
return (
);
}
// ─── SEARCH RESULTS ────────────────────────────────────────────────────
function ResultsScreen({ ctx, searchId }) {
const { listings, searches, navigate, back, favs, toggleFav, deleteSearch, refreshSearch, evaluateSearch, exportSearch, busy } = ctx;
const search = searches.find(s => s.id === searchId) || searches[0];
const [sort, setSort] = React.useState('score'); // score | price | newest
const [view, setView] = React.useState('cards'); // cards | table
const [filterOpen, setFilterOpen] = React.useState(false);
const [filters, setFilters] = React.useState({
platforms: { immoscout24: true, kleinanzeigen: true },
minScore: 0,
provFreeOnly: false,
});
const [refreshing, setRefreshing] = React.useState(false);
// Apply filters
if (!search) {
return (
} title="Keine Suche gefunden" subtitle="Legen Sie zuerst eine Suche an." />
);
}
let filtered = listings.filter(l => String(l.searchId) === String(search.id));
if (search.category === 'kauf') {
if (search.priceMin) filtered = filtered.filter(l => l.price >= search.priceMin);
if (search.priceMax) filtered = filtered.filter(l => l.price <= search.priceMax);
}
filtered = filtered.filter(l => filters.platforms[l.platform]);
if (filters.minScore > 0) filtered = filtered.filter(l => Number(l.aiScore ?? 0) >= Number(filters.minScore));
if (filters.provFreeOnly) filtered = filtered.filter(l => l.provisionsfrei);
filtered = [...filtered].sort((a, b) => {
if (sort === 'score') return (b.aiScore || 0) - (a.aiScore || 0);
if (sort === 'price') return a.price - b.price;
if (sort === 'newest') return new Date(b.posted) - new Date(a.posted);
return 0;
});
const doRefresh = async () => {
setRefreshing(true);
try {
await refreshSearch(search.taskId || search.id);
} finally {
setRefreshing(false);
}
};
const doEvaluate = async () => {
await evaluateSearch(search.taskId || search.id);
};
const doExport = () => {
exportSearch(search.taskId || search.id);
};
const doDelete = async () => {
if (confirm(`Suche „${search.name}" wirklich löschen?`)) {
await deleteSearch(search.taskId || search.id);
back();
}
};
return (
>}
/>
{/* Filter / sort bar */}
setSort('score')}>KI-Score
setSort('price')}>Preis ↑
setSort('newest')}>Neueste
setView('cards')}>
setView('table')}>
{/* Summary */}
{filtered.length} Treffer · zuletzt aktualisiert {fmtRelative(search.lastRun)}
{busy && · arbeitet...}
{search.newToday > 0 &&
+{search.newToday} neu}
{filtered.length === 0 ? (
}
title="Keine Treffer"
subtitle="Lockern Sie die Filter oder fügen Sie weitere Plattformen hinzu."
cta={
}
/>
) : view === 'cards' ? (
{filtered.map(l => (
navigate('listing', { id: l.id })} isFav={favs.has(l.id)} onFav={toggleFav} />
))}
) : (
)}
{/* Filter sheet */}
setFilterOpen(false)} title="Filter" height="62%">
setFilters(f => ({ ...f, platforms: { ...f.platforms, immoscout24: !f.platforms.immoscout24 } }))}
/>
setFilters(f => ({ ...f, platforms: { ...f.platforms, kleinanzeigen: !f.platforms.kleinanzeigen } }))}
/>
{search.category === 'kauf' && (
<>
setFilters(f => ({ ...f, minScore: +e.target.value }))}
style={{ width: '100%', accentColor: 'var(--primary)' }}
/>
alle5080+
>
)}
setFilters(f => ({ ...f, provFreeOnly: !f.provFreeOnly }))} className="card" style={{ padding: 14, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }}>
Maklerprovision ausschließen
);
}
function SortPill({ children, active, onClick }) {
return (
);
}
function ViewBtn({ active, onClick, children }) {
return (
);
}
function FilterToggleCard({ label, active, onClick }) {
return (
{label}
);
}
function ResultsMoreMenu({ onExport, onEvaluate, onDelete }) {
const [open, setOpen] = React.useState(false);
return (
setOpen(!open)}>
{open && (
<>
setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 50 }} />
} onClick={() => { setOpen(false); onEvaluate(); }}>KI-Bewertung starten
} onClick={() => { setOpen(false); onExport(); }}>Als CSV exportieren
} danger onClick={() => { setOpen(false); onDelete(); }}>Suche löschen
>
)}
);
}
function MenuItem({ icon, children, onClick, danger }) {
return (
);
}
function ResultsTable({ listings, navigate, favs, toggleFav }) {
return (
{listings.map((l, i) => (
navigate('listing', { id: l.id })}
style={{
display: 'flex', gap: 12, padding: 12, alignItems: 'center', cursor: 'pointer',
borderBottom: i < listings.length - 1 ? '0.5px solid var(--border)' : 'none',
}}
>
{l.title}
{l.district} · {fmtArea(l.area)} · {l.rooms} Zi.
{l.aiScore !== null && l.aiScore !== undefined && (
= 80 ? '#2F8F5A' : l.aiScore >= 60 ? '#C28428' : '#C8331C',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>{l.aiScore}
)}
))}
);
}
Object.assign(window, { DashboardScreen, SearchBuilderScreen, ResultsScreen, IconBtn, Section, Toggle, Label });