mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
adding welcome screen
This commit is contained in:
@@ -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',
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
37
lib/api/routes/trackingRoute.js
Normal file
37
lib/api/routes/trackingRoute.js
Normal file
@@ -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 };
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 && <TrackingModal />}
|
||||
<NewsModal />
|
||||
<Routes>
|
||||
<Route path="/403" element={<InsufficientPermission />} />
|
||||
<Route path="/jobs/new" element={<JobMutation />} />
|
||||
|
||||
BIN
ui/src/assets/news/1.png
Normal file
BIN
ui/src/assets/news/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 531 KiB |
BIN
ui/src/assets/news/2.png
Normal file
BIN
ui/src/assets/news/2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 MiB |
BIN
ui/src/assets/news/3.png
Normal file
BIN
ui/src/assets/news/3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 MiB |
21
ui/src/assets/news/news.json
Normal file
21
ui/src/assets/news/news.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876509",
|
||||
"content":
|
||||
[
|
||||
{
|
||||
"title": "Welcome to Fredy",
|
||||
"text": "Thanks for choosing Fredy!<br/>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.<br/>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"
|
||||
}
|
||||
]
|
||||
}
|
||||
68
ui/src/components/news/NewsModal.jsx
Normal file
68
ui/src/components/news/NewsModal.jsx
Normal file
@@ -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: (
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<img src={heart} width="30" alt="Fredy Logo" style={{ marginRight: '10px' }} />
|
||||
<b>{item.title}</b>
|
||||
</div>
|
||||
),
|
||||
description: (
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
{item.image && (
|
||||
<img
|
||||
src={`/ui/src/assets/news/${item.image}`}
|
||||
alt={item.title}
|
||||
style={{ width: '100%', marginBottom: 10, borderRadius: 4 }}
|
||||
/>
|
||||
)}
|
||||
<p dangerouslySetInnerHTML={{ __html: item.text }} />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const handleClose = (poi) => {
|
||||
actions.userSettings.setNewsHash(newsConfig.key);
|
||||
if (poi) {
|
||||
actions.tracking.trackPoi(poi);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<UserGuide
|
||||
mode="modal"
|
||||
mask={true}
|
||||
steps={steps}
|
||||
visible={userSettingsLoaded && newsHash !== newsConfig.key}
|
||||
onFinish={() => handleClose(pois.WELCOME_FINISHED)}
|
||||
onSkip={() => handleClose(pois.WELCOME_SKIPPED)}
|
||||
modalProps={{
|
||||
width: '10rem',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsModal;
|
||||
3
ui/src/components/news/NewsModal.less
Normal file
3
ui/src/components/news/NewsModal.less
Normal file
@@ -0,0 +1,3 @@
|
||||
.semi-userGuide-modal-body-title {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user