Compare commits

..

6 Commits

Author SHA1 Message Date
orangecoding
19d4721f9f improve welcome screen 2026-02-17 14:03:15 +01:00
orangecoding
a794645393 fixing login route 2026-02-17 12:50:21 +01:00
orangecoding
fd7e228972 adding welcome screen 2026-02-17 12:35:39 +01:00
orangecoding
b86e351007 fixing lint even harder 2026-02-16 13:50:50 +01:00
orangecoding
19c4860da7 fixing eslint harder 2026-02-16 12:59:34 +01:00
orangecoding
d98e06cfdf fixing eslint 2026-02-16 12:40:41 +01:00
48 changed files with 380 additions and 189 deletions

View File

@@ -8,20 +8,20 @@ import js from '@eslint/js';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
import react from 'eslint-plugin-react';
import babelParser from '@babel/eslint-parser';
export default [
{
files: ['**/*.{js,jsx,ts,tsx}'],
ignores: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/public/**', 'db/**', 'conf/**'],
},
js.configs.recommended,
prettier,
{
files: ['**/*.{js,jsx}'],
languageOptions: {
parser: babelParser,
ecmaVersion: 'latest',
sourceType: 'module',
ecmaVersion: 2021,
parserOptions: {
ecmaFeatures: { jsx: true },
},
globals: {
...globals.browser,
...globals.node,
@@ -32,70 +32,14 @@ export default [
after: 'readonly',
it: 'readonly',
},
parserOptions: { requireConfigFile: false },
},
plugins: { react },
rules: {
eqeqeq: [2, 'allow-null'],
strict: 0,
'no-redeclare': [2, { builtinGlobals: false }],
'class-methods-use-this': 'off',
indent: ['off', 2],
'linebreak-style': ['error', 'unix'],
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: true }],
semi: ['error', 'always'],
'no-console': ['error', { allow: ['warn', 'error'] }],
'jsx-quotes': ['error', 'prefer-double'],
'react/display-name': 'off',
'react/forbid-prop-types': 'off',
'react/jsx-closing-bracket-location': 'off',
'react/jsx-curly-spacing': 'off',
'react/jsx-handler-names': ['off', { eventHandlerPrefix: 'handle', eventHandlerPropPrefix: 'on' }],
'react/jsx-indent-props': 'off',
'react/jsx-key': 'off',
'react/jsx-max-props-per-line': 'off',
'react/jsx-no-bind': ['error', { ignoreRefs: true, allowArrowFunctions: true, allowBind: false }],
'react/jsx-no-duplicate-props': ['error', { ignoreCase: true }],
'react/jsx-no-literals': 'off',
'react/jsx-no-undef': 'error',
'react/jsx-pascal-case': ['error', { allowAllCaps: true, ignore: [] }],
'react/sort-prop-types': ['off', { ignoreCase: true, callbacksLast: false, requiredFirst: false }],
'react/jsx-sort-prop-types': 'off',
'react/jsx-sort-props': 'off',
'react/jsx-uses-react': 'error',
'react/jsx-uses-vars': 'error',
'react/no-danger': 'warn',
'react/no-deprecated': 'error',
'react/no-did-mount-set-state': 'error',
'react/no-did-update-set-state': 'warn',
'react/no-direct-mutation-state': 'off',
'react/no-is-mounted': 'error',
'react/no-set-state': 'off',
'react/no-string-refs': 'warn',
'react/no-unknown-property': 'error',
'react/prop-types': ['error', { ignore: [], customValidators: [], skipUndeclared: true }],
'react/react-in-jsx-scope': 'error',
'react/require-extension': 'off',
'react/require-render-return': 'error',
'react/self-closing-comp': 'warn',
'react/sort-comp': 'off',
'react/jsx-wrap-multilines': ['warn', { declaration: true, assignment: true, return: true }],
'react/wrap-multilines': 'off',
'react/jsx-first-prop-new-line': 'off',
'react/jsx-equals-spacing': ['warn', 'never'],
'react/jsx-no-target-blank': 'error',
'react/jsx-filename-extension': ['error', { extensions: ['.jsx'] }],
'react/jsx-no-comment-textnodes': 'error',
'react/no-comment-textnodes': 'off',
'react/no-render-return-value': 'error',
'react/require-optimization': ['off', { allowDecorators: [] }],
'react/no-find-dom-node': 'warn',
'react/forbid-component-props': ['off', { forbid: [] }],
'react/no-danger-with-children': 'error',
'react/no-unused-prop-types': ['warn', { customValidators: [], skipShapeProps: true }],
'react/style-prop-object': 'error',
'react/no-children-prop': 'warn',
},
settings: { react: { version: 'detect' } },
rules: {
...js.configs.recommended.rules,
'no-console': ['error', { allow: ['warn', 'error'] }],
},
},
prettier,
];

