child searches - wer sucht der findet :)
This commit is contained in:
parent
af7637d158
commit
2d59f5df14
@ -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(),
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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
@ -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>
|
||||
|
||||
@ -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>}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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}`, {
|
||||
|
||||
@ -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> => {
|
||||
|
||||
@ -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')} />
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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' };
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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]);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user