## Worker System - Role-agnostic workers that can handle any task type - Pod-based architecture with StatefulSet (5-15 pods, 5 workers each) - Custom pod names (Aethelgard, Xylos, Kryll, etc.) - Worker registry with friendly names and resource monitoring - Hub-and-spoke visualization on JobQueue page ## Stealth & Anti-Detection (REQUIRED) - Proxies are MANDATORY - workers fail to start without active proxies - CrawlRotator initializes on worker startup - Loads proxies from `proxies` table - Auto-rotates proxy + fingerprint on 403 errors - 12 browser fingerprints (Chrome, Firefox, Safari, Edge) - Locale/timezone matching for geographic consistency ## Task System - Renamed product_resync → product_refresh - Task chaining: store_discovery → entry_point → product_discovery - Priority-based claiming with FOR UPDATE SKIP LOCKED - Heartbeat and stale task recovery ## UI Updates - JobQueue: Pod visualization, resource monitoring on hover - WorkersDashboard: Simplified worker list - Removed unused filters from task list ## Other - IP2Location service for visitor analytics - Findagram consumer features scaffolding - Documentation updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
364 lines
9.4 KiB
JavaScript
364 lines
9.4 KiB
JavaScript
/**
|
|
* localStorage helpers for user data persistence
|
|
*
|
|
* Manages favorites, price alerts, and saved searches without requiring authentication.
|
|
* All data is stored locally in the browser.
|
|
*/
|
|
|
|
const STORAGE_KEYS = {
|
|
FAVORITES: 'findagram_favorites',
|
|
ALERTS: 'findagram_alerts',
|
|
SAVED_SEARCHES: 'findagram_saved_searches',
|
|
};
|
|
|
|
// ============================================================
|
|
// FAVORITES
|
|
// ============================================================
|
|
|
|
/**
|
|
* Get all favorite product IDs
|
|
* @returns {number[]} Array of product IDs
|
|
*/
|
|
export function getFavorites() {
|
|
try {
|
|
const data = localStorage.getItem(STORAGE_KEYS.FAVORITES);
|
|
return data ? JSON.parse(data) : [];
|
|
} catch (e) {
|
|
console.error('Error reading favorites:', e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a product is favorited
|
|
* @param {number} productId
|
|
* @returns {boolean}
|
|
*/
|
|
export function isFavorite(productId) {
|
|
const favorites = getFavorites();
|
|
return favorites.includes(productId);
|
|
}
|
|
|
|
/**
|
|
* Add a product to favorites
|
|
* @param {number} productId
|
|
*/
|
|
export function addFavorite(productId) {
|
|
const favorites = getFavorites();
|
|
if (!favorites.includes(productId)) {
|
|
favorites.push(productId);
|
|
localStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(favorites));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a product from favorites
|
|
* @param {number} productId
|
|
*/
|
|
export function removeFavorite(productId) {
|
|
const favorites = getFavorites();
|
|
const updated = favorites.filter(id => id !== productId);
|
|
localStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(updated));
|
|
}
|
|
|
|
/**
|
|
* Toggle a product's favorite status
|
|
* @param {number} productId
|
|
* @returns {boolean} New favorite status
|
|
*/
|
|
export function toggleFavorite(productId) {
|
|
if (isFavorite(productId)) {
|
|
removeFavorite(productId);
|
|
return false;
|
|
} else {
|
|
addFavorite(productId);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all favorites
|
|
*/
|
|
export function clearFavorites() {
|
|
localStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify([]));
|
|
}
|
|
|
|
// ============================================================
|
|
// PRICE ALERTS
|
|
// ============================================================
|
|
|
|
/**
|
|
* @typedef {Object} PriceAlert
|
|
* @property {string} id - Unique alert ID
|
|
* @property {number} productId - Product ID to track
|
|
* @property {string} productName - Product name (for display when offline)
|
|
* @property {string} productImage - Product image URL
|
|
* @property {string} brandName - Brand name
|
|
* @property {number} targetPrice - Target price to alert at
|
|
* @property {number} originalPrice - Price when alert was created
|
|
* @property {boolean} active - Whether alert is active
|
|
* @property {string} createdAt - ISO date string
|
|
*/
|
|
|
|
/**
|
|
* Get all price alerts
|
|
* @returns {PriceAlert[]}
|
|
*/
|
|
export function getAlerts() {
|
|
try {
|
|
const data = localStorage.getItem(STORAGE_KEYS.ALERTS);
|
|
return data ? JSON.parse(data) : [];
|
|
} catch (e) {
|
|
console.error('Error reading alerts:', e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get alert for a specific product
|
|
* @param {number} productId
|
|
* @returns {PriceAlert|null}
|
|
*/
|
|
export function getAlertForProduct(productId) {
|
|
const alerts = getAlerts();
|
|
return alerts.find(a => a.productId === productId) || null;
|
|
}
|
|
|
|
/**
|
|
* Create a new price alert
|
|
* @param {Object} params
|
|
* @param {number} params.productId
|
|
* @param {string} params.productName
|
|
* @param {string} params.productImage
|
|
* @param {string} params.brandName
|
|
* @param {number} params.targetPrice
|
|
* @param {number} params.originalPrice
|
|
* @returns {PriceAlert}
|
|
*/
|
|
export function createAlert({ productId, productName, productImage, brandName, targetPrice, originalPrice }) {
|
|
const alerts = getAlerts();
|
|
|
|
// Check if alert already exists for this product
|
|
const existingIndex = alerts.findIndex(a => a.productId === productId);
|
|
|
|
const alert = {
|
|
id: existingIndex >= 0 ? alerts[existingIndex].id : `alert_${Date.now()}`,
|
|
productId,
|
|
productName,
|
|
productImage,
|
|
brandName,
|
|
targetPrice,
|
|
originalPrice,
|
|
active: true,
|
|
createdAt: existingIndex >= 0 ? alerts[existingIndex].createdAt : new Date().toISOString(),
|
|
};
|
|
|
|
if (existingIndex >= 0) {
|
|
alerts[existingIndex] = alert;
|
|
} else {
|
|
alerts.push(alert);
|
|
}
|
|
|
|
localStorage.setItem(STORAGE_KEYS.ALERTS, JSON.stringify(alerts));
|
|
return alert;
|
|
}
|
|
|
|
/**
|
|
* Update an existing alert
|
|
* @param {string} alertId
|
|
* @param {Partial<PriceAlert>} updates
|
|
*/
|
|
export function updateAlert(alertId, updates) {
|
|
const alerts = getAlerts();
|
|
const index = alerts.findIndex(a => a.id === alertId);
|
|
if (index >= 0) {
|
|
alerts[index] = { ...alerts[index], ...updates };
|
|
localStorage.setItem(STORAGE_KEYS.ALERTS, JSON.stringify(alerts));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle alert active status
|
|
* @param {string} alertId
|
|
* @returns {boolean} New active status
|
|
*/
|
|
export function toggleAlertActive(alertId) {
|
|
const alerts = getAlerts();
|
|
const alert = alerts.find(a => a.id === alertId);
|
|
if (alert) {
|
|
alert.active = !alert.active;
|
|
localStorage.setItem(STORAGE_KEYS.ALERTS, JSON.stringify(alerts));
|
|
return alert.active;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Delete an alert
|
|
* @param {string} alertId
|
|
*/
|
|
export function deleteAlert(alertId) {
|
|
const alerts = getAlerts();
|
|
const updated = alerts.filter(a => a.id !== alertId);
|
|
localStorage.setItem(STORAGE_KEYS.ALERTS, JSON.stringify(updated));
|
|
}
|
|
|
|
/**
|
|
* Clear all alerts
|
|
*/
|
|
export function clearAlerts() {
|
|
localStorage.setItem(STORAGE_KEYS.ALERTS, JSON.stringify([]));
|
|
}
|
|
|
|
// ============================================================
|
|
// SAVED SEARCHES
|
|
// ============================================================
|
|
|
|
/**
|
|
* @typedef {Object} SavedSearch
|
|
* @property {string} id - Unique search ID
|
|
* @property {string} name - User-defined name for the search
|
|
* @property {Object} filters - Search filter parameters
|
|
* @property {string} [filters.search] - Search term
|
|
* @property {string} [filters.type] - Category type
|
|
* @property {string} [filters.brandName] - Brand filter
|
|
* @property {string} [filters.strainType] - Strain type filter
|
|
* @property {number} [filters.priceMax] - Max price filter
|
|
* @property {number} [filters.thcMin] - Min THC filter
|
|
* @property {string} createdAt - ISO date string
|
|
*/
|
|
|
|
/**
|
|
* Get all saved searches
|
|
* @returns {SavedSearch[]}
|
|
*/
|
|
export function getSavedSearches() {
|
|
try {
|
|
const data = localStorage.getItem(STORAGE_KEYS.SAVED_SEARCHES);
|
|
return data ? JSON.parse(data) : [];
|
|
} catch (e) {
|
|
console.error('Error reading saved searches:', e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new saved search
|
|
* @param {Object} params
|
|
* @param {string} params.name - Display name for the search
|
|
* @param {Object} params.filters - Search filters
|
|
* @returns {SavedSearch}
|
|
*/
|
|
export function createSavedSearch({ name, filters }) {
|
|
const searches = getSavedSearches();
|
|
|
|
const search = {
|
|
id: `search_${Date.now()}`,
|
|
name,
|
|
filters,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
|
|
searches.push(search);
|
|
localStorage.setItem(STORAGE_KEYS.SAVED_SEARCHES, JSON.stringify(searches));
|
|
return search;
|
|
}
|
|
|
|
/**
|
|
* Update a saved search
|
|
* @param {string} searchId
|
|
* @param {Partial<SavedSearch>} updates
|
|
*/
|
|
export function updateSavedSearch(searchId, updates) {
|
|
const searches = getSavedSearches();
|
|
const index = searches.findIndex(s => s.id === searchId);
|
|
if (index >= 0) {
|
|
searches[index] = { ...searches[index], ...updates };
|
|
localStorage.setItem(STORAGE_KEYS.SAVED_SEARCHES, JSON.stringify(searches));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a saved search
|
|
* @param {string} searchId
|
|
*/
|
|
export function deleteSavedSearch(searchId) {
|
|
const searches = getSavedSearches();
|
|
const updated = searches.filter(s => s.id !== searchId);
|
|
localStorage.setItem(STORAGE_KEYS.SAVED_SEARCHES, JSON.stringify(updated));
|
|
}
|
|
|
|
/**
|
|
* Clear all saved searches
|
|
*/
|
|
export function clearSavedSearches() {
|
|
localStorage.setItem(STORAGE_KEYS.SAVED_SEARCHES, JSON.stringify([]));
|
|
}
|
|
|
|
// ============================================================
|
|
// UTILITY FUNCTIONS
|
|
// ============================================================
|
|
|
|
/**
|
|
* Build a URL with search params from filters
|
|
* @param {Object} filters
|
|
* @returns {string}
|
|
*/
|
|
export function buildSearchUrl(filters) {
|
|
const params = new URLSearchParams();
|
|
Object.entries(filters).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== null && value !== '') {
|
|
params.set(key, value);
|
|
}
|
|
});
|
|
return `/products?${params.toString()}`;
|
|
}
|
|
|
|
/**
|
|
* Generate a name for a search based on its filters
|
|
* @param {Object} filters
|
|
* @returns {string}
|
|
*/
|
|
export function generateSearchName(filters) {
|
|
const parts = [];
|
|
|
|
if (filters.search) parts.push(`"${filters.search}"`);
|
|
if (filters.type) parts.push(filters.type);
|
|
if (filters.brandName) parts.push(filters.brandName);
|
|
if (filters.strainType) parts.push(filters.strainType);
|
|
if (filters.priceMax) parts.push(`Under $${filters.priceMax}`);
|
|
if (filters.thcMin) parts.push(`${filters.thcMin}%+ THC`);
|
|
|
|
return parts.length > 0 ? parts.join(' - ') : 'All Products';
|
|
}
|
|
|
|
// Default export
|
|
const storage = {
|
|
// Favorites
|
|
getFavorites,
|
|
isFavorite,
|
|
addFavorite,
|
|
removeFavorite,
|
|
toggleFavorite,
|
|
clearFavorites,
|
|
// Alerts
|
|
getAlerts,
|
|
getAlertForProduct,
|
|
createAlert,
|
|
updateAlert,
|
|
toggleAlertActive,
|
|
deleteAlert,
|
|
clearAlerts,
|
|
// Saved Searches
|
|
getSavedSearches,
|
|
createSavedSearch,
|
|
updateSavedSearch,
|
|
deleteSavedSearch,
|
|
clearSavedSearches,
|
|
// Utilities
|
|
buildSearchUrl,
|
|
generateSearchName,
|
|
};
|
|
|
|
export default storage;
|