View File

@@ -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',
};

View File

@@ -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);

View 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 };

View File

@@ -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 };

View File

@@ -22,7 +22,7 @@ puppeteer.use(StealthPlugin());
export default async function execute(url, waitForSelector, options) {
let browser;
let page;
let result = null;
let result;
let userDataDir;
let removeUserDataDir = false;
try {

View File

@@ -88,7 +88,7 @@ export function up(db) {
}
} catch (e) {
// If parsing fails, let it throw to rollback the migration
throw new Error(`Failed to import users from ${usersJsonPath}: ${e.message}`);
throw new Error(`Failed to import users from ${usersJsonPath}: ${e.message}`, { cause: e });
}
}
@@ -116,7 +116,7 @@ export function up(db) {
}
}
} catch (e) {
throw new Error(`Failed to import jobs from ${jobsJsonPath}: ${e.message}`);
throw new Error(`Failed to import jobs from ${jobsJsonPath}: ${e.message}`, { cause: e });
}
}
}

View File

@@ -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);

View File

@@ -1,6 +1,6 @@
{
"name": "fredy",
"version": "19.3.8",
"version": "19.4.1",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"prepare": "husky",
@@ -103,12 +103,14 @@
"@babel/eslint-parser": "7.28.6",
"@babel/preset-env": "7.29.0",
"@babel/preset-react": "7.28.5",
"@eslint/js": "^10.0.1",
"chai": "6.2.2",
"chalk": "^5.6.2",
"eslint": "10.0.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-react": "7.37.5",
"esmock": "2.7.3",
"globals": "^17.3.0",
"history": "5.3.0",
"husky": "9.1.7",
"less": "4.5.1",

View File

@@ -11,7 +11,7 @@ import GeneralSettings from './views/generalSettings/GeneralSettings';
import UserSettings from './views/userSettings/UserSettings';
import JobMutation from './views/jobs/mutation/JobMutation';
import UserMutator from './views/user/mutation/UserMutator';
import { useActions, useSelector } from './services/state/store';
import { useActions, useSelector, useFredyState } from './services/state/store';
import { Routes, Route, Navigate } from 'react-router-dom';
import Login from './views/login/Login';
import Users from './views/user/Users';
@@ -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();
@@ -40,20 +41,24 @@ export default function FredyApp() {
useEffect(() => {
async function init() {
await actions.user.getCurrentUser();
if (!needsLogin()) {
await actions.provider.getProvider();
await actions.jobsData.getJobs();
await actions.jobsData.getSharableUserList();
await actions.notificationAdapter.getAdapter();
await actions.generalSettings.getGeneralSettings();
await actions.userSettings.getUserSettings();
await actions.versionUpdate.getVersionUpdate();
const user = useFredyState.getState().user.currentUser;
if (!user || Object.keys(user).length === 0) {
setLoading(false);
return;
}
await actions.provider.getProvider();
await actions.jobsData.getJobs();
await actions.jobsData.getSharableUserList();
await actions.notificationAdapter.getAdapter();
await actions.generalSettings.getGeneralSettings();
await actions.userSettings.getUserSettings();
await actions.versionUpdate.getVersionUpdate();
await actions.tracking.getTrackingPois();
setLoading(false);
}
init();
}, [currentUser?.userId]);
}, []);
const needsLogin = () => {
return currentUser == null || Object.keys(currentUser).length === 0;
@@ -63,10 +68,7 @@ export default function FredyApp() {
const { Sider, Content } = Layout;
return loading ? null : needsLogin() ? (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
<Login />
) : (
<Layout className="app">
<Sider>
@@ -88,6 +90,7 @@ export default function FredyApp() {
</>
)}
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
{!settings.demoMode && <NewsModal />}
<Routes>
<Route path="/403" element={<InsufficientPermission />} />
<Route path="/jobs/new" element={<JobMutation />} />
@@ -134,6 +137,7 @@ export default function FredyApp() {
}
/>
{/* Authenticated fallbacks */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Content>

View File

@@ -3,8 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { HashRouter } from 'react-router-dom';
import { createRoot } from 'react-dom/client';
import en_US from '@douyinfe/semi-ui-19/lib/es/locale/source/en_US';

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

BIN
ui/src/assets/news/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

View 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"
}
]
}

View File

@@ -3,7 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useState } from 'react';
import { useState } from 'react';
import { Modal, Radio, RadioGroup, Typography } from '@douyinfe/semi-ui-19';
const { Text } = Typography;

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { Card, Typography, Space } from '@douyinfe/semi-ui-19';
import './DashboardCard.less';

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import './FredyFooter.less';
import { useSelector } from '../../services/state/store.js';
import { Typography, Layout, Space, Divider } from '@douyinfe/semi-ui-19';

