mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e859250545 | ||
|
|
4dd0370ec1 | ||
|
|
51b4e51f3f | ||
|
|
fa1899765c |
@@ -5,11 +5,15 @@
|
|||||||
|
|
||||||
import { NoNewListingsWarning } from './errors.js';
|
import { NoNewListingsWarning } from './errors.js';
|
||||||
import { storeListings, getKnownListingHashesForJobAndProvider } from './services/storage/listingsStorage.js';
|
import { storeListings, getKnownListingHashesForJobAndProvider } from './services/storage/listingsStorage.js';
|
||||||
|
import { getJob } from './services/storage/jobStorage.js';
|
||||||
import * as notify from './notification/notify.js';
|
import * as notify from './notification/notify.js';
|
||||||
import Extractor from './services/extractor/extractor.js';
|
import Extractor from './services/extractor/extractor.js';
|
||||||
import urlModifier from './services/queryStringMutator.js';
|
import urlModifier from './services/queryStringMutator.js';
|
||||||
import logger from './services/logger.js';
|
import logger from './services/logger.js';
|
||||||
import { geocodeAddress } from './services/geocoding/geoCodingService.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
|
* @typedef {Object} Listing
|
||||||
@@ -82,6 +86,7 @@ class FredyPipelineExecutioner {
|
|||||||
.then(this._findNew.bind(this))
|
.then(this._findNew.bind(this))
|
||||||
.then(this._geocode.bind(this))
|
.then(this._geocode.bind(this))
|
||||||
.then(this._save.bind(this))
|
.then(this._save.bind(this))
|
||||||
|
.then(this._calculateDistance.bind(this))
|
||||||
.then(this._filterBySimilarListings.bind(this))
|
.then(this._filterBySimilarListings.bind(this))
|
||||||
.then(this._notify.bind(this))
|
.then(this._notify.bind(this))
|
||||||
.catch(this._handleError.bind(this));
|
.catch(this._handleError.bind(this));
|
||||||
@@ -201,6 +206,42 @@ class FredyPipelineExecutioner {
|
|||||||
return newListings;
|
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.
|
* Remove listings that are similar to already known entries according to the similarity cache.
|
||||||
* Adds the remaining listings to the cache.
|
* Adds the remaining listings to the cache.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { providerRouter } from './routes/providerRouter.js';
|
|||||||
import { versionRouter } from './routes/versionRouter.js';
|
import { versionRouter } from './routes/versionRouter.js';
|
||||||
import { loginRouter } from './routes/loginRoute.js';
|
import { loginRouter } from './routes/loginRoute.js';
|
||||||
import { userRouter } from './routes/userRoute.js';
|
import { userRouter } from './routes/userRoute.js';
|
||||||
|
import { userSettingsRouter } from './routes/userSettingsRoute.js';
|
||||||
import { jobRouter } from './routes/jobRouter.js';
|
import { jobRouter } from './routes/jobRouter.js';
|
||||||
import bodyParser from 'body-parser';
|
import bodyParser from 'body-parser';
|
||||||
import restana from 'restana';
|
import restana from 'restana';
|
||||||
@@ -20,7 +21,6 @@ import { demoRouter } from './routes/demoRouter.js';
|
|||||||
import logger from '../services/logger.js';
|
import logger from '../services/logger.js';
|
||||||
import { listingsRouter } from './routes/listingsRouter.js';
|
import { listingsRouter } from './routes/listingsRouter.js';
|
||||||
import { getSettings } from '../services/storage/settingsStorage.js';
|
import { getSettings } from '../services/storage/settingsStorage.js';
|
||||||
import { featureRouter } from './routes/featureRouter.js';
|
|
||||||
import { dashboardRouter } from './routes/dashboardRouter.js';
|
import { dashboardRouter } from './routes/dashboardRouter.js';
|
||||||
import { backupRouter } from './routes/backupRouter.js';
|
import { backupRouter } from './routes/backupRouter.js';
|
||||||
const service = restana();
|
const service = restana();
|
||||||
@@ -36,6 +36,7 @@ service.use('/api/version', authInterceptor());
|
|||||||
service.use('/api/listings', authInterceptor());
|
service.use('/api/listings', authInterceptor());
|
||||||
service.use('/api/dashboard', authInterceptor());
|
service.use('/api/dashboard', authInterceptor());
|
||||||
service.use('/api/features', authInterceptor());
|
service.use('/api/features', authInterceptor());
|
||||||
|
service.use('/api/user/settings', authInterceptor());
|
||||||
|
|
||||||
// /admin can only be accessed when user is having admin permissions
|
// /admin can only be accessed when user is having admin permissions
|
||||||
service.use('/api/admin', adminInterceptor());
|
service.use('/api/admin', adminInterceptor());
|
||||||
@@ -44,11 +45,11 @@ service.use('/api/admin/generalSettings', generalSettingsRouter);
|
|||||||
service.use('/api/admin/backup', backupRouter);
|
service.use('/api/admin/backup', backupRouter);
|
||||||
service.use('/api/jobs/provider', providerRouter);
|
service.use('/api/jobs/provider', providerRouter);
|
||||||
service.use('/api/admin/users', userRouter);
|
service.use('/api/admin/users', userRouter);
|
||||||
|
service.use('/api/user/settings', userSettingsRouter);
|
||||||
service.use('/api/version', versionRouter);
|
service.use('/api/version', versionRouter);
|
||||||
service.use('/api/jobs', jobRouter);
|
service.use('/api/jobs', jobRouter);
|
||||||
service.use('/api/login', loginRouter);
|
service.use('/api/login', loginRouter);
|
||||||
service.use('/api/listings', listingsRouter);
|
service.use('/api/listings', listingsRouter);
|
||||||
service.use('/api/features', featureRouter);
|
|
||||||
service.use('/api/dashboard', dashboardRouter);
|
service.use('/api/dashboard', dashboardRouter);
|
||||||
//this route is unsecured intentionally as it is being queried from the login page
|
//this route is unsecured intentionally as it is being queried from the login page
|
||||||
service.use('/api/demo', demoRouter);
|
service.use('/api/demo', demoRouter);
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
@@ -64,12 +64,10 @@ listingsRouter.get('/table', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
listingsRouter.get('/map', async (req, res) => {
|
listingsRouter.get('/map', async (req, res) => {
|
||||||
const { jobId, minPrice, maxPrice } = req.query || {};
|
const { jobId } = req.query || {};
|
||||||
|
|
||||||
res.body = listingStorage.getListingsForMap({
|
res.body = listingStorage.getListingsForMap({
|
||||||
jobId: nullOrEmpty(jobId) ? null : jobId,
|
jobId: nullOrEmpty(jobId) ? null : jobId,
|
||||||
minPrice: minPrice ? parseInt(minPrice, 10) : null,
|
|
||||||
maxPrice: maxPrice ? parseInt(maxPrice, 10) : null,
|
|
||||||
userId: req.session.currentUser,
|
userId: req.session.currentUser,
|
||||||
isAdmin: isAdminFn(req),
|
isAdmin: isAdminFn(req),
|
||||||
});
|
});
|
||||||
|
|||||||
66
lib/api/routes/userSettingsRoute.js
Normal file
66
lib/api/routes/userSettingsRoute.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/*
|
||||||
|
* 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 { upsertSettings } from '../../services/storage/settingsStorage.js';
|
||||||
|
import { geocodeAddress } from '../../services/geocoding/geoCodingService.js';
|
||||||
|
import { autocompleteAddress } from '../../services/geocoding/autocompleteService.js';
|
||||||
|
import { calculateDistanceForUser } from '../../services/geocoding/distanceService.js';
|
||||||
|
import { fromJson } from '../../utils.js';
|
||||||
|
import { trackFeature } from '../../services/tracking/Tracker.js';
|
||||||
|
import { FEATURES } from '../../features.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.status(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
userSettingsRouter.post('/', async (req, res) => {
|
||||||
|
const userId = req.session.currentUser;
|
||||||
|
const { home_address } = req.body;
|
||||||
|
|
||||||
|
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);
|
||||||
|
calculateDistanceForUser(userId);
|
||||||
|
res.send({ success: true, coords });
|
||||||
|
} else {
|
||||||
|
res.status(400).send({ error: 'Could not geocode address' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If address is empty, maybe clear it?
|
||||||
|
upsertSettings({ home_address: null }, userId);
|
||||||
|
res.send({ success: true });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { userSettingsRouter };
|
||||||
@@ -3,12 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const FEATURES = {
|
export const FEATURES = {
|
||||||
WATCHLIST_MANAGEMENT: false,
|
DISTANCE_ADDRESS_ENTERED: 'DISTANCE_ADDRESS_ENTERED',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function getFeatures() {
|
|
||||||
return {
|
|
||||||
...FEATURES,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as utils from '../utils.js';
|
import * as utils from '../utils.js';
|
||||||
|
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
||||||
|
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
activeTester: checkIfListingIsActive,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const init = (sourceConfig, blacklistTerms) => {
|
export const init = (sourceConfig, blacklistTerms) => {
|
||||||
|
|||||||
@@ -6,22 +6,28 @@
|
|||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { getListingsToGeocode, updateListingGeocoordinates } from '../storage/listingsStorage.js';
|
import { getListingsToGeocode, updateListingGeocoordinates } from '../storage/listingsStorage.js';
|
||||||
import { geocodeAddress, isGeocodingPaused } from '../geocoding/geoCodingService.js';
|
import { geocodeAddress, isGeocodingPaused } from '../geocoding/geoCodingService.js';
|
||||||
|
import { getJobs } from '../storage/jobStorage.js';
|
||||||
|
import { calculateDistanceForJob } from '../geocoding/distanceService.js';
|
||||||
|
|
||||||
async function runTask() {
|
async function runTask() {
|
||||||
const listings = getListingsToGeocode();
|
const listings = getListingsToGeocode();
|
||||||
if (listings.length === 0) {
|
if (listings.length > 0) {
|
||||||
return;
|
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) {
|
//additional run
|
||||||
if (isGeocodingPaused()) {
|
const jobs = getJobs();
|
||||||
break;
|
for (const job of jobs) {
|
||||||
}
|
calculateDistanceForJob(job.id, job.userId);
|
||||||
|
|
||||||
const coords = await geocodeAddress(listing.address);
|
|
||||||
if (coords) {
|
|
||||||
updateListingGeocoordinates(listing.id, coords.lat, coords.lng);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
lib/services/geocoding/autocompleteService.js
Normal file
26
lib/services/geocoding/autocompleteService.js
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 geocode = throttle(doGeocode);
|
||||||
|
|
||||||
|
export const autocomplete = throttle(doAutocomplete);
|
||||||
|
|
||||||
export const isPaused = () => Date.now() - last429 < PAUSE_DURATION;
|
export const isPaused = () => Date.now() - last429 < PAUSE_DURATION;
|
||||||
|
|||||||
61
lib/services/geocoding/distanceService.js
Normal file
61
lib/services/geocoding/distanceService.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
lib/services/listings/distanceCalculator.js
Normal file
35
lib/services/listings/distanceCalculator.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ export default async function checkIfListingIsActive(link) {
|
|||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(link, {
|
const res = await fetch(link, {
|
||||||
|
redirect: 'manual',
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent':
|
'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',
|
'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',
|
||||||
|
|||||||
@@ -432,14 +432,11 @@ export const updateListingGeocoordinates = (id, latitude, longitude) => {
|
|||||||
*
|
*
|
||||||
* @param {Object} params
|
* @param {Object} params
|
||||||
* @param {string} [params.jobId]
|
* @param {string} [params.jobId]
|
||||||
* @param {boolean} [params.activeOnly=true]
|
|
||||||
* @param {number} [params.minPrice]
|
|
||||||
* @param {number} [params.maxPrice]
|
|
||||||
* @param {string} [params.userId]
|
* @param {string} [params.userId]
|
||||||
* @param {boolean} [params.isAdmin=false]
|
* @param {boolean} [params.isAdmin=false]
|
||||||
* @returns {{listings: Object[], maxPrice: number}} Object containing listings and maxPrice.
|
* @returns {{listings: Object[], maxPrice: number}} Object containing listings and maxPrice.
|
||||||
*/
|
*/
|
||||||
export const getListingsForMap = ({ jobId, minPrice, maxPrice, userId = null, isAdmin = false } = {}) => {
|
export const getListingsForMap = ({ jobId, userId = null, isAdmin = false } = {}) => {
|
||||||
const baseWhereParts = [
|
const baseWhereParts = [
|
||||||
'l.latitude IS NOT NULL',
|
'l.latitude IS NOT NULL',
|
||||||
'l.longitude IS NOT NULL',
|
'l.longitude IS NOT NULL',
|
||||||
@@ -461,15 +458,6 @@ export const getListingsForMap = ({ jobId, minPrice, maxPrice, userId = null, is
|
|||||||
}
|
}
|
||||||
|
|
||||||
const wherePartsForListings = [...baseWhereParts];
|
const wherePartsForListings = [...baseWhereParts];
|
||||||
if (minPrice !== undefined && minPrice !== null) {
|
|
||||||
params.minPrice = minPrice;
|
|
||||||
wherePartsForListings.push('l.price >= @minPrice');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (maxPrice !== undefined && maxPrice !== null) {
|
|
||||||
params.maxPrice = maxPrice;
|
|
||||||
wherePartsForListings.push('l.price <= @maxPrice');
|
|
||||||
}
|
|
||||||
|
|
||||||
const listings = SqliteConnection.query(
|
const listings = SqliteConnection.query(
|
||||||
`SELECT l.*, j.name AS job_name
|
`SELECT l.*, j.name AS job_name
|
||||||
@@ -479,17 +467,8 @@ export const getListingsForMap = ({ jobId, minPrice, maxPrice, userId = null, is
|
|||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
|
|
||||||
const maxPriceRow = SqliteConnection.query(
|
|
||||||
`SELECT MAX(l.price) AS maxPrice
|
|
||||||
FROM listings l
|
|
||||||
LEFT JOIN jobs j ON j.id = l.job_id
|
|
||||||
WHERE ${baseWhereParts.join(' AND ')}`,
|
|
||||||
params,
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
listings,
|
listings,
|
||||||
maxPrice: maxPriceRow?.maxPrice || 0,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -523,3 +502,57 @@ export const getGeocoordinatesByAddress = (address) => {
|
|||||||
)[0];
|
)[0];
|
||||||
return row ? { lat: row.latitude, lng: row.longitude } : null;
|
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 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.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 },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
12
lib/services/storage/migrations/sql/8.distances.js
Normal file
12
lib/services/storage/migrations/sql/8.distances.js
Normal 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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -37,12 +37,25 @@ function compileSettings(rows, configValues) {
|
|||||||
* @returns {Record<string, any>}
|
* @returns {Record<string, any>}
|
||||||
*/
|
*/
|
||||||
export async function refreshSettingsCache() {
|
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();
|
const configValues = await readConfigFromStorage();
|
||||||
cachedSettingsConfig = compileSettings(rows, configValues);
|
cachedSettingsConfig = compileSettings(rows, configValues);
|
||||||
return cachedSettingsConfig;
|
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.
|
* Get the compiled settings config. Loads it once and caches the result.
|
||||||
* @returns {Record<string, any>}
|
* @returns {Record<string, any>}
|
||||||
@@ -83,10 +96,12 @@ export function upsertSettings(settingsMapOrEntry, userId = null) {
|
|||||||
SqliteConnection.execute(
|
SqliteConnection.execute(
|
||||||
`INSERT INTO settings (id, create_date, name, value, user_id)
|
`INSERT INTO settings (id, create_date, name, value, user_id)
|
||||||
VALUES (@id, @create_date, @name, @value, @userId)
|
VALUES (@id, @create_date, @name, @value, @userId)
|
||||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value`,
|
ON CONFLICT(name, IFNULL(user_id, 'GLOBAL_SETTING')) DO UPDATE SET value = excluded.value`,
|
||||||
{ id, create_date, name, value: json, userId },
|
{ id, create_date, name, value: json, userId },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// keep cache in sync
|
// keep cache in sync (only for global settings)
|
||||||
refreshSettingsCache();
|
if (userId == null) {
|
||||||
|
refreshSettingsCache();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,25 @@ export const trackMainEvent = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const trackFeature = async (feature) => {
|
||||||
|
try {
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (settings.analyticsEnabled && !inDevMode()) {
|
||||||
|
const trackingObj = await enrichTrackingObject({
|
||||||
|
feature,
|
||||||
|
});
|
||||||
|
|
||||||
|
await fetch(`${FREDY_TRACKING_URL}/feature`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(trackingObj),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Error tracking feature', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Note, this will only be used when Fredy runs in demo mode
|
* Note, this will only be used when Fredy runs in demo mode
|
||||||
*/
|
*/
|
||||||
|
|||||||
1409
package-lock.json
generated
1409
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "18.0.0",
|
"version": "19.0.0",
|
||||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
@@ -59,18 +59,19 @@
|
|||||||
"Firefox ESR"
|
"Firefox ESR"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@douyinfe/semi-icons": "^2.90.11",
|
"@douyinfe/semi-icons": "^2.90.13",
|
||||||
"@douyinfe/semi-ui": "2.90.11",
|
"@douyinfe/semi-ui": "2.90.13",
|
||||||
|
"@douyinfe/semi-ui-19": "^2.90.13",
|
||||||
"@sendgrid/mail": "8.1.6",
|
"@sendgrid/mail": "8.1.6",
|
||||||
"@vitejs/plugin-react": "5.1.2",
|
"@vitejs/plugin-react": "5.1.2",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"better-sqlite3": "^12.6.0",
|
"better-sqlite3": "^12.6.2",
|
||||||
"body-parser": "2.2.2",
|
"body-parser": "2.2.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
"cookie-session": "2.1.1",
|
"cookie-session": "2.1.1",
|
||||||
"handlebars": "4.7.8",
|
"handlebars": "4.7.8",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.23",
|
||||||
"maplibre-gl": "^5.16.0",
|
"maplibre-gl": "^5.16.0",
|
||||||
"nanoid": "5.1.6",
|
"nanoid": "5.1.6",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
@@ -78,13 +79,14 @@
|
|||||||
"node-mailjet": "6.0.11",
|
"node-mailjet": "6.0.11",
|
||||||
"p-throttle": "^8.1.0",
|
"p-throttle": "^8.1.0",
|
||||||
"package-up": "^5.0.0",
|
"package-up": "^5.0.0",
|
||||||
"puppeteer": "^24.35.0",
|
"puppeteer": "^24.36.0",
|
||||||
"puppeteer-extra": "^3.3.6",
|
"puppeteer-extra": "^3.3.6",
|
||||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||||
"query-string": "9.3.1",
|
"query-string": "9.3.1",
|
||||||
"react": "18.3.1",
|
"react": "19.2.3",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"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": "7.12.0",
|
||||||
"react-router-dom": "7.12.0",
|
"react-router-dom": "7.12.0",
|
||||||
"restana": "5.1.0",
|
"restana": "5.1.0",
|
||||||
@@ -96,9 +98,9 @@
|
|||||||
"zustand": "^5.0.10"
|
"zustand": "^5.0.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.28.5",
|
"@babel/core": "7.28.6",
|
||||||
"@babel/eslint-parser": "7.28.5",
|
"@babel/eslint-parser": "7.28.6",
|
||||||
"@babel/preset-env": "7.28.5",
|
"@babel/preset-env": "7.28.6",
|
||||||
"@babel/preset-react": "7.28.5",
|
"@babel/preset-react": "7.28.5",
|
||||||
"chai": "6.2.2",
|
"chai": "6.2.2",
|
||||||
"eslint": "9.39.2",
|
"eslint": "9.39.2",
|
||||||
@@ -111,6 +113,6 @@
|
|||||||
"lint-staged": "16.2.7",
|
"lint-staged": "16.2.7",
|
||||||
"mocha": "11.7.5",
|
"mocha": "11.7.5",
|
||||||
"nodemon": "^3.1.11",
|
"nodemon": "^3.1.11",
|
||||||
"prettier": "3.7.4"
|
"prettier": "3.8.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable no-unused-vars */
|
||||||
const db = {};
|
const db = {};
|
||||||
export const storeListings = (jobKey, providerId, listings) => {
|
export const storeListings = (jobKey, providerId, listings) => {
|
||||||
if (!Array.isArray(listings)) throw Error('Not a valid array');
|
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) => {
|
export const getKnownListingHashesForJobAndProvider = (jobKey, providerId) => {
|
||||||
return db[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 */
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ export const mockFredy = async () => {
|
|||||||
'../lib/services/storage/listingsStorage.js': {
|
'../lib/services/storage/listingsStorage.js': {
|
||||||
...mockStore,
|
...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': {
|
'../lib/notification/notify.js': {
|
||||||
send,
|
send,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import React, { useEffect } from 'react';
|
|||||||
import InsufficientPermission from './components/permission/InsufficientPermission';
|
import InsufficientPermission from './components/permission/InsufficientPermission';
|
||||||
import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
|
import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
|
||||||
import GeneralSettings from './views/generalSettings/GeneralSettings';
|
import GeneralSettings from './views/generalSettings/GeneralSettings';
|
||||||
|
import UserSettings from './views/userSettings/UserSettings';
|
||||||
import JobMutation from './views/jobs/mutation/JobMutation';
|
import JobMutation from './views/jobs/mutation/JobMutation';
|
||||||
import UserMutator from './views/user/mutation/UserMutator';
|
import UserMutator from './views/user/mutation/UserMutator';
|
||||||
import { useActions, useSelector } from './services/state/store';
|
import { useActions, useSelector } from './services/state/store';
|
||||||
@@ -18,12 +19,12 @@ import Jobs from './views/jobs/Jobs';
|
|||||||
|
|
||||||
import './App.less';
|
import './App.less';
|
||||||
import TrackingModal from './components/tracking/TrackingModal.jsx';
|
import TrackingModal from './components/tracking/TrackingModal.jsx';
|
||||||
import { Banner, Divider } from '@douyinfe/semi-ui';
|
import { Banner, Divider } from '@douyinfe/semi-ui-19';
|
||||||
import VersionBanner from './components/version/VersionBanner.jsx';
|
import VersionBanner from './components/version/VersionBanner.jsx';
|
||||||
import Listings from './views/listings/Listings.jsx';
|
import Listings from './views/listings/Listings.jsx';
|
||||||
import MapView from './views/listings/Map.jsx';
|
import MapView from './views/listings/Map.jsx';
|
||||||
import Navigation from './components/navigation/Navigation.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 FredyFooter from './components/footer/FredyFooter.jsx';
|
||||||
import WatchlistManagement from './views/listings/management/WatchlistManagement.jsx';
|
import WatchlistManagement from './views/listings/management/WatchlistManagement.jsx';
|
||||||
import Dashboard from './views/dashboard/Dashboard.jsx';
|
import Dashboard from './views/dashboard/Dashboard.jsx';
|
||||||
@@ -123,6 +124,14 @@ export default function FredyApp() {
|
|||||||
</PermissionAwareRoute>
|
</PermissionAwareRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/userSettings"
|
||||||
|
element={
|
||||||
|
<PermissionAwareRoute currentUser={currentUser} adminOnly={false}>
|
||||||
|
<UserSettings />
|
||||||
|
</PermissionAwareRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/generalSettings"
|
path="/generalSettings"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import React from 'react';
|
|||||||
|
|
||||||
import { HashRouter } from 'react-router-dom';
|
import { HashRouter } from 'react-router-dom';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import en_US from '@douyinfe/semi-ui/lib/es/locale/source/en_US';
|
import en_US from '@douyinfe/semi-ui-19/lib/es/locale/source/en_US';
|
||||||
import { LocaleProvider } from '@douyinfe/semi-ui';
|
import { LocaleProvider } from '@douyinfe/semi-ui-19';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './Index.less';
|
import './Index.less';
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import './FredyFooter.less';
|
import './FredyFooter.less';
|
||||||
import { useSelector } from '../../services/state/store.js';
|
import { useSelector } from '../../services/state/store.js';
|
||||||
import { Typography } from '@douyinfe/semi-ui';
|
import { Typography } from '@douyinfe/semi-ui-19';
|
||||||
|
|
||||||
export default function FredyFooter() {
|
export default function FredyFooter() {
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|||||||
@@ -20,12 +20,13 @@ import {
|
|||||||
Pagination,
|
Pagination,
|
||||||
Toast,
|
Toast,
|
||||||
Empty,
|
Empty,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui-19';
|
||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
IconDelete,
|
IconDelete,
|
||||||
IconDescend2,
|
IconDescend2,
|
||||||
IconEdit,
|
IconEdit,
|
||||||
|
IconCopy,
|
||||||
IconPlayCircle,
|
IconPlayCircle,
|
||||||
IconBriefcase,
|
IconBriefcase,
|
||||||
IconBell,
|
IconBell,
|
||||||
@@ -198,12 +199,14 @@ const JobGrid = () => {
|
|||||||
<div className="jobGrid__searchbar">
|
<div className="jobGrid__searchbar">
|
||||||
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
|
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
|
||||||
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
|
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
|
||||||
<Button
|
<div>
|
||||||
icon={<IconFilter />}
|
<Button
|
||||||
onClick={() => {
|
icon={<IconFilter />}
|
||||||
setShowFilterBar(!showFilterBar);
|
onClick={() => {
|
||||||
}}
|
setShowFilterBar(!showFilterBar);
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,7 +290,9 @@ const JobGrid = () => {
|
|||||||
'This job has been shared with you by another user, therefor it is read-only.',
|
'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>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -343,40 +348,59 @@ const JobGrid = () => {
|
|||||||
|
|
||||||
<div className="jobGrid__actions">
|
<div className="jobGrid__actions">
|
||||||
<Popover content={getPopoverContent('Run Job')}>
|
<Popover content={getPopoverContent('Run Job')}>
|
||||||
<Button
|
<div>
|
||||||
type="primary"
|
<Button
|
||||||
theme="solid"
|
type="primary"
|
||||||
icon={<IconPlayCircle />}
|
theme="solid"
|
||||||
disabled={job.isOnlyShared || job.running}
|
icon={<IconPlayCircle />}
|
||||||
onClick={() => onJobRun(job.id)}
|
disabled={job.isOnlyShared || job.running}
|
||||||
/>
|
onClick={() => onJobRun(job.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={getPopoverContent('Edit a Job')}>
|
<Popover content={getPopoverContent('Edit a Job')}>
|
||||||
<Button
|
<div>
|
||||||
type="secondary"
|
<Button
|
||||||
theme="solid"
|
type="secondary"
|
||||||
icon={<IconEdit />}
|
theme="solid"
|
||||||
disabled={job.isOnlyShared}
|
icon={<IconEdit />}
|
||||||
onClick={() => navigate(`/jobs/edit/${job.id}`)}
|
disabled={job.isOnlyShared}
|
||||||
/>
|
onClick={() => navigate(`/jobs/edit/${job.id}`)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
<Popover content={getPopoverContent('Clone Job')}>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
type="tertiary"
|
||||||
|
theme="solid"
|
||||||
|
icon={<IconCopy />}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onClick={() => navigate('/jobs/new', { state: { cloneFrom: job.id } })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
|
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
|
||||||
<Button
|
<div>
|
||||||
type="danger"
|
<Button
|
||||||
theme="solid"
|
type="danger"
|
||||||
icon={<IconDescend2 />}
|
theme="solid"
|
||||||
disabled={job.isOnlyShared}
|
icon={<IconDescend2 />}
|
||||||
onClick={() => onListingRemoval(job.id)}
|
disabled={job.isOnlyShared}
|
||||||
/>
|
onClick={() => onListingRemoval(job.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={getPopoverContent('Delete Job')}>
|
<Popover content={getPopoverContent('Delete Job')}>
|
||||||
<Button
|
<div>
|
||||||
type="danger"
|
<Button
|
||||||
theme="solid"
|
type="danger"
|
||||||
icon={<IconDelete />}
|
theme="solid"
|
||||||
disabled={job.isOnlyShared}
|
icon={<IconDelete />}
|
||||||
onClick={() => onJobRemoval(job.id)}
|
disabled={job.isOnlyShared}
|
||||||
/>
|
onClick={() => onJobRemoval(job.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Popover,
|
Popover,
|
||||||
Empty,
|
Empty,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui-19';
|
||||||
import {
|
import {
|
||||||
IconBriefcase,
|
IconBriefcase,
|
||||||
IconCart,
|
IconCart,
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
IconStarStroked,
|
IconStarStroked,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconFilter,
|
IconFilter,
|
||||||
|
IconActivity,
|
||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
import no_image from '../../../assets/no_image.jpg';
|
import no_image from '../../../assets/no_image.jpg';
|
||||||
import * as timeService from '../../../services/time/timeService.js';
|
import * as timeService from '../../../services/time/timeService.js';
|
||||||
@@ -107,12 +108,14 @@ const ListingsGrid = () => {
|
|||||||
<div className="listingsGrid__searchbar">
|
<div className="listingsGrid__searchbar">
|
||||||
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
|
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
|
||||||
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
|
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
|
||||||
<Button
|
<div>
|
||||||
icon={<IconFilter />}
|
<Button
|
||||||
onClick={() => {
|
icon={<IconFilter />}
|
||||||
setShowFilterBar(!showFilterBar);
|
onClick={() => {
|
||||||
}}
|
setShowFilterBar(!showFilterBar);
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
{showFilterBar && (
|
{showFilterBar && (
|
||||||
@@ -272,6 +275,15 @@ const ListingsGrid = () => {
|
|||||||
<Text type="tertiary" size="small" icon={<IconBriefcase />}>
|
<Text type="tertiary" size="small" icon={<IconBriefcase />}>
|
||||||
{item.provider.charAt(0).toUpperCase() + item.provider.slice(1)}
|
{item.provider.charAt(0).toUpperCase() + item.provider.slice(1)}
|
||||||
</Text>
|
</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>
|
</Space>
|
||||||
<Divider margin=".6rem" />
|
<Divider margin=".6rem" />
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Typography } from '@douyinfe/semi-ui';
|
import { Typography } from '@douyinfe/semi-ui-19';
|
||||||
|
|
||||||
export default function Headline({ text, size = 3 } = {}) {
|
export default function Headline({ text, size = 3 } = {}) {
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button } from '@douyinfe/semi-ui';
|
import { Button } from '@douyinfe/semi-ui-19';
|
||||||
import { xhrPost } from '../../services/xhr';
|
import { xhrPost } from '../../services/xhr';
|
||||||
import { IconUser } from '@douyinfe/semi-icons';
|
import { IconUser } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
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 { IconStar, IconSetting, IconTerminal, IconHistogram, IconSidebar } from '@douyinfe/semi-icons';
|
||||||
import logoWhite from '../../assets/logo_white.png';
|
import logoWhite from '../../assets/logo_white.png';
|
||||||
import heart from '../../assets/heart.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 { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import './Navigate.less';
|
import './Navigate.less';
|
||||||
import { useFeature } from '../../hooks/featureHook.js';
|
|
||||||
import { useScreenWidth } from '../../hooks/screenWidth.js';
|
import { useScreenWidth } from '../../hooks/screenWidth.js';
|
||||||
|
|
||||||
export default function Navigation({ isAdmin }) {
|
export default function Navigation({ isAdmin }) {
|
||||||
@@ -21,7 +20,6 @@ export default function Navigation({ isAdmin }) {
|
|||||||
|
|
||||||
const width = useScreenWidth();
|
const width = useScreenWidth();
|
||||||
const [collapsed, setCollapsed] = useState(width <= 850);
|
const [collapsed, setCollapsed] = useState(width <= 850);
|
||||||
const watchlistFeature = useFeature('WATCHLIST_MANAGEMENT') || false;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (width <= 850) {
|
if (width <= 850) {
|
||||||
@@ -46,11 +44,9 @@ export default function Navigation({ isAdmin }) {
|
|||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
const settingsItems = [
|
const settingsItems = [
|
||||||
{ itemKey: '/users', text: 'User Management' },
|
{ itemKey: '/users', text: 'User Management' },
|
||||||
|
{ itemKey: '/userSettings', text: 'User Specific Settings' },
|
||||||
{ itemKey: '/generalSettings', text: 'General Settings' },
|
{ itemKey: '/generalSettings', text: 'General Settings' },
|
||||||
];
|
];
|
||||||
if (watchlistFeature) {
|
|
||||||
settingsItems.push({ itemKey: '/watchlistManagement', text: 'Watchlist Management' });
|
|
||||||
}
|
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
itemKey: 'settings',
|
itemKey: 'settings',
|
||||||
@@ -58,6 +54,13 @@ export default function Navigation({ isAdmin }) {
|
|||||||
icon: <IconSetting />,
|
icon: <IconSetting />,
|
||||||
items: settingsItems,
|
items: settingsItems,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
items.push({
|
||||||
|
itemKey: 'settings',
|
||||||
|
text: 'Settings',
|
||||||
|
icon: <IconSetting />,
|
||||||
|
items: [{ itemKey: '/userSettings', text: 'User Specific Settings' }],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePathName(name) {
|
function parsePathName(name) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Card } from '@douyinfe/semi-ui';
|
import { Card } from '@douyinfe/semi-ui-19';
|
||||||
|
|
||||||
import './SegmentParts.less';
|
import './SegmentParts.less';
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
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 { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
export default function NotificationAdapterTable({ notificationAdapter = [], onRemove, onEdit } = {}) {
|
export default function NotificationAdapterTable({ notificationAdapter = [], onRemove, onEdit } = {}) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
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 { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
export default function ProviderTable({ providerData = [], onRemove, onEdit } = {}) {
|
export default function ProviderTable({ providerData = [], onRemove, onEdit } = {}) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import React from 'react';
|
|||||||
|
|
||||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||||
import { format } from '../../services/time/timeService';
|
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';
|
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
const empty = (
|
const empty = (
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Modal } from '@douyinfe/semi-ui';
|
import { Modal } from '@douyinfe/semi-ui-19';
|
||||||
import Logo from '../logo/Logo.jsx';
|
import Logo from '../logo/Logo.jsx';
|
||||||
import { xhrPost } from '../../services/xhr.js';
|
import { xhrPost } from '../../services/xhr.js';
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
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 { useSelector } from '../../services/state/store.js';
|
||||||
import { MarkdownRender } from '@douyinfe/semi-ui';
|
import { MarkdownRender } from '@douyinfe/semi-ui-19';
|
||||||
|
|
||||||
import './VersionBanner.less';
|
import './VersionBanner.less';
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button, Col, Row, Toast } from '@douyinfe/semi-ui';
|
import { Button, Col, Row, Toast } from '@douyinfe/semi-ui-19';
|
||||||
import {
|
import {
|
||||||
IconTerminal,
|
IconTerminal,
|
||||||
IconStar,
|
IconStar,
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import React from 'react';
|
|||||||
|
|
||||||
import { useActions, useSelector } from '../../services/state/store';
|
import { useActions, useSelector } from '../../services/state/store';
|
||||||
|
|
||||||
import { Divider, TimePicker, Button, Checkbox, Input, Modal } from '@douyinfe/semi-ui';
|
import { Divider, TimePicker, Button, Checkbox, Input, Modal } from '@douyinfe/semi-ui-19';
|
||||||
import { InputNumber } from '@douyinfe/semi-ui';
|
import { InputNumber } from '@douyinfe/semi-ui-19';
|
||||||
import { xhrPost } from '../../services/xhr';
|
import { xhrPost } from '../../services/xhr';
|
||||||
import { SegmentPart } from '../../components/segment/SegmentPart';
|
import { SegmentPart } from '../../components/segment/SegmentPart';
|
||||||
import { Banner, Toast } from '@douyinfe/semi-ui';
|
import { Banner, Toast } from '@douyinfe/semi-ui-19';
|
||||||
import {
|
import {
|
||||||
downloadBackup as downloadBackupZip,
|
downloadBackup as downloadBackupZip,
|
||||||
precheckRestore as clientPrecheckRestore,
|
precheckRestore as clientPrecheckRestore,
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import ProviderMutator from './components/provider/ProviderMutator';
|
|||||||
import Headline from '../../../components/headline/Headline';
|
import Headline from '../../../components/headline/Headline';
|
||||||
import { useActions, useSelector } from '../../../services/state/store';
|
import { useActions, useSelector } from '../../../services/state/store';
|
||||||
import { xhrPost } from '../../../services/xhr';
|
import { xhrPost } from '../../../services/xhr';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||||
import { Divider, Input, Switch, Button, TagInput, Toast, Select } from '@douyinfe/semi-ui';
|
import { Divider, Input, Switch, Button, TagInput, Toast, Select } from '@douyinfe/semi-ui-19';
|
||||||
import './JobMutation.less';
|
import './JobMutation.less';
|
||||||
import { SegmentPart } from '../../../components/segment/SegmentPart';
|
import { SegmentPart } from '../../../components/segment/SegmentPart';
|
||||||
import {
|
import {
|
||||||
@@ -30,14 +30,20 @@ export default function JobMutator() {
|
|||||||
const jobs = useSelector((state) => state.jobsData.jobs);
|
const jobs = useSelector((state) => state.jobsData.jobs);
|
||||||
const shareableUserList = useSelector((state) => state.jobsData.shareableUserList);
|
const shareableUserList = useSelector((state) => state.jobsData.shareableUserList);
|
||||||
const params = useParams();
|
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 jobToBeEdit = params.jobId == null ? null : jobs.find((job) => job.id === params.jobId);
|
||||||
|
|
||||||
const defaultBlacklist = jobToBeEdit?.blacklist || [];
|
const sourceJob = jobToBeEdit || jobToClone;
|
||||||
const defaultName = jobToBeEdit?.name || null;
|
|
||||||
const defaultProviderData = jobToBeEdit?.provider || [];
|
const defaultBlacklist = sourceJob?.blacklist || [];
|
||||||
const defaultNotificationAdapter = jobToBeEdit?.notificationAdapter || [];
|
const defaultName = jobToClone ? `Copy of - ${sourceJob?.name}` : sourceJob?.name || null;
|
||||||
const defaultEnabled = jobToBeEdit?.enabled ?? true;
|
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 [providerToEdit, setProviderToEdit] = useState(null);
|
||||||
const [providerCreationVisible, setProviderCreationVisibility] = useState(false);
|
const [providerCreationVisible, setProviderCreationVisibility] = useState(false);
|
||||||
@@ -47,7 +53,7 @@ export default function JobMutator() {
|
|||||||
const [name, setName] = useState(defaultName);
|
const [name, setName] = useState(defaultName);
|
||||||
const [blacklist, setBlacklist] = useState(defaultBlacklist);
|
const [blacklist, setBlacklist] = useState(defaultBlacklist);
|
||||||
const [notificationAdapterData, setNotificationAdapterData] = useState(defaultNotificationAdapter);
|
const [notificationAdapterData, setNotificationAdapterData] = useState(defaultNotificationAdapter);
|
||||||
const [shareWithUsers, setShareWithUsers] = useState(jobToBeEdit?.shared_with_user ?? []);
|
const [shareWithUsers, setShareWithUsers] = useState(defaultShareWithUsers);
|
||||||
const [enabled, setEnabled] = useState(defaultEnabled);
|
const [enabled, setEnabled] = useState(defaultEnabled);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { transform } from '../../../../../services/transformer/notificationAdapt
|
|||||||
import { xhrPost } from '../../../../../services/xhr';
|
import { xhrPost } from '../../../../../services/xhr';
|
||||||
import Help from './NotificationHelpDisplay';
|
import Help from './NotificationHelpDisplay';
|
||||||
import { useSelector } from '../../../../../services/state/store';
|
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 './NotificationAdapterMutator.less';
|
||||||
import { useScreenWidth } from '../../../../../hooks/screenWidth.js';
|
import { useScreenWidth } from '../../../../../hooks/screenWidth.js';
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Banner, MarkdownRender } from '@douyinfe/semi-ui';
|
import { Banner, MarkdownRender } from '@douyinfe/semi-ui-19';
|
||||||
|
|
||||||
export default function Help({ readme }) {
|
export default function Help({ readme }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
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 { transform } from '../../../../../services/transformer/providerTransformer';
|
||||||
import { useSelector } from '../../../../../services/state/store';
|
import { useSelector } from '../../../../../services/state/store';
|
||||||
import { IconLikeHeart } from '@douyinfe/semi-icons';
|
import { IconLikeHeart } from '@douyinfe/semi-icons';
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import React, { useEffect, useRef, useState } from 'react';
|
|||||||
import maplibregl from 'maplibre-gl';
|
import maplibregl from 'maplibre-gl';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
import { useSelector, useActions } from '../../services/state/store.js';
|
import { useSelector, useActions } from '../../services/state/store.js';
|
||||||
import { Select, Slider, Space, Typography, Button, Popover, Divider, Switch, Banner } from '@douyinfe/semi-ui';
|
import { Select, Space, Typography, Button, Popover, Divider, Switch, Banner } from '@douyinfe/semi-ui-19';
|
||||||
import { IconFilter } from '@douyinfe/semi-icons';
|
import { IconFilter } from '@douyinfe/semi-icons';
|
||||||
import no_image from '../../assets/no_image.jpg';
|
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 './Map.less';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@@ -65,23 +67,31 @@ export default function MapView() {
|
|||||||
const markers = useRef([]);
|
const markers = useRef([]);
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
const listings = useSelector((state) => state.listingsData.mapListings);
|
const listings = useSelector((state) => state.listingsData.mapListings);
|
||||||
const maxPriceFromStore = useSelector((state) => state.listingsData.maxPrice);
|
|
||||||
const [style, setStyle] = useState('STANDARD');
|
const [style, setStyle] = useState('STANDARD');
|
||||||
const [show3dBuildings, setShow3dBuildings] = useState(false);
|
const [show3dBuildings, setShow3dBuildings] = useState(false);
|
||||||
|
|
||||||
const jobs = useSelector((state) => state.jobsData.jobs);
|
const jobs = useSelector((state) => state.jobsData.jobs);
|
||||||
const [jobId, setJobId] = useState(null);
|
const [jobId, setJobId] = useState(null);
|
||||||
const [priceRange, setPriceRange] = useState([0, 100000]);
|
const [priceRange, setPriceRange] = useState([0, 0]);
|
||||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||||
|
|
||||||
const lastJobIdRef = useRef('__INITIAL__');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (maxPriceFromStore > 0 && lastJobIdRef.current !== jobId) {
|
setPriceRange([0, getMaxPrice()]);
|
||||||
setPriceRange([0, maxPriceFromStore]);
|
}, [listings]);
|
||||||
lastJobIdRef.current = jobId;
|
|
||||||
}
|
const getMaxPrice = () => {
|
||||||
}, [maxPriceFromStore, jobId]);
|
return listings.reduce((max, item) => {
|
||||||
|
const price = Number(item.price);
|
||||||
|
return Number.isFinite(price) && price > max ? price : max;
|
||||||
|
}, -Infinity);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterListings = () => {
|
||||||
|
const min = priceRange[0];
|
||||||
|
const max = priceRange[1] && priceRange[1] > 0 ? priceRange[1] : getMaxPrice();
|
||||||
|
|
||||||
|
return listings.filter((listing) => listing.price && listing.price >= min && listing.price <= max);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (map.current) return;
|
if (map.current) return;
|
||||||
@@ -97,13 +107,22 @@ export default function MapView() {
|
|||||||
|
|
||||||
map.current.addControl(
|
map.current.addControl(
|
||||||
new maplibregl.NavigationControl({
|
new maplibregl.NavigationControl({
|
||||||
showCompass: false,
|
showCompass: true,
|
||||||
visualizePitch: true,
|
visualizePitch: true,
|
||||||
visualizeRoll: true,
|
visualizeRoll: true,
|
||||||
}),
|
}),
|
||||||
'top-right',
|
'top-right',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
map.current.addControl(
|
||||||
|
new maplibregl.GeolocateControl({
|
||||||
|
positionOptions: {
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
},
|
||||||
|
trackUserLocation: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
map.current.remove();
|
map.current.remove();
|
||||||
};
|
};
|
||||||
@@ -199,14 +218,12 @@ export default function MapView() {
|
|||||||
const fetchListings = async () => {
|
const fetchListings = async () => {
|
||||||
actions.listingsData.getListingsForMap({
|
actions.listingsData.getListingsForMap({
|
||||||
jobId,
|
jobId,
|
||||||
minPrice: priceRange[0] > 0 ? priceRange[0] : null,
|
|
||||||
maxPrice: maxPriceFromStore > 0 && priceRange[1] < maxPriceFromStore ? priceRange[1] : null,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchListings();
|
fetchListings();
|
||||||
}, [jobId, priceRange]);
|
}, [jobId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map.current) return;
|
if (!map.current) return;
|
||||||
@@ -214,7 +231,7 @@ export default function MapView() {
|
|||||||
markers.current.forEach((marker) => marker.remove());
|
markers.current.forEach((marker) => marker.remove());
|
||||||
markers.current = [];
|
markers.current = [];
|
||||||
|
|
||||||
listings.forEach((listing) => {
|
filterListings().forEach((listing) => {
|
||||||
if (
|
if (
|
||||||
listing.latitude != null &&
|
listing.latitude != null &&
|
||||||
listing.longitude != null &&
|
listing.longitude != null &&
|
||||||
@@ -247,7 +264,7 @@ export default function MapView() {
|
|||||||
markers.current.push(marker);
|
markers.current.push(marker);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [listings]);
|
}, [listings, priceRange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="map-view-container">
|
<div className="map-view-container">
|
||||||
@@ -264,12 +281,14 @@ export default function MapView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Popover content="Filter Results" style={{ color: 'white', padding: '.5rem' }}>
|
<Popover content="Filter Results" style={{ color: 'white', padding: '.5rem' }}>
|
||||||
<Button
|
<div>
|
||||||
icon={<IconFilter />}
|
<Button
|
||||||
onClick={() => {
|
icon={<IconFilter />}
|
||||||
setShowFilterBar(!showFilterBar);
|
onClick={() => {
|
||||||
}}
|
setShowFilterBar(!showFilterBar);
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -285,7 +304,9 @@ export default function MapView() {
|
|||||||
placeholder="Job"
|
placeholder="Job"
|
||||||
showClear
|
showClear
|
||||||
style={{ width: 150 }}
|
style={{ width: 150 }}
|
||||||
onChange={(val) => setJobId(val)}
|
onChange={(val) => {
|
||||||
|
setJobId(val);
|
||||||
|
}}
|
||||||
value={jobId}
|
value={jobId}
|
||||||
>
|
>
|
||||||
{jobs?.map((j) => (
|
{jobs?.map((j) => (
|
||||||
@@ -302,13 +323,18 @@ export default function MapView() {
|
|||||||
<Text strong>Price Range (€):</Text>
|
<Text strong>Price Range (€):</Text>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ width: 250, padding: '0 10px' }}>
|
<div style={{ width: 250, padding: '0 10px' }}>
|
||||||
<Slider
|
<div className="map__rangesliderLabels">
|
||||||
range
|
<span>{priceRange[0]} €</span>
|
||||||
|
<span>{priceRange[1]} €</span>
|
||||||
|
</div>
|
||||||
|
<RangeSlider
|
||||||
min={0}
|
min={0}
|
||||||
max={maxPriceFromStore || 100000}
|
max={getMaxPrice()}
|
||||||
step={100}
|
step={100}
|
||||||
value={priceRange}
|
value={priceRange}
|
||||||
onChange={(val) => setPriceRange(val)}
|
onInput={(val) => {
|
||||||
|
setPriceRange(val);
|
||||||
|
}}
|
||||||
tipFormatter={(val) => `${val} €`}
|
tipFormatter={(val) => `${val} €`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -63,11 +63,22 @@
|
|||||||
border-bottom-color: var(--semi-color-bg-1) !important;
|
border-bottom-color: var(--semi-color-bg-1) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.maplibregl-ctrl-group {
|
.map {
|
||||||
background: var(--semi-color-bg-1) !important;
|
&__rangesliderLabels{
|
||||||
}
|
color: white;
|
||||||
|
display: flex;
|
||||||
.maplibregl-ctrl-group button {
|
justify-content: space-between;
|
||||||
background-color: var(--semi-color-bg-1) !important;
|
margin-bottom: .3rem;
|
||||||
border-color: var(--semi-color-border) !important;
|
font-size: .7rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.range-slider .range-slider__thumb {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
top: 50%;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #2196f3;
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { IconHorn } from '@douyinfe/semi-icons';
|
import { IconHorn } from '@douyinfe/semi-icons';
|
||||||
import { SegmentPart } from '../../../components/segment/SegmentPart.jsx';
|
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 NotificationAdapterMutator from '../../jobs/mutation/components/notificationAdapter/NotificationAdapterMutator.jsx';
|
||||||
import Headline from '../../../components/headline/Headline.jsx';
|
import Headline from '../../../components/headline/Headline.jsx';
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import Logo from '../../components/logo/Logo';
|
|||||||
import { xhrPost } from '../../services/xhr';
|
import { xhrPost } from '../../services/xhr';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useActions, useSelector } from '../../services/state/store';
|
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 './login.less';
|
||||||
import { IconUser, IconLock } from '@douyinfe/semi-icons';
|
import { IconUser, IconLock } from '@douyinfe/semi-icons';
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Modal } from '@douyinfe/semi-ui';
|
import { Modal } from '@douyinfe/semi-ui-19';
|
||||||
const UserRemovalModal = function UserRemovalModal({ onOk, onCancel }) {
|
const UserRemovalModal = function UserRemovalModal({ onOk, onCancel }) {
|
||||||
return (
|
return (
|
||||||
<Modal title="Removing user" visible={true} closable={false} onOk={onOk} onCancel={onCancel}>
|
<Modal title="Removing user" visible={true} closable={false} onOk={onOk} onCancel={onCancel}>
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Toast } from '@douyinfe/semi-ui';
|
import { Toast } from '@douyinfe/semi-ui-19';
|
||||||
import UserTable from '../../components/table/UserTable';
|
import UserTable from '../../components/table/UserTable';
|
||||||
import { useActions, useSelector } from '../../services/state/store';
|
import { useActions, useSelector } from '../../services/state/store';
|
||||||
import { IconPlus } from '@douyinfe/semi-icons';
|
import { IconPlus } from '@douyinfe/semi-icons';
|
||||||
import { Button } from '@douyinfe/semi-ui';
|
import { Button } from '@douyinfe/semi-ui-19';
|
||||||
import UserRemovalModal from './UserRemovalModal';
|
import UserRemovalModal from './UserRemovalModal';
|
||||||
import { xhrDelete } from '../../services/xhr';
|
import { xhrDelete } from '../../services/xhr';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import React from 'react';
|
|||||||
import { xhrGet, xhrPost } from '../../../services/xhr';
|
import { xhrGet, xhrPost } from '../../../services/xhr';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useActions } from '../../../services/state/store';
|
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 './UserMutator.less';
|
||||||
import { SegmentPart } from '../../../components/segment/SegmentPart';
|
import { SegmentPart } from '../../../components/segment/SegmentPart';
|
||||||
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
||||||
|
|||||||
119
ui/src/views/userSettings/UserSettings.jsx
Normal file
119
ui/src/views/userSettings/UserSettings.jsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/*
|
||||||
|
* 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, Typography, Banner } from '@douyinfe/semi-ui-19';
|
||||||
|
import { IconSave, IconHome } from '@douyinfe/semi-icons';
|
||||||
|
import { xhrGet, xhrPost } from '../../services/xhr';
|
||||||
|
import { SegmentPart } from '../../components/segment/SegmentPart';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
|
||||||
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
const UserSettings = () => {
|
||||||
|
const [address, setAddress] = useState('');
|
||||||
|
const [coords, setCoords] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [dataSource, setDataSource] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUserSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchUserSettings = async () => {
|
||||||
|
try {
|
||||||
|
const response = await xhrGet('/api/user/settings');
|
||||||
|
if (response.status === 200) {
|
||||||
|
const homeAddress = response.json.home_address;
|
||||||
|
setAddress(homeAddress?.address || '');
|
||||||
|
setCoords(homeAddress?.coords || null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Toast.error('Failed to fetch user settings');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const response = await xhrPost('/api/user/settings', { home_address: address });
|
||||||
|
if (response.status === 200) {
|
||||||
|
setCoords(response.json.coords);
|
||||||
|
Toast.success('Settings saved successfully');
|
||||||
|
} 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="user-settings">
|
||||||
|
<Title heading={2}>User Specific Settings</Title>
|
||||||
|
<Divider />
|
||||||
|
<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}
|
||||||
|
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;
|
||||||
Reference in New Issue
Block a user