// 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 (
);
}
return (
ImmoBot
Die Verbindung zur App konnte nicht hergestellt werden.
);
}
// ─── Native app shell ──────────────────────────────────────────────────
function AppShell({ children, statusDark = false, isDesktop = false }) {
return (
);
}
// Bootstrap
ReactDOM.createRoot(document.getElementById('root')).render();