View File

@@ -3,7 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { useState, useEffect, useMemo, useRef } from 'react';
import {
Card,
Col,

View File

@@ -3,7 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo } from 'react';
import {
Card,
Col,

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { Typography } from '@douyinfe/semi-ui-19';
export default function Headline({ text, size = 3 } = {}) {

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import logo from '../../assets/logo.png';
import logoWhite from '../../assets/logo_white.png';

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { Button } from '@douyinfe/semi-ui-19';
import { xhrPost } from '../../services/xhr';
import { IconUser } from '@douyinfe/semi-icons';

View File

@@ -3,7 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { Button, Nav } from '@douyinfe/semi-ui-19';
import { IconStar, IconSetting, IconTerminal, IconHistogram, IconSidebar } from '@douyinfe/semi-icons';
import logoWhite from '../../assets/logo_white.png';

View File

@@ -0,0 +1,70 @@
/*
* 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 newsImages = import.meta.glob('../../assets/news/*', { eager: true, query: '?url', import: 'default' });
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 && newsImages[`../../assets/news/${item.image}`] && (
<img
src={newsImages[`../../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;

View File

@@ -0,0 +1,3 @@
.semi-userGuide-modal-body-title {
width: 100%;
}

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import insufficientPermission from '../../assets/insufficient_permission.png';
export default function InsufficientPermission() {

View File

@@ -3,8 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { Navigate } from 'react-router-dom';
export default function PermissionAwareRoute({ currentUser, children }) {

View File

@@ -3,8 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import './Placeholder.less';
function getPlaceholder(rowCount, className) {

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { Card } from '@douyinfe/semi-ui-19';
import './SegmentParts.less';

View File

@@ -3,8 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';

View File

@@ -3,8 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
import { Typography } from '@douyinfe/semi-ui';

View File

@@ -3,8 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import { format } from '../../services/time/timeService';
import { Table, Button, Empty } from '@douyinfe/semi-ui-19';

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { Modal } from '@douyinfe/semi-ui-19';
import Logo from '../logo/Logo.jsx';
import { xhrPost } from '../../services/xhr.js';

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { Collapse, Descriptions } from '@douyinfe/semi-ui-19';
import { useSelector } from '../../services/state/store.js';
import { MarkdownRender } from '@douyinfe/semi-ui-19';

View File

@@ -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];
}

View File

@@ -3,8 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import JobGrid from '../../components/grid/jobs/JobGrid.jsx';
import './Jobs.less';

View File

@@ -3,7 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { Fragment, useState } from 'react';
import { Fragment, useState } from 'react';
import NotificationAdapterMutator from './components/notificationAdapter/NotificationAdapterMutator';
import NotificationAdapterTable from '../../../components/table/NotificationAdapterTable';

View File

@@ -3,7 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useState } from 'react';
import { useState } from 'react';
import { transform } from '../../../../../services/transformer/notificationAdapterTransformer';
import { xhrPost } from '../../../../../services/xhr';

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { Banner, MarkdownRender } from '@douyinfe/semi-ui-19';
export default function Help({ readme }) {

View File

@@ -3,7 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { Banner, Modal, Select, Input } from '@douyinfe/semi-ui-19';
import { transform } from '../../../../../services/transformer/providerTransformer';

View File

@@ -3,7 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useSelector, useActions } from '../../services/state/store.js';
import {

View File

@@ -3,8 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import ListingsGrid from '../../components/grid/listings/ListingsGrid.jsx';
export default function Listings() {

View File

@@ -3,7 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { renderToString } from 'react-dom/server';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';

View File

@@ -3,7 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useState } from 'react';
import { useState } from 'react';
import { IconHorn } from '@douyinfe/semi-icons';
import { SegmentPart } from '../../../components/segment/SegmentPart.jsx';
import { Banner, Button, Checkbox, Space } from '@douyinfe/semi-ui-19';

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import { Modal } from '@douyinfe/semi-ui-19';
const UserRemovalModal = function UserRemovalModal({ onOk, onCancel }) {
return (

View File

@@ -3,11 +3,11 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useEffect, useState, useMemo } from 'react';
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);
}
};

View File

@@ -48,7 +48,7 @@
"@babel/eslint-parser@7.28.6":
version "7.28.6"
resolved "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.6.tgz"
resolved "https://registry.yarnpkg.com/@babel/eslint-parser/-/eslint-parser-7.28.6.tgz#6a294a4add732ebe7ded8a8d2792dd03dd81dc3f"
integrity sha512-QGmsKi2PBO/MHSQk+AAgA9R6OHQr+VqnniFE0eMWZcVcfBZoA2dKn2hUsl3Csg/Plt9opRUWdY7//VXsrIlEiA==
dependencies:
"@nicolo-ribaudo/eslint-scope-5-internals" "5.1.1-v1"
@@ -1218,6 +1218,11 @@
dependencies:
"@types/json-schema" "^7.0.15"
"@eslint/js@^10.0.1":
version "10.0.1"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-10.0.1.tgz#1e8a876f50117af8ab67e47d5ad94d38d6622583"
integrity sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==
"@eslint/object-schema@^3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.1.tgz#9a1dc9af00d790dc79a9bf57a756e3cb2740ddb9"
@@ -1455,7 +1460,7 @@
"@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1":
version "5.1.1-v1"
resolved "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz"
resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz#dbf733a965ca47b1973177dc0bb6c889edcfb129"
integrity sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==
dependencies:
eslint-scope "5.1.1"
@@ -1465,16 +1470,16 @@
resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz"
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
"@puppeteer/browsers@2.12.0":
version "2.12.0"
resolved "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.12.0.tgz"
integrity sha512-Xuq42yxcQJ54ti8ZHNzF5snFvtpgXzNToJ1bXUGQRaiO8t+B6UM8sTUJfvV+AJnqtkJU/7hdy6nbKyA12aHtRw==
"@puppeteer/browsers@2.12.1":
version "2.12.1"
resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-2.12.1.tgz#eea8d90bab08e709550f5bf987b5af92f3286f1e"
integrity sha512-fXa6uXLxfslBlus3MEpW8S6S9fe5RwmAE5Gd8u3krqOwnkZJV3/lQJiY3LaFdTctLLqJtyMgEUGkbDnRNf6vbQ==
dependencies:
debug "^4.4.3"
extract-zip "^2.0.1"
progress "^2.0.3"
proxy-agent "^6.5.0"
semver "^7.7.3"
semver "^7.7.4"
tar-fs "^3.1.1"
yargs "^17.7.2"
@@ -2612,10 +2617,10 @@ chownr@^1.1.1:
resolved "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz"
integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
chromium-bidi@13.1.1:
version "13.1.1"
resolved "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-13.1.1.tgz"
integrity sha512-zB9MpoPd7VJwjowQqiW3FKOvQwffFMjQ8Iejp5ZW+sJaKLRhZX1sTxzl3Zt22TDB4zP0OOqs8lRoY7eAW5geyQ==
chromium-bidi@14.0.0:
version "14.0.0"
resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-14.0.0.tgz#15a12ab083ae519a49a724e94994ca0a9ced9c8e"
integrity sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==
dependencies:
mitt "^3.0.1"
zod "^3.24.1"
@@ -3366,7 +3371,7 @@ eslint-plugin-react@7.37.5:
eslint-scope@5.1.1:
version "5.1.1"
resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz"
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c"
integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==
dependencies:
esrecurse "^4.3.0"
@@ -3384,7 +3389,7 @@ eslint-scope@^9.1.0:
eslint-visitor-keys@^2.1.0:
version "2.1.0"
resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz"
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303"
integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==
eslint-visitor-keys@^3.4.3:
@@ -3468,7 +3473,7 @@ esrecurse@^4.3.0:
estraverse@^4.1.1:
version "4.3.0"
resolved "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz"
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d"
integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0:
@@ -3906,6 +3911,11 @@ glob@^7.0.0, glob@^7.1.3:
once "^1.3.0"
path-is-absolute "^1.0.0"
globals@^17.3.0:
version "17.3.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.3.0.tgz#8b96544c2fa91afada02747cc9731c002a96f3b9"
integrity sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==
globalthis@^1.0.4:
version "1.0.4"
resolved "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz"
@@ -6213,17 +6223,17 @@ punycode@^2.1.0:
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
puppeteer-core@24.37.2:
version "24.37.2"
resolved "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.37.2.tgz"
integrity sha512-nN8qwE3TGF2vA/+xemPxbesntTuqD9vCGOiZL2uh8HES3pPzLX20MyQjB42dH2rhQ3W3TljZ4ZaKZ0yX/abQuw==
puppeteer-core@24.37.3:
version "24.37.3"
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.37.3.tgz#56f65d743eb99fe86b246b6af9ea3e94b0e4a4f9"
integrity sha512-fokQ8gv+hNgsRWqVuP5rUjGp+wzV5aMTP3fcm8ekNabmLGlJdFHas1OdMscAH9Gzq4Qcf7cfI/Pe6wEcAqQhqg==
dependencies:
"@puppeteer/browsers" "2.12.0"
chromium-bidi "13.1.1"
"@puppeteer/browsers" "2.12.1"
chromium-bidi "14.0.0"
debug "^4.4.3"
devtools-protocol "0.0.1566079"
typed-query-selector "^2.12.0"
webdriver-bidi-protocol "0.4.0"
webdriver-bidi-protocol "0.4.1"
ws "^8.19.0"
puppeteer-extra-plugin-stealth@^2.11.2:
@@ -6274,15 +6284,15 @@ puppeteer-extra@^3.3.6:
deepmerge "^4.2.2"
puppeteer@^24.37.2:
version "24.37.2"
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.37.2.tgz#059a486d1db6d16ea58c4456eba896b27946656f"
integrity sha512-FV1W/919ve0y0oiS/3Rp5XY4MUNUokpZOH/5M4MMDfrrvh6T9VbdKvAHrAFHBuCxvluDxhjra20W7Iz6HJUcIQ==
version "24.37.3"
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.37.3.tgz#68327e1f571887ed1ba7916d770e6028975a21db"
integrity sha512-AUGGWq0BhPM+IOS2U9A+ZREH3HDFkV1Y5HERYGDg5cbGXjoGsTCT7/A6VZRfNU0UJJdCclyEimZICkZW6pqJyw==
dependencies:
"@puppeteer/browsers" "2.12.0"
chromium-bidi "13.1.1"
"@puppeteer/browsers" "2.12.1"
chromium-bidi "14.0.0"
cosmiconfig "^9.0.0"
devtools-protocol "0.0.1566079"
puppeteer-core "24.37.2"
puppeteer-core "24.37.3"
typed-query-selector "^2.12.0"
qs@^6.14.1:
@@ -6777,11 +6787,6 @@ semver@^7.3.5, semver@^7.5.3:
resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz"
integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==
semver@^7.7.3:
version "7.7.3"
resolved "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz"
integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==
semver@^7.7.4:
version "7.7.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
@@ -7633,10 +7638,10 @@ web-streams-polyfill@^3.0.3:
resolved "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz"
integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==
webdriver-bidi-protocol@0.4.0:
version "0.4.0"
resolved "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.0.tgz"
integrity sha512-U9VIlNRrq94d1xxR9JrCEAx5Gv/2W7ERSv8oWRoNe/QYbfccS0V3h/H6qeNeCRJxXGMhhnkqvwNrvPAYeuP9VA==
webdriver-bidi-protocol@0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz#d411e7b8e158408d83bb166b0b4f1054fa3f077e"
integrity sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==
whatwg-encoding@^3.1.1:
version "3.1.1"