From 6f89453225c0b869df5313f9e9ccbf0b7acb45a4 Mon Sep 17 00:00:00 2001 From: Babayaga Date: Mon, 30 Mar 2026 17:35:22 +0200 Subject: [PATCH] kbot love:) --- packages/ui/docs/product-pacbot.md | 20 ++ packages/ui/src/lib/db.ts | 34 ++ packages/ui/src/modules/feed/client-feed.ts | 99 +++--- .../ui/src/modules/pages/PagePickerDialog.tsx | 19 +- packages/ui/src/modules/pages/client-pages.ts | 311 +++++------------- .../src/modules/places/CompetitorsMapView.tsx | 105 +++--- packages/ui/src/modules/places/InfoPanel.tsx | 1 + .../src/modules/places/client-gridsearch.ts | 36 +- .../components/map-layers/SimulatorLayers.tsx | 32 +- .../modules/places/gadm-picker/GadmPicker.tsx | 4 +- .../gadm-picker/GadmRegionCollector.tsx | 25 ++ .../modules/places/gridsearch/GridSearch.tsx | 11 +- .../gridsearch/GridSearchStreamContext.tsx | 1 + .../places/gridsearch/GridSearchWizard.tsx | 46 ++- .../modules/places/gridsearch/JobViewer.tsx | 1 + 15 files changed, 355 insertions(+), 390 deletions(-) create mode 100644 packages/ui/docs/product-pacbot.md diff --git a/packages/ui/docs/product-pacbot.md b/packages/ui/docs/product-pacbot.md new file mode 100644 index 00000000..d4308ade --- /dev/null +++ b/packages/ui/docs/product-pacbot.md @@ -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. + diff --git a/packages/ui/src/lib/db.ts b/packages/ui/src/lib/db.ts index 6e132768..f574da9a 100644 --- a/packages/ui/src/lib/db.ts +++ b/packages/ui/src/lib/db.ts @@ -68,6 +68,40 @@ export const fetchWithDeduplication = async ( }); }; +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 => { + 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(endpoint: string, options: RequestInit = {}): Promise { + 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 }); diff --git a/packages/ui/src/modules/feed/client-feed.ts b/packages/ui/src/modules/feed/client-feed.ts index 0bb4c7d3..f7364595 100644 --- a/packages/ui/src/modules/feed/client-feed.ts +++ b/packages/ui/src/modules/feed/client-feed.ts @@ -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(`${FEED_API_ENDPOINT}?${params.toString()}`); +}; diff --git a/packages/ui/src/modules/pages/PagePickerDialog.tsx b/packages/ui/src/modules/pages/PagePickerDialog.tsx index 70708932..b04cb30d 100644 --- a/packages/ui/src/modules/pages/PagePickerDialog.tsx +++ b/packages/ui/src/modules/pages/PagePickerDialog.tsx @@ -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 = ({ 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) diff --git a/packages/ui/src/modules/pages/client-pages.ts b/packages/ui/src/modules/pages/client-pages.ts index 34c965d0..8cdf2508 100644 --- a/packages/ui/src/modules/pages/client-pages.ts +++ b/packages/ui/src/modules/pages/client-pages.ts @@ -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 => { - 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 => { - 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 => { - 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 => { + return apiClient('/api/pages', { + method: 'POST', + body: JSON.stringify(pageData), + }); +}; + +export const updatePage = async (pageId: string, updates: any): Promise => { + return apiClient(`/api/pages/${pageId}`, { + method: 'PATCH', + body: JSON.stringify(updates), + }); +}; + +export const deletePage = async (pageId: string): Promise => { + 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' }); +}; diff --git a/packages/ui/src/modules/places/CompetitorsMapView.tsx b/packages/ui/src/modules/places/CompetitorsMapView.tsx index e43a9cd6..ca4c1758 100644 --- a/packages/ui/src/modules/places/CompetitorsMapView.tsx +++ b/packages/ui/src/modules/places/CompetitorsMapView.tsx @@ -218,7 +218,17 @@ export const CompetitorsMapView: React.FC = ({ competit // Grid Search Simulator State const [simulatorActive, setSimulatorActive] = useState(false); - const [pickerRegions, setPickerRegions] = useState([]); + const [pickerRegions, setPickerRegions] = useState(() => { + 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([]); const [simulatorData, setSimulatorData] = useState(null); const [simulatorPath, setSimulatorPath] = useState(null); @@ -260,20 +270,38 @@ export const CompetitorsMapView: React.FC = ({ 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(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 = ({ 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 = ({ competit }; }, []); + const lastFittedLocationIdsRef = useRef(null); + // Handle Markers & Data Updates useEffect(() => { if (!map.current) return; @@ -418,18 +451,18 @@ export const CompetitorsMapView: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ competit - // Handle Layout Resize (debounced to avoid firing easeTo per pixel of drag) + // Handle Layout Resize (debounced to avoid layout thrashing) const resizeTimerRef = useRef | 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ competit map={map.current} isDarkStyle={mapStyleKey === 'dark'} simulatorData={liveAreas.length > 0 ? null : simulatorData} - simulatorPath={liveAreas.length > 0 ? null : simulatorPath} + simulatorPath={simulatorPath} simulatorScanner={liveScanner || simulatorScanner} /> = ({ isOpen, onClose, lat, lng, if (!isOpen || !locationName) return; const fetchLlmInfo = async () => { + return setLoadingLlm(true); setLlmInfo(null); try { diff --git a/packages/ui/src/modules/places/client-gridsearch.ts b/packages/ui/src/modules/places/client-gridsearch.ts index 49c08bd0..9780b167 100644 --- a/packages/ui/src/modules/places/client-gridsearch.ts +++ b/packages/ui/src/modules/places/client-gridsearch.ts @@ -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 => { - 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(endpoint: string, options: RequestInit = {}): Promise { - 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 => { return apiClient('/api/locations/gridsearch/polygons', { diff --git a/packages/ui/src/modules/places/components/map-layers/SimulatorLayers.tsx b/packages/ui/src/modules/places/components/map-layers/SimulatorLayers.tsx index 8facc529..71c338a5 100644 --- a/packages/ui/src/modules/places/components/map-layers/SimulatorLayers.tsx +++ b/packages/ui/src/modules/places/components/map-layers/SimulatorLayers.tsx @@ -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]); diff --git a/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx b/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx index 9494ae6a..ff32d5a1 100644 --- a/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx +++ b/packages/ui/src/modules/places/gadm-picker/GadmPicker.tsx @@ -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]); diff --git a/packages/ui/src/modules/places/gadm-picker/GadmRegionCollector.tsx b/packages/ui/src/modules/places/gadm-picker/GadmRegionCollector.tsx index ff211831..98ccbeda 100644 --- a/packages/ui/src/modules/places/gadm-picker/GadmRegionCollector.tsx +++ b/packages/ui/src/modules/places/gadm-picker/GadmRegionCollector.tsx @@ -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; } diff --git a/packages/ui/src/modules/places/gridsearch/GridSearch.tsx b/packages/ui/src/modules/places/gridsearch/GridSearch.tsx index 55b95911..c142397c 100644 --- a/packages/ui/src/modules/places/gridsearch/GridSearch.tsx +++ b/packages/ui/src/modules/places/gridsearch/GridSearch.tsx @@ -127,10 +127,19 @@ function GridSearchLayout({ selectedJobId, setSelectedJobId }: { selectedJobId: {/* Main Content Area */}
+ {!isSidebarOpen && !isSharedView && !selectedJobId && ( + + )} {selectedJobId ? ( setIsSidebarOpen(!isSidebarOpen)} /> ) : ( - setSelectedJobId(id, 'map')} /> + setSelectedJobId(id, 'map')} setIsSidebarOpen={setIsSidebarOpen} /> )}
diff --git a/packages/ui/src/modules/places/gridsearch/GridSearchStreamContext.tsx b/packages/ui/src/modules/places/gridsearch/GridSearchStreamContext.tsx index c2853c04..48e3bd73 100644 --- a/packages/ui/src/modules/places/gridsearch/GridSearchStreamContext.tsx +++ b/packages/ui/src/modules/places/gridsearch/GridSearchStreamContext.tsx @@ -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); diff --git a/packages/ui/src/modules/places/gridsearch/GridSearchWizard.tsx b/packages/ui/src/modules/places/gridsearch/GridSearchWizard.tsx index 971c5c3a..5cacc286 100644 --- a/packages/ui/src/modules/places/gridsearch/GridSearchWizard.tsx +++ b/packages/ui/src/modules/places/gridsearch/GridSearchWizard.tsx @@ -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 ( - + ); } -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; + }); + }} /> diff --git a/packages/ui/src/modules/places/gridsearch/JobViewer.tsx b/packages/ui/src/modules/places/gridsearch/JobViewer.tsx index 6c976c9c..ccef16e4 100644 --- a/packages/ui/src/modules/places/gridsearch/JobViewer.tsx +++ b/packages/ui/src/modules/places/gridsearch/JobViewer.tsx @@ -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 (