// app.jsx — Main app shell, routing, state management const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "palette": 3, "font": 2 }/*EDITMODE-END*/; // 4 curated accent palettes const PALETTES = [ { name: 'Korall (Standard)', primary: '#D97757', deep: '#B85A3C', soft: '#FAEDE4', ink: '#5C2E1B' }, { name: 'Tiefblau', primary: '#2A6FDB', deep: '#1F58B0', soft: '#E1ECF9', ink: '#0F2B5C' }, { name: 'Waldgrün', primary: '#3F8F5C', deep: '#2A6E45', soft: '#E0EFE5', ink: '#1E4730' }, { name: 'Gold', primary: '#C28428', deep: '#9A6618', soft: '#FAF1DD', ink: '#5C3F0E' }, ]; // 4 curated typography bundles (body + display + tracking adj.) const FONTS = [ { name: 'Editorial', sans: "'Plus Jakarta Sans', -apple-system, system-ui, sans-serif", display: "'Instrument Serif', 'Plus Jakarta Sans', serif", displayWeight: 400, sansTrack: -0.01, displayTrack: -0.02, sample: { sans: 'Aa', display: 'Aa' }, }, { name: 'Geometrisch', sans: "'Space Grotesk', system-ui, sans-serif", display: "'Bricolage Grotesque', 'Space Grotesk', sans-serif", displayWeight: 500, sansTrack: -0.005, displayTrack: -0.03, sample: { sans: 'Aa', display: 'Aa' }, }, { name: 'Klassisch', sans: "'Manrope', system-ui, sans-serif", display: "'Newsreader', 'Manrope', serif", displayWeight: 500, sansTrack: -0.005, displayTrack: -0.015, sample: { sans: 'Aa', display: 'Aa' }, }, { name: 'Skandinavisch', sans: "'DM Sans', system-ui, sans-serif", display: "'Fraunces', 'DM Sans', serif", displayWeight: 400, sansTrack: -0.01, displayTrack: -0.02, sample: { sans: 'Aa', display: 'Aa' }, }, ]; // ─── Font bundle picker (custom — shows preview Aa) ───────────────────── function FontBundlePicker({ value, onChange }) { return (
{FONTS.map((f, i) => { const on = i === value; return ( ); })}
); } function useIsDesktop() { const [isDesktop, setIsDesktop] = React.useState(() => window.matchMedia('(min-width: 960px)').matches); React.useEffect(() => { const media = window.matchMedia('(min-width: 960px)'); const update = () => setIsDesktop(media.matches); update(); media.addEventListener('change', update); return () => media.removeEventListener('change', update); }, []); return isDesktop; } const DESKTOP_ROUTES = new Set([ 'dashboard', 'searchBuilder', 'searchBuilderMiete', 'results', 'mietenResults', 'listing', 'aiEval', 'mietenDashboard', 'discover', 'investor', 'compare', 'portfolio', 'notifications', 'profile', 'security', 'admin', 'exports', ]); function desktopRouteHash(name = 'dashboard', params = null) { const route = DESKTOP_ROUTES.has(name) ? name : 'dashboard'; const query = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value == null || value === '') return; query.set(key, Array.isArray(value) ? value.join(',') : String(value)); }); const qs = query.toString(); return `#/${route}${qs ? `?${qs}` : ''}`; } function desktopRouteUrl(name, params) { return `/${desktopRouteHash(name, params)}`; } function parseDesktopRouteHash() { const raw = window.location.hash || ''; if (!raw.startsWith('#/')) return null; const clean = raw.slice(2); const [routeName, queryString = ''] = clean.split('?'); if (!DESKTOP_ROUTES.has(routeName)) return null; const query = new URLSearchParams(queryString); const params = {}; query.forEach((value, key) => { params[key] = key === 'ids' ? value.split(',').filter(Boolean) : value; }); return { name: routeName, params: Object.keys(params).length ? params : null }; } function App() { const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); const palette = PALETTES[t.palette] || PALETTES[0]; const font = FONTS[t.font] || FONTS[0]; const isDesktop = useIsDesktop(); const sharedListing = window.REVAMP_SHARED_LISTING || null; // Apply palette + font tokens to CSS vars React.useEffect(() => { const r = document.documentElement; r.style.setProperty('--primary', palette.primary); r.style.setProperty('--primary-deep', palette.deep); r.style.setProperty('--primary-soft', palette.soft); r.style.setProperty('--primary-ink', palette.ink); r.style.setProperty('--font-sans', font.sans); r.style.setProperty('--font-display', font.display); r.style.setProperty('--font-display-weight', font.displayWeight); }, [t.palette, t.font]); // App state const [booting, setBooting] = React.useState(true); const [authed, setAuthed] = React.useState(false); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(null); const [flash, setFlash] = React.useState(null); const [user, setUser] = React.useState(null); const [taskLimit, setTaskLimit] = React.useState(null); const [admin, setAdmin] = React.useState(null); const [tab, setTab] = React.useState('immobilien'); // bottom nav root // Stack: array of { name, params } per tab — but simpler: single stack const [stack, setStack] = React.useState([{ name: 'dashboard', params: null }]); const [searches, setSearches] = React.useState([]); const [listings, setListings] = React.useState([]); const [evaluations, setEvaluations] = React.useState({}); const [favs, setFavs] = React.useState(new Set()); const [archived, setArchived] = React.useState(new Set()); const [notifications, setNotifications] = React.useState([]); const [investor, setInvestor] = React.useState(null); const [portfolioAnalytics, setPortfolioAnalytics] = React.useState(null); const [notificationSettings, setNotificationSettings] = React.useState(null); const notifBadge = notifications.filter(n => !n.read).length; const desktopHistoryReady = React.useRef(false); const mobileHistoryReady = React.useRef(false); const deepLinkHandled = React.useRef(false); const stackRef = React.useRef(stack); React.useEffect(() => { stackRef.current = stack; }, [stack]); const writeDesktopHistory = React.useCallback((name, params = null, replace = false) => { if (!isDesktop || !authed || sharedListing) return; const url = desktopRouteUrl(name, params); const current = `${window.location.pathname}${window.location.hash}`; if (current === url) return; window.history[replace ? 'replaceState' : 'pushState']({ immobotDesktop: true, name, params }, '', url); }, [isDesktop, authed, sharedListing]); const writeMobileHistory = React.useCallback((nextStack, replace = false) => { if (isDesktop || !authed || sharedListing || !window.history?.pushState) return; const safeStack = Array.isArray(nextStack) && nextStack.length ? nextStack : [{ name: 'dashboard', params: null }]; const url = `${window.location.pathname}${window.location.hash || ''}`; window.history[replace ? 'replaceState' : 'pushState']({ immobotMobile: true, stack: safeStack }, '', url); }, [isDesktop, authed, sharedListing]); const applyBootstrap = React.useCallback((payload) => { const data = payload?.bootstrap || payload; if (!data) return; setUser(data.user || null); setTaskLimit(data.taskLimit || null); setAdmin(data.admin || null); setSearches(data.searches || []); setListings(data.listings || []); setEvaluations(data.evaluations || {}); window.AI_EVALS = data.evaluations || {}; setFavs(new Set((data.favorites || []).map(String))); setArchived(new Set((data.archived || []).map(String))); setNotifications(data.notifications || []); }, []); const loadSession = React.useCallback(async () => { setBooting(true); setError(null); try { const result = await RevampApi.session(); setAuthed(!!result.authenticated); if (result.authenticated) applyBootstrap(result.bootstrap); } catch (err) { setError(err.message || 'Die Verbindung zur App konnte nicht hergestellt werden.'); } finally { setBooting(false); } }, [applyBootstrap]); React.useEffect(() => { loadSession(); }, [loadSession]); React.useEffect(() => { if (deepLinkHandled.current || booting || !authed || sharedListing) return; const params = new URLSearchParams(window.location.search || ''); const adId = params.get('ad') || params.get('ad_id'); if (!adId) return; deepLinkHandled.current = true; const mode = params.get('mode'); const targetTab = mode === 'rental' ? 'mieten' : 'immobilien'; setTab(targetTab); reset('listing', { id: adId }, true); }, [authed, booting, sharedListing, isDesktop]); const runMutation = async (fn, options = {}) => { setBusy(true); setError(null); try { const result = await fn(); if (result?.bootstrap) applyBootstrap(result.bootstrap); if (result?.admin) setAdmin(result.admin); if (result?.message && !options.silent) setFlash(result.message); return result; } catch (err) { const msg = err.payload?.message || err.message || 'Die Aktion ist fehlgeschlagen.'; setError(msg); throw err; } finally { setBusy(false); } }; // Navigation API const navigate = (name, params = null, replace = false) => { setStack(s => { const nextStack = replace ? [...s.slice(0, -1), { name, params }] : [...s, { name, params }]; writeDesktopHistory(name, params, replace); writeMobileHistory(nextStack, replace); return nextStack; }); }; const back = () => { if (authed && stack.length > 1 && window.history?.back) { window.history.back(); return; } setStack(s => s.length > 1 ? s.slice(0, -1) : s); }; const reset = (name, params = null, replace = false) => { const nextStack = [{ name, params }]; setStack(nextStack); writeDesktopHistory(name, params, replace); writeMobileHistory(nextStack, replace); }; const toggleFav = (id, forceOn) => { const stringId = String(id); const shouldAdd = forceOn || !favs.has(stringId); setFavs(s => { const ns = new Set(s); if (shouldAdd) ns.add(stringId); else ns.delete(stringId); return ns; }); setArchived(s => { const ns = new Set(s); if (shouldAdd) ns.delete(stringId); return ns; }); runMutation( () => shouldAdd ? RevampApi.setAdAction(id, 'favorite') : RevampApi.removeAdAction(id), { silent: true } ).catch(() => loadSession()); }; const archiveListing = (id) => { const stringId = String(id); setArchived(s => new Set([...s, stringId])); setFavs(s => { const ns = new Set(s); ns.delete(stringId); return ns; }); runMutation(() => RevampApi.setAdAction(id, 'archive'), { silent: true }).catch(() => loadSession()); }; const addSearch = async (data) => { const result = await runMutation(() => RevampApi.createSearch(data)); return result.search; }; const deleteSearch = async (id) => { await runMutation(() => RevampApi.deleteSearch(id)); setSearches(arr => arr.filter(s => s.id !== id)); }; const refreshSearch = (id) => runMutation(() => RevampApi.refreshSearch(id)); const evaluateSearch = (id) => runMutation(() => RevampApi.evaluateSearch(id)); const exportSearch = (id) => { window.location.href = RevampApi.exportSearchUrl(id); }; const evaluateAd = (taskId, adId) => runMutation(() => RevampApi.evaluateAd(taskId, adId)); const scrapeAdDetails = (taskId, adId) => runMutation(() => RevampApi.scrapeAdDetails(taskId, adId)); const contactAd = (taskId, adId) => runMutation(() => RevampApi.contactAd(taskId, adId)); const inquiryPreview = (taskId, adId) => RevampApi.inquiryPreview(taskId, adId); const toggleSharing = (taskId, adId) => runMutation(() => RevampApi.toggleSharing(taskId, adId)); const updateProfile = (data) => runMutation(() => RevampApi.updateProfile(data)); const toggleExpertMode = () => runMutation(() => RevampApi.toggleExpertMode()); const activateDebugMode = (code) => runMutation(() => RevampApi.activateDebugMode(code)); const updatePassword = (data) => runMutation(() => RevampApi.updatePassword(data)); const deleteProfile = async (password) => { await runMutation(() => RevampApi.deleteProfile(password), { silent: true }); setAuthed(false); setUser(null); setSearches([]); setListings([]); setFavs(new Set()); setArchived(new Set()); setNotifications([]); setInvestor(null); setPortfolioAnalytics(null); setNotificationSettings(null); reset('dashboard'); }; const loadInvestor = async () => { const result = await runMutation(() => RevampApi.investor(), { silent: true }); setInvestor(result); window.INVESTOR = result; const investorItems = Array.isArray(result?.evaluations) ? result.evaluations : []; const investorListings = investorItems.map(item => item?.listing).filter(Boolean); if (investorListings.length) { setListings(current => { const map = new Map((current || []).map(item => [String(item.id ?? item.adId), item])); investorListings.forEach(item => map.set(String(item.id ?? item.adId), { ...(map.get(String(item.id ?? item.adId)) || {}), ...item })); const next = Array.from(map.values()); window.LISTINGS = next; return next; }); } const investorEvaluations = investorItems.filter(item => item?.evaluation).reduce((acc, item) => { const key = String(item.adId || item.listing?.id || item.listing?.adId || ''); if (key) acc[key] = item.evaluation; return acc; }, {}); if (Object.keys(investorEvaluations).length) { setEvaluations(current => { const next = { ...(current || {}), ...investorEvaluations }; window.AI_EVALS = next; return next; }); } if (result?.portfolio) setPortfolioAnalytics(result.portfolio); return result; }; const loadInvestorCompare = (ids = []) => runMutation(() => RevampApi.investorCompare(ids), { silent: true }); const loadPortfolioAnalytics = async () => { const result = await runMutation(() => RevampApi.portfolioAnalytics(), { silent: true }); setPortfolioAnalytics(result); return result; }; const exportPortfolio = () => { window.location.href = RevampApi.portfolioExportUrl(); }; const loadNotificationSettings = async () => { const result = await runMutation(() => RevampApi.notificationSubscriptions(), { silent: true }); setNotificationSettings(result); return result; }; const storePushSubscription = async (subscription) => { const result = await runMutation(() => RevampApi.storePushSubscription(subscription)); if (result?.notifications) setNotificationSettings(result.notifications); return result; }; const revokePushSubscription = async (endpoint) => { const result = await runMutation(() => RevampApi.revokePushSubscription(endpoint)); if (result?.notifications) setNotificationSettings(result.notifications); return result; }; const revokeAllPushSubscriptions = async () => { const result = await runMutation(() => RevampApi.revokeAllPushSubscriptions()); if (result?.notifications) setNotificationSettings(result.notifications); return result; }; const sendTestNotification = () => runMutation(() => RevampApi.sendTestNotification()); const reloadAdmin = async () => { const result = await runMutation(() => RevampApi.adminJobs(), { silent: true }); setAdmin(result); }; // Switching tabs resets the stack to the root of that tab const setActiveTab = (newTab) => { setTab(newTab); const roots = { immobilien: 'dashboard', mieten: 'mietenDashboard', investor: 'investor', portfolio: 'portfolio', notifications: 'notifications', profile: 'profile', }; reset(roots[newTab]); }; const ctx = { user, taskLimit, admin, listings, searches, favs, archived, notifications, evaluations, investor, portfolioAnalytics, notificationSettings, notifBadge, busy, error, flash, setFlash, isDesktop, navigate, back, reset, toggleFav, archiveListing, addSearch, deleteSearch, refreshSearch, evaluateSearch, exportSearch, evaluateAd, scrapeAdDetails, contactAd, inquiryPreview, toggleSharing, updateProfile, updatePassword, deleteProfile, toggleExpertMode, activateDebugMode, loadInvestor, loadInvestorCompare, loadPortfolioAnalytics, exportPortfolio, loadNotificationSettings, storePushSubscription, revokePushSubscription, revokeAllPushSubscriptions, sendTestNotification, reloadAdmin, setNotifications, setTab, signIn: async (email, password) => { const result = await runMutation(() => RevampApi.login(email, password), { silent: true }); setAuthed(true); applyBootstrap(result.bootstrap); reset('dashboard'); }, register: async (name, email, password) => { const result = await runMutation(() => RevampApi.register(name, email, password), { silent: true }); setAuthed(true); applyBootstrap(result.bootstrap); reset('dashboard'); }, signOut: async () => { await runMutation(() => RevampApi.logout(), { silent: true }).catch(() => {}); setAuthed(false); setUser(null); setSearches([]); setListings([]); setFavs(new Set()); setArchived(new Set()); setNotifications([]); setInvestor(null); setPortfolioAnalytics(null); setNotificationSettings(null); reset('dashboard'); }, }; React.useEffect(() => { if (!authed || booting || sharedListing) { desktopHistoryReady.current = false; mobileHistoryReady.current = false; return; } const parsed = parseDesktopRouteHash(); if (parsed) { setStack([parsed]); desktopHistoryReady.current = isDesktop; mobileHistoryReady.current = false; return; } if (!isDesktop) { desktopHistoryReady.current = false; if (!mobileHistoryReady.current) { mobileHistoryReady.current = true; const existingStack = window.history.state?.immobotMobile && Array.isArray(window.history.state.stack) ? window.history.state.stack : null; const currentStack = existingStack?.length ? existingStack : (stackRef.current?.length ? stackRef.current : [{ name: 'dashboard', params: null }]); window.history.replaceState({ immobotMobile: true, stack: currentStack }, '', `${window.location.pathname}${window.location.hash || ''}`); } return; } mobileHistoryReady.current = false; if (desktopHistoryReady.current) return; desktopHistoryReady.current = true; const currentRoute = stack[stack.length - 1] || { name: 'dashboard', params: null }; window.history.replaceState( { immobotDesktop: true, name: currentRoute.name, params: currentRoute.params }, '', desktopRouteUrl(currentRoute.name, currentRoute.params) ); }, [isDesktop, authed, booting, sharedListing]); React.useEffect(() => { if (!isDesktop || !authed || sharedListing) return undefined; const onPop = () => { const parsed = parseDesktopRouteHash() || { name: 'dashboard', params: null }; setStack([parsed]); if (!parseDesktopRouteHash()) { window.history.replaceState({ immobotDesktop: true, name: parsed.name, params: parsed.params }, '', desktopRouteUrl(parsed.name, parsed.params)); } }; window.addEventListener('popstate', onPop); return () => window.removeEventListener('popstate', onPop); }, [isDesktop, authed, sharedListing]); React.useEffect(() => { if (isDesktop || !authed || sharedListing) return undefined; const onPop = (event) => { const historyStack = event.state?.immobotMobile && Array.isArray(event.state.stack) ? event.state.stack : null; if (historyStack?.length) { setStack(historyStack); return; } const currentStack = stackRef.current || []; if (currentStack.length > 1) { setStack(currentStack.slice(0, -1)); } }; window.addEventListener('popstate', onPop); return () => window.removeEventListener('popstate', onPop); }, [isDesktop, authed, sharedListing]); // Resolve current screen const current = stack[stack.length - 1]; let screen = null; switch (current.name) { case 'dashboard': screen = ; break; case 'searchBuilder': screen = ; break; case 'searchBuilderMiete': screen = ; break; case 'results': screen = ; break; case 'mietenResults': screen = ; break; case 'listing': screen = ; break; case 'aiEval': screen = ; break; case 'mietenDashboard': screen = ; break; case 'swipe': screen = ; break; case 'investor': screen = ; break; case 'compare': screen = ; break; case 'portfolio': screen = ; break; case 'notifications': screen = ; break; case 'profile': screen = ; break; case 'security': screen = ; break; case 'admin': screen = ; break; default: screen = ; } // Hide bottom nav for fullscreen flows const hideNav = ['searchBuilder', 'searchBuilderMiete', 'listing', 'aiEval', 'swipe', 'compare', 'security', 'admin'].includes(current.name); // Status bar in dark mode on dark screens const statusDark = !authed || current.name === 'swipe'; if (sharedListing) { return ( ); } if (booting) { return ( ); } if (!authed) { return ( <> { setAuthed(true); reset('dashboard'); }} /> [p.primary, p.deep, p.soft])} onChange={(v) => { const idx = PALETTES.findIndex(p => p.primary === (Array.isArray(v) ? v[0] : v)); if (idx >= 0) setTweak('palette', idx); }} /> setTweak('font', i)} /> ); } if (isDesktop) { return ( <> [p.primary, p.deep, p.soft])} onChange={(v) => { const idx = PALETTES.findIndex(p => p.primary === (Array.isArray(v) ? v[0] : v)); if (idx >= 0) setTweak('palette', idx); }} /> setTweak('font', i)} /> ); } return ( <>
{screen}
{!hideNav && ( )}
[p.primary, p.deep, p.soft])} onChange={(v) => { const idx = PALETTES.findIndex(p => p.primary === (Array.isArray(v) ? v[0] : v)); if (idx >= 0) setTweak('palette', idx); }} /> setTweak('font', i)} /> { if (v === 'landing') { setAuthed(false); return; } const tabMap = { dashboard: 'immobilien', searchBuilder: 'immobilien', results: 'immobilien', listing: 'immobilien', aiEval: 'immobilien', mietenDashboard: 'mieten', mietenResults: 'mieten', investor: 'investor', compare: 'investor', portfolio: 'portfolio', notifications: 'notifications', profile: 'profile', security: 'profile', swipe: 'immobilien', admin: 'profile', }; const params = v === 'results' ? { searchId: 's1' } : v === 'mietenResults' ? { searchId: 's4' } : v === 'listing' ? { id: 'l3' } : v === 'aiEval' ? { id: 'l3' } : null; setTab(tabMap[v] || 'immobilien'); reset(v, params); }} /> ); } function LoadingScreen({ error, onRetry }) { if (!error) { return (
ImmoBot
); } return (
ImmoBot

ImmoBot

Die Verbindung zur App konnte nicht hergestellt werden.

); } // ─── Native app shell ────────────────────────────────────────────────── function AppShell({ children, statusDark = false, isDesktop = false }) { return (
{children}
); } // Bootstrap ReactDOM.createRoot(document.getElementById('root')).render();