kbot love:)

This commit is contained in:
lovebird 2026-03-30 17:35:22 +02:00
parent 6c4ced9101
commit 6f89453225
15 changed files with 355 additions and 390 deletions

View File

@ -0,0 +1,20 @@
Introducing the upcoming rollout of PAC-BOT, a geographic data extraction suite powered by autonomous agents. Designed for B2B growth marketers and sales teams, PAC-BOT transforms tedious market research into an automated, highly visual process that scales globally—from entire countries down to precise town and neighborhood levels.
Unlike traditional data scraping tools that rely on static databases or generic city radiuses, PAC-BOT deploys bleeding-edge AI agents to dynamically navigate exact regional geographical boundaries. Operating live on an interactive map, these agents visually "scan" hexagonal grids to harvest high-quality business profiles, intelligently matching extracted data against a user's specific Ideal Customer Profile (ICP).
"Sales teams often waste hours filtering out unqualified leads because legacy tools lack context and geographic precision," said [Spokesperson Name, Title]. "With PAC-BOT, you define your exact region and let our autonomous agents find potential clients that match your specific profile, entirely hands-free."
### Bleeding-Edge Technology Meets Deep Market Intelligence
Going beyond simple contact extraction, PAC-BOT provides structured, multi-dimensional insights that empower businesses to comprehensively analyze their target markets. Key capabilities include:
* **Intelligent Profile Matching (Beta):** The system actively evaluates extracted business data against unique industry requirements, ensuring every generated lead is a high-probability prospect.
* **Yield and Trend Analysis:** Through integrated reporting views, users can extract detailed information about regional market density, economic yield, and emerging local trends to better inform go-to-market strategies.
* **Live Visual Harvesting:** Users can monitor the autonomous agents in real-time. As the system processes geographic cells, the interactive map visually updates, providing absolute transparency into the extraction progress.
* **Pinpoint Precision, Worldwide Reach:** Achieve 100% boundary-accurate results anywhere on the planet without the structural noise of overlapping service radiuses.
### Looking Ahead: Automated Multilingual Outreach
As part of its ongoing product roadmap, [Company Name] confirmed that PAC-BOT will soon evolve from a lead discovery engine into an automated global sales deployment. Upcoming features will enable the system to automatically generate hyper-tailored, localized outreach emails for any niche, in any language—allowing businesses to drop a pin anywhere in the world and instantly dispatch the perfect customized pitch.

View File

