kbot love:)
This commit is contained in:
parent
6c4ced9101
commit
6f89453225
20
packages/ui/docs/product-pacbot.md
Normal file
20
packages/ui/docs/product-pacbot.md
Normal 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.
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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()}`);
|
||||
};
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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' });
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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', {
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user