// 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={} /> ) : (
{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.name}
{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.

)}
{/* 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 ( ); } 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 (
Meine Auswahl

Merkliste

{/* 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.'}
{/* 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 ( ); } function PortfolioMini({ label, value }) { return (
{label}
{value || '—'}
); } // ─── 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 (
Aktivitäten

Mitteilungen

{/* Push subscription card */}
Browser-Push
{notificationSettings?.hasSubscriptions ? `${notificationSettings.subscriptions.length} Gerät(e) aktiv` : 'Noch kein Gerät verbunden.'}
{!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)} /> )) )}
{/* 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}
); } 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.title}
{!n.read && }
{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 */}
Aktiv
Suchkontingent
{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
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" />
Standardnachricht