child searches - wer sucht der findet :)

This commit is contained in:
lovebird 2026-03-28 12:31:46 +01:00
parent af7637d158
commit 2d59f5df14
19 changed files with 3013 additions and 2739 deletions

View File

@ -211,6 +211,8 @@ export const GoogleMediaSchema = z.object({
// Raw data schema
export const LocationSchema = z.object({
position: z.number(),
rating: z.number(),
reviews: z.number(),
title: z.string(),
description: z.string(),
place_id: z.string(),
@ -252,6 +254,11 @@ export const CompetitorSchemaFull = z.object({
thumbnail: z.string().optional().nullable(),
types: z.array(z.string()).optional().nullable(),
raw_data: LocationSchema.optional().nullable(),
sites: z.array(z.object({
name: z.string(),
url: z.string(),
content: z.string()
})).optional(),
continent: z.string().optional().nullable(),
country: z.string().optional().nullable(),
city: z.string().optional().nullable(),

View File

@ -66,6 +66,7 @@ let FileBrowser: any;
let SupportChat: any;
GridSearch = React.lazy(() => import("./modules/places/gridsearch/GridSearch"));
LocationDetail = React.lazy(() => import("./modules/places/LocationDetail"));
if (enablePlaygrounds) {
PlaygroundEditor = React.lazy(() => import("./pages/PlaygroundEditor"));
@ -81,7 +82,7 @@ if (enablePlaygrounds) {
VariablePlayground = React.lazy(() => import("./components/variables/VariablesEditor").then(module => ({ default: module.VariablesEditor })));
I18nPlayground = React.lazy(() => import("./components/playground/I18nPlayground"));
PlaygroundChat = React.lazy(() => import("./pages/PlaygroundChat"));
LocationDetail = React.lazy(() => import("./modules/places/LocationDetail"));
Tetris = React.lazy(() => import("./apps/tetris/Tetris"));
FileBrowser = React.lazy(() => import("./apps/filebrowser/FileBrowser"));
SupportChat = React.lazy(() => import("./pages/SupportChat"));
@ -178,7 +179,7 @@ const AppWrapper = () => {
<Route path="/admin/*" element={<React.Suspense fallback={<div>Loading...</div>}><AdminPage /></React.Suspense>} />
<Route path="/products/gridsearch" element={<React.Suspense fallback={<div>Loading...</div>}><GridSearch /></React.Suspense>} />
{enablePlaygrounds && <Route path="/products/places/detail/:place_id" element={<React.Suspense fallback={<div>Loading...</div>}><LocationDetail /></React.Suspense>} />}
<Route path="/products/places/detail/:place_id" element={<React.Suspense fallback={<div>Loading...</div>}><LocationDetail /></React.Suspense>} />
{enablePlaygrounds && <Route path="/products/places/*" element={<React.Suspense fallback={<div>Loading...</div>}><PlacesModule /></React.Suspense>} />}
{/* Playground Routes */}

View File

@ -109,7 +109,7 @@ export const WebSocketProvider: React.FC<WebSocketProviderProps> = ({ children,
);
return newConnectionEstablished;
} catch (error) {
logger.logError(error, '[ModbusContext] modbusService.connect() failed');
// logger.logError(error, '[ModbusContext] modbusService.connect() failed');
return false;
}
}, [handleWsStatusChange, ensureModbus]);
@ -149,7 +149,7 @@ export const WebSocketProvider: React.FC<WebSocketProviderProps> = ({ children,
return isNowConnected;
} catch (error: any) {
logger.logError(error, '[ModbusContext] Error in connectToServer');
// logger.logError(error, '[ModbusContext] Error in connectToServer');
setIsConnected(false);
setConnecting(false);
return false;

File diff suppressed because it is too large Load Diff

View File

@ -20,6 +20,7 @@ import { RegionLayers } from './components/map-layers/RegionLayers';
import { MapLayerToggles } from './components/MapLayerToggles';
import { MapOverlayToolbars } from './components/MapOverlayToolbars';
import { LiveSearchLayers } from './components/map-layers/LiveSearchLayers';
import { T, translate } from '../../i18n';
const safeSetStyle = (m: maplibregl.Map, style: any) => {
const terrain = m.getTerrain();
@ -62,16 +63,16 @@ export const MAP_PRESETS: Record<MapPreset, MapFeatures> = {
enableLayerToggles: true,
enableInfoPanel: true,
enableLocationDetails: true,
enableAutoRegions: false,
enableSimulator: false,
enableAutoRegions: true,
enableSimulator: true,
canDebug: true,
canPlaybackSpeed: true,
},
Minimal: {
enableSidebarTools: true, // hasTools (minimal = true)
enableRuler: false,
enableEnrichment: false,
enableLayerToggles: false,
enableRuler: true,
enableEnrichment: true,
enableLayerToggles: true,
enableInfoPanel: false,
enableLocationDetails: true,
enableAutoRegions: true,
@ -100,6 +101,7 @@ interface CompetitorsMapViewProps {
liveRadii?: any[];
liveNodes?: any[];
liveScanner?: any;
onRegionsChange?: (regions: any[]) => void;
}
@ -142,13 +144,13 @@ const renderPopupHtml = (competitor: CompetitorFull) => {
const tagsHtml = `<div class="mb-2 flex flex-wrap gap-1">${getTags()}</div>`;
// Contact details
const addressHtml = `<div class="flex items-start"><svg class="h-4 w-4 mr-2 mt-0.5 flex-shrink-0 text-gray-400" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin"><path d="M20 10c0 6-9 13-9 13s-9-7-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg><span>${competitor.address || 'No address'}</span></div>`;
const addressHtml = `<div class="flex items-start"><svg class="h-4 w-4 mr-2 mt-0.5 flex-shrink-0 text-gray-400" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin"><path d="M20 10c0 6-9 13-9 13s-9-7-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg><span>${competitor.address || translate('No address')}</span></div>`;
const phoneHtml = competitor.phone ? `<div class="flex items-center"><svg class="h-4 w-4 mr-2 flex-shrink-0 text-gray-400" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-phone"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg><span>${competitor.phone}</span></div>` : '';
const websiteHtml = competitor.website ? `<div class="flex items-center"><svg class="h-4 w-4 mr-2 flex-shrink-0 text-gray-400" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-globe"><circle cx="12" cy="12" r="10"/><line x1="2" x2="22" y1="12" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg><a href="${competitor.website}" target="_blank" rel="noopener noreferrer" class="text-indigo-600 hover:text-indigo-500 truncate">Visit Website</a></div>` : '';
const websiteHtml = competitor.website ? `<div class="flex items-center"><svg class="h-4 w-4 mr-2 flex-shrink-0 text-gray-400" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-globe"><circle cx="12" cy="12" r="10"/><line x1="2" x2="22" y1="12" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg><a href="${competitor.website}" target="_blank" rel="noopener noreferrer" class="text-indigo-600 hover:text-indigo-500 truncate">${translate('Visit Website')}</a></div>` : '';
const hoursHtml = competitor.operating_hours ? `<div class="flex items-start"><svg class="h-4 w-4 mr-2 mt-0.5 flex-shrink-0 text-gray-400" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-clock"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg><span class="text-xs">Operating hours available</span></div>` : '';
const hoursHtml = competitor.operating_hours ? `<div class="flex items-start"><svg class="h-4 w-4 mr-2 mt-0.5 flex-shrink-0 text-gray-400" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-clock"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg><span class="text-xs">${translate('Operating hours available')}</span></div>` : '';
return `
<div class="min-w-[280px] max-w-[280px] -m-3">
@ -171,7 +173,7 @@ const renderPopupHtml = (competitor: CompetitorFull) => {
`;
};
export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competitors, onMapCenterUpdate, initialCenter, initialZoom, onMapMove, enrich, isEnriching, enrichmentProgress, initialGadmRegions, initialSimulatorSettings, simulatorSettings, onSimulatorSettingsChange, liveAreas = [], liveRadii = [], liveNodes = [], liveScanner, preset = 'SearchView', customFeatures }) => {
export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competitors, onMapCenterUpdate, initialCenter, initialZoom, onMapMove, enrich, isEnriching, enrichmentProgress, initialGadmRegions, initialSimulatorSettings, simulatorSettings, onSimulatorSettingsChange, liveAreas = [], liveRadii = [], liveNodes = [], liveScanner, preset = 'SearchView', customFeatures, onRegionsChange }) => {
const features: MapFeatures = useMemo(() => {
return {
...MAP_PRESETS[preset],
@ -249,6 +251,11 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
})();
}, [features.enableAutoRegions, initialGadmRegions]);
// Bubble picker regions up to parent
useEffect(() => {
onRegionsChange?.(pickerRegions);
}, [pickerRegions, onRegionsChange]);
// Fit map to loaded region boundaries (waits for map readiness)
const hasFittedBoundsRef = useRef(false);
useEffect(() => {
@ -521,13 +528,13 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
>
<div className="p-2 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center bg-gray-50/50 dark:bg-gray-900/50 flex-shrink-0">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="gadm" className="text-xs">Area Selector</TabsTrigger>
<TabsTrigger value="simulator" className="text-xs">Grid Search</TabsTrigger>
<TabsTrigger value="gadm" className="text-xs"><T>Area Selector</T></TabsTrigger>
<TabsTrigger value="simulator" className="text-xs"><T>Grid Search</T></TabsTrigger>
</TabsList>
<button
onClick={() => { setGadmPickerActive(false); setSimulatorActive(false); setSidebarOpen(false); }}
className="ml-2 p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-700"
title="Close Tools"
title={translate('Close Tools')}
>
<X className="w-4 h-4" />
</button>
@ -563,7 +570,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
/>
) : (
<div className="p-4 text-sm text-gray-500 text-center border border-gray-200 dark:border-gray-700 rounded-md bg-gray-50 dark:bg-gray-900/50">
Please select a region using the Area Selector first to define the search bounds.
<T>Please select a region using the Area Selector first to define the search bounds.</T>
</div>
)}
</TabsContent>
@ -694,7 +701,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
onClick={() => enrich(locationIds.split(','), ['meta'])}
disabled={isEnriching || validLocations.length === 0}
className={`p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 ${isEnriching ? 'text-indigo-600 animate-pulse' : 'text-gray-500'}`}
title="Enrich Visible Locations"
title={translate("Enrich Visible Locations")}
>
<Sparkles className="w-3.5 h-3.5" />
</button>

View File

@ -1,6 +1,7 @@
import React from 'react';
import { Tooltip, Chip, Popover, Box, Typography, List, ListItem, ListItemButton, ListItemText, ListItemIcon } from '@mui/material';
import { Mail, ContentCopy, CheckCircle } from '@mui/icons-material';
import { T, translate } from '../../i18n';
export interface EmailCellProps {
emails?: Array<{
@ -79,8 +80,8 @@ export const EmailCell: React.FC<EmailCellProps> = ({ emails, website, isExclude
if (isExcluded) {
return (
<div className="flex items-center justify-center h-full w-full">
<Tooltip title="Type excluded from search">
<span className="text-gray-300 text-xs italic select-none">Excluded</span>
<Tooltip title={translate("Type excluded from search")}>
<span className="text-gray-300 text-xs italic select-none"><T>Excluded</T></span>
</Tooltip>
</div>
)
@ -100,7 +101,7 @@ export const EmailCell: React.FC<EmailCellProps> = ({ emails, website, isExclude
return (
<div className="flex items-center justify-start h-full py-1">
<Tooltip
title={hasMultiple ? "Click to see all emails" : "Click to copy"}
title={hasMultiple ? translate("Click to see all emails") : translate("Click to copy")}
>
<Chip
icon={copiedEmail === primaryEmail.email ? <CheckCircle /> : <Mail />}
@ -128,7 +129,7 @@ export const EmailCell: React.FC<EmailCellProps> = ({ emails, website, isExclude
>
<Box sx={{ p: 1, maxHeight: 300, overflow: 'auto' }}>
<Typography variant="subtitle2" sx={{ px: 2, py: 1, color: 'text.secondary' }}>
Found {uniqueEmails.length} emails
{translate('Found')} {uniqueEmails.length} {uniqueEmails.length === 1 ? translate('email') : translate('emails')}
</Typography>
<List dense>
{uniqueEmails.map((emailData, index) => (
@ -147,8 +148,8 @@ export const EmailCell: React.FC<EmailCellProps> = ({ emails, website, isExclude
primary={emailData.email}
secondary={
<>
{emailData.source && <span style={{ display: 'block', fontSize: '0.75rem' }}>Source: {emailData.source}</span>}
{emailData.foundAt && <span style={{ display: 'block', fontSize: '0.75rem' }}>Found: {new Date(emailData.foundAt).toLocaleDateString()}</span>}
{emailData.source && <span style={{ display: 'block', fontSize: '0.75rem' }}><T>Source</T>: {emailData.source}</span>}
{emailData.foundAt && <span style={{ display: 'block', fontSize: '0.75rem' }}><T>Found</T>: {new Date(emailData.foundAt).toLocaleDateString()}</span>}
</>
}
/>

View File

@ -1,20 +1,26 @@
import React, { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { ArrowLeft, MapPin, Globe, Phone, Clock, Calendar, Image as ImageIcon, Instagram, Facebook, Linkedin, Youtube, Twitter } from 'lucide-react';
import { ArrowLeft, MapPin, Globe, Phone, Clock, Calendar, Image as ImageIcon, Instagram, Facebook, Linkedin, Youtube, Twitter, Star, Fingerprint, ListOrdered } from 'lucide-react';
import Lightbox from "yet-another-react-lightbox";
import "yet-another-react-lightbox/styles.css";
import { API_URL, THUMBNAIL_WIDTH } from '../../constants';
import type { CompetitorFull } from '@polymech/shared';
import { fetchCompetitorById } from './client-gridsearch';
import { fetchCompetitorById, fetchPlacePhotos } from './client-gridsearch';
import MarkdownRenderer from '../../components/MarkdownRenderer';
import { T, translate } from '../../i18n';
// Extracted Presentation Component
export const LocationDetailView: React.FC<{ competitor: CompetitorFull; onClose?: () => void }> = ({ competitor, onClose }) => {
export const LocationDetailView: React.FC<{ competitor: CompetitorFull; onClose?: () => void; livePhotos?: any }> = ({ competitor, onClose, livePhotos }) => {
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const [activeTab, setActiveTab] = useState<'overview' | 'debug'>('overview');
const [activeTab, setActiveTab] = useState<'overview' | 'homepage' | 'debug'>('overview');
const showDebug = import.meta.env.VITE_LOCATION_DETAIL_DEBUG === 'true';
const photos = competitor.raw_data?.google_media?.photos?.map((photo: any) => ({
console.log(competitor);
// Prefer live-fetched photos, fall back to DB-cached
const photoSource = livePhotos || competitor.raw_data?.google_media;
const photos = photoSource?.photos?.map((photo: any) => ({
src: photo.image,
alt: competitor.title,
})) || [];
@ -30,8 +36,8 @@ export const LocationDetailView: React.FC<{ competitor: CompetitorFull; onClose?
].filter(link => link.url);
return (
<div className="h-full overflow-y-auto ">
{onClose ? (
<div className="h-full overflow-y-auto bg-gray-50/50 dark:bg-gray-900/50">
{onClose && (
// Property Pane Header
<div className="sticky top-0 z-10 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between">
<h2 className="text-lg font-medium text-gray-900 dark:text-gray-100 truncate pr-4">{competitor.title}</h2>
@ -41,7 +47,7 @@ export const LocationDetailView: React.FC<{ competitor: CompetitorFull; onClose?
target="_blank"
rel="noopener noreferrer"
className="p-1 rounded-full hover: text-gray-500"
title="Open details in new tab"
title={translate("Open details in new tab")}
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
@ -54,20 +60,12 @@ export const LocationDetailView: React.FC<{ competitor: CompetitorFull; onClose?
</button>
</div>
</div>
) : (
// Standalone Page Header
<div className="mb-8 px-4 sm:px-6 lg:px-8 pt-6">
<Link to="/products/places" className="inline-flex items-center text-sm text-gray-500 hover:">
<ArrowLeft className="h-4 w-4 mr-1" />
Back to Places
</Link>
</div>
)}
<div className={onClose ? "p-4 space-y-6" : "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-8"}>
{/* Header Section (Title & Tags) - Hide title if in pane header, or show specific layout */}
{!onClose && (
<div className=" shadow overflow-hidden sm:rounded-lg">
<div className="bg-white/80 dark:bg-gray-800/70 backdrop-blur-sm shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6 flex justify-between items-start">
<div>
<h3 className="text-2xl font-bold leading-6 text-gray-900 dark:text-white">{competitor.title}</h3>
@ -119,7 +117,7 @@ export const LocationDetailView: React.FC<{ competitor: CompetitorFull; onClose?
</div>
)}
{showDebug && (
{(showDebug || competitor.website) && (
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="-mb-px flex space-x-8 px-4" aria-label="Tabs">
<button
@ -127,19 +125,32 @@ export const LocationDetailView: React.FC<{ competitor: CompetitorFull; onClose?
className={`${activeTab === 'overview'
? 'border-indigo-500 text-indigo-600 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm`}
} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors`}
>
Overview
</button>
<button
onClick={() => setActiveTab('debug')}
className={`${activeTab === 'debug'
? 'border-indigo-500 text-indigo-600 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm`}
>
Debug
<T>Overview</T>
</button>
{competitor.website && (
<button
onClick={() => setActiveTab('homepage')}
className={`${activeTab === 'homepage'
? 'border-indigo-500 text-indigo-600 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors`}
>
<T>Homepage</T>
</button>
)}
{showDebug && (
<button
onClick={() => setActiveTab('debug')}
className={`${activeTab === 'debug'
? 'border-indigo-500 text-indigo-600 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors`}
>
<T>Debug</T>
</button>
)}
</nav>
</div>
)}
@ -147,11 +158,11 @@ export const LocationDetailView: React.FC<{ competitor: CompetitorFull; onClose?
{activeTab === 'overview' ? (
<>
<div className={` ${!onClose && 'shadow overflow-hidden sm:rounded-lg'}`}>
<div className={`bg-white/80 dark:bg-gray-800/70 backdrop-blur-sm ${!onClose && 'shadow overflow-hidden sm:rounded-lg'}`}>
<div className={!onClose ? "px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700" : "mb-3"}>
<h3 className="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100 flex items-center">
<ImageIcon className="h-5 w-5 mr-2" />
Photos ({photos.length})
<T>Photos</T> ({photos.length})
</h3>
</div>
<div className={`grid gap-2 ${onClose ? 'grid-cols-3' : 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 px-4 pb-4'}`}>
@ -185,12 +196,12 @@ export const LocationDetailView: React.FC<{ competitor: CompetitorFull; onClose?
</div>
<div className={` ${!onClose && 'shadow overflow-hidden sm:rounded-lg'}`}>
<div className={`bg-white/80 dark:bg-gray-800/70 backdrop-blur-sm ${!onClose && 'shadow overflow-hidden sm:rounded-lg'}`}>
<div className={!onClose ? "border-t border-gray-200 px-4 py-5 sm:px-6" : ""}>
<dl className={`grid grid-cols-1 gap-x-4 gap-y-4 ${!onClose ? 'sm:grid-cols-2 gap-y-8' : ''}`}>
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center">
<MapPin className="h-4 w-4 mr-2" /> Address
<MapPin className="h-4 w-4 mr-2" /> <T>Address</T>
</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-200">
{competitor.address}
@ -201,7 +212,7 @@ export const LocationDetailView: React.FC<{ competitor: CompetitorFull; onClose?
rel="noopener noreferrer"
className="inline-flex items-center text-xs text-indigo-600 hover:text-indigo-500 font-medium"
>
View on Map
<T>View on Map</T>
</a>
{competitor.gps_coordinates && (
<a
@ -210,7 +221,7 @@ export const LocationDetailView: React.FC<{ competitor: CompetitorFull; onClose?
rel="noopener noreferrer"
className="inline-flex items-center text-xs text-indigo-600 hover:text-indigo-500 font-medium ml-3"
>
Street View
<T>Street View</T>
</a>
)}
</div>
@ -219,28 +230,28 @@ export const LocationDetailView: React.FC<{ competitor: CompetitorFull; onClose?
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center">
<Globe className="h-4 w-4 mr-2" /> Website
<Globe className="h-4 w-4 mr-2" /> <T>Website</T>
</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-200 break-all">
{competitor.website ? (
<a href={competitor.website} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:underline">
Visit Website
<T>Visit Website</T>
</a>
) : 'N/A'}
) : <T>N/A</T>}
</dd>
</div>
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center">
<Phone className="h-4 w-4 mr-2" /> Phone
<Phone className="h-4 w-4 mr-2" /> <T>Phone</T>
</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-200">{competitor.phone || 'N/A'}</dd>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-200">{competitor.phone || <T>N/A</T>}</dd>
</div>
{socialLinks.length > 0 && (
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
Social Profiles
<T>Social Profiles</T>
</dt>
<dd className="flex flex-wrap gap-3 mt-1">
{socialLinks.map((social) => (
@ -250,7 +261,7 @@ export const LocationDetailView: React.FC<{ competitor: CompetitorFull; onClose?
target="_blank"
rel="noopener noreferrer"
className={`p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${social.color}`}
title={social.name}
title={translate(social.name)}
>
<social.icon className="h-5 w-5" />
</a>
@ -259,49 +270,129 @@ export const LocationDetailView: React.FC<{ competitor: CompetitorFull; onClose?
</div>
)}
{competitor.raw_data?.rating !== undefined && (
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center">
<Star className="h-4 w-4 mr-2 text-yellow-500" /> <T>Rating</T>
</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-200 font-medium">
{competitor.raw_data.rating} <span className="text-gray-500 dark:text-gray-400 font-normal">({competitor.raw_data.reviews || 0} <T>reviews</T>)</span>
</dd>
</div>
)}
{competitor.raw_data?.provider_id && (
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center">
<Fingerprint className="h-4 w-4 mr-2" /> <T>Provider ID</T>
</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-200 font-mono text-xs break-all">
{competitor.raw_data.provider_id}
</dd>
</div>
)}
{competitor.raw_data?.position !== undefined && (
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center">
<ListOrdered className="h-4 w-4 mr-2" /> <T>Search Position</T>
</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-200">
#{competitor.raw_data.position}
</dd>
</div>
)}
{!onClose && (
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center">
<Calendar className="h-4 w-4 mr-2" /> Last Updated
<Calendar className="h-4 w-4 mr-2" /> <T>Last Updated</T>
</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-200">
{competitor.updated_at ? new Date(competitor.updated_at).toLocaleDateString() : 'N/A'}
{competitor.updated_at ? new Date(competitor.updated_at).toLocaleDateString() : <T>N/A</T>}
</dd>
</div>
)}
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-2">
<Clock className="h-4 w-4 mr-2" /> Operating Hours
<Clock className="h-4 w-4 mr-2" /> <T>Operating Hours</T>
</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-200">
{competitor.operating_hours ? (
<ul className="grid grid-cols-1 gap-1 text-xs sm:text-sm">
{Object.entries(competitor.operating_hours).map(([day, hours]) => (
<li key={day} className="flex justify-between border-b border-gray-100 dark:border-gray-700 pb-1 last:border-0">
<span className="capitalize font-medium text-gray-600 dark:text-gray-400 w-24">{day}</span>
<span className="capitalize font-medium text-gray-600 dark:text-gray-400 w-24">{(translate(day) !== day) ? translate(day) : day}</span>
<span>{hours as string}</span>
</li>
))}
</ul>
) : 'Not available'}
) : <T>Not available</T>}
</dd>
</div>
{competitor.raw_data?.description && (
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Description</dt>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1"><T>Description</T></dt>
<dd className="text-sm text-gray-900 dark:text-gray-200">{competitor.raw_data.description}</dd>
</div>
)}
</dl>
</div>
</div>
{competitor.sites && competitor.sites.length > 0 && (
<div className={`mt-6 bg-white/80 dark:bg-gray-800/70 backdrop-blur-sm ${!onClose && 'shadow overflow-hidden sm:rounded-lg'}`}>
<div className={!onClose ? "px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700" : "mb-3"}>
<h3 className="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100 flex items-center">
<Globe className="h-5 w-5 mr-2 text-gray-500" />
<T>Extracted Web Content</T>
</h3>
</div>
<div className="p-4 space-y-6">
{competitor.sites.map((site: any, idx: number) => (
<div key={idx} className="border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden bg-white dark:bg-gray-800">
<div className="bg-gray-50 dark:bg-gray-800/80 px-4 py-2.5 border-b border-gray-200 dark:border-gray-700 flex flex-col sm:flex-row justify-between items-start sm:items-center text-sm gap-2">
<span className="font-semibold text-gray-800 dark:text-gray-200 capitalize flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500 shadow-sm shadow-green-400/50"></span>
{site.name} <T>Page</T>
</span>
<a href={site.url} target="_blank" rel="noopener noreferrer" className="text-indigo-600 dark:text-indigo-400 hover:underline truncate max-w-full text-xs" title={site.url}>
{site.url}
</a>
</div>
<div className="p-4 sm:p-6 max-h-[500px] overflow-y-auto custom-scrollbar">
<MarkdownRenderer content={site.content || ''} baseUrl={site.url} />
</div>
</div>
))}
</div>
</div>
)}
</>
) : activeTab === 'homepage' && competitor.website ? (
<div className={`mt-4 bg-white dark:bg-gray-800 ${!onClose && 'shadow sm:rounded-lg'} overflow-hidden min-h-[600px] flex flex-col`}>
<div className="p-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex items-center justify-between text-xs">
<span className="text-gray-600 dark:text-gray-400 font-mono truncate mr-4">
{competitor.website}
</span>
<a href={competitor.website} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:underline shrink-0 font-medium px-2 py-1 rounded bg-indigo-50 dark:bg-indigo-900/30">
<T>Open in new tab</T>
</a>
</div>
<iframe
src={competitor.website}
className="w-full flex-1 border-0 min-h-[600px] bg-white"
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
referrerPolicy="no-referrer"
title={translate("Homepage Preview")}
/>
</div>
) : (
<div className={!onClose ? "shadow overflow-hidden sm:rounded-lg" : ""}>
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100 mb-4">Raw Data</h3>
<h3 className="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100 mb-4"><T>Raw Data</T></h3>
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 overflow-auto max-h-[600px]">
<pre className="text-xs font-mono text-gray-700 dark:text-gray-300">
{JSON.stringify(competitor, null, 2)}
@ -345,6 +436,7 @@ const LocationDetail: React.FC = () => {
const [competitor, setCompetitor] = useState<CompetitorFull | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [livePhotos, setLivePhotos] = useState<any>(null);
useEffect(() => {
const fetchCompetitor = async () => {
@ -352,6 +444,13 @@ const LocationDetail: React.FC = () => {
try {
const data = await fetchCompetitorById(place_id);
setCompetitor(data);
// Fetch photos on-the-fly (async, non-blocking)
if (!data?.raw_data?.google_media?.photos?.length) {
fetchPlacePhotos(place_id)
.then(photos => { if (photos?.photos?.length) setLivePhotos(photos); })
.catch(err => console.warn('Photos fetch skipped:', err.message));
}
} catch (err) {
console.error('Error fetching competitor details:', err);
setError('Failed to load competitor details.');
@ -367,13 +466,13 @@ const LocationDetail: React.FC = () => {
if (error || !competitor) {
return (
<div className="text-center py-12">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Error</h2>
<p className="text-gray-600 mb-8">{error || 'Competitor not found'}</p>
<Link to="/products/places" className="text-indigo-600 hover:underline">Return to list</Link>
<h2 className="text-2xl font-bold text-gray-900 mb-4"><T>Error</T></h2>
<p className="text-gray-600 mb-8">{error || <T>Competitor not found</T>}</p>
<Link to="/products/places" className="text-indigo-600 hover:underline"><T>Return to list</T></Link>
</div>
);
}
return <LocationDetailView competitor={competitor} />;
return <LocationDetailView competitor={competitor} livePhotos={livePhotos} />;
};
export default LocationDetail;

View File

@ -2,6 +2,7 @@ import React from 'react';
import {
Ban
} from 'lucide-react';
import { T } from '../../i18n';
import {
DropdownMenu,
@ -55,12 +56,12 @@ export const TypeCell: React.FC<TypeCellProps> = ({ types, excludedTypes = [], o
>
{isExcluded ? (
<>
<span className="mr-2">Restore "{type}"</span>
<span className="mr-2"><T>Restore</T> "{type}"</span>
</>
) : (
<>
<Ban className="mr-2 h-4 w-4 text-red-600 dark:text-red-500" />
<span className="text-red-600 dark:text-red-500">Exclude "{type}"</span>
<span className="text-red-600 dark:text-red-500"><T>Exclude</T> "{type}"</span>
</>
)}
</DropdownMenuItem>
@ -80,7 +81,7 @@ export const TypeCell: React.FC<TypeCellProps> = ({ types, excludedTypes = [], o
</Badge>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Other Types</DropdownMenuLabel>
<DropdownMenuLabel><T>Other Types</T></DropdownMenuLabel>
<DropdownMenuSeparator />
{safeTypes.slice(2).map(type => {
const isExcluded = excludedTypes.includes(type);

View File

@ -12,6 +12,7 @@ export interface GridSearchSummary {
query: string;
countries?: string[];
generatedAt: string;
children?: GridSearchSummary[];
}
export interface RestoredRunState {
@ -113,6 +114,11 @@ export const fetchCompetitorById = async (id: string): Promise<any> => {
return res.data;
};
export const fetchPlacePhotos = async (placeId: string): Promise<any> => {
const res = await apiClient<{ data: any }>(`/api/places/${placeId}/photos`);
return res.data;
};
// --- Places GridSearch API Methods (New C++ backend) ---
export const fetchPlacesGridSearches = async (): Promise<GridSearchSummary[]> => {
@ -146,6 +152,17 @@ export const retryPlacesGridSearchJob = async (id: string): Promise<any> => {
});
};
export const expandPlacesGridSearch = async (
parentId: string,
areas: { gid: string; name: string; level: number; raw?: any }[],
settings?: Record<string, any>
): Promise<{ message: string; jobId: string }> => {
return apiClient<{ message: string; jobId: string }>(`/api/places/gridsearch/${parentId}/expand`, {
method: 'POST',
body: JSON.stringify({ areas, settings }),
});
};
export const deletePlacesGridSearch = async (id: string): Promise<boolean> => {
// Note: Assuming there is a similar endpoint for places deletion.
const res = await apiClient<{ success: boolean; data: any }>(`/api/places/gridsearch/${id}`, {

View File

@ -72,7 +72,7 @@ export const paramsToSortModel = (searchParams: URLSearchParams): GridSortModel
return [];
};
const DEFAULT_HIDDEN_COLUMNS = ['address', 'country', 'website', 'phone', 'instagram', 'facebook', 'linkedin', 'youtube', 'twitter'];
const DEFAULT_HIDDEN_COLUMNS = ['address', 'country', 'phone'];
// Convert visibility model to URL params
export const visibilityModelToParams = (model: GridColumnVisibilityModel): Record<string, string> => {

View File

@ -100,8 +100,6 @@ export default function GridSearch() {
const [wizardSessionKey, setWizardSessionKey] = useState(0);
console.log('Search Wizard :', wizardSessionKey, selectedJobId, searchParams)
return (
<div className="flex h-[calc(100vh-64px)] overflow-hidden bg-gray-50 dark:bg-gray-900 w-full relative">
@ -153,10 +151,10 @@ export default function GridSearch() {
</button>
{/* Main Content Area */}
<RestoredSearchProvider jobId={selectedJobId}>
<RestoredSearchProvider key={selectedJobId || 'none'} jobId={selectedJobId}>
<div className="flex-1 flex flex-col min-w-0 relative z-10 w-full overflow-hidden bg-gradient-to-br from-indigo-50/50 via-white to-blue-50/50 dark:from-gray-900 dark:via-gray-900 dark:to-gray-800">
{selectedJobId ? (
<JobViewer jobId={selectedJobId} onRerun={(settings) => { setInitialWizardSettings(settings); setSelectedJobId(null); setWizardSessionKey(k => k + 0); }} />
<JobViewer key={selectedJobId} jobId={selectedJobId} />
) : (
<GridSearchWizard key={wizardSessionKey} initialSettings={initialWizardSettings} onJobSubmitted={(id) => setSelectedJobId(id, 'map')} />
)}

View File

@ -1,6 +1,6 @@
import React, { useState, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { LayoutGrid, List, Map as MapIcon, PieChart, FileText, Terminal } from 'lucide-react';
import { LayoutGrid, List, Map as MapIcon, PieChart, FileText, Terminal, PlusCircle, Loader2 } from 'lucide-react';
import { CompetitorsGridView } from '../CompetitorsGridView';
import { CompetitorsMapView } from '../CompetitorsMapView';
@ -8,6 +8,7 @@ import { CompetitorsThumbView } from '../CompetitorsThumbView';
import { CompetitorsMetaView } from '../CompetitorsMetaView';
import { CompetitorsReportView } from './CompetitorsReportView';
import { useRestoredSearch } from './RestoredSearchContext';
import { expandPlacesGridSearch } from '../client-gridsearch';
import { type CompetitorFull } from '@polymech/shared';
import { type LogEntry } from '@/contexts/LogContext';
@ -28,6 +29,7 @@ interface GridSearchResultsProps {
streaming?: boolean;
statusMessage?: string;
sseLogs?: LogEntry[];
onExpandSubmitted?: () => void;
}
const MOCK_SETTINGS = {
@ -37,7 +39,7 @@ const MOCK_SETTINGS = {
auto_enrich: false
};
export function GridSearchResults({ jobId, competitors, excludedTypes, updateExcludedTypes, liveAreas, liveRadii, liveNodes, liveScanner, stats, streaming, statusMessage, sseLogs }: GridSearchResultsProps) {
export function GridSearchResults({ jobId, competitors, excludedTypes, updateExcludedTypes, liveAreas, liveRadii, liveNodes, liveScanner, stats, streaming, statusMessage, sseLogs, onExpandSubmitted }: GridSearchResultsProps) {
const filteredCompetitors = React.useMemo(() => {
if (!excludedTypes || excludedTypes.length === 0) return competitors;
const excludedSet = new Set(excludedTypes.map(t => t.toLowerCase()));
@ -53,6 +55,42 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
gid: a.gid, name: a.name, level: a.level,
})) || undefined;
// Track regions picked in the map's GADM picker
const [pickedRegions, setPickedRegions] = useState<any[]>([]);
const [mapSimSettings, setMapSimSettings] = useState<any>(null);
const [expanding, setExpanding] = useState(false);
const [expandError, setExpandError] = useState<string | null>(null);
// Determine which picked regions are actually new (not already in this search)
const existingGids = React.useMemo(() => {
return new Set((restoredGadmAreas || []).map((a: any) => a.gid));
}, [restoredGadmAreas]);
const freshRegions = React.useMemo(() => {
return pickedRegions.filter(r => !existingGids.has(r.gid));
}, [pickedRegions, existingGids]);
const handleExpand = useCallback(async () => {
if (freshRegions.length === 0) return;
setExpanding(true);
setExpandError(null);
try {
await expandPlacesGridSearch(jobId, freshRegions.map(r => ({
gid: r.gid,
name: r.name,
level: r.level,
raw: r.raw || { level: r.level, gadmName: r.name, gid: r.gid },
})), mapSimSettings || undefined);
onExpandSubmitted?.();
} catch (e: any) {
setExpandError(e.message || 'Failed to expand search');
console.error('Expand failed:', e);
} finally {
setExpanding(false);
}
}, [jobId, freshRegions, onExpandSubmitted]);
const [viewMode, setViewMode] = useState<ViewMode>(() => {
const urlView = searchParams.get('view') as ViewMode;
if (urlView && ['grid', 'thumb', 'map', 'meta', 'report', ...(import.meta.env.DEV ? ['log'] : [])].includes(urlView)) return urlView;
@ -77,7 +115,29 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
return (
<div className="flex flex-col h-full overflow-hidden w-full relative">
<div className="flex justify-end mb-4 mt-2 px-2">
<div className="flex justify-between mb-4 mt-2 px-2">
{/* Expand button — only visible when fresh regions are picked */}
<div className="flex items-center gap-2">
{freshRegions.length > 0 && (
<button
onClick={handleExpand}
disabled={expanding || streaming}
className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 text-white text-xs font-medium rounded-lg transition-colors shadow-sm"
title={`Expand search with ${freshRegions.length} new region(s)`}
>
{expanding ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<PlusCircle className="w-3.5 h-3.5" />
)}
Expand +{freshRegions.length}
</button>
)}
{expandError && (
<span className="text-xs text-red-500">{expandError}</span>
)}
</div>
<div className="flex items-center gap-3 p-1 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm bg-white dark:bg-gray-800">
<span className="text-xs text-gray-500 px-2">
{filteredCompetitors.length} results
@ -193,6 +253,9 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
return newParams;
}, { replace: true });
}}
onRegionsChange={setPickedRegions}
simulatorSettings={mapSimSettings}
onSimulatorSettingsChange={setMapSimSettings}
/>
)}
@ -212,7 +275,7 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
<div className="h-full">
<ChatLogBrowser
logs={sseLogs}
clearLogs={() => {}}
clearLogs={() => { }}
title="SSE Stream Events"
/>
</div>

View File

@ -75,7 +75,7 @@ function bearingBetween(lat1: number, lng1: number, lat2: number, lng2: number):
const dLng = toRad(lng2 - lng1);
const y = Math.sin(dLng) * Math.cos(toRad(lat2));
const x = Math.cos(toRad(lat1)) * Math.sin(toRad(lat2)) -
Math.sin(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.cos(dLng);
Math.sin(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.cos(dLng);
return (toDeg(Math.atan2(y, x)) + 360) % 360;
}
@ -146,8 +146,7 @@ function applyEventToState(
const stats = parsed.stats || data?.stats;
if (stats) next.stats = { ...next.stats, ...stats };
console.log(`SSE ${type}`, parsed);
// console.log(`SSE ${type}`, parsed);
switch (type) {
case 'grid-ready':
next.stats = { ...next.stats, totalWaypoints: data?.totalWaypoints || data?.waypoints?.length || 0, phase: 'searching' };

View File

@ -6,6 +6,7 @@ import { GadmRegionCollector } from '../gadm-picker/GadmRegionCollector';
import { GadmNode } from '../gadm-picker/GadmTreePicker';
import { GadmPickerProvider, useGadmPicker } from '../gadm-picker/GadmPickerContext';
import { Badge } from "@/components/ui/badge";
import { T, translate } from '../../../i18n';
export function GridSearchWizard({ onJobSubmitted, initialSettings }: { onJobSubmitted: (jobId: string) => void, initialSettings?: any }) {
const initNodes = initialSettings?.guidedAreas?.map((a: any) => ({
@ -65,6 +66,14 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings }: { onJobSubmi
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [pastSearches, setPastSearches] = useState<string[]>(() => {
try {
return JSON.parse(localStorage.getItem('gridSearchPastQueries') || '[]');
} catch {
return [];
}
});
useEffect(() => {
if (!initialSettings?.excludeTypes) {
getGridSearchExcludeTypes().then(types => {
@ -100,6 +109,11 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings }: { onJobSubmi
const excludeTypes = excludeTypesStr.split(',').map(s => s.trim()).filter(Boolean);
await saveGridSearchExcludeTypes(excludeTypes);
// Save search to history
const newPastSearches = Array.from(new Set([searchQuery.trim(), ...pastSearches])).filter(Boolean).slice(0, 15);
localStorage.setItem('gridSearchPastQueries', JSON.stringify(newPastSearches));
setPastSearches(newPastSearches);
const regionNames = collectedNodes.map(n => n.name).join(' | ');
const areas = collectedNodes.map(node => {
@ -131,7 +145,7 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings }: { onJobSubmi
onJobSubmitted(res.jobId);
setStep(5);
} catch (err: any) {
setError(err.message || 'Failed to submit job');
setError(err.message || translate('Failed to submit job'));
}
setSubmitting(false);
};
@ -170,8 +184,8 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings }: { onJobSubmi
{step === 1 && (
<div className="flex flex-col flex-1 min-h-0 space-y-6">
<div className="shrink-0">
<h2 className="text-3xl font-bold text-gray-800 dark:text-gray-100 mb-2">Where do you want to search?</h2>
<p className="text-gray-500 dark:text-gray-400">Select one or more countries, regions, or cities to perform the grid search.</p>
<h2 className="text-3xl font-bold text-gray-800 dark:text-gray-100 mb-2"><T>Where do you want to search?</T></h2>
<p className="text-gray-500 dark:text-gray-400"><T>Select one or more countries, regions, or cities to perform the grid search.</T></p>
</div>
<GadmRegionCollector
resolutions={resolutions}
@ -182,8 +196,8 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings }: { onJobSubmi
{step === 2 && (
<div className="space-y-6">
<h2 className="text-3xl font-bold text-gray-800 dark:text-gray-100 mb-2">What are you looking for?</h2>
<p className="text-gray-500 dark:text-gray-400">Enter your SerpAPI search query for Google Maps.</p>
<h2 className="text-3xl font-bold text-gray-800 dark:text-gray-100 mb-2"><T>What are you looking for?</T></h2>
<p className="text-gray-500 dark:text-gray-400"><T>Enter your SerpAPI search query for Google Maps.</T></p>
<div className="relative mt-8">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
@ -191,13 +205,17 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings }: { onJobSubmi
</div>
<input
type="text"
placeholder="e.g., HVAC contractors"
list="past-searches"
placeholder={translate('e.g., HVAC contractors')}
className="w-full block pl-12 pr-4 py-4 text-lg border-2 border-transparent bg-gray-100 dark:bg-gray-900 focus:bg-white dark:focus:bg-gray-800 focus:border-indigo-500 focus:ring-0 rounded-xl transition-colors dark:text-white"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
autoFocus
onKeyDown={e => { if (e.key === 'Enter' && searchQuery) handleNext(); }}
/>
<datalist id="past-searches">
{pastSearches.map(s => <option key={s} value={s} />)}
</datalist>
</div>
</div>
)}
@ -205,7 +223,7 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings }: { onJobSubmi
{step === 3 && (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex items-center justify-between border-b pb-3 dark:border-gray-700 shrink-0">
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-100">Preview & Simulate</h2>
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-100"><T>Preview & Simulate</T></h2>
</div>
<div className="border rounded-xl overflow-hidden flex-1 min-h-0 mt-3 flex flex-col">
<CompetitorsMapView
@ -224,18 +242,18 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings }: { onJobSubmi
{step === 4 && (
<div className="space-y-6">
<h2 className="text-3xl font-bold text-gray-800 dark:text-gray-100 mb-2">Ready to Launch</h2>
<p className="text-gray-500 dark:text-gray-400">Configure your grid granularity and start the scan.</p>
<h2 className="text-3xl font-bold text-gray-800 dark:text-gray-100 mb-2"><T>Ready to Launch</T></h2>
<p className="text-gray-500 dark:text-gray-400"><T>Configure your grid granularity and start the scan.</T></p>
<div className="mt-8 bg-gray-50 dark:bg-gray-900 rounded-xl p-6 border dark:border-gray-700">
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-4">Search Summary</h3>
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-4"><T>Search Summary</T></h3>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Regions:</span>
<span className="text-gray-600 dark:text-gray-400"><T>Regions</T>:</span>
<span className="font-medium text-gray-900 dark:text-gray-100 text-right max-w-xs">{collectedNodes.map(n => n.name).join(', ')}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Query:</span>
<span className="text-gray-600 dark:text-gray-400"><T>Query</T>:</span>
<span className="font-medium text-gray-900 dark:text-gray-100">"{searchQuery}"</span>
</div>
<div className="pt-4 border-t dark:border-gray-700 mt-4">
@ -248,16 +266,16 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings }: { onJobSubmi
className="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500 cursor-pointer"
/>
<label htmlFor="enableEnrichments" className="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
Enable Auto-Enrichments (Website & Email Lookup)
<T>Enable Auto-Enrichments (Website & Email Lookup)</T>
</label>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 ml-7">
Automatically fetch the website and look up emails for each found location. Select this for deeper data collection (uses more credits).
<T>Automatically fetch the website and look up emails for each found location. Select this for deeper data collection (uses more credits).</T>
</p>
</div>
<div className="pt-4 border-t dark:border-gray-700 mt-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Search Limit per grid cell (Max 100)</label>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"><T>Search Limit per grid cell (Max 100)</T></label>
<input
type="number"
min="1"
@ -267,24 +285,24 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings }: { onJobSubmi
className="w-full px-3 py-2 border rounded-lg focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-800 dark:border-gray-600 dark:text-white"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Maximum number of results to fetch per grid cell from Google Maps. Allowed range: 1-20. Lower limits save credits and time.
<T>Maximum number of results to fetch per grid cell from Google Maps. Allowed range: 1-20. Lower limits save credits and time.</T>
</p>
</div>
<div className="pt-4 border-t dark:border-gray-700 mt-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Exclude Types (comma separated)</label>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"><T>Exclude Types (comma separated)</T></label>
<input
type="text"
value={excludeTypesStr}
onChange={(e) => setExcludeTypesStr(e.target.value)}
placeholder="e.g. restaurant, lodging"
placeholder={translate('e.g. restaurant, lodging')}
className="w-full px-3 py-2 border rounded-lg focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-800 dark:border-gray-600 dark:text-white"
/>
<p className="text-xs text-gray-400 mt-1">Found places with these types will be ignored globally.</p>
<p className="text-xs text-gray-400 mt-1"><T>Found places with these types will be ignored globally.</T></p>
{knownTypes.length > 0 && (
<div className="mt-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Known Types in Database</label>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"><T>Known Types in Database</T></label>
<div className="flex flex-wrap gap-2 max-h-48 overflow-y-auto p-3 border rounded-lg bg-gray-50 dark:bg-gray-800 dark:border-gray-700">
{knownTypes.map(type => {
const exclusions = excludeTypesStr.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
@ -329,13 +347,13 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings }: { onJobSubmi
<div className="w-20 h-20 bg-green-100 text-green-600 rounded-full flex items-center justify-center mx-auto mb-6">
<CheckCircle className="w-10 h-10" />
</div>
<h2 className="text-3xl font-bold text-gray-800 dark:text-gray-100">Search Launched!</h2>
<h2 className="text-3xl font-bold text-gray-800 dark:text-gray-100"><T>Search Launched!</T></h2>
<p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">
Your grid search has been successfully queued. You can monitor its progress in the sidebar.
<T>Your grid search has been successfully queued. You can monitor its progress in the sidebar.</T>
</p>
<div className="pt-6">
<button className="text-indigo-600 font-medium hover:underline bg-indigo-50 px-4 py-2 rounded-lg" onClick={handleReset}>
Start Another Search
<T>Start Another Search</T>
</button>
</div>
</div>
@ -350,7 +368,7 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings }: { onJobSubmi
disabled={step === 1 || submitting}
className="flex items-center text-gray-500 hover:text-gray-800 dark:hover:text-gray-200 font-medium disabled:opacity-0 transition-opacity"
>
<ChevronLeft className="w-5 h-5 mr-1" /> Back
<ChevronLeft className="w-5 h-5 mr-1" /> <T>Back</T>
</button>
{step < 4 ? (
@ -359,7 +377,7 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings }: { onJobSubmi
disabled={(step === 1 && collectedNodes.length === 0) || (step === 2 && !searchQuery.trim())}
className="flex items-center bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-2.5 rounded-xl font-medium transition-colors disabled:opacity-50"
>
Continue <ChevronRight className="w-5 h-5 ml-1" />
<T>Continue</T> <ChevronRight className="w-5 h-5 ml-1" />
</button>
) : (
<button
@ -368,7 +386,7 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings }: { onJobSubmi
className="flex items-center bg-indigo-600 hover:bg-indigo-700 text-white px-8 py-2.5 rounded-xl font-medium transition-colors disabled:opacity-50"
>
{submitting ? <Loader2 className="w-5 h-5 animate-spin mr-2" /> : null}
{submitting ? 'Launching...' : 'Launch Search'}
{submitting ? <T>Launching...</T> : <T>Launch Search</T>}
</button>
)}
</div>

View File

@ -1,14 +1,14 @@
import { useState, useEffect } from 'react';
import { Loader2, AlertCircle, Plus, Lock, MapPin } from 'lucide-react';
import { Loader2, AlertCircle, Lock, MapPin } from 'lucide-react';
import { fetchPlacesGridSearchById, retryPlacesGridSearchJob, getGridSearchExcludeTypes, saveGridSearchExcludeTypes } from '../client-gridsearch';
import { GridSearchResults } from './GridSearchResults';
import { useNavigate } from 'react-router-dom';
import { useRestoredSearch } from './RestoredSearchContext';
import { GridSearchStreamProvider, useGridSearchStream } from './GridSearchStreamContext';
import CollapsibleSection from '@/components/CollapsibleSection';
import { T } from '@/i18n';
function LiveGridSearchResults({ jobId, excludedTypes, dummyUpdateExcluded }: any) {
function LiveGridSearchResults({ jobId, excludedTypes, dummyUpdateExcluded, onExpandSubmitted }: any) {
const { competitors, liveAreas, liveRadii, liveNodes, stats, streaming, statusMessage, liveScanner, sseLogs } = useGridSearchStream();
return (
@ -25,18 +25,18 @@ function LiveGridSearchResults({ jobId, excludedTypes, dummyUpdateExcluded }: an
streaming={streaming}
statusMessage={statusMessage}
sseLogs={sseLogs}
onExpandSubmitted={onExpandSubmitted}
/>
);
}
export function JobViewer({ jobId, onRerun }: { jobId: string, onRerun?: (settings: any) => void }) {
const navigate = useNavigate();
export function JobViewer({ jobId }: { jobId: string }) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [competitors, setCompetitors] = useState<any[]>([]);
const [jobData, setJobData] = useState<any>(null);
const { state: restoredState, isLocked, isComplete, loading: restoredLoading } = useRestoredSearch();
const { state: restoredState, isLocked, isComplete, loading: restoredLoading, refetch } = useRestoredSearch();
const guidedAreas = restoredState?.run?.request?.guided?.areas || [];
const searchSettings = restoredState?.run?.request?.guided?.settings || null;
@ -108,25 +108,6 @@ export function JobViewer({ jobId, onRerun }: { jobId: string, onRerun?: (settin
}
};
const handleRerun = () => {
if (onRerun && jobData) {
const guidedAreas = jobData.request?.guided?.areas || [];
const searchQuery = jobData.request?.types?.join(', ') || jobData.request?.search?.types?.join(', ') || '';
const simulatorSettings = jobData.request?.guided?.settings || {};
const enableEnrichments = !!(jobData.request?.enrichers && jobData.request.enrichers.length > 0);
const searchLimit = jobData.request?.limitPerArea || 20;
const excludeTypes = jobData.request?.excludeTypes || [];
onRerun({
guidedAreas,
searchQuery,
simulatorSettings,
enableEnrichments,
searchLimit,
excludeTypes,
});
}
};
if (!jobId) return null;
@ -198,25 +179,6 @@ export function JobViewer({ jobId, onRerun }: { jobId: string, onRerun?: (settin
<div className="w-full">
<div className="flex justify-between items-center mb-2">
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-100"><T>Search Results</T></h2>
<div className="flex items-center space-x-2">
<button
onClick={handleRerun}
className="flex items-center px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200 text-sm font-medium rounded-lg transition-colors shadow-sm ml-4 shrink-0"
>
<T>Re-run Search...</T>
</button>
<button
onClick={() => {
const params = new URLSearchParams(window.location.search);
params.delete('jobId');
navigate(`${window.location.pathname}?${params.toString()}`, { replace: true });
}}
className="flex items-center px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-lg transition-colors shadow-sm shrink-0"
>
<Plus className="w-4 h-4 mr-2" />
<T>New Search</T>
</button>
</div>
</div>
<CollapsibleSection
@ -312,6 +274,7 @@ export function JobViewer({ jobId, onRerun }: { jobId: string, onRerun?: (settin
jobId={jobId}
excludedTypes={excludedTypes}
dummyUpdateExcluded={handleUpdateExcluded}
onExpandSubmitted={refetch}
/>
</GridSearchStreamProvider>
</div>

View File

@ -1,128 +1,182 @@
import React, { useEffect, useState } from 'react';
import { fetchPlacesGridSearches, deletePlacesGridSearch, GridSearchSummary } from '../client-gridsearch';
import { Loader2, XCircle, CheckCircle, ChevronRight, Trash2, Clock } from 'lucide-react';
import { getCurrentLang } from '../../../i18n';
function getRelativeTime(dateString: string): string {
if (!dateString) return '';
try {
const lang = getCurrentLang();
const rtf = new Intl.RelativeTimeFormat(lang, { numeric: 'auto', style: 'short' });
const now = new Date();
const date = new Date(dateString);
const diffMs = date.getTime() - now.getTime();
const diffSec = Math.round(diffMs / 1000);
const diffMin = Math.round(diffSec / 60);
const diffHour = Math.round(diffMin / 60);
const diffDay = Math.round(diffHour / 24);
if (Math.abs(diffDay) >= 7) {
return date.toLocaleDateString(lang, { month: 'short', day: 'numeric' });
} else if (Math.abs(diffDay) >= 1) {
return rtf.format(diffDay, 'day');
} else if (Math.abs(diffHour) >= 1) {
return rtf.format(diffHour, 'hour');
} else if (Math.abs(diffMin) >= 1) {
return rtf.format(diffMin, 'minute');
} else {
return rtf.format(diffSec, 'second');
}
} catch (e) {
return '';
}
}
export const OngoingSearches = ({ onSelectJob, selectedJobId, onSearchDeleted }: { onSelectJob?: (jobId: string) => void, selectedJobId?: string | null, onSearchDeleted?: (jobId: string) => void }) => {
const [pastSearches, setPastSearches] = useState<GridSearchSummary[]>([]);
const loadPast = async () => {
try {
const history = await fetchPlacesGridSearches();
setPastSearches(history);
} catch (err) {
console.error('Failed to load past searches', err);
}
};
useEffect(() => {
loadPast();
// Poll past searches frequently so live searches update in the list
const interval = setInterval(loadPast, 10_000);
return () => clearInterval(interval);
}, []);
return (
<div className="w-full flex mt-4 flex-col">
{pastSearches.length > 0 && (
<div className="flex-1 overflow-y-auto">
<h3 className="px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2 mt-2">Searches</h3>
<div className="flex flex-col">
{pastSearches.map(search => {
const isSelected = selectedJobId === search.id;
return (
<div
key={search.id}
onClick={() => onSelectJob && onSelectJob(search.id)}
className={`flex items-center justify-between p-3 cursor-pointer border-b dark:border-gray-800 transition-colors ${isSelected ? 'bg-indigo-50 dark:bg-indigo-900/30 border-l-2 border-l-indigo-600' : 'hover:bg-gray-100 dark:hover:bg-gray-800 border-l-2 border-l-transparent'}`}
>
<div className="flex items-center space-x-3 overflow-hidden flex-1">
{search.status === 'searching' || search.status === 'enriching' ? (
<Loader2 className="shrink-0 w-4 h-4 text-amber-500 animate-spin" />
) : search.status === 'failed' ? (
<XCircle className="shrink-0 w-4 h-4 text-red-500" />
) : (
<CheckCircle className={`shrink-0 w-4 h-4 ${isSelected ? 'text-indigo-600 dark:text-indigo-400' : 'text-green-500'}`} />
)}
<div className="min-w-0 pr-2">
<h4 className={`font-medium text-sm truncate ${isSelected ? 'text-indigo-900 dark:text-indigo-300' : 'text-gray-900 dark:text-gray-100'}`} title={search.regionName}>
{search.regionName} <span className="text-xs font-normal opacity-70">({search.level})</span>
</h4>
<div className="flex items-center space-x-2 mt-0.5">
{search.countries && search.countries.length > 0 && !search.countries.includes(search.regionName) && (
<span className="text-[10px] uppercase font-bold text-gray-400 dark:text-gray-500 tracking-wider whitespace-nowrap">
{search.countries.join(', ')}
</span>
)}
<p className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]" title={search.query}>
{search.query}
</p>
{search.generatedAt && (
<span className="flex items-center text-[10px] text-gray-400 dark:text-gray-500 whitespace-nowrap">
<Clock className="w-3 h-3 mr-1 inline" />
{getRelativeTime(search.generatedAt)}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center shrink-0 space-x-1">
<button
onClick={async (e) => {
e.stopPropagation();
if (confirm('Are you sure you want to delete this search?')) {
try {
await deletePlacesGridSearch(search.id);
setPastSearches(prev => prev.filter(s => s.id !== search.id));
if (onSearchDeleted) onSearchDeleted(search.id);
} catch (err) {
console.error('Failed to delete search', err);
alert('Failed to delete search.');
}
}
}}
className="p-1.5 text-gray-400 hover:text-red-500 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
title="Delete Search"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
{isSelected && <ChevronRight className="w-4 h-4 text-indigo-500 ml-1" />}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
};
import React, { useEffect, useState } from 'react';
import { fetchPlacesGridSearches, deletePlacesGridSearch, GridSearchSummary } from '../client-gridsearch';
import { Loader2, XCircle, CheckCircle, ChevronRight, Trash2, Clock, GitBranch } from 'lucide-react';
import { getCurrentLang } from '../../../i18n';
function getRelativeTime(dateString: string): string {
if (!dateString) return '';
try {
const lang = getCurrentLang();
const rtf = new Intl.RelativeTimeFormat(lang, { numeric: 'auto', style: 'short' });
const now = new Date();
const date = new Date(dateString);
const diffMs = date.getTime() - now.getTime();
const diffSec = Math.round(diffMs / 1000);
const diffMin = Math.round(diffSec / 60);
const diffHour = Math.round(diffMin / 60);
const diffDay = Math.round(diffHour / 24);
if (Math.abs(diffDay) >= 7) {
return date.toLocaleDateString(lang, { month: 'short', day: 'numeric' });
} else if (Math.abs(diffDay) >= 1) {
return rtf.format(diffDay, 'day');
} else if (Math.abs(diffHour) >= 1) {
return rtf.format(diffHour, 'hour');
} else if (Math.abs(diffMin) >= 1) {
return rtf.format(diffMin, 'minute');
} else {
return rtf.format(diffSec, 'second');
}
} catch (e) {
return '';
}
}
const StatusIcon = ({ status, isSelected }: { status: string; isSelected: boolean }) => {
if (status === 'searching' || status === 'enriching') {
return <Loader2 className="shrink-0 w-4 h-4 text-amber-500 animate-spin" />;
}
if (status === 'failed') {
return <XCircle className="shrink-0 w-4 h-4 text-red-500" />;
}
return <CheckCircle className={`shrink-0 w-4 h-4 ${isSelected ? 'text-indigo-600 dark:text-indigo-400' : 'text-green-500'}`} />;
};
const SearchRow = ({
search,
isSelected,
isChild,
onSelect,
onDelete,
}: {
search: GridSearchSummary;
isSelected: boolean;
isChild?: boolean;
onSelect: () => void;
onDelete: () => void;
}) => (
<div
onClick={onSelect}
className={`flex items-center justify-between cursor-pointer border-b dark:border-gray-800 transition-colors
${isChild ? 'pl-7 pr-3 py-2' : 'p-3'}
${isSelected
? 'bg-indigo-50 dark:bg-indigo-900/30 border-l-2 border-l-indigo-600'
: 'hover:bg-gray-100 dark:hover:bg-gray-800 border-l-2 border-l-transparent'
}`}
>
<div className="flex items-center space-x-3 overflow-hidden flex-1">
{isChild ? (
<GitBranch className="shrink-0 w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
) : null}
<StatusIcon status={search.status} isSelected={isSelected} />
<div className="min-w-0 pr-2">
<h4 className={`font-medium truncate ${isChild ? 'text-xs' : 'text-sm'} ${isSelected ? 'text-indigo-900 dark:text-indigo-300' : 'text-gray-900 dark:text-gray-100'}`} title={search.regionName}>
{search.regionName} {!isChild && <span className="text-xs font-normal opacity-70">({search.level})</span>}
</h4>
<div className="flex items-center space-x-2 mt-0.5">
{!isChild && search.countries && search.countries.length > 0 && !search.countries.includes(search.regionName) && (
<span className="text-[10px] uppercase font-bold text-gray-400 dark:text-gray-500 tracking-wider whitespace-nowrap">
{search.countries.join(', ')}
</span>
)}
<p className={`text-gray-500 dark:text-gray-400 truncate max-w-[200px] ${isChild ? 'text-[10px]' : 'text-xs'}`} title={search.query}>
{search.query}
</p>
{search.generatedAt && (
<span className="flex items-center text-[10px] text-gray-400 dark:text-gray-500 whitespace-nowrap">
<Clock className="w-3 h-3 mr-1 inline" />
{getRelativeTime(search.generatedAt)}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center shrink-0 space-x-1">
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className={`p-1.5 rounded-md transition-colors ${
isChild
? 'text-gray-300 hover:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-800'
: 'text-gray-400 hover:text-red-500 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
title="Delete Search"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
{isSelected && <ChevronRight className="w-4 h-4 text-indigo-500 ml-1" />}
</div>
</div>
);
export const OngoingSearches = ({ onSelectJob, selectedJobId, onSearchDeleted }: { onSelectJob?: (jobId: string) => void, selectedJobId?: string | null, onSearchDeleted?: (jobId: string) => void }) => {
const [pastSearches, setPastSearches] = useState<GridSearchSummary[]>([]);
const loadPast = async () => {
try {
const history = await fetchPlacesGridSearches();
setPastSearches(history);
} catch (err) {
console.error('Failed to load past searches', err);
}
};
useEffect(() => {
loadPast();
// Poll past searches frequently so live searches update in the list
const interval = setInterval(loadPast, 10_000);
return () => clearInterval(interval);
}, []);
const handleDelete = async (id: string, parentId?: string) => {
if (confirm('Are you sure you want to delete this search?')) {
try {
await deletePlacesGridSearch(id);
setPastSearches(prev => {
if (parentId) {
return prev.map(p => p.id === parentId ? { ...p, children: p.children?.filter(c => c.id !== id) } : p);
}
return prev.filter(s => s.id !== id);
});
if (onSearchDeleted) onSearchDeleted(id);
} catch (err) {
console.error('Failed to delete search', err);
alert('Failed to delete search.');
}
}
};
return (
<div className="w-full flex mt-4 flex-col">
{pastSearches.length > 0 && (
<div className="flex-1 overflow-y-auto">
<h3 className="px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2 mt-2">Searches</h3>
<div className="flex flex-col">
{pastSearches.map(search => (
<React.Fragment key={search.id}>
<SearchRow
search={search}
isSelected={selectedJobId === search.id}
onSelect={() => onSelectJob && onSelectJob(search.id)}
onDelete={() => handleDelete(search.id)}
/>
{search.children && search.children.length > 0 && search.children.map(child => (
<SearchRow
key={child.id}
search={child}
isSelected={selectedJobId === child.id}
isChild
onSelect={() => onSelectJob && onSelectJob(child.id)}
onDelete={() => handleDelete(child.id, search.id)}
/>
))}
</React.Fragment>
))}
</div>
</div>
)}
</div>
);
};

View File

@ -3,6 +3,7 @@ import { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../../hooks/useAuth';
import { toast } from 'sonner';
import { supabase } from '@/integrations/supabase/client';
import { translate } from '../../i18n';
export interface CompetitorSettings {
known_types: string[];
@ -27,14 +28,14 @@ export const useCompetitorSettings = () => {
if (error) throw error;
const fetchedSettings = data?.settings || {};
const fetchedSettings = (data?.settings as Record<string, any>) || {};
setSettings({
known_types: Array.isArray(fetchedSettings.known_types) ? fetchedSettings.known_types : [],
excluded_types: Array.isArray(fetchedSettings.excluded_types) ? fetchedSettings.excluded_types : [],
});
} catch (error: any) {
console.error('Error fetching settings:', error);
toast.error('Failed to load settings');
toast.error(translate('Failed to load settings'));
} finally {
setLoading(false);
}
@ -61,7 +62,7 @@ export const useCompetitorSettings = () => {
// Preservation check: If we found no profile, but we expect one (e.g. existing user), this is risky.
// However, maybeSingle returns null for new users.
const currentSettings = currentProfile?.settings || {};
const currentSettings = (currentProfile?.settings as Record<string, any>) || {};
const { error } = await supabase
.from('profiles')
@ -74,10 +75,10 @@ export const useCompetitorSettings = () => {
}, { onConflict: 'user_id' });
if (error) throw error;
toast.success('Settings saved');
toast.success(translate('Settings saved'));
} catch (error: any) {
console.error('Error updating settings:', error);
toast.error('Failed to save settings');
toast.error(translate('Failed to save settings'));
// Revert on error
fetchSettings();
}

View File

@ -1,15 +1,29 @@
import React from 'react';
import { Link } from 'react-router-dom';
import type { GridColDef, GridRenderCellParams } from '@mui/x-data-grid';
import { Globe, Instagram, Facebook, Linkedin, Youtube, Twitter } from 'lucide-react';
import { Globe, Instagram, Facebook, Linkedin, Youtube, Twitter, Github, Star } from 'lucide-react';
import { EmailCell } from './EmailCell';
import { TypeCell } from './TypeCell';
import type { CompetitorSettings } from './useCompetitorSettings';
import { T, translate } from '../../i18n';
interface UseGridColumnsProps {
settings: CompetitorSettings;
updateExcludedTypes: (types: string[]) => Promise<void>;
}
// Helper: find a social URL from any of the known data shapes
const findSocialUrl = (row: any, platform: string): string => {
const match = (p: any) => p.source === platform || p.platform === platform;
const rd = row.raw_data as any;
return rd?.[platform] || // raw_data.instagram etc
rd?.meta?.social?.find(match)?.url || // raw_data.meta.social[]
row.meta?.social?.find(match)?.url || // meta.social[] (nested)
row.social?.find(match)?.url || // social[] (DB singular)
row.socials?.find(match)?.url || // socials[] (SSE plural)
'';
};
export const useGridColumns = ({
settings,
updateExcludedTypes
@ -29,7 +43,7 @@ export const useGridColumns = ({
return React.useMemo(() => [
{
field: 'thumbnail',
headerName: 'Image',
headerName: translate('Image'),
width: 100,
renderCell: (params: GridRenderCellParams) => (
params.value ? (
@ -46,18 +60,26 @@ export const useGridColumns = ({
},
{
field: 'title',
headerName: 'Name',
headerName: translate('Name'),
flex: 1,
minWidth: 200,
renderCell: (params: GridRenderCellParams) => (
<span className="font-medium">
{params.value}
</span>
),
renderCell: (params: GridRenderCellParams) => {
const placeId = params.row.place_id || params.row.placeId;
return placeId ? (
<Link
to={`/products/places/detail/${placeId}`}
className="font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 hover:underline"
>
{params.value}
</Link>
) : (
<span className="font-medium">{params.value}</span>
);
},
},
{
field: 'email',
headerName: 'Email',
headerName: translate('Email'),
width: 250,
valueGetter: (value: any, row: any) => {
const emails = row.emails || row.meta?.emails || [];
@ -84,27 +106,27 @@ export const useGridColumns = ({
},
{
field: 'phone',
headerName: 'Phone',
headerName: translate('Phone'),
width: 150,
},
{
field: 'address',
headerName: 'Address',
headerName: translate('Address'),
width: 200,
},
{
field: 'city',
headerName: 'City',
headerName: translate('City'),
width: 120,
},
{
field: 'country',
headerName: 'Country',
headerName: translate('Country'),
width: 120,
},
{
field: 'website',
headerName: 'Website',
headerName: translate('Website'),
width: 200,
renderCell: (params: GridRenderCellParams) => (
params.value ? (
@ -115,14 +137,47 @@ export const useGridColumns = ({
className="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 flex items-center h-full"
>
<Globe className="h-4 w-4 mr-2" />
Visit
<T>Visit</T>
</a>
) : null
),
},
{
field: 'rating',
headerName: translate('Rating'),
width: 100,
type: 'number',
valueGetter: (value: any, row: any) => {
const rd = row.raw_data as any;
if (rd?.rating != null) return rd.rating;
if (row.meta?.rating != null) return row.meta.rating;
if (row.rating != null) return row.rating;
return null;
},
renderCell: (params: GridRenderCellParams) => {
const rating = params.value;
if (rating == null) return null;
const reviews = params.row.raw_data?.reviews || params.row.meta?.reviews || params.row.reviews;
return (
<div className="flex flex-col justify-center h-full leading-tight">
<div className="flex items-center gap-1 text-sm font-medium text-gray-900 dark:text-gray-100">
<Star className="h-3 w-3 text-yellow-500 fill-yellow-500" />
{rating}
</div>
{reviews != null && (
<div className="text-[10px] text-gray-500 dark:text-gray-400">
{reviews} {reviews === 1 ? translate('rev') : translate('revs')}
</div>
)}
</div>
);
}
},
{
field: 'types',
headerName: 'Types',
headerName: translate('Types'),
width: 200,
renderCell: (params: GridRenderCellParams) => (
<TypeCell
@ -131,10 +186,8 @@ export const useGridColumns = ({
onToggle={(type) => {
const currentExcluded = settings.excluded_types || [];
if (currentExcluded.includes(type)) {
// Restore (remove from excluded)
updateExcludedTypes(currentExcluded.filter(t => t !== type));
} else {
// Exclude (add to excluded)
updateExcludedTypes([...currentExcluded, type]);
}
}}
@ -142,64 +195,42 @@ export const useGridColumns = ({
),
},
{
field: 'instagram',
headerName: 'IG',
width: 50,
valueGetter: (value: any, row: any) => {
const rd = row.raw_data as any;
return rd?.instagram ||
rd?.meta?.social?.find((p: any) => p.source === 'instagram')?.url ||
row.meta?.social?.find((p: any) => p.source === 'instagram')?.url || '';
field: 'social',
headerName: translate('Social'),
width: 140,
sortable: false,
filterable: false,
renderCell: (params: GridRenderCellParams) => {
const row = params.row;
const socials: { platform: string; url: string; icon: React.ReactNode; color: string }[] = [];
const platforms = [
{ key: 'instagram', icon: <Instagram className="h-4 w-4" />, color: 'text-pink-600 hover:text-pink-800 dark:text-pink-400' },
{ key: 'facebook', icon: <Facebook className="h-4 w-4" />, color: 'text-blue-600 hover:text-blue-800 dark:text-blue-400' },
{ key: 'linkedin', icon: <Linkedin className="h-4 w-4" />, color: 'text-blue-700 hover:text-blue-900 dark:text-blue-400' },
{ key: 'youtube', icon: <Youtube className="h-4 w-4" />, color: 'text-red-600 hover:text-red-800 dark:text-red-400' },
{ key: 'twitter', icon: <Twitter className="h-4 w-4" />, color: 'text-blue-400 hover:text-blue-600 dark:text-blue-300' },
{ key: 'github', icon: <Github className="h-4 w-4" />, color: 'text-gray-700 hover:text-gray-900 dark:text-gray-300' },
{ key: 'tiktok', icon: <svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M19.59 6.69a4.83 4.83 0 01-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 01-2.88 2.5 2.89 2.89 0 01-2.89-2.89 2.89 2.89 0 012.89-2.89c.28 0 .54.04.79.1v-3.5a6.37 6.37 0 00-.79-.05A6.34 6.34 0 003.15 15.2a6.34 6.34 0 006.34 6.34 6.34 6.34 0 006.34-6.34V8.79a8.18 8.18 0 004.76 1.52V6.88a4.84 4.84 0 01-1-.19z"/></svg>, color: 'text-gray-800 hover:text-black dark:text-gray-300' },
];
for (const p of platforms) {
const url = findSocialUrl(row, p.key);
if (url) socials.push({ platform: p.key, url, icon: p.icon, color: p.color });
}
if (!socials.length) return null;
return (
<div className="flex items-center gap-1.5 h-full">
{socials.map(s => (
<a key={s.platform} href={s.url} target="_blank" rel="noopener noreferrer" className={s.color}>
{s.icon}
</a>
))}
</div>
);
},
renderCell: (params: GridRenderCellParams) => renderSocialCell(params, <Instagram className="h-4 w-4" />, "text-pink-600 hover:text-pink-800 dark:text-pink-400 dark:hover:text-pink-300")
},
{
field: 'facebook',
headerName: 'FB',
width: 50,
valueGetter: (value: any, row: any) => {
const rd = row.raw_data as any;
return rd?.facebook ||
rd?.meta?.social?.find((p: any) => p.source === 'facebook')?.url ||
row.meta?.social?.find((p: any) => p.source === 'facebook')?.url || '';
},
renderCell: (params: GridRenderCellParams) => renderSocialCell(params, <Facebook className="h-4 w-4" />, "text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300")
},
{
field: 'linkedin',
headerName: 'LI',
width: 50,
valueGetter: (value: any, row: any) => {
const rd = row.raw_data as any;
return rd?.linkedin ||
rd?.meta?.social?.find((p: any) => p.source === 'linkedin')?.url ||
row.meta?.social?.find((p: any) => p.source === 'linkedin')?.url || '';
},
renderCell: (params: GridRenderCellParams) => renderSocialCell(params, <Linkedin className="h-4 w-4" />, "text-blue-700 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300")
},
{
field: 'youtube',
headerName: 'YT',
width: 50,
valueGetter: (value: any, row: any) => {
const rd = row.raw_data as any;
return rd?.youtube ||
rd?.meta?.social?.find((p: any) => p.source === 'youtube')?.url ||
row.meta?.social?.find((p: any) => p.source === 'youtube')?.url || '';
},
renderCell: (params: GridRenderCellParams) => renderSocialCell(params, <Youtube className="h-4 w-4" />, "text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300")
},
{
field: 'twitter',
headerName: 'X',
width: 50,
valueGetter: (value: any, row: any) => {
const rd = row.raw_data as any;
return rd?.twitter ||
rd?.meta?.social?.find((p: any) => p.source === 'twitter')?.url ||
row.meta?.social?.find((p: any) => p.source === 'twitter')?.url || '';
},
renderCell: (params: GridRenderCellParams) => renderSocialCell(params, <Twitter className="h-4 w-4" />, "text-blue-400 hover:text-blue-600 dark:text-blue-300 dark:hover:text-blue-200")
},
], [settings, updateExcludedTypes]);
};

View File

@ -169,14 +169,12 @@ class ModbusService {
const isOpen = this.ws.readyState === WebSocket.OPEN && this.status === 'CONNECTED';
const isConnecting = this.ws.readyState === WebSocket.CONNECTING && (this.status === 'CONNECTING' || this.status === 'RECONNECTING');
if (isOpen || isConnecting) {
console.log('[ModbusService] Existing WebSocket is in a valid state (', this.ws.readyState, '/', this.status, '). Aborting new connection attempt.');
resolve();
return;
}
}
if (!this.wsUrl) {
console.error('WebSocket URL is not set.');
this.updateStatus('ERROR');
reject(new Error('WebSocket URL is not set.'));
return;
@ -188,7 +186,6 @@ class ModbusService {
this.ws.onerror = null;
this.ws.onclose = null;
if (this.ws.readyState !== WebSocket.CLOSED && this.ws.readyState !== WebSocket.CLOSING) {
console.log('[ModbusService] Closing existing WebSocket before new attempt.');
this.ws.close();
}
this.ws = null;