diff --git a/lib/features.js b/lib/TRACKING_POIS.js
similarity index 63%
rename from lib/features.js
rename to lib/TRACKING_POIS.js
index 7d8f5af..d0977f2 100644
--- a/lib/features.js
+++ b/lib/TRACKING_POIS.js
@@ -3,6 +3,8 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
-export const FEATURES = {
+export const TRACKING_POIS = {
DISTANCE_ADDRESS_ENTERED: 'DISTANCE_ADDRESS_ENTERED',
+ WELCOME_FINISHED: 'WELCOME_FINISHED',
+ WELCOME_SKIPPED: 'WELCOME_SKIPPED',
};
diff --git a/lib/api/api.js b/lib/api/api.js
index 1eb304a..ddd4634 100644
--- a/lib/api/api.js
+++ b/lib/api/api.js
@@ -23,6 +23,7 @@ import { listingsRouter } from './routes/listingsRouter.js';
import { getSettings } from '../services/storage/settingsStorage.js';
import { dashboardRouter } from './routes/dashboardRouter.js';
import { backupRouter } from './routes/backupRouter.js';
+import { trackingRouter } from './routes/trackingRoute.js';
const service = restana();
const staticService = files(path.join(getDirName(), '../ui/public'));
const PORT = (await getSettings()).port || 9998;
@@ -36,6 +37,7 @@ service.use('/api/version', authInterceptor());
service.use('/api/listings', authInterceptor());
service.use('/api/dashboard', authInterceptor());
service.use('/api/user/settings', authInterceptor());
+service.use('/api/tracking', authInterceptor());
// /admin can only be accessed when user is having admin permissions
service.use('/api/admin', adminInterceptor());
@@ -50,6 +52,7 @@ service.use('/api/jobs', jobRouter);
service.use('/api/login', loginRouter);
service.use('/api/listings', listingsRouter);
service.use('/api/dashboard', dashboardRouter);
+service.use('/api/tracking', trackingRouter);
//this route is unsecured intentionally as it is being queried from the login page
service.use('/api/demo', demoRouter);
diff --git a/lib/api/routes/trackingRoute.js b/lib/api/routes/trackingRoute.js
new file mode 100644
index 0000000..8231e34
--- /dev/null
+++ b/lib/api/routes/trackingRoute.js
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2026 by Christian Kellner.
+ * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
+ */
+
+import restana from 'restana';
+import { trackPoi } from '../../services/tracking/Tracker.js';
+import { TRACKING_POIS } from '../../TRACKING_POIS.js';
+import logger from '../../services/logger.js';
+
+const service = restana();
+const trackingRouter = service.newRouter();
+
+trackingRouter.get('/trackingPois', async (req, res) => {
+ res.body = TRACKING_POIS;
+ res.send();
+});
+
+trackingRouter.post('/poi', async (req, res) => {
+ const { poi } = req.body;
+ if (!poi) {
+ res.statusCode = 400;
+ res.send({ error: 'Feature name is required' });
+ return;
+ }
+
+ try {
+ await trackPoi(poi);
+ res.send({ success: true });
+ } catch (error) {
+ logger.error('Error tracking feature', error);
+ res.statusCode = 500;
+ res.send({ error: error.message });
+ }
+});
+
+export { trackingRouter };
diff --git a/lib/api/routes/userSettingsRoute.js b/lib/api/routes/userSettingsRoute.js
index 38bb293..9b34b83 100644
--- a/lib/api/routes/userSettingsRoute.js
+++ b/lib/api/routes/userSettingsRoute.js
@@ -10,8 +10,8 @@ import { resetGeocoordinatesAndDistanceForUser } from '../../services/storage/li
import { geocodeAddress } from '../../services/geocoding/geoCodingService.js';
import { autocompleteAddress } from '../../services/geocoding/autocompleteService.js';
import { fromJson } from '../../utils.js';
-import { trackFeature } from '../../services/tracking/Tracker.js';
-import { FEATURES } from '../../features.js';
+import { trackPoi } from '../../services/tracking/Tracker.js';
+import { TRACKING_POIS } from '../../TRACKING_POIS.js';
import logger from '../../services/logger.js';
import { runGeoCordTask } from '../../services/crons/geocoding-cron.js';
@@ -53,7 +53,7 @@ userSettingsRouter.post('/home-address', async (req, res) => {
try {
if (home_address) {
- await trackFeature(FEATURES.DISTANCE_ADDRESS_ENTERED);
+ await trackPoi(TRACKING_POIS.DISTANCE_ADDRESS_ENTERED);
const coords = await geocodeAddress(home_address);
if (coords && coords.lat !== -1) {
upsertSettings({ home_address: { address: home_address, coords } }, userId);
@@ -76,4 +76,25 @@ userSettingsRouter.post('/home-address', async (req, res) => {
}
});
+userSettingsRouter.post('/news-hash', async (req, res) => {
+ const userId = req.session.currentUser;
+ const { news_hash } = req.body;
+
+ const globalSettings = await getSettings();
+ if (globalSettings.demoMode) {
+ res.statusCode = 403;
+ res.send({ error: 'In demo mode, it is not allowed to change settings.' });
+ return;
+ }
+
+ try {
+ upsertSettings({ news_hash }, userId);
+ res.send({ success: true });
+ } catch (error) {
+ logger.error('Error updating news hash', error);
+ res.statusCode = 500;
+ res.send({ error: error.message });
+ }
+});
+
export { userSettingsRouter };
diff --git a/lib/services/tracking/Tracker.js b/lib/services/tracking/Tracker.js
index 6790559..50b1f31 100644
--- a/lib/services/tracking/Tracker.js
+++ b/lib/services/tracking/Tracker.js
@@ -70,11 +70,11 @@ export const trackMainEvent = async () => {
}
};
-export const trackFeature = async (feature) => {
+export const trackPoi = async (poi) => {
if (!(await shouldTrack())) return;
const trackingObj = await enrichTrackingObject({
- feature,
+ feature: poi,
});
await sendTrackingData('/feature', trackingObj);
diff --git a/package.json b/package.json
index 1cf91e8..0a4afeb 100755
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "fredy",
- "version": "19.3.9",
+ "version": "19.4.0",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"prepare": "husky",
@@ -80,7 +80,7 @@
"node-mailjet": "6.0.11",
"p-throttle": "^8.1.0",
"package-up": "^5.0.0",
- "puppeteer": "^24.37.2",
+ "puppeteer": "^24.37.3",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"query-string": "9.3.1",
diff --git a/ui/src/App.jsx b/ui/src/App.jsx
index 8cf0725..a5be910 100644
--- a/ui/src/App.jsx
+++ b/ui/src/App.jsx
@@ -29,6 +29,7 @@ import FredyFooter from './components/footer/FredyFooter.jsx';
import WatchlistManagement from './views/listings/management/WatchlistManagement.jsx';
import Dashboard from './views/dashboard/Dashboard.jsx';
import ListingDetail from './views/listings/ListingDetail.jsx';
+import NewsModal from './components/news/NewsModal.jsx';
export default function FredyApp() {
const actions = useActions();
@@ -48,6 +49,7 @@ export default function FredyApp() {
await actions.generalSettings.getGeneralSettings();
await actions.userSettings.getUserSettings();
await actions.versionUpdate.getVersionUpdate();
+ await actions.tracking.getTrackingPois();
}
setLoading(false);
}
@@ -88,6 +90,7 @@ export default function FredyApp() {
>
)}
{settings.analyticsEnabled === null && !settings.demoMode && }
+
} />
} />
diff --git a/ui/src/assets/news/1.png b/ui/src/assets/news/1.png
new file mode 100644
index 0000000..4e89ceb
Binary files /dev/null and b/ui/src/assets/news/1.png differ
diff --git a/ui/src/assets/news/2.png b/ui/src/assets/news/2.png
new file mode 100644
index 0000000..5d78854
Binary files /dev/null and b/ui/src/assets/news/2.png differ
diff --git a/ui/src/assets/news/3.png b/ui/src/assets/news/3.png
new file mode 100644
index 0000000..76aca58
Binary files /dev/null and b/ui/src/assets/news/3.png differ
diff --git a/ui/src/assets/news/news.json b/ui/src/assets/news/news.json
new file mode 100644
index 0000000..b8c714c
--- /dev/null
+++ b/ui/src/assets/news/news.json
@@ -0,0 +1,21 @@
+{
+ "key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876509",
+ "content":
+[
+ {
+ "title": "Welcome to Fredy",
+ "text": "Thanks for choosing Fredy!
With Fredy you can quickly set up scraping jobs that run automatically every few minutes and continuously search platforms like ImmoScout and similar sites for new listings that match your criteria. Instead of manually refreshing pages and risking to miss out on good offers, Fredy monitors the market in the background and notifies you as soon as something relevant appears.",
+ "image": "1.png"
+ },
+ {
+ "title": "Stay in Control with the Grid View",
+ "text": "The grid view gives you a clean and structured overview of all matching listings. You can instantly compare prices, sizes, locations and key details without leaving Fredy. Open listings in a focused detail view, bookmark interesting ones and keep track of what you have already checked.",
+ "image": "2.png"
+ },
+ {
+ "title": "Explore Listings on the Map",
+ "text": "Switch to the map view to understand the bigger picture. See exactly where listings are located and evaluate neighborhoods at a glance. This helps you spot hidden gems, avoid unwanted areas and make faster, better decisions based on location.
If you want Fredy to calculate commute times for you, head over to the User-Specific Settings and add your home address.",
+ "image": "3.png"
+ }
+]
+}
diff --git a/ui/src/components/news/NewsModal.jsx b/ui/src/components/news/NewsModal.jsx
new file mode 100644
index 0000000..e1eb87a
--- /dev/null
+++ b/ui/src/components/news/NewsModal.jsx
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2026 by Christian Kellner.
+ * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
+ */
+
+import { UserGuide } from '@douyinfe/semi-ui-19';
+import { useScreenWidth } from '../../hooks/screenWidth';
+import heart from '../../assets/heart.png';
+import newsConfig from '../../assets/news/news.json';
+import { useActions, useSelector } from '../../services/state/store';
+
+import './NewsModal.less';
+
+const NewsModal = () => {
+ const screenWidth = useScreenWidth();
+ const newsHash = useSelector((state) => state.userSettings.settings.news_hash);
+ const userSettingsLoaded = useSelector((state) => state.userSettings.loaded);
+ const pois = useSelector((state) => state.tracking.pois);
+ const actions = useActions();
+
+ if (newsConfig == null || newsConfig.length === 0 || screenWidth <= 768) {
+ return null;
+ }
+
+ const steps = newsConfig.content.map((item) => ({
+ title: (
+
+

+
{item.title}
+
+ ),
+ description: (
+
+ {item.image && (
+

+ )}
+
+
+ ),
+ }));
+
+ const handleClose = (poi) => {
+ actions.userSettings.setNewsHash(newsConfig.key);
+ if (poi) {
+ actions.tracking.trackPoi(poi);
+ }
+ };
+
+ return (
+ handleClose(pois.WELCOME_FINISHED)}
+ onSkip={() => handleClose(pois.WELCOME_SKIPPED)}
+ modalProps={{
+ width: '10rem',
+ }}
+ />
+ );
+};
+
+export default NewsModal;
diff --git a/ui/src/components/news/NewsModal.less b/ui/src/components/news/NewsModal.less
new file mode 100644
index 0000000..54573b4
--- /dev/null
+++ b/ui/src/components/news/NewsModal.less
@@ -0,0 +1,3 @@
+.semi-userGuide-modal-body-title {
+ width: 100%;
+}
\ No newline at end of file
diff --git a/ui/src/services/state/store.js b/ui/src/services/state/store.js
index e1e0588..2fe44ba 100644
--- a/ui/src/services/state/store.js
+++ b/ui/src/services/state/store.js
@@ -8,7 +8,7 @@
*/
import { create } from 'zustand';
import { shallow } from 'zustand/shallow';
-import { xhrGet } from '../xhr.js';
+import { xhrGet, xhrPost } from '../xhr.js';
import queryString from 'query-string';
const logger = (config) => (set, get, api) =>
@@ -27,10 +27,21 @@ const logger = (config) => (set, get, api) =>
api,
);
+/**
+ * Middleware to track loading state of async actions.
+ */
+const loadingTracker = (config) => (set, get, api) => {
+ const wrappedSet = (partial, replace) => {
+ set(partial, replace);
+ };
+
+ return config(wrappedSet, get, api);
+};
+
// Create the Zustand store with slices and actions
export const useFredyState = create(
logger(
- (set) => {
+ loadingTracker((set) => {
// Async actions that directly set state (no separate reducer concept)
const effects = {
dashboard: {
@@ -169,6 +180,23 @@ export const useFredyState = create(
}
},
},
+ tracking: {
+ async getTrackingPois() {
+ try {
+ const response = await xhrGet('/api/tracking/trackingPois');
+ set((state) => ({ tracking: { ...state.tracking, pois: Object.freeze(response.json) } }));
+ } catch (Exception) {
+ console.error('Error while trying to get resource for api/tracking. Error:', Exception);
+ }
+ },
+ async trackPoi(poi) {
+ try {
+ await xhrPost('/api/tracking/poi', { poi });
+ } catch (Exception) {
+ console.error('Error while trying to track poi. Error:', Exception);
+ }
+ },
+ },
listingsData: {
async getListingsData({
page = 1,
@@ -234,9 +262,46 @@ export const useFredyState = create(
async getUserSettings() {
try {
const response = await xhrGet('/api/user/settings');
- set((state) => ({ userSettings: { ...state.userSettings, settings: response.json } }));
+ set((state) => ({ userSettings: { ...state.userSettings, settings: response.json, loaded: true } }));
} catch (Exception) {
console.error('Error while trying to get resource for api/user/settings. Error:', Exception);
+ // Mark as loaded even on error to prevent blocking the UI
+ set((state) => ({ userSettings: { ...state.userSettings, loaded: true } }));
+ }
+ },
+ async setNewsHash(newsHash) {
+ try {
+ await xhrPost('/api/user/settings/news-hash', { news_hash: newsHash });
+ set((state) => ({
+ userSettings: {
+ ...state.userSettings,
+ settings: { ...state.userSettings.settings, news_hash: newsHash },
+ },
+ }));
+ } catch (Exception) {
+ console.error('Error while trying to update news hash. Error:', Exception);
+ throw Exception;
+ }
+ },
+ async setHomeAddress(address) {
+ try {
+ const response = await xhrPost('/api/user/settings/home-address', { home_address: address });
+ if (response.status === 200) {
+ set((state) => ({
+ userSettings: {
+ ...state.userSettings,
+ settings: {
+ ...state.userSettings.settings,
+ home_address: { address, coords: response.json.coords },
+ },
+ },
+ }));
+ return response.json;
+ }
+ throw response;
+ } catch (Exception) {
+ console.error('Error while trying to update home address. Error:', Exception);
+ throw Exception;
}
},
},
@@ -255,9 +320,10 @@ export const useFredyState = create(
maxPrice: 0,
},
generalSettings: { settings: {} },
- userSettings: { settings: {} },
+ userSettings: { settings: {}, loaded: false },
demoMode: { demoMode: false },
versionUpdate: {},
+ tracking: { pois: {} },
provider: [],
jobsData: {
jobs: [],
@@ -276,6 +342,7 @@ export const useFredyState = create(
generalSettings: { ...effects.generalSettings },
demoMode: { ...effects.demoMode },
versionUpdate: { ...effects.versionUpdate },
+ tracking: { ...effects.tracking },
listingsData: { ...effects.listingsData },
provider: { ...effects.provider },
jobsData: { ...effects.jobsData },
@@ -283,12 +350,34 @@ export const useFredyState = create(
userSettings: { ...effects.userSettings },
};
+ // Wrap actions to track loading state
+ const wrappedActions = {};
+ Object.keys(actions).forEach((slice) => {
+ wrappedActions[slice] = {};
+ Object.keys(actions[slice]).forEach((actionName) => {
+ const originalAction = actions[slice][actionName];
+ if (typeof originalAction === 'function') {
+ wrappedActions[slice][actionName] = async (...args) => {
+ const fullActionName = `${slice}.${actionName}`;
+ set((state) => ({ loading: { ...state.loading, [fullActionName]: true } }));
+ try {
+ return await originalAction(...args);
+ } finally {
+ set((state) => ({ loading: { ...state.loading, [fullActionName]: false } }));
+ }
+ };
+ } else {
+ wrappedActions[slice][actionName] = originalAction;
+ }
+ });
+ });
+
return {
...initial,
- __actions: { actions },
+ loading: {},
+ __actions: { actions: wrappedActions },
};
- },
- { name: 'fredy' },
+ }),
),
);
@@ -312,3 +401,27 @@ export function useSelector(selector, equalityFn = shallow) {
export function useActions() {
return useFredyState((s) => s.__actions.actions);
}
+
+/**
+ * Hook to check if a specific action is currently loading.
+ * @param {Function} action - The action function from useActions()
+ * @returns {boolean}
+ */
+export function useIsLoading(action) {
+ const actions = useActions();
+ const loading = useSelector((state) => state.loading);
+
+ // Find the action name by comparing the function
+ let actionPath = null;
+ for (const slice in actions) {
+ for (const name in actions[slice]) {
+ if (actions[slice][name] === action) {
+ actionPath = `${slice}.${name}`;
+ break;
+ }
+ }
+ if (actionPath) break;
+ }
+
+ return !!loading[actionPath];
+}
diff --git a/ui/src/views/userSettings/UserSettings.jsx b/ui/src/views/userSettings/UserSettings.jsx
index a82cf9e..939a782 100644
--- a/ui/src/views/userSettings/UserSettings.jsx
+++ b/ui/src/views/userSettings/UserSettings.jsx
@@ -6,8 +6,8 @@
import { useEffect, useState, useMemo } from 'react';
import { Divider, Button, AutoComplete, Toast, Banner } from '@douyinfe/semi-ui-19';
import { IconSave, IconHome } from '@douyinfe/semi-icons';
-import { useSelector, useActions } from '../../services/state/store';
-import { xhrGet, xhrPost } from '../../services/xhr';
+import { useSelector, useActions, useIsLoading } from '../../services/state/store';
+import { xhrGet } from '../../services/xhr';
import { SegmentPart } from '../../components/segment/SegmentPart';
import debounce from 'lodash/debounce';
@@ -16,7 +16,7 @@ const UserSettings = () => {
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const [address, setAddress] = useState(homeAddress?.address || '');
const [coords, setCoords] = useState(homeAddress?.coords || null);
- const [saving, setSaving] = useState(false);
+ const saving = useIsLoading(actions.userSettings.setHomeAddress);
const [dataSource, setDataSource] = useState([]);
useEffect(() => {
@@ -25,22 +25,15 @@ const UserSettings = () => {
}, [homeAddress]);
const handleSave = async () => {
- setSaving(true);
try {
- const response = await xhrPost('/api/user/settings/home-address', { home_address: address });
- if (response.status === 200) {
- setCoords(response.json.coords);
- await actions.userSettings.getUserSettings();
- Toast.success(
- 'Settings saved successfully. We will now start calculating distances for you. This may take a while and runs in the background.',
- );
- } else {
- Toast.error(response.json.error || 'Failed to save settings');
- }
+ const responseJson = await actions.userSettings.setHomeAddress(address);
+ setCoords(responseJson.coords);
+ await actions.userSettings.getUserSettings();
+ Toast.success(
+ 'Settings saved successfully. We will now start calculating distances for you. This may take a while and runs in the background.',
+ );
} catch (error) {
Toast.error(error.json?.error || 'Error while saving settings');
- } finally {
- setSaving(false);
}
};