@ -68,6 +68,40 @@ export const fetchWithDeduplication = async <T>(
});
};
export const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin;
/** Helper function to get authorization headers */
export const getAuthHeaders = async (): Promise<HeadersInit> => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData?.session?.access_token;
const headers: HeadersInit = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
return headers;
};
/** Generic API Client handler */
export async function apiClient<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const headers = await getAuthHeaders();
const response = await fetch(`${serverUrl}${endpoint}`, {
...options,
headers: {
...headers,
...options.headers,
},
});
if (!response.ok) {
throw new Error(`API Error on ${endpoint}: ${response.statusText}`);
}
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return response.json();
}
return response.text() as unknown as T;
}
export const invalidateCache = (key: string) => {
const queryKey = parseQueryKey(key);
queryClient.invalidateQueries({ queryKey });

View File

@ -1,57 +1,42 @@
import { FEED_API_ENDPOINT } from '@/constants';
import { supabase as defaultSupabase } from "@/integrations/supabase/client";
import { getCurrentLang } from '@/i18n';
import type { FeedSortOption } from '@/hooks/useFeedData';
export interface FetchFeedOptions {
source?: 'home' | 'collection' | 'tag' | 'user' | 'widget' | 'search';
sourceId?: string;
page?: number;
limit?: number;
sortBy?: FeedSortOption;
categoryIds?: string[];
categorySlugs?: string[];
contentType?: 'posts' | 'pages' | 'pictures' | 'files';
visibilityFilter?: 'invisible' | 'private';
lang?: string;
}
export const fetchFeed = async (options: FetchFeedOptions) => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
const headers: HeadersInit = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const params = new URLSearchParams();
if (options.page !== undefined) params.append('page', String(options.page));
if (options.limit !== undefined) params.append('limit', String(options.limit));
if (options.sortBy) params.append('sortBy', options.sortBy);
const lang = options.lang || getCurrentLang();
if (lang) params.append('lang', lang);
if (options.source) params.append('source', options.source);
if (options.sourceId) params.append('sourceId', options.sourceId);
if (options.categoryIds && options.categoryIds.length > 0) {
params.append('categoryIds', options.categoryIds.join(','));
}
if (options.categorySlugs && options.categorySlugs.length > 0) {
params.append('categorySlugs', options.categorySlugs.join(','));
}
if (options.contentType) params.append('contentType', options.contentType);
if (options.visibilityFilter) params.append('visibilityFilter', options.visibilityFilter);
const SERVER_URL = import.meta.env.VITE_SERVER_IMAGE_API_URL || '';
const fetchUrl = SERVER_URL
? `${SERVER_URL}${FEED_API_ENDPOINT}?${params.toString()}`
: `${FEED_API_ENDPOINT}?${params.toString()}`;
const res = await fetch(fetchUrl, { headers });
if (!res.ok) {
console.warn('Feed API failed', res.statusText);
throw new Error(`Feed fetch failed: ${res.statusText}`);
}
return await res.json();
};
import { FEED_API_ENDPOINT } from '@/constants';
import { apiClient } from '@/lib/db';
import { getCurrentLang } from '@/i18n';
import type { FeedSortOption } from '@/hooks/useFeedData';
export interface FetchFeedOptions {
source?: 'home' | 'collection' | 'tag' | 'user' | 'widget' | 'search';
sourceId?: string;
page?: number;
limit?: number;
sortBy?: FeedSortOption;
categoryIds?: string[];
categorySlugs?: string[];
contentType?: 'posts' | 'pages' | 'pictures' | 'files';
visibilityFilter?: 'invisible' | 'private';
lang?: string;
}
export const fetchFeed = async (options: FetchFeedOptions) => {
const params = new URLSearchParams();
if (options.page !== undefined) params.append('page', String(options.page));
if (options.limit !== undefined) params.append('limit', String(options.limit));
if (options.sortBy) params.append('sortBy', options.sortBy);
const lang = options.lang || getCurrentLang();
if (lang) params.append('lang', lang);
if (options.source) params.append('source', options.source);
if (options.sourceId) params.append('sourceId', options.sourceId);
if (options.categoryIds && options.categoryIds.length > 0) {
params.append('categoryIds', options.categoryIds.join(','));
}
if (options.categorySlugs && options.categorySlugs.length > 0) {
params.append('categorySlugs', options.categorySlugs.join(','));
}
if (options.contentType) params.append('contentType', options.contentType);
if (options.visibilityFilter) params.append('visibilityFilter', options.visibilityFilter);
return apiClient<any[]>(`${FEED_API_ENDPOINT}?${params.toString()}`);
};

View File

@ -6,7 +6,7 @@ import { T, translate } from '@/i18n';
import { Search, FileText, Check, Users, User } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { cn } from '@/lib/utils';
import { FEED_API_ENDPOINT } from '@/constants';
import { fetchFeed } from '@/modules/feed/client-feed';
/** Simplified page shape returned from the feed API */
interface FeedPage {
@ -49,23 +49,14 @@ export const PagePickerDialog: React.FC<PagePickerDialogProps> = ({
const fetchPages = async () => {
setLoading(true);
try {
const params = new URLSearchParams({
const feedItems: any[] = await fetchFeed({
contentType: 'pages',
limit: '200',
limit: 200,
sortBy: 'latest',
visibilityFilter: 'invisible',
...(!showAllUsers && user?.id ? { source: 'user', sourceId: user.id } : {}),
});
// Filter to current user's pages unless "all users" is toggled
if (!showAllUsers && user?.id) {
params.set('source', 'user');
params.set('sourceId', user.id);
}
const res = await fetch(`${FEED_API_ENDPOINT}?${params}`);
if (!res.ok) throw new Error(`Failed to fetch pages: ${res.statusText}`);
const feedItems: any[] = await res.json();
// Transform feed items into simple page objects
const transformed: FeedPage[] = feedItems
.filter((item: any) => item.type === 'page-intern' || item.meta?.slug)

View File

@ -1,231 +1,80 @@
import { supabase as defaultSupabase } from "@/integrations/supabase/client";
import { fetchWithDeduplication } from "@/lib/db";
import { getCurrentLang } from '@/i18n';
export const fetchUserPage = async (userId: string, slug: string) => {
const key = `user-page-${userId}-${slug}`;
return fetchWithDeduplication(key, async () => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
const headers: HeadersInit = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const lang = getCurrentLang();
const langParam = lang && lang !== 'en' ? `?lang=${lang}` : '';
const res = await fetch(`/api/user-page/${userId}/${slug}${langParam}`, { headers });
if (!res.ok) {
if (res.status === 404) return null;
throw new Error(`Failed to fetch user page: ${res.statusText}`);
}
return await res.json();
}, 10);
};
export const fetchPageById = async (id: string) => {
return fetchWithDeduplication(`page-${id}`, async () => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
const headers: HeadersInit = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`/api/user-page/${id}/${id}`, { headers });
if (!res.ok) {
if (res.status === 404) return null;
throw new Error(`Failed to fetch page: ${res.statusText}`);
}
const result = await res.json();
return result?.page || result;
});
};
export const fetchUserPages = async (userId: string) => {
const key = `user-pages-${userId}`;
return fetchWithDeduplication(key, async () => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
const headers: HeadersInit = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`/api/pages?userId=${userId}`, { headers });
if (!res.ok) throw new Error(`Failed to fetch user pages: ${res.statusText}`);
return await res.json();
});
};
export const fetchPageDetailsById = async (pageId: string) => {
const key = `page-details-${pageId}`;
return fetchWithDeduplication(key, async () => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
const headers: HeadersInit = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
// Use page ID as both identifier and slug - server will detect UUID in slug position
const res = await fetch(`/api/user-page/${pageId}/${pageId}`, { headers });
if (!res.ok) {
if (res.status === 404) return null;
throw new Error(`Failed to fetch page details: ${res.statusText}`);
}
return await res.json();
});
};
export const createPage = async (pageData: any): Promise<any> => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
if (!token) throw new Error('Not authenticated');
const baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin;
const response = await fetch(`${baseUrl}/api/pages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(pageData)
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to create page');
}
return await response.json();
};
export const updatePage = async (pageId: string, updates: any): Promise<any> => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
if (!token) throw new Error('Not authenticated');
const baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin;
const response = await fetch(`${baseUrl}/api/pages/${pageId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(updates)
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to update page');
}
return await response.json();
};
export const deletePage = async (pageId: string): Promise<void> => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
if (!token) throw new Error('Not authenticated');
const baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin;
const response = await fetch(`${baseUrl}/api/pages/${pageId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to delete page');
}
};
export const updatePageMeta = async (pageId: string, metaUpdates: any) => {
// Fetch current state via API to merge
const page = await fetchPageDetailsById(pageId);
if (!page) throw new Error("Page not found");
const currentMeta = page.meta || {};
const newMeta = { ...currentMeta, ...metaUpdates };
// Use API wrapper to update
const result = await updatePage(pageId, { meta: newMeta });
return result;
};
// --- Page Versions ---
export const fetchPageVersions = async (pageId: string) => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
if (!token) throw new Error('Not authenticated');
const baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin;
const res = await fetch(`${baseUrl}/api/pages/${pageId}/versions`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error('Failed to fetch versions');
return await res.json();
};
export const createPageVersion = async (pageId: string, label?: string) => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
if (!token) throw new Error('Not authenticated');
const baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin;
const res = await fetch(`${baseUrl}/api/pages/${pageId}/versions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ label })
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || 'Failed to create version');
}
return await res.json();
};
export const deletePageVersion = async (pageId: string, versionId: string) => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
if (!token) throw new Error('Not authenticated');
const baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin;
const res = await fetch(`${baseUrl}/api/pages/${pageId}/versions/${versionId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || 'Failed to delete version');
}
};
export const restorePageVersion = async (pageId: string, versionId: string) => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
if (!token) throw new Error('Not authenticated');
const baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin;
const res = await fetch(`${baseUrl}/api/pages/${pageId}/versions/${versionId}/restore`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || 'Failed to restore version');
}
return await res.json();
};
import { apiClient, fetchWithDeduplication } from '@/lib/db';
import { getCurrentLang } from '@/i18n';
export const fetchUserPage = async (userId: string, slug: string) => {
const key = `user-page-${userId}-${slug}`;
return fetchWithDeduplication(key, async () => {
const lang = getCurrentLang();
const langParam = lang && lang !== 'en' ? `?lang=${lang}` : '';
const result = await apiClient(`/api/user-page/${userId}/${slug}${langParam}`);
return result ?? null;
}, 10);
};
export const fetchPageById = async (id: string) => {
return fetchWithDeduplication(`page-${id}`, async () => {
const result: any = await apiClient(`/api/user-page/${id}/${id}`);
return result?.page || result;
});
};
export const fetchUserPages = async (userId: string) => {
const key = `user-pages-${userId}`;
return fetchWithDeduplication(key, async () => {
return apiClient(`/api/pages?userId=${userId}`);
});
};
export const fetchPageDetailsById = async (pageId: string) => {
const key = `page-details-${pageId}`;
return fetchWithDeduplication(key, async () => {
const result = await apiClient(`/api/user-page/${pageId}/${pageId}`);
return result ?? null;
});
};
export const createPage = async (pageData: any): Promise<any> => {
return apiClient('/api/pages', {
method: 'POST',
body: JSON.stringify(pageData),
});
};
export const updatePage = async (pageId: string, updates: any): Promise<any> => {
return apiClient(`/api/pages/${pageId}`, {
method: 'PATCH',
body: JSON.stringify(updates),
});
};
export const deletePage = async (pageId: string): Promise<void> => {
return apiClient(`/api/pages/${pageId}`, { method: 'DELETE' });
};
export const updatePageMeta = async (pageId: string, metaUpdates: any) => {
const page = await fetchPageDetailsById(pageId);
if (!page) throw new Error('Page not found');
const newMeta = { ...(page as any).meta, ...metaUpdates };
return updatePage(pageId, { meta: newMeta });
};
// --- Page Versions ---
export const fetchPageVersions = async (pageId: string) => {
return apiClient(`/api/pages/${pageId}/versions`);
};
export const createPageVersion = async (pageId: string, label?: string) => {
return apiClient(`/api/pages/${pageId}/versions`, {
method: 'POST',
body: JSON.stringify({ label }),
});
};
export const deletePageVersion = async (pageId: string, versionId: string) => {
return apiClient(`/api/pages/${pageId}/versions/${versionId}`, { method: 'DELETE' });
};
export const restorePageVersion = async (pageId: string, versionId: string) => {
return apiClient(`/api/pages/${pageId}/versions/${versionId}/restore`, { method: 'POST' });
};

View File

@ -218,7 +218,17 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
// Grid Search Simulator State
const [simulatorActive, setSimulatorActive] = useState(false);
const [pickerRegions, setPickerRegions] = useState<any[]>([]);
const [pickerRegions, setPickerRegions] = useState<any[]>(() => {
console.log('initialGadmRegions', initialGadmRegions);
if (!features.enableAutoRegions || !initialGadmRegions || initialGadmRegions.length === 0) return [];
return initialGadmRegions.map((r: any) => ({
gid: r.gid,
gadmName: r.name,
level: r.level,
raw: r.raw || { gid: r.gid, gadmName: r.name, level: r.level }
}));
});
const [pickerPolygons, setPickerPolygons] = useState<any[]>([]);
const [simulatorData, setSimulatorData] = useState<any>(null);
const [simulatorPath, setSimulatorPath] = useState<any>(null);
@ -260,20 +270,38 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
console.error('Failed to fetch boundary for', region.gid, err);
}
}
setPickerRegions(regions);
setPickerPolygons(polygons);
})();
}, [features.enableAutoRegions, initialGadmRegions]);
const onRegionsChangeRef = useRef(onRegionsChange);
useEffect(() => {
onRegionsChangeRef.current = onRegionsChange;
}, [onRegionsChange]);
// Bubble picker regions up to parent
useEffect(() => {
onRegionsChange?.(pickerRegions);
}, [pickerRegions, onRegionsChange]);
onRegionsChangeRef.current?.(pickerRegions);
}, [pickerRegions]);
// Fit map to loaded region boundaries (waits for map readiness)
const hasFittedBoundsRef = useRef(false);
const lastFittedPolygonsRef = useRef<any[] | null>(null);
useEffect(() => {
if (hasFittedBoundsRef.current || pickerPolygons.length === 0 || initialCenter) return;
if (pickerPolygons.length === 0) return;
// Skip if we already successfully fitted for these exact polygons
if (hasFittedBoundsRef.current && lastFittedPolygonsRef.current === pickerPolygons) return;
const isInitialLoad = lastFittedPolygonsRef.current === null;
// Skip actual camera movement if we are in poster mode, or if respecting initialCenter on load
if (isPosterMode || (initialCenter && isInitialLoad)) {
hasFittedBoundsRef.current = true;
lastFittedPolygonsRef.current = pickerPolygons;
return;
}
const fitToPolygons = () => {
if (!map.current) return false;
@ -293,19 +321,22 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
if (hasPoints && !bounds.isEmpty()) {
map.current.fitBounds(bounds, { padding: 50, maxZoom: 10 });
hasFittedBoundsRef.current = true;
lastFittedPolygonsRef.current = pickerPolygons;
return true;
}
return false;
};
// Try immediately, then poll until map is ready
// Reset state for new polygons and try fitting
hasFittedBoundsRef.current = false;
if (fitToPolygons()) return;
const interval = setInterval(() => {
if (fitToPolygons()) clearInterval(interval);
}, 200);
const timeout = setTimeout(() => clearInterval(interval), 5000);
return () => { clearInterval(interval); clearTimeout(timeout); };
}, [pickerPolygons]);
}, [pickerPolygons, initialCenter, isPosterMode]);
// Enrichment Hook - NOW PASSED VIA PROPS
// const { enrich, isEnriching, progress: enrichmentProgress } = useLocationEnrichment();
@ -397,6 +428,8 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
};
}, []);
const lastFittedLocationIdsRef = useRef<string | null>(null);
// Handle Markers & Data Updates
useEffect(() => {
if (!map.current) return;
@ -418,18 +451,18 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
// Inner div for the pin to handle hover transform isolation
const pin = document.createElement('div');
// Theme-aware, more elegant pin styling with highlighting for the active one
pin.className = `w-7 h-7 rounded-full flex items-center justify-center font-bold text-xs shadow-md border-[1.5px] cursor-pointer transition-all duration-300 backdrop-blur-sm ` +
pin.className = `w-7 h-7 rounded-full flex items-center justify-center font-bold text-xs shadow-md border-[1.5px] cursor-pointer transition-all duration-300 backdrop-blur-sm ` +
(isSelected
? (isDark
? 'bg-amber-500 border-amber-300 text-amber-900 scale-[1.35] z-50 ring-4 ring-amber-500/30 shadow-[0_0_15px_rgba(245,158,11,0.5)]'
? (isDark
? 'bg-amber-500 border-amber-300 text-amber-900 scale-[1.35] z-50 ring-4 ring-amber-500/30 shadow-[0_0_15px_rgba(245,158,11,0.5)]'
: 'bg-amber-400 border-white text-amber-900 scale-[1.35] z-50 ring-4 ring-amber-400/40 shadow-[0_0_15px_rgba(251,191,36,0.6)]')
: (isDark
? 'bg-indigo-900/80 border-indigo-400/60 text-indigo-100 hover:bg-indigo-800/90 hover:border-indigo-300 hover:scale-125'
: (isDark
? 'bg-indigo-900/80 border-indigo-400/60 text-indigo-100 hover:bg-indigo-800/90 hover:border-indigo-300 hover:scale-125'
: 'bg-indigo-600 border-white text-white shadow-lg hover:bg-indigo-700 hover:scale-125')
);
pin.innerHTML = loc.label;
el.appendChild(pin);
@ -441,7 +474,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
// Fly to location
if (map.current) {
map.current.flyTo({ center: [loc.lon, loc.lat], zoom: 15, padding: { right: selectedLocation ? sidebarWidth : 0 } });
map.current.flyTo({ center: [loc.lon, loc.lat], zoom: 15, duration: 500 });
}
setInfoPanelOpen(false); // Close info if opening location detail
});
@ -462,13 +495,18 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
markersRef.current.push(marker);
});
// Fit bounds
if (validLocations.length > 0 && !initialCenter) {
const bounds = new maplibregl.LngLatBounds();
validLocations.forEach(loc => bounds.extend([loc.lon, loc.lat]));
map.current.fitBounds(bounds, { padding: 50, maxZoom: 15 });
// Fit bounds only when the core locations set changes (not on every state toggle)
if (validLocations.length > 0 && lastFittedLocationIdsRef.current !== locationIds) {
const isInitialLoad = lastFittedLocationIdsRef.current === null;
if (!isPosterMode && (!initialCenter || !isInitialLoad)) {
const bounds = new maplibregl.LngLatBounds();
validLocations.forEach(loc => bounds.extend([loc.lon, loc.lat]));
map.current.fitBounds(bounds, { padding: 50, maxZoom: 15 });
}
lastFittedLocationIdsRef.current = locationIds;
}
}, [locationIds, validLocations, sidebarWidth, initialCenter, mapStyleKey, selectedLocation]);
}, [locationIds, validLocations, sidebarWidth, initialCenter, mapStyleKey, selectedLocation, isPosterMode]);
// Sync Theme/Style
@ -486,19 +524,13 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
// Handle Layout Resize (debounced to avoid firing easeTo per pixel of drag)
// Handle Layout Resize (debounced to avoid layout thrashing)
const resizeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (resizeTimerRef.current) clearTimeout(resizeTimerRef.current);
resizeTimerRef.current = setTimeout(() => {
if (map.current) {
map.current.resize();
if (selectedLocation) {
map.current.easeTo({
padding: { right: 0, top: 0, bottom: 0, left: 0 },
duration: 300
});
}
}
}, 100);
return () => { if (resizeTimerRef.current) clearTimeout(resizeTimerRef.current); };
@ -546,7 +578,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
// Keyboard Navigation for Detail View
useEffect(() => {
if (!features.enableLocationDetails || !selectedLocation) return;
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if in inputs
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
@ -563,7 +595,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
handled = true;
} else {
handled = true;
// Spatial Navigation logic
const currentLoc = validLocations.find(l => l.place_id === current.place_id);
if (currentLoc) {
@ -573,7 +605,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
candidates.forEach(loc => {
const dx = loc.lon - currentLoc.lon;
const dy = loc.lat - currentLoc.lat;
// Check if it's in the correct direction hemisphere
let isValidDirection = false;
if (e.key === 'ArrowRight' && dx > 0) isValidDirection = true;
@ -614,11 +646,10 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
if (e.key !== 'Escape' && nextLoc) {
setSelectedLocation(nextLoc);
if (map.current) {
map.current.flyTo({
center: [nextLoc.lon, nextLoc.lat],
zoom: 15,
padding: { right: sidebarWidth },
duration: 500
map.current.flyTo({
center: [nextLoc.lon, nextLoc.lat],
zoom: map.current.getZoom(),
duration: 500
});
}
}
@ -760,7 +791,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
map={map.current}
isDarkStyle={mapStyleKey === 'dark'}
simulatorData={liveAreas.length > 0 ? null : simulatorData}
simulatorPath={liveAreas.length > 0 ? null : simulatorPath}
simulatorPath={simulatorPath}
simulatorScanner={liveScanner || simulatorScanner}
/>
<RegionLayers

View File

@ -64,6 +64,7 @@ export const InfoPanel: React.FC<InfoPanelProps> = ({ isOpen, onClose, lat, lng,
if (!isOpen || !locationName) return;
const fetchLlmInfo = async () => {
return
setLoadingLlm(true);
setLlmInfo(null);
try {

View File

@ -1,7 +1,4 @@
import { supabase as defaultSupabase } from "@/integrations/supabase/client";
import { fetchWithDeduplication } from "@/lib/db";
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || '';
import { fetchWithDeduplication, apiClient, serverUrl } from "@/lib/db";
// --- Types ---
export interface GridSearchSummary {
@ -75,37 +72,6 @@ export interface GridSearchPreviewPayload {
lng?: number;
}
/** Helper function to get authorization headers */
const getAuthHeaders = async (): Promise<HeadersInit> => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData?.session?.access_token;
const headers: HeadersInit = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
return headers;
};
/** Generic API Client handler */
async function apiClient<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const headers = await getAuthHeaders();
const response = await fetch(`${serverUrl}${endpoint}`, {
...options,
headers: {
...headers,
...options.headers,
},
});
if (!response.ok) {
throw new Error(`API Error on ${endpoint}: ${response.statusText}`);
}
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return response.json();
}
return response.text() as unknown as T;
}
// --- API Methods ---
export const fetchGridSearchPolygons = async (payload: GridSearchPolygonPayload): Promise<any> => {
return apiClient<any>('/api/locations/gridsearch/polygons', {

View File

@ -170,7 +170,7 @@ export function SimulatorLayers({ map, isDarkStyle, simulatorData, simulatorPath
'match', ['get', 'sim_status'],
'pending', currentIsDark ? 'rgba(150, 150, 150, 0.2)' : 'rgba(0, 0, 0, 0.25)',
'skipped', currentIsDark ? 'rgba(239, 68, 68, 0.3)' : 'rgba(220, 38, 38, 0.6)',
'processed', currentIsDark ? 'rgba(34, 197, 94, 0.4)' : 'rgba(22, 163, 74, 0.7)',
'processed', 'rgba(0,0,0,0)',
'rgba(0,0,0,0)'
],
'fill-outline-color': currentIsDark ? 'rgba(150, 150, 150, 0.5)' : 'rgba(0, 0, 0, 0.6)'
@ -187,9 +187,13 @@ export function SimulatorLayers({ map, isDarkStyle, simulatorData, simulatorPath
type: 'line',
source: 'simulator-path',
paint: {
'line-color': 'rgba(59, 130, 246, 0.4)',
'line-width': 1.5,
'line-dasharray': [3, 3]
'line-color': 'rgba(250, 204, 21, 0.4)',
'line-width': 3,
'line-dasharray': [0, 3]
},
layout: {
'line-cap': 'round',
'line-join': 'round'
}
});
}
@ -223,7 +227,7 @@ export function SimulatorLayers({ map, isDarkStyle, simulatorData, simulatorPath
'match', ['get', 'sim_status'],
'pending', isDarkStyle ? 'rgba(150, 150, 150, 0.2)' : 'rgba(0, 0, 0, 0.25)',
'skipped', isDarkStyle ? 'rgba(239, 68, 68, 0.3)' : 'rgba(220, 38, 38, 0.6)',
'processed', isDarkStyle ? 'rgba(34, 197, 94, 0.4)' : 'rgba(22, 163, 74, 0.7)',
'processed', 'rgba(0,0,0,0)',
'rgba(0,0,0,0)'
]);
map.setPaintProperty('simulator-grid-fill', 'fill-outline-color', isDarkStyle ? 'rgba(150, 150, 150, 0.5)' : 'rgba(0, 0, 0, 0.6)');
@ -258,7 +262,9 @@ export function SimulatorLayers({ map, isDarkStyle, simulatorData, simulatorPath
}
const [lng, lat] = feature.geometry.coordinates;
const bearing = feature.properties?.bearing ?? 0;
// Default to a 90 degree bearing (East) if not provided or exactly 0 (backend's rest default)
let bearing = feature.properties?.bearing ?? 90;
if (bearing === 0) bearing = 90;
if (!scannerMarkerRef.current) {
const el = createPacmanElement();
@ -283,16 +289,26 @@ export function SimulatorLayers({ map, isDarkStyle, simulatorData, simulatorPath
scannerMarkerRef.current.setLngLat([lng, lat]);
}
// Convert MapLibre bearing (0=N, 90=E, 180=S, -90=W) to CSS rotate (0=Right, 90=Down)
let rot = bearing - 90;
// Normalize to -180 to +180
while (rot > 180) rot -= 360;
while (rot < -180) rot += 360;
// If pacman points Left-ish, he flips upside down (eye on bottom). Flip Y to fix.
const flip = Math.abs(rot) > 90 ? -1 : 1;
// Rotate the whole pacman body to face bearing direction
if (scannerElementRef.current) {
const body = scannerElementRef.current.querySelector('.sim-pacman-body') as HTMLElement;
if (body) {
body.style.transform = `translate(-50%, -50%) rotate(${bearing}deg)`;
body.style.transform = `translate(-50%, -50%) rotate(${rot}deg) scaleY(${flip})`;
}
// Rotate dots opposite so they trail behind
const dots = scannerElementRef.current.querySelector('.sim-pacman-dots') as HTMLElement;
if (dots) {
dots.style.transform = `rotate(${bearing}deg)`;
dots.style.transform = `rotate(${rot}deg)`;
}
}
}, [map, simulatorScanner]);

View File

@ -559,7 +559,7 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
// Queue them all concurrently via isMulti=true
for (const item of parsed) {
const raw = item.raw || { gid: item.gid, gadmName: item.name, level: item.level };
const raw = item.raw ? { ...item.raw, level: item.level } : { gid: item.gid, gadmName: item.name, level: item.level };
handleSelectRegion(raw, item.level, true);
}
}
@ -603,7 +603,7 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
setGeojsons({});
for (const item of initialRegions) {
const raw = (item as any).raw || { gid: item.gid, gadmName: item.name, level: item.level };
const raw = (item as any).raw ? { ...((item as any).raw), level: item.level } : { gid: item.gid, gadmName: item.name, level: item.level };
handleSelectRegion(raw, item.level, true);
}
}, [initialRegions]);

View File

@ -131,6 +131,31 @@ export function GadmRegionCollector({
performLocate();
}
}, [hasAutoLocated]);
// Ensure tree path expands to existing selections (e.g. when returning from back-navigation)
useEffect(() => {
if (selectedNodes.length === 0 || roots.length === 0) return;
let expandedAnything = false;
for (const node of selectedNodes) {
const data = node.data;
if (!data) continue;
const pathGids: string[] = [];
for (let i = 0; i <= node.level; i++) {
if (data[`GID_${i}`]) {
pathGids.push(data[`GID_${i}`]);
}
}
if (pathGids.length > 0) {
expandedAnything = true;
setTimeout(() => {
treeApiRef.current?.expandPath(pathGids);
}, 100);
}
}
}, [roots.length]); // Relies on roots being loaded
useEffect(() => {
if (!regionQuery || regionQuery.length < 2) { setSuggestions([]); return; }

View File

@ -127,10 +127,19 @@ function GridSearchLayout({ selectedJobId, setSelectedJobId }: { selectedJobId:
{/* Main Content Area */}
<div className="flex-1 flex flex-col min-w-0 relative z-10 w-full overflow-hidden">
{!isSidebarOpen && !isSharedView && !selectedJobId && (
<button
onClick={() => setIsSidebarOpen(true)}
className="absolute top-4 left-4 z-50 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition"
title="Open Past Searches"
>
<PanelLeftOpen className="w-5 h-5 text-gray-600 dark:text-gray-300" />
</button>
)}
{selectedJobId ? (
<JobViewer key={selectedJobId} jobId={selectedJobId} isSidebarOpen={isSidebarOpen} onToggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)} />
) : (
<GridSearchWizard key={wizardSessionKey} initialSettings={initialWizardSettings} onJobSubmitted={(id) => setSelectedJobId(id, 'map')} />
<GridSearchWizard key={wizardSessionKey} initialSettings={initialWizardSettings} onJobSubmitted={(id) => setSelectedJobId(id, 'map')} setIsSidebarOpen={setIsSidebarOpen} />
)}
</div>
</div>

View File

@ -310,6 +310,7 @@ export function GridSearchStreamProvider({
if (!isActive) return;
try {
const { type, parsed, data } = parseStreamEvent(eventType, e.data);
console.log('Stream event', type, parsed, data);
setState(prev => applyEventToState(prev, type, parsed, data));
} catch (err) {
console.error('Failed parsing stream event', eventType, err);

View File

@ -8,7 +8,7 @@ import { GadmPickerProvider, useGadmPicker } from '../gadm-picker/GadmPickerCont
import { Badge } from "@/components/ui/badge";
import { T, translate } from '../../../i18n';
export function GridSearchWizard({ onJobSubmitted, initialSettings }: { onJobSubmitted: (jobId: string) => void, initialSettings?: any }) {
export function GridSearchWizard({ onJobSubmitted, initialSettings, setIsSidebarOpen }: { onJobSubmitted: (jobId: string) => void, initialSettings?: any, setIsSidebarOpen?: (open: boolean) => void }) {
const initNodes = initialSettings?.guidedAreas?.map((a: any) => ({
gid: a.gid,
name: a.name,
@ -17,12 +17,12 @@ export function GridSearchWizard({ onJobSubmitted, initialSettings }: { onJobSub
})) || [];
return (
<GadmPickerProvider initialSelectedNodes={initNodes}>
<GridSearchWizardInner onJobSubmitted={onJobSubmitted} initialSettings={initialSettings} />
<GridSearchWizardInner onJobSubmitted={onJobSubmitted} initialSettings={initialSettings} setIsSidebarOpen={setIsSidebarOpen} />
</GadmPickerProvider>
);
}
function GridSearchWizardInner({ onJobSubmitted, initialSettings }: { onJobSubmitted: (jobId: string) => void, initialSettings?: any }) {
function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOpen }: { onJobSubmitted: (jobId: string) => void, initialSettings?: any, setIsSidebarOpen?: (open: boolean) => void }) {
const [step, setStep] = useState(() => {
return (initialSettings?.guidedAreas && initialSettings.guidedAreas.length > 0) ? 2 : 1;
});
@ -92,7 +92,11 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings }: { onJobSubmi
if (step === 1) { setStep(2); return; }
if (step === 2) { setStep(3); return; }
if (step === 2) {
setStep(3);
setIsSidebarOpen?.(false);
return;
}
if (step === 3) { setStep(4); return; }
};
@ -232,9 +236,41 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings }: { onJobSubmi
onMapCenterUpdate={() => { }}
enrich={async () => { }}
isEnriching={false}
initialGadmRegions={collectedNodes.map(n => ({ gid: n.gid, name: n.name, level: resolutions[n.gid] ?? n.level }))}
initialGadmRegions={collectedNodes.map(n => ({ gid: n.gid, name: n.name, level: resolutions[n.gid] ?? n.level, raw: n.data }))}
simulatorSettings={simulatorSettings}
onSimulatorSettingsChange={setSimulatorSettings}
onRegionsChange={(regions) => {
console.log('regions', regions);
setCollectedNodes(prev => {
if (prev.length === regions.length && prev.every((n, i) => n.gid === regions[i].gid)) return prev;
return regions.map((r: any) => ({
gid: r.gid,
name: r.gadmName || r.name,
level: r.raw?.level ?? r.level,
hasChildren: r.raw?.hasChildren ?? true,
hasPolygons: r.raw?.hasPolygons ?? true,
data: r.raw
}));
});
setResolutions(prev => {
const next = { ...prev };
let changed = false;
regions.forEach((r: any) => {
if (next[r.gid] !== r.level) {
next[r.gid] = r.level;
changed = true;
}
});
const gids = new Set(regions.map((r: any) => r.gid));
for (const k in next) {
if (!gids.has(k)) {
delete next[k];
changed = true;
}
}
return changed ? next : prev;
});
}}
/>
</div>
</div>

View File

@ -202,6 +202,7 @@ export function JobViewer({ jobId, isSidebarOpen, onToggleSidebar }: { jobId: st
const allResults = jobData?.result?.searchResult?.results || competitors;
const foundTypes = Array.from(new Set(allResults.flatMap((r: any) => r.types || []).filter(Boolean))).sort() as string[];
console.log(JSON.stringify(foundTypes, null, 2));
return (
<div className="flex flex-col flex-1 w-full overflow-hidden min-h-0 mt-2 dark:bg-gray-800/70 p-1">
<div className="w-full flex-1 min-h-0 flex flex-col rounded-2xl shadow-xl transition-all duration-300">