Compare commits

..

25 Commits

Author SHA1 Message Date
orangecoding
d09770dae2 fancy, almost impossible to see animation on dashboard 2026-02-05 09:54:42 +01:00
orangecoding
248e4d2562 improve tracking 2026-02-04 14:41:55 +01:00
orangecoding
7b8e961b49 adding confirmation dialog if to remove listing entirely from db or just hide it 2026-02-03 14:04:40 +01:00
orangecoding
f66ceccbb4 next release version 2026-01-29 13:01:39 +01:00
orangecoding
a3db725af6 fixing image rendering 2026-01-29 13:01:07 +01:00
orangecoding
0663bd945f smaller demo improvements 2026-01-29 09:46:23 +01:00
orangecoding
bc355fb5fe fixing some bugs the wife found ;) 2026-01-28 21:25:48 +01:00
orangecoding
797421f0d5 hardening demo handling 2026-01-28 16:29:59 +01:00
orangecoding
0b2b42fc75 improve geocoding 2026-01-28 15:55:23 +01:00
Christian Kellner
472169693f Improvements 01 28 (#264)
* improving footer

* improve ui

* upgrading dependencies

* adding glow to all boxes on dashboard

* introducing single listing view

* next release version

* improve screenshots and login page
2026-01-28 14:27:03 +01:00
orangecoding
3117044139 fixing immoscout scraper 2026-01-26 19:52:37 +01:00
orangecoding
7879d0e94a next release version 2026-01-26 12:35:57 +01:00
orangecoding
afd1048c9e hardening the check if a listing is active 2026-01-26 12:34:49 +01:00
orangecoding
acbaab05ed next release version 2026-01-26 12:07:43 +01:00
orangecoding
72fffc526b deleting a listing now sets it to deleted in the db, preventing it from reappearing when scraping happens 2026-01-26 12:07:21 +01:00
orangecoding
9e5989ece3 zoom into map where most markers are 2026-01-26 11:54:47 +01:00
orangecoding
afc200c9e1 improved tooltip in map, improved user-settings handling 2026-01-26 11:50:16 +01:00
orangecoding
59226491f2 improved tooltip in map, improved user-settings handling 2026-01-26 11:20:02 +01:00
orangecoding
28f7760120 adapt link to listing in grid view to behave like a real link 2026-01-26 10:43:38 +01:00
orangecoding
2465514b7a fixing immoscout translator, allowing balcony and garden for purchases 2026-01-26 10:20:21 +01:00
Christian Kellner
9dde377fe6 possibility to display distance (#262) 2026-01-25 13:52:56 +01:00
Katrin Leinweber
28a3a7f372 Use EUR-symbol to match Map.jsx (see d43c5b3) (#261)
Co-authored-by: Katrin Leinweber <katrinleinweber@noreply.github.com>
2026-01-25 12:32:11 +01:00
orangecoding
e859250545 next release version 2026-01-22 15:10:31 +00:00
Christian Kellner
4dd0370ec1 Calculating the distance (#255)
* migra for distance

* adding distance calculator

* adding ability to store home address

* improve distance calculation

* calculating distance

* show distance in grid view

* upgrading dependencies

* moving to react 19

* ability to clone a job

* fixing tests

* polishing
2026-01-22 16:09:36 +01:00
orangecoding
51b4e51f3f fixing setting kleinanzeigen listings to inactive if not available anymore 2026-01-16 11:36:51 +01:00
82 changed files with 3124 additions and 1100 deletions

View File

@@ -1,94 +0,0 @@
Newer release changelog see https://github.com/orangecoding/fredy/releases
---
###### [V5.5.0]
- Upgrading dependencies
- fixing provider
- allow multiple instances of 1 provider
- **BREAKING**: Minimum node version is now 16
###### [V5.4.6]
- Adding Instana node.js monitoring
-
###### [V5.4.5]
- Adding Instana node.js monitoring
###### [V5.4.4]
- Add support for Immo Südwest Presse (immo.swp.de)
- Telegram: Use job name instead of ID and link in title
- Fix race condition if user ID is in session but not in user store
- Allow visiting the original provider URL
###### [V5.4.3]
- re-writing readme
- improving docker build
- using github's actions to build docker and test automatically
###### [V5.4.2]
- Fixing prod build
###### [V5.4.1]
- Upgrading dependencies
- Provider urls are now automagically been changed to include the correct sort order for search results
```
Note: It has been an point of confusion since the very beginning of Fredy, that people simply copied the url, but
did not take care of sorting the search results by date. If this is not done, Fredy will most likely not see the latest
results, thus cannot report them. This release fixes it by adding the necessary params (or replaces them).
```
###### [V5.3.0]
- Upgrading dependencies
- It's now possible to send mails to multiple receiver using comma separation for MailJet & Sendgrid
- Fixing Immowelt scraping
###### [V5.2.0]
- Upgrading dependencies
- Adding new similarity check layer (Duplicates are being removed now)
- Adding paging for search results
###### [V5.1.0]
- Upgrading dependencies
- NodeJS 12.13 is now the minimum supported version
- Adding general settings as new configuration page to ui
- Adding new feature working hours
###### [V5.0.0]
- Upgrading dependencies
- NodeJS 12 is now the minimum supported version
###### [V4.0.0]
Bringing back Immoscout :tada:
###### [V3.0.0]
This is basically a re-write, your old config file will not be compatible anymore. Please re-created your search jobs
on the new ui and use the values from your previous config file if needed.
```
- We're getting rid of manual config changes, Fredy, now ships with a UI so that it's easy for you to create and edit jobs
```
###### [V2.0.0]
```
- Fredy can now run multiple search job on one instance
- Changed lot's of the structure of Fredy to make this happen
[BREAKING CHANGES]
- The config has been changed, the config of V1.x will not work any longer
- Sources have been renamed to provider
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 MiB

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 402 KiB

After

Width:  |  Height:  |  Size: 531 KiB

View File

@@ -7,7 +7,7 @@
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
/>
<meta name="google" content="notranslate" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>Fredy || Real Estate Finder</title>

View File

@@ -8,7 +8,6 @@ import { checkIfConfigIsAccessible, getProviders, refreshConfig } from './lib/ut
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
import { runMigrations } from './lib/services/storage/migrations/migrate.js';
import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js';
import { cleanupDemoAtMidnight } from './lib/services/crons/demoCleanup-cron.js';
import { initTrackerCron } from './lib/services/crons/tracker-cron.js';
import logger from './lib/services/logger.js';
import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.js';
@@ -54,7 +53,6 @@ await import('./lib/api/api.js');
if (settings.demoMode) {
logger.info('Running in demo mode');
cleanupDemoAtMidnight();
}
ensureAdminUserExists();

View File

@@ -5,11 +5,15 @@
import { NoNewListingsWarning } from './errors.js';
import { storeListings, getKnownListingHashesForJobAndProvider } from './services/storage/listingsStorage.js';
import { getJob } from './services/storage/jobStorage.js';
import * as notify from './notification/notify.js';
import Extractor from './services/extractor/extractor.js';
import urlModifier from './services/queryStringMutator.js';
import logger from './services/logger.js';
import { geocodeAddress } from './services/geocoding/geoCodingService.js';
import { distanceMeters } from './services/listings/distanceCalculator.js';
import { getUserSettings } from './services/storage/settingsStorage.js';
import { updateListingDistance } from './services/storage/listingsStorage.js';
/**
* @typedef {Object} Listing
@@ -82,6 +86,7 @@ class FredyPipelineExecutioner {
.then(this._findNew.bind(this))
.then(this._geocode.bind(this))
.then(this._save.bind(this))
.then(this._calculateDistance.bind(this))
.then(this._filterBySimilarListings.bind(this))
.then(this._notify.bind(this))
.catch(this._handleError.bind(this));
@@ -201,6 +206,42 @@ class FredyPipelineExecutioner {
return newListings;
}
/**
* Calculate distance for new listings.
*
* @param {Listing[]} listings
* @returns {Listing[]}
* @private
*/
_calculateDistance(listings) {
if (listings.length === 0) return [];
const job = getJob(this._jobKey);
const userId = job?.userId;
if (userId == null || typeof userId !== 'string') {
logger.debug('Skipping distance calculation: userId is missing or invalid');
return listings;
}
const userSettings = getUserSettings(userId);
const homeAddress = userSettings?.home_address;
if (!homeAddress || !homeAddress.coords) {
return listings;
}
const { lat, lng } = homeAddress.coords;
for (const listing of listings) {
if (listing.latitude != null && listing.longitude != null) {
const dist = distanceMeters(lat, lng, listing.latitude, listing.longitude);
updateListingDistance(listing.id, dist);
listing.distance_to_destination = dist;
}
}
return listings;
}
/**
* Remove listings that are similar to already known entries according to the similarity cache.
* Adds the remaining listings to the cache.

View File

@@ -10,6 +10,7 @@ import { providerRouter } from './routes/providerRouter.js';
import { versionRouter } from './routes/versionRouter.js';
import { loginRouter } from './routes/loginRoute.js';
import { userRouter } from './routes/userRoute.js';
import { userSettingsRouter } from './routes/userSettingsRoute.js';
import { jobRouter } from './routes/jobRouter.js';
import bodyParser from 'body-parser';
import restana from 'restana';
@@ -20,7 +21,6 @@ import { demoRouter } from './routes/demoRouter.js';
import logger from '../services/logger.js';
import { listingsRouter } from './routes/listingsRouter.js';
import { getSettings } from '../services/storage/settingsStorage.js';
import { featureRouter } from './routes/featureRouter.js';
import { dashboardRouter } from './routes/dashboardRouter.js';
import { backupRouter } from './routes/backupRouter.js';
const service = restana();
@@ -35,7 +35,7 @@ service.use('/api/jobs', authInterceptor());
service.use('/api/version', authInterceptor());
service.use('/api/listings', authInterceptor());
service.use('/api/dashboard', authInterceptor());
service.use('/api/features', authInterceptor());
service.use('/api/user/settings', authInterceptor());
// /admin can only be accessed when user is having admin permissions
service.use('/api/admin', adminInterceptor());
@@ -44,11 +44,11 @@ service.use('/api/admin/generalSettings', generalSettingsRouter);
service.use('/api/admin/backup', backupRouter);
service.use('/api/jobs/provider', providerRouter);
service.use('/api/admin/users', userRouter);
service.use('/api/user/settings', userSettingsRouter);
service.use('/api/version', versionRouter);
service.use('/api/jobs', jobRouter);
service.use('/api/login', loginRouter);
service.use('/api/listings', listingsRouter);
service.use('/api/features', featureRouter);
service.use('/api/dashboard', dashboardRouter);
//this route is unsecured intentionally as it is being queried from the login page
service.use('/api/demo', demoRouter);

View File

@@ -1,17 +0,0 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import restana from 'restana';
import getFeatures from '../../features.js';
const service = restana();
const featureRouter = service.newRouter();
featureRouter.get('/', async (req, res) => {
const features = getFeatures();
res.body = Object.assign({}, { features });
res.send();
});
export { featureRouter };

View File

@@ -11,10 +11,13 @@ import logger from '../../services/logger.js';
import { bus } from '../../services/events/event-bus.js';
import { isRunning as isJobRunning } from '../../services/jobs/run-state.js';
import { addClient as addSseClient, removeClient } from '../../services/sse/sse-broker.js';
import { getSettings } from '../../services/storage/settingsStorage.js';
const service = restana();
const jobRouter = service.newRouter();
const DEMO_JOB_NAME = 'Demo-Job';
function doesJobBelongsToUser(job, req) {
const userId = req.session.currentUser;
if (userId == null) {
@@ -161,6 +164,7 @@ jobRouter.post('/:jobId/run', async (req, res) => {
jobRouter.post('/', async (req, res) => {
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled, shareWithUsers = [] } = req.body;
const settings = await getSettings();
try {
let jobFromDb = jobStorage.getJob(jobId);
@@ -169,6 +173,11 @@ jobRouter.post('/', async (req, res) => {
return;
}
if (settings.demoMode && jobFromDb && jobFromDb.name === DEMO_JOB_NAME) {
res.send(new Error('Sorry, but you cannot change the Status of our Demo Job ;)'));
return;
}
jobStorage.upsertJob({
userId: req.session.currentUser,
jobId,
@@ -188,8 +197,14 @@ jobRouter.post('/', async (req, res) => {
jobRouter.delete('', async (req, res) => {
const { jobId } = req.body;
const settings = await getSettings();
try {
const job = jobStorage.getJob(jobId);
if (settings.demoMode && job.name === DEMO_JOB_NAME) {
res.send(new Error('Sorry, but you cannot remove the Demo Job ;)'));
return;
}
if (!doesJobBelongsToUser(job, req)) {
res.send(new Error('You are trying to remove a job that is not associated to your user'));
} else {
@@ -204,8 +219,15 @@ jobRouter.delete('', async (req, res) => {
jobRouter.put('/:jobId/status', async (req, res) => {
const { status } = req.body;
const { jobId } = req.params;
const settings = await getSettings();
try {
const job = jobStorage.getJob(jobId);
if (settings.demoMode && job.name === DEMO_JOB_NAME) {
res.send(new Error('Sorry, but you cannot change the Status of our Demo Job ;)'));
return;
}
if (!doesJobBelongsToUser(job, req)) {
res.send(new Error('You are trying change a job that is not associated to your user'));
} else {

View File

@@ -10,6 +10,7 @@ import { isAdmin as isAdminFn } from '../security.js';
import logger from '../../services/logger.js';
import { nullOrEmpty } from '../../utils.js';
import { getJobs } from '../../services/storage/jobStorage.js';
import { getSettings } from '../../services/storage/settingsStorage.js';
const service = restana();
@@ -74,6 +75,18 @@ listingsRouter.get('/map', async (req, res) => {
res.send();
});
listingsRouter.get('/:listingId', async (req, res) => {
const { listingId } = req.params;
const listing = listingStorage.getListingById(listingId, req.session.currentUser, isAdminFn(req));
if (!listing) {
res.statusCode = 404;
res.body = { message: 'Listing not found' };
return res.send();
}
res.body = listing;
res.send();
});
// Toggle watch state for the current user on a listing
listingsRouter.post('/watch', async (req, res) => {
try {
@@ -94,9 +107,15 @@ listingsRouter.post('/watch', async (req, res) => {
});
listingsRouter.delete('/job', async (req, res) => {
const { jobId } = req.body;
const { jobId, hardDelete = false } = req.body;
const settings = await getSettings();
try {
listingStorage.deleteListingsByJobId(jobId);
if (settings.demoMode) {
res.send(new Error('Sorry, but you cannot remove listings in demo mode ;)'));
return;
}
listingStorage.deleteListingsByJobId(jobId, hardDelete);
} catch (error) {
res.send(new Error(error));
logger.error(error);
@@ -105,10 +124,10 @@ listingsRouter.delete('/job', async (req, res) => {
});
listingsRouter.delete('/', async (req, res) => {
const { ids } = req.body;
const { ids, hardDelete = false } = req.body;
try {
if (Array.isArray(ids) && ids.length > 0) {
listingStorage.deleteListingsById(ids);
listingStorage.deleteListingsById(ids, hardDelete);
}
} catch (error) {
res.send(new Error(error));

View File

@@ -0,0 +1,79 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import restana from 'restana';
import SqliteConnection from '../../services/storage/SqliteConnection.js';
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
import { resetGeocoordinatesAndDistanceForUser } from '../../services/storage/listingsStorage.js';
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 logger from '../../services/logger.js';
import { runGeoCordTask } from '../../services/crons/geocoding-cron.js';
const service = restana();
const userSettingsRouter = service.newRouter();
userSettingsRouter.get('/', async (req, res) => {
const userId = req.session.currentUser;
const rows = SqliteConnection.query('SELECT name, value FROM settings WHERE user_id = @userId', { userId });
const settings = {};
for (const r of rows) {
settings[r.name] = fromJson(r.value, null);
}
res.body = settings;
res.send();
});
userSettingsRouter.get('/autocomplete', async (req, res) => {
const { q } = req.query;
try {
const results = await autocompleteAddress(q);
res.body = results;
res.send();
} catch (error) {
res.statusCode = 500;
res.send({ error: error.message });
}
});
userSettingsRouter.post('/home-address', async (req, res) => {
const userId = req.session.currentUser;
const { home_address } = req.body;
const settings = await getSettings();
if (settings.demoMode) {
res.send(new Error('In demo mode, it is not allowed to change the home address.'));
return;
}
try {
if (home_address) {
await trackFeature(FEATURES.DISTANCE_ADDRESS_ENTERED);
const coords = await geocodeAddress(home_address);
if (coords && coords.lat !== -1) {
upsertSettings({ home_address: { address: home_address, coords } }, userId);
resetGeocoordinatesAndDistanceForUser(userId);
//we do NOT wait for this to finish, as we don't want to block the response
runGeoCordTask();
res.send({ success: true, coords });
} else {
res.statusCode = 400;
res.send({ error: 'Could not geocode address' });
}
} else {
upsertSettings({ home_address: null }, userId);
res.send({ success: true });
}
} catch (error) {
logger.error('Error updating home address settings', error);
res.statusCode = 500;
res.send({ error: error.message });
}
});
export { userSettingsRouter };

View File

@@ -3,12 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
const FEATURES = {
WATCHLIST_MANAGEMENT: false,
export const FEATURES = {
DISTANCE_ADDRESS_ENTERED: 'DISTANCE_ADDRESS_ENTERED',
};
export default function getFeatures() {
return {
...FEATURES,
};
}

View File

@@ -8,7 +8,7 @@
*
* The mobile API provides the following endpoints:
* - GET /search/total?{search parameters}: Returns the total number of listings for the given query
* Example: `curl -H "User-Agent: ImmoScout_27.3_26.0_._" https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin `
* Example: `curl -H "User-Agent: ImmoScout_27.12_26.2_._" https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin `
*
* - POST /search/list?{search parameters}: Actually retrieves the listings. Body is json encoded and contains
* data specifying additional results (advertisements) to return. The format is as follows:
@@ -20,12 +20,12 @@
* ```
* It is not necessary to provide data for the specified keys.
*
* Example: `curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' -H "Connection: keep-alive" -H "User-Agent: ImmoScout_27.3_26.0_._" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"supportedResultListType": [], "userData": {}}'`
* Example: `curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' -H "Connection: keep-alive" -H "User-Agent: ImmoScout_27.12_26.2_._" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"supportedResultListType": [], "userData": {}}'`
* - GET /expose/{id} - Returns the details of a listing. The response contains additional details not included in the
* listing response.
*
* Example: `curl -H "User-Agent: ImmoScout_27.3_26.0_._" "https://api.mobile.immobilienscout24.de/expose/158382494"`
* Example: `curl -H "User-Agent: ImmoScout_27.12_26.2_._" "https://api.mobile.immobilienscout24.de/expose/158382494"`
*
*
* It is necessary to set the correct User Agent (see `getListings`) in the request header.
@@ -52,7 +52,7 @@ async function getListings(url) {
const response = await fetch(url, {
method: 'POST',
headers: {
'User-Agent': 'ImmoScout_27.3_26.0_._',
'User-Agent': 'ImmoScout_27.12_26.2_._',
'Content-Type': 'application/json',
},
body: JSON.stringify({
@@ -88,7 +88,7 @@ async function getListings(url) {
async function isListingActive(link) {
const result = await fetch(convertImmoscoutListingToMobileListing(link), {
headers: {
'User-Agent': 'ImmoScout_27.3_26.0_._',
'User-Agent': 'ImmoScout_27.12_26.2_._',
},
});

View File

@@ -36,7 +36,7 @@ const config = {
},
normalize: normalize,
filter: applyBlacklist,
activeTester: checkIfListingIsActive,
activeTester: (url) => checkIfListingIsActive(url, 'Angebot nicht gefunden'),
};
export const init = (sourceConfig, blacklist) => {
config.enabled = sourceConfig.enabled;

View File

@@ -4,6 +4,7 @@
*/
import * as utils from '../utils.js';
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
let appliedBlackList = [];
@@ -40,6 +41,7 @@ const config = {
},
normalize: normalize,
filter: applyBlacklist,
activeTester: checkIfListingIsActive,
};
export const init = (sourceConfig, blacklistTerms) => {

View File

@@ -1,29 +0,0 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { removeJobsByUserId } from '../storage/jobStorage.js';
import { getUsers } from '../storage/userStorage.js';
import logger from '../logger.js';
import cron from 'node-cron';
import { getSettings } from '../storage/settingsStorage.js';
/**
* if we are running in demo environment, we have to cleanup the db files (specifically the jobs table)
*/
export function cleanupDemoAtMidnight() {
cron.schedule('0 0 * * *', cleanup);
}
async function cleanup() {
const settings = await getSettings();
if (settings.demoMode) {
const demoUser = getUsers(false).find((user) => user.username === 'demo');
if (demoUser == null) {
logger.error('Demo user not found, cannot remove Jobs');
return Promise.resolve();
}
removeJobsByUserId(demoUser.id);
}
}

View File

@@ -6,28 +6,41 @@
import cron from 'node-cron';
import { getListingsToGeocode, updateListingGeocoordinates } from '../storage/listingsStorage.js';
import { geocodeAddress, isGeocodingPaused } from '../geocoding/geoCodingService.js';
import { getJobs } from '../storage/jobStorage.js';
import { calculateDistanceForJob } from '../geocoding/distanceService.js';
import { getSettings } from '../storage/settingsStorage.js';
import logger from '../logger.js';
async function runTask() {
export async function runGeoCordTask() {
const listings = getListingsToGeocode();
if (listings.length === 0) {
return;
if (listings.length > 0) {
for (const listing of listings) {
if (isGeocodingPaused()) {
break;
}
const coords = await geocodeAddress(listing.address);
if (coords) {
updateListingGeocoordinates(listing.id, coords.lat, coords.lng);
}
}
}
for (const listing of listings) {
if (isGeocodingPaused()) {
break;
}
const coords = await geocodeAddress(listing.address);
if (coords) {
updateListingGeocoordinates(listing.id, coords.lat, coords.lng);
}
//additional run
const jobs = getJobs();
for (const job of jobs) {
calculateDistanceForJob(job.id, job.userId);
}
}
export async function initGeocodingCron() {
const settings = await getSettings();
if (settings.demoMode) {
logger.info('Do not start geo service as we are in demo mode');
return;
}
// run directly on start
await runTask();
await runGeoCordTask();
// then every 6 hours
cron.schedule('0 */6 * * *', runTask);
cron.schedule('0 */6 * * *', runGeoCordTask);
}

View File

@@ -5,12 +5,19 @@
import cron from 'node-cron';
import runActiveChecker from '../listings/listingActiveService.js';
import logger from '../logger.js';
import { getSettings } from '../storage/settingsStorage.js';
async function runTask() {
await runActiveChecker();
}
export async function initActiveCheckerCron() {
const settings = await getSettings();
if (settings.demoMode) {
logger.info('Do not start listing active checker as we are in demo mode');
return;
}
//run directly on start
await runTask();
// then every day at 1 am

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { autocomplete as nominatimAutocomplete } from './client/nominatimClient.js';
import logger from '../logger.js';
/**
* Autocompletes an address using Nominatim.
*
* @param {string} query - The search query.
* @returns {Promise<string[]>} List of matching addresses.
*/
export async function autocompleteAddress(query) {
if (!query) {
return [];
}
try {
return await nominatimAutocomplete(query);
} catch (error) {
logger.error('Error during address autocomplete:', error);
return [];
}
}

View File

@@ -100,6 +100,53 @@ async function doGeocode(address) {
}
}
/**
* Autocompletes an address using Nominatim.
*
* @param {string} query - The search query.
* @returns {Promise<string[]>} List of matching addresses.
*/
async function doAutocomplete(query) {
if (Date.now() - last429 < PAUSE_DURATION) {
return [];
}
const url = `${API_URL}?q=${encodeURIComponent(query)}&format=json&addressdetails=1&limit=5&countrycodes=de`;
try {
const response = await fetch(url, {
agent,
headers: {
'User-Agent': userAgent,
},
});
if (response.status === 429) {
logger.warn('Nominatim rate limit hit. Pausing for 1 hour.');
last429 = Date.now();
return [];
}
if (!response.ok) {
logger.error(`Nominatim API error: ${response.status} ${response.statusText}`);
return [];
}
const data = await response.json();
if (Array.isArray(data)) {
return data.map((item) => item.display_name);
}
return [];
} catch (error) {
logger.error('Error during Nominatim autocomplete:', error);
return [];
}
}
export const geocode = throttle(doGeocode);
export const autocomplete = throttle(doAutocomplete);
export const isPaused = () => Date.now() - last429 < PAUSE_DURATION;

View File

@@ -0,0 +1,61 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { distanceMeters } from '../listings/distanceCalculator.js';
import {
getListingsToCalculateDistance,
getListingsForUserToCalculateDistance,
updateListingDistance,
} from '../storage/listingsStorage.js';
import { getUserSettings } from '../storage/settingsStorage.js';
/**
* Calculates and updates distances for listings of a specific job.
* Only processes listings where distance_to_destination is null.
*
* @param {string} jobId
* @param {string} userId
* @returns {void}
*/
export function calculateDistanceForJob(jobId, userId) {
const userSettings = getUserSettings(userId);
const homeAddress = userSettings.home_address;
if (!homeAddress || !homeAddress.coords) {
return;
}
const listings = getListingsToCalculateDistance(jobId);
const { lat, lng } = homeAddress.coords;
for (const listing of listings) {
const dist = distanceMeters(lat, lng, listing.latitude, listing.longitude);
updateListingDistance(listing.id, dist);
}
}
/**
* Calculates and updates distances for all active listings of a user.
* Usually called when the user updates their home address.
*
* @param {string} userId
* @returns {void}
*/
export function calculateDistanceForUser(userId) {
const userSettings = getUserSettings(userId);
const homeAddress = userSettings.home_address;
if (!homeAddress || !homeAddress.coords) {
return;
}
const listings = getListingsForUserToCalculateDistance(userId);
const { lat, lng } = homeAddress.coords;
for (const listing of listings) {
const dist = distanceMeters(lat, lng, listing.latitude, listing.longitude);
updateListingDistance(listing.id, dist);
}
}

View File

@@ -103,13 +103,17 @@ const REAL_ESTATE_TYPE = {
'haus-mieten': 'houserent',
'wohnung-mieten': 'apartmentrent',
'wohnung-kaufen': 'apartmentbuy',
'wohnung-kaufen-mit-balkon': 'apartmentbuy',
'eigentumswohnung-mit-garten': 'apartmentbuy',
'haus-kaufen': 'housebuy',
};
const WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP = {
// Category "Balkon/Terrasse"
'wohnung-mit-balkon-mieten': { equipment: ['balcony'] },
'wohnung-kaufen-mit-balkon': { equipment: ['balcony'] },
'wohnung-mit-garten-mieten': { equipment: ['garden'] },
'eigentumswohnung-mit-garten': { equipment: ['garden'] },
// Category "Wohnungstyp"
'souterrainwohnung-mieten': { apartmenttypes: ['halfbasement'] },
'erdgeschosswohnung-mieten': { apartmenttypes: ['groundfloor'] },
@@ -144,7 +148,7 @@ export function convertWebToMobile(webUrl) {
const realTypeKey = segments.at(-1);
let realType = REAL_ESTATE_TYPE[realTypeKey];
let additionalParamsFromWebPath;
let additionalParamsFromWebPath = WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP[realTypeKey] || null;
if (!realType) {
// Test for seo optimized apartment path (only used on the ImmoScout web app)
@@ -165,7 +169,7 @@ export function convertWebToMobile(webUrl) {
Object.entries(rawParams).filter(([key]) => key !== 'enteredFrom' && PARAM_NAME_MAP[key]),
);
const geocodes = `/${segments.slice(2, 5).join('/')}`;
const geocodes = `/${segments.slice(2, segments.length - 1).join('/')}`;
const isRadius = segments.includes('radius');
const mobileParams = {
searchType: isRadius ? 'radius' : 'region',

View File

@@ -0,0 +1,35 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
const R = 6371000; // Earth radius in meters
/**
* Calculate the great-circle distance between two points on Earth using the Haversine formula.
* This is to calculate the distance between the listing address & the address provided by the user. I know, it is only
* a rough estimation as this calculates the distance as a straight line, but it's more convenient than using an external
* service and still gives a good approximation for sorting purposes.
* Returns distance in meters.
*
* @param {number} lat1
* @param {number} lon1
* @param {number} lat2
* @param {number} lon2
* @returns {number}
*/
export function distanceMeters(lat1, lon1, lat2, lon2) {
const toRad = (deg) => (deg * Math.PI) / 180;
const phi1 = toRad(lat1);
const phi2 = toRad(lat2);
const dPhi = toRad(lat2 - lat1);
const dLambda = toRad(lon2 - lon1);
const a =
Math.sin(dPhi / 2) * Math.sin(dPhi / 2) +
Math.cos(phi1) * Math.cos(phi2) * Math.sin(dLambda / 2) * Math.sin(dLambda / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return Math.round(R * c * 10) / 10;
}

View File

@@ -8,37 +8,71 @@ import { randomBetween, sleep } from '../../utils.js';
const maxAttempts = 3;
const userAgents = [
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15',
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
];
/**
* Check if a listing is still active with up to 3 attempts and exponential backoff.
* Backoff waits are capped and the last wait is at most 2000 ms.
* Check if a listing is still active with up to 5 attempts and exponential backoff.
* Backoff waits are randomized and capped.
*
* Rules:
* - HTTP 200 => return 1
* - HTTP 200 => return 1 (if checkForText is provided and found, returns 0)
* - HTTP 401/403 => return -1 (most certainly detected as a bot)
* - HTTP 404 => return 0
* - Other statuses or network errors => retry until attempts are exhausted
*
* @returns {Promise<Integer>} 1 if active, o if not active and -1 if detected as bot
* @returns {Promise<Integer>} 1 if active, 0 if not active and -1 if detected as bot
*/
export default async function checkIfListingIsActive(link) {
export default async function checkIfListingIsActive(link, checkForText = null) {
await sleep(randomBetween(50, 100));
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const userAgent = userAgents[Math.floor(Math.random() * userAgents.length)];
const res = await fetch(link, {
redirect: 'manual',
headers: {
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36',
'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
'User-Agent': userAgent,
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
'Accept-Encoding': 'gzip, deflate, br',
'Cache-Control': 'max-age=0',
'Sec-Ch-Ua': '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"macOS"',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
'Upgrade-Insecure-Requests': '1',
Referer: 'https://www.google.com/',
},
});
if (res.status === 200) {
if (checkForText) {
const htmText = await res.text();
if (htmText.includes(checkForText)) {
return 0;
}
}
return 1;
}
if (res.status === 401) return -1;
if (res.status === 403) return -1;
if (res.status === 404) return 0;
if (res.status === 401 || res.status === 403) {
if (attempt < maxAttempts) {
await sleep(backoffDelay(attempt));
continue;
}
return -1;
}
if (res.status === 404 || res.status === 410) return 0;
// For any other status, only retry if attempts remain
if (attempt < maxAttempts) {
@@ -61,13 +95,13 @@ export default async function checkIfListingIsActive(link) {
}
/**
* Exponential backoff delay with cap.
* attempt: 1 -> 500ms, 2 -> 1000ms, 3 -> 2000ms (cap)
* Exponential backoff delay with cap and jitter.
* @param {number} attempt 1-based attempt index
* @returns {number} delay in ms
*/
function backoffDelay(attempt) {
const base = 500;
const cap = 2000;
return Math.min(base * 2 ** (attempt - 1), cap);
const delay = Math.min(base * 2 ** (attempt - 1), cap);
return delay + randomBetween(0, 1000);
}

View File

@@ -37,12 +37,12 @@ export const upsertJob = ({
if (existing) {
SqliteConnection.execute(
`UPDATE jobs
SET enabled = @enabled,
name = @name,
blacklist = @blacklist,
provider = @provider,
notification_adapter = @notification_adapter,
shared_with_user = @shareWithUsers
SET enabled = @enabled,
name = @name,
blacklist = @blacklist,
provider = @provider,
notification_adapter = @notification_adapter,
shared_with_user = @shareWithUsers
WHERE id = @id`,
{
id,
@@ -87,10 +87,10 @@ export const getJob = (jobId) => {
j.provider,
j.shared_with_user,
j.notification_adapter AS notificationAdapter,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
FROM jobs j
WHERE j.id = @id
LIMIT 1`,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
FROM jobs j
WHERE j.id = @id
LIMIT 1`,
{ id: jobId },
)[0];
if (!row) return null;
@@ -150,9 +150,10 @@ export const getJobs = () => {
j.provider,
j.shared_with_user,
j.notification_adapter AS notificationAdapter,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
FROM jobs j
ORDER BY j.name IS NULL, j.name`,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
FROM jobs j
WHERE j.enabled = 1
ORDER BY j.name IS NULL, j.name`,
);
return rows.map((row) => ({
...row,
@@ -250,11 +251,11 @@ export const queryJobs = ({
j.provider,
j.shared_with_user,
j.notification_adapter AS notificationAdapter,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
FROM jobs j
${whereSql}
${orderSql}
LIMIT @limit OFFSET @offset`,
${orderSql}
LIMIT @limit OFFSET @offset`,
params,
);

View File

@@ -48,7 +48,8 @@ export const getListingsKpisForJobIds = (jobIds = []) => {
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) AS activeCount,
AVG(price) AS avgPrice
FROM listings
WHERE job_id IN (${placeholders})`,
WHERE job_id IN (${placeholders})
AND manually_deleted = 0`,
jobIds,
)[0] || {};
@@ -80,6 +81,7 @@ export const getProviderDistributionForJobIds = (jobIds = []) => {
`SELECT provider, COUNT(*) AS cnt
FROM listings
WHERE job_id IN (${placeholders})
AND manually_deleted = 0
GROUP BY provider
ORDER BY cnt DESC`,
jobIds,
@@ -118,8 +120,8 @@ export const getActiveOrUnknownListings = () => {
return SqliteConnection.query(
`SELECT *
FROM listings
WHERE is_active is null
OR is_active = 1
WHERE (is_active is null OR is_active = 1)
AND manually_deleted = 0
ORDER BY provider`,
);
};
@@ -306,6 +308,9 @@ export const queryListings = ({
whereParts.push('(wl.id IS NULL)');
}
// Build whereSql (filtering by manually_deleted = 0)
whereParts.push('(l.manually_deleted = 0)');
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
const whereSqlWithAlias = whereSql
.replace(/\btitle\b/g, 'l.title')
@@ -365,13 +370,21 @@ export const queryListings = ({
* Delete all listings for a given job id.
*
* @param {string} jobId - The job identifier whose listings should be removed.
* @returns {any} The result from SqliteConnection.execute (may contain changes count).
* @param {boolean} [hardDelete=false] - Whether to hard delete from DB or just mark as deleted.
* @returns {any} The result from SqliteConnection.execute.
*/
export const deleteListingsByJobId = (jobId) => {
export const deleteListingsByJobId = (jobId, hardDelete = false) => {
if (!jobId) return;
if (hardDelete) {
return SqliteConnection.execute(
`DELETE FROM listings
WHERE job_id = @jobId`,
{ jobId },
);
}
return SqliteConnection.execute(
`DELETE
FROM listings
`UPDATE listings
SET manually_deleted = 1
WHERE job_id = @jobId`,
{ jobId },
);
@@ -381,15 +394,23 @@ export const deleteListingsByJobId = (jobId) => {
* Delete listings by a list of listing IDs.
*
* @param {string[]} ids - Array of listing IDs to delete.
* @param {boolean} [hardDelete=false] - Whether to hard delete from DB or just mark as deleted.
* @returns {any} The result from SqliteConnection.execute.
*/
export const deleteListingsById = (ids) => {
export const deleteListingsById = (ids, hardDelete = false) => {
if (!Array.isArray(ids) || ids.length === 0) return;
const placeholders = ids.map(() => '?').join(',');
if (hardDelete) {
return SqliteConnection.execute(
`DELETE FROM listings
WHERE id IN (${placeholders})`,
ids,
);
}
return SqliteConnection.execute(
`DELETE
FROM listings
WHERE id IN (${placeholders})`,
`UPDATE listings
SET manually_deleted = 1
WHERE id IN (${placeholders})`,
ids,
);
};
@@ -404,6 +425,7 @@ export const getListingsToGeocode = () => {
`SELECT id, address
FROM listings
WHERE is_active = 1
AND manually_deleted = 0
AND address IS NOT NULL
AND (latitude IS NULL OR longitude IS NULL)`,
);
@@ -443,6 +465,7 @@ export const getListingsForMap = ({ jobId, userId = null, isAdmin = false } = {}
'l.latitude != -1',
'l.longitude != -1',
'l.is_active = 1',
'l.manually_deleted = 0',
];
const params = { userId: userId || '__NO_USER__' };
@@ -479,7 +502,7 @@ export const getListingsForMap = ({ jobId, userId = null, isAdmin = false } = {}
* @returns {{title: string|null, address: string|null, price: number|null}[]}
*/
export const getAllEntriesFromListings = () => {
return SqliteConnection.query(`SELECT title, address, price FROM listings`);
return SqliteConnection.query(`SELECT title, address, price FROM listings WHERE manually_deleted = 0`);
};
/**
@@ -493,6 +516,7 @@ export const getGeocoordinatesByAddress = (address) => {
`SELECT latitude, longitude
FROM listings
WHERE address = @address
AND manually_deleted = 0
AND latitude IS NOT NULL
AND longitude IS NOT NULL
AND latitude != -1
@@ -502,3 +526,105 @@ export const getGeocoordinatesByAddress = (address) => {
)[0];
return row ? { lat: row.latitude, lng: row.longitude } : null;
};
/**
* Return all active listings for a given job that have geocoordinates but no distance set.
*
* @param {string} jobId
* @returns {Object[]}
*/
export const getListingsToCalculateDistance = (jobId) => {
return SqliteConnection.query(
`SELECT id, latitude, longitude
FROM listings
WHERE job_id = @jobId
AND is_active = 1
AND manually_deleted = 0
AND latitude IS NOT NULL
AND longitude IS NOT NULL
AND distance_to_destination IS NULL`,
{ jobId },
);
};
/**
* Return all active listings for a given user (across all jobs) that have geocoordinates.
*
* @param {string} userId
* @returns {Object[]}
*/
export const getListingsForUserToCalculateDistance = (userId) => {
return SqliteConnection.query(
`SELECT l.id, l.latitude, l.longitude
FROM listings l
JOIN jobs j ON l.job_id = j.id
WHERE j.user_id = @userId
AND l.is_active = 1
AND l.manually_deleted = 0
AND l.latitude IS NOT NULL
AND l.longitude IS NOT NULL`,
{ userId },
);
};
/**
* Update the distance to destination for a listing.
*
* @param {string} id
* @param {number} distance
* @returns {void}
*/
export const updateListingDistance = (id, distance) => {
SqliteConnection.execute(
`UPDATE listings
SET distance_to_destination = @distance
WHERE id = @id`,
{ id, distance },
);
};
/**
* Return a single listing by id.
*
* @param {string} id
* @param {string} userId
* @param {boolean} isAdmin
* @returns {Object|null}
*/
export const getListingById = (id, userId = null, isAdmin = false) => {
const params = { id, userId: userId || '__NO_USER__' };
let whereScoping = '';
if (!isAdmin) {
whereScoping = `AND (j.user_id = @userId OR EXISTS (SELECT 1 FROM json_each(j.shared_with_user) AS sw WHERE sw.value = @userId))`;
}
return (
SqliteConnection.query(
`SELECT l.*, j.name AS job_name, CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END AS isWatched
FROM listings l
LEFT JOIN jobs j ON j.id = l.job_id
LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
WHERE l.id = @id AND l.manually_deleted = 0 ${whereScoping}`,
params,
)[0] || null
);
};
/**
* Resets geocoordinates and distance for all listings related to a user.
*
* @param {string} userId
* @returns {void}
*/
export const resetGeocoordinatesAndDistanceForUser = (userId) => {
SqliteConnection.execute(
`UPDATE listings
SET latitude = NULL,
longitude = NULL,
distance_to_destination = NULL
WHERE job_id IN (
SELECT id FROM jobs j
WHERE j.user_id = @userId
)`,
{ userId },
);
};

View File

@@ -0,0 +1,16 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
export function up(db) {
// 1. Add manually_deleted column
db.exec(`ALTER TABLE listings ADD COLUMN manually_deleted INTEGER NOT NULL DEFAULT 0;`);
// 2. Remove change_set column
try {
db.exec(`ALTER TABLE listings DROP COLUMN change_set;`);
} catch {
// if column does not exists for whatever reason
}
}

View File

@@ -0,0 +1,12 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
// Migration: Removing city field and adding distance field
export function up(db) {
db.exec(`
ALTER TABLE listings ADD COLUMN distance_to_destination INTEGER;
`);
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
export function up(db) {
// 1. Remove old unique index
db.exec(`DROP INDEX IF EXISTS idx_settings_name;`);
// 2. Add new unique index for name and user_id.
// Since user_id can be NULL, we need a special index or use coalesce for the index.
// In SQLite, multiple NULLs are allowed in a UNIQUE index, which is fine for our global settings (user_id IS NULL).
// But we want only one global setting for a given name.
// Actually, in SQLite, UNIQUE allows multiple NULL values.
// To have only one NULL user_id for a name, we can use a partial index or COALESCE.
db.exec(`
CREATE UNIQUE INDEX idx_settings_name_user_id ON settings (name, IFNULL(user_id, 'GLOBAL_SETTING'));
`);
}

View File

@@ -37,12 +37,25 @@ function compileSettings(rows, configValues) {
* @returns {Record<string, any>}
*/
export async function refreshSettingsCache() {
const rows = SqliteConnection.query(`SELECT name, value FROM settings`);
const rows = SqliteConnection.query(`SELECT name, value FROM settings WHERE user_id IS NULL`);
const configValues = await readConfigFromStorage();
cachedSettingsConfig = compileSettings(rows, configValues);
return cachedSettingsConfig;
}
/**
* Retrieves user-specific settings from the database.
* @param {string} userId
* @returns {Record<string, any>}
*/
export function getUserSettings(userId) {
if (!userId || typeof userId !== 'string') {
return {};
}
const userRows = SqliteConnection.query(`SELECT name, value FROM settings WHERE user_id = @userId`, { userId });
return compileSettings(userRows, {});
}
/**
* Get the compiled settings config. Loads it once and caches the result.
* @returns {Record<string, any>}
@@ -77,16 +90,28 @@ export function upsertSettings(settingsMapOrEntry, userId = null) {
: Object.entries(settingsMapOrEntry || {});
for (const [name, rawValue] of entries) {
const id = nanoid();
const create_date = Date.now();
const json = toJson(rawValue);
SqliteConnection.execute(
`INSERT INTO settings (id, create_date, name, value, user_id)
if (rawValue === null) {
SqliteConnection.execute(
`DELETE FROM settings WHERE name = @name AND (user_id = @userId OR (user_id IS NULL AND @userId IS NULL))`,
{
name,
userId,
},
);
} else {
const id = nanoid();
const create_date = Date.now();
const json = toJson(rawValue);
SqliteConnection.execute(
`INSERT INTO settings (id, create_date, name, value, user_id)
VALUES (@id, @create_date, @name, @value, @userId)
ON CONFLICT(name) DO UPDATE SET value = excluded.value`,
{ id, create_date, name, value: json, userId },
);
ON CONFLICT(name, IFNULL(user_id, 'GLOBAL_SETTING')) DO UPDATE SET value = excluded.value`,
{ id, create_date, name, value: json, userId },
);
}
}
// keep cache in sync (only for global settings)
if (userId == null) {
refreshSettingsCache();
}
// keep cache in sync
refreshSettingsCache();
}

View File

@@ -7,6 +7,7 @@ import * as hasher from '../security/hash.js';
import { nanoid } from 'nanoid';
import SqliteConnection from './SqliteConnection.js';
import { getSettings } from './settingsStorage.js';
import { inDevMode } from '../../utils.js';
/**
* Get all users.
@@ -137,8 +138,12 @@ export const removeUser = (userId) => {
export const ensureDemoUserExists = async () => {
const settings = await getSettings();
if (!settings.demoMode) {
// Remove demo user (and cascade delete their jobs/listings)
SqliteConnection.execute(`DELETE FROM users WHERE username = 'demo'`);
if (!inDevMode()) {
// Remove demo user (and cascade delete their jobs/listings)
SqliteConnection.execute(`DELETE
FROM users
WHERE username = 'demo'`);
}
return;
}
// Ensure demo user exists when demo mode is on

View File

@@ -15,72 +15,86 @@ const deviceId = getUniqueId() || 'N/A';
const version = await getPackageVersion();
const FREDY_TRACKING_URL = 'https://fredy.orange-coding.net/tracking';
export const trackMainEvent = async () => {
const staticTrackingData = {
operatingSystem: os.platform(),
osVersion: os.release(),
arch: process.arch,
language: process.env.LANG || 'en',
nodeVersion: process.version || 'N/A',
deviceId,
version,
};
const shouldTrack = async () => {
const settings = await getSettings();
return settings.analyticsEnabled && !inDevMode();
};
const sendTrackingData = async (endpoint, payload) => {
try {
const settings = await getSettings();
if (settings.analyticsEnabled && !inDevMode()) {
const activeProvider = new Set();
const activeAdapter = new Set();
const jobs = getJobs();
if (jobs != null && jobs.length > 0) {
jobs.forEach((job) => {
job.provider.forEach((provider) => activeProvider.add(provider.id));
job.notificationAdapter.forEach((adapter) => activeAdapter.add(adapter.id));
});
const trackingObj = enrichTrackingObject({
adapter: Array.from(activeAdapter),
provider: Array.from(activeProvider),
});
await fetch(`${FREDY_TRACKING_URL}/main`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(trackingObj),
});
}
const response = await fetch(`${FREDY_TRACKING_URL}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload ? JSON.stringify(payload) : undefined,
});
if (!response.ok) {
logger.warn(`Error sending tracking data to ${endpoint}. Status: ${response.status}`);
}
} catch (error) {
logger.warn('Error sending tracking data', error);
logger.warn(`Error sending tracking data to ${endpoint}`, error);
}
};
export const trackMainEvent = async () => {
if (!(await shouldTrack())) return;
const activeProvider = new Set();
const activeAdapter = new Set();
const jobs = getJobs();
if (jobs != null && jobs.length > 0) {
jobs.forEach((job) => {
job.provider.forEach((provider) => activeProvider.add(provider.id));
job.notificationAdapter.forEach((adapter) => activeAdapter.add(adapter.id));
});
const trackingObj = await enrichTrackingObject({
adapter: Array.from(activeAdapter),
provider: Array.from(activeProvider),
});
await sendTrackingData('/main', trackingObj);
}
};
export const trackFeature = async (feature) => {
if (!(await shouldTrack())) return;
const trackingObj = await enrichTrackingObject({
feature,
});
await sendTrackingData('/feature', trackingObj);
};
/**
* Note, this will only be used when Fredy runs in demo mode
*/
export async function trackDemoAccessed() {
const settings = await getSettings();
if (settings.analyticsEnabled && !inDevMode() && settings.demoMode) {
try {
await fetch(`${FREDY_TRACKING_URL}/demo/accessed`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
logger.warn('Error sending tracking data', error);
}
const trackingObj = await enrichTrackingObject({});
await sendTrackingData('/demo/accessed', trackingObj);
}
}
async function enrichTrackingObject(trackingObject) {
const settings = await getSettings();
const operatingSystem = os.platform();
const osVersion = os.release();
const arch = process.arch;
const language = process.env.LANG || 'en';
const nodeVersion = process.version || 'N/A';
return {
...trackingObject,
...staticTrackingData,
isDemo: settings.demoMode,
operatingSystem,
osVersion,
arch,
nodeVersion,
language,
deviceId,
version,
};
}

View File

@@ -95,7 +95,7 @@ function isOneOf(word, arr) {
* @returns {boolean}
*/
function nullOrEmpty(val) {
return val == null || val.length === 0 || val === 'null' || val === 'undefined';
return val == null || val.length === 0;
}
/**

262
package-lock.json generated
View File

@@ -1,26 +1,27 @@
{
"name": "fredy",
"version": "18.0.0",
"version": "18.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fredy",
"version": "18.0.0",
"version": "18.0.2",
"license": "Apache-2.0",
"dependencies": {
"@douyinfe/semi-icons": "^2.90.13",
"@douyinfe/semi-ui": "2.90.13",
"@douyinfe/semi-ui-19": "^2.90.13",
"@sendgrid/mail": "8.1.6",
"@vitejs/plugin-react": "5.1.2",
"adm-zip": "^0.5.16",
"better-sqlite3": "^12.6.0",
"better-sqlite3": "^12.6.2",
"body-parser": "2.2.2",
"chart.js": "^4.5.1",
"cheerio": "^1.1.2",
"cookie-session": "2.1.1",
"handlebars": "4.7.8",
"lodash": "4.17.21",
"lodash": "4.17.23",
"maplibre-gl": "^5.16.0",
"nanoid": "5.1.6",
"node-cron": "^4.2.1",
@@ -28,13 +29,14 @@
"node-mailjet": "6.0.11",
"p-throttle": "^8.1.0",
"package-up": "^5.0.0",
"puppeteer": "^24.35.0",
"puppeteer": "^24.36.0",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"query-string": "9.3.1",
"react": "18.3.1",
"react": "19.2.3",
"react-chartjs-2": "^5.3.1",
"react-dom": "18.3.1",
"react-dom": "19.2.3",
"react-range-slider-input": "^3.3.2",
"react-router": "7.12.0",
"react-router-dom": "7.12.0",
"restana": "5.1.0",
@@ -61,7 +63,7 @@
"lint-staged": "16.2.7",
"mocha": "11.7.5",
"nodemon": "^3.1.11",
"prettier": "3.8.0"
"prettier": "3.8.1"
},
"engines": {
"node": ">=22.0.0",
@@ -1987,6 +1989,53 @@
"react-dom": ">=16.0.0"
}
},
"node_modules/@douyinfe/semi-ui-19": {
"version": "2.90.13",
"resolved": "https://registry.npmjs.org/@douyinfe/semi-ui-19/-/semi-ui-19-2.90.13.tgz",
"integrity": "sha512-OdJOOKEiRBTpwSdGSdtkwzAsQxehrQtvsU37YgMR3jGXL38PGN+UhEjdVbx+p6HS3G9fMbL1n/2+LvnHg4vx8A==",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1",
"@douyinfe/semi-animation": "2.90.13",
"@douyinfe/semi-animation-react": "2.90.13",
"@douyinfe/semi-foundation": "2.90.13",
"@douyinfe/semi-icons": "2.90.13",
"@douyinfe/semi-illustrations": "2.90.13",
"@douyinfe/semi-theme-default": "2.90.13",
"@tiptap/core": "^3.10.7",
"@tiptap/extension-document": "^3.10.7",
"@tiptap/extension-hard-break": "^3.10.7",
"@tiptap/extension-mention": "^3.10.7",
"@tiptap/extension-paragraph": "^3.10.7",
"@tiptap/extension-text": "^3.10.7",
"@tiptap/extensions": "^3.10.7",
"@tiptap/pm": "^3.10.7",
"@tiptap/react": "^3.10.7",
"async-validator": "^3.5.0",
"classnames": "^2.2.6",
"copy-text-to-clipboard": "^2.1.1",
"date-fns": "^2.29.3",
"date-fns-tz": "^1.3.8",
"fast-copy": "^3.0.1 ",
"jsonc-parser": "^3.3.1",
"lodash": "^4.17.21",
"prop-types": "^15.7.2",
"prosemirror-state": "^1.4.3",
"react-resizable": "^3.0.5",
"react-window": "^1.8.2",
"scroll-into-view-if-needed": "^2.2.24",
"utility-types": "^3.10.0"
},
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz",
@@ -2707,22 +2756,22 @@
}
},
"node_modules/@tiptap/core": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.15.3.tgz",
"integrity": "sha512-bmXydIHfm2rEtGju39FiQNfzkFx9CDvJe+xem1dgEZ2P6Dj7nQX9LnA1ZscW7TuzbBRkL5p3dwuBIi3f62A66A==",
"version": "3.16.0",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.16.0.tgz",
"integrity": "sha512-XegRaNuoQ/guzBQU2xHxOwFXXrtoXW9tiyXDhssSqylvZmBVSlRIPNHA6ArkHBKm6ehLf6+6Y9fF3uky1yCXYQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^3.15.3"
"@tiptap/pm": "^3.16.0"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.11.0.tgz",
"integrity": "sha512-P3j9lQ+EZ5Zg/isJzLpCPX7bp7WUBmz8GPs/HPlyMyN2su8LqXntITBZr8IP1JNBlB/wR83k/W0XqdC57mG7cA==",
"version": "3.16.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.16.0.tgz",
"integrity": "sha512-nFL7FMu1LjZ5ZGf4U3tw56JLj/SpLysZvHQ1EneGB+90TEI/WReOvTY9VwH1egGWwrl7/OvQuGKclbuLIsy+BA==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -2733,8 +2782,8 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.11.0",
"@tiptap/pm": "^3.11.0"
"@tiptap/core": "^3.16.0",
"@tiptap/pm": "^3.16.0"
}
},
"node_modules/@tiptap/extension-document": {
@@ -2751,9 +2800,9 @@
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.11.0.tgz",
"integrity": "sha512-nEHdWZHEJYX1II1oJQ4aeZ8O/Kss4BRbYFXQFGIvPelCfCYEATpUJh3aq3767ARSq40bOWyu+Dcd4SCW0We6Sw==",
"version": "3.16.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.16.0.tgz",
"integrity": "sha512-cokYXL8EkW+CFIlke70GLL7iKetUtYEp87muMG9oflczyj0BjmGAbO7Mskm+bcQBhxZ0dIYILTqKn2bNBvCDFw==",
"license": "MIT",
"optional": true,
"funding": {
@@ -2762,8 +2811,8 @@
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "^3.11.0",
"@tiptap/pm": "^3.11.0"
"@tiptap/core": "^3.16.0",
"@tiptap/pm": "^3.16.0"
}
},
"node_modules/@tiptap/extension-hard-break": {
@@ -2835,9 +2884,9 @@
}
},
"node_modules/@tiptap/pm": {
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.15.3.tgz",
"integrity": "sha512-Zm1BaU1TwFi3CQiisxjgnzzIus+q40bBKWLqXf6WEaus8Z6+vo1MT2pU52dBCMIRaW9XNDq3E5cmGtMc1AlveA==",
"version": "3.16.0",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.16.0.tgz",
"integrity": "sha512-FMxZ6Tc5ONKa/EByDV8lswct6YW2lF/wn11zqXmrfBZhdG7UQPTijpSwb6TCqaO5GOHmixaIaDPj+zimUREHQA==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
@@ -2865,13 +2914,13 @@
}
},
"node_modules/@tiptap/react": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.11.0.tgz",
"integrity": "sha512-SDGei/2DjwmhzsxIQNr6dkB6NxLgXZjQ6hF36NfDm4937r5NLrWrNk5tCsoDQiKZ0DHEzuJ6yZM5C7I7LZLB6w==",
"version": "3.16.0",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.16.0.tgz",
"integrity": "sha512-r1R19Ma4zxGt8ImiNOqSArAnWO239KUI9tTVeelgTyekPj7643lO8GbtuXJfAeWGPduDIpcAgR/Dd4NKieetiA==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"fast-deep-equal": "^3.1.3",
"fast-equals": "^5.3.3",
"use-sync-external-store": "^1.4.0"
},
"funding": {
@@ -2879,12 +2928,12 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.11.0",
"@tiptap/extension-floating-menu": "^3.11.0"
"@tiptap/extension-bubble-menu": "^3.16.0",
"@tiptap/extension-floating-menu": "^3.16.0"
},
"peerDependencies": {
"@tiptap/core": "^3.11.0",
"@tiptap/pm": "^3.11.0",
"@tiptap/core": "^3.16.0",
"@tiptap/pm": "^3.16.0",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
@@ -3674,9 +3723,9 @@
}
},
"node_modules/better-sqlite3": {
"version": "12.6.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.0.tgz",
"integrity": "sha512-FXI191x+D6UPWSze5IzZjhz+i9MK9nsuHsmTX9bXVl52k06AfZ2xql0lrgIUuzsMsJ7Vgl5kIptvDgBLIV3ZSQ==",
"version": "12.6.2",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz",
"integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -4177,9 +4226,9 @@
"license": "ISC"
},
"node_modules/chromium-bidi": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-12.0.1.tgz",
"integrity": "sha512-fGg+6jr0xjQhzpy5N4ErZxQ4wF7KLEvhGZXD6EgvZKDhu7iOhZXnZhcDxPJDcwTcrD48NPzOCo84RP2lv3Z+Cg==",
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-13.0.1.tgz",
"integrity": "sha512-c+RLxH0Vg2x2syS9wPw378oJgiJNXtYXUvnVAldUlt5uaHekn0CCU7gPksNgHjrH1qFhmjVXQj4esvuthuC7OQ==",
"license": "Apache-2.0",
"dependencies": {
"mitt": "^3.0.1",
@@ -4421,12 +4470,16 @@
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cookie-session": {
@@ -4491,6 +4544,17 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/core-js": {
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz",
"integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-js-compat": {
"version": "3.45.1",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.45.1.tgz",
@@ -4867,9 +4931,9 @@
}
},
"node_modules/devtools-protocol": {
"version": "0.0.1534754",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz",
"integrity": "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==",
"version": "0.0.1551306",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1551306.tgz",
"integrity": "sha512-CFx8QdSim8iIv+2ZcEOclBKTQY6BI1IEDa7Tm9YkwAXzEWFndTEzpTo5jAUhSnq24IC7xaDw0wvGcm96+Y3PEg==",
"license": "BSD-3-Clause"
},
"node_modules/diff": {
@@ -5811,8 +5875,18 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
@@ -7721,9 +7795,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.debounce": {
@@ -10110,9 +10184,9 @@
}
},
"node_modules/prettier": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.0.tgz",
"integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==",
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"bin": {
@@ -10444,17 +10518,17 @@
}
},
"node_modules/puppeteer": {
"version": "24.35.0",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.35.0.tgz",
"integrity": "sha512-sbjB5JnJ+3nwgSdRM/bqkFXqLxRz/vsz0GRIeTlCk+j+fGpqaF2dId9Qp25rXz9zfhqnN9s0krek1M/C2GDKtA==",
"version": "24.36.0",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.36.0.tgz",
"integrity": "sha512-BD/VCyV/Uezvd6o7Fd1DmEJSxTzofAKplzDy6T9d4WbLTQ5A+06zY7VwO91ZlNU22vYE8sidVEsTpTrKc+EEnQ==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "2.11.1",
"chromium-bidi": "12.0.1",
"chromium-bidi": "13.0.1",
"cosmiconfig": "^9.0.0",
"devtools-protocol": "0.0.1534754",
"puppeteer-core": "24.35.0",
"devtools-protocol": "0.0.1551306",
"puppeteer-core": "24.36.0",
"typed-query-selector": "^2.12.0"
},
"bin": {
@@ -10465,17 +10539,17 @@
}
},
"node_modules/puppeteer-core": {
"version": "24.35.0",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.35.0.tgz",
"integrity": "sha512-vt1zc2ME0kHBn7ZDOqLvgvrYD5bqNv5y2ZNXzYnCv8DEtZGw/zKhljlrGuImxptZ4rq+QI9dFGrUIYqG4/IQzA==",
"version": "24.36.0",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.36.0.tgz",
"integrity": "sha512-P3Ou0MAFDCQ0dK1d9F9+8jTrg6JvXjUacgG0YkJQP4kbEnUOGokSDEMmMId5ZhXD5HwsHM202E9VwEpEjWfwxg==",
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "2.11.1",
"chromium-bidi": "12.0.1",
"chromium-bidi": "13.0.1",
"debug": "^4.4.3",
"devtools-protocol": "0.0.1534754",
"devtools-protocol": "0.0.1551306",
"typed-query-selector": "^2.12.0",
"webdriver-bidi-protocol": "0.3.10",
"webdriver-bidi-protocol": "0.4.0",
"ws": "^8.19.0"
},
"engines": {
@@ -10751,13 +10825,10 @@
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
@@ -10773,16 +10844,15 @@
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^18.3.1"
"react": "^19.2.3"
}
},
"node_modules/react-draggable": {
@@ -10805,6 +10875,25 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-range-slider-input": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/react-range-slider-input/-/react-range-slider-input-3.3.2.tgz",
"integrity": "sha512-CGyD/6Vlc7qakSW+92WAKrp333Xo9W+udW62xvf6dSwqEj7LFSY75udcbNRtCQhuXW1O7o71yC4AC/CC0etqSg==",
"license": "MIT",
"dependencies": {
"clsx": "^1.1.1",
"core-js": "^3.22.4"
}
},
"node_modules/react-range-slider-input/node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -11432,13 +11521,10 @@
"optional": true
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/scroll-into-view-if-needed": {
"version": "2.2.31",
@@ -11513,9 +11599,9 @@
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/set-function-length": {
@@ -13038,9 +13124,9 @@
}
},
"node_modules/webdriver-bidi-protocol": {
"version": "0.3.10",
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.10.tgz",
"integrity": "sha512-5LAE43jAVLOhB/QqX4bwSiv0Hg1HBfMmOuwBSXHdvg4GMGu9Y0lIq7p4R/yySu6w74WmaR4GM4H9t2IwLW7hgw==",
"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==",
"license": "Apache-2.0"
},
"node_modules/whatwg-encoding": {

View File

@@ -1,6 +1,6 @@
{
"name": "fredy",
"version": "18.0.1",
"version": "19.3.6",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"prepare": "husky",
@@ -61,45 +61,46 @@
"dependencies": {
"@douyinfe/semi-icons": "^2.90.13",
"@douyinfe/semi-ui": "2.90.13",
"@douyinfe/semi-ui-19": "^2.90.13",
"@sendgrid/mail": "8.1.6",
"@vitejs/plugin-react": "5.1.2",
"@vitejs/plugin-react": "5.1.3",
"adm-zip": "^0.5.16",
"better-sqlite3": "^12.6.0",
"better-sqlite3": "^12.6.2",
"body-parser": "2.2.2",
"chart.js": "^4.5.1",
"cheerio": "^1.1.2",
"cheerio": "^1.2.0",
"cookie-session": "2.1.1",
"handlebars": "4.7.8",
"lodash": "4.17.21",
"maplibre-gl": "^5.16.0",
"lodash": "4.17.23",
"maplibre-gl": "^5.17.0",
"nanoid": "5.1.6",
"node-cron": "^4.2.1",
"node-fetch": "3.3.2",
"node-mailjet": "6.0.11",
"p-throttle": "^8.1.0",
"package-up": "^5.0.0",
"puppeteer": "^24.35.0",
"puppeteer": "^24.36.1",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"query-string": "9.3.1",
"react": "18.3.1",
"react": "19.2.4",
"react-chartjs-2": "^5.3.1",
"react-dom": "18.3.1",
"react-dom": "19.2.4",
"react-range-slider-input": "^3.3.2",
"react-router": "7.12.0",
"react-router-dom": "7.12.0",
"react-router": "7.13.0",
"react-router-dom": "7.13.0",
"restana": "5.1.0",
"semver": "^7.7.3",
"serve-static": "2.2.1",
"slack": "11.0.2",
"vite": "7.3.1",
"x-var": "^3.0.1",
"zustand": "^5.0.10"
"zustand": "^5.0.11"
},
"devDependencies": {
"@babel/core": "7.28.6",
"@babel/core": "7.29.0",
"@babel/eslint-parser": "7.28.6",
"@babel/preset-env": "7.28.6",
"@babel/preset-env": "7.29.0",
"@babel/preset-react": "7.28.5",
"chai": "6.2.2",
"eslint": "9.39.2",
@@ -112,6 +113,6 @@
"lint-staged": "16.2.7",
"mocha": "11.7.5",
"nodemon": "^3.1.11",
"prettier": "3.8.0"
"prettier": "3.8.1"
}
}

View File

@@ -41,7 +41,7 @@ Challenges:
_Returns the total number of listings for the given query._
```
curl -H "User-Agent: ImmoScout_27.3_26.0_._" \
curl -H "User-Agent: ImmoScout_27.12_26.2_._" \
-H "Accept: application/json" \
"https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin"
```
@@ -63,7 +63,7 @@ _The body is json encoded and contains data specifying additional results (adver
```
curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' \
-H "Connection: keep-alive" \
-H "User-Agent: ImmoScout_27.3_26.0_._" \
-H "User-Agent: ImmoScout_27.12_26.2_._" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"supportedResultListType":[],"userData":{}}'
@@ -78,7 +78,7 @@ curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calc
The response contains additional details not included in the listing response.
```
curl -H "User-Agent: ImmoScout_27.3_26.0_._" \
curl -H "User-Agent: ImmoScout_27.12_26.2_._" \
-H "Accept: application/json" \
"https://api.mobile.immobilienscout24.de/expose/158382494"
```

View File

@@ -3,6 +3,7 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/* eslint-disable no-unused-vars */
const db = {};
export const storeListings = (jobKey, providerId, listings) => {
if (!Array.isArray(listings)) throw Error('Not a valid array');
@@ -11,3 +12,16 @@ export const storeListings = (jobKey, providerId, listings) => {
export const getKnownListingHashesForJobAndProvider = (jobKey, providerId) => {
return db[providerId] || [];
};
export const getGeocoordinatesByAddress = (any) => {
return null;
};
export function getUserSettings(userId) {
return null;
}
export const updateListingDistance = (id, distance) => {
// noop
};
/* eslint-enable no-unused-vars */

View File

@@ -58,7 +58,7 @@ describe('#immoscout-mobile URL conversion', () => {
const response = await fetch(url, {
method: 'POST',
headers: {
'User-Agent': 'ImmoScout_27.3_26.0_._',
'User-Agent': 'ImmoScout_27.12_26.2_._',
'Content-Type': 'application/json',
},
body: JSON.stringify({
@@ -75,7 +75,9 @@ describe('#immoscout-mobile URL conversion', () => {
expect(responseBody.totalResults).to.be.greaterThan(0);
expect(responseBody.totalResults).to.be.greaterThan(0);
expect(responseBody.resultListItems.length).to.greaterThan(0);
expect(responseBody.resultListItems[0].item.realEstateType).to.equal(type);
expect(responseBody.resultListItems.filter((r) => r.type === 'EXPOSE_RESULT')[0].item.realEstateType).to.equal(
type,
);
}
});
});

View File

@@ -15,6 +15,15 @@ export const mockFredy = async () => {
'../lib/services/storage/listingsStorage.js': {
...mockStore,
},
'../lib/services/storage/settingsStorage.js': {
...mockStore,
},
'../lib/services/geocoding/geoCodingService.js': {
geocodeAddress: mockStore.getGeocoordinatesByAddress,
},
'../lib/services/storage/jobStorage.js': {
getJob: (jobKey) => ({ id: jobKey, userId: 'user1' }),
},
'../lib/notification/notify.js': {
send,
},

View File

@@ -8,6 +8,7 @@ import React, { useEffect } from 'react';
import InsufficientPermission from './components/permission/InsufficientPermission';
import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
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';
@@ -18,15 +19,16 @@ import Jobs from './views/jobs/Jobs';
import './App.less';
import TrackingModal from './components/tracking/TrackingModal.jsx';
import { Banner, Divider } from '@douyinfe/semi-ui';
import { Banner } from '@douyinfe/semi-ui-19';
import VersionBanner from './components/version/VersionBanner.jsx';
import Listings from './views/listings/Listings.jsx';
import MapView from './views/listings/Map.jsx';
import Navigation from './components/navigation/Navigation.jsx';
import { Layout } from '@douyinfe/semi-ui';
import { Layout } from '@douyinfe/semi-ui-19';
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';
export default function FredyApp() {
const actions = useActions();
@@ -39,12 +41,12 @@ export default function FredyApp() {
async function init() {
await actions.user.getCurrentUser();
if (!needsLogin()) {
await actions.features.getFeatures();
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();
}
setLoading(false);
@@ -58,7 +60,7 @@ export default function FredyApp() {
};
const isAdmin = () => currentUser != null && currentUser.isAdmin;
const { Footer, Sider, Content } = Layout;
const { Sider, Content } = Layout;
return loading ? null : needsLogin() ? (
<Routes>
@@ -67,11 +69,11 @@ export default function FredyApp() {
</Routes>
) : (
<Layout className="app">
<Layout className="app">
<Sider>
<Navigation isAdmin={isAdmin()} />
</Sider>
<Content>
<Sider>
<Navigation isAdmin={isAdmin()} />
</Sider>
<Layout className="app__main">
<Content className="app__content">
{versionUpdate?.newVersion && <VersionBanner />}
{settings.demoMode && (
<>
@@ -86,60 +88,57 @@ export default function FredyApp() {
</>
)}
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
<Divider />
<div className="app__content">
<Routes>
<Route path="/403" element={<InsufficientPermission />} />
<Route path="/jobs/new" element={<JobMutation />} />
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/jobs" element={<Jobs />} />
<Route path="/listings" element={<Listings />} />
<Route path="/map" element={<MapView />} />
<Route path="/watchlistManagement" element={<WatchlistManagement />} />
<Routes>
<Route path="/403" element={<InsufficientPermission />} />
<Route path="/jobs/new" element={<JobMutation />} />
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/jobs" element={<Jobs />} />
<Route path="/listings" element={<Listings />} />
<Route path="/listings/listing/:listingId" element={<ListingDetail />} />
<Route path="/map" element={<MapView />} />
<Route path="/watchlistManagement" element={<WatchlistManagement />} />
{/* Permission-aware routes */}
<Route
path="/users/new"
element={
<PermissionAwareRoute currentUser={currentUser}>
<UserMutator />
</PermissionAwareRoute>
}
/>
<Route
path="/users/edit/:userId"
element={
<PermissionAwareRoute currentUser={currentUser}>
<UserMutator />
</PermissionAwareRoute>
}
/>
<Route
path="/users"
element={
<PermissionAwareRoute currentUser={currentUser}>
<Users />
</PermissionAwareRoute>
}
/>
<Route
path="/generalSettings"
element={
<PermissionAwareRoute currentUser={currentUser}>
<GeneralSettings />
</PermissionAwareRoute>
}
/>
{/* Permission-aware routes */}
<Route
path="/users/new"
element={
<PermissionAwareRoute currentUser={currentUser}>
<UserMutator />
</PermissionAwareRoute>
}
/>
<Route
path="/users/edit/:userId"
element={
<PermissionAwareRoute currentUser={currentUser}>
<UserMutator />
</PermissionAwareRoute>
}
/>
<Route
path="/users"
element={
<PermissionAwareRoute currentUser={currentUser}>
<Users />
</PermissionAwareRoute>
}
/>
<Route path="/userSettings" element={<UserSettings />} />
<Route
path="/generalSettings"
element={
<PermissionAwareRoute currentUser={currentUser}>
<GeneralSettings />
</PermissionAwareRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</div>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Content>
</Layout>
<Footer>
<FredyFooter />
</Footer>
</Layout>
</Layout>
);
}

View File

@@ -1,45 +1,31 @@
.app {
height: 100%;
width: 100%;
height: 100vh;
width: 100vw;
&__main {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
&__content {
margin: 1rem;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
position: relative;
padding: 24px;
background-color: var(--semi-color-bg-0);
box-sizing: border-box;
display: flex;
flex-direction: column;
@media (max-width: 768px) {
padding: 12px;
}
}
}
.ui.inverted.segment {
background: #31303078 !important;
}
.ui.black.label,
.ui.black.labels .label {
background-color: #31303078 !important;
}
a:link {
color: #54a9ff;
background-color: transparent;
text-decoration: none;
}
a:visited {
color: #54a9ff;
background-color: transparent;
text-decoration: none;
}
a:hover {
color: #54a9ff;
background-color: transparent;
text-decoration: underline;
}
a:active {
color: #54a9ff;
background-color: transparent;
text-decoration: underline;
}
.semi-icon:not(.semi-tabs-bar .semi-tabs-tab .semi-icon) {
vertical-align: middle;
}

View File

@@ -7,8 +7,8 @@ import React from 'react';
import { HashRouter } from 'react-router-dom';
import { createRoot } from 'react-dom/client';
import en_US from '@douyinfe/semi-ui/lib/es/locale/source/en_US';
import { LocaleProvider } from '@douyinfe/semi-ui';
import en_US from '@douyinfe/semi-ui-19/lib/es/locale/source/en_US';
import { LocaleProvider } from '@douyinfe/semi-ui-19';
import App from './App';
import './Index.less';

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 React, { useState } from 'react';
import { Modal, Radio, RadioGroup, Typography } from '@douyinfe/semi-ui-19';
const { Text } = Typography;
const ListingDeletionModal = ({
visible,
onConfirm,
onCancel,
title = 'Delete Listings',
showOptions = true,
message = 'How would you like to delete the selected listing(s)?',
}) => {
const [deleteType, setDeleteType] = useState('soft');
const handleOk = () => {
onConfirm(!showOptions || deleteType === 'hard');
};
return (
<Modal
title={title}
visible={visible}
onOk={handleOk}
onCancel={onCancel}
okText="Confirm"
cancelText="Cancel"
style={{ maxWidth: '500px' }}
>
<div style={{ marginBottom: 16 }}>
<Text>{message}</Text>
</div>
{showOptions && (
<RadioGroup value={deleteType} onChange={(e) => setDeleteType(e.target.value)} style={{ width: '100%' }}>
<Radio value="soft" style={{ alignItems: 'flex-start', width: '100%' }}>
<div style={{ marginLeft: 8 }}>
<Text strong>Mark as deleted (Soft Delete)</Text>
<br />
<Text type="secondary">
Listings are kept in the database but marked as hidden. They will <b>not</b> re-appear during the next
scraping session.
</Text>
</div>
</Radio>
<Radio value="hard" style={{ marginTop: 16, alignItems: 'flex-start', width: '100%' }}>
<div style={{ marginLeft: 8 }}>
<Text strong>Remove from database (Hard Delete)</Text>
<br />
<Text type="secondary">
Listings are completely removed from the database.
<br />
<Text type="warning">
Consequence: They might re-appear when scraping the next time because Fredy won't know they were
previously found.
</Text>
</Text>
</div>
</Radio>
</RadioGroup>
)}
</Modal>
);
};
export default ListingDeletionModal;

View File

@@ -1,92 +1,80 @@
@import "DashboardCardColors.less";
.color-variant(@bg, @border, @text) {
background-color: @bg;
border: 1px solid @border;
color: @text;
}
.dashboard-card {
box-sizing: border-box;
padding: .8rem;
border-radius: .5rem;
border-width: 1px;
font-weight: 600;
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
/* Make all KPI boxes the same size regardless of content/font */
width: 100%;
max-width: none;
height: 10rem;
display: flex;
flex-direction: column;
height: 140px;
margin-bottom: 16px;
transition: transform 0.2s;
background-color: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
border: 1px solid var(--semi-color-border);
--pulse-color: rgba(255, 255, 255, 0.1);
position: relative;
z-index: 1;
overflow: visible;
&.blue {
.color-variant(@color-blue-bg, @color-blue-border, @color-blue-text);
}
&.orange {
.color-variant(@color-orange-bg, @color-orange-border, @color-orange-text);
}
&.green {
.color-variant(@color-green-bg, @color-green-border, @color-green-text);
}
&.purple {
.color-variant(@color-purple-bg, @color-purple-border, @color-purple-text);
}
&.gray {
.color-variant(@color-gray-bg, @color-gray-border, @color-gray-text);
}
&__header {
display: flex;
align-items: center;
gap: .6rem;
/* Keep header from growing content height */
min-height: 2rem;
overflow: hidden;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: inherit;
box-shadow: 0 4px 25px -2px var(--pulse-color);
opacity: 0;
animation: pulse 5s infinite ease-in-out;
pointer-events: none;
z-index: -1;
will-change: opacity;
}
&__icon {
border-radius: .6rem;
display: grid;
place-items: center;
}
&__title {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
}
&__content {
margin-top: .4rem;
font-size: .7rem;
flex: 1 1 auto;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
width: 100%;
}
&__value {
margin: 0;
font-size: 1.5rem;
line-height: 1.1;
color: #fff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 700;
margin-bottom: 4px;
color: var(--semi-color-text-0);
}
&__desc {
opacity: .8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.blue {
--pulse-color: var(--semi-color-primary);
box-shadow: 0 4px 20px -5px var(--pulse-color);
}
&.orange {
--pulse-color: var(--semi-color-warning);
box-shadow: 0 4px 20px -5px var(--pulse-color);
}
&.green {
--pulse-color: var(--semi-color-success);
box-shadow: 0 4px 20px -5px var(--pulse-color);
}
&.purple {
--pulse-color: var(--semi-color-info);
box-shadow: 0 4px 20px -5px var(--pulse-color);
}
&.gray {
--pulse-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 4px 20px -5px var(--pulse-color);
}
}
@keyframes pulse {
0%, 100% {
opacity: 0.1;
}
50% {
opacity: 0.5;
}
}

View File

@@ -3,12 +3,8 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/*
* Copyright (c) 2025 by Christian Kellner.
* 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';
export default function KpiCard({
@@ -20,21 +16,28 @@ export default function KpiCard({
color = 'gray',
children,
}) {
const { Text } = Typography;
return (
<div className={`dashboard-card ${color}`}>
<div className="dashboard-card__header">
<div className="dashboard-card__icon">{icon}</div>
<div className="dashboard-card__title">
<span>{title}</span>
<Card className={`dashboard-card ${color}`} bodyStyle={{ padding: '16px' }}>
<Space vertical align="start" spacing="tight" style={{ width: '100%' }}>
<Space>
<div className="dashboard-card__icon">{icon}</div>
<Text strong className="dashboard-card__title">
{title}
</Text>
</Space>
<div className="dashboard-card__content">
<div className="dashboard-card__value" style={{ fontSize: valueFontSize }}>
{value}
{children}
</div>
{description && (
<Text size="small" type="tertiary" className="dashboard-card__desc">
{description}
</Text>
)}
</div>
</div>
<div className="dashboard-card__content">
<p className="dashboard-card__value" style={{ fontSize: valueFontSize }}>
{value}
{children}
</p>
{description && <span className="dashboard-card__desc">{description}</span>}
</div>
</div>
</Space>
</Card>
);
}

View File

@@ -6,19 +6,23 @@
import React from 'react';
import './FredyFooter.less';
import { useSelector } from '../../services/state/store.js';
import { Typography } from '@douyinfe/semi-ui';
import { Typography, Layout, Space, Divider } from '@douyinfe/semi-ui-19';
export default function FredyFooter() {
const { Text } = Typography;
const { Footer } = Layout;
const version = useSelector((state) => state.versionUpdate.versionUpdate);
return (
<div className="fredyFooter">
<div className="fredyFooter__version">
<Text type="tertiary">Fredy V{version?.localFredyVersion || 'N/A'}</Text>
</div>
<div className="fredyFooter__copyRight">
<Text link={{ href: 'https://github.com/orangecoding', target: '_blank' }}>Made with </Text>
</div>
</div>
<Footer className="fredyFooter">
<Space split={<Divider layout="vertical" />}>
<Text type="tertiary" size="small">
Fredy V{version?.localFredyVersion || 'N/A'}
</Text>
<Text size="small" link={{ href: 'https://github.com/orangecoding', target: '_blank' }}>
Made with
</Text>
</Space>
</Footer>
);
}

View File

@@ -1,20 +1,12 @@
.fredyFooter {
background:rgb(53, 54, 60);
color: white;
background-color: var(--semi-color-bg-1);
display: flex;
justify-content: space-between;
justify-content: flex-end;
align-items: center;
height: 1.7rem;
border-radius: .3rem;
border-top: 1px solid #45464b;
&__version {
padding-left: .5rem;
font-size: small;
}
&__copyRight {
padding-right: 1rem;
}
padding: 0 1rem;
height: 32px;
border-top: 1px solid var(--semi-color-border);
z-index: 1000;
position: relative;
flex-shrink: 0;
}

View File

@@ -20,12 +20,13 @@ import {
Pagination,
Toast,
Empty,
} from '@douyinfe/semi-ui';
} from '@douyinfe/semi-ui-19';
import {
IconAlertTriangle,
IconDelete,
IconDescend2,
IconEdit,
IconCopy,
IconPlayCircle,
IconBriefcase,
IconBell,
@@ -34,6 +35,7 @@ import {
IconPlusCircle,
} from '@douyinfe/semi-icons';
import { useNavigate } from 'react-router-dom';
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
import { useActions, useSelector } from '../../../services/state/store.js';
import { xhrDelete, xhrPut, xhrPost } from '../../../services/xhr.js';
import debounce from 'lodash/debounce';
@@ -59,6 +61,9 @@ const JobGrid = () => {
const [activityFilter, setActivityFilter] = useState(null);
const [showFilterBar, setShowFilterBar] = useState(false);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [pendingDeletion, setPendingDeletion] = useState(null); // { type: 'job'|'listings', jobId }
const pendingJobIdRef = useRef(null);
const evtSourceRef = useRef(null);
@@ -124,24 +129,35 @@ const JobGrid = () => {
};
}, [handleFilterChange]);
const onJobRemoval = async (jobId) => {
try {
await xhrDelete('/api/jobs', { jobId });
Toast.success('Job successfully removed');
loadData();
actions.jobsData.getJobs(); // refresh select list too
} catch (error) {
Toast.error(error);
}
const onJobRemoval = (jobId) => {
setPendingDeletion({ type: 'job', jobId });
setDeleteModalVisible(true);
};
const onListingRemoval = async (jobId) => {
const onListingRemoval = (jobId) => {
setPendingDeletion({ type: 'listings', jobId });
setDeleteModalVisible(true);
};
const confirmDeletion = async (hardDelete) => {
const { type, jobId } = pendingDeletion;
try {
await xhrDelete('/api/listings/job', { jobId });
Toast.success('Listings successfully removed');
if (type === 'job') {
await xhrDelete('/api/jobs', { jobId });
Toast.success('Job and listings successfully removed');
} else if (type === 'listings') {
await xhrDelete('/api/listings/job', { jobId, hardDelete });
Toast.success('Listings successfully removed');
}
loadData();
if (type === 'job') {
actions.jobsData.getJobs(); // refresh select list too
}
} catch (error) {
Toast.error(error);
Toast.error(error.message || 'Error performing deletion');
} finally {
setDeleteModalVisible(false);
setPendingDeletion(null);
}
};
@@ -184,29 +200,21 @@ const JobGrid = () => {
return (
<div className="jobGrid">
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Button
style={{ width: '7rem', margin: 0 }}
type="primary"
icon={<IconPlusCircle />}
className="jobs__newButton"
onClick={() => navigate('/jobs/new')}
>
<Space vertical align="start" style={{ width: '100%', marginBottom: '16px' }} spacing="medium">
<Button type="primary" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}>
New Job
</Button>
<div className="jobGrid__searchbar">
<div className="jobGrid__searchbar" style={{ width: '100%' }}>
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
<Button
icon={<IconFilter />}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
</Popover>
<Button
icon={<IconFilter />}
style={{ marginLeft: '8px' }}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
</div>
</div>
</Space>
{showFilterBar && (
<div className="jobGrid__toolbar">
@@ -274,7 +282,6 @@ const JobGrid = () => {
<Card
className="jobGrid__card"
bodyStyle={{ padding: '16px' }}
headerLine={true}
title={
<div className="jobGrid__header">
<Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title">
@@ -287,7 +294,9 @@ const JobGrid = () => {
'This job has been shared with you by another user, therefor it is read-only.',
)}
>
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)', marginLeft: '8px' }} />
<div>
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)', marginLeft: '8px' }} />
</div>
</Popover>
)}
</div>
@@ -343,40 +352,61 @@ const JobGrid = () => {
<div className="jobGrid__actions">
<Popover content={getPopoverContent('Run Job')}>
<Button
type="primary"
theme="solid"
icon={<IconPlayCircle />}
disabled={job.isOnlyShared || job.running}
onClick={() => onJobRun(job.id)}
/>
<div>
<Button
type="primary"
style={{ background: '#21aa21b5' }}
size="small"
theme="solid"
icon={<IconPlayCircle />}
disabled={job.isOnlyShared || job.running}
onClick={() => onJobRun(job.id)}
/>
</div>
</Popover>
<Popover content={getPopoverContent('Edit a Job')}>
<Button
type="secondary"
theme="solid"
icon={<IconEdit />}
disabled={job.isOnlyShared}
onClick={() => navigate(`/jobs/edit/${job.id}`)}
/>
<div>
<Button
type="secondary"
size="small"
icon={<IconEdit />}
disabled={job.isOnlyShared}
onClick={() => navigate(`/jobs/edit/${job.id}`)}
/>
</div>
</Popover>
<Popover content={getPopoverContent('Clone Job')}>
<div>
<Button
type="tertiary"
size="small"
icon={<IconCopy />}
disabled={job.isOnlyShared}
onClick={() => navigate('/jobs/new', { state: { cloneFrom: job.id } })}
/>
</div>
</Popover>
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
<Button
type="danger"
theme="solid"
icon={<IconDescend2 />}
disabled={job.isOnlyShared}
onClick={() => onListingRemoval(job.id)}
/>
<div>
<Button
type="danger"
size="small"
icon={<IconDescend2 />}
disabled={job.isOnlyShared}
onClick={() => onListingRemoval(job.id)}
/>
</div>
</Popover>
<Popover content={getPopoverContent('Delete Job')}>
<Button
type="danger"
theme="solid"
icon={<IconDelete />}
disabled={job.isOnlyShared}
onClick={() => onJobRemoval(job.id)}
/>
<div>
<Button
type="danger"
size="small"
icon={<IconDelete />}
disabled={job.isOnlyShared}
onClick={() => onJobRemoval(job.id)}
/>
</div>
</Popover>
</div>
</div>
@@ -395,6 +425,21 @@ const JobGrid = () => {
/>
</div>
)}
<ListingDeletionModal
visible={deleteModalVisible}
title={pendingDeletion?.type === 'job' ? 'Delete Job' : 'Delete Listings'}
showOptions={pendingDeletion?.type !== 'job'}
message={
pendingDeletion?.type === 'job'
? 'Are you sure you want to delete this job? All associated listings will be removed from the database.'
: 'How would you like to delete the selected listing(s)?'
}
onConfirm={confirmDeletion}
onCancel={() => {
setDeleteModalVisible(false);
setPendingDeletion(null);
}}
/>
</div>
);
};

View File

@@ -1,11 +1,16 @@
.jobGrid {
&__card {
height: 100%;
transition: transform 0.2s;
transition: transform 0.2s, box-shadow 0.2s;
background-color: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
border: 1px solid var(--semi-color-border);
box-shadow: 0 0 15px -3px rgb(78 78 78 / 50%);
&:hover {
transform: translateY(-4px);
box-shadow: var(--semi-shadow-elevated);
box-shadow: 0 0 15px -3px rgb(78 78 78 / 70%);
background-color: rgba(36, 36, 36, 1);
}
}
@@ -19,12 +24,14 @@
&__toolbar {
&__card {
border-radius: 5px;
border-radius: var(--semi-border-radius-medium);
display: flex;
flex-direction: column;
gap: .3rem;
background: #232429;
background: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
padding: 0.5rem;
border: 1px solid var(--semi-color-border);
}
}

View File

@@ -19,7 +19,7 @@ import {
Select,
Popover,
Empty,
} from '@douyinfe/semi-ui';
} from '@douyinfe/semi-ui-19';
import {
IconBriefcase,
IconCart,
@@ -31,7 +31,11 @@ import {
IconStarStroked,
IconSearch,
IconFilter,
IconActivity,
IconEyeOpened,
} from '@douyinfe/semi-icons';
import { useNavigate } from 'react-router-dom';
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
import no_image from '../../../assets/no_image.jpg';
import * as timeService from '../../../services/time/timeService.js';
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
@@ -48,6 +52,7 @@ const ListingsGrid = () => {
const providers = useSelector((state) => state.provider);
const jobs = useSelector((state) => state.jobsData.jobs);
const actions = useActions();
const navigate = useNavigate();
const [page, setPage] = useState(1);
const pageSize = 40;
@@ -61,6 +66,9 @@ const ListingsGrid = () => {
const [providerFilter, setProviderFilter] = useState(null);
const [showFilterBar, setShowFilterBar] = useState(false);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [listingToDelete, setListingToDelete] = useState(null);
const loadData = () => {
actions.listingsData.getListingsData({
page,
@@ -102,17 +110,36 @@ const ListingsGrid = () => {
setPage(_page);
};
const confirmDeletion = async (hardDelete) => {
try {
await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete });
Toast.success('Listing successfully removed');
loadData();
} catch (error) {
Toast.error(error.message || 'Error deleting listing');
} finally {
setDeleteModalVisible(false);
setListingToDelete(null);
}
};
const cap = (val) => {
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
};
return (
<div className="listingsGrid">
<div className="listingsGrid__searchbar">
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
<Button
icon={<IconFilter />}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
<div>
<Button
icon={<IconFilter />}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
</div>
</Popover>
</div>
{showFilterBar && (
@@ -216,6 +243,8 @@ const ListingsGrid = () => {
<Col key={item.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}>
<Card
className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`}
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/listings/listing/${item.id}`)}
cover={
<div style={{ position: 'relative' }}>
<div className="listingsGrid__imageContainer">
@@ -248,11 +277,9 @@ const ListingsGrid = () => {
bodyStyle={{ padding: '12px' }}
>
<div className="listingsGrid__content">
<a href={item.url} target="_blank" rel="noopener noreferrer" className="listingsGrid__titleLink">
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
{item.title}
</Text>
</a>
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
{cap(item.title)}
</Text>
<Space vertical align="start" spacing={2} style={{ width: '100%', marginTop: 8 }}>
<Text type="secondary" icon={<IconCart />} size="small">
{item.price}
@@ -272,31 +299,40 @@ const ListingsGrid = () => {
<Text type="tertiary" size="small" icon={<IconBriefcase />}>
{item.provider.charAt(0).toUpperCase() + item.provider.slice(1)}
</Text>
{item.distance_to_destination ? (
<Text type="tertiary" size="small" icon={<IconActivity />}>
{item.distance_to_destination} m to chosen address
</Text>
) : (
<Text type="tertiary" size="small" icon={<IconActivity />}>
Distance cannot be calculated, provide an address
</Text>
)}
</Space>
<Divider margin=".6rem" />
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div className="listingsGrid__linkButton" onClick={(e) => e.stopPropagation()}>
<a href={item.link} target="_blank" rel="noopener noreferrer">
<IconLink />
</a>
</div>
<Button
title="Link to listing"
type="primary"
type="secondary"
size="small"
onClick={async () => {
window.open(item.link);
}}
icon={<IconLink />}
title="View Details"
onClick={() => navigate(`/listings/listing/${item.id}`)}
icon={<IconEyeOpened />}
/>
<Button
title="Remove"
type="danger"
size="small"
onClick={async () => {
try {
await xhrDelete('/api/listings/', { ids: [item.id] });
Toast.success('Listing(s) successfully removed');
loadData();
} catch (error) {
Toast.error(error);
}
onClick={(e) => {
e.stopPropagation();
setListingToDelete(item.id);
setDeleteModalVisible(true);
}}
icon={<IconDelete />}
/>
@@ -317,6 +353,14 @@ const ListingsGrid = () => {
/>
</div>
)}
<ListingDeletionModal
visible={deleteModalVisible}
onConfirm={confirmDeletion}
onCancel={() => {
setDeleteModalVisible(false);
setListingToDelete(null);
}}
/>
</div>
);
};

View File

@@ -33,11 +33,15 @@
&__card {
height: 100%;
transition: transform 0.2s;
transition: transform 0.2s, box-shadow 0.2s;
background-color: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
border: 1px solid var(--semi-color-border);
&:hover {
transform: translateY(-4px);
box-shadow: var(--semi-shadow-elevated);
background-color: rgba(36, 36, 36, 1);
}
&--inactive {
@@ -90,17 +94,45 @@
}
&__toolbar {
&__card {
border-radius: 5px;
border-radius: var(--semi-border-radius-medium);
display: flex;
flex-direction: column;
gap: .3rem;
background: #232429;
background: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
padding: 0.5rem;
border: 1px solid var(--semi-color-border);
}
}
&__setupButton {
margin-bottom: 1rem;
}
&__linkButton {
background: var(--semi-color-primary);
font-size: 14px;
line-height: 20px;
font-weight: 600;
height: 24px;
width: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
a {
color: white;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
&:hover {
background: var(--semi-color-primary-hover);
}
}
}

View File

@@ -4,7 +4,7 @@
*/
import React from 'react';
import { Typography } from '@douyinfe/semi-ui';
import { Typography } from '@douyinfe/semi-ui-19';
export default function Headline({ text, size = 3 } = {}) {
const { Title } = Typography;

View File

@@ -4,7 +4,7 @@
*/
import React from 'react';
import { Button } from '@douyinfe/semi-ui';
import { Button } from '@douyinfe/semi-ui-19';
import { xhrPost } from '../../services/xhr';
import { IconUser } from '@douyinfe/semi-icons';

View File

@@ -6,5 +6,6 @@
gap: 0.5rem;
width: 100%;
display: flex;
padding-bottom: 12px;
}
}

View File

@@ -4,7 +4,7 @@
*/
import React, { useEffect, useState } from 'react';
import { Button, Nav } from '@douyinfe/semi-ui';
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';
import heart from '../../assets/heart.png';
@@ -12,7 +12,6 @@ import Logout from '../logout/Logout.jsx';
import { useLocation, useNavigate } from 'react-router-dom';
import './Navigate.less';
import { useFeature } from '../../hooks/featureHook.js';
import { useScreenWidth } from '../../hooks/screenWidth.js';
export default function Navigation({ isAdmin }) {
@@ -21,7 +20,6 @@ export default function Navigation({ isAdmin }) {
const width = useScreenWidth();
const [collapsed, setCollapsed] = useState(width <= 850);
const watchlistFeature = useFeature('WATCHLIST_MANAGEMENT') || false;
useEffect(() => {
if (width <= 850) {
@@ -46,11 +44,9 @@ export default function Navigation({ isAdmin }) {
if (isAdmin) {
const settingsItems = [
{ itemKey: '/users', text: 'User Management' },
{ itemKey: '/userSettings', text: 'User Specific Settings' },
{ itemKey: '/generalSettings', text: 'General Settings' },
];
if (watchlistFeature) {
settingsItems.push({ itemKey: '/watchlistManagement', text: 'Watchlist Management' });
}
items.push({
itemKey: 'settings',
@@ -58,6 +54,13 @@ export default function Navigation({ isAdmin }) {
icon: <IconSetting />,
items: settingsItems,
});
} else {
items.push({
itemKey: 'settings',
text: 'Settings',
icon: <IconSetting />,
items: [{ itemKey: '/userSettings', text: 'User Specific Settings' }],
});
}
function parsePathName(name) {
@@ -67,20 +70,18 @@ export default function Navigation({ isAdmin }) {
return (
<Nav
style={{ height: '100%' }}
style={{ height: '100%', maxWidth: collapsed ? '60px' : '240px' }}
items={items}
isCollapsed={collapsed}
selectedKeys={[parsePathName(location.pathname)]}
onSelect={(key) => {
navigate(key.itemKey);
}}
header={<img src={collapsed ? heart : logoWhite} width={collapsed ? '80' : '160'} alt="Fredy Logo" />}
header={<img src={collapsed ? heart : logoWhite} width={collapsed ? '30' : '120'} alt="Fredy Logo" />}
footer={
<Nav.Footer className="navigate__footer">
<Logout text={!collapsed} />
<Button icon={<IconSidebar />} onClick={() => setCollapsed(!collapsed)}>
{!collapsed && 'Collapse'}
</Button>
<Button icon={<IconSidebar />} onClick={() => setCollapsed(!collapsed)} />
</Nav.Footer>
}
/>

View File

@@ -4,16 +4,16 @@
*/
import React from 'react';
import { Card } from '@douyinfe/semi-ui';
import { Card } from '@douyinfe/semi-ui-19';
import './SegmentParts.less';
export const SegmentPart = ({ name, Icon = null, children, helpText = null }) => {
export const SegmentPart = ({ name, Icon = null, children, helpText = null, className = '' }) => {
const { Meta } = Card;
return (
<Card
className="segmentParts"
className={`segmentParts ${className}`}
title={
(helpText || name) && (
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />

View File

@@ -5,7 +5,7 @@
import React from 'react';
import { Empty, Table, Button } from '@douyinfe/semi-ui';
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
export default function NotificationAdapterTable({ notificationAdapter = [], onRemove, onEdit } = {}) {

View File

@@ -5,10 +5,12 @@
import React from 'react';
import { Empty, Table, Button } from '@douyinfe/semi-ui';
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
import { Typography } from '@douyinfe/semi-ui';
export default function ProviderTable({ providerData = [], onRemove, onEdit } = {}) {
const { Text } = Typography;
return (
<Table
pagination={false}
@@ -22,11 +24,7 @@ export default function ProviderTable({ providerData = [], onRemove, onEdit } =
title: 'URL',
dataIndex: 'url',
render: (_, data) => {
return (
<a href={data.url} target="_blank" rel="noopener noreferrer">
Visit site
</a>
);
return <Text link={{ href: data.url, target: '_blank' }}>Open Provider</Text>;
},
},
{

View File

@@ -7,7 +7,7 @@ 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';
import { Table, Button, Empty } from '@douyinfe/semi-ui-19';
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
const empty = (

View File

@@ -4,7 +4,7 @@
*/
import React from 'react';
import { Modal } from '@douyinfe/semi-ui';
import { Modal } from '@douyinfe/semi-ui-19';
import Logo from '../logo/Logo.jsx';
import { xhrPost } from '../../services/xhr.js';

View File

@@ -4,9 +4,9 @@
*/
import React from 'react';
import { Collapse, Descriptions } from '@douyinfe/semi-ui';
import { Collapse, Descriptions } from '@douyinfe/semi-ui-19';
import { useSelector } from '../../services/state/store.js';
import { MarkdownRender } from '@douyinfe/semi-ui';
import { MarkdownRender } from '@douyinfe/semi-ui-19';
import './VersionBanner.less';

View File

@@ -63,16 +63,6 @@ export const useFredyState = create(
}
},
},
features: {
async getFeatures() {
try {
const response = await xhrGet('/api/features');
set((state) => ({ ...state.features, ...response.json }));
} catch (Exception) {
console.error('Error while trying to get resource for api/features. Error:', Exception);
}
},
},
provider: {
async getProvider() {
try {
@@ -205,6 +195,18 @@ export const useFredyState = create(
console.error('Error while trying to get resource for api/listings. Error:', Exception);
}
},
async getListing(listingId) {
try {
const response = await xhrGet(`/api/listings/${listingId}`);
set((state) => ({
listingsData: { ...state.listingsData, currentListing: response.json },
}));
return response.json;
} catch (Exception) {
console.error(`Error while trying to get resource for api/listings/${listingId}. Error:`, Exception);
throw Exception;
}
},
async getListingsForMap({ jobId, minPrice, maxPrice } = {}) {
try {
const qryString = queryString.stringify(
@@ -228,6 +230,16 @@ export const useFredyState = create(
}
},
},
userSettings: {
async getUserSettings() {
try {
const response = await xhrGet('/api/user/settings');
set((state) => ({ userSettings: { ...state.userSettings, settings: response.json } }));
} catch (Exception) {
console.error('Error while trying to get resource for api/user/settings. Error:', Exception);
}
},
},
};
// Initial state
@@ -239,10 +251,11 @@ export const useFredyState = create(
page: 1,
result: [],
mapListings: [],
currentListing: null,
maxPrice: 0,
},
features: {},
generalSettings: { settings: {} },
userSettings: { settings: {} },
demoMode: { demoMode: false },
versionUpdate: {},
provider: [],
@@ -265,9 +278,9 @@ export const useFredyState = create(
versionUpdate: { ...effects.versionUpdate },
listingsData: { ...effects.listingsData },
provider: { ...effects.provider },
features: { ...effects.features },
jobsData: { ...effects.jobsData },
user: { ...effects.user },
userSettings: { ...effects.userSettings },
};
return {

View File

@@ -4,7 +4,7 @@
*/
import React from 'react';
import { Button, Col, Row, Toast } from '@douyinfe/semi-ui';
import { Button, Col, Row, Toast } from '@douyinfe/semi-ui-19';
import {
IconTerminal,
IconStar,
@@ -20,7 +20,6 @@ import {
import { useSelector, useActions } from '../../services/state/store';
import KpiCard from '../../components/cards/KpiCard.jsx';
import PieChartCard from '../../components/cards/PieChartCard.jsx';
import Headline from '../../components/headline/Headline.jsx';
import './Dashboard.less';
import { SegmentPart } from '../../components/segment/SegmentPart.jsx';
@@ -39,12 +38,10 @@ export default function Dashboard() {
return (
<div className="dashboard">
<Headline text="Dashboard" size={3} />
<Row gutter={16} className="dashboard__row">
<Row gutter={[16, 16]} className="dashboard__row">
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
<SegmentPart name="General" Icon={IconTerminal}>
<Row gutter={16} className="dashboard__row">
<Row gutter={[16, 16]} className="dashboard__row">
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
<KpiCard
title="Search Interval"
@@ -104,7 +101,7 @@ export default function Dashboard() {
</Col>
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
<SegmentPart name="Overview" Icon={IconStar}>
<Row gutter={16} className="dashboard__row">
<Row gutter={[16, 16]} className="dashboard__row">
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
<KpiCard
title="Jobs"
@@ -136,7 +133,14 @@ export default function Dashboard() {
<KpiCard
title="Avg. Price"
color="purple"
value={`${!kpis.avgPriceOfListings ? '---' : kpis.avgPriceOfListings} EUR`}
value={`${
!kpis.avgPriceOfListings
? '---'
: new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(kpis.avgPriceOfListings)
}`}
icon={<IconNoteMoney />}
description="Avg. Price of listings"
/>
@@ -146,8 +150,13 @@ export default function Dashboard() {
</Col>
</Row>
<SegmentPart name="Provider Insights" Icon={IconStar} helpText="Percentage of found listings over all providers">
<PieChartCard title="Jobs per Provider" data={pieData} isLoading={false} />
<SegmentPart
name="Provider Insights"
Icon={IconStar}
helpText="Percentage of found listings over all providers"
className="dashboard__provider-insights"
>
<PieChartCard data={pieData} />
</SegmentPart>
</div>
);

View File

@@ -1,11 +1,33 @@
.dashboard {
display: flex;
flex-direction: column;
flex: 1;
&__row {
margin-bottom: 1rem;
/* Ensure grid items wrap to next line on narrow screens */
margin-bottom: 24px;
flex-wrap: wrap;
/* Vertical gap of 1rem between wrapped grid items (no px) */
.semi-col {
margin-bottom: 1rem;
margin-bottom: 0; // Handled by Row gutter
}
}
&__provider-insights {
flex: 1;
display: flex;
flex-direction: column;
margin: 0 !important;
.semi-card-body {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
max-height: 300px;
> * {
flex: 1;
}
}
}
}

View File

@@ -7,11 +7,11 @@ import React from 'react';
import { useActions, useSelector } from '../../services/state/store';
import { Divider, TimePicker, Button, Checkbox, Input, Modal } from '@douyinfe/semi-ui';
import { InputNumber } from '@douyinfe/semi-ui';
import { Divider, TimePicker, Button, Checkbox, Input, Modal } from '@douyinfe/semi-ui-19';
import { InputNumber } from '@douyinfe/semi-ui-19';
import { xhrPost } from '../../services/xhr';
import { SegmentPart } from '../../components/segment/SegmentPart';
import { Banner, Toast } from '@douyinfe/semi-ui';
import { Banner, Toast } from '@douyinfe/semi-ui-19';
import {
downloadBackup as downloadBackupZip,
precheckRestore as clientPrecheckRestore,

View File

@@ -12,8 +12,8 @@ import ProviderMutator from './components/provider/ProviderMutator';
import Headline from '../../../components/headline/Headline';
import { useActions, useSelector } from '../../../services/state/store';
import { xhrPost } from '../../../services/xhr';
import { useNavigate, useParams } from 'react-router-dom';
import { Divider, Input, Switch, Button, TagInput, Toast, Select } from '@douyinfe/semi-ui';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { Divider, Input, Switch, Button, TagInput, Toast, Select } from '@douyinfe/semi-ui-19';
import './JobMutation.less';
import { SegmentPart } from '../../../components/segment/SegmentPart';
import {
@@ -30,14 +30,20 @@ export default function JobMutator() {
const jobs = useSelector((state) => state.jobsData.jobs);
const shareableUserList = useSelector((state) => state.jobsData.shareableUserList);
const params = useParams();
const location = useLocation();
const cloneFromId = location.state?.cloneFrom;
const jobToClone = cloneFromId ? jobs.find((job) => job.id === cloneFromId) : null;
const jobToBeEdit = params.jobId == null ? null : jobs.find((job) => job.id === params.jobId);
const defaultBlacklist = jobToBeEdit?.blacklist || [];
const defaultName = jobToBeEdit?.name || null;
const defaultProviderData = jobToBeEdit?.provider || [];
const defaultNotificationAdapter = jobToBeEdit?.notificationAdapter || [];
const defaultEnabled = jobToBeEdit?.enabled ?? true;
const sourceJob = jobToBeEdit || jobToClone;
const defaultBlacklist = sourceJob?.blacklist || [];
const defaultName = jobToClone ? `Copy of - ${sourceJob?.name}` : sourceJob?.name || null;
const defaultProviderData = sourceJob?.provider || [];
const defaultNotificationAdapter = sourceJob?.notificationAdapter || [];
const defaultEnabled = sourceJob?.enabled ?? true;
const defaultShareWithUsers = sourceJob?.shared_with_user ?? [];
const [providerToEdit, setProviderToEdit] = useState(null);
const [providerCreationVisible, setProviderCreationVisibility] = useState(false);
@@ -47,7 +53,7 @@ export default function JobMutator() {
const [name, setName] = useState(defaultName);
const [blacklist, setBlacklist] = useState(defaultBlacklist);
const [notificationAdapterData, setNotificationAdapterData] = useState(defaultNotificationAdapter);
const [shareWithUsers, setShareWithUsers] = useState(jobToBeEdit?.shared_with_user ?? []);
const [shareWithUsers, setShareWithUsers] = useState(defaultShareWithUsers);
const [enabled, setEnabled] = useState(defaultEnabled);
const navigate = useNavigate();
const actions = useActions();

View File

@@ -9,7 +9,7 @@ import { transform } from '../../../../../services/transformer/notificationAdapt
import { xhrPost } from '../../../../../services/xhr';
import Help from './NotificationHelpDisplay';
import { useSelector } from '../../../../../services/state/store';
import { Banner, Button, Form, Modal, Select, Switch } from '@douyinfe/semi-ui';
import { Banner, Button, Form, Modal, Select, Switch } from '@douyinfe/semi-ui-19';
import './NotificationAdapterMutator.less';
import { useScreenWidth } from '../../../../../hooks/screenWidth.js';

View File

@@ -4,7 +4,7 @@
*/
import React from 'react';
import { Banner, MarkdownRender } from '@douyinfe/semi-ui';
import { Banner, MarkdownRender } from '@douyinfe/semi-ui-19';
export default function Help({ readme }) {
return (

View File

@@ -5,7 +5,7 @@
import React, { useState, useEffect } from 'react';
import { Banner, Modal, Select, Input } from '@douyinfe/semi-ui';
import { Banner, Modal, Select, Input } from '@douyinfe/semi-ui-19';
import { transform } from '../../../../../services/transformer/providerTransformer';
import { useSelector } from '../../../../../services/state/store';
import { IconLikeHeart } from '@douyinfe/semi-icons';

View File

@@ -0,0 +1,388 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useEffect, useRef, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useSelector, useActions } from '../../services/state/store.js';
import {
Typography,
Button,
Space,
Card,
Row,
Col,
Image,
Tag,
Divider,
Descriptions,
Banner,
Spin,
Toast,
} from '@douyinfe/semi-ui-19';
import {
IconArrowLeft,
IconMapPin,
IconCart,
IconClock,
IconBriefcase,
IconActivity,
IconLink,
IconStar,
IconStarStroked,
IconRealSize,
} from '@douyinfe/semi-icons';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import no_image from '../../assets/no_image.jpg';
import * as timeService from '../../services/time/timeService.js';
import { distanceMeters, getBoundsFromCoords } from './mapUtils.js';
import { xhrPost } from '../../services/xhr.js';
import './ListingDetail.less';
const { Title, Text } = Typography;
const STYLES = {
STANDARD: 'https://tiles.openfreemap.org/styles/bright',
};
export default function ListingDetail() {
const { listingId } = useParams();
const navigate = useNavigate();
const actions = useActions();
const listing = useSelector((state) => state.listingsData.currentListing);
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const mapContainer = useRef(null);
const map = useRef(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchListing() {
try {
setLoading(true);
await actions.listingsData.getListing(listingId);
} catch (e) {
console.error('Failed to load listing details:', e);
Toast.error('Failed to load listing details');
navigate('/listings');
} finally {
setLoading(false);
}
}
fetchListing();
}, [listingId]);
const hasGeo =
listing?.latitude != null && listing?.longitude != null && listing?.latitude !== -1 && listing?.longitude !== -1;
useEffect(() => {
if (loading || !listing || !mapContainer.current || !hasGeo) return;
if (map.current) {
map.current.remove();
}
map.current = new maplibregl.Map({
container: mapContainer.current,
style: STYLES.STANDARD,
center: [listing.longitude, listing.latitude],
zoom: 14,
cooperativeGestures: true,
});
map.current.addControl(new maplibregl.NavigationControl(), 'top-right');
new maplibregl.Marker({ color: '#3FB1CE' })
.setLngLat([listing.longitude, listing.latitude])
.setPopup(new maplibregl.Popup({ offset: 25 }).setHTML(`<h4>Listing Location</h4><p>${listing.address}</p>`))
.addTo(map.current);
if (homeAddress?.coords) {
new maplibregl.Marker({ color: 'red' })
.setLngLat([homeAddress.coords.lng, homeAddress.coords.lat])
.setPopup(new maplibregl.Popup({ offset: 25 }).setHTML(`<h4>Home Address</h4><p>${homeAddress.address}</p>`))
.addTo(map.current);
const bounds = getBoundsFromCoords([
[listing.longitude, listing.latitude],
[homeAddress.coords.lng, homeAddress.coords.lat],
]);
map.current.fitBounds(bounds, {
padding: 50,
maxZoom: 15,
});
const drawLine = () => {
if (!map.current || !map.current.isStyleLoaded()) return;
const distance = distanceMeters(
listing.latitude,
listing.longitude,
homeAddress.coords.lat,
homeAddress.coords.lng,
);
const midpoint = [
(listing.longitude + homeAddress.coords.lng) / 2,
(listing.latitude + homeAddress.coords.lat) / 2,
];
if (map.current.getSource('route')) {
map.current.getSource('route').setData({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: [
[listing.longitude, listing.latitude],
[homeAddress.coords.lng, homeAddress.coords.lat],
],
},
},
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: midpoint,
},
properties: {
distance: `${Math.round(distance)} m`,
},
},
],
});
} else {
map.current.addSource('route', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: [
[listing.longitude, listing.latitude],
[homeAddress.coords.lng, homeAddress.coords.lat],
],
},
},
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: midpoint,
},
properties: {
distance: `${Math.round(distance)} m`,
},
},
],
},
});
map.current.addLayer({
id: 'route',
type: 'line',
source: 'route',
layout: {
'line-join': 'round',
'line-cap': 'round',
},
paint: {
'line-color': '#3FB1CE',
'line-width': 4,
'line-dasharray': [2, 1],
},
filter: ['==', '$type', 'LineString'],
});
map.current.addLayer({
id: 'route-distance',
type: 'symbol',
source: 'route',
layout: {
'text-field': ['get', 'distance'],
'text-size': 14,
'text-offset': [0, -1],
'text-allow-overlap': true,
},
paint: {
'text-color': '#ffffff',
'text-halo-color': '#3FB1CE',
'text-halo-width': 2,
},
filter: ['==', '$type', 'Point'],
});
}
};
if (map.current.isStyleLoaded()) {
drawLine();
} else {
map.current.on('load', drawLine);
}
}
return () => {
if (map.current) {
map.current.remove();
map.current = null;
}
};
}, [listing, loading, homeAddress]);
const handleWatch = async () => {
try {
await xhrPost('/api/listings/watch', { listingId: listing.id });
Toast.success(listing.isWatched === 1 ? 'Removed from Watchlist' : 'Added to Watchlist');
actions.listingsData.getListing(listingId);
} catch (e) {
console.error('Failed to operate Watchlist:', e);
Toast.error('Failed to operate Watchlist');
}
};
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Spin size="large" />
</div>
);
}
if (!listing) return null;
const data = [
{
key: 'Job',
value: listing.job_name,
Icon: <IconBriefcase />,
},
{
key: 'Provider',
value: listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1),
Icon: <IconBriefcase />,
},
{ key: 'Price', value: `${listing.price}`, Icon: <IconCart /> },
{
key: 'Size',
value: listing.size ? `${listing.size}` : 'N/A',
Icon: <IconRealSize />,
},
{
key: 'Added',
value: timeService.format(listing.created_at),
Icon: <IconClock />,
},
];
return (
<div className="listing-detail">
<div className="listing-detail__back">
<Button icon={<IconArrowLeft />} onClick={() => navigate(-1)} theme="borderless">
Back
</Button>
</div>
<Card className="listing-detail__card">
<div className="listing-detail__header">
<Space vertical align="start" spacing="tight">
<Title heading={2} className="listing-detail__title">
{listing.title}
</Title>
<Space align="center">
<IconMapPin style={{ fontSize: '18px', color: 'var(--semi-color-primary)' }} />
<Text type="secondary">{listing.address || 'No address provided'}</Text>
</Space>
</Space>
<Space wrap className="listing-detail__header-actions">
<Button
icon={
listing.isWatched === 1 ? (
<IconStar style={{ color: 'var(--semi-color-warning)' }} />
) : (
<IconStarStroked />
)
}
onClick={handleWatch}
theme="light"
>
{listing.isWatched === 1 ? 'Watched' : 'Watch'}
</Button>
<Text link={{ href: listing.link, target: '_blank' }} icon={<IconLink />} underline>
Open listing
</Text>
</Space>
</div>
<Row>
<Col span={24} lg={12}>
<div className="listing-detail__image-container">
<Image
src={listing.image_url}
fallback={no_image}
style={{ width: '100%', height: '100%' }}
preview={true}
/>
</div>
</Col>
<Col span={24} lg={12}>
<div className="listing-detail__info-section">
<Title heading={4} style={{ marginBottom: '1rem' }}>
Details
</Title>
<Descriptions column={1}>
{data.map((item, index) => (
<Descriptions.Item key={index}>
<Space>
{item.Icon}
{item.value}
</Space>
</Descriptions.Item>
))}
</Descriptions>
<Divider margin="1.5rem" />
<Title heading={4} style={{ marginBottom: '1rem' }}>
Description
</Title>
<Text type="secondary" style={{ whiteSpace: 'pre-wrap' }}>
{listing.description || 'No description available.'}
</Text>
{listing.distance_to_destination && (
<>
<Divider margin="1.5rem" />
<Space align="center">
<IconActivity style={{ fontSize: '18px', color: 'var(--semi-color-primary)' }} />
<Text strong>Distance to home:</Text>
<Tag color="blue">{listing.distance_to_destination} m</Tag>
</Space>
</>
)}
</div>
</Col>
</Row>
</Card>
<div className="listing-detail__map-wrapper">
<Title heading={3}>Location</Title>
{!hasGeo ? (
<Banner
type="warning"
bordered
description="This listing has no valid geocoordinates, so we cannot show it on the map."
style={{ marginTop: '1rem' }}
/>
) : (
<div ref={mapContainer} className="listing-detail__map-container" />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
.listing-detail {
padding-bottom: 2rem;
&__back {
margin-bottom: 1.5rem;
}
&__card {
background-color: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
border: 1px solid var(--semi-color-border);
margin-bottom: 2rem;
overflow: hidden;
.semi-card-body {
padding: 0;
}
}
&__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 1.5rem;
gap: 1rem;
border-bottom: 1px solid var(--semi-color-border);
@media (max-width: 768px) {
flex-direction: column;
align-items: stretch;
& > .semi-space {
width: 100%;
}
&-actions {
width: 100%;
justify-content: flex-start;
margin-top: 0.5rem;
.semi-button {
flex: 1;
}
}
}
}
&__title {
margin: 0 !important;
word-break: break-word;
@media (max-width: 768px) {
font-size: 1.5rem;
}
}
&__image-container {
width: 100%;
height: 400px;
background-color: var(--semi-color-fill-0);
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
@media (max-width: 768px) {
height: 250px;
}
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
&__info-section {
padding: 1.5rem;
}
&__map-container {
height: 400px;
width: 100%;
border-radius: var(--semi-border-radius-medium);
margin-top: 1rem;
border: 1px solid var(--semi-color-border);
@media (max-width: 768px) {
height: 300px;
}
}
&__map-wrapper {
margin-top: 2rem;
margin-bottom: 3rem;
}
.info-tag {
font-size: 0.9rem;
padding: 0.2rem 0.6rem;
}
}
.listing-detail-popup {
.map-popup-content {
padding: 5px;
h4 {
margin: 5px 0;
}
}
}

View File

@@ -4,15 +4,22 @@
*/
import React, { useEffect, useRef, useState } from 'react';
import { renderToString } from 'react-dom/server';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { useSelector, useActions } from '../../services/state/store.js';
import { Select, Space, Typography, Button, Popover, Divider, Switch, Banner } from '@douyinfe/semi-ui';
import { IconFilter } from '@douyinfe/semi-icons';
import { distanceMeters, generateCircleCoords, getBoundsFromCenter, getBoundsFromCoords } from './mapUtils.js';
import { Select, Space, Typography, Button, Popover, Divider, Switch, Banner, Toast } from '@douyinfe/semi-ui-19';
import { IconFilter, IconLink } from '@douyinfe/semi-icons';
import { IconDelete, IconEyeOpened } from '@douyinfe/semi-icons';
import no_image from '../../assets/no_image.jpg';
import RangeSlider from 'react-range-slider-input';
import 'react-range-slider-input/dist/style.css';
import './Map.less';
import { xhrDelete } from '../../services/xhr.js';
import { Link, useNavigate } from 'react-router-dom';
import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
const { Text } = Typography;
@@ -65,8 +72,11 @@ export default function MapView() {
const mapContainer = useRef(null);
const map = useRef(null);
const markers = useRef([]);
const homeMarker = useRef(null);
const actions = useActions();
const navigate = useNavigate();
const listings = useSelector((state) => state.listingsData.mapListings);
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const [style, setStyle] = useState('STANDARD');
const [show3dBuildings, setShow3dBuildings] = useState(false);
@@ -74,6 +84,23 @@ export default function MapView() {
const [jobId, setJobId] = useState(null);
const [priceRange, setPriceRange] = useState([0, 0]);
const [showFilterBar, setShowFilterBar] = useState(false);
const [distanceFilter, setDistanceFilter] = useState(0);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [listingToDelete, setListingToDelete] = useState(null);
const confirmListingDeletion = async (hardDelete) => {
try {
await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete });
Toast.success('Listing successfully removed');
fetchListings();
} catch (error) {
Toast.error(error.message || 'Error deleting listing');
} finally {
setDeleteModalVisible(false);
setListingToDelete(null);
}
};
useEffect(() => {
setPriceRange([0, getMaxPrice()]);
@@ -93,6 +120,22 @@ export default function MapView() {
return listings.filter((listing) => listing.price && listing.price >= min && listing.price <= max);
};
useEffect(() => {
window.deleteListing = (id) => {
setListingToDelete(id);
setDeleteModalVisible(true);
};
window.viewDetails = (id) => {
navigate(`/listings/listing/${id}`);
};
return () => {
delete window.deleteListing;
delete window.viewDetails;
};
}, [navigate]);
useEffect(() => {
if (map.current) return;
@@ -150,6 +193,7 @@ export default function MapView() {
if (!map.current) return;
const add3dLayer = () => {
if (!map.current || !map.current.isStyleLoaded()) return;
if (show3dBuildings) {
if (!map.current.getSource('openfreemap')) {
map.current.addSource('openfreemap', {
@@ -201,11 +245,7 @@ export default function MapView() {
}
};
if (map.current.isStyleLoaded()) {
add3dLayer();
} else {
map.current.once('styledata', add3dLayer);
}
add3dLayer();
}, [show3dBuildings, style]);
const setMapStyle = (value) => {
@@ -225,12 +265,118 @@ export default function MapView() {
fetchListings();
}, [jobId]);
useEffect(() => {
if (!map.current) return;
if (homeAddress?.coords) {
// We only want to zoom/fly when distanceFilter OR homeAddress actually change,
// not on every render. useEffect dependency array handles this.
if (distanceFilter > 0) {
const bounds = getBoundsFromCenter([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter);
map.current.fitBounds(bounds, {
padding: 20,
maxZoom: 15,
duration: 1000,
});
} else {
map.current.flyTo({
center: [homeAddress.coords.lng, homeAddress.coords.lat],
zoom: 12,
duration: 1000,
});
}
} else {
const filtered = filterListings();
const coords = filtered
.filter((l) => l.latitude != null && l.longitude != null && l.latitude !== -1 && l.longitude !== -1)
.map((l) => [l.longitude, l.latitude]);
if (coords.length > 0) {
const bounds = getBoundsFromCoords(coords);
map.current.fitBounds(bounds, {
padding: 50,
maxZoom: 15,
duration: 1000,
});
}
}
}, [homeAddress?.address, distanceFilter, listings]);
useEffect(() => {
if (!map.current) return;
markers.current.forEach((marker) => marker.remove());
markers.current = [];
if (homeMarker.current) {
homeMarker.current.remove();
homeMarker.current = null;
}
if (homeAddress?.coords) {
homeMarker.current = new maplibregl.Marker({ color: 'red' })
.setLngLat([homeAddress.coords.lng, homeAddress.coords.lat])
.setPopup(
new maplibregl.Popup({ offset: 25 }).setHTML(
`<div class="map-popup-content"><h4>Home Address</h4><p>${homeAddress.address}</p></div>`,
),
)
.addTo(map.current);
}
const addCircleLayer = () => {
if (!map.current || !map.current.isStyleLoaded()) return;
if (map.current.getLayer('distance-circle')) map.current.removeLayer('distance-circle');
if (map.current.getLayer('distance-circle-outline')) map.current.removeLayer('distance-circle-outline');
if (map.current.getSource('distance-circle-source')) map.current.removeSource('distance-circle-source');
if (distanceFilter > 0 && homeAddress?.coords) {
const ret = generateCircleCoords([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter);
map.current.addSource('distance-circle-source', {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [ret],
},
},
});
map.current.addLayer({
id: 'distance-circle',
type: 'fill',
source: 'distance-circle-source',
paint: {
'fill-color': '#90EE90',
'fill-opacity': 0.3,
},
});
map.current.addLayer({
id: 'distance-circle-outline',
type: 'line',
source: 'distance-circle-source',
paint: {
'line-color': '#006400',
'line-width': 1,
},
});
}
};
const updateLayers = () => {
addCircleLayer();
};
if (map.current.isStyleLoaded()) {
updateLayers();
} else {
map.current.on('load', updateLayers);
}
filterListings().forEach((listing) => {
if (
listing.latitude != null &&
@@ -242,21 +388,59 @@ export default function MapView() {
? listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1)
: 'N/A';
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(
`<div class="map-popup-content">
<img src="${listing.image_url || no_image}" alt="${listing.title}" />
const popupContent = `
<div class="map-popup-content">
<img
src="${listing.image_url}"
onerror="this.onerror=null;this.src='${no_image}'"
/>
<h4>${listing.title}</h4>
<div class="info">
<span><strong>Price:</strong> ${listing.price ? listing.price + ' €' : 'N/A'}</span>
<span><strong>Address:</strong> ${listing.address || 'N/A'}</span>
<span><strong>Job:</strong> ${listing.job_name || 'N/A'}</span>
<span><strong>Provider:</strong> ${capitalizedProvider}</span>
<a href="${listing.link}" target="_blank" rel="noopener noreferrer">View Listing</a>
<span><strong>Size:</strong> ${listing.size != null ? `${listing.size}` : 'N/A'}</span>
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: space-between;">
<div class="map-popup-content__linkButton">
<a href="${listing.link}" target="_blank" rel="noopener noreferrer">
${renderToString(<IconLink />)}
</a>
</div>
<button
class="map-popup-content__detailsButton"
title="View Details"
onclick="viewDetails('${listing.id}')"
>
${renderToString(<IconEyeOpened />)}
</button>
<button
class="map-popup-content__deleteButton"
title="Remove"
onclick="deleteListing('${listing.id}')"
>
${renderToString(<IconDelete />)}
</button>
</div>
</div>
</div>`,
);
</div>`;
const marker = new maplibregl.Marker()
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(popupContent);
let color = '#3FB1CE'; // Default blue-ish
if (distanceFilter > 0 && homeAddress?.coords) {
const dist = distanceMeters(
homeAddress.coords.lat,
homeAddress.coords.lng,
listing.latitude,
listing.longitude,
);
if (dist <= distanceFilter * 1000) {
color = 'orange';
}
}
const marker = new maplibregl.Marker({ color })
.setLngLat([listing.longitude, listing.latitude])
.setPopup(popup)
.addTo(map.current);
@@ -264,7 +448,7 @@ export default function MapView() {
markers.current.push(marker);
}
});
}, [listings, priceRange]);
}, [listings, priceRange, homeAddress, distanceFilter]);
return (
<div className="map-view-container">
@@ -281,12 +465,14 @@ export default function MapView() {
</div>
</div>
<Popover content="Filter Results" style={{ color: 'white', padding: '.5rem' }}>
<Button
icon={<IconFilter />}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
<div>
<Button
icon={<IconFilter />}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
</div>
</Popover>
</div>
@@ -316,6 +502,29 @@ export default function MapView() {
</div>
</div>
<Divider layout="vertical" />
<div className="listingsGrid__toolbar__card">
<div>
<Text strong>Distance:</Text>
</div>
<div style={{ display: 'flex', gap: '.3rem', alignItems: 'center' }}>
<Select
placeholder="Distance"
style={{ width: 100 }}
onChange={(val) => {
setDistanceFilter(val);
}}
value={distanceFilter}
>
<Select.Option value={0}>---</Select.Option>
<Select.Option value={5}>5 km</Select.Option>
<Select.Option value={10}>10 km</Select.Option>
<Select.Option value={15}>15 km</Select.Option>
<Select.Option value={20}>20 km</Select.Option>
<Select.Option value={25}>25 km</Select.Option>
</Select>
</div>
</div>
<Divider layout="vertical" />
<div className="listingsGrid__toolbar__card">
<div>
<Text strong>Price Range ():</Text>
@@ -341,6 +550,21 @@ export default function MapView() {
</div>
)}
{!homeAddress && (
<Banner
fullMode={true}
type="warning"
bordered
closeIcon={null}
description={
<span>
You have not set your home address yet. Please do so in the <Link to="/userSettings">user settings</Link>{' '}
to use the distance filter.
</span>
}
/>
)}
<Banner
fullMode={true}
type="info"
@@ -350,6 +574,14 @@ export default function MapView() {
/>
<div ref={mapContainer} className="map-container" />
<ListingDeletionModal
visible={deleteModalVisible}
onConfirm={confirmListingDeletion}
onCancel={() => {
setDeleteModalVisible(false);
setListingToDelete(null);
}}
/>
</div>
);
}

View File

@@ -6,8 +6,9 @@
.map-view-container {
display: flex;
flex-direction: column;
height: calc(100vh - 120px); /* Adjust based on header/footer height */
padding: 1rem;
height: 100%;
padding: 0;
box-sizing: border-box;
}
.map-filter-bar {
@@ -43,10 +44,85 @@
}
.info {
font-size: 0.9rem;
font-size: 0.8rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
gap: 0.1rem;
}
&__linkButton {
background: var(--semi-color-primary);
color: white;
font-size: 14px;
line-height: 20px;
font-weight: 600;
height: 32px;
width: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
a {
color: white;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
width: 100%;
height: 100%;
}
&:hover {
background: var(--semi-color-primary-hover);
cursor: pointer;
}
}
&__detailsButton {
background: var(--semi-color-tertiary);
color: white;
border: none;
font-size: 14px;
height: 32px;
width: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
cursor: pointer;
padding: 0;
&:hover {
background: var(--semi-color-tertiary-hover);
}
svg {
fill: currentColor;
}
}
&__deleteButton {
background: var(--semi-color-danger);
color: white;
border: none;
font-size: 14px;
height: 32px;
width: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
cursor: pointer;
padding: 0;
&:hover {
background: var(--semi-color-danger-hover);
}
svg {
fill: currentColor;
}
}
}

View File

@@ -6,7 +6,7 @@
import React, { 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';
import { Banner, Button, Checkbox, Space } from '@douyinfe/semi-ui-19';
import NotificationAdapterMutator from '../../jobs/mutation/components/notificationAdapter/NotificationAdapterMutator.jsx';
import Headline from '../../../components/headline/Headline.jsx';

View File

@@ -0,0 +1,130 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/**
* Calculates the great-circle distance between two points on a sphere using the Haversine formula.
*
* I'm using the Haversine formula here because it accounts for the Earth's curvature.
* By calculating the central angle (c) between two points and multiplying it by the Earth's radius (R ≈ 6371km),
* we get a pretty accurate straight-line distance. It's basically some trigonometry involving
* sines and cosines of the latitudes and longitudes to find the chord length (a) first.
*
* @param {number} lat1 - Latitude of the first point
* @param {number} lon1 - Longitude of the first point
* @param {number} lat2 - Latitude of the second point
* @param {number} lon2 - Longitude of the second point
* @returns {number} Distance in meters, rounded to one decimal place
*/
export const distanceMeters = (lat1, lon1, lat2, lon2) => {
const R = 6371000;
const toRad = (deg) => (deg * Math.PI) / 180;
const phi1 = toRad(lat1);
const phi2 = toRad(lat2);
const dPhi = toRad(lat2 - lat1);
const dLambda = toRad(lon2 - lon1);
const a =
Math.sin(dPhi / 2) * Math.sin(dPhi / 2) +
Math.cos(phi1) * Math.cos(phi2) * Math.sin(dLambda / 2) * Math.sin(dLambda / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return Math.round(R * c * 10) / 10;
};
/**
* Generates an array of coordinates representing a circle on a map.
*
* To get this circle right, I'm approximating it with a polygon of 64 points.
* Since the Earth isn't flat, I have to adjust the longitude distance based on the latitude
* using the cosine of the latitude. The formula for the points is basically:
* x = center_lon + radius_lon * cos(theta)
* y = center_lat + radius_lat * sin(theta)
* where theta ranges from 0 to 2π. This handles the slight "squishing" of distances as you move away from the equator.
*
* @param {number[]} center - [longitude, latitude] of the center
* @param {number} radiusInKm - Radius of the circle in kilometers
* @param {number} [points=64] - Number of points to generate for the polygon
* @returns {number[][]} Array of [longitude, latitude] coordinates
*/
export const generateCircleCoords = (center, radiusInKm, points = 64) => {
const [longitude, latitude] = center;
const coords = [];
// 1 degree of latitude is roughly 110.574 km
// 1 degree of longitude is roughly 111.32 km * cos(latitude)
const distanceX = radiusInKm / (111.32 * Math.cos((latitude * Math.PI) / 180));
const distanceY = radiusInKm / 110.574;
for (let i = 0; i < points; i++) {
const theta = (i / points) * (2 * Math.PI);
const x = distanceX * Math.cos(theta);
const y = distanceY * Math.sin(theta);
coords.push([longitude + x, latitude + y]);
}
// Close the polygon
coords.push(coords[0]);
return coords;
};
/**
* Calculates the bounding box for a given center and radius.
*
* I'm calculating the bounds by offsetting the center coordinates by the radius.
* Again, using the 110.574 km per degree latitude and the cosine-adjusted longitude
* to make sure the bounds actually contain the circle, even at our latitudes.
* I've added a bit of padding (15% by default) to make sure everything fits nicely on the screen.
*
* @param {number[]} center - [longitude, latitude] of the center
* @param {number} radiusInKm - Radius in kilometers
* @param {number} [padding=0.15] - Percentage of padding to add
* @returns {number[][]} Bounding box coordinates [[minLon, minLat], [maxLon, maxLat]]
*/
export const getBoundsFromCenter = (center, radiusInKm, padding = 0.15) => {
const [lng, lat] = center;
const kmInDegLat = 1 / 110.574;
const kmInDegLng = 1 / (111.32 * Math.cos((lat * Math.PI) / 180));
const offsetLng = radiusInKm * kmInDegLng * (1 + padding);
const offsetLat = radiusInKm * kmInDegLat * (1 + padding);
return [
[lng - offsetLng, lat - offsetLat],
[lng + offsetLng, lat + offsetLat],
];
};
/**
* Calculates the bounding box for a set of coordinates.
*
* @param {number[][]} coords - Array of [longitude, latitude] coordinates
* @param {number} [padding=0.1] - Padding to add to the bounds
* @returns {number[][]} Bounding box coordinates [[minLon, minLat], [maxLon, maxLat]]
*/
export const getBoundsFromCoords = (coords, padding = 0.1) => {
if (!coords || coords.length === 0) return null;
let minLng = Infinity;
let minLat = Infinity;
let maxLng = -Infinity;
let maxLat = -Infinity;
coords.forEach(([lng, lat]) => {
if (lng < minLng) minLng = lng;
if (lng > maxLng) maxLng = lng;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
});
const lngDiff = maxLng - minLng;
const latDiff = maxLat - minLat;
return [
[minLng - lngDiff * padding, minLat - latDiff * padding],
[maxLng + lngDiff * padding, maxLat + latDiff * padding],
];
};

View File

@@ -10,7 +10,7 @@ import Logo from '../../components/logo/Logo';
import { xhrPost } from '../../services/xhr';
import { useNavigate } from 'react-router-dom';
import { useActions, useSelector } from '../../services/state/store';
import { Input, Button, Banner, Toast } from '@douyinfe/semi-ui';
import { Input, Button, Banner, Toast } from '@douyinfe/semi-ui-19';
import './login.less';
import { IconUser, IconLock } from '@douyinfe/semi-icons';
@@ -58,54 +58,62 @@ export default function Login() {
return (
<div className="login">
<div className="login__bgImage" style={{ background: `url("${cityBackground}")` }} />
<Logo />
<form>
<div className="login__loginWrapper">
{error && <Banner type="danger" closeIcon={null} description={error} />}
<Input
size="large"
prefix={<IconUser />}
placeholder="Username"
value={username}
showClear
autoFocus
onChange={(value) => setUserName(value)}
onKeyPress={async (e) => {
if (e.key === 'Enter') {
await tryLogin();
}
}}
/>
<div className="login__loginWrapper">
<div className="login__logoWrapper">
<Logo width={250} white />
</div>
<Input
size="large"
mode="password"
prefix={<IconLock />}
value={password}
placeholder="Password"
onChange={(value) => setPassword(value)}
onKeyPress={async (e) => {
if (e.key === 'Enter') {
await tryLogin();
}
}}
{demoMode && (
<Banner
fullMode={true}
type="info"
bordered
closeIcon={null}
description="This is the demo version of Fredy. Use 'demo' as both the username and password to log in."
style={{ marginBottom: '1.5rem' }}
/>
)}
<Button type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '1rem' }}>
<form onSubmit={(e) => e.preventDefault()}>
{error && <Banner type="danger" closeIcon={null} description={error} style={{ marginBottom: '1rem' }} />}
<div className="login__inputGroup">
<Input
size="large"
prefix={<IconUser />}
placeholder="Username"
value={username}
showClear
autoFocus
onChange={(value) => setUserName(value)}
onKeyPress={async (e) => {
if (e.key === 'Enter') {
await tryLogin();
}
}}
/>
</div>
<div className="login__inputGroup">
<Input
size="large"
mode="password"
prefix={<IconLock />}
value={password}
placeholder="Password"
onChange={(value) => setPassword(value)}
onKeyPress={async (e) => {
if (e.key === 'Enter') {
await tryLogin();
}
}}
/>
</div>
<Button block type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '1rem' }}>
Login
</Button>
{demoMode && (
<Banner
fullMode={true}
type="info"
bordered
closeIcon={null}
description="This is the demo version of Fredy. Use 'demo' as both the username and password to log in."
/>
)}
</div>
</form>
</form>
</div>
</div>
);
}

View File

@@ -4,32 +4,80 @@
align-items: center;
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
&__bgImage {
background-size: cover;
filter: blur(8px);
-webkit-filter: blur(8px);
background-position: center;
filter: blur(10px) brightness(0.4);
-webkit-filter: blur(10px) brightness(0.4);
position: absolute;
top: 0;
left: 0;
top: -20px;
left: -20px;
right: -20px;
bottom: -20px;
z-index: 0;
right: 0;
bottom: 0;
}
&__loginWrapper {
border: 1px solid #555050;
border-radius: 30px;
position: relative;
z-index: 1;
background-color: #151313ab;
background: rgba(20, 20, 25, 0.7);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 24px;
padding: 3rem;
width: 90%;
max-width: 420px;
display: flex;
flex-direction: column;
padding: 2rem;
gap: 1rem;
align-items: center;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
}
&__logoWrapper {
margin-bottom: 2.5rem;
display: flex;
justify-content: center;
width: 100%;
.logo {
position: static !important;
max-width: 100%;
height: auto;
}
}
form {
z-index: 1;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.2rem;
}
&__inputGroup {
width: 100%;
}
// Mobile responsiveness
@media (max-width: 480px) {
&__loginWrapper {
padding: 2rem 1.5rem;
width: 95%;
border-radius: 20px;
background: rgba(20, 20, 25, 0.85);
&::after {
opacity: 0.2;
filter: blur(10px);
}
}
&__logoWrapper {
margin-bottom: 1.5rem;
}
}
}

View File

@@ -4,7 +4,7 @@
*/
import React from 'react';
import { Modal } from '@douyinfe/semi-ui';
import { Modal } from '@douyinfe/semi-ui-19';
const UserRemovalModal = function UserRemovalModal({ onOk, onCancel }) {
return (
<Modal title="Removing user" visible={true} closable={false} onOk={onOk} onCancel={onCancel}>

View File

@@ -5,11 +5,11 @@
import React from 'react';
import { Toast } from '@douyinfe/semi-ui';
import { Toast } from '@douyinfe/semi-ui-19';
import UserTable from '../../components/table/UserTable';
import { useActions, useSelector } from '../../services/state/store';
import { IconPlus } from '@douyinfe/semi-icons';
import { Button } from '@douyinfe/semi-ui';
import { Button } from '@douyinfe/semi-ui-19';
import UserRemovalModal from './UserRemovalModal';
import { xhrDelete } from '../../services/xhr';
import { useNavigate } from 'react-router-dom';

View File

@@ -8,7 +8,7 @@ import React from 'react';
import { xhrGet, xhrPost } from '../../../services/xhr';
import { useNavigate, useParams } from 'react-router-dom';
import { useActions } from '../../../services/state/store';
import { Divider, Input, Switch, Button, Toast } from '@douyinfe/semi-ui';
import { Divider, Input, Switch, Button, Toast } from '@douyinfe/semi-ui-19';
import './UserMutator.less';
import { SegmentPart } from '../../../components/segment/SegmentPart';
import { IconPlusCircle } from '@douyinfe/semi-icons';

View File

@@ -0,0 +1,103 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { 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 { SegmentPart } from '../../components/segment/SegmentPart';
import debounce from 'lodash/debounce';
const UserSettings = () => {
const actions = useActions();
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 [dataSource, setDataSource] = useState([]);
useEffect(() => {
setAddress(homeAddress?.address || '');
setCoords(homeAddress?.coords || null);
}, [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');
}
} catch (error) {
Toast.error(error.json?.error || 'Error while saving settings');
} finally {
setSaving(false);
}
};
const debouncedSearch = useMemo(
() =>
debounce((value) => {
xhrGet(`/api/user/settings/autocomplete?q=${encodeURIComponent(value)}`)
.then((response) => {
if (response.status === 200) {
setDataSource(response.json);
}
})
.catch(() => {
// Silently fail for autocomplete
});
}, 300),
[],
);
const searchAddress = (value) => {
if (!value) {
setDataSource([]);
return;
}
debouncedSearch(value);
};
return (
<div className="user-settings">
<SegmentPart
name="Distance claculation"
Icon={IconHome}
helpText="The address you enter is used to calculate the distance between your chosen location and each listing. The distance is computed using an approximate mathematical method and is intended to give you a rough indication of commute time. If you update your address, we will recalculate the distance for all active listings."
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', maxWidth: '600px' }}>
<AutoComplete
data={dataSource}
value={address}
showClear
onChange={(v) => setAddress(v)}
onSearch={searchAddress}
placeholder="Enter your home address"
style={{ width: '100%' }}
/>
{coords && coords.lat === -1 && (
<Banner type="danger" description="Address found but could not be geocoded accurately." closeIcon={null} />
)}
</div>
</SegmentPart>
<Divider />
<div style={{ marginTop: '20px' }}>
<Button icon={<IconSave />} theme="solid" type="primary" onClick={handleSave} loading={saving}>
Save Settings
</Button>
</div>
</div>
);
};
export default UserSettings;

622
yarn.lock

File diff suppressed because it is too large Load Diff