// screens-extras.jsx — Rental flow, swipe browse, portfolio, notifications, profile, admin
// ─── MIETEN DASHBOARD ──────────────────────────────────────────────────
function MietenDashboardScreen({ ctx }) {
const { listings, searches, navigate, user } = ctx;
const rentalSearches = searches.filter(s => s.category === 'miete');
const newRentals = listings.filter(l => l.type === 'miete').slice(0, 3);
return (
{((h => h < 11 ? 'Guten Morgen' : h < 17 ? 'Guten Tag' : 'Guten Abend')(new Date().getHours()))}, {(user?.name || user?.email || 'Chris').trim().split(/\s+/)[0] || 'Chris'} · Mieten
Mietwohnungen
navigate('searchBuilderMiete')}>
s.active).length} label="Aktive Suchen" />
s+x.matches, 0)} label="Treffer" />
s+x.newToday, 0)}`} label="Neu heute" accent />
{rentalSearches.length === 0 ? (
}
title="Noch keine Mietsuche"
subtitle="Legen Sie Ihre erste Suche an und wir benachrichtigen Sie bei neuen Inseraten."
cta={ navigate('searchBuilderMiete')} className="btn btn-accent">Mietsuche erstellen }
/>
) : (
{rentalSearches.map(s => (
navigate('mietenResults', { searchId: s.id })} />
))}
)}
navigate('mietenResults', { searchId: rentalSearches[0]?.id })}>
{newRentals.map(l => (
navigate('listing', { id: l.id })} />
))}
Mietangebote werden nicht KI-bewertet . Sortierung nach Neuheit und Stichwortmatch.
);
}
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)}
);
}
// ─── SWIPE BROWSE (Tinder-style) ───────────────────────────────────────
function SwipeScreen({ ctx }) {
const { listings, back, navigate, favs, toggleFav, archiveListing, archived } = ctx;
const queue = listings.filter(l => l.type === 'kauf' && !archived.has(l.id)).slice(0, 8);
const [idx, setIdx] = React.useState(0);
const [dragX, setDragX] = React.useState(0);
const [dragging, setDragging] = React.useState(false);
const startX = React.useRef(0);
const currentY = React.useRef(0);
const onStart = (e) => {
startX.current = (e.touches ? e.touches[0].clientX : e.clientX);
currentY.current = (e.touches ? e.touches[0].clientY : e.clientY);
setDragging(true);
};
const onMove = (e) => {
if (!dragging) return;
const x = (e.touches ? e.touches[0].clientX : e.clientX);
setDragX(x - startX.current);
};
const onEnd = () => {
if (!dragging) return;
setDragging(false);
if (Math.abs(dragX) > 100) {
const decision = dragX > 0 ? 'like' : 'pass';
const item = queue[idx];
if (decision === 'like') toggleFav(item.id, true);
else archiveListing(item.id);
setDragX(dragX > 0 ? 400 : -400);
setTimeout(() => {
setDragX(0);
setIdx(i => i + 1);
}, 180);
} else {
setDragX(0);
}
};
const decide = (decision) => {
const item = queue[idx];
if (!item) return;
if (decision === 'like') toggleFav(item.id, true);
else archiveListing(item.id);
setDragX(decision === 'like' ? 400 : -400);
setTimeout(() => {
setDragX(0);
setIdx(i => i + 1);
}, 180);
};
const current = queue[idx];
const next = queue[idx + 1];
const angle = dragX * 0.06;
const likeOpacity = Math.max(0, Math.min(1, dragX / 80));
const passOpacity = Math.max(0, Math.min(1, -dragX / 80));
return (
Entdecken
{Math.min(idx+1, queue.length)} / {queue.length}
{current ? (
<>
{/* Next card (peek behind) */}
{next && (
)}
{/* Current card */}
Math.abs(dragX) < 6 && navigate('listing', { id: current.id })}
style={{
transform: `translateX(${dragX}px) rotate(${angle}deg)`,
transition: dragging ? 'none' : 'transform .25s ease',
cursor: 'grab', touchAction: 'pan-y',
}}
likeOpacity={likeOpacity}
passOpacity={passOpacity}
/>
>
) : (
Alles durch!
Sie haben alle Inserate gesehen. Neue Treffer erhalten Sie als Mitteilung.
setIdx(0)} className="btn btn-accent" style={{ marginTop: 20 }}>Von vorn beginnen
)}
{/* Action buttons */}
{current && (
decide('pass')} color="#fff" bg="rgba(255,255,255,.12)" size={56}>
navigate('listing', { id: current.id })} color="#1F1A14" bg="#fff" size={48}>
decide('like')} color="#fff" bg="var(--primary)" size={56}>
)}
);
}
function ActionBtn({ children, onClick, color, bg, size = 56 }) {
return (
{children}
);
}
function SwipeCard({ listing, onStart, onMove, onEnd, onTap, style, likeOpacity = 0, passOpacity = 0 }) {
return (
{/* Like/Pass overlays */}
MERKEN
WEITER
{/* Badge top */}
{listing.provisionsfrei &&
PROVISIONSFREI }
{listing.aiScore !== null && listing.aiScore !== undefined && (
= 80 ? '#2F8F5A' : listing.aiScore >= 60 ? '#C28428' : '#C8331C',
color: '#fff', fontSize: 17, fontWeight: 800,
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 12px rgba(0,0,0,.3)', flexDirection: 'column', lineHeight: 1,
}}>
{listing.aiScore}
KI-SCORE
)}
{/* Body */}
{fmtPriceShort(listing.price)}
{listing.title}
{listing.district}, {listing.city}
{fmtArea(listing.area)}
{listing.rooms} Zi.
{listing.baujahr}
);
}
// ─── PORTFOLIO (favorites + archive) ───────────────────────────────────
function PortfolioScreen({ ctx }) {
const { listings, favs, archived, navigate, toggleFav, portfolioAnalytics, loadPortfolioAnalytics, exportPortfolio, busy } = ctx;
const [tab, setTab] = React.useState('favs');
const [loaded, setLoaded] = React.useState(false);
const favList = listings.filter(l => favs.has(l.id));
const archiveList = listings.filter(l => archived.has(l.id));
const totalValue = favList.filter(l => l.type === 'kauf').reduce((s, l) => s + l.price, 0);
const avgScore = (() => {
const xs = favList.filter(l => l.aiScore);
return xs.length ? Math.round(xs.reduce((s,l) => s+l.aiScore, 0) / xs.length) : null;
})();
const analytics = portfolioAnalytics;
const summary = analytics?.summary || {};
const bestYield = (analytics?.yields || []).reduce((best, item) => {
const value = Math.max(item.gross_yield || 0, item.net_yield || 0, item.cap_rate || 0);
return value > (best.value || 0) ? { ...item, value } : best;
}, {});
React.useEffect(() => {
if (!loaded) {
setLoaded(true);
loadPortfolioAnalytics?.().catch(() => {});
}
}, [loaded]);
return (
navigate('investor')} className="btn btn-soft" style={{ padding: '10px 13px', fontSize: 13 }}>
Investor
{/* Analytics */}
l.type==='kauf').length} Kauf · ${favList.filter(l=>l.type==='miete').length} Miete`} />
Portfolio-Analytics
{summary.estimatedAnnualIncome ? `${fmtMetricPrice(summary.estimatedAnnualIncome)} pot. Jahresmiete` : 'Rendite-Ansicht aktiv'}
{bestYield.label ? `${bestYield.label}: bester Renditewert ${fmtPct(bestYield.value)}` : 'Gemerkt-Kaufobjekte werden mit den alten Investment-Metriken bewertet.'}
navigate('investor')} className="btn btn-primary" style={{ flex: 1, fontSize: 13, padding: '11px 12px' }}>
Top-Deals öffnen
CSV
{/* Tabs */}
setTab('favs')}>
Gemerkt ({favList.length})
setTab('archive')}>
Archiv ({archiveList.length})
{(tab==='favs' ? favList : archiveList).length === 0 ? (
:
}
title={tab==='favs' ? 'Noch nichts gemerkt' : 'Archiv ist leer'}
subtitle={tab==='favs' ? 'Tippen Sie auf das Herz, um Inserate hier zu sammeln.' : 'Verschobene Inserate landen hier.'}
/>
) : (
{(tab==='favs' ? favList : archiveList).map(l => (
navigate('listing', { id: l.id })} isFav={favs.has(l.id)} onFav={toggleFav} compact />
))}
)}
);
}
function TabBtn({ active, onClick, children }) {
return (
{children}
);
}
function PortfolioMini({ label, value }) {
return (
);
}
// ─── NOTIFICATIONS ─────────────────────────────────────────────────────
function NotificationsScreen({ ctx }) {
const {
notifications, navigate, setNotifications, searches, notificationSettings,
loadNotificationSettings, storePushSubscription, revokePushSubscription,
revokeAllPushSubscriptions, sendTestNotification, busy,
} = ctx;
const [pushMessage, setPushMessage] = React.useState(null);
const [pushBusy, setPushBusy] = React.useState(false);
const pushSupported = typeof window !== 'undefined'
&& 'serviceWorker' in navigator
&& 'PushManager' in window
&& 'Notification' in window;
React.useEffect(() => {
loadNotificationSettings?.().catch(() => {});
}, []);
const markAll = () => setNotifications(notifications.map(n => ({ ...n, read: true })));
const click = (n) => {
setNotifications(notifications.map(x => x.id === n.id ? { ...x, read: true } : x));
if (n.listingId) navigate('listing', { id: n.listingId });
else if (n.searchId) {
const s = searches.find(x => x.id === n.searchId);
if (s) {
if (s.category === 'kauf') navigate('results', { searchId: n.searchId });
else navigate('mietenResults', { searchId: n.searchId });
}
}
};
const enablePush = async () => {
setPushMessage(null);
if (!pushSupported) {
setPushMessage('Dieser Browser unterstützt keine Push-Benachrichtigungen.');
return;
}
if (!notificationSettings?.vapidPublicKey) {
setPushMessage('Der VAPID-Schluessel ist auf dem Server nicht konfiguriert.');
return;
}
setPushBusy(true);
try {
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
setPushMessage('Benachrichtigungen wurden im Browser nicht erlaubt.');
return;
}
const registration = await navigator.serviceWorker.register('/sw.js');
await navigator.serviceWorker.ready;
const existing = await registration.pushManager.getSubscription();
if (existing) {
await storePushSubscription(existing.toJSON());
} else {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(notificationSettings.vapidPublicKey),
});
await storePushSubscription(subscription.toJSON());
}
setPushMessage('Dieses Gerät ist jetzt für Push aktiviert.');
} catch (err) {
setPushMessage(err.message || 'Push konnte nicht aktiviert werden.');
} finally {
setPushBusy(false);
}
};
const removeDevice = async (endpoint) => {
setPushMessage(null);
await revokePushSubscription(endpoint);
};
const removeAll = async () => {
setPushMessage(null);
await revokeAllPushSubscriptions();
};
return (
{/* Push subscription card */}
Browser-Push
{notificationSettings?.hasSubscriptions
? `${notificationSettings.subscriptions.length} Gerät(e) aktiv`
: 'Noch kein Gerät verbunden.'}
Aktivieren
{!pushSupported && (
Push ist in diesem Browser nicht verfügbar.
)}
{pushMessage && (
{pushMessage}
)}
{(notificationSettings?.subscriptions || []).length === 0 ? (
Aktivieren Sie Push für dieses Gerät, damit neue Treffer und hohe KI-Bewertungen sofort ankommen.
) : (
notificationSettings.subscriptions.map(device => (
removeDevice(device.endpoint)} />
))
)}
Test senden
Alle entfernen
{/* Stream */}
Letzte Aktivität
{notifications.map(n => (
click(n)} />
))}
);
}
function PrefRow({ label, on, onClick }) {
return (
{label}
);
}
function PushDeviceRow({ device, onRemove }) {
return (
{device.label}
{device.endpointPreview}
Entfernen
);
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; i += 1) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
function NotifRow({ n, onClick }) {
const icons = {
'ai-high': { i: , bg: 'var(--primary-soft)', c: 'var(--primary-ink)' },
'new-match': { i: , bg: 'var(--info-soft)', c: 'var(--info)' },
'system': { i: , bg: 'var(--surface-2)', c: 'var(--ink-muted)' },
};
const cfg = icons[n.kind] || icons.system;
return (
{cfg.i}
{n.body}
{fmtRelative(n.at)}
);
}
// ─── PROFILE ───────────────────────────────────────────────────────────
const RENTAL_PROFILE_DEFAULTS = {
personalized_inquiry_enabled: true,
full_name: '',
age: '',
profession: '',
current_city: '',
move_reason: '',
household: '',
smoking: '',
pets: '',
employment: '',
monthly_net_income: '',
documents: '',
rental_intent: '',
availability: '',
additional_notes: '',
};
const RENTAL_PROFILE_FIELDS = [
['full_name', 'Name'],
['age', 'Alter'],
['profession', 'Beruf'],
['current_city', 'Aktueller Ort'],
['household', 'Haushalt'],
['monthly_net_income', 'Nettoeinkommen'],
['smoking', 'Rauchen'],
['pets', 'Haustiere'],
['employment', 'Arbeitsverhältnis'],
['documents', 'Unterlagen', 'textarea'],
['move_reason', 'Suchgrund', 'textarea'],
['rental_intent', 'Mietabsicht', 'textarea'],
['availability', 'Besichtigung', 'textarea'],
['additional_notes', 'Weitere Hinweise', 'textarea'],
];
function rentalInquiryPersonalizationEnabled(profile = {}) {
const value = profile.personalized_inquiry_enabled;
return !(value === false || value === 'false' || value === 0 || value === '0');
}
function ProfileScreen({ ctx }) {
const { navigate, reset, setTab, signOut, user, taskLimit, updateProfile, toggleExpertMode, activateDebugMode, busy } = ctx;
const accountRef = React.useRef(null);
const inquiryRef = React.useRef(null);
const [integrationsOpen, setIntegrationsOpen] = React.useState(false);
const [profile, setProfile] = React.useState(() => ({
name: user?.name || '',
email: user?.email || '',
telegram_chat_id: user?.telegram_chat_id || '',
ebay_userid: user?.ebay_userid || '',
ebay_contactname: user?.ebay_contactname || '',
ebay_email: user?.ebay_email || '',
ebay_password: '',
default_reply_message: user?.default_reply_message || '',
rental_profile: { ...RENTAL_PROFILE_DEFAULTS, ...(user?.rental_profile || {}) },
}));
const [debugCode, setDebugCode] = React.useState('');
const [localMessage, setLocalMessage] = React.useState(null);
const setField = (key, value) => setProfile(p => ({ ...p, [key]: value }));
const setRentalProfileField = (key, value) => setProfile(p => ({
...p,
rental_profile: { ...RENTAL_PROFILE_DEFAULTS, ...(p.rental_profile || {}), [key]: value },
}));
const scrollToAccount = () => accountRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
const scrollToInquiry = () => inquiryRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
React.useEffect(() => {
const shouldScroll = sessionStorage.getItem('immobotScrollInquiryProfile') === '1'
|| window.location.hash.includes('inquiry=1')
|| window.location.search.includes('inquiry=1');
if (!shouldScroll) return;
sessionStorage.removeItem('immobotScrollInquiryProfile');
setTimeout(scrollToInquiry, 120);
}, []);
const goToActiveSearches = () => {
setTab?.('immobilien');
reset?.('dashboard');
};
const saveProfile = async () => {
setLocalMessage(null);
await updateProfile(profile);
setLocalMessage('Gespeichert');
setField('ebay_password', '');
};
return (
Profil
{/* User card */}
{user?.initials || 'I'}
{user?.name || 'ImmoBot Nutzer'}
{user?.email}
{user?.isAdmin ? 'Admin' : 'Aktiv'}
{/* Plan card */}
{taskLimit?.current_tasks ?? 0}
aktive Suchen
{taskLimit?.remaining_slots == null ? 'Unbegr. Suchen' : `${taskLimit.remaining_slots} Slots frei`}
{taskLimit?.remaining_evaluations == null ? 'Unbegr. KI' : `${taskLimit.remaining_evaluations} KI heute`}
✓ CSV-Export
Expert {user?.expertMode ? 'aus' : 'an'}
debugCode && activateDebugMode(debugCode)} className="btn btn-accent" style={{ flex: 1, fontSize: 13, padding: '10px' }}>
Debug aktivieren
setDebugCode(e.target.value)} placeholder="Debug-Code" style={{ marginTop: 10, width: '100%', border: '1px solid rgba(255,255,255,.18)', background: 'rgba(255,255,255,.10)', color: '#fff', borderRadius: 10, padding: '10px 12px', fontFamily: 'var(--font-sans)' }} />
Persönliche Daten
setField('name', v)} />
setField('email', v)} type="email" />
setIntegrationsOpen(true)} className="btn btn-outline" style={{ width: '100%' }}>
Schnittstellen verwalten
{localMessage && {localMessage}
}
Profil speichern
{/* Menu */}
Konto
} title="Persönliche Daten" detail="bearbeiten" onTap={scrollToAccount} />
} title="Schnittstellen" detail="Telegram, Kleinanzeigen" onTap={() => setIntegrationsOpen(true)} />
} title="Investor-Ansicht" detail="Top-Deals" onTap={() => navigate('investor')} />
} title="Benachrichtigungen" onTap={() => navigate('notifications', null, true)} />
{user?.isAdmin &&
} title="Job-Monitor (Admin)" onTap={() => navigate('admin')} />}
} title="Sicherheit & Konto" onTap={() => navigate('security')} isLast />
Such-Voreinstellungen
} title="Aktive Suchen" detail={String(taskLimit?.current_tasks ?? 0)} onTap={goToActiveSearches} />
} title="Expert-Modus" detail={user?.expertMode ? 'Ein' : 'Aus'} onTap={toggleExpertMode} />
} title="Sprache" detail="Deutsch" onTap={() => setLocalMessage('Sprache ist bereits auf Deutsch eingestellt.')} isLast />
} title="Abmelden" danger onTap={signOut} isLast />
setIntegrationsOpen(false)} title="Schnittstellen" height="72%">
setField('telegram_chat_id', v)} />
setField('ebay_userid', v)} />
setField('ebay_contactname', v)} />
setField('ebay_email', v)} type="email" />
setField('ebay_password', v)} type="password" placeholder="Unverändert lassen" />
{ await saveProfile(); setIntegrationsOpen(false); }} disabled={busy} className="btn btn-primary" style={{ width: '100%', opacity: busy ? .7 : 1, marginTop: 4 }}>
Schnittstellen speichern
ImmoBot v3.4.1 · Build 2026.05.13
);
}
function ProfileInput({ label, value, onChange, type = 'text', placeholder }) {
return (
{label}
onChange(e.target.value)}
type={type}
placeholder={placeholder}
style={{
width: '100%', padding: '11px 12px', borderRadius: 12,
border: '1px solid var(--border)', background: 'var(--surface-2)',
color: 'var(--ink)', fontFamily: 'var(--font-sans)', fontSize: 14,
}}
/>
);
}
function ProfileTextarea({ label, value, onChange, placeholder }) {
return (
{label}
);
}
function InquiryProfileFields({ value = {}, onChange = () => {}, columns = 1 }) {
const profile = { ...RENTAL_PROFILE_DEFAULTS, ...(value || {}) };
const enabled = rentalInquiryPersonalizationEnabled(profile);
return (
);
}
function ProfileRow({ icon, title, detail, isLast, danger, onTap }) {
return (
{
if (!onTap) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onTap();
}
}}
style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px',
borderBottom: isLast ? 'none' : '0.5px solid var(--border)',
cursor: onTap ? 'pointer' : 'default',
color: danger ? 'var(--danger)' : 'var(--ink)',
}}
>
{icon}
{title}
{detail &&
{detail}
}
{onTap &&
}
);
}
// ─── ADMIN JOB MONITOR ────────────────────────────────────────────────
function AdminScreen({ ctx }) {
const { back, admin, reloadAdmin, user } = ctx;
const [jobs, setJobs] = React.useState(admin?.jobs || []);
const [debug, setDebug] = React.useState(true);
React.useEffect(() => {
setJobs(admin?.jobs || []);
}, [admin]);
React.useEffect(() => {
if (user?.isAdmin) reloadAdmin().catch(() => {});
}, [user?.isAdmin]);
if (!user?.isAdmin) {
return (
} title="Adminbereich" subtitle="Dieser Bereich ist nur für Admin-Konten sichtbar." />
);
}
// Animate running jobs
React.useEffect(() => {
const t = setInterval(() => {
setJobs(js => js.map(j => {
if (j.status === 'running' && j.progress < 100) {
const newP = Math.min(100, j.progress + Math.random() * 3);
return { ...j, progress: newP };
}
return j;
}));
}, 800);
return () => clearInterval(t);
}, []);
const counts = {
running: jobs.filter(j => j.status === 'running').length,
queued: jobs.filter(j => j.status === 'queued').length,
completed: jobs.filter(j => j.status === 'completed').length,
failed: jobs.filter(j => j.status === 'failed' || j.status === 'skipped').length,
};
return (
setDebug(!debug)}>DEBUG {debug ? 'ON' : 'OFF'}
} />
{/* Stat strip */}
{/* Job list */}
Aktive & geplante Jobs
{jobs.map(j => )}
{/* Debug panel */}
{debug && (
Debug-Konsole
{(admin?.audits || []).length === 0 ? (
Keine aktuellen Audit-Eintraege.
) : (
admin.audits.map(a => (
[{fmtDateTime(a.created_at)}] {a.type}
{a.message}
))
)}
)}
);
}
function MiniStat({ label, value, color }) {
return (
);
}
function JobRow({ job }) {
const statusCfg = {
running: { c: '#2A6FDB', bg: 'var(--info-soft)', label: 'läuft' },
queued: { c: '#C28428', bg: 'var(--warn-soft)', label: 'wartet' },
completed: { c: '#2F8F5A', bg: 'var(--success-soft)', label: 'fertig' },
failed: { c: '#C8331C', bg: 'var(--danger-soft)', label: 'fehler' },
skipped: { c: '#6B6259', bg: 'var(--surface-2)', label: 'übersprungen' },
};
const cfg = statusCfg[job.status];
const kindIcon = job.kind === 'ai-eval' ? : ;
return (
{kindIcon}
{job.kind === 'ai-eval' ? 'KI-Eval' : 'Scrape'} · {job.target}
{cfg.label}
{job.search} · {job.items}
{job.status === 'running' && (
)}
{job.error && (
{job.error}
)}
);
}
Object.assign(window, {
MietenDashboardScreen, SwipeScreen, PortfolioScreen, NotificationsScreen, ProfileScreen, AdminScreen